diff --git a/.changepacks/changepack_log_project_intake.json b/.changepacks/changepack_log_project_intake.json new file mode 100644 index 00000000..523590e3 --- /dev/null +++ b/.changepacks/changepack_log_project_intake.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "Add project intake, evidence-backed ERD/model generation and supervisor work-unit DAG/briefs; /devup starts project intake before its Figma branch.", + "date": "2026-10-18T00:00:00+09:00" +} diff --git a/.changepacks/changepack_log_source_size.json b/.changepacks/changepack_log_source_size.json new file mode 100644 index 00000000..d8456415 --- /dev/null +++ b/.changepacks/changepack_log_source_size.json @@ -0,0 +1,9 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Patch", + "crates/devup-mcp-figma/Cargo.toml": "Patch", + "crates/devup-mcp-devup-ui/Cargo.toml": "Patch" + }, + "note": "Organize Rust source by responsibility with a 1000-line limit, preserve public paths and test names, embed workflow prose and keyword/class data, support appSelectionKeywords overrides, and guard all Rust source sizes and embedded JSON in the existing size-contract test.", + "date": "2026-10-18T00:00:00+09:00" +} diff --git a/.gitattributes b/.gitattributes index ebd7e1d6..94b0d254 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,3 +7,9 @@ # are not git's to touch in either direction. crates/devup-mcp/src/server/skills/**/*.md -text crates/devup-mcp/src/server/skills/manifest.json -text + +# Compiled-in text is part of the binary's output contract. Preserve the +# original template bytes, including intentional newlines, on every platform. +crates/devup-mcp/src/server/text/** -text +crates/devup-mcp-figma/src/text/** -text +crates/devup-mcp-devup-ui/src/text/** -text diff --git a/Cargo.lock b/Cargo.lock index f060fa39..a45de052 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,20 @@ dependencies = [ "cpufeatures 0.3.1", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -299,6 +313,21 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.2" @@ -345,6 +374,12 @@ dependencies = [ "piper", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.13.1" @@ -712,6 +747,7 @@ dependencies = [ "dunce", "futures-util", "image", + "jsonschema", "oxc_allocator", "oxc_ast", "oxc_ast_visit", @@ -856,6 +892,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -925,6 +970,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -957,6 +1013,29 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -966,6 +1045,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint 0.4.8", +] + [[package]] name = "fs-set-times" version = "0.20.3" @@ -1114,9 +1203,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1162,6 +1253,25 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1169,8 +1279,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", + "equivalent", + "foldhash", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1265,6 +1383,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1662,6 +1781,62 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b320ad34c177093d21622edc9215bb608294c2333ff296672c0c8265a5ee60c" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "reqwest", + "rustls", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1472f7be79026f79e01a9b172263311e829d7c210d57b39cb57037abc0ce0500" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7550a7a84787e715ead0b47e01fa8c6bb7c3413630f91a8f2094c06c2078a" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "keyring" version = "4.2.0" @@ -1774,6 +1949,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1876,6 +2057,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -2013,6 +2200,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "oxc_allocator" version = "0.148.0" @@ -2604,6 +2797,23 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "referencing" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473f4dcca28ead7ee0f8e5b5d0bbae2549f4fc4c958fb20af328961b6c174be3" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -2641,8 +2851,10 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ "base64 0.23.1", "bytes", + "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -3204,6 +3416,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3603,6 +3836,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-id-start" version = "1.4.0" @@ -3673,6 +3912,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3685,6 +3934,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/FOLLOWUP-9-REVIEW.md b/FOLLOWUP-9-REVIEW.md new file mode 100644 index 00000000..e4bfc07f --- /dev/null +++ b/FOLLOWUP-9-REVIEW.md @@ -0,0 +1,12 @@ +Follow-up 9 decisions and review + +- Runtime reads Jira/Figma/spec connectors and passes source-labelled facts; the server reads local models. Commands are returned for runtime execution, never launched by the planning tool. +- API models are explicit vespertide wire objects; signup/post evidence seeds a reviewed draft. Other domains require explicit complete entities. Existing source models are preserved unless explicitly revised. +- Jira work uses supplied issue dependencies; otherwise shared foundations precede feature layer chains. Supervisor must sequence cross-feature model changes. Briefs retain the complete shared ERD and screen plan to avoid losing cross-feature context. +- Intake always asks the four required first-round questions, using source facts as defaults. Refactor is available as a custom work-type answer to preserve the runtime's four-option limit. +- MCP initialize trimmed repeated export sizing/connection prose to leave space for project intake; full guide retains the detailed rules, and the 1500-byte cap remains enforced. +- Review: planning persists commands and verification evidence; completion requires the executing runtime's actual checks, not a generated plan. Unit status is supervisor-reported. Existing screen-plan export gate remains authoritative for Figma page writes. + +- jsonschema 0.59 enables serde_json float_roundtrip; two diagnostic snapshot floats change only their final decimal digit. The snapshot is updated for the more accurate parsing under workspace feature unification. + +Validation: cargo fmt --all --check and cargo clippy --workspace --all-targets -- -D warnings passed. cargo test --workspace --no-fail-fast completed with only symlink_escape_refused_before_read and rejects_a_directory_symlink_parent_that_escapes_the_root (Windows os error 1314). Test-list baseline after follow-up 9: 1644 named tests. diff --git a/README.md b/README.md index 5639d7dd..4d8c58f9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # devup-mcp +`/devup` starts `devup_project_plan` intake for projects, features, bug fixes, +Figma pages and refactors. Read sources, settle every question, confirm the ERD +and plan, then split supervisor work units; Figma screen planning is one branch. + + Rust-native MCP server that reads Figma designs and generates DevupUI artifacts. One binary acts as a local stdio MCP server and a read-only client of Figma Remote MCP. 저장소는 Cargo workspace이며 `devup-mcp` 실행 crate, OAuth·upstream·snapshot을 담당하는 `devup-mcp-figma`, TSX·theme projection을 담당하는 `devup-mcp-devup-ui`, PNG 비교 library/CLI인 `devup-mcp-visual`로 구성됩니다. 별도 IR/auth/server crate 없이 MCP 제품 설치 단위는 `devup-mcp` 하나입니다. @@ -1225,3 +1230,105 @@ HUG 검증은 원본과 일치하는 생성 auto-layout에서 크기 override `outputPaths`의 프레임 출력 키는 `frame::`입니다. 예: `{"frame:3997:46715:tsx":"C:/allowed/Screen.tsx","frame:3997:46715:sourceMap":"C:/allowed/Screen.map.json"}`. `outputPathResults.supportedKeys`는 이번 투영에서 실제 쓸 수 있는 키를 열거합니다. 프레임 응답에 일반 `tsx`/`sourceMap` 키를 쓰거나 미지원·미생성 출력 키를 쓰면 `outputPathResults.diagnostics`에 명시하며 조용히 무시하지 않습니다. 최상위 단일 출력은 기존 일반 키를 사용합니다. `outputPaths` 응답은 성공적으로 커밋한 파일만 나타냅니다. 자산 binary는 계속 `assetRequests[].outputPath`를 사용합니다. 명시적 `resource`와 자동 resource 전환 모두 `nextAction.tool/arguments`로 동일 artifact의 작은 본문 대조 예시를 제공합니다. 일반 화면은 단일 frame·단일 출력이며 파일을 다시 쓰지 않습니다. `sizeEstimate.outputBytes`는 기존 선택 출력의 UTF-8 바이트 수(PNG는 binary 바이트 수)이고 `minimumWireBytes`는 메타데이터를 제외한 JSON 중복 전송 하한입니다. 재투영 응답의 전체 크기는 실측하지 않았으므로 한도 미만의 경우에도 `inlineFit=unknown`입니다. 확실히 한도를 넘는 경우 resource를 안내하며, inline 크기 초과 시 기존 R12 교정 인자를 유지합니다. 자산 요청·출력 경로가 코드 URL을 결정하는 경우에는 원래 결합된 resource 인자를 유지하여 잘못된 본문 대조를 방지합니다. + + +## Project intake comes first + +`/devup` is the entry point for new projects, existing features, bug fixes, +Figma page implementation and refactors. Figma screen planning is a branch +of the confirmed project plan; complete this section before the screen workflow below. + +Read sources before asking: `devup_project_context` for every supported scope, +Atlassian MCP `getJiraIssue` / `searchJiraIssuesUsingJql` for issues, epic children +and dependencies, referenced Confluence/spec documents, and Figma exploration +(screen names, field labels, forms, tables and detail screens). Pass each read as +`evidence: [{source, facts}]` to `devup_project_plan`. Facts use `goal`, `parts`, +`figma` (links), `screens` ([{name, fields}]), and `.screens`, `.states`, +`.permissions`, `.forms`, `.notifications`. Backend decisions use `erd`, `tenancy`, +`auth`, `lifecycle`, `indexes`, `storage`; delivery uses `jiraPolicy`, `branchPolicy`, +`prPolicy`. A sourced fact is a default with its provenance, not another question. +Sources are read by the runtime's connectors, never by treating a URL as its contents. + +1. `devup_project_plan intake` with projectRoot: always ask the four initial + questions with the runtime question tool (recommended option first): work type + (refactor may be a custom answer), optional Figma/Jira/spec sources, one-sentence + goal, and parts. Existing `apis/` recommends front + admin + API; + `apps/admin` recommends front + admin. Retain planId on every later call. +2. Read newly named sources. `draft` with all initial answers, source-labelled + evidence and `features: [{id,scope,screens,acceptance}]` or + `issues: [{key,scope,dependencies,acceptance}]` (epic children are issues too). + Each unresolved feature gets concrete screen/role, state, permission, + validation, notification/integration rounds. Put all remaining unknowns into + extraQuestions. No implementation starts while a question remains unanswered. +3. API in scope: inspect existing `vespertide.json` and `apis/*/models/*.json`. + Draft complete `entities` as vespertide model objects and `relations` with + from/to, cardinality oneToMany|manyToMany, column/joinTable and onDelete. + Signup/post Figma evidence seeds user/post entities and a user FK, but the + agent must expand every actual domain from the source evidence. Review the + ERD round, ownership/tenancy, auth with integer roles (0,10,50,100), status + enums, soft delete, audit timestamps, nullable fields, unique/composite keys, + filter/sort indexes, file/image storage and cascade/restrict/set_null actions. + Read https://github.com/dev-five-git/vespertide/blob/main/SKILL.md and schemas/. + Use snake_case, `$schema` on every model, inline PK/FK/unique/index constraints, + and table constraints only for CHECK. A manyToMany relation generates its join + table. modelsDir is relative to projectRoot; set it explicitly for multiple APIs. + Re-draft structured entities for requested corrections before confirming. +4. When Figma is present, run the existing screen-plan draft/rounds/confirm + workflow (routes, groups, exclusions, site facts) before project confirm. +5. Ask all delivery questions now: Jira none/comment/transition, branch, PR. + `devup_project_plan confirm` requires every applicable answer and writes source + models plus confirmed state in one transaction. Then in modelsWorkingDirectory + run the returned commands, in order: `vespertide diff`, `vespertide sql`, + `vespertide revision -m "confirmed project plan"`, `vespertide export --orm seaorm`. + The tool returns commands; the runtime executes and checks them. Never hand-write + migrations/ or src/models/. Build vespera CRUD and feature routes, openapi.json, + and the devup-api client; `devup_stack_diff` must show zero drift before done. +6. Latest-first updates, vinext, exact layout, all interaction/site defaults, + runtime/visual audits and the existing never-wait rules below still apply. + After confirm, use project-plan defer with id/reason/recommended/destructive; + continue independent work. At the end assume with no decisions resolves all + pending recommendations; destructive decisions remain unbuilt. Status includes + ERD Mermaid, decisions and the unit table; retain unresolved evidence in reports. + +## Supervisor execution + +`devup_project_plan split` splits work, not files. Jira input produces one unit +per issue/sub-task with its explicit dependencies (reject cycles/unknown IDs). +Without Jira, it produces shared layout/theme first, then each feature's +models -> API -> client -> front chain; front-only features depend on shared. +Review cross-feature dependencies before dispatch; the supervisor sequences +shared database work to avoid concurrent schema edits. Each unit has acceptance +criteria and build/lint/typecheck/tests/validate/runtime/visual/stack-diff checks. +`.devup/units/.md` contains scope, decisions, ERD and screen context, and checklist. + +Default `unitQuestions: "supervisor"` means workers never ask the user: questions +were settled across all units during intake, and supervisor ownership avoids +repeated questions and keeps decisions consistent. `unitQuestions: "worker"` +is an explicit alternative, still subject to the confirmed never-wait policy. +Load `orca-cli` and `orchestration` skills to create one worktree per unit, +dispatch the DAG in parallel where independent, collect worker_done, and rerun +each unit's checks yourself before merging. New decisions use orchestration +ask/reply to the supervisor: answer from recorded decisions, or defer with a +recommended option while the worker continues other work. Send failed checks +back to the same worker session. Update unit states with status unitUpdates +(pending -> running -> verifying -> merged, or unresolved); merged needs recorded +supervisor verification. Without Orca, execute the same units sequentially in one +session. Only under the recorded Jira/PR policy, comment results and PR link and +transition issues. Final report includes the unit table, DAG, ERD and unresolved +items; never claim completion from split or command generation alone. + + + +Source organization: Rust source under `crates/` is limited to 1000 lines per +file, including tests. The existing size-contract test checks this alongside +all embedded JSON text assets. Source sections use `include!` inside the +original module namespace to retain narrow privacy and existing test names. +Long tool text is in `crates/devup-mcp/src/server/text/`; other crates use +`src/text/`. `include_str!` keeps these assets inside the binary. + +App selection keyword overrides in `.devup/screen-plan.config.json`: +```json +{"appSelectionKeywords":{"publicApps":["storefront"],"admin":["controlroom"],"publicScreens":["catalogue"],"adminRoutes":["control"]}} +``` +Each supplied category replaces its defaults; omitted categories retain the +vendored defaults. An empty category explicitly disables its keyword matches. diff --git a/crates/devup-mcp-devup-ui/src/codegen/component.rs b/crates/devup-mcp-devup-ui/src/codegen/component.rs index 0b89de14..5cc3a6e7 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component.rs @@ -1,2257 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet, HashSet}; - -use devup_mcp_figma::{ - CollectedPayload, DevupError, Diagnostic, DiagnosticSeverity, ErrorCode, RawNode, Snapshot, - UpstreamResult, -}; -use serde::{Deserialize, Serialize}; - -use super::{animation, layout, style, text, variant}; -use crate::provenance::{ - FidelityReport, ProjectionTrace, SourceMap, build_projection_trace, finalize_tsx, mark_node, - validate_fidelity, -}; -use crate::theme::variable_token; -use crate::validation::validate_tsx; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RootLayout { - #[default] - Standalone, - Embedded, -} - -#[derive(Debug, Clone, Default)] -pub struct CodegenOptions { - /// Actual acquired/requested representation per instance-specific asset id. - pub asset_policies: BTreeMap, - pub component_name: Option, - pub include_diagnostics: bool, - pub inline_instances: bool, - pub text_style_tokens: std::collections::BTreeMap, - /// Materialized typography values from the same resources as devup.json. - /// Names alone cannot prove that a local font property is redundant. - pub typography_values: BTreeMap, - pub variable_tokens: std::collections::BTreeMap, - pub root_layout: RootLayout, - /// Name every asset after the node it came from, rather than after its - /// layer. Off by default, which is how the plugin names them: a layer - /// name is the file name, and two nodes named alike share a file. - /// - /// That sharing is a loss wherever the two are not the same picture. A - /// designer names three logos `Logo`, and only one of them can be - /// written; a photograph drawn at three widths is one file, so at two of - /// them the file is the wrong size for the box and the picture is - /// stretched into it. Named per node, each gets a file of its own. - pub asset_names_per_node: bool, - /// Opt-in width-change anchors; `None` keeps the drawn-width output. - pub width_policy: Option, - /// Length tokens of the theme generated from the same variables, by the - /// id of the variable named for them. A length bound to one of those - /// variables is written as its `$token` where the token comes to the - /// drawn value at the width the screen was drawn for. - pub length_tokens: BTreeMap, -} - -/// A `length` token as the theme writes it, in the theme a page shows -/// without `data-theme`: one length, or a responsive array of them. -#[derive(Debug, Clone, PartialEq)] -pub struct LengthToken { - pub token: String, - pub value: serde_json::Value, -} - -impl CodegenOptions { - pub fn with_payload_tokens(self, payload: &CollectedPayload) -> Self { - self.with_resource_results(payload.variables.as_ref(), payload.styles.as_ref()) - } - - /// The two collected resources on their own, for a caller that kept them - /// without the rest of the payload — a capture replayed from a fixture - /// has its variables and styles but no live target, stats or assets to - /// rebuild a `CollectedPayload` around them. - pub fn with_resource_results( - mut self, - variables: Option<&UpstreamResult>, - styles: Option<&UpstreamResult>, - ) -> Self { - self.text_style_tokens = named_tokens(styles, "styles"); - self.variable_tokens = named_tokens(variables, "variables"); - self.length_tokens = length_tokens(variables); - let mut snapshot = variables - .and_then(|result| crate::theme::variable_snapshot_from_result(result).ok()) - .unwrap_or_else(|| { - serde_json::from_value(serde_json::json!({})).expect("empty variable snapshot") - }); - if let Some(styles) = - styles.and_then(|result| crate::theme::variable_snapshot_from_result(result).ok()) - { - for style in styles.styles { - if !snapshot - .styles - .iter() - .any(|existing| existing.id == style.id) - { - snapshot.styles.push(style); - } - } - } - self.typography_values = - crate::theme::generate_devup_json(&snapshot, crate::theme::ThemeScope::File) - .ok() - .and_then(|output| serde_json::from_str::(&output.json).ok()) - .and_then(|value| { - value - .pointer("/theme/typography") - .and_then(serde_json::Value::as_object) - .cloned() - }) - .map(|values| values.into_iter().collect()) - .unwrap_or_default(); - self - } -} - -/// The length tokens the theme of these variables defines, by variable id: -/// the same generator and scope the export's `devupJson` uses, so a token -/// written into the code is the one the theme defines, with its value. -fn length_tokens(variables: Option<&UpstreamResult>) -> BTreeMap { - let Some(snapshot) = - variables.and_then(|result| crate::theme::variable_snapshot_from_result(result).ok()) - else { - return BTreeMap::new(); - }; - let Some(theme) = crate::theme::generate_devup_json(&snapshot, crate::theme::ThemeScope::Node) - .ok() - .and_then(|output| serde_json::from_str::(&output.json).ok()) - else { - return BTreeMap::new(); - }; - // devup-ui's default theme is the first one under `colors`. - let lengths = &theme["theme"]["length"]; - let Some(lengths) = theme["theme"]["colors"] - .as_object() - .and_then(|themes| themes.keys().next()) - .or_else(|| lengths.as_object().and_then(|themes| themes.keys().next())) - .and_then(|name| lengths.get(name)) - .and_then(serde_json::Value::as_object) - else { - return BTreeMap::new(); - }; - snapshot - .variables - .iter() - .chain(&snapshot.used_remote_variables) - .filter_map(|variable| { - let token = variable_token( - &variable.name, - variable.code_syntax.get("WEB").map(String::as_str), - ); - let value = lengths.get(&token)?.clone(); - Some((variable.id.clone(), LengthToken { token, value })) - }) - .collect() -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CodegenOutput { - pub tsx: String, - pub imports: Vec, - pub used_tokens: BTreeSet, - pub diagnostics: Vec, - pub source_map: SourceMap, - pub projection_trace: ProjectionTrace, - pub fidelity_report: FidelityReport, - /// The policy the output was generated under, so verification and the - /// width report judge the anchors it actually wrote. - pub width_policy: Option, -} - -pub fn generate_component( - snapshot: &Snapshot, - root_id: &str, - options: &CodegenOptions, -) -> Result { - let generated = generate_node_marked(snapshot, root_id, options)?; - let root = snapshot.nodes.get(root_id).ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Node to convert was not found in the Figma snapshot.", - false, - ) - })?; - let component_name = options - .component_name - .as_deref() - .map(normalize_component_name) - .unwrap_or_else(|| normalize_component_name(root.typed_view().name().unwrap_or(""))); - let body = generated - .tsx - .lines() - .map(|line| format!(" {line}")) - .collect::>() - .join("\n"); - let mut tsx = format!( - "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" - )); - finalize_codegen_output( - CodegenOutput { tsx, ..generated }, - snapshot, - options, - root_id, - ) -} - -pub fn generate_legacy_component( - snapshot: &Snapshot, - root_id: &str, - options: &CodegenOptions, -) -> Result { - let generated = generate_node_marked(snapshot, root_id, options)?; - let root = snapshot.nodes.get(root_id).ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Node to convert was not found in the Figma snapshot.", - false, - ) - })?; - let component_name = options - .component_name - .as_deref() - .map(legacy_component_name) - .unwrap_or_else(|| legacy_component_name(root.typed_view().name().unwrap_or(""))); - let body = if generated.tsx.contains('\n') { - let indented = generated - .tsx - .lines() - .map(|line| format!(" {line}")) - .collect::>() - .join("\n"); - format!("(\n{indented}\n )") - } else { - generated.tsx.clone() - }; - finalize_codegen_output( - CodegenOutput { - tsx: format!("export function {component_name}() {{\n return {body}\n}}"), - ..generated - }, - snapshot, - options, - root_id, - ) -} - -pub fn render_component_source( - component: &str, - code: &str, - variants: &[(String, String)], -) -> String { - let variants = variants - .iter() - .filter(|(key, _)| !key.eq_ignore_ascii_case("effect")) - .collect::>(); - let wrapped = if code.contains('\n') { - format!( - "(\n{}\n )", - code.lines() - .map(|line| format!(" {line}")) - .collect::>() - .join("\n") - ) - } else { - code.split_whitespace().collect::>().join(" ") - }; - if variants.is_empty() { - return format!("export function {component}() {{\n return {wrapped}\n}}"); - } - let interface = variants - .iter() - .map(|(key, value)| { - let optional = if value == "boolean" { "?" } else { "" }; - format!(" {key}{optional}: {value}") - }) - .collect::>() - .join("\n"); - let keys = variants - .iter() - .map(|(key, _)| key.as_str()) - .collect::>() - .join(", "); - format!( - "export interface {component}Props {{\n{interface}\n}}\n\nexport function {component}({{ {keys} }}: {component}Props) {{\n return {wrapped}\n}}" - ) -} - -pub fn generate_component_set_target( - snapshot: &Snapshot, - root_id: &str, - target_name: &str, - options: &CodegenOptions, -) -> Result { - let root = snapshot.nodes.get(root_id).ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Component set was not found in the Figma snapshot.", - false, - ) - })?; - let component_set = if root.typed_view().node_type() == "COMPONENT_SET" - && root.typed_view().name() == Some(target_name) - { - Some(root) - } else { - snapshot.nodes.values().find(|node| { - node.typed_view().node_type() == "COMPONENT_SET" - && node.typed_view().name() == Some(target_name) - }) - }; - let root_view = component_set.unwrap_or(root).typed_view(); - let main = component_set.is_some(); - if let Some(set) = component_set - && let Some(output) = variant::generate_variant_component_set(snapshot, &set.id, options)? - { - return finalize_codegen_output(output, snapshot, options, &set.id); - } - let target_id = if main { - let default_name = root_view - .value("defaultVariant") - .and_then(|value| value.get("name")) - .and_then(serde_json::Value::as_str); - root_view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .find(|node| node.typed_view().name() == default_name) - .or_else(|| { - root_view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .next() - }) - .map(|node| node.id.as_str()) - } else { - snapshot - .nodes - .values() - .find(|node| { - node.typed_view().node_type() == "COMPONENT" - && node.typed_view().name() == Some(target_name) - }) - .map(|node| node.id.as_str()) - } - .ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - format!("Output '{target_name}' was not found in the component set."), - false, - ) - })?; - - let (generated, provenance_root_id) = if main { - match generate_component_asset_child(snapshot, target_id, options) { - Some(output) => output?, - None => ( - generate_node_marked(snapshot, target_id, options)?, - target_id.to_owned(), - ), - } - } else { - ( - generate_node_marked(snapshot, target_id, options)?, - target_id.to_owned(), - ) - }; - let code = if !main && generated.tsx.contains("") { - generated.tsx.replace("", "") - } else { - generated.tsx.clone() - }; - let variants = if main { - component_variants( - snapshot, - component_set.expect("main component set"), - target_id, - ) - } else { - Vec::new() - }; - finalize_codegen_output( - CodegenOutput { - tsx: render_component_source(&legacy_component_name(target_name), &code, &variants), - ..generated - }, - snapshot, - options, - &provenance_root_id, - ) -} - -pub fn generate_inlined_component_instance( - snapshot: &Snapshot, - root_id: &str, - instance_id: &str, - options: &CodegenOptions, -) -> Result { - let root = snapshot.nodes.get(root_id).ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Inline instance root was not found.", - false, - ) - })?; - let instance = snapshot.nodes.get(instance_id).ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Component instance to inline was not found.", - false, - ) - })?; - let name = instance.typed_view().name().unwrap_or("Component"); - let selected_variants = instance - .typed_view() - .value("componentProperties") - .and_then(serde_json::Value::as_object) - .map(|properties| { - properties - .iter() - .filter_map(|(key, value)| { - (value.get("type").and_then(serde_json::Value::as_str) == Some("VARIANT")) - .then(|| value.get("value").and_then(serde_json::Value::as_str)) - .flatten() - .map(|value| (key.as_str(), value)) - }) - .collect::>() - }) - .unwrap_or_default(); - let set = snapshot - .nodes - .values() - .find(|node| { - node.typed_view().node_type() == "COMPONENT_SET" - && node.typed_view().name() == Some(name) - }) - .ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - format!("Component set '{name}' was not found."), - false, - ) - })?; - let selected = set - .typed_view() - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .find(|candidate| { - selected_variants.iter().all(|(key, value)| { - candidate - .typed_view() - .value("variantProperties") - .and_then(|variants| variants.get(*key)) - .and_then(serde_json::Value::as_str) - == Some(*value) - }) - }) - .ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - format!("Instance variant '{name}' was not found."), - false, - ) - })?; - let mut projected = snapshot.clone(); - if let Some(projected_set) = projected.nodes.get_mut(&set.id) - && let Some(children) = projected_set - .fields - .get_mut("childrenIds") - .and_then(serde_json::Value::as_array_mut) - { - children.retain(|id| id.as_str() != Some(&selected.id)); - } - if let Some(projected_selected) = projected.nodes.get_mut(&selected.id) { - for field in ["layoutSizingHorizontal", "layoutSizingVertical"] { - projected_selected.fields.insert( - field.to_owned(), - serde_json::Value::String("FIXED".to_owned()), - ); - } - } - let mut output = generate_node_marked(&projected, &selected.id, options)?; - if let Some(close) = output.tsx.rfind("\n/>") { - let suffix = output.tsx[close + 3..].to_owned(); - let mut lines = output.tsx[..close] - .lines() - .map(str::to_owned) - .collect::>(); - for (field, prop) in [("height", "h"), ("width", "w")] { - if let Some(value) = root.typed_view().number(field) { - lines.push(format!(" {prop}=\"{}\"", layout::px(value))); - } - } - lines[1..].sort(); - output.tsx = format!("{}\n/>{suffix}", lines.join("\n")); - } - let usage = selected_variants - .iter() - .map(|(key, value)| format!(" {key}=\"{value}\"")) - .collect::(); - output.tsx = format!("{{/* <{name}{usage} /> */}}\n{}", output.tsx); - finalize_codegen_output(output, &projected, options, &selected.id) -} - -fn generate_component_asset_child( - snapshot: &Snapshot, - component_id: &str, - options: &CodegenOptions, -) -> Option> { - let component = snapshot.nodes.get(component_id)?; - let children = component.typed_view().child_ids().collect::>(); - if children.len() != 1 { - return None; - } - let child_id = children[0]; - let child = snapshot.nodes.get(child_id)?; - style::asset_kind(snapshot, child)?; - let mut projected = snapshot.clone(); - let projected_child = projected.nodes.get_mut(child_id)?; - for dimension in ["width", "height"] { - if projected_child.typed_view().number(dimension).is_none() - && let Some(value) = component.typed_view().number(dimension) - { - projected_child - .fields - .insert(dimension.to_owned(), serde_json::Value::from(value)); - } - } - projected_child.fields.insert( - "layoutSizingHorizontal".to_owned(), - serde_json::Value::String("FIXED".to_owned()), - ); - projected_child.fields.insert( - "layoutSizingVertical".to_owned(), - serde_json::Value::String("FIXED".to_owned()), - ); - Some( - generate_node_marked(&projected, child_id, options) - .map(|output| (output, child_id.to_owned())), - ) -} - -pub fn render_component_registration_snapshot( - snapshot: &Snapshot, - root_id: &str, - target_name: &str, -) -> Result { - let root = snapshot.nodes.get(root_id).ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Component registration root was not found.", - false, - ) - })?; - let set = if root.typed_view().node_type() == "COMPONENT_SET" { - Some(root) - } else { - snapshot - .nodes - .values() - .find(|node| node.typed_view().node_type() == "COMPONENT_SET") - }; - let selected = if root.typed_view().node_type() == "COMPONENT" { - root - } else { - root.typed_view() - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .find(|node| node.typed_view().name() == Some(target_name)) - .ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - format!("Registration target '{target_name}' was not found."), - false, - ) - })? - }; - let name = set - .and_then(|set| set.typed_view().name()) - .or_else(|| selected.typed_view().name()) - .unwrap_or("Component"); - let selected_is_set_child = set.is_some_and(|set| { - set.typed_view() - .child_ids() - .any(|child| child == selected.id) - }); - let node = registration_node(snapshot, selected, set, selected_is_set_child, 1); - let variants = set - .map(|set| component_variants(snapshot, set, &selected.id)) - .unwrap_or_default() - .into_iter() - .filter(|(key, _)| !key.eq_ignore_ascii_case("effect")) - .collect::>(); - let variants = if variants.is_empty() { - "{}".to_owned() - } else { - let lines = variants - .iter() - .map(|(key, value)| format!(" {}: {},", json_string(key), json_string(value))) - .collect::>() - .join("\n"); - format!("{{\n{lines}\n }}") - }; - let box_size = set.is_none(); - let mut props = Vec::new(); - if let Some(set) = set - && selected - .typed_view() - .value("reactions") - .and_then(serde_json::Value::as_array) - .is_some_and(|reactions| { - reactions.iter().any(|reaction| { - reaction - .get("trigger") - .and_then(|trigger| trigger.get("type")) - .and_then(serde_json::Value::as_str) - == Some("ON_HOVER") - }) - }) - && let Some(hover) = set - .typed_view() - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .find(|node| node.id != selected.id && node.typed_view().number("opacity").is_some()) - && let Some(opacity) = hover.typed_view().number("opacity") - { - props.extend([ - " \"_hover\": {".to_owned(), - format!( - " \"opacity\": {},", - json_string(&layout::format_number(opacity)) - ), - " },".to_owned(), - " \"aspectRatio\": undefined,".to_owned(), - " \"flex\": undefined,".to_owned(), - " \"h\": undefined,".to_owned(), - " \"maxH\": undefined,".to_owned(), - " \"maxW\": undefined,".to_owned(), - " \"minH\": undefined,".to_owned(), - " \"minW\": undefined,".to_owned(), - " \"transition\": \"0.3ms ease-in-out\",".to_owned(), - " \"transitionProperty\": \"opacity\",".to_owned(), - " \"w\": undefined,".to_owned(), - ]); - } else { - props.push(" \"aspectRatio\": undefined,".to_owned()); - } - if !props.iter().any(|prop| prop.contains("\"_hover\"")) { - if box_size { - props.push(" \"boxSize\": \"100%\",".to_owned()); - } - props.push(" \"flex\": undefined,".to_owned()); - if !box_size { - props.push(" \"h\": undefined,".to_owned()); - } - props.extend([ - " \"maxH\": undefined,".to_owned(), - " \"maxW\": undefined,".to_owned(), - " \"minH\": undefined,".to_owned(), - " \"minW\": undefined,".to_owned(), - ]); - if !box_size { - props.push(" \"w\": undefined,".to_owned()); - } - } - Ok(format!( - "{{\n \"name\": {},\n \"node\": {},\n \"tree\": {{\n \"children\": [],\n \"component\": \"Box\",\n \"nodeName\": {},\n \"nodeType\": \"COMPONENT\",\n \"props\": {{\n{}\n }},\n }},\n \"variantComments\": {{}},\n \"variants\": {},\n}}", - json_string(name), - node, - json_string(selected.typed_view().name().unwrap_or(target_name)), - props.join("\n"), - variants - )) -} - -fn registration_node( - snapshot: &Snapshot, - node: &RawNode, - parent: Option<&RawNode>, - circular_children: bool, - depth: usize, -) -> String { - let view = node.typed_view(); - let indent = " ".repeat(depth); - let child_indent = " ".repeat(depth + 1); - let mut entries = Vec::new(); - entries.push(format!("{child_indent}\"children\": [],")); - entries.push(format!( - "{child_indent}\"name\": {},", - json_string(view.name().unwrap_or("")) - )); - if let Some(opacity) = view.number("opacity") { - entries.push(format!( - "{child_indent}\"opacity\": {},", - layout::format_number(opacity) - )); - } - if let Some(parent) = parent { - entries.push(format!( - "{child_indent}\"parent\": {},", - registration_parent(snapshot, parent, node, circular_children, depth + 1) - )); - } - if let Some(value) = view.value("reactions") { - entries.push(format!( - "{child_indent}\"reactions\": {},", - js_value(value, depth + 1) - )); - } - entries.push(format!( - "{child_indent}\"type\": {},", - json_string(view.node_type()) - )); - if let Some(value) = view.value("variantProperties") { - entries.push(format!( - "{child_indent}\"variantProperties\": {},", - js_value(value, depth + 1) - )); - } - if let Some(visible) = view.bool("visible") { - entries.push(format!("{child_indent}\"visible\": {visible},")); - } - format!("{{\n{}\n{indent}}}", entries.join("\n")) -} - -fn registration_parent( - snapshot: &Snapshot, - parent: &RawNode, - selected: &RawNode, - circular_children: bool, - depth: usize, -) -> String { - let view = parent.typed_view(); - let indent = " ".repeat(depth); - let child_indent = " ".repeat(depth + 1); - let children_rendered = if view.child_ids().next().is_none() { - "[]".to_owned() - } else { - let values = view - .child_ids() - .filter_map(|id| { - if id == selected.id { - Some("[Circular]".to_owned()) - } else { - snapshot - .nodes - .get(id) - .map(|child| registration_child(child, circular_children, depth + 2)) - } - }) - .collect::>(); - if values.is_empty() { - "[]".to_owned() - } else { - format!( - "[\n{}\n{child_indent}]", - values - .iter() - .map(|value| format!("{}{},", " ".repeat(depth + 2), value)) - .collect::>() - .join("\n") - ) - } - }; - let mut entries = vec![format!("{child_indent}\"children\": {children_rendered},")]; - for field in ["componentPropertyDefinitions", "defaultVariant"] { - if let Some(value) = view.value(field) { - let rendered = if field == "defaultVariant" - && value.get("name").and_then(serde_json::Value::as_str) - == selected.typed_view().name() - && circular_children - { - "[Circular]".to_owned() - } else if field == "defaultVariant" { - registration_embedded_node(value, depth + 1) - } else { - js_value(value, depth + 1) - }; - entries.push(format!("{child_indent}{}: {rendered},", json_string(field))); - } - } - entries.push(format!( - "{child_indent}\"name\": {},", - json_string(view.name().unwrap_or("")) - )); - entries.push(format!( - "{child_indent}\"type\": {},", - json_string(view.node_type()) - )); - if let Some(visible) = view.bool("visible") { - entries.push(format!("{child_indent}\"visible\": {visible},")); - } - format!("{{\n{}\n{indent}}}", entries.join("\n")) -} - -fn registration_child(node: &RawNode, circular_parent: bool, depth: usize) -> String { - let view = node.typed_view(); - let indent = " ".repeat(depth); - let child_indent = " ".repeat(depth + 1); - let mut entries = vec![ - format!("{child_indent}\"children\": [],"), - format!( - "{child_indent}\"name\": {},", - json_string(view.name().unwrap_or("")) - ), - ]; - if let Some(opacity) = view.number("opacity") { - entries.push(format!( - "{child_indent}\"opacity\": {},", - layout::format_number(opacity) - )); - } - if circular_parent { - entries.push(format!("{child_indent}\"parent\": [Circular],")); - } - if let Some(value) = view.value("reactions") { - entries.push(format!( - "{child_indent}\"reactions\": {},", - js_value(value, depth + 1) - )); - } - entries.push(format!( - "{child_indent}\"type\": {},", - json_string(view.node_type()) - )); - if let Some(value) = view.value("variantProperties") { - entries.push(format!( - "{child_indent}\"variantProperties\": {},", - js_value(value, depth + 1) - )); - } - if let Some(visible) = view.bool("visible") { - entries.push(format!("{child_indent}\"visible\": {visible},")); - } - format!("{{\n{}\n{indent}}}", entries.join("\n")) -} - -fn registration_embedded_node(value: &serde_json::Value, depth: usize) -> String { - let Some(object) = value.as_object() else { - return js_value(value, depth); - }; - let indent = " ".repeat(depth); - let child_indent = " ".repeat(depth + 1); - let mut entries = vec![format!("{child_indent}\"children\": [],")]; - for field in [ - "name", - "opacity", - "reactions", - "type", - "variantProperties", - "visible", - ] { - if let Some(value) = object.get(field) { - entries.push(format!( - "{child_indent}{}: {},", - json_string(field), - js_value(value, depth + 1) - )); - } - } - format!("{{\n{}\n{indent}}}", entries.join("\n")) -} - -fn js_value(value: &serde_json::Value, depth: usize) -> String { - match value { - serde_json::Value::Null => "null".to_owned(), - serde_json::Value::Bool(value) => value.to_string(), - serde_json::Value::Number(value) => value.to_string(), - serde_json::Value::String(value) => json_string(value), - serde_json::Value::Array(values) => { - if values.is_empty() { - "[]".to_owned() - } else { - let indent = " ".repeat(depth); - let child_indent = " ".repeat(depth + 1); - format!( - "[\n{}\n{indent}]", - values - .iter() - .map(|value| format!("{child_indent}{},", js_value(value, depth + 1))) - .collect::>() - .join("\n") - ) - } - } - serde_json::Value::Object(values) => { - if values.is_empty() { - "{}".to_owned() - } else { - let indent = " ".repeat(depth); - let child_indent = " ".repeat(depth + 1); - format!( - "{{\n{}\n{indent}}}", - values - .iter() - .map(|(key, value)| format!( - "{child_indent}{}: {},", - json_string(key), - js_value(value, depth + 1) - )) - .collect::>() - .join("\n") - ) - } - } - } -} - -fn json_string(value: &str) -> String { - serde_json::to_string(value).expect("string JSON serialization") -} - -fn component_variants( - snapshot: &Snapshot, - root: &RawNode, - default_id: &str, -) -> Vec<(String, String)> { - let definitions = root - .typed_view() - .value("componentPropertyDefinitions") - .and_then(serde_json::Value::as_object); - let mut variants = Vec::new(); - let mut added = BTreeSet::new(); - let add = |raw_key: &str, - definition: &serde_json::Value, - variants: &mut Vec<(String, String)>, - added: &mut BTreeSet| { - let key = component_property_name(raw_key); - if added.contains(&key) { - return; - } - let value = match definition.get("type").and_then(serde_json::Value::as_str) { - Some("VARIANT") => definition - .get("variantOptions") - .and_then(serde_json::Value::as_array) - .map(|options| { - options - .iter() - .filter_map(serde_json::Value::as_str) - .map(|option| format!("'{option}'")) - .collect::>() - .join(" | ") - }), - Some("BOOLEAN") => Some("boolean".to_owned()), - Some("INSTANCE_SWAP") => Some("React.ReactNode".to_owned()), - _ => None, - }; - if let Some(value) = value { - added.insert(key.clone()); - variants.push((key, value)); - } - }; - if let Some(definitions) = definitions { - for (key, definition) in definitions { - if definition.get("type").and_then(serde_json::Value::as_str) == Some("VARIANT") { - add(key, definition, &mut variants, &mut added); - } - } - fn visit_refs( - snapshot: &Snapshot, - id: &str, - definitions: &serde_json::Map, - variants: &mut Vec<(String, String)>, - added: &mut BTreeSet, - add: &impl Fn(&str, &serde_json::Value, &mut Vec<(String, String)>, &mut BTreeSet), - ) { - let Some(node) = snapshot.nodes.get(id) else { - return; - }; - let view = node.typed_view(); - if let Some(references) = view - .value("componentPropertyReferences") - .and_then(serde_json::Value::as_object) - { - for field in ["mainComponent", "visible"] { - if let Some(key) = references.get(field).and_then(serde_json::Value::as_str) - && let Some(definition) = definitions.get(key) - { - add(key, definition, variants, added); - } - } - } - for child in view.child_ids() { - visit_refs(snapshot, child, definitions, variants, added, add); - } - } - visit_refs( - snapshot, - default_id, - definitions, - &mut variants, - &mut added, - &add, - ); - for (key, definition) in definitions { - add(key, definition, &mut variants, &mut added); - } - } - variants -} - -fn component_property_name(raw: &str) -> String { - raw.split('#').next().unwrap_or(raw).to_owned() -} - -pub(super) fn legacy_component_name(input: &str) -> String { - if !input.is_empty() - && input - .chars() - .all(|character| !character.is_alphabetic() || character.is_uppercase()) - { - let mut characters = input.chars(); - if let Some(first) = characters.next() { - let candidate = first - .to_uppercase() - .chain(characters.flat_map(char::to_lowercase)) - .collect::(); - return sanitize_legacy_identifier(&candidate); - } - } - let mut output = String::new(); - for part in input - .split(['-', '_', '/', ' ']) - .filter(|part| !part.is_empty()) - { - let acronym = part - .chars() - .filter(|character| character.is_alphabetic()) - .all(|character| character.is_uppercase()); - let normalized = if acronym { - part.to_lowercase() - } else { - part.to_owned() - }; - let mut characters = normalized.chars(); - if let Some(first) = characters.next() { - output.extend(first.to_uppercase()); - output.extend(characters); - } - } - sanitize_legacy_identifier(&output) -} - -fn sanitize_legacy_identifier(candidate: &str) -> String { - let mut output = candidate - .chars() - .filter(|character| character.is_alphanumeric() || *character == '_') - .collect::(); - if output.is_empty() { - return "FigmaComponent".to_owned(); - } - if output - .chars() - .next() - .is_some_and(|character| character.is_numeric()) - { - output.insert(0, '_'); - } - output -} - -pub fn generate_node( - snapshot: &Snapshot, - root_id: &str, - options: &CodegenOptions, -) -> Result { - finalize_codegen_output( - generate_node_marked(snapshot, root_id, options)?, - snapshot, - options, - root_id, - ) -} - -fn generate_node_marked( - snapshot: &Snapshot, - root_id: &str, - options: &CodegenOptions, -) -> Result { - let root = snapshot.nodes.get(root_id).ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Node to convert was not found in the Figma snapshot.", - false, - ) - })?; - let render_root = if root.typed_view().node_type() == "SECTION" { - root.typed_view() - .child_ids() - .next() - .and_then(|id| snapshot.nodes.get(id)) - .unwrap_or(root) - } else { - root - }; - let mut context = Context { - asset_policies: options.asset_policies.clone(), - asset_names_per_node: options.asset_names_per_node, - inline_instances: options.inline_instances, - text_style_tokens: options.text_style_tokens.clone(), - typography_values: options.typography_values.clone(), - variable_tokens: options.variable_tokens.clone(), - length_tokens: options.length_tokens.clone(), - root_layout: options.root_layout, - width_policy: options.width_policy.clone(), - ..Context::default() - }; - let jsx = render_node(snapshot, render_root, 0, &mut context, &mut HashSet::new())?; - let imports = context.imports.iter().cloned().collect::>(); - Ok(CodegenOutput { - tsx: jsx, - imports, - used_tokens: context.used_tokens, - diagnostics: context.diagnostics, - source_map: SourceMap::empty(), - projection_trace: ProjectionTrace::default(), - fidelity_report: FidelityReport::default(), - width_policy: options.width_policy.clone(), - }) -} - -fn finalize_codegen_output( - mut output: CodegenOutput, - snapshot: &Snapshot, - options: &CodegenOptions, - root_id: &str, -) -> Result { - let (tsx, source_map) = finalize_tsx( - &output.tsx, - snapshot, - &options.variable_tokens, - &options.text_style_tokens, - ); - output.tsx = tsx; - output.source_map = source_map; - super::image::attach_evidence(snapshot, options, &mut output); - crate::provenance::account_for_sizing(snapshot, &mut output); - validate_tsx(&output.tsx)?; - if let Some(contract) = super::evidence::placement_contract(snapshot, &output, options, root_id) - { - output.diagnostics.push(contract); - } - // Background and variant projections share paint suppression in style; - // attach evidence for those paths as well as ordinary asset leaves. - for entry in output - .source_map - .entries - .iter() - .filter(|e| e.property.is_none() && e.resolution == "node") - { - let Some(node) = entry.node_id.as_ref().and_then(|id| snapshot.nodes.get(id)) else { - continue; - }; - let Some(reason) = style::non_rendering_asset_reason(snapshot, node) else { - continue; - }; - if output.diagnostics.iter().any(|d| { - d.code == "DEVUP_CODEGEN_NON_RENDERING_ASSET" && d.node_id.as_deref() == Some(&node.id) - }) { - continue; - } - let Some(source) = entry - .generated_range - .as_ref() - .and_then(|r| output.tsx.get(r.start..r.end)) - .filter(|s| !s.is_empty()) - else { - continue; - }; - output.diagnostics.push(non_rendering_diagnostic( - node, - reason, - source, - "layout-only", - )); - } - output - .diagnostics - .extend(unresolved_token_bindings(snapshot, root_id, options)); - output.projection_trace = - build_projection_trace(snapshot, root_id, &output.tsx, &output.source_map); - output.fidelity_report = validate_fidelity(snapshot, root_id, &output)?; - output - .diagnostics - .retain(|d| d.code != "DEVUP_CODEGEN_LAYOUT_UNCOVERED"); - // A coverage shortfall has no proven intentional-exclusion classification. - // Report the actual field instead of allowing an exact quality grade. - for pair in &output.fidelity_report.uncovered_layout { - if let Some((node_id, property)) = pair.rsplit_once('#') { - output.diagnostics.push(Diagnostic { - code: "DEVUP_CODEGEN_LAYOUT_UNCOVERED".into(), - message: "No verified property mapping accounts for this layout field; generated evidence describes what was emitted.".into(), - node_id: Some(node_id.into()), - property: Some(property.into()), - fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), - details: Some(super::evidence::uncovered_layout_details(snapshot, &output, options, node_id, property)), - ..Diagnostic::default() - }); - } - } - let absolute_components = output - .source_map - .entries - .iter() - .filter(|e| e.property.is_none()) - .filter_map(|e| e.node_id.as_deref()) - .filter(|id| { - snapshot.nodes.get(*id).is_some_and(|n| { - n.typed_view().string("layoutPositioning") == Some("ABSOLUTE") - && n.typed_view().bool("visible") != Some(false) - }) - }) - .map(|id| { - ( - id.to_owned(), - crate::provenance::absolute_component_verification(snapshot, &output, id), - ) - }) - .collect::>(); - // Every emitted ABSOLUTE node receives the same generated-property audit. - for id in absolute_components.keys() { - if !output.diagnostics.iter().any(|d| { - d.code == "DEVUP_CODEGEN_ABSOLUTE_FALLBACK" && d.node_id.as_deref() == Some(id) - }) { - output.diagnostics.push(Diagnostic { - code: "DEVUP_CODEGEN_ABSOLUTE_FALLBACK".into(), - node_id: Some(id.clone()), - fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Approximated), - ..Diagnostic::default() - }); - } - } - for diagnostic in &mut output.diagnostics { - let property = match diagnostic.code.as_str() { - "DEVUP_CODEGEN_ABSOLUTE_FALLBACK" => Some("layoutPositioning"), - "DEVUP_CODEGEN_MASK_FALLBACK" => Some("isMask"), - "DEVUP_CODEGEN_EFFECT_FALLBACK" => Some("effects"), - "DEVUP_CODEGEN_IMAGE_COMPOSITION_UNSUPPORTED" => Some("fills"), - "DEVUP_CODEGEN_ANIMATION_UNREACHABLE" => Some("reactions"), - "DEVUP_CODEGEN_VARIANT_CHILD_FALLBACK" => Some("childrenIds"), - "DEVUP_CODEGEN_TOKEN_NAME_UNRESOLVED" => diagnostic.property.as_deref(), - _ => None, - }; - if let Some(property) = property.map(str::to_owned) { - diagnostic.property = Some(property.clone()); - let node_id = diagnostic.node_id.as_deref().unwrap_or(root_id); - let generated = output - .source_map - .entries - .iter() - .filter(|entry| { - entry.node_id.as_deref() == Some(node_id) && entry.property.is_none() - }) - .filter_map(|entry| entry.generated_range.as_ref()) - .filter_map(|range| output.tsx.get(range.start..range.end)) - .collect::>(); - diagnostic.details = Some(serde_json::json!({ - "originalValue": super::evidence::fallback_original(snapshot, node_id, &property), - "originalResourceId": diagnostic.resource_id, - "appliedValue": {"generatedSource": generated, "fallback": diagnostic.fallback, - "heightPreservation": if property == "layoutPositioning" { - snapshot.nodes.get(node_id).map(|node| { - let view = node.typed_view(); - let expected = view.number("height").map(|h|format!("{}px",layout::format_number(h))); - let preserved = expected.as_ref().is_some_and(|height|generated.iter().any(|source| { - let tag = source.split('>').next().unwrap_or(source); - tag.contains(&format!("h=\"{height}\"")) || tag.contains(&format!("boxSize=\"{height}\"")) - })); - serde_json::json!({"sourceSizing":view.string("layoutSizingVertical"),"sourceHeight":view.number("height"), - "state":if view.string("layoutSizingVertical") != Some("FIXED") {"not-fixed"} else if preserved {"preserved"} else {"unverified"}, - "basis":"Generated explicit height; derived padding alone is not a fixed-height guarantee."}) - }) - } else {None}, - "derivedPadding": if property == "layoutPositioning" { - snapshot.nodes.get(node_id).and_then(|node|layout::derived_padding(snapshot,node)) - .map(|[top,right,bottom,left]|serde_json::json!({"top":top,"right":right,"bottom":bottom,"left":left, - "basis":"Generator-derived inset from visible child geometry; not the source padding fields."})) - } else { None }}, - "evidenceLimit": "Generated source and collected geometry are comparison evidence, not measured browser layout or proof of responsive equivalence.", - "stage": "projection" - })); - if property == "layoutPositioning" - && let Some(components) = absolute_components.get(node_id) - { - let unresolved = ["height","width","horizontal","vertical"].into_iter() - .filter(|name|components[*name]["fidelityImpact"] != "none") - .map(|name|serde_json::json!({"component":name,"condition":components[name]["resolutionCondition"]})) - .collect::>(); - let verified = unresolved.is_empty(); - diagnostic.message = if verified {"Absolute dimensions and constraint props verified against collected geometry."} else {"Absolute conversion is partially verified; components identify preserved values and remaining approximations."}.into(); - if verified { - diagnostic.code = "DEVUP_CODEGEN_ABSOLUTE_VERIFIED".into(); - diagnostic.fidelity_impact = Some(devup_mcp_figma::FidelityImpact::None); - } - let details = diagnostic.details.as_mut().unwrap(); - details["components"] = components.clone(); - details["appliedValue"]["widthPreservation"] = components["width"].clone(); - details["componentSummary"] = serde_json::json!({ - "verified": (["height", "width", "horizontal", "vertical"].into_iter() - .filter(|name| components[*name]["fidelityImpact"] == "none").collect::>()), - "unresolved": (["height", "width", "horizontal", "vertical"].into_iter() - .filter(|name| components[*name]["fidelityImpact"] != "none").collect::>()) - }); - details["resolutionConditions"] = serde_json::json!(unresolved); - details["classification"] = serde_json::json!(if verified { - "verified-absolute-components" - } else { - "partially-verified-absolute-components" - }); - } - } - } - output.source_map.describe_properties(&output.tsx); - output.source_map.describe_conversions(snapshot); - crate::provenance::attributes::audit_properties(snapshot, &mut output, options, root_id); - output - .diagnostics - .extend(super::evidence::image_layout_diagnostics(snapshot, &output)); - output.fidelity_report = validate_fidelity(snapshot, root_id, &output)?; - Ok(output) -} - -/// Reports every binding the generated code had to write out as a value. -/// -/// A fill bound to a variable, or a text carrying a style, is the design -/// saying "this is a token". The generator writes the token when the resource -/// catalog carried that variable or style, and the resolved value when it did -/// not - and it has to write something, because the module still has to -/// compile and render. What it must not do is stay quiet about it: a -/// hardcoded `#7d7f83` where the design says `$caption` renders identically -/// today and stops following the theme tomorrow, and a caller reading a -/// response graded `exact` has no way to know one is in there. -/// -/// Run as a pass over the collected subtree rather than inside rendering, so -/// it sees the node a binding belongs to and needs no argument threaded -/// through the render functions to reach it. -fn unresolved_token_bindings( - snapshot: &Snapshot, - root_id: &str, - options: &CodegenOptions, -) -> Vec { - fn paint_variable_ids(fills: Option<&serde_json::Value>) -> Vec { - fills - .and_then(serde_json::Value::as_array) - .map(|paints| { - paints - .iter() - .filter_map(|paint| { - Some( - paint - .get("boundVariables")? - .get("color")? - .get("id")? - .as_str()? - .to_owned(), - ) - }) - .collect() - }) - .unwrap_or_default() - } - - let mut reported = BTreeSet::new(); - let mut diagnostics = Vec::new(); - let mut pending = vec![root_id.to_owned()]; - let mut seen = BTreeSet::new(); - while let Some(id) = pending.pop() { - if !seen.insert(id.clone()) { - continue; - } - let Some(node) = snapshot.nodes.get(&id) else { - continue; - }; - let view = node.typed_view(); - if view.bool("visible") == Some(false) && !node.field_errors.contains_key("visible") { - // No paint or typography token is required by an omitted subtree. - continue; - } - pending.extend(view.child_ids().map(str::to_owned)); - - let mut lost = Vec::new(); - for variable_id in paint_variable_ids(view.value("fills")) { - if !options.variable_tokens.contains_key(&variable_id) { - lost.push(("variable", "fills", variable_id)); - } - } - if let Some(segments) = view - .value("styledTextSegments") - .and_then(serde_json::Value::as_array) - { - for segment in segments { - for variable_id in paint_variable_ids(segment.get("fills")) { - if !options.variable_tokens.contains_key(&variable_id) { - lost.push(("variable", "fills", variable_id)); - } - } - if let Some(style_id) = segment - .get("textStyleId") - .and_then(serde_json::Value::as_str) - && !style_id.is_empty() - && !options.text_style_tokens.contains_key(style_id) - { - lost.push(("textStyle", "textStyleId", style_id.to_owned())); - } - } - } - if let Some(style_id) = view.string("textStyleId") - && !style_id.is_empty() - && !options.text_style_tokens.contains_key(style_id) - { - lost.push(("textStyle", "textStyleId", style_id.to_owned())); - } - - for (kind, property, resource_id) in lost { - if !reported.insert((id.clone(), property, resource_id.clone())) { - continue; - } - diagnostics.push(Diagnostic { - code: "DEVUP_CODEGEN_TOKEN_NAME_UNRESOLVED".to_owned(), - message: format!( - "{id} binds {property} to a {kind} the resource catalog did not name, \ - so the resolved value was written instead of its token. Collect the \ - resource, or treat the value in the generated code as a token that \ - still needs a name." - ), - severity: Some(DiagnosticSeverity::Warning), - node_id: Some(id.clone()), - property: Some(property.to_owned()), - resource_id: Some(resource_id.clone()), - resource_kind: Some(kind.to_owned()), - details: Some(serde_json::json!({ - "nodeId": id, - "property": property, - "resourceId": resource_id - })), - ..Diagnostic::default() - }); - } - } - diagnostics.sort_by(|left, right| left.message.cmp(&right.message)); - diagnostics -} - -#[derive(Default)] -struct Context { - asset_policies: BTreeMap, - asset_names_per_node: bool, - imports: BTreeSet, - used_tokens: BTreeSet, - diagnostics: Vec, - inline_instances: bool, - instance_boolean_properties: Vec>, - text_style_tokens: std::collections::BTreeMap, - typography_values: BTreeMap, - variable_tokens: std::collections::BTreeMap, - length_tokens: BTreeMap, - root_layout: RootLayout, - width_policy: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum PropValue { - String(String), -} - -pub(super) type Prop = (String, PropValue); - -fn render_node( - snapshot: &Snapshot, - node: &RawNode, - depth: usize, - context: &mut Context, - visiting: &mut HashSet, -) -> Result { - if !visiting.insert(node.id.clone()) { - return Err(DevupError::new( - ErrorCode::DevupCodegenFailed, - "Figma node tree contains a circular reference.", - false, - )); - } - let view = node.typed_view(); - // Hidden snapshot subtrees have no deliverable assets. Do not emit their - // image/background/mask references, even under display:none. - if view.bool("visible") == Some(false) { - context.diagnostics.push(non_rendering_diagnostic( - node, - "visible-false", - "", - "omitted", - )); - visiting.remove(&node.id); - return Ok(mark_node( - &node.id, - if depth == 0 { - "<>".into() - } else { - String::new() - }, - )); - } - add_fallback_diagnostics(snapshot, node, context); - let concrete_visibility = if context.inline_instances { - view.value("componentPropertyReferences") - .and_then(serde_json::Value::as_object) - .and_then(|references| references.get("visible")) - .and_then(serde_json::Value::as_str) - .and_then(|property| { - context - .instance_boolean_properties - .iter() - .rev() - .find_map(|properties| properties.get(property).copied()) - }) - } else { - None - }; - if concrete_visibility == Some(false) { - visiting.remove(&node.id); - return Ok(mark_node(&node.id, String::new())); - } - if view.node_type() == "INSTANCE" - && !context.inline_instances - && style::non_rendering_asset_reason(snapshot, node).is_none() - { - let references = view - .value("componentPropertyReferences") - .and_then(serde_json::Value::as_object); - let main_property = references - .and_then(|references| references.get("mainComponent")) - .and_then(serde_json::Value::as_str) - .map(component_property_name); - let visible_property = references - .and_then(|references| references.get("visible")) - .and_then(serde_json::Value::as_str) - .map(component_property_name); - let component = snapshot - .nodes - .values() - .filter(|candidate| candidate.typed_view().node_type() == "COMPONENT") - .filter_map(|candidate| candidate.typed_view().name()) - .filter(|name| { - view.name() - .is_some_and(|instance| instance.starts_with(name)) - }) - .max_by_key(|name| name.len()) - .map(legacy_component_name) - .unwrap_or_else(|| legacy_component_name(view.name().unwrap_or("Component"))); - let content = main_property.unwrap_or_else(|| format!("<{component} />")); - let expression = if let Some(visible) = visible_property { - format!("{{{visible} && {content}}}") - } else if references.is_some_and(|references| references.contains_key("mainComponent")) { - format!("{{{content}}}") - } else { - content - }; - if view.string("layoutPositioning") == Some("ABSOLUTE") { - let mut props = Vec::new(); - layout::push_layout_props( - snapshot, - node, - "Box", - &mut props, - context.root_layout, - depth == 0, - context.width_policy.as_ref(), - super::image::original_image(node, &context.asset_policies), - ); - props.retain(|(name, value)| { - matches!( - name.as_str(), - "pos" | "left" | "right" | "top" | "bottom" | "transform" | "transformOrigin" - ) || (name == "w" && matches!(value, PropValue::String(value) if value == "100%")) - }); - context.imports.insert("Box".to_owned()); - let indent = " ".repeat(depth); - let (opening_props, multiline_props) = render_props(&props, depth); - let close_open = if multiline_props { - format!("{opening_props}\n{indent}>") - } else { - format!("{opening_props}>") - }; - visiting.remove(&node.id); - return Ok(mark_node( - &node.id, - format!( - "{indent}", - " ".repeat(depth + 1), - expression - ), - )); - } - visiting.remove(&node.id); - return Ok(mark_node( - &node.id, - format!("{}{expression}", " ".repeat(depth)), - )); - } - let asset = super::image::asset_kind(snapshot, node, &context.asset_policies); - if asset.is_some() - && let Some(reason) = style::non_rendering_asset_reason(snapshot, node) - { - // A transparent/clipped asset still occupies layout space. Keep the - // asset sizing policy, but emit no external paint or descendant asset. - let mut props = Vec::new(); - layout::push_layout_props( - snapshot, - node, - "Image", - &mut props, - context.root_layout, - depth == 0, - context.width_policy.as_ref(), - super::image::original_image(node, &context.asset_policies), - ); - layout::string_prop(&mut props, "visibility", "hidden"); - let (opening, multiline) = render_props(&props, depth); - let indent = " ".repeat(depth); - let rendered = if multiline { - format!("{indent}") - } else { - format!("{indent}") - }; - context.imports.insert("Box".into()); - context.diagnostics.push(non_rendering_diagnostic( - node, - reason, - &rendered, - "layout-only", - )); - visiting.remove(&node.id); - return Ok(mark_node(&node.id, rendered)); - } - let inferred_mode = view - .value("inferredAutoLayout") - .and_then(serde_json::Value::as_object) - .and_then(|layout| layout.get("layoutMode")) - .and_then(serde_json::Value::as_str); - let inferred_align = |name: &str| { - view.string(name).or_else(|| { - view.value("inferredAutoLayout") - .and_then(serde_json::Value::as_object) - .and_then(|layout| layout.get(name)) - .and_then(serde_json::Value::as_str) - }) - }; - let component = if asset == Some(style::AssetKind::SvgMask) { - "Box" - } else if asset.is_some() { - "Image" - } else if view.node_type() == "TEXT" { - "Text" - } else if layout::centres_its_only_child(snapshot, node) { - "Center" - } else { - match inferred_mode { - Some("GRID") => "Grid", - Some("HORIZONTAL" | "VERTICAL") - if inferred_align("primaryAxisAlignItems") == Some("CENTER") - && inferred_align("counterAxisAlignItems") == Some("CENTER") => - { - "Center" - } - Some("VERTICAL") => "VStack", - Some("HORIZONTAL") => "Flex", - _ => "Box", - } - }; - context.imports.insert(component.to_owned()); - - let mut props = Vec::new(); - layout::push_layout_props( - snapshot, - node, - component, - &mut props, - context.root_layout, - depth == 0, - context.width_policy.as_ref(), - super::image::original_image(node, &context.asset_policies), - ); - // 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. - // Positioned children, including those of page and embedded roots, are - // anchored by push_layout_props above. This legacy AUTO fallback only - // applies to other frames. - let page_root = snapshot - .nodes - .values() - .find(|candidate| { - candidate - .typed_view() - .child_ids() - .any(|child| child == node.id) - }) - .map(|parent| parent.typed_view().node_type()) - .or_else(|| view.string("parentType")) - .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE" | "COMPONENT_SET")); - if !(depth == 0 && context.root_layout == RootLayout::Embedded) - && !page_root - && asset.is_none() - && view.value("inferredAutoLayout").is_none() - && view.string("layoutPositioning") == Some("AUTO") - && layout::derived_padding(snapshot, node).is_none() - && !layout::centres_its_only_child(snapshot, node) - && view.child_ids().any(|child| { - snapshot - .nodes - .get(child) - .is_some_and(|child| child.typed_view().string("layoutPositioning") == Some("AUTO")) - }) - { - layout::string_prop(&mut props, "pos", "relative"); - } - style::push_style_props( - snapshot, - node, - component, - asset, - &mut props, - &mut context.used_tokens, - style::StyleOptions { - reserve_image_content: !(depth == 0 && context.root_layout == RootLayout::Embedded), - asset_policies: &context.asset_policies, - variable_tokens: &context.variable_tokens, - length_tokens: &context.length_tokens, - asset_names_per_node: context.asset_names_per_node, - width_policy: context.width_policy.as_ref(), - }, - ); - text::validate_line_metrics(&view, &context.text_style_tokens)?; - text::push_text_props( - &view, - &context.text_style_tokens, - &context.typography_values, - &context.variable_tokens, - &mut context.used_tokens, - &mut props, - ); - style::tokenize_lengths( - snapshot, - node, - &mut props, - &context.length_tokens, - &mut context.used_tokens, - ); - // Last, as the plugin merges `getReactionProps` last: what a timed Smart - // Animate changes becomes keyframes on the child it changes, or on the - // frame itself. - let before = props.len(); - animation::push_animation_props(snapshot, node, &context.variable_tokens, &mut props); - if props.len() > before { - context.imports.insert("keyframes".to_owned()); - } - if asset.is_some() { - props.retain(|(name, _)| { - !matches!( - name.as_str(), - "alignItems" - | "justifyContent" - | "flexDir" - | "gap" - | "outline" - | "outlineOffset" - | "overflow" - | "p" - | "px" - | "py" - | "pt" - | "pr" - | "pb" - | "pl" - ) - }); - } - let indent = " ".repeat(depth); - let instance_properties = - (context.inline_instances && view.node_type() == "INSTANCE").then(|| { - view.value("componentProperties") - .and_then(serde_json::Value::as_object) - .map(|properties| { - properties - .iter() - .filter_map(|(key, property)| { - (property.get("type").and_then(serde_json::Value::as_str) - == Some("BOOLEAN")) - .then(|| { - property - .get("value") - .and_then(serde_json::Value::as_bool) - .map(|value| (key.clone(), value)) - }) - .flatten() - }) - .collect::>() - }) - .unwrap_or_default() - }); - if let Some(properties) = instance_properties { - context.instance_boolean_properties.push(properties); - } - let children_result = if asset.is_some() { - Ok(Vec::new()) - } else { - for child in view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .filter(|child| child.typed_view().bool("visible") == Some(false)) - { - context.diagnostics.push(non_rendering_diagnostic( - child, - "visible-false", - "", - "omitted", - )); - } - view.child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .filter(|child| child.typed_view().bool("visible") != Some(false)) - .map(|child| render_node(snapshot, child, depth + 1, context, visiting)) - .collect::, _>>() - }; - if context.inline_instances && view.node_type() == "INSTANCE" { - context.instance_boolean_properties.pop(); - } - let mut children = children_result?; - if asset.is_none() - && component != "Text" - && let Some(layers) = super::image::render_paint_layers( - snapshot, - node, - style::StyleOptions { - reserve_image_content: !(depth == 0 && context.root_layout == RootLayout::Embedded), - asset_policies: &context.asset_policies, - variable_tokens: &context.variable_tokens, - length_tokens: &context.length_tokens, - asset_names_per_node: context.asset_names_per_node, - width_policy: context.width_policy.as_ref(), - }, - &mut context.used_tokens, - depth + 1, - ) - { - props.retain(|(name, _)| { - !matches!( - name.as_str(), - "bg" | "bgBlendMode" | "objectFit" | "objectPos" - ) - }); - if !props.iter().any(|(name, _)| name == "pos") { - layout::string_prop(&mut props, "pos", "relative"); - } - layout::string_prop(&mut props, "isolation", "isolate"); - context.imports.insert("Image".into()); - context.imports.insert("Box".into()); - children.splice(0..0, layers); - } - - let (opening_props, multiline_props) = render_props(&props, depth); - let image_index = super::image::selected_image_index(node, &context.asset_policies); - let affine = if asset == Some(style::AssetKind::Png) && image_index.is_some() { - let mapping = super::image::map_for( - snapshot, - node, - image_index, - &context.asset_policies, - context.asset_names_per_node, - ); - super::image::render_affine_image( - &props, - &mapping, - depth, - !(depth == 0 && context.root_layout == RootLayout::Embedded), - ) - } else { - None - }; - if affine.is_some() { - context.imports.insert("Box".into()); - } - let rendered = if let Some(rendered) = affine { - rendered - } else if component == "Text" { - let children = text::render_text_children( - &view, - &context.text_style_tokens, - &context.typography_values, - &context.variable_tokens, - &mut context.used_tokens, - depth + 1, - ); - let close_open = if multiline_props { - format!("{opening_props}\n{indent}>") - } else { - format!("{opening_props}>") - }; - format!("{indent}") - } else if children.is_empty() { - if multiline_props { - format!("{indent}<{component}{opening_props}\n{indent}/>") - } else { - format!("{indent}<{component}{opening_props} />") - } - } else { - let close_open = if multiline_props { - format!("{opening_props}\n{indent}>") - } else { - format!("{opening_props}>") - }; - format!( - "{indent}<{component}{close_open}\n{}\n{indent}", - children.join("\n") - ) - }; - let rendered = if concrete_visibility == Some(true) - || (view.node_type() == "INSTANCE" && context.inline_instances) - { - rendered - } else { - view.value("componentPropertyReferences") - .and_then(serde_json::Value::as_object) - .and_then(|references| references.get("visible")) - .and_then(serde_json::Value::as_str) - .map(component_property_name) - .map(|property| { - let content = rendered.strip_prefix(&indent).unwrap_or(&rendered); - format!("{indent}{{{property} && {content}}}") - }) - .unwrap_or(rendered) - }; - visiting.remove(&node.id); - Ok(mark_node(&node.id, rendered)) -} - -fn non_rendering_diagnostic(node: &RawNode, reason: &str, source: &str, state: &str) -> Diagnostic { - let view = node.typed_view(); - let field = if view.bool("visible") == Some(false) { - "visible" - } else { - "absoluteRenderBounds" - }; - let confirmed = !node.field_errors.contains_key(field) - && if field == "visible" { - view.bool(field) == Some(false) - } else { - view.value(field).is_some_and(serde_json::Value::is_null) - }; - Diagnostic { - code: "DEVUP_CODEGEN_NON_RENDERING_ASSET".into(), - node_id: Some(node.id.clone()), property: Some("assetReference".into()), - message: if confirmed { "Confirmed non-rendering node accounted for without an asset reference." } else { "Asset paint was omitted, but non-rendering could not be confirmed from collected fields." }.into(), - fidelity_impact: Some(if confirmed { devup_mcp_figma::FidelityImpact::None } else { devup_mcp_figma::FidelityImpact::Approximated }), - details: Some(serde_json::json!({ - "originalValue":{"visible":view.value("visible"),"opacity":view.value("opacity"),"absoluteBoundingBox":view.value("absoluteBoundingBox"),"absoluteRenderBounds":view.value("absoluteRenderBounds")}, - "appliedValue":{"state":state,"generatedSource":source.chars().take(800).collect::(),"sourceTruncated":source.chars().count()>800}, - "classification":if confirmed {"accounted-for-non-rendering"} else {"unverified-non-rendering"},"reason":reason, - "verification":{"state":if confirmed {"accounted-for"} else {"unverified"},"field":field,"fieldPresent":view.value(field).is_some(),"value":view.value(field),"readError":node.field_errors.get(field)}, - "nextAction":if confirmed {"No asset bytes are required for this confirmed non-rendering node. Correct visibility or clipping in Figma if it should render."} else {"Recapture visible and absoluteRenderBounds without field errors; an absent field is not evidence of non-rendering."} - })), - ..Diagnostic::default() - } -} - -/// A style name without a leading group that is only a number. -/// -/// `0/` and `3/` in front of a style name are how a Figma library is made to -/// sort in the picker; they are not part of what the style is called, and the -/// reference does not carry them into the token. A group that names something -/// (`typography/`) is part of the name and stays. -fn named_tokens(result: Option<&UpstreamResult>, collection: &str) -> BTreeMap { - fn visit(value: &serde_json::Value, collection: &str, tokens: &mut BTreeMap) { - if let Some(values) = value.get(collection).and_then(serde_json::Value::as_array) { - for value in values { - if let (Some(id), Some(name)) = ( - value.get("id").and_then(serde_json::Value::as_str), - value.get("name").and_then(serde_json::Value::as_str), - ) { - let token = if collection == "variables" { - variable_token( - name, - value - .get("codeSyntax") - .and_then(serde_json::Value::as_object) - .and_then(|syntax| syntax.get("WEB")) - .and_then(serde_json::Value::as_str), - ) - } else { - // The rule `devup.json` names the style by, so the - // `typography="…"` written here is a key that exists - // there: a leading breakpoint or number says where the - // style applies and is dropped, any other group is - // kept. See `theme::style_token`. - crate::theme::style_token(name).1 - }; - tokens.insert(id.to_owned(), token); - } - } - } - match value { - serde_json::Value::Object(object) => { - object - .values() - .for_each(|value| visit(value, collection, tokens)); - } - serde_json::Value::Array(values) => { - values - .iter() - .for_each(|value| visit(value, collection, tokens)); - } - _ => {} - } - } - let mut tokens = BTreeMap::new(); - if let Some(result) = result { - visit(&result.raw, collection, &mut tokens); - } - tokens -} - -pub(super) fn render_props(props: &[Prop], depth: usize) -> (String, bool) { - if props.is_empty() { - return (String::new(), false); - } - let mut sorted = props.to_vec(); - sorted.sort_by(|left, right| left.0.cmp(&right.0)); - let rendered = sorted - .into_iter() - .map(|(name, value)| match value { - PropValue::String(value) => render_static_attribute(&name, &value), - }) - .collect::>(); - // Five props, or one that spans lines — a `keyframes({...})` — and the - // props go one to a line, which is the plugin's `propsToString` rule. - let multiline = - rendered.len() >= 5 || rendered.iter().any(|attribute| attribute.contains('\n')); - if multiline { - let prefix = " ".repeat(depth + 1); - let padded = rendered - .iter() - .map(|attribute| attribute.replace('\n', &format!("\n{prefix}"))) - .collect::>(); - ( - format!("\n{prefix}{}", padded.join(&format!("\n{prefix}"))), - true, - ) - } else { - (format!(" {}", rendered.join(" ")), false) - } -} - -/// A prop as JSX. A value is a quoted string, except `animationName` holding -/// a `keyframes({...})` call, which is the expression itself — the plugin's -/// `propsToString` makes the same exception, and it is how devup-ui's -/// `keyframes` is meant to be written. -pub(super) fn render_static_attribute(name: &str, value: &str) -> String { - if name == "animationName" && value.starts_with("keyframes(") { - return format!("{name}={{{value}}}"); - } - let mut escaped = String::with_capacity(value.len()); - for character in value.chars() { - match character { - '&' => escaped.push_str("&"), - '"' => escaped.push_str("""), - '<' => escaped.push_str("<"), - '>' => escaped.push_str(">"), - _ => escaped.push(character), - } - } - format!("{name}=\"{escaped}\"") -} - -fn add_fallback_diagnostics(snapshot: &Snapshot, node: &RawNode, context: &mut Context) { - use devup_mcp_figma::FidelityImpact; - - let view = node.typed_view(); - if let Some(diagnostic) = text::whitespace_collapse_diagnostic(&view) { - context.diagnostics.push(diagnostic); - } - let candidates = [ - ( - super::image::original_image(node, &context.asset_policies) - && matches!( - view.node_type(), - "VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION" - ), - "DEVUP_CODEGEN_IMAGE_SHAPE_UNSUPPORTED", - "The original image source does not encode this vector's shape. Use the vector rendition or an explicit CSS mask; source-image rectangle equivalence is unverified.", - FidelityImpact::Lossy, - ), - ( - view.value("fills") - .and_then(serde_json::Value::as_array) - .is_some_and(|fills| { - fills.iter().any(|p| { - p["visible"] != false - && p["type"] - .as_str() - .is_some_and(|t| t.starts_with("GRADIENT_")) - && p["gradientStops"].as_array().is_none_or(|s| s.len() < 2) - }) - }), - "DEVUP_CODEGEN_IMAGE_COMPOSITION_UNSUPPORTED", - "A visible gradient has insufficient stops for CSS. Recapture the missing paint geometry or explicitly flatten the entire subtree; this composition cannot be reconstructed from the collected fields.", - FidelityImpact::Lossy, - ), - ( - style::asset_kind(snapshot, node).is_none() - && view - .value("fills") - .and_then(serde_json::Value::as_array) - .is_some_and(|fills| { - fills.iter().enumerate().any(|(i, p)| { - p["type"] == "IMAGE" - && p["visible"] != false - && !super::image::policy_for( - node, - &format!("{}:fills:{i}", node.id), - &context.asset_policies, - ) - .original() - }) - }) - && (view.child_ids().next().is_some() - || view - .value("fills") - .and_then(serde_json::Value::as_array) - .is_some_and(|fs| fs.iter().filter(|p| p["visible"] != false).count() > 1) - || view - .value("effects") - .and_then(serde_json::Value::as_array) - .is_some_and(|es| es.iter().any(|e| e["visible"] != false))), - "DEVUP_CODEGEN_IMAGE_COMPOSITION_UNSUPPORTED", - "This live layout background references a native node rendition that includes the node's other fills, effects and subtree. CSS/live children can repeat that composition. Review the exported PNG and flatten the entire subtree, or export original fill bytes separately and implement the paint transforms/compositing before use; automatic fidelity is unsupported here.", - FidelityImpact::Lossy, - ), - ( - view.bool("isMask") == Some(true), - "DEVUP_CODEGEN_MASK_FALLBACK", - "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", - "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()) - && !style::effects_are_exact(&view), - "DEVUP_CODEGEN_EFFECT_FALLBACK", - "Some Figma effects may not be converted into computed CSS.", - FidelityImpact::Lossy, - ), - ( - animation::has_unreachable_destination(snapshot, node), - "DEVUP_CODEGEN_ANIMATION_UNREACHABLE", - "A timed Smart Animate points at a frame that was not collected, so no keyframes are written for it.", - FidelityImpact::Lossy, - ), - ]; - for (enabled, code, message, fidelity_impact) in candidates { - if enabled { - context.diagnostics.push(Diagnostic { - code: code.to_owned(), - message: message.to_owned(), - node_id: Some(node.id.clone()), - fidelity_impact: Some(fidelity_impact), - ..Diagnostic::default() - }); - } - } -} - -pub fn normalize_component_name(input: &str) -> String { - let mut result = String::new(); - let mut segment = String::new(); - for character in input.chars().chain(std::iter::once(' ')) { - if character.is_alphanumeric() || character == '_' { - segment.push(character); - continue; - } - if !segment.is_empty() { - let all_ascii_upper = segment - .chars() - .filter(|character| character.is_ascii_alphabetic()) - .all(|character| character.is_ascii_uppercase()); - let mut characters = segment.chars(); - if let Some(first) = characters.next() { - result.extend(first.to_uppercase()); - for rest in characters { - if all_ascii_upper { - result.extend(rest.to_lowercase()); - } else { - result.push(rest); - } - } - } - segment.clear(); - } - } - if result.is_empty() { - return "FigmaComponent".to_owned(); - } - if result - .chars() - .next() - .is_some_and(|character| character.is_numeric()) - { - result.insert(0, '_'); - } - 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 -} +// Source sections share this module to preserve privacy and test names. +include!("component/component_generation.rs"); +include!("component/component_variants.rs"); +include!("component/render_node.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/component/component_generation.rs b/crates/devup-mcp-devup-ui/src/codegen/component/component_generation.rs new file mode 100644 index 00000000..c15adac0 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/component/component_generation.rs @@ -0,0 +1,922 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use devup_mcp_figma::{ + CollectedPayload, DevupError, Diagnostic, DiagnosticSeverity, ErrorCode, RawNode, Snapshot, + UpstreamResult, +}; +use serde::{Deserialize, Serialize}; + +use super::{animation, layout, style, text, variant}; +use crate::provenance::{ + FidelityReport, ProjectionTrace, SourceMap, build_projection_trace, finalize_tsx, mark_node, + validate_fidelity, +}; +use crate::theme::variable_token; +use crate::validation::validate_tsx; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RootLayout { + #[default] + Standalone, + Embedded, +} + +#[derive(Debug, Clone, Default)] +pub struct CodegenOptions { + /// Actual acquired/requested representation per instance-specific asset id. + pub asset_policies: BTreeMap, + pub component_name: Option, + pub include_diagnostics: bool, + pub inline_instances: bool, + pub text_style_tokens: std::collections::BTreeMap, + /// Materialized typography values from the same resources as devup.json. + /// Names alone cannot prove that a local font property is redundant. + pub typography_values: BTreeMap, + pub variable_tokens: std::collections::BTreeMap, + pub root_layout: RootLayout, + /// Name every asset after the node it came from, rather than after its + /// layer. Off by default, which is how the plugin names them: a layer + /// name is the file name, and two nodes named alike share a file. + /// + /// That sharing is a loss wherever the two are not the same picture. A + /// designer names three logos `Logo`, and only one of them can be + /// written; a photograph drawn at three widths is one file, so at two of + /// them the file is the wrong size for the box and the picture is + /// stretched into it. Named per node, each gets a file of its own. + pub asset_names_per_node: bool, + /// Opt-in width-change anchors; `None` keeps the drawn-width output. + pub width_policy: Option, + /// Length tokens of the theme generated from the same variables, by the + /// id of the variable named for them. A length bound to one of those + /// variables is written as its `$token` where the token comes to the + /// drawn value at the width the screen was drawn for. + pub length_tokens: BTreeMap, +} + +/// A `length` token as the theme writes it, in the theme a page shows +/// without `data-theme`: one length, or a responsive array of them. +#[derive(Debug, Clone, PartialEq)] +pub struct LengthToken { + pub token: String, + pub value: serde_json::Value, +} + +impl CodegenOptions { + pub fn with_payload_tokens(self, payload: &CollectedPayload) -> Self { + self.with_resource_results(payload.variables.as_ref(), payload.styles.as_ref()) + } + + /// The two collected resources on their own, for a caller that kept them + /// without the rest of the payload — a capture replayed from a fixture + /// has its variables and styles but no live target, stats or assets to + /// rebuild a `CollectedPayload` around them. + pub fn with_resource_results( + mut self, + variables: Option<&UpstreamResult>, + styles: Option<&UpstreamResult>, + ) -> Self { + self.text_style_tokens = named_tokens(styles, "styles"); + self.variable_tokens = named_tokens(variables, "variables"); + self.length_tokens = length_tokens(variables); + let mut snapshot = variables + .and_then(|result| crate::theme::variable_snapshot_from_result(result).ok()) + .unwrap_or_else(|| { + serde_json::from_value(serde_json::json!({})).expect("empty variable snapshot") + }); + if let Some(styles) = + styles.and_then(|result| crate::theme::variable_snapshot_from_result(result).ok()) + { + for style in styles.styles { + if !snapshot + .styles + .iter() + .any(|existing| existing.id == style.id) + { + snapshot.styles.push(style); + } + } + } + self.typography_values = + crate::theme::generate_devup_json(&snapshot, crate::theme::ThemeScope::File) + .ok() + .and_then(|output| serde_json::from_str::(&output.json).ok()) + .and_then(|value| { + value + .pointer("/theme/typography") + .and_then(serde_json::Value::as_object) + .cloned() + }) + .map(|values| values.into_iter().collect()) + .unwrap_or_default(); + self + } +} + +/// The length tokens the theme of these variables defines, by variable id: +/// the same generator and scope the export's `devupJson` uses, so a token +/// written into the code is the one the theme defines, with its value. +fn length_tokens(variables: Option<&UpstreamResult>) -> BTreeMap { + let Some(snapshot) = + variables.and_then(|result| crate::theme::variable_snapshot_from_result(result).ok()) + else { + return BTreeMap::new(); + }; + let Some(theme) = crate::theme::generate_devup_json(&snapshot, crate::theme::ThemeScope::Node) + .ok() + .and_then(|output| serde_json::from_str::(&output.json).ok()) + else { + return BTreeMap::new(); + }; + // devup-ui's default theme is the first one under `colors`. + let lengths = &theme["theme"]["length"]; + let Some(lengths) = theme["theme"]["colors"] + .as_object() + .and_then(|themes| themes.keys().next()) + .or_else(|| lengths.as_object().and_then(|themes| themes.keys().next())) + .and_then(|name| lengths.get(name)) + .and_then(serde_json::Value::as_object) + else { + return BTreeMap::new(); + }; + snapshot + .variables + .iter() + .chain(&snapshot.used_remote_variables) + .filter_map(|variable| { + let token = variable_token( + &variable.name, + variable.code_syntax.get("WEB").map(String::as_str), + ); + let value = lengths.get(&token)?.clone(); + Some((variable.id.clone(), LengthToken { token, value })) + }) + .collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodegenOutput { + pub tsx: String, + pub imports: Vec, + pub used_tokens: BTreeSet, + pub diagnostics: Vec, + pub source_map: SourceMap, + pub projection_trace: ProjectionTrace, + pub fidelity_report: FidelityReport, + /// The policy the output was generated under, so verification and the + /// width report judge the anchors it actually wrote. + pub width_policy: Option, +} + +pub fn generate_component( + snapshot: &Snapshot, + root_id: &str, + options: &CodegenOptions, +) -> Result { + let generated = generate_node_marked(snapshot, root_id, options)?; + let root = snapshot.nodes.get(root_id).ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Node to convert was not found in the Figma snapshot.", + false, + ) + })?; + let component_name = options + .component_name + .as_deref() + .map(normalize_component_name) + .unwrap_or_else(|| normalize_component_name(root.typed_view().name().unwrap_or(""))); + let body = generated + .tsx + .lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n"); + let mut tsx = format!( + "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" + )); + finalize_codegen_output( + CodegenOutput { tsx, ..generated }, + snapshot, + options, + root_id, + ) +} + +pub fn generate_legacy_component( + snapshot: &Snapshot, + root_id: &str, + options: &CodegenOptions, +) -> Result { + let generated = generate_node_marked(snapshot, root_id, options)?; + let root = snapshot.nodes.get(root_id).ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Node to convert was not found in the Figma snapshot.", + false, + ) + })?; + let component_name = options + .component_name + .as_deref() + .map(legacy_component_name) + .unwrap_or_else(|| legacy_component_name(root.typed_view().name().unwrap_or(""))); + let body = if generated.tsx.contains('\n') { + let indented = generated + .tsx + .lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n"); + format!("(\n{indented}\n )") + } else { + generated.tsx.clone() + }; + finalize_codegen_output( + CodegenOutput { + tsx: format!("export function {component_name}() {{\n return {body}\n}}"), + ..generated + }, + snapshot, + options, + root_id, + ) +} + +pub fn render_component_source( + component: &str, + code: &str, + variants: &[(String, String)], +) -> String { + let variants = variants + .iter() + .filter(|(key, _)| !key.eq_ignore_ascii_case("effect")) + .collect::>(); + let wrapped = if code.contains('\n') { + format!( + "(\n{}\n )", + code.lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n") + ) + } else { + code.split_whitespace().collect::>().join(" ") + }; + if variants.is_empty() { + return format!("export function {component}() {{\n return {wrapped}\n}}"); + } + let interface = variants + .iter() + .map(|(key, value)| { + let optional = if value == "boolean" { "?" } else { "" }; + format!(" {key}{optional}: {value}") + }) + .collect::>() + .join("\n"); + let keys = variants + .iter() + .map(|(key, _)| key.as_str()) + .collect::>() + .join(", "); + format!( + "export interface {component}Props {{\n{interface}\n}}\n\nexport function {component}({{ {keys} }}: {component}Props) {{\n return {wrapped}\n}}" + ) +} + +pub fn generate_component_set_target( + snapshot: &Snapshot, + root_id: &str, + target_name: &str, + options: &CodegenOptions, +) -> Result { + let root = snapshot.nodes.get(root_id).ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Component set was not found in the Figma snapshot.", + false, + ) + })?; + let component_set = if root.typed_view().node_type() == "COMPONENT_SET" + && root.typed_view().name() == Some(target_name) + { + Some(root) + } else { + snapshot.nodes.values().find(|node| { + node.typed_view().node_type() == "COMPONENT_SET" + && node.typed_view().name() == Some(target_name) + }) + }; + let root_view = component_set.unwrap_or(root).typed_view(); + let main = component_set.is_some(); + if let Some(set) = component_set + && let Some(output) = variant::generate_variant_component_set(snapshot, &set.id, options)? + { + return finalize_codegen_output(output, snapshot, options, &set.id); + } + let target_id = if main { + let default_name = root_view + .value("defaultVariant") + .and_then(|value| value.get("name")) + .and_then(serde_json::Value::as_str); + root_view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .find(|node| node.typed_view().name() == default_name) + .or_else(|| { + root_view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .next() + }) + .map(|node| node.id.as_str()) + } else { + snapshot + .nodes + .values() + .find(|node| { + node.typed_view().node_type() == "COMPONENT" + && node.typed_view().name() == Some(target_name) + }) + .map(|node| node.id.as_str()) + } + .ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + format!("Output '{target_name}' was not found in the component set."), + false, + ) + })?; + + let (generated, provenance_root_id) = if main { + match generate_component_asset_child(snapshot, target_id, options) { + Some(output) => output?, + None => ( + generate_node_marked(snapshot, target_id, options)?, + target_id.to_owned(), + ), + } + } else { + ( + generate_node_marked(snapshot, target_id, options)?, + target_id.to_owned(), + ) + }; + let code = if !main && generated.tsx.contains("") { + generated.tsx.replace("", "") + } else { + generated.tsx.clone() + }; + let variants = if main { + component_variants( + snapshot, + component_set.expect("main component set"), + target_id, + ) + } else { + Vec::new() + }; + finalize_codegen_output( + CodegenOutput { + tsx: render_component_source(&legacy_component_name(target_name), &code, &variants), + ..generated + }, + snapshot, + options, + &provenance_root_id, + ) +} + +pub fn generate_inlined_component_instance( + snapshot: &Snapshot, + root_id: &str, + instance_id: &str, + options: &CodegenOptions, +) -> Result { + let root = snapshot.nodes.get(root_id).ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Inline instance root was not found.", + false, + ) + })?; + let instance = snapshot.nodes.get(instance_id).ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Component instance to inline was not found.", + false, + ) + })?; + let name = instance.typed_view().name().unwrap_or("Component"); + let selected_variants = instance + .typed_view() + .value("componentProperties") + .and_then(serde_json::Value::as_object) + .map(|properties| { + properties + .iter() + .filter_map(|(key, value)| { + (value.get("type").and_then(serde_json::Value::as_str) == Some("VARIANT")) + .then(|| value.get("value").and_then(serde_json::Value::as_str)) + .flatten() + .map(|value| (key.as_str(), value)) + }) + .collect::>() + }) + .unwrap_or_default(); + let set = snapshot + .nodes + .values() + .find(|node| { + node.typed_view().node_type() == "COMPONENT_SET" + && node.typed_view().name() == Some(name) + }) + .ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + format!("Component set '{name}' was not found."), + false, + ) + })?; + let selected = set + .typed_view() + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .find(|candidate| { + selected_variants.iter().all(|(key, value)| { + candidate + .typed_view() + .value("variantProperties") + .and_then(|variants| variants.get(*key)) + .and_then(serde_json::Value::as_str) + == Some(*value) + }) + }) + .ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + format!("Instance variant '{name}' was not found."), + false, + ) + })?; + let mut projected = snapshot.clone(); + if let Some(projected_set) = projected.nodes.get_mut(&set.id) + && let Some(children) = projected_set + .fields + .get_mut("childrenIds") + .and_then(serde_json::Value::as_array_mut) + { + children.retain(|id| id.as_str() != Some(&selected.id)); + } + if let Some(projected_selected) = projected.nodes.get_mut(&selected.id) { + for field in ["layoutSizingHorizontal", "layoutSizingVertical"] { + projected_selected.fields.insert( + field.to_owned(), + serde_json::Value::String("FIXED".to_owned()), + ); + } + } + let mut output = generate_node_marked(&projected, &selected.id, options)?; + if let Some(close) = output.tsx.rfind("\n/>") { + let suffix = output.tsx[close + 3..].to_owned(); + let mut lines = output.tsx[..close] + .lines() + .map(str::to_owned) + .collect::>(); + for (field, prop) in [("height", "h"), ("width", "w")] { + if let Some(value) = root.typed_view().number(field) { + lines.push(format!(" {prop}=\"{}\"", layout::px(value))); + } + } + lines[1..].sort(); + output.tsx = format!("{}\n/>{suffix}", lines.join("\n")); + } + let usage = selected_variants + .iter() + .map(|(key, value)| format!(" {key}=\"{value}\"")) + .collect::(); + output.tsx = format!("{{/* <{name}{usage} /> */}}\n{}", output.tsx); + finalize_codegen_output(output, &projected, options, &selected.id) +} + +fn generate_component_asset_child( + snapshot: &Snapshot, + component_id: &str, + options: &CodegenOptions, +) -> Option> { + let component = snapshot.nodes.get(component_id)?; + let children = component.typed_view().child_ids().collect::>(); + if children.len() != 1 { + return None; + } + let child_id = children[0]; + let child = snapshot.nodes.get(child_id)?; + style::asset_kind(snapshot, child)?; + let mut projected = snapshot.clone(); + let projected_child = projected.nodes.get_mut(child_id)?; + for dimension in ["width", "height"] { + if projected_child.typed_view().number(dimension).is_none() + && let Some(value) = component.typed_view().number(dimension) + { + projected_child + .fields + .insert(dimension.to_owned(), serde_json::Value::from(value)); + } + } + projected_child.fields.insert( + "layoutSizingHorizontal".to_owned(), + serde_json::Value::String("FIXED".to_owned()), + ); + projected_child.fields.insert( + "layoutSizingVertical".to_owned(), + serde_json::Value::String("FIXED".to_owned()), + ); + Some( + generate_node_marked(&projected, child_id, options) + .map(|output| (output, child_id.to_owned())), + ) +} + +pub fn render_component_registration_snapshot( + snapshot: &Snapshot, + root_id: &str, + target_name: &str, +) -> Result { + let root = snapshot.nodes.get(root_id).ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Component registration root was not found.", + false, + ) + })?; + let set = if root.typed_view().node_type() == "COMPONENT_SET" { + Some(root) + } else { + snapshot + .nodes + .values() + .find(|node| node.typed_view().node_type() == "COMPONENT_SET") + }; + let selected = if root.typed_view().node_type() == "COMPONENT" { + root + } else { + root.typed_view() + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .find(|node| node.typed_view().name() == Some(target_name)) + .ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + format!("Registration target '{target_name}' was not found."), + false, + ) + })? + }; + let name = set + .and_then(|set| set.typed_view().name()) + .or_else(|| selected.typed_view().name()) + .unwrap_or("Component"); + let selected_is_set_child = set.is_some_and(|set| { + set.typed_view() + .child_ids() + .any(|child| child == selected.id) + }); + let node = registration_node(snapshot, selected, set, selected_is_set_child, 1); + let variants = set + .map(|set| component_variants(snapshot, set, &selected.id)) + .unwrap_or_default() + .into_iter() + .filter(|(key, _)| !key.eq_ignore_ascii_case("effect")) + .collect::>(); + let variants = if variants.is_empty() { + "{}".to_owned() + } else { + let lines = variants + .iter() + .map(|(key, value)| format!(" {}: {},", json_string(key), json_string(value))) + .collect::>() + .join("\n"); + format!("{{\n{lines}\n }}") + }; + let box_size = set.is_none(); + let mut props = Vec::new(); + if let Some(set) = set + && selected + .typed_view() + .value("reactions") + .and_then(serde_json::Value::as_array) + .is_some_and(|reactions| { + reactions.iter().any(|reaction| { + reaction + .get("trigger") + .and_then(|trigger| trigger.get("type")) + .and_then(serde_json::Value::as_str) + == Some("ON_HOVER") + }) + }) + && let Some(hover) = set + .typed_view() + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .find(|node| node.id != selected.id && node.typed_view().number("opacity").is_some()) + && let Some(opacity) = hover.typed_view().number("opacity") + { + props.extend([ + " \"_hover\": {".to_owned(), + format!( + " \"opacity\": {},", + json_string(&layout::format_number(opacity)) + ), + " },".to_owned(), + " \"aspectRatio\": undefined,".to_owned(), + " \"flex\": undefined,".to_owned(), + " \"h\": undefined,".to_owned(), + " \"maxH\": undefined,".to_owned(), + " \"maxW\": undefined,".to_owned(), + " \"minH\": undefined,".to_owned(), + " \"minW\": undefined,".to_owned(), + " \"transition\": \"0.3ms ease-in-out\",".to_owned(), + " \"transitionProperty\": \"opacity\",".to_owned(), + " \"w\": undefined,".to_owned(), + ]); + } else { + props.push(" \"aspectRatio\": undefined,".to_owned()); + } + if !props.iter().any(|prop| prop.contains("\"_hover\"")) { + if box_size { + props.push(" \"boxSize\": \"100%\",".to_owned()); + } + props.push(" \"flex\": undefined,".to_owned()); + if !box_size { + props.push(" \"h\": undefined,".to_owned()); + } + props.extend([ + " \"maxH\": undefined,".to_owned(), + " \"maxW\": undefined,".to_owned(), + " \"minH\": undefined,".to_owned(), + " \"minW\": undefined,".to_owned(), + ]); + if !box_size { + props.push(" \"w\": undefined,".to_owned()); + } + } + Ok(format!( + include_str!("../../text/component-render_component_registration_snapshot-dcc3fe0a.md"), + json_string(name), + node, + json_string(selected.typed_view().name().unwrap_or(target_name)), + props.join("\n"), + variants + )) +} + +fn registration_node( + snapshot: &Snapshot, + node: &RawNode, + parent: Option<&RawNode>, + circular_children: bool, + depth: usize, +) -> String { + let view = node.typed_view(); + let indent = " ".repeat(depth); + let child_indent = " ".repeat(depth + 1); + let mut entries = Vec::new(); + entries.push(format!("{child_indent}\"children\": [],")); + entries.push(format!( + "{child_indent}\"name\": {},", + json_string(view.name().unwrap_or("")) + )); + if let Some(opacity) = view.number("opacity") { + entries.push(format!( + "{child_indent}\"opacity\": {},", + layout::format_number(opacity) + )); + } + if let Some(parent) = parent { + entries.push(format!( + "{child_indent}\"parent\": {},", + registration_parent(snapshot, parent, node, circular_children, depth + 1) + )); + } + if let Some(value) = view.value("reactions") { + entries.push(format!( + "{child_indent}\"reactions\": {},", + js_value(value, depth + 1) + )); + } + entries.push(format!( + "{child_indent}\"type\": {},", + json_string(view.node_type()) + )); + if let Some(value) = view.value("variantProperties") { + entries.push(format!( + "{child_indent}\"variantProperties\": {},", + js_value(value, depth + 1) + )); + } + if let Some(visible) = view.bool("visible") { + entries.push(format!("{child_indent}\"visible\": {visible},")); + } + format!("{{\n{}\n{indent}}}", entries.join("\n")) +} + +fn registration_parent( + snapshot: &Snapshot, + parent: &RawNode, + selected: &RawNode, + circular_children: bool, + depth: usize, +) -> String { + let view = parent.typed_view(); + let indent = " ".repeat(depth); + let child_indent = " ".repeat(depth + 1); + let children_rendered = if view.child_ids().next().is_none() { + "[]".to_owned() + } else { + let values = view + .child_ids() + .filter_map(|id| { + if id == selected.id { + Some("[Circular]".to_owned()) + } else { + snapshot + .nodes + .get(id) + .map(|child| registration_child(child, circular_children, depth + 2)) + } + }) + .collect::>(); + if values.is_empty() { + "[]".to_owned() + } else { + format!( + "[\n{}\n{child_indent}]", + values + .iter() + .map(|value| format!("{}{},", " ".repeat(depth + 2), value)) + .collect::>() + .join("\n") + ) + } + }; + let mut entries = vec![format!("{child_indent}\"children\": {children_rendered},")]; + for field in ["componentPropertyDefinitions", "defaultVariant"] { + if let Some(value) = view.value(field) { + let rendered = if field == "defaultVariant" + && value.get("name").and_then(serde_json::Value::as_str) + == selected.typed_view().name() + && circular_children + { + "[Circular]".to_owned() + } else if field == "defaultVariant" { + registration_embedded_node(value, depth + 1) + } else { + js_value(value, depth + 1) + }; + entries.push(format!("{child_indent}{}: {rendered},", json_string(field))); + } + } + entries.push(format!( + "{child_indent}\"name\": {},", + json_string(view.name().unwrap_or("")) + )); + entries.push(format!( + "{child_indent}\"type\": {},", + json_string(view.node_type()) + )); + if let Some(visible) = view.bool("visible") { + entries.push(format!("{child_indent}\"visible\": {visible},")); + } + format!("{{\n{}\n{indent}}}", entries.join("\n")) +} + +fn registration_child(node: &RawNode, circular_parent: bool, depth: usize) -> String { + let view = node.typed_view(); + let indent = " ".repeat(depth); + let child_indent = " ".repeat(depth + 1); + let mut entries = vec![ + format!("{child_indent}\"children\": [],"), + format!( + "{child_indent}\"name\": {},", + json_string(view.name().unwrap_or("")) + ), + ]; + if let Some(opacity) = view.number("opacity") { + entries.push(format!( + "{child_indent}\"opacity\": {},", + layout::format_number(opacity) + )); + } + if circular_parent { + entries.push(format!("{child_indent}\"parent\": [Circular],")); + } + if let Some(value) = view.value("reactions") { + entries.push(format!( + "{child_indent}\"reactions\": {},", + js_value(value, depth + 1) + )); + } + entries.push(format!( + "{child_indent}\"type\": {},", + json_string(view.node_type()) + )); + if let Some(value) = view.value("variantProperties") { + entries.push(format!( + "{child_indent}\"variantProperties\": {},", + js_value(value, depth + 1) + )); + } + if let Some(visible) = view.bool("visible") { + entries.push(format!("{child_indent}\"visible\": {visible},")); + } + format!("{{\n{}\n{indent}}}", entries.join("\n")) +} + +fn registration_embedded_node(value: &serde_json::Value, depth: usize) -> String { + let Some(object) = value.as_object() else { + return js_value(value, depth); + }; + let indent = " ".repeat(depth); + let child_indent = " ".repeat(depth + 1); + let mut entries = vec![format!("{child_indent}\"children\": [],")]; + for field in [ + "name", + "opacity", + "reactions", + "type", + "variantProperties", + "visible", + ] { + if let Some(value) = object.get(field) { + entries.push(format!( + "{child_indent}{}: {},", + json_string(field), + js_value(value, depth + 1) + )); + } + } + format!("{{\n{}\n{indent}}}", entries.join("\n")) +} + +fn js_value(value: &serde_json::Value, depth: usize) -> String { + match value { + serde_json::Value::Null => "null".to_owned(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::String(value) => json_string(value), + serde_json::Value::Array(values) => { + if values.is_empty() { + "[]".to_owned() + } else { + let indent = " ".repeat(depth); + let child_indent = " ".repeat(depth + 1); + format!( + "[\n{}\n{indent}]", + values + .iter() + .map(|value| format!("{child_indent}{},", js_value(value, depth + 1))) + .collect::>() + .join("\n") + ) + } + } + serde_json::Value::Object(values) => { + if values.is_empty() { + "{}".to_owned() + } else { + let indent = " ".repeat(depth); + let child_indent = " ".repeat(depth + 1); + format!( + "{{\n{}\n{indent}}}", + values + .iter() + .map(|(key, value)| format!( + "{child_indent}{}: {},", + json_string(key), + js_value(value, depth + 1) + )) + .collect::>() + .join("\n") + ) + } + } + } +} + +fn json_string(value: &str) -> String { + serde_json::to_string(value).expect("string JSON serialization") +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/codegen/component/component_variants.rs b/crates/devup-mcp-devup-ui/src/codegen/component/component_variants.rs new file mode 100644 index 00000000..ed506132 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/component/component_variants.rs @@ -0,0 +1,558 @@ + + +fn component_variants( + snapshot: &Snapshot, + root: &RawNode, + default_id: &str, +) -> Vec<(String, String)> { + let definitions = root + .typed_view() + .value("componentPropertyDefinitions") + .and_then(serde_json::Value::as_object); + let mut variants = Vec::new(); + let mut added = BTreeSet::new(); + let add = |raw_key: &str, + definition: &serde_json::Value, + variants: &mut Vec<(String, String)>, + added: &mut BTreeSet| { + let key = component_property_name(raw_key); + if added.contains(&key) { + return; + } + let value = match definition.get("type").and_then(serde_json::Value::as_str) { + Some("VARIANT") => definition + .get("variantOptions") + .and_then(serde_json::Value::as_array) + .map(|options| { + options + .iter() + .filter_map(serde_json::Value::as_str) + .map(|option| format!("'{option}'")) + .collect::>() + .join(" | ") + }), + Some("BOOLEAN") => Some("boolean".to_owned()), + Some("INSTANCE_SWAP") => Some("React.ReactNode".to_owned()), + _ => None, + }; + if let Some(value) = value { + added.insert(key.clone()); + variants.push((key, value)); + } + }; + if let Some(definitions) = definitions { + for (key, definition) in definitions { + if definition.get("type").and_then(serde_json::Value::as_str) == Some("VARIANT") { + add(key, definition, &mut variants, &mut added); + } + } + fn visit_refs( + snapshot: &Snapshot, + id: &str, + definitions: &serde_json::Map, + variants: &mut Vec<(String, String)>, + added: &mut BTreeSet, + add: &impl Fn(&str, &serde_json::Value, &mut Vec<(String, String)>, &mut BTreeSet), + ) { + let Some(node) = snapshot.nodes.get(id) else { + return; + }; + let view = node.typed_view(); + if let Some(references) = view + .value("componentPropertyReferences") + .and_then(serde_json::Value::as_object) + { + for field in ["mainComponent", "visible"] { + if let Some(key) = references.get(field).and_then(serde_json::Value::as_str) + && let Some(definition) = definitions.get(key) + { + add(key, definition, variants, added); + } + } + } + for child in view.child_ids() { + visit_refs(snapshot, child, definitions, variants, added, add); + } + } + visit_refs( + snapshot, + default_id, + definitions, + &mut variants, + &mut added, + &add, + ); + for (key, definition) in definitions { + add(key, definition, &mut variants, &mut added); + } + } + variants +} + +fn component_property_name(raw: &str) -> String { + raw.split('#').next().unwrap_or(raw).to_owned() +} + +pub(super) fn legacy_component_name(input: &str) -> String { + if !input.is_empty() + && input + .chars() + .all(|character| !character.is_alphabetic() || character.is_uppercase()) + { + let mut characters = input.chars(); + if let Some(first) = characters.next() { + let candidate = first + .to_uppercase() + .chain(characters.flat_map(char::to_lowercase)) + .collect::(); + return sanitize_legacy_identifier(&candidate); + } + } + let mut output = String::new(); + for part in input + .split(['-', '_', '/', ' ']) + .filter(|part| !part.is_empty()) + { + let acronym = part + .chars() + .filter(|character| character.is_alphabetic()) + .all(|character| character.is_uppercase()); + let normalized = if acronym { + part.to_lowercase() + } else { + part.to_owned() + }; + let mut characters = normalized.chars(); + if let Some(first) = characters.next() { + output.extend(first.to_uppercase()); + output.extend(characters); + } + } + sanitize_legacy_identifier(&output) +} + +fn sanitize_legacy_identifier(candidate: &str) -> String { + let mut output = candidate + .chars() + .filter(|character| character.is_alphanumeric() || *character == '_') + .collect::(); + if output.is_empty() { + return "FigmaComponent".to_owned(); + } + if output + .chars() + .next() + .is_some_and(|character| character.is_numeric()) + { + output.insert(0, '_'); + } + output +} + +pub fn generate_node( + snapshot: &Snapshot, + root_id: &str, + options: &CodegenOptions, +) -> Result { + finalize_codegen_output( + generate_node_marked(snapshot, root_id, options)?, + snapshot, + options, + root_id, + ) +} + +fn generate_node_marked( + snapshot: &Snapshot, + root_id: &str, + options: &CodegenOptions, +) -> Result { + let root = snapshot.nodes.get(root_id).ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Node to convert was not found in the Figma snapshot.", + false, + ) + })?; + let render_root = if root.typed_view().node_type() == "SECTION" { + root.typed_view() + .child_ids() + .next() + .and_then(|id| snapshot.nodes.get(id)) + .unwrap_or(root) + } else { + root + }; + let mut context = Context { + asset_policies: options.asset_policies.clone(), + asset_names_per_node: options.asset_names_per_node, + inline_instances: options.inline_instances, + text_style_tokens: options.text_style_tokens.clone(), + typography_values: options.typography_values.clone(), + variable_tokens: options.variable_tokens.clone(), + length_tokens: options.length_tokens.clone(), + root_layout: options.root_layout, + width_policy: options.width_policy.clone(), + ..Context::default() + }; + let jsx = render_node(snapshot, render_root, 0, &mut context, &mut HashSet::new())?; + let imports = context.imports.iter().cloned().collect::>(); + Ok(CodegenOutput { + tsx: jsx, + imports, + used_tokens: context.used_tokens, + diagnostics: context.diagnostics, + source_map: SourceMap::empty(), + projection_trace: ProjectionTrace::default(), + fidelity_report: FidelityReport::default(), + width_policy: options.width_policy.clone(), + }) +} + +fn finalize_codegen_output( + mut output: CodegenOutput, + snapshot: &Snapshot, + options: &CodegenOptions, + root_id: &str, +) -> Result { + let (tsx, source_map) = finalize_tsx( + &output.tsx, + snapshot, + &options.variable_tokens, + &options.text_style_tokens, + ); + output.tsx = tsx; + output.source_map = source_map; + super::image::attach_evidence(snapshot, options, &mut output); + crate::provenance::account_for_sizing(snapshot, &mut output); + validate_tsx(&output.tsx)?; + if let Some(contract) = super::evidence::placement_contract(snapshot, &output, options, root_id) + { + output.diagnostics.push(contract); + } + // Background and variant projections share paint suppression in style; + // attach evidence for those paths as well as ordinary asset leaves. + for entry in output + .source_map + .entries + .iter() + .filter(|e| e.property.is_none() && e.resolution == "node") + { + let Some(node) = entry.node_id.as_ref().and_then(|id| snapshot.nodes.get(id)) else { + continue; + }; + let Some(reason) = style::non_rendering_asset_reason(snapshot, node) else { + continue; + }; + if output.diagnostics.iter().any(|d| { + d.code == "DEVUP_CODEGEN_NON_RENDERING_ASSET" && d.node_id.as_deref() == Some(&node.id) + }) { + continue; + } + let Some(source) = entry + .generated_range + .as_ref() + .and_then(|r| output.tsx.get(r.start..r.end)) + .filter(|s| !s.is_empty()) + else { + continue; + }; + output.diagnostics.push(non_rendering_diagnostic( + node, + reason, + source, + "layout-only", + )); + } + output + .diagnostics + .extend(unresolved_token_bindings(snapshot, root_id, options)); + output.projection_trace = + build_projection_trace(snapshot, root_id, &output.tsx, &output.source_map); + output.fidelity_report = validate_fidelity(snapshot, root_id, &output)?; + output + .diagnostics + .retain(|d| d.code != "DEVUP_CODEGEN_LAYOUT_UNCOVERED"); + // A coverage shortfall has no proven intentional-exclusion classification. + // Report the actual field instead of allowing an exact quality grade. + for pair in &output.fidelity_report.uncovered_layout { + if let Some((node_id, property)) = pair.rsplit_once('#') { + output.diagnostics.push(Diagnostic { + code: "DEVUP_CODEGEN_LAYOUT_UNCOVERED".into(), + message: "No verified property mapping accounts for this layout field; generated evidence describes what was emitted.".into(), + node_id: Some(node_id.into()), + property: Some(property.into()), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), + details: Some(super::evidence::uncovered_layout_details(snapshot, &output, options, node_id, property)), + ..Diagnostic::default() + }); + } + } + let absolute_components = output + .source_map + .entries + .iter() + .filter(|e| e.property.is_none()) + .filter_map(|e| e.node_id.as_deref()) + .filter(|id| { + snapshot.nodes.get(*id).is_some_and(|n| { + n.typed_view().string("layoutPositioning") == Some("ABSOLUTE") + && n.typed_view().bool("visible") != Some(false) + }) + }) + .map(|id| { + ( + id.to_owned(), + crate::provenance::absolute_component_verification(snapshot, &output, id), + ) + }) + .collect::>(); + // Every emitted ABSOLUTE node receives the same generated-property audit. + for id in absolute_components.keys() { + if !output.diagnostics.iter().any(|d| { + d.code == "DEVUP_CODEGEN_ABSOLUTE_FALLBACK" && d.node_id.as_deref() == Some(id) + }) { + output.diagnostics.push(Diagnostic { + code: "DEVUP_CODEGEN_ABSOLUTE_FALLBACK".into(), + node_id: Some(id.clone()), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Approximated), + ..Diagnostic::default() + }); + } + } + for diagnostic in &mut output.diagnostics { + let property = match diagnostic.code.as_str() { + "DEVUP_CODEGEN_ABSOLUTE_FALLBACK" => Some("layoutPositioning"), + "DEVUP_CODEGEN_MASK_FALLBACK" => Some("isMask"), + "DEVUP_CODEGEN_EFFECT_FALLBACK" => Some("effects"), + "DEVUP_CODEGEN_IMAGE_COMPOSITION_UNSUPPORTED" => Some("fills"), + "DEVUP_CODEGEN_ANIMATION_UNREACHABLE" => Some("reactions"), + "DEVUP_CODEGEN_VARIANT_CHILD_FALLBACK" => Some("childrenIds"), + "DEVUP_CODEGEN_TOKEN_NAME_UNRESOLVED" => diagnostic.property.as_deref(), + _ => None, + }; + if let Some(property) = property.map(str::to_owned) { + diagnostic.property = Some(property.clone()); + let node_id = diagnostic.node_id.as_deref().unwrap_or(root_id); + let generated = output + .source_map + .entries + .iter() + .filter(|entry| { + entry.node_id.as_deref() == Some(node_id) && entry.property.is_none() + }) + .filter_map(|entry| entry.generated_range.as_ref()) + .filter_map(|range| output.tsx.get(range.start..range.end)) + .collect::>(); + diagnostic.details = Some(serde_json::json!({ + "originalValue": super::evidence::fallback_original(snapshot, node_id, &property), + "originalResourceId": diagnostic.resource_id, + "appliedValue": {"generatedSource": generated, "fallback": diagnostic.fallback, + "heightPreservation": if property == "layoutPositioning" { + snapshot.nodes.get(node_id).map(|node| { + let view = node.typed_view(); + let expected = view.number("height").map(|h|format!("{}px",layout::format_number(h))); + let preserved = expected.as_ref().is_some_and(|height|generated.iter().any(|source| { + let tag = source.split('>').next().unwrap_or(source); + tag.contains(&format!("h=\"{height}\"")) || tag.contains(&format!("boxSize=\"{height}\"")) + })); + serde_json::json!({"sourceSizing":view.string("layoutSizingVertical"),"sourceHeight":view.number("height"), + "state":if view.string("layoutSizingVertical") != Some("FIXED") {"not-fixed"} else if preserved {"preserved"} else {"unverified"}, + "basis":"Generated explicit height; derived padding alone is not a fixed-height guarantee."}) + }) + } else {None}, + "derivedPadding": if property == "layoutPositioning" { + snapshot.nodes.get(node_id).and_then(|node|layout::derived_padding(snapshot,node)) + .map(|[top,right,bottom,left]|serde_json::json!({"top":top,"right":right,"bottom":bottom,"left":left, + "basis":"Generator-derived inset from visible child geometry; not the source padding fields."})) + } else { None }}, + "evidenceLimit": "Generated source and collected geometry are comparison evidence, not measured browser layout or proof of responsive equivalence.", + "stage": "projection" + })); + if property == "layoutPositioning" + && let Some(components) = absolute_components.get(node_id) + { + let unresolved = ["height","width","horizontal","vertical"].into_iter() + .filter(|name|components[*name]["fidelityImpact"] != "none") + .map(|name|serde_json::json!({"component":name,"condition":components[name]["resolutionCondition"]})) + .collect::>(); + let verified = unresolved.is_empty(); + diagnostic.message = if verified {"Absolute dimensions and constraint props verified against collected geometry."} else {"Absolute conversion is partially verified; components identify preserved values and remaining approximations."}.into(); + if verified { + diagnostic.code = "DEVUP_CODEGEN_ABSOLUTE_VERIFIED".into(); + diagnostic.fidelity_impact = Some(devup_mcp_figma::FidelityImpact::None); + } + let details = diagnostic.details.as_mut().unwrap(); + details["components"] = components.clone(); + details["appliedValue"]["widthPreservation"] = components["width"].clone(); + details["componentSummary"] = serde_json::json!({ + "verified": (["height", "width", "horizontal", "vertical"].into_iter() + .filter(|name| components[*name]["fidelityImpact"] == "none").collect::>()), + "unresolved": (["height", "width", "horizontal", "vertical"].into_iter() + .filter(|name| components[*name]["fidelityImpact"] != "none").collect::>()) + }); + details["resolutionConditions"] = serde_json::json!(unresolved); + details["classification"] = serde_json::json!(if verified { + "verified-absolute-components" + } else { + "partially-verified-absolute-components" + }); + } + } + } + output.source_map.describe_properties(&output.tsx); + output.source_map.describe_conversions(snapshot); + crate::provenance::attributes::audit_properties(snapshot, &mut output, options, root_id); + output + .diagnostics + .extend(super::evidence::image_layout_diagnostics(snapshot, &output)); + output.fidelity_report = validate_fidelity(snapshot, root_id, &output)?; + Ok(output) +} + +/// Reports every binding the generated code had to write out as a value. +/// +/// A fill bound to a variable, or a text carrying a style, is the design +/// saying "this is a token". The generator writes the token when the resource +/// catalog carried that variable or style, and the resolved value when it did +/// not - and it has to write something, because the module still has to +/// compile and render. What it must not do is stay quiet about it: a +/// hardcoded `#7d7f83` where the design says `$caption` renders identically +/// today and stops following the theme tomorrow, and a caller reading a +/// response graded `exact` has no way to know one is in there. +/// +/// Run as a pass over the collected subtree rather than inside rendering, so +/// it sees the node a binding belongs to and needs no argument threaded +/// through the render functions to reach it. +fn unresolved_token_bindings( + snapshot: &Snapshot, + root_id: &str, + options: &CodegenOptions, +) -> Vec { + fn paint_variable_ids(fills: Option<&serde_json::Value>) -> Vec { + fills + .and_then(serde_json::Value::as_array) + .map(|paints| { + paints + .iter() + .filter_map(|paint| { + Some( + paint + .get("boundVariables")? + .get("color")? + .get("id")? + .as_str()? + .to_owned(), + ) + }) + .collect() + }) + .unwrap_or_default() + } + + let mut reported = BTreeSet::new(); + let mut diagnostics = Vec::new(); + let mut pending = vec![root_id.to_owned()]; + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id.clone()) { + continue; + } + let Some(node) = snapshot.nodes.get(&id) else { + continue; + }; + let view = node.typed_view(); + if view.bool("visible") == Some(false) && !node.field_errors.contains_key("visible") { + // No paint or typography token is required by an omitted subtree. + continue; + } + pending.extend(view.child_ids().map(str::to_owned)); + + let mut lost = Vec::new(); + for variable_id in paint_variable_ids(view.value("fills")) { + if !options.variable_tokens.contains_key(&variable_id) { + lost.push(("variable", "fills", variable_id)); + } + } + if let Some(segments) = view + .value("styledTextSegments") + .and_then(serde_json::Value::as_array) + { + for segment in segments { + for variable_id in paint_variable_ids(segment.get("fills")) { + if !options.variable_tokens.contains_key(&variable_id) { + lost.push(("variable", "fills", variable_id)); + } + } + if let Some(style_id) = segment + .get("textStyleId") + .and_then(serde_json::Value::as_str) + && !style_id.is_empty() + && !options.text_style_tokens.contains_key(style_id) + { + lost.push(("textStyle", "textStyleId", style_id.to_owned())); + } + } + } + if let Some(style_id) = view.string("textStyleId") + && !style_id.is_empty() + && !options.text_style_tokens.contains_key(style_id) + { + lost.push(("textStyle", "textStyleId", style_id.to_owned())); + } + + for (kind, property, resource_id) in lost { + if !reported.insert((id.clone(), property, resource_id.clone())) { + continue; + } + diagnostics.push(Diagnostic { + code: "DEVUP_CODEGEN_TOKEN_NAME_UNRESOLVED".to_owned(), + message: format!( + include_str!( + "../../text/component-unresolved_token_bindings_Diagnostic-08be2867.md" + ), + id = id, + kind = kind, + property = property + ), + severity: Some(DiagnosticSeverity::Warning), + node_id: Some(id.clone()), + property: Some(property.to_owned()), + resource_id: Some(resource_id.clone()), + resource_kind: Some(kind.to_owned()), + details: Some(serde_json::json!({ + "nodeId": id, + "property": property, + "resourceId": resource_id + })), + ..Diagnostic::default() + }); + } + } + diagnostics.sort_by(|left, right| left.message.cmp(&right.message)); + diagnostics +} + +#[derive(Default)] +struct Context { + asset_policies: BTreeMap, + asset_names_per_node: bool, + imports: BTreeSet, + used_tokens: BTreeSet, + diagnostics: Vec, + inline_instances: bool, + instance_boolean_properties: Vec>, + text_style_tokens: std::collections::BTreeMap, + typography_values: BTreeMap, + variable_tokens: std::collections::BTreeMap, + length_tokens: BTreeMap, + root_layout: RootLayout, + width_policy: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum PropValue { + String(String), +} + +pub(super) type Prop = (String, PropValue); \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/codegen/component/render_node.rs b/crates/devup-mcp-devup-ui/src/codegen/component/render_node.rs new file mode 100644 index 00000000..2ce0fe19 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/component/render_node.rs @@ -0,0 +1,781 @@ + + +fn render_node( + snapshot: &Snapshot, + node: &RawNode, + depth: usize, + context: &mut Context, + visiting: &mut HashSet, +) -> Result { + if !visiting.insert(node.id.clone()) { + return Err(DevupError::new( + ErrorCode::DevupCodegenFailed, + "Figma node tree contains a circular reference.", + false, + )); + } + let view = node.typed_view(); + // Hidden snapshot subtrees have no deliverable assets. Do not emit their + // image/background/mask references, even under display:none. + if view.bool("visible") == Some(false) { + context.diagnostics.push(non_rendering_diagnostic( + node, + "visible-false", + "", + "omitted", + )); + visiting.remove(&node.id); + return Ok(mark_node( + &node.id, + if depth == 0 { + "<>".into() + } else { + String::new() + }, + )); + } + add_fallback_diagnostics(snapshot, node, context); + let concrete_visibility = if context.inline_instances { + view.value("componentPropertyReferences") + .and_then(serde_json::Value::as_object) + .and_then(|references| references.get("visible")) + .and_then(serde_json::Value::as_str) + .and_then(|property| { + context + .instance_boolean_properties + .iter() + .rev() + .find_map(|properties| properties.get(property).copied()) + }) + } else { + None + }; + if concrete_visibility == Some(false) { + visiting.remove(&node.id); + return Ok(mark_node(&node.id, String::new())); + } + if view.node_type() == "INSTANCE" + && !context.inline_instances + && style::non_rendering_asset_reason(snapshot, node).is_none() + { + let references = view + .value("componentPropertyReferences") + .and_then(serde_json::Value::as_object); + let main_property = references + .and_then(|references| references.get("mainComponent")) + .and_then(serde_json::Value::as_str) + .map(component_property_name); + let visible_property = references + .and_then(|references| references.get("visible")) + .and_then(serde_json::Value::as_str) + .map(component_property_name); + let component = snapshot + .nodes + .values() + .filter(|candidate| candidate.typed_view().node_type() == "COMPONENT") + .filter_map(|candidate| candidate.typed_view().name()) + .filter(|name| { + view.name() + .is_some_and(|instance| instance.starts_with(name)) + }) + .max_by_key(|name| name.len()) + .map(legacy_component_name) + .unwrap_or_else(|| legacy_component_name(view.name().unwrap_or("Component"))); + let content = main_property.unwrap_or_else(|| format!("<{component} />")); + let expression = if let Some(visible) = visible_property { + format!("{{{visible} && {content}}}") + } else if references.is_some_and(|references| references.contains_key("mainComponent")) { + format!("{{{content}}}") + } else { + content + }; + if view.string("layoutPositioning") == Some("ABSOLUTE") { + let mut props = Vec::new(); + layout::push_layout_props( + snapshot, + node, + "Box", + &mut props, + context.root_layout, + depth == 0, + context.width_policy.as_ref(), + super::image::original_image(node, &context.asset_policies), + ); + props.retain(|(name, value)| { + matches!( + name.as_str(), + "pos" | "left" | "right" | "top" | "bottom" | "transform" | "transformOrigin" + ) || (name == "w" && matches!(value, PropValue::String(value) if value == "100%")) + }); + context.imports.insert("Box".to_owned()); + let indent = " ".repeat(depth); + let (opening_props, multiline_props) = render_props(&props, depth); + let close_open = if multiline_props { + format!("{opening_props}\n{indent}>") + } else { + format!("{opening_props}>") + }; + visiting.remove(&node.id); + return Ok(mark_node( + &node.id, + format!( + "{indent}", + " ".repeat(depth + 1), + expression + ), + )); + } + visiting.remove(&node.id); + return Ok(mark_node( + &node.id, + format!("{}{expression}", " ".repeat(depth)), + )); + } + let asset = super::image::asset_kind(snapshot, node, &context.asset_policies); + if asset.is_some() + && let Some(reason) = style::non_rendering_asset_reason(snapshot, node) + { + // A transparent/clipped asset still occupies layout space. Keep the + // asset sizing policy, but emit no external paint or descendant asset. + let mut props = Vec::new(); + layout::push_layout_props( + snapshot, + node, + "Image", + &mut props, + context.root_layout, + depth == 0, + context.width_policy.as_ref(), + super::image::original_image(node, &context.asset_policies), + ); + layout::string_prop(&mut props, "visibility", "hidden"); + let (opening, multiline) = render_props(&props, depth); + let indent = " ".repeat(depth); + let rendered = if multiline { + format!("{indent}") + } else { + format!("{indent}") + }; + context.imports.insert("Box".into()); + context.diagnostics.push(non_rendering_diagnostic( + node, + reason, + &rendered, + "layout-only", + )); + visiting.remove(&node.id); + return Ok(mark_node(&node.id, rendered)); + } + let inferred_mode = view + .value("inferredAutoLayout") + .and_then(serde_json::Value::as_object) + .and_then(|layout| layout.get("layoutMode")) + .and_then(serde_json::Value::as_str); + let inferred_align = |name: &str| { + view.string(name).or_else(|| { + view.value("inferredAutoLayout") + .and_then(serde_json::Value::as_object) + .and_then(|layout| layout.get(name)) + .and_then(serde_json::Value::as_str) + }) + }; + let component = if asset == Some(style::AssetKind::SvgMask) { + "Box" + } else if asset.is_some() { + "Image" + } else if view.node_type() == "TEXT" { + "Text" + } else if layout::centres_its_only_child(snapshot, node) { + "Center" + } else { + match inferred_mode { + Some("GRID") => "Grid", + Some("HORIZONTAL" | "VERTICAL") + if inferred_align("primaryAxisAlignItems") == Some("CENTER") + && inferred_align("counterAxisAlignItems") == Some("CENTER") => + { + "Center" + } + Some("VERTICAL") => "VStack", + Some("HORIZONTAL") => "Flex", + _ => "Box", + } + }; + context.imports.insert(component.to_owned()); + + let mut props = Vec::new(); + layout::push_layout_props( + snapshot, + node, + component, + &mut props, + context.root_layout, + depth == 0, + context.width_policy.as_ref(), + super::image::original_image(node, &context.asset_policies), + ); + // 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. + // Positioned children, including those of page and embedded roots, are + // anchored by push_layout_props above. This legacy AUTO fallback only + // applies to other frames. + let page_root = snapshot + .nodes + .values() + .find(|candidate| { + candidate + .typed_view() + .child_ids() + .any(|child| child == node.id) + }) + .map(|parent| parent.typed_view().node_type()) + .or_else(|| view.string("parentType")) + .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE" | "COMPONENT_SET")); + if !(depth == 0 && context.root_layout == RootLayout::Embedded) + && !page_root + && asset.is_none() + && view.value("inferredAutoLayout").is_none() + && view.string("layoutPositioning") == Some("AUTO") + && layout::derived_padding(snapshot, node).is_none() + && !layout::centres_its_only_child(snapshot, node) + && view.child_ids().any(|child| { + snapshot + .nodes + .get(child) + .is_some_and(|child| child.typed_view().string("layoutPositioning") == Some("AUTO")) + }) + { + layout::string_prop(&mut props, "pos", "relative"); + } + style::push_style_props( + snapshot, + node, + component, + asset, + &mut props, + &mut context.used_tokens, + style::StyleOptions { + reserve_image_content: !(depth == 0 && context.root_layout == RootLayout::Embedded), + asset_policies: &context.asset_policies, + variable_tokens: &context.variable_tokens, + length_tokens: &context.length_tokens, + asset_names_per_node: context.asset_names_per_node, + width_policy: context.width_policy.as_ref(), + }, + ); + text::validate_line_metrics(&view, &context.text_style_tokens)?; + text::push_text_props( + &view, + &context.text_style_tokens, + &context.typography_values, + &context.variable_tokens, + &mut context.used_tokens, + &mut props, + ); + style::tokenize_lengths( + snapshot, + node, + &mut props, + &context.length_tokens, + &mut context.used_tokens, + ); + // Last, as the plugin merges `getReactionProps` last: what a timed Smart + // Animate changes becomes keyframes on the child it changes, or on the + // frame itself. + let before = props.len(); + animation::push_animation_props(snapshot, node, &context.variable_tokens, &mut props); + if props.len() > before { + context.imports.insert("keyframes".to_owned()); + } + if asset.is_some() { + props.retain(|(name, _)| { + !matches!( + name.as_str(), + "alignItems" + | "justifyContent" + | "flexDir" + | "gap" + | "outline" + | "outlineOffset" + | "overflow" + | "p" + | "px" + | "py" + | "pt" + | "pr" + | "pb" + | "pl" + ) + }); + } + let indent = " ".repeat(depth); + let instance_properties = + (context.inline_instances && view.node_type() == "INSTANCE").then(|| { + view.value("componentProperties") + .and_then(serde_json::Value::as_object) + .map(|properties| { + properties + .iter() + .filter_map(|(key, property)| { + (property.get("type").and_then(serde_json::Value::as_str) + == Some("BOOLEAN")) + .then(|| { + property + .get("value") + .and_then(serde_json::Value::as_bool) + .map(|value| (key.clone(), value)) + }) + .flatten() + }) + .collect::>() + }) + .unwrap_or_default() + }); + if let Some(properties) = instance_properties { + context.instance_boolean_properties.push(properties); + } + let children_result = if asset.is_some() { + Ok(Vec::new()) + } else { + for child in view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") == Some(false)) + { + context.diagnostics.push(non_rendering_diagnostic( + child, + "visible-false", + "", + "omitted", + )); + } + view.child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") != Some(false)) + .map(|child| render_node(snapshot, child, depth + 1, context, visiting)) + .collect::, _>>() + }; + if context.inline_instances && view.node_type() == "INSTANCE" { + context.instance_boolean_properties.pop(); + } + let mut children = children_result?; + if asset.is_none() + && component != "Text" + && let Some(layers) = super::image::render_paint_layers( + snapshot, + node, + style::StyleOptions { + reserve_image_content: !(depth == 0 && context.root_layout == RootLayout::Embedded), + asset_policies: &context.asset_policies, + variable_tokens: &context.variable_tokens, + length_tokens: &context.length_tokens, + asset_names_per_node: context.asset_names_per_node, + width_policy: context.width_policy.as_ref(), + }, + &mut context.used_tokens, + depth + 1, + ) + { + props.retain(|(name, _)| { + !matches!( + name.as_str(), + "bg" | "bgBlendMode" | "objectFit" | "objectPos" + ) + }); + if !props.iter().any(|(name, _)| name == "pos") { + layout::string_prop(&mut props, "pos", "relative"); + } + layout::string_prop(&mut props, "isolation", "isolate"); + context.imports.insert("Image".into()); + context.imports.insert("Box".into()); + children.splice(0..0, layers); + } + + let (opening_props, multiline_props) = render_props(&props, depth); + let image_index = super::image::selected_image_index(node, &context.asset_policies); + let affine = if asset == Some(style::AssetKind::Png) && image_index.is_some() { + let mapping = super::image::map_for( + snapshot, + node, + image_index, + &context.asset_policies, + context.asset_names_per_node, + ); + super::image::render_affine_image( + &props, + &mapping, + depth, + !(depth == 0 && context.root_layout == RootLayout::Embedded), + ) + } else { + None + }; + if affine.is_some() { + context.imports.insert("Box".into()); + } + let rendered = if let Some(rendered) = affine { + rendered + } else if component == "Text" { + let children = text::render_text_children( + &view, + &context.text_style_tokens, + &context.typography_values, + &context.variable_tokens, + &mut context.used_tokens, + depth + 1, + ); + let close_open = if multiline_props { + format!("{opening_props}\n{indent}>") + } else { + format!("{opening_props}>") + }; + format!("{indent}") + } else if children.is_empty() { + if multiline_props { + format!("{indent}<{component}{opening_props}\n{indent}/>") + } else { + format!("{indent}<{component}{opening_props} />") + } + } else { + let close_open = if multiline_props { + format!("{opening_props}\n{indent}>") + } else { + format!("{opening_props}>") + }; + format!( + "{indent}<{component}{close_open}\n{}\n{indent}", + children.join("\n") + ) + }; + let rendered = if concrete_visibility == Some(true) + || (view.node_type() == "INSTANCE" && context.inline_instances) + { + rendered + } else { + view.value("componentPropertyReferences") + .and_then(serde_json::Value::as_object) + .and_then(|references| references.get("visible")) + .and_then(serde_json::Value::as_str) + .map(component_property_name) + .map(|property| { + let content = rendered.strip_prefix(&indent).unwrap_or(&rendered); + format!("{indent}{{{property} && {content}}}") + }) + .unwrap_or(rendered) + }; + visiting.remove(&node.id); + Ok(mark_node(&node.id, rendered)) +} + +fn non_rendering_diagnostic(node: &RawNode, reason: &str, source: &str, state: &str) -> Diagnostic { + let view = node.typed_view(); + let field = if view.bool("visible") == Some(false) { + "visible" + } else { + "absoluteRenderBounds" + }; + let confirmed = !node.field_errors.contains_key(field) + && if field == "visible" { + view.bool(field) == Some(false) + } else { + view.value(field).is_some_and(serde_json::Value::is_null) + }; + Diagnostic { + code: "DEVUP_CODEGEN_NON_RENDERING_ASSET".into(), + node_id: Some(node.id.clone()), property: Some("assetReference".into()), + message: if confirmed { "Confirmed non-rendering node accounted for without an asset reference." } else { "Asset paint was omitted, but non-rendering could not be confirmed from collected fields." }.into(), + fidelity_impact: Some(if confirmed { devup_mcp_figma::FidelityImpact::None } else { devup_mcp_figma::FidelityImpact::Approximated }), + details: Some(serde_json::json!({ + "originalValue":{"visible":view.value("visible"),"opacity":view.value("opacity"),"absoluteBoundingBox":view.value("absoluteBoundingBox"),"absoluteRenderBounds":view.value("absoluteRenderBounds")}, + "appliedValue":{"state":state,"generatedSource":source.chars().take(800).collect::(),"sourceTruncated":source.chars().count()>800}, + "classification":if confirmed {"accounted-for-non-rendering"} else {"unverified-non-rendering"},"reason":reason, + "verification":{"state":if confirmed {"accounted-for"} else {"unverified"},"field":field,"fieldPresent":view.value(field).is_some(),"value":view.value(field),"readError":node.field_errors.get(field)}, + "nextAction":if confirmed {"No asset bytes are required for this confirmed non-rendering node. Correct visibility or clipping in Figma if it should render."} else {"Recapture visible and absoluteRenderBounds without field errors; an absent field is not evidence of non-rendering."} + })), + ..Diagnostic::default() + } +} + +/// A style name without a leading group that is only a number. +/// +/// `0/` and `3/` in front of a style name are how a Figma library is made to +/// sort in the picker; they are not part of what the style is called, and the +/// reference does not carry them into the token. A group that names something +/// (`typography/`) is part of the name and stays. +fn named_tokens(result: Option<&UpstreamResult>, collection: &str) -> BTreeMap { + fn visit(value: &serde_json::Value, collection: &str, tokens: &mut BTreeMap) { + if let Some(values) = value.get(collection).and_then(serde_json::Value::as_array) { + for value in values { + if let (Some(id), Some(name)) = ( + value.get("id").and_then(serde_json::Value::as_str), + value.get("name").and_then(serde_json::Value::as_str), + ) { + let token = if collection == "variables" { + variable_token( + name, + value + .get("codeSyntax") + .and_then(serde_json::Value::as_object) + .and_then(|syntax| syntax.get("WEB")) + .and_then(serde_json::Value::as_str), + ) + } else { + // The rule `devup.json` names the style by, so the + // `typography="…"` written here is a key that exists + // there: a leading breakpoint or number says where the + // style applies and is dropped, any other group is + // kept. See `theme::style_token`. + crate::theme::style_token(name).1 + }; + tokens.insert(id.to_owned(), token); + } + } + } + match value { + serde_json::Value::Object(object) => { + object + .values() + .for_each(|value| visit(value, collection, tokens)); + } + serde_json::Value::Array(values) => { + values + .iter() + .for_each(|value| visit(value, collection, tokens)); + } + _ => {} + } + } + let mut tokens = BTreeMap::new(); + if let Some(result) = result { + visit(&result.raw, collection, &mut tokens); + } + tokens +} + +pub(super) fn render_props(props: &[Prop], depth: usize) -> (String, bool) { + if props.is_empty() { + return (String::new(), false); + } + let mut sorted = props.to_vec(); + sorted.sort_by(|left, right| left.0.cmp(&right.0)); + let rendered = sorted + .into_iter() + .map(|(name, value)| match value { + PropValue::String(value) => render_static_attribute(&name, &value), + }) + .collect::>(); + // Five props, or one that spans lines — a `keyframes({...})` — and the + // props go one to a line, which is the plugin's `propsToString` rule. + let multiline = + rendered.len() >= 5 || rendered.iter().any(|attribute| attribute.contains('\n')); + if multiline { + let prefix = " ".repeat(depth + 1); + let padded = rendered + .iter() + .map(|attribute| attribute.replace('\n', &format!("\n{prefix}"))) + .collect::>(); + ( + format!("\n{prefix}{}", padded.join(&format!("\n{prefix}"))), + true, + ) + } else { + (format!(" {}", rendered.join(" ")), false) + } +} + +/// A prop as JSX. A value is a quoted string, except `animationName` holding +/// a `keyframes({...})` call, which is the expression itself — the plugin's +/// `propsToString` makes the same exception, and it is how devup-ui's +/// `keyframes` is meant to be written. +pub(super) fn render_static_attribute(name: &str, value: &str) -> String { + if name == "animationName" && value.starts_with("keyframes(") { + return format!("{name}={{{value}}}"); + } + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '"' => escaped.push_str("""), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + _ => escaped.push(character), + } + } + format!("{name}=\"{escaped}\"") +} + +fn add_fallback_diagnostics(snapshot: &Snapshot, node: &RawNode, context: &mut Context) { + use devup_mcp_figma::FidelityImpact; + + let view = node.typed_view(); + if let Some(diagnostic) = text::whitespace_collapse_diagnostic(&view) { + context.diagnostics.push(diagnostic); + } + let candidates = [ + ( + super::image::original_image(node, &context.asset_policies) + && matches!( + view.node_type(), + "VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION" + ), + "DEVUP_CODEGEN_IMAGE_SHAPE_UNSUPPORTED", + "The original image source does not encode this vector's shape. Use the vector rendition or an explicit CSS mask; source-image rectangle equivalence is unverified.", + FidelityImpact::Lossy, + ), + ( + view.value("fills") + .and_then(serde_json::Value::as_array) + .is_some_and(|fills| { + fills.iter().any(|p| { + p["visible"] != false + && p["type"] + .as_str() + .is_some_and(|t| t.starts_with("GRADIENT_")) + && p["gradientStops"].as_array().is_none_or(|s| s.len() < 2) + }) + }), + "DEVUP_CODEGEN_IMAGE_COMPOSITION_UNSUPPORTED", + include_str!("../../text/component-add_fallback_diagnostics-9333dc00.md"), + FidelityImpact::Lossy, + ), + ( + style::asset_kind(snapshot, node).is_none() + && view + .value("fills") + .and_then(serde_json::Value::as_array) + .is_some_and(|fills| { + fills.iter().enumerate().any(|(i, p)| { + p["type"] == "IMAGE" + && p["visible"] != false + && !super::image::policy_for( + node, + &format!("{}:fills:{i}", node.id), + &context.asset_policies, + ) + .original() + }) + }) + && (view.child_ids().next().is_some() + || view + .value("fills") + .and_then(serde_json::Value::as_array) + .is_some_and(|fs| fs.iter().filter(|p| p["visible"] != false).count() > 1) + || view + .value("effects") + .and_then(serde_json::Value::as_array) + .is_some_and(|es| es.iter().any(|e| e["visible"] != false))), + "DEVUP_CODEGEN_IMAGE_COMPOSITION_UNSUPPORTED", + include_str!("../../text/component-add_fallback_diagnostics-b5ee2878.md"), + FidelityImpact::Lossy, + ), + ( + view.bool("isMask") == Some(true), + "DEVUP_CODEGEN_MASK_FALLBACK", + "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", + "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()) + && !style::effects_are_exact(&view), + "DEVUP_CODEGEN_EFFECT_FALLBACK", + "Some Figma effects may not be converted into computed CSS.", + FidelityImpact::Lossy, + ), + ( + animation::has_unreachable_destination(snapshot, node), + "DEVUP_CODEGEN_ANIMATION_UNREACHABLE", + "A timed Smart Animate points at a frame that was not collected, so no keyframes are written for it.", + FidelityImpact::Lossy, + ), + ]; + for (enabled, code, message, fidelity_impact) in candidates { + if enabled { + context.diagnostics.push(Diagnostic { + code: code.to_owned(), + message: message.to_owned(), + node_id: Some(node.id.clone()), + fidelity_impact: Some(fidelity_impact), + ..Diagnostic::default() + }); + } + } +} + +pub fn normalize_component_name(input: &str) -> String { + let mut result = String::new(); + let mut segment = String::new(); + for character in input.chars().chain(std::iter::once(' ')) { + if character.is_alphanumeric() || character == '_' { + segment.push(character); + continue; + } + if !segment.is_empty() { + let all_ascii_upper = segment + .chars() + .filter(|character| character.is_ascii_alphabetic()) + .all(|character| character.is_ascii_uppercase()); + let mut characters = segment.chars(); + if let Some(first) = characters.next() { + result.extend(first.to_uppercase()); + for rest in characters { + if all_ascii_upper { + result.extend(rest.to_lowercase()); + } else { + result.push(rest); + } + } + } + segment.clear(); + } + } + if result.is_empty() { + return "FigmaComponent".to_owned(); + } + if result + .chars() + .next() + .is_some_and(|character| character.is_numeric()) + { + result.insert(0, '_'); + } + 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/evidence.rs b/crates/devup-mcp-devup-ui/src/codegen/evidence.rs index 5b0202a6..19fbe981 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/evidence.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/evidence.rs @@ -76,7 +76,7 @@ pub(super) fn image_layout_diagnostics( "classification":"grid-track-sizing", "reasonCodes":template.unverified, "originalValue":{"sizes":node.typed_view().value(sizes),"count":node.typed_view().value(count)}, "generatedProperty":generated, "generatedSizing":sizing(element), - "sizingPolicy":"Preserve valid explicit tracks; use auto for unknown/invalid tracks and a count-only equal-share fallback (auto for HUG). Omit unsupported declarations above the 4096-track codegen budget. No child pixel width is inferred.", + "sizingPolicy":include_str!("../text/evidence-image_layout_diagnostics_Diagnostic-c94c1150.md"), "verification":{"state":"unverified","reasonCode":"source-track-sizing-incomplete"}, "nextAction":"Collect explicit track sizes and child FIXED/FILL/HUG sizing. Compare computed card widths before accepting the fallback; no original card width is inferred.", "evidenceLimit":"Generated CSS policy and collected metadata only; browser layout was not measured." @@ -208,7 +208,7 @@ pub(super) fn image_layout_diagnostics( "generatedSizing":sizing(element), "layout":{"nodeId":parent.id,"mode":mode,"generatedSizing":sizing(parent_element),"zeroMinimumColumns":columns.is_some_and(|css| css.contains("minmax(0,"))}, "verification":{"state":"unverified","reasonCode":"browser-intrinsic-layout-not-measured"}, - "nextAction":"Preserve explicit fixed dimensions or source-supported aspect ratio; measure rendered card sizes with the chosen assets. Zero-minimum fractional columns protect track allocation but do not establish image height or crop fidelity.", + "nextAction":include_str!("../text/evidence-image_layout_diagnostics_Diagnostic-79e776df.md"), "evidenceLimit":"Potential CSS intrinsic-size dependency, not a measured cause or proof of unequal cards. Intrinsic attributes use acquired pixels when available." })), ..Diagnostic::default() @@ -262,7 +262,7 @@ pub(super) fn placement_contract( "color": color, "layoutMode": parent.string("layoutMode"), "name": parent.string("name"), - "note": "The surface this root is drawn on in Figma, read from the collected parent. It is reported, not emitted: the parent is not part of this component. Apply it to the host when the screen is meant to sit on it.", + "note": include_str!("../text/evidence-placement_contract-5c61c6c3.md"), })) }); let source = output @@ -287,7 +287,8 @@ pub(super) fn placement_contract( let embedded = options.root_layout == super::RootLayout::Embedded; let mut requirements = Vec::new(); if absolute { - requirements.push("Provide a positioned host matching the original parent dimensions and coordinate origin; the parent is not included in this component. Review the emitted offsets before insertion.".to_owned()); + requirements + .push(include_str!("../text/evidence-placement_contract-adba181a.md").to_owned()); } if vertical_fill && (embedded @@ -296,7 +297,7 @@ pub(super) fn placement_contract( || tag.contains(&format!("boxSize=\"{}\"", super::layout::px(h))) })) { - requirements.push("Provide a definite height on this generated root matching its captured FIXED height so its emitted vertical FILL child can grow into the remaining space. A host height alone is insufficient unless the root also receives that height; preserve the child's emitted flex growth and following action order.".into()); + requirements.push(include_str!("../text/evidence-placement_contract-7c9ff723.md").into()); } if positioned { for (axis, sizing, prop) in [ @@ -313,7 +314,11 @@ pub(super) fn placement_contract( let size = captured .map(super::layout::px) .unwrap_or_else(|| "not collected".into()); - requirements.push(format!("Ensure the generated root's {axis} matches the intended containing block (captured {axis}: {size}); this axis is determined by host CSS or in-flow content, not a generated fixed size.")); + requirements.push(format!( + include_str!("../text/evidence-placement_contract-09b061a1.md"), + axis = axis, + size = size + )); } } if !absolute && !relative { @@ -333,7 +338,7 @@ pub(super) fn placement_contract( "parentIncludedInOutput": false, "parentMissingReason": if parent.is_none() { Some("The root is at the capture boundary; its parent geometry was not collected.") } else { None }, "containingBlock": if absolute { "external-host" } else if relative { "generated-root" } else { "normal-flow-host" }, - "coordinateBasis": if absolute { "Emitted offsets resolve against the external positioned host; source parent placement is not self-contained." } else { "The root is inserted in normal document flow; source canvas x/y are not its placement in the application. Positioned children resolve against the generated root when it establishes a containing block." }, + "coordinateBasis": if absolute { "Emitted offsets resolve against the external positioned host; source parent placement is not self-contained." } else { include_str!("../text/evidence-placement_contract_Diagnostic-d84874e6.md") }, "sourceSize": {"width":view.number("width"), "height":view.number("height"), "horizontal":view.string("layoutSizingHorizontal"), "vertical":view.string("layoutSizingVertical")}, "sourcePosition": {"x":view.number("x"), "y":view.number("y"), "constraints":view.value("constraints")}, "sourceParentSize": parent.map(|p| json!({"width":p.typed_view().number("width"), "height":p.typed_view().number("height")})), diff --git a/crates/devup-mcp-devup-ui/src/codegen/image.rs b/crates/devup-mcp-devup-ui/src/codegen/image.rs index 091776cb..bc2fef4a 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/image.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/image.rs @@ -1,1396 +1,7 @@ //! Image bytes and the layout painted with those bytes are separate contracts. //! Figma's Paint API defines imageTransform for CROP, rotation for FILL/FIT, //! and paint order/blending: https://developers.figma.com/docs/plugins/api/Paint/ -use std::collections::{BTreeMap, BTreeSet}; -use devup_mcp_figma::{AssetRenderMode, Diagnostic, DiagnosticSeverity, RawNode, Snapshot}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -use super::{component::Prop, layout::string_prop, style}; - -// Matrix coefficients need more precision than ordinary CSS lengths. -fn number(value: f64) -> String { - let text = format!("{value:.10}"); - let text = text.trim_end_matches('0').trim_end_matches('.'); - if matches!(text, "-0" | "") { - "0".into() - } else { - text.into() - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssetPixelSize { - #[serde(alias = "w")] - pub width: f64, - #[serde(alias = "h")] - pub height: f64, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct AssetVariant { - pub path: String, - pub width: u32, - pub height: u32, - pub density: f64, -} - -/// Supplied by acquisition, keyed by the instance-specific manifest assetId. -/// Missing evidence is retained as unknown; a filename never proves baking. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct AssetRepresentationPolicy { - pub render_mode: AssetRenderMode, - #[serde(alias = "effectsBakedIntoAsset")] - pub effects_baked: Option, - #[serde(alias = "sourceSize")] - pub source_pixel_size: Option, - #[serde(alias = "pixelSize", alias = "outputSize")] - pub output_pixel_size: Option, - #[serde(alias = "nodeSize")] - pub node_box: Option, - #[serde(alias = "publicPath")] - pub asset_path: Option, - pub variants: Vec, - pub capture_node_id: Option, - pub capture_box: Option, - pub context_scope: Option, -} - -impl AssetRepresentationPolicy { - pub(super) fn original(&self) -> bool { - matches!( - self.render_mode, - AssetRenderMode::OriginalSource | AssetRenderMode::Fill - ) - } - pub(super) fn incompatible_capture(&self, node_id: &str) -> bool { - self.render_mode == AssetRenderMode::Visible - && self - .capture_node_id - .as_deref() - .is_some_and(|id| id != node_id) - } -} - -pub(super) fn policy_for( - node: &RawNode, - asset_id: &str, - policies: &BTreeMap, -) -> AssetRepresentationPolicy { - policies - .get(asset_id) - .or_else(|| policies.get(&node.id)) - .cloned() - .unwrap_or_default() -} - -pub(super) fn original_image( - node: &RawNode, - policies: &BTreeMap, -) -> bool { - if policies.get(&node.id).is_some_and(|p| !p.original()) { - return false; - } - node.typed_view() - .value("fills") - .and_then(Value::as_array) - .is_some_and(|fills| { - let images = fills - .iter() - .enumerate() - .filter(|(_, p)| p["type"] == "IMAGE" && p["visible"] != false) - .collect::>(); - !images.is_empty() - && images.iter().all(|(i, _)| { - policy_for(node, &format!("{}:fills:{i}", node.id), policies).original() - }) - }) -} - -pub(super) fn selected_image_index( - node: &RawNode, - policies: &BTreeMap, -) -> Option { - let fills = node.typed_view().value("fills")?.as_array()?; - fills - .iter() - .enumerate() - .find(|(i, p)| { - p["type"] == "IMAGE" - && p["visible"] != false - && !policy_for(node, &format!("{}:fills:{i}", node.id), policies).original() - }) - .map(|(i, _)| i) - .or_else(|| { - fills - .iter() - .position(|p| p["type"] == "IMAGE" && p["visible"] != false) - }) -} - -pub(super) fn asset_kind( - snapshot: &Snapshot, - node: &RawNode, - policies: &BTreeMap, -) -> Option { - if policies.get(&node.id).is_some_and(|p| !p.original()) { - return Some(style::AssetKind::Png); - } - let native = node - .typed_view() - .value("fills") - .and_then(Value::as_array) - .is_some_and(|fs| { - fs.iter().enumerate().any(|(i, p)| { - p["type"] == "IMAGE" - && p["visible"] != false - && !policy_for(node, &format!("{}:fills:{i}", node.id), policies).original() - }) - }); - if native { - Some(style::AssetKind::Png) - } else { - let kind = style::asset_kind(snapshot, node); - if kind == Some(style::AssetKind::Png) && !original_image(node, policies) { - let mut pending = node - .typed_view() - .child_ids() - .map(str::to_owned) - .collect::>(); - let mut seen = BTreeSet::new(); - while let Some(id) = pending.pop() { - if !seen.insert(id.clone()) { - continue; - } - if let Some(child) = snapshot.nodes.get(&id) { - if original_image(child, policies) { - return None; - } - pending.extend(child.typed_view().child_ids().map(str::to_owned)); - } - } - } - kind - } -} - -fn fields(node: &Value) -> &Value { - node.get("fields").unwrap_or(node) -} -fn size(value: &Value) -> Option { - let width = value.get("width").or_else(|| value.get("w"))?.as_f64()?; - let height = value.get("height").or_else(|| value.get("h"))?.as_f64()?; - (width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) - .then_some(AssetPixelSize { width, height }) -} -fn valid_size(value: Option) -> Option { - value.filter(|s| s.width.is_finite() && s.height.is_finite() && s.width > 0.0 && s.height > 0.0) -} -fn node_size(node: &Value, policy: &AssetRepresentationPolicy) -> Option { - valid_size(policy.node_box) - .or_else(|| size(fields(node))) - .or_else(|| size(&fields(node)["absoluteBoundingBox"])) -} -fn paint<'a>(node: &'a Value, asset_id: &str) -> Option<&'a Value> { - let index = asset_id - .rsplit_once(":fills:") - .and_then(|(_, s)| s.parse::().ok()); - let fills = fields(node)["fills"].as_array()?; - index - .and_then(|i| fills.get(i)) - .or_else(|| fills.iter().find(|p| p["type"] == "IMAGE")) -} - -type Matrix = [[f64; 3]; 2]; -const IDENTITY: Matrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; -fn matrix(value: &Value) -> Option { - let rows = value.as_array()?; - if rows.len() != 2 { - return None; - } - let row = |i: usize| -> Option<[f64; 3]> { - let r = rows[i].as_array()?; - if r.len() != 3 { - return None; - } - let v = [r[0].as_f64()?, r[1].as_f64()?, r[2].as_f64()?]; - v.iter().all(|v| v.is_finite()).then_some(v) - }; - Some([row(0)?, row(1)?]) -} -fn inverse(m: Matrix) -> Option { - let [[a, b, c], [d, e, f]] = m; - let det = a * e - b * d; - if !det.is_finite() || det.abs() < 1e-10 { - return None; - } - Some([ - [e / det, -b / det, (b * f - c * e) / det], - [-d / det, a / det, (c * d - a * f) / det], - ]) -} -fn point(m: Matrix, p: [f64; 2]) -> [f64; 2] { - [ - m[0][0] * p[0] + m[0][1] * p[1] + m[0][2], - m[1][0] * p[0] + m[1][1] * p[1] + m[1][2], - ] -} -fn corners(m: Matrix) -> Vec<[f64; 2]> { - [[0., 0.], [1., 0.], [1., 1.], [0., 1.]] - .map(|p| point(m, p)) - .to_vec() -} -fn clipped_polygon(mut poly: Vec<[f64; 2]>) -> Vec<[f64; 2]> { - for (axis, edge, sign) in [(0, 0., 1.), (0, 1., -1.), (1, 0., 1.), (1, 1., -1.)] { - let input = std::mem::take(&mut poly); - if input.is_empty() { - break; - } - let mut previous = *input.last().unwrap(); - for current in input { - let inside = |p: [f64; 2]| (p[axis] - edge) * sign >= 0.; - if inside(current) != inside(previous) { - let t = (edge - previous[axis]) / (current[axis] - previous[axis]); - poly.push([ - previous[0] + t * (current[0] - previous[0]), - previous[1] + t * (current[1] - previous[1]), - ]); - } - if inside(current) { - poly.push(current); - } - previous = current; - } - } - poly -} -fn coverage(m: Matrix) -> Value { - // Map the node's unit-square viewport INTO normalized source coordinates, - // then intersect with the source unit square. Bounds are not polygon area. - let polygon = clipped_polygon(corners(m)); - let mut area = 0.; - for i in 0..polygon.len() { - let a = polygon[i]; - let b = polygon[(i + 1) % polygon.len()]; - area += a[0] * b[1] - b[0] * a[1]; - } - let bounds = if polygon.is_empty() { - json!({"x":0,"y":0,"width":0,"height":0}) - } else { - let min = |axis: usize| { - polygon - .iter() - .map(|p| p[axis]) - .fold(f64::INFINITY, f64::min) - }; - let max = |axis: usize| { - polygon - .iter() - .map(|p| p[axis]) - .fold(f64::NEG_INFINITY, f64::max) - }; - json!({"x":min(0),"y":min(1),"width":max(0)-min(0),"height":max(1)-min(1)}) - }; - json!({"known":true,"w":bounds["width"],"h":bounds["height"],"area":(area.abs()/2.).clamp(0.,1.),"bounds":bounds,"polygon":polygon,"coordinateSpace":"normalized-source-unit-square","method":"viewport-polygon-intersection"}) -} - -#[derive(Default)] -struct Projection { - image: BTreeMap, - background: Option, - forward: Option, - sampling: Option, - visible: Value, - reasons: Vec, -} -fn projection(node: &Value, asset_id: &str, policy: &AssetRepresentationPolicy) -> Projection { - let mut out = Projection { - visible: json!({"known":false,"reason":"source-dimensions-unavailable"}), - ..Projection::default() - }; - if !policy.original() { - out.image.insert("objectFit".into(), "fill".into()); - out.background = Some("0 0/100% 100% no-repeat".into()); - out.visible = json!({"known":false,"reason":"baked-render-source-coverage-unverified"}); - return out; - } - let Some(p) = paint(node, asset_id) else { - out.reasons.push("image-paint-unavailable".into()); - return out; - }; - let mode = p["scaleMode"].as_str().unwrap_or("FILL"); - if mode == "CROP" { - let Some(t) = matrix(&p["imageTransform"]) else { - out.reasons.push("crop-transform-missing-or-invalid".into()); - out.visible = json!({"known":false,"reason":"crop-transform-missing-or-invalid"}); - return out; - }; - let Some(forward) = inverse(t) else { - out.reasons.push("singular-image-transform".into()); - out.visible = json!({"known":false,"reason":"singular-image-transform"}); - return out; - }; - out.visible = coverage(t); - out.sampling = Some(t); - out.forward = Some(forward); - let [[a, b, c], [d, e, f]] = t; - if a > 0. - && e > 0. - && b.abs() < 1e-10 - && d.abs() < 1e-10 - && ((1. - a).abs() > 1e-10 || c.abs() < 1e-10) - && ((1. - e).abs() > 1e-10 || f.abs() < 1e-10) - { - let x = if (1. - a).abs() < 1e-10 { - 0. - } else { - c / (1. - a) * 100. - }; - let y = if (1. - e).abs() < 1e-10 { - 0. - } else { - f / (1. - e) * 100. - }; - out.background = Some(format!( - "{}% {}%/{}% {}% no-repeat", - number(x), - number(y), - number(100. / a), - number(100. / e) - )); - } - out.image.insert("objectFit".into(), "fill".into()); - } else if matches!(mode, "FILL" | "FIT") { - let fit = if mode == "FIT" { "contain" } else { "cover" }; - out.image.insert("objectFit".into(), fit.into()); - out.image.insert("objectPos".into(), "center".into()); - out.background = Some(format!("center/{fit} no-repeat")); - // Figma documents imageTransform as CROP-only. An incidental matrix - // on FILL/FIT is not evidence that its diagonal measures coverage. - if matrix(&p["imageTransform"]).is_some_and(|t| t != IDENTITY) { - out.reasons - .push("image-transform-inapplicable-to-scale-mode".into()); - } - let rotation = p["rotation"].as_f64().unwrap_or(0.); - if !rotation.is_finite() || (rotation / 90. - (rotation / 90.).round()).abs() > 1e-8 { - out.reasons.push("unsupported-paint-rotation".into()); - out.visible = json!({"known":false,"reason":"unsupported-paint-rotation"}); - return out; - } - if let (Some(source), Some(box_size)) = ( - valid_size(policy.source_pixel_size), - node_size(node, policy), - ) { - let r = rotation.to_radians(); - let (c, s) = (r.cos(), r.sin()); - let rw = c.abs() * source.width + s.abs() * source.height; - let rh = s.abs() * source.width + c.abs() * source.height; - let scale = if mode == "FIT" { - (box_size.width / rw).min(box_size.height / rh) - } else { - (box_size.width / rw).max(box_size.height / rh) - }; - let a = scale * c * source.width / box_size.width; - let b = -scale * s * source.height / box_size.width; - let d = scale * s * source.width / box_size.height; - let e = scale * c * source.height / box_size.height; - let forward = [[a, b, (1. - a - b) / 2.], [d, e, (1. - d - e) / 2.]]; - out.sampling = inverse(forward); - out.visible = coverage(inverse(forward).unwrap()); - if rotation.rem_euclid(360.).abs() > 1e-8 { - out.forward = Some(forward); - out.background = None; - out.image.insert("objectFit".into(), "fill".into()); - } - } else if rotation != 0. { - out.reasons - .push("rotation-requires-source-and-node-dimensions".into()); - } - } else if mode == "TILE" { - out.background = Some(if let Some(source) = valid_size(policy.source_pixel_size) { - let factor = p["scalingFactor"] - .as_f64() - .filter(|n| n.is_finite() && *n > 0.) - .unwrap_or(1.); - format!( - "0 0/{}px {}px repeat", - number(source.width * factor), - number(source.height * factor) - ) - } else { - "repeat".into() - }); - out.visible = json!({"known":false,"reason":"tile-repetition-has-no-single-crop-polygon"}); - if p["rotation"] - .as_f64() - .is_some_and(|r| r.rem_euclid(360.) != 0.) - { - out.reasons.push("tile-rotation-unrepresentable".into()); - } - } else { - out.visible = json!({"known":false,"reason":"unsupported-image-scale-mode"}); - out.reasons.push("unsupported-image-scale-mode".into()); - } - if let Some(m) = out.forward { - if let Some(b) = node_size(node, policy) { - let [[a, x, tx], [y, d, ty]] = m; - out.image.insert( - "transform".into(), - format!( - "translate({}%, {}%) matrix({}, {}, {}, {}, 0, 0)", - number(tx * 100.), - number(ty * 100.), - number(a), - number(y * b.height / b.width), - number(x * b.width / b.height), - number(d) - ), - ); - out.image.insert("transformOrigin".into(), "0 0".into()); - } else { - out.reasons - .push("affine-projection-requires-node-box".into()); - } - } - out -} - -/// Reusable acquisition-to-CSS mapping. `node` accepts a RawNode JSON object -/// or its fields. `css` uses DevupUI prop names; intrinsic attributes are separate. -pub fn asset_css_map( - node: &Value, - asset_id: &str, - asset_path: &str, - policy: &AssetRepresentationPolicy, -) -> Value { - let f = fields(node); - let node_id = node["id"] - .as_str() - .or_else(|| asset_id.rsplit_once(":fills:").map(|(id, _)| id)) - .unwrap_or(asset_id); - let p = paint(node, asset_id); - let mut projected = projection(node, asset_id, policy); - if policy.original() - && policy.effects_baked != Some(true) - && let Some(opacity) = p.and_then(|p| p["opacity"].as_f64()).filter(|n| *n < 1.) - { - projected.image.insert("opacity".into(), number(opacity)); - } - let box_size = node_size(node, policy); - let path = policy.asset_path.as_deref().unwrap_or(asset_path); - let baked = policy.effects_baked == Some(true); - if policy.effects_baked.is_none() { - projected - .reasons - .push("effects-baked-state-unverified".into()); - } - if policy.incompatible_capture(node_id) { - projected - .reasons - .push("incompatible-visible-capture".into()); - } - let mut wrapper: BTreeMap = BTreeMap::new(); - if let Some(b) = box_size { - wrapper.insert( - "aspectRatio".into(), - format!("{} / {}", number(b.width), number(b.height)), - ); - } - wrapper.insert("minW".into(), "0".into()); - wrapper.insert("minH".into(), "0".into()); - let clip=projected.image.contains_key("transform").then(||json!({"pos":"relative","w":"100%","h":"100%","overflow":"hidden","borderRadius":"inherit"})); - let raw: RawNode = serde_json::from_value( - json!({"id":node_id,"type":node["type"].as_str().unwrap_or("RECTANGLE"),"fields":f}), - ) - .expect("synthetic node has required fields"); - let effects = if baked || (!policy.original() && policy.effects_baked.is_none()) { - BTreeMap::new() - } else { - style::image_effect_props(&raw) - }; - if f["isMask"] == true { - projected.visible = json!({"known":false,"reason":"mask-aperture-unavailable"}); - projected.reasons.push("mask-aperture-unavailable".into()); - } - if p.and_then(|p| p["filters"].as_object()) - .is_some_and(|fs| fs.values().any(|v| v.as_f64().is_some_and(|n| n != 0.))) - { - projected - .reasons - .push("figma-image-filters-unrepresentable".into()); - } - if matches!( - node["type"].as_str(), - Some("VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION") - ) { - projected - .reasons - .push("original-source-vector-shape-unrepresented".into()); - } - if f["cornerSmoothing"].as_f64().is_some_and(|n| n > 0.) { - projected - .reasons - .push("figma-corner-smoothing-approximated".into()); - } - if p.and_then(|p| p["blendMode"].as_str()) - .is_some_and(|m| matches!(m, "LINEAR_BURN" | "LINEAR_DODGE")) - { - projected - .reasons - .push("paint-blend-mode-unrepresentable".into()); - } - let intrinsic = if policy.original() { - valid_size(policy.output_pixel_size).or_else(|| valid_size(policy.source_pixel_size)) - } else { - valid_size(policy.output_pixel_size) - }; - let base = policy - .variants - .iter() - .find(|v| (v.density - 1.).abs() < 1e-9 && v.width > 0 && v.height > 0) - .map(|v| AssetPixelSize { - width: v.width as f64, - height: v.height as f64, - }) - .or_else(|| valid_size(policy.output_pixel_size)); - let mut variants = policy - .variants - .iter() - .filter(|v| { - !v.path.is_empty() - && v.width > 0 - && v.height > 0 - && v.density.is_finite() - && v.density > 0. - }) - .filter(|v| { - base.is_some_and(|b| { - (v.width as f64 - b.width * v.density).abs() <= 1. - && (v.height as f64 - b.height * v.density).abs() <= 1. - }) - }) - .collect::>(); - if variants.len() != policy.variants.len() { - projected - .reasons - .push("variant-density-inconsistent-with-encoded-dimensions".into()); - } - variants.sort_by(|a, b| a.density.total_cmp(&b.density)); - variants.dedup_by(|a, b| a.density == b.density); - let src_set = (!variants.is_empty()).then(|| { - variants - .iter() - .map(|v| format!("{} {}x", v.path, number(v.density))) - .collect::>() - .join(", ") - }); - let sizes = src_set - .as_ref() - .and_then(|_| box_size.map(|b| format!("{}px", number(b.width)))); - let effective = |s: Option| { - s.zip(box_size).map(|(s,b)|json!({"x":s.width/b.width,"y":s.height/b.height,"minimum":(s.width/b.width).min(s.height/b.height),"basis":"pixel-dimensions-divided-by-layout-node-box"})) - }; - let source_effective = effective(valid_size(policy.source_pixel_size)); - let output_effective = effective(valid_size(policy.output_pixel_size)); - let projected_scale = |pixels: Option| { - pixels.zip(box_size).zip(projected.sampling).map(|((s,b),m)| { - let a=s.width*m[0][0]/b.width;let c=s.width*m[0][1]/b.height; - let d=s.height*m[1][0]/b.width;let e=s.height*m[1][1]/b.height; - let trace=a*a+c*c+d*d+e*e;let det=a*e-c*d; - json!({"x":a.hypot(d),"y":c.hypot(e),"minimum":((trace-(trace*trace-4.*det*det).max(0.).sqrt())/2.).max(0.).sqrt(),"basis":"singular-values-of-source-pixels-per-node-pixel-normalized-sampling-transform"}) - }) - }; - let projected_source_effective = projected_scale(valid_size(policy.source_pixel_size)); - let projected_output_effective = if policy.original() { - projected_scale(valid_size(policy.output_pixel_size)) - } else { - output_effective.clone() - }; - if let Some(e) = if policy.original() { - projected_output_effective - .as_ref() - .or(projected_source_effective.as_ref()) - .or(source_effective.as_ref()) - } else { - output_effective.as_ref() - } && e["minimum"].as_f64().is_some_and(|n| n < 1.5) - { - projected.reasons.push("low-resolution-bitmap".into()); - } - if intrinsic.is_none() { - projected - .reasons - .push("intrinsic-pixel-dimensions-unavailable".into()); - } - let property_evidence=effects.iter().map(|(property,value)|json!({"property":property,"value":value,"appliedBy":"css","source":"node.effects/radii/strokes/opacity/blendMode"})).collect::>(); - json!({"assetId":asset_id,"nodeId":node_id,"instanceIdentity":node_id,"instancePath":f.get("instancePath").or_else(||f.get("parentId")),"assetPath":path, - "renderMode":policy.render_mode,"effectsBaked":policy.effects_baked,"effectsAppliedBy":if baked{json!("asset")}else if policy.original()||policy.effects_baked==Some(false){json!("css")}else{Value::Null}, - "sourcePixelSize":policy.source_pixel_size,"outputPixelSize":policy.output_pixel_size,"nodeBox":box_size, - "layoutSizing":{"horizontal":f["layoutSizingHorizontal"],"vertical":f["layoutSizingVertical"]}, - "captureNodeId":policy.capture_node_id,"captureBox":policy.capture_box,"contextScope":policy.context_scope, - "scaleMode":p.and_then(|p|p.get("scaleMode")),"imageTransform":p.and_then(|p|p.get("imageTransform")), - "paintLayer":{"fillIndex":asset_id.rsplit_once(":fills:").and_then(|(_,i)|i.parse::().ok()),"opacity":p.and_then(|p|p.get("opacity")),"blendMode":p.and_then(|p|p.get("blendMode")),"nodeOpacity":f["opacity"],"nodeBlendMode":f["blendMode"]}, - "coordinateSemantics":{"imageTransform":"CROP: normalized node viewport to normalized source; inverse projects source into viewport","cropConventionEvidence":"inferred normalized sampling convention; official Paint API documents applicability but not direction; no canvas parity claim","fillFit":"centered aspect-preserving cover/contain; imageTransform is inapplicable","cssMatrix":"conjugate normalized inverse by node pixel dimensions; percentage translation precedes linear matrix","coverage":"source unit square intersected with transformed viewport polygon","scope":"node paint only; ancestor masks/clips and canvas composition are not established"}, - "visibleSourceRatio":projected.visible,"cropApplied":if policy.original(){json!(false)}else{Value::Null},"cssCropApplied":policy.original()&&projected.forward.is_some(), - "css":{"wrapper":wrapper,"clip":clip,"image":projected.image,"effects":effects,"background":projected.background}, - "composition":{"applyEffectsTo":"outer-node-after-paint-clipping","paintOrder":"fills array bottom-to-top; live children above paint elements","affineImage":"full source in a 100% sized absolute Image inside a relative overflow-hidden clip","hugAxes":"clip uses measured nodeBox content dimension where the outer HUG axis has no definite CSS size","coverageScope":"rectangular-node-viewport-before-radius-mask-and-ancestor-composition","shapeClippingIncludedInCoverage":false}, - "intrinsicAttributes":intrinsic.map(|s|json!({"width":s.width,"height":s.height})),"srcSet":src_set,"sizes":sizes, - "sourceEffectiveScale":source_effective,"outputEffectiveScale":output_effective,"projectedSourceEffectiveScale":projected_source_effective,"projectedOutputEffectiveScale":projected_output_effective,"variants":variants, - "propertyEvidence":property_evidence,"warnings":projected.reasons,"unsupported":projected.reasons.iter().filter(|s|!matches!(s.as_str(),"image-transform-inapplicable-to-scale-mode"|"low-resolution-bitmap"|"intrinsic-pixel-dimensions-unavailable"|"effects-baked-state-unverified")).collect::>()}) -} - -pub(super) fn apply_image_props(props: &mut Vec, mapping: &Value) { - for (name, value) in mapping["css"]["image"].as_object().into_iter().flatten() { - if matches!(name.as_str(), "transform" | "transformOrigin" | "opacity") { - continue; - } - if let Some(v) = value.as_str() { - string_prop(props, name, v); - } - } - for name in ["width", "height"] { - if let Some(v) = mapping["intrinsicAttributes"][name].as_f64() { - string_prop(props, name, number(v)); - } - } - for name in ["srcSet", "sizes"] { - if let Some(v) = mapping[name].as_str() { - string_prop(props, name, v); - } - } - for name in ["minW", "minH", "aspectRatio"] { - if let Some(v) = mapping["css"]["wrapper"][name].as_str() { - string_prop(props, name, v); - } - } -} - -/// A HUG image contributes its measured display content, rather than the -/// unrelated pixel dimensions of the full source file. Embedded roots keep -/// their host sizing contract and never use this standalone content anchor. -pub(super) fn reserve_hug_content(props: &mut Vec, mapping: &Value) { - if mapping["layoutSizing"]["horizontal"] != "HUG" - || mapping["layoutSizing"]["vertical"] != "HUG" - || !mapping["css"]["image"]["transform"].is_null() - || props - .iter() - .any(|(n, _)| matches!(n.as_str(), "w" | "h" | "boxSize")) - { - return; - } - for (dimension, css) in [("width", "w"), ("height", "h")] { - if let Some(value) = mapping["nodeBox"][dimension].as_f64() { - string_prop(props, css, format!("{}px", number(value))); - } - } -} - -fn element(component: &str, props: &[Prop], children: Option<&str>, depth: usize) -> String { - let indent = " ".repeat(depth); - let (opening, multiline) = super::component::render_props(props, depth); - match children { - Some(content) => format!( - "{indent}<{component}{opening}{}>\n{content}\n{indent}", - if multiline { - format!("\n{indent}") - } else { - String::new() - } - ), - None => format!( - "{indent}<{component}{opening}{} />", - if multiline { - format!("\n{indent}") - } else { - String::new() - } - ), - } -} - -pub(super) fn render_affine_image( - props: &[Prop], - mapping: &Value, - depth: usize, - reserve_content: bool, -) -> Option { - mapping["css"]["image"]["transform"].as_str()?; - let image_names = [ - "src", - "srcSet", - "sizes", - "width", - "height", - "objectFit", - "objectPos", - "alt", - ]; - let mut outer = props - .iter() - .filter(|(n, _)| !image_names.contains(&n.as_str())) - .cloned() - .collect::>(); - // Layout transforms stay on the outer node; the paint transform belongs - // only to the inner full-source image. - if !outer.iter().any(|(n, _)| n == "pos") { - string_prop(&mut outer, "pos", "relative"); - } - let mut image = props - .iter() - .filter(|(n, _)| image_names.contains(&n.as_str())) - .cloned() - .collect::>(); - for (name, value) in mapping["css"]["image"].as_object().into_iter().flatten() { - if let Some(v) = value.as_str() { - string_prop(&mut image, name, v); - } - } - for (name, value) in [ - ("w", "100%"), - ("h", "100%"), - ("pos", "absolute"), - ("left", "0"), - ("top", "0"), - ("maxW", "none"), - ("display", "block"), - ] { - string_prop(&mut image, name, value); - } - let mut clip = Vec::new(); - for (name, value) in [ - ("w", "100%"), - ("h", "100%"), - ("pos", "relative"), - ("overflow", "hidden"), - ("borderRadius", "inherit"), - ] { - string_prop(&mut clip, name, value); - } - for (dimension, css, axis) in [("width", "w", "horizontal"), ("height", "h", "vertical")] { - if reserve_content - && mapping["layoutSizing"][axis] == "HUG" - && !outer.iter().any(|(n, _)| n == css || n == "boxSize") - && let Some(value) = mapping["nodeBox"][dimension].as_f64() - { - string_prop(&mut clip, css, format!("{}px", number(value))); - } - } - Some(element( - "Box", - &outer, - Some(&element( - "Box", - &clip, - Some(&element("Image", &image, None, depth + 2)), - depth + 1, - )), - depth, - )) -} - -/// Separate paint elements support per-paint opacity/blending and affine -/// images while keeping live children outside the image's crop wrapper. -pub(super) fn render_paint_layers( - snapshot: &Snapshot, - node: &RawNode, - options: style::StyleOptions<'_>, - used: &mut BTreeSet, - depth: usize, -) -> Option> { - if style::non_rendering_asset_reason(snapshot, node).is_some() { - return None; - } - let paints = node.typed_view().value("fills")?.as_array()?; - let images = paints - .iter() - .enumerate() - .filter(|(_, p)| p["type"] == "IMAGE" && p["visible"] != false) - .collect::>(); - if images.is_empty() - || images.iter().any(|(i, _)| { - !policy_for( - node, - &format!("{}:fills:{i}", node.id), - options.asset_policies, - ) - .original() - }) - { - return None; - } - let mut layers = Vec::new(); - for (index, paint) in paints - .iter() - .enumerate() - .filter(|(_, p)| p["visible"] != false && p["opacity"] != 0) - { - let mut layer = Vec::new(); - for (name, value) in [ - ("pos", "absolute"), - ("inset", "0"), - ("zIndex", "-1"), - ("pointerEvents", "none"), - ("overflow", "hidden"), - ("borderRadius", "inherit"), - ] { - string_prop(&mut layer, name, value); - } - if let Some(blend) = paint["blendMode"].as_str().and_then(style::blend_mode) { - string_prop(&mut layer, "mixBlendMode", blend); - } - let child = if paint["type"] == "IMAGE" && paint["scaleMode"] != "TILE" { - let mapping = map_for( - snapshot, - node, - Some(index), - options.asset_policies, - options.asset_names_per_node, - ); - let mut image = Vec::new(); - string_prop( - &mut image, - "src", - mapping["assetPath"].as_str().unwrap_or_default(), - ); - string_prop(&mut image, "alt", ""); - apply_image_props(&mut image, &mapping); - image.retain(|(n, _)| !matches!(n.as_str(), "aspectRatio" | "minW" | "minH")); - for (name, value) in mapping["css"]["image"].as_object().into_iter().flatten() { - if name == "opacity" { - continue; - } - if let Some(v) = value.as_str() { - string_prop(&mut image, name, v); - } - } - for (name, value) in [ - ("pos", "absolute"), - ("inset", "0"), - ("w", "100%"), - ("h", "100%"), - ("maxW", "none"), - ("display", "block"), - ] { - string_prop(&mut image, name, value); - } - if let Some(opacity) = paint["opacity"].as_f64() - && opacity < 1. - { - string_prop(&mut layer, "opacity", number(opacity)); - } - Some(element("Image", &image, None, depth + 1)) - } else { - if let Some(css) = style::paint_css(snapshot, node, paint, index, false, used, options) - { - string_prop(&mut layer, "bg", css); - } - None - }; - layers.push(element("Box", &layer, child.as_deref(), depth)); - } - Some(layers) -} - -/// Warn only for asset references and CSS on the same JSX element. Parsing -/// excludes comments/text and prevents unrelated ancestor effects matching. -pub fn validate_asset_effects( - tsx: &str, - policies: &BTreeMap, -) -> Vec { - let attrs = crate::provenance::attributes::generated_attributes(tsx); - let mut diagnostics = Vec::new(); - for (asset_id, policy) in policies { - if policy.effects_baked != Some(true) { - continue; - } - let paths = policy - .asset_path - .iter() - .map(String::as_str) - .chain(policy.variants.iter().map(|v| v.path.as_str())) - .collect::>(); - let elements = attrs - .iter() - .filter(|a| { - matches!( - a.name.as_str(), - "src" | "bg" | "background" | "backgroundImage" - ) && literal_attribute(&a.source).is_some_and(|v| { - paths.iter().any(|p| { - !p.is_empty() && (v == *p || background_urls(v).iter().any(|url| url == p)) - }) - }) - }) - .map(|a| a.element) - .collect::>(); - for element in elements { - let repeated = attrs - .iter() - .filter(|a| { - a.element == element - && matches!( - a.name.as_str(), - "filter" | "boxShadow" | "textShadow" | "backdropFilter" - ) - && literal_attribute(&a.source).is_some_and(|v| { - !matches!(v.trim(), "none" | "initial" | "inherit" | "unset" | "") - }) - }) - .map(|a| a.source.clone()) - .collect::>(); - if !repeated.is_empty() { - diagnostics.push(Diagnostic{code:"DEVUP_CODEGEN_DOUBLE_EFFECT".into(),message:"CSS effects on a baked asset create a double-effect risk; verify the effect kinds before applying them together.".into(),severity:Some(DiagnosticSeverity::Warning),details:Some(json!({"assetId":asset_id,"elementIndex":element,"effectsBaked":true,"generatedProperties":repeated})),..Diagnostic::default()}); - } - } - } - diagnostics -} - -fn literal_attribute(source: &str) -> Option<&str> { - let (_, value) = source.split_once('=')?; - let value = value.trim(); - let value = value - .strip_prefix('{') - .and_then(|v| v.strip_suffix('}')) - .unwrap_or(value) - .trim(); - value - .strip_prefix('"') - .and_then(|v| v.strip_suffix('"')) - .filter(|v| !v.contains(['"', '\\'])) - .or_else(|| { - value - .strip_prefix('\'') - .and_then(|v| v.strip_suffix('\'')) - .filter(|v| !v.contains(['\'', '\\'])) - }) -} -fn background_urls(value: &str) -> Vec<&str> { - value - .split("url(") - .skip(1) - .filter_map(|v| { - v.split_once(')') - .map(|(url, _)| url.trim().trim_matches(['\'', '"'])) - }) - .collect() -} - -/// Variant/responsive trees use the same representation and paint geometry -/// as the ordinary renderer. Synthetic paint elements inherit provenance -/// from their outer source node rather than inventing Figma node identities. -pub(super) fn project_tree( - mut tree: super::variant::Tree, - snapshot: &Snapshot, - node: &RawNode, - options: &super::CodegenOptions, -) -> super::variant::Tree { - if style::non_rendering_asset_reason(snapshot, node).is_some() { - return tree; - } - use super::variant::Tree; - let synthetic = - |component: &str, props: BTreeMap, children: Vec, name: &str| Tree { - node_id: String::new(), - node_name: name.into(), - source_node_ids: BTreeSet::new(), - component: component.into(), - props, - children, - content: None, - is_component: false, - visible_when: None, - drawn_when: Vec::new(), - leading_comment: None, - slots: Vec::new(), - }; - let visible = node.typed_view().value("fills").and_then(Value::as_array); - if tree.component == "Image" { - let index = visible.and_then(|fs| { - fs.iter() - .position(|p| p["type"] == "IMAGE" && p["visible"] != false) - }); - if index.is_none() { - return tree; - } - let mapping = map_for( - snapshot, - node, - index, - &options.asset_policies, - options.asset_names_per_node, - ); - if mapping["css"]["image"]["transform"].is_null() { - return tree; - } - let image_names = [ - "src", - "srcSet", - "sizes", - "width", - "height", - "objectFit", - "objectPos", - "alt", - ]; - let mut image = tree - .props - .iter() - .filter(|(n, _)| image_names.contains(&n.as_str())) - .map(|(n, v)| (n.clone(), v.clone())) - .collect::>(); - tree.props.retain(|n, _| !image_names.contains(&n.as_str())); - tree.component = "Box".into(); - tree.props.entry("pos".into()).or_insert("relative".into()); - for (n, v) in mapping["css"]["image"].as_object().into_iter().flatten() { - if let Some(v) = v.as_str() { - image.insert(n.clone(), v.into()); - } - } - for (n, v) in [ - ("w", "100%"), - ("h", "100%"), - ("pos", "absolute"), - ("left", "0"), - ("top", "0"), - ("maxW", "none"), - ("display", "block"), - ] { - image.insert(n.into(), v.into()); - } - let mut clip: BTreeMap = [ - ("w", "100%"), - ("h", "100%"), - ("pos", "relative"), - ("overflow", "hidden"), - ("borderRadius", "inherit"), - ] - .into_iter() - .map(|(n, v)| (n.into(), v.into())) - .collect(); - for (dimension, css, axis) in [("width", "w", "horizontal"), ("height", "h", "vertical")] { - if mapping["layoutSizing"][axis] == "HUG" - && !(options.root_layout == super::RootLayout::Embedded - && snapshot.roots.contains(&node.id)) - && !tree.props.contains_key(css) - && !tree.props.contains_key("boxSize") - && let Some(value) = mapping["nodeBox"][dimension].as_f64() - { - clip.insert(css.into(), format!("{}px", number(value))); - } - } - tree.children = vec![synthetic( - "Box", - clip, - vec![synthetic("Image", image, vec![], "source-image")], - "image-clip", - )]; - return tree; - } - if tree.component == "Text" { - return tree; - } - let Some(paints) = visible else { - return tree; - }; - if !paints.iter().enumerate().any(|(i, p)| { - p["type"] == "IMAGE" - && p["visible"] != false - && policy_for( - node, - &format!("{}:fills:{i}", node.id), - &options.asset_policies, - ) - .original() - }) { - return tree; - } - let style_options = style::StyleOptions { - reserve_image_content: !matches!(options.root_layout, super::RootLayout::Embedded), - asset_policies: &options.asset_policies, - variable_tokens: &options.variable_tokens, - length_tokens: &options.length_tokens, - asset_names_per_node: options.asset_names_per_node, - width_policy: options.width_policy.as_ref(), - }; - let mut layers = Vec::new(); - let mut used = BTreeSet::new(); - for (index, p) in paints - .iter() - .enumerate() - .filter(|(_, p)| p["visible"] != false && p["opacity"] != 0) - { - let mut props: BTreeMap = [ - ("pos", "absolute"), - ("inset", "0"), - ("zIndex", "-1"), - ("pointerEvents", "none"), - ("overflow", "hidden"), - ("borderRadius", "inherit"), - ] - .into_iter() - .map(|(n, v)| (n.into(), v.into())) - .collect(); - if let Some(blend) = p["blendMode"].as_str().and_then(style::blend_mode) { - props.insert("mixBlendMode".into(), blend); - } - let children = if p["type"] == "IMAGE" && p["scaleMode"] != "TILE" { - let mapping = map_for( - snapshot, - node, - Some(index), - &options.asset_policies, - options.asset_names_per_node, - ); - let mut image_props = Vec::new(); - apply_image_props(&mut image_props, &mapping); - let mut image = image_props - .into_iter() - .filter(|(n, _)| !matches!(n.as_str(), "aspectRatio" | "minW" | "minH")) - .map(|(n, super::component::PropValue::String(v))| (n, v)) - .collect::>(); - image.insert( - "src".into(), - mapping["assetPath"].as_str().unwrap_or_default().into(), - ); - image.insert("alt".into(), String::new()); - for (n, v) in mapping["css"]["image"].as_object().into_iter().flatten() { - if n == "opacity" { - continue; - } - if let Some(v) = v.as_str() { - image.insert(n.clone(), v.into()); - } - } - for (n, v) in [ - ("pos", "absolute"), - ("inset", "0"), - ("w", "100%"), - ("h", "100%"), - ("maxW", "none"), - ("display", "block"), - ] { - image.insert(n.into(), v.into()); - } - if let Some(opacity) = p["opacity"].as_f64() - && opacity < 1. - { - props.insert("opacity".into(), number(opacity)); - } - vec![synthetic( - "Image", - image, - vec![], - &format!("paint-{index}-image"), - )] - } else { - if let Some(css) = - style::paint_css(snapshot, node, p, index, false, &mut used, style_options) - { - props.insert("bg".into(), css); - } - vec![] - }; - layers.push(synthetic("Box", props, children, &format!("paint-{index}"))); - } - tree.props - .retain(|n, _| !matches!(n.as_str(), "bg" | "bgBlendMode" | "objectFit" | "objectPos")); - tree.props.entry("pos".into()).or_insert("relative".into()); - tree.props.insert("isolation".into(), "isolate".into()); - tree.children.splice(0..0, layers); - tree -} - -pub(super) fn map_for( - snapshot: &Snapshot, - node: &RawNode, - index: Option, - policies: &BTreeMap, - per_node: bool, -) -> Value { - let id = index - .map(|i| format!("{}:fills:{i}", node.id)) - .unwrap_or_else(|| node.id.clone()); - let default_path = index - .and_then(|i| style::image_fill_path(snapshot, &node.id, i, per_node)) - .or_else(|| style::asset_path(snapshot, &node.id, per_node)) - .unwrap_or_default(); - asset_css_map( - &serde_json::to_value(node).expect("RawNode serializes"), - &id, - &default_path, - &policy_for(node, &id, policies), - ) -} - -pub(super) fn attach_evidence( - snapshot: &Snapshot, - options: &super::CodegenOptions, - output: &mut super::CodegenOutput, -) { - use crate::provenance::{GeneratedRange, ProvenanceEntry}; - let attrs = crate::provenance::attributes::generated_attributes(&output.tsx); - let emitted = output - .source_map - .entries - .iter() - .filter(|e| e.property.is_none() && e.resolution == "node") - .filter_map(|e| Some((e.node_id.clone()?, e.generated_range.clone()?))) - .collect::>(); - for (id, range) in emitted { - let Some(node) = snapshot.nodes.get(&id) else { - continue; - }; - let indices = node - .typed_view() - .value("fills") - .and_then(Value::as_array) - .map(|fs| { - fs.iter() - .enumerate() - .filter(|(_, p)| p["type"] == "IMAGE" && p["visible"] != false) - .map(|(i, _)| Some(i)) - .collect::>() - }) - .unwrap_or_default(); - let indices = if indices.is_empty() - && style::asset_kind(snapshot, node) == Some(style::AssetKind::Png) - { - vec![None] - } else { - indices - }; - for index in indices { - let mut mapping = map_for( - snapshot, - node, - index, - &options.asset_policies, - options.asset_names_per_node, - ); - let path = mapping["assetPath"].as_str().unwrap_or_default().to_owned(); - let asset_id = mapping["assetId"].as_str().unwrap_or(&id).to_owned(); - // Nested nodes have their own range; only this node's properties - // can substantiate its mapping, even if another instance reuses bytes. - let own = attrs - .iter() - .filter(|a| range.start <= a.start && a.end <= range.end) - .filter(|a| { - !output.source_map.entries.iter().any(|e| { - e.property.is_none() - && e.node_id.as_deref() != Some(&id) - && e.generated_range.as_ref().is_some_and(|r| { - range.start <= r.start - && r.end <= range.end - && r.start <= a.start - && a.end <= r.end - }) - }) - }) - .collect::>(); - let references = own - .iter() - .filter(|a| { - matches!(a.name.as_str(), "src" | "bg") - && !path.is_empty() - && literal_attribute(&a.source).is_some_and(|value| { - value == path || background_urls(value).iter().any(|url| *url == path) - }) - }) - .collect::>(); - if references.is_empty() { - let policy = policy_for(node, &asset_id, &options.asset_policies); - if !policy.incompatible_capture(&id) { - continue; - } - mapping["emitted"] = json!(false); - mapping["effectsAppliedBy"] = Value::Null; - mapping["emittedProperties"] = json!([]); - mapping["propertyEvidence"] = json!([]); - output.diagnostics.push(Diagnostic{code:"DEVUP_CODEGEN_IMAGE_MAPPING_UNSUPPORTED".into(),message:"A visible capture of a different node cannot be composed into this leaf's layout box.".into(),node_id:Some(id.clone()),property:Some("fills".into()),fidelity_impact:Some(devup_mcp_figma::FidelityImpact::Lossy),details:Some(json!({"assetId":asset_id,"reason":"incompatible-visible-capture","captureNodeId":policy.capture_node_id,"captureBox":policy.capture_box,"emittedAssetReference":false})),..Diagnostic::default()}); - output.source_map.asset_css_mappings.push(mapping); - continue; - } - mapping["emitted"] = json!(true); - let properties=own.iter().map(|a|json!({"elementIndex":a.element,"name":a.name,"generatedProperty":a.source})).collect::>(); - mapping["emittedProperties"] = json!(properties); - // The public source map exposes verified emission, while the - // reusable recipe remains useful before code generation. - mapping["propertyEvidence"]=json!(own.iter().filter_map(|a| { - let source=match a.name.as_str(){"filter"|"boxShadow"|"backdropFilter"=>"effects","borderRadius"|"borderTopLeftRadius"|"borderTopRightRadius"|"borderBottomLeftRadius"|"borderBottomRightRadius"=>"radii","width"|"height"=>"acquired-pixel-dimensions","transform"|"transformOrigin"|"objectFit"|"objectPos"=>"image-paint-geometry","srcSet"|"sizes"=>"acquired-variants","opacity"=>"node-or-paint-opacity","mixBlendMode"|"bgBlendMode"=>"node-or-paint-blend-mode","bg"=>"paint-stack-gradient-or-solid",_=>return None}; - Some(json!({"property":a.name,"generatedProperty":a.source,"source":source,"elementIndex":a.element,"scope":"node-paint-stack","effectsAppliedBy":mapping["effectsAppliedBy"]})) - }).collect::>()); - for a in own.iter().filter(|a| { - matches!( - a.name.as_str(), - "width" - | "height" - | "srcSet" - | "sizes" - | "objectFit" - | "objectPos" - | "transform" - | "transformOrigin" - | "aspectRatio" - | "minW" - | "minH" - | "filter" - | "boxShadow" - | "backdropFilter" - ) - }) { - let property = match a.name.as_str() { - "width" | "height" => "sourcePixelSize", - "srcSet" | "sizes" => "assetVariants", - "filter" | "boxShadow" | "backdropFilter" => "effects", - _ => "fills", - }; - output.source_map.entries.push(ProvenanceEntry {generated_range:Some(GeneratedRange{start:a.start,end:a.end}),calculation:Some("Acquisition representation policy and image-paint geometry; see assetCssMappings for dimensions, coordinate semantics and limits.".into()),generated_property:Some(a.source.clone()),json_pointer:None,node_id:Some(id.clone()),property:Some(property.into()),variable_id:None,style_id:None,asset_id:Some(asset_id.clone()),resolution:"asset-css-projection".into(),element_index:Some(a.element)}); - } - for a in &references { - output.source_map.entries.push(ProvenanceEntry { - generated_range: Some(GeneratedRange { - start: a.start, - end: a.end, - }), - calculation: Some("Instance-specific acquired image path.".into()), - generated_property: Some(a.source.clone()), - json_pointer: None, - node_id: Some(id.clone()), - property: Some("fills".into()), - variable_id: None, - style_id: None, - asset_id: Some(asset_id.clone()), - resolution: "asset".into(), - element_index: Some(a.element), - }); - if mapping["effectsBaked"] == true { - let mut entry = output.source_map.entries.last().unwrap().clone(); - entry.property = Some("effects".into()); - entry.resolution = "effects-baked-into-asset".into(); - entry.calculation=Some("Supplied acquisition evidence confirms baked effects; the same CSS effects are suppressed.".into()); - output.source_map.entries.push(entry); - } - } - for reason in mapping["warnings"] - .as_array() - .into_iter() - .flatten() - .filter_map(Value::as_str) - { - let unsupported = mapping["unsupported"] - .as_array() - .is_some_and(|xs| xs.iter().any(|x| x == reason)); - output.diagnostics.push(Diagnostic{code:if unsupported{"DEVUP_CODEGEN_IMAGE_MAPPING_UNSUPPORTED"}else{"DEVUP_CODEGEN_IMAGE_MAPPING_EVIDENCE"}.into(),message:format!("Image mapping: {reason}."),node_id:Some(id.clone()),property:Some("fills".into()),severity:Some(DiagnosticSeverity::Warning),fidelity_impact:Some(if unsupported{devup_mcp_figma::FidelityImpact::Lossy}else{devup_mcp_figma::FidelityImpact::Approximated}),details:Some(json!({"assetId":asset_id,"reason":reason,"renderMode":mapping["renderMode"],"evidenceLimit":mapping["coordinateSemantics"]["scope"]})),..Diagnostic::default()}); - } - output.source_map.asset_css_mappings.push(mapping); - } - } - output - .diagnostics - .extend(validate_asset_effects(&output.tsx, &options.asset_policies)); -} +// Source sections share this module to preserve privacy and test names. +include!("image/image_projection.rs"); +include!("image/project_tree.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/image/image_projection.rs b/crates/devup-mcp-devup-ui/src/codegen/image/image_projection.rs new file mode 100644 index 00000000..4e015422 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/image/image_projection.rs @@ -0,0 +1,978 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use devup_mcp_figma::{AssetRenderMode, Diagnostic, DiagnosticSeverity, RawNode, Snapshot}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::{component::Prop, layout::string_prop, style}; + +// Matrix coefficients need more precision than ordinary CSS lengths. +fn number(value: f64) -> String { + let text = format!("{value:.10}"); + let text = text.trim_end_matches('0').trim_end_matches('.'); + if matches!(text, "-0" | "") { + "0".into() + } else { + text.into() + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetPixelSize { + #[serde(alias = "w")] + pub width: f64, + #[serde(alias = "h")] + pub height: f64, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AssetVariant { + pub path: String, + pub width: u32, + pub height: u32, + pub density: f64, +} + +/// Supplied by acquisition, keyed by the instance-specific manifest assetId. +/// Missing evidence is retained as unknown; a filename never proves baking. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AssetRepresentationPolicy { + pub render_mode: AssetRenderMode, + #[serde(alias = "effectsBakedIntoAsset")] + pub effects_baked: Option, + #[serde(alias = "sourceSize")] + pub source_pixel_size: Option, + #[serde(alias = "pixelSize", alias = "outputSize")] + pub output_pixel_size: Option, + #[serde(alias = "nodeSize")] + pub node_box: Option, + #[serde(alias = "publicPath")] + pub asset_path: Option, + pub variants: Vec, + pub capture_node_id: Option, + pub capture_box: Option, + pub context_scope: Option, +} + +impl AssetRepresentationPolicy { + pub(super) fn original(&self) -> bool { + matches!( + self.render_mode, + AssetRenderMode::OriginalSource | AssetRenderMode::Fill + ) + } + pub(super) fn incompatible_capture(&self, node_id: &str) -> bool { + self.render_mode == AssetRenderMode::Visible + && self + .capture_node_id + .as_deref() + .is_some_and(|id| id != node_id) + } +} + +pub(super) fn policy_for( + node: &RawNode, + asset_id: &str, + policies: &BTreeMap, +) -> AssetRepresentationPolicy { + policies + .get(asset_id) + .or_else(|| policies.get(&node.id)) + .cloned() + .unwrap_or_default() +} + +pub(super) fn original_image( + node: &RawNode, + policies: &BTreeMap, +) -> bool { + if policies.get(&node.id).is_some_and(|p| !p.original()) { + return false; + } + node.typed_view() + .value("fills") + .and_then(Value::as_array) + .is_some_and(|fills| { + let images = fills + .iter() + .enumerate() + .filter(|(_, p)| p["type"] == "IMAGE" && p["visible"] != false) + .collect::>(); + !images.is_empty() + && images.iter().all(|(i, _)| { + policy_for(node, &format!("{}:fills:{i}", node.id), policies).original() + }) + }) +} + +pub(super) fn selected_image_index( + node: &RawNode, + policies: &BTreeMap, +) -> Option { + let fills = node.typed_view().value("fills")?.as_array()?; + fills + .iter() + .enumerate() + .find(|(i, p)| { + p["type"] == "IMAGE" + && p["visible"] != false + && !policy_for(node, &format!("{}:fills:{i}", node.id), policies).original() + }) + .map(|(i, _)| i) + .or_else(|| { + fills + .iter() + .position(|p| p["type"] == "IMAGE" && p["visible"] != false) + }) +} + +pub(super) fn asset_kind( + snapshot: &Snapshot, + node: &RawNode, + policies: &BTreeMap, +) -> Option { + if policies.get(&node.id).is_some_and(|p| !p.original()) { + return Some(style::AssetKind::Png); + } + let native = node + .typed_view() + .value("fills") + .and_then(Value::as_array) + .is_some_and(|fs| { + fs.iter().enumerate().any(|(i, p)| { + p["type"] == "IMAGE" + && p["visible"] != false + && !policy_for(node, &format!("{}:fills:{i}", node.id), policies).original() + }) + }); + if native { + Some(style::AssetKind::Png) + } else { + let kind = style::asset_kind(snapshot, node); + if kind == Some(style::AssetKind::Png) && !original_image(node, policies) { + let mut pending = node + .typed_view() + .child_ids() + .map(str::to_owned) + .collect::>(); + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id.clone()) { + continue; + } + if let Some(child) = snapshot.nodes.get(&id) { + if original_image(child, policies) { + return None; + } + pending.extend(child.typed_view().child_ids().map(str::to_owned)); + } + } + } + kind + } +} + +fn fields(node: &Value) -> &Value { + node.get("fields").unwrap_or(node) +} +fn size(value: &Value) -> Option { + let width = value.get("width").or_else(|| value.get("w"))?.as_f64()?; + let height = value.get("height").or_else(|| value.get("h"))?.as_f64()?; + (width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) + .then_some(AssetPixelSize { width, height }) +} +fn valid_size(value: Option) -> Option { + value.filter(|s| s.width.is_finite() && s.height.is_finite() && s.width > 0.0 && s.height > 0.0) +} +fn node_size(node: &Value, policy: &AssetRepresentationPolicy) -> Option { + valid_size(policy.node_box) + .or_else(|| size(fields(node))) + .or_else(|| size(&fields(node)["absoluteBoundingBox"])) +} +fn paint<'a>(node: &'a Value, asset_id: &str) -> Option<&'a Value> { + let index = asset_id + .rsplit_once(":fills:") + .and_then(|(_, s)| s.parse::().ok()); + let fills = fields(node)["fills"].as_array()?; + index + .and_then(|i| fills.get(i)) + .or_else(|| fills.iter().find(|p| p["type"] == "IMAGE")) +} + +type Matrix = [[f64; 3]; 2]; +const IDENTITY: Matrix = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; +fn matrix(value: &Value) -> Option { + let rows = value.as_array()?; + if rows.len() != 2 { + return None; + } + let row = |i: usize| -> Option<[f64; 3]> { + let r = rows[i].as_array()?; + if r.len() != 3 { + return None; + } + let v = [r[0].as_f64()?, r[1].as_f64()?, r[2].as_f64()?]; + v.iter().all(|v| v.is_finite()).then_some(v) + }; + Some([row(0)?, row(1)?]) +} +fn inverse(m: Matrix) -> Option { + let [[a, b, c], [d, e, f]] = m; + let det = a * e - b * d; + if !det.is_finite() || det.abs() < 1e-10 { + return None; + } + Some([ + [e / det, -b / det, (b * f - c * e) / det], + [-d / det, a / det, (c * d - a * f) / det], + ]) +} +fn point(m: Matrix, p: [f64; 2]) -> [f64; 2] { + [ + m[0][0] * p[0] + m[0][1] * p[1] + m[0][2], + m[1][0] * p[0] + m[1][1] * p[1] + m[1][2], + ] +} +fn corners(m: Matrix) -> Vec<[f64; 2]> { + [[0., 0.], [1., 0.], [1., 1.], [0., 1.]] + .map(|p| point(m, p)) + .to_vec() +} +fn clipped_polygon(mut poly: Vec<[f64; 2]>) -> Vec<[f64; 2]> { + for (axis, edge, sign) in [(0, 0., 1.), (0, 1., -1.), (1, 0., 1.), (1, 1., -1.)] { + let input = std::mem::take(&mut poly); + if input.is_empty() { + break; + } + let mut previous = *input.last().unwrap(); + for current in input { + let inside = |p: [f64; 2]| (p[axis] - edge) * sign >= 0.; + if inside(current) != inside(previous) { + let t = (edge - previous[axis]) / (current[axis] - previous[axis]); + poly.push([ + previous[0] + t * (current[0] - previous[0]), + previous[1] + t * (current[1] - previous[1]), + ]); + } + if inside(current) { + poly.push(current); + } + previous = current; + } + } + poly +} +fn coverage(m: Matrix) -> Value { + // Map the node's unit-square viewport INTO normalized source coordinates, + // then intersect with the source unit square. Bounds are not polygon area. + let polygon = clipped_polygon(corners(m)); + let mut area = 0.; + for i in 0..polygon.len() { + let a = polygon[i]; + let b = polygon[(i + 1) % polygon.len()]; + area += a[0] * b[1] - b[0] * a[1]; + } + let bounds = if polygon.is_empty() { + json!({"x":0,"y":0,"width":0,"height":0}) + } else { + let min = |axis: usize| { + polygon + .iter() + .map(|p| p[axis]) + .fold(f64::INFINITY, f64::min) + }; + let max = |axis: usize| { + polygon + .iter() + .map(|p| p[axis]) + .fold(f64::NEG_INFINITY, f64::max) + }; + json!({"x":min(0),"y":min(1),"width":max(0)-min(0),"height":max(1)-min(1)}) + }; + json!({"known":true,"w":bounds["width"],"h":bounds["height"],"area":(area.abs()/2.).clamp(0.,1.),"bounds":bounds,"polygon":polygon,"coordinateSpace":"normalized-source-unit-square","method":"viewport-polygon-intersection"}) +} + +#[derive(Default)] +struct Projection { + image: BTreeMap, + background: Option, + forward: Option, + sampling: Option, + visible: Value, + reasons: Vec, +} +fn projection(node: &Value, asset_id: &str, policy: &AssetRepresentationPolicy) -> Projection { + let mut out = Projection { + visible: json!({"known":false,"reason":"source-dimensions-unavailable"}), + ..Projection::default() + }; + if !policy.original() { + out.image.insert("objectFit".into(), "fill".into()); + out.background = Some("0 0/100% 100% no-repeat".into()); + out.visible = json!({"known":false,"reason":"baked-render-source-coverage-unverified"}); + return out; + } + let Some(p) = paint(node, asset_id) else { + out.reasons.push("image-paint-unavailable".into()); + return out; + }; + let mode = p["scaleMode"].as_str().unwrap_or("FILL"); + if mode == "CROP" { + let Some(t) = matrix(&p["imageTransform"]) else { + out.reasons.push("crop-transform-missing-or-invalid".into()); + out.visible = json!({"known":false,"reason":"crop-transform-missing-or-invalid"}); + return out; + }; + let Some(forward) = inverse(t) else { + out.reasons.push("singular-image-transform".into()); + out.visible = json!({"known":false,"reason":"singular-image-transform"}); + return out; + }; + out.visible = coverage(t); + out.sampling = Some(t); + out.forward = Some(forward); + let [[a, b, c], [d, e, f]] = t; + if a > 0. + && e > 0. + && b.abs() < 1e-10 + && d.abs() < 1e-10 + && ((1. - a).abs() > 1e-10 || c.abs() < 1e-10) + && ((1. - e).abs() > 1e-10 || f.abs() < 1e-10) + { + let x = if (1. - a).abs() < 1e-10 { + 0. + } else { + c / (1. - a) * 100. + }; + let y = if (1. - e).abs() < 1e-10 { + 0. + } else { + f / (1. - e) * 100. + }; + out.background = Some(format!( + "{}% {}%/{}% {}% no-repeat", + number(x), + number(y), + number(100. / a), + number(100. / e) + )); + } + out.image.insert("objectFit".into(), "fill".into()); + } else if matches!(mode, "FILL" | "FIT") { + let fit = if mode == "FIT" { "contain" } else { "cover" }; + out.image.insert("objectFit".into(), fit.into()); + out.image.insert("objectPos".into(), "center".into()); + out.background = Some(format!("center/{fit} no-repeat")); + // Figma documents imageTransform as CROP-only. An incidental matrix + // on FILL/FIT is not evidence that its diagonal measures coverage. + if matrix(&p["imageTransform"]).is_some_and(|t| t != IDENTITY) { + out.reasons + .push("image-transform-inapplicable-to-scale-mode".into()); + } + let rotation = p["rotation"].as_f64().unwrap_or(0.); + if !rotation.is_finite() || (rotation / 90. - (rotation / 90.).round()).abs() > 1e-8 { + out.reasons.push("unsupported-paint-rotation".into()); + out.visible = json!({"known":false,"reason":"unsupported-paint-rotation"}); + return out; + } + if let (Some(source), Some(box_size)) = ( + valid_size(policy.source_pixel_size), + node_size(node, policy), + ) { + let r = rotation.to_radians(); + let (c, s) = (r.cos(), r.sin()); + let rw = c.abs() * source.width + s.abs() * source.height; + let rh = s.abs() * source.width + c.abs() * source.height; + let scale = if mode == "FIT" { + (box_size.width / rw).min(box_size.height / rh) + } else { + (box_size.width / rw).max(box_size.height / rh) + }; + let a = scale * c * source.width / box_size.width; + let b = -scale * s * source.height / box_size.width; + let d = scale * s * source.width / box_size.height; + let e = scale * c * source.height / box_size.height; + let forward = [[a, b, (1. - a - b) / 2.], [d, e, (1. - d - e) / 2.]]; + out.sampling = inverse(forward); + out.visible = coverage(inverse(forward).unwrap()); + if rotation.rem_euclid(360.).abs() > 1e-8 { + out.forward = Some(forward); + out.background = None; + out.image.insert("objectFit".into(), "fill".into()); + } + } else if rotation != 0. { + out.reasons + .push("rotation-requires-source-and-node-dimensions".into()); + } + } else if mode == "TILE" { + out.background = Some(if let Some(source) = valid_size(policy.source_pixel_size) { + let factor = p["scalingFactor"] + .as_f64() + .filter(|n| n.is_finite() && *n > 0.) + .unwrap_or(1.); + format!( + "0 0/{}px {}px repeat", + number(source.width * factor), + number(source.height * factor) + ) + } else { + "repeat".into() + }); + out.visible = json!({"known":false,"reason":"tile-repetition-has-no-single-crop-polygon"}); + if p["rotation"] + .as_f64() + .is_some_and(|r| r.rem_euclid(360.) != 0.) + { + out.reasons.push("tile-rotation-unrepresentable".into()); + } + } else { + out.visible = json!({"known":false,"reason":"unsupported-image-scale-mode"}); + out.reasons.push("unsupported-image-scale-mode".into()); + } + if let Some(m) = out.forward { + if let Some(b) = node_size(node, policy) { + let [[a, x, tx], [y, d, ty]] = m; + out.image.insert( + "transform".into(), + format!( + "translate({}%, {}%) matrix({}, {}, {}, {}, 0, 0)", + number(tx * 100.), + number(ty * 100.), + number(a), + number(y * b.height / b.width), + number(x * b.width / b.height), + number(d) + ), + ); + out.image.insert("transformOrigin".into(), "0 0".into()); + } else { + out.reasons + .push("affine-projection-requires-node-box".into()); + } + } + out +} + +/// Reusable acquisition-to-CSS mapping. `node` accepts a RawNode JSON object +/// or its fields. `css` uses DevupUI prop names; intrinsic attributes are separate. +pub fn asset_css_map( + node: &Value, + asset_id: &str, + asset_path: &str, + policy: &AssetRepresentationPolicy, +) -> Value { + let f = fields(node); + let node_id = node["id"] + .as_str() + .or_else(|| asset_id.rsplit_once(":fills:").map(|(id, _)| id)) + .unwrap_or(asset_id); + let p = paint(node, asset_id); + let mut projected = projection(node, asset_id, policy); + if policy.original() + && policy.effects_baked != Some(true) + && let Some(opacity) = p.and_then(|p| p["opacity"].as_f64()).filter(|n| *n < 1.) + { + projected.image.insert("opacity".into(), number(opacity)); + } + let box_size = node_size(node, policy); + let path = policy.asset_path.as_deref().unwrap_or(asset_path); + let baked = policy.effects_baked == Some(true); + if policy.effects_baked.is_none() { + projected + .reasons + .push("effects-baked-state-unverified".into()); + } + if policy.incompatible_capture(node_id) { + projected + .reasons + .push("incompatible-visible-capture".into()); + } + let mut wrapper: BTreeMap = BTreeMap::new(); + if let Some(b) = box_size { + wrapper.insert( + "aspectRatio".into(), + format!("{} / {}", number(b.width), number(b.height)), + ); + } + wrapper.insert("minW".into(), "0".into()); + wrapper.insert("minH".into(), "0".into()); + let clip=projected.image.contains_key("transform").then(||json!({"pos":"relative","w":"100%","h":"100%","overflow":"hidden","borderRadius":"inherit"})); + let raw: RawNode = serde_json::from_value( + json!({"id":node_id,"type":node["type"].as_str().unwrap_or("RECTANGLE"),"fields":f}), + ) + .expect("synthetic node has required fields"); + let effects = if baked || (!policy.original() && policy.effects_baked.is_none()) { + BTreeMap::new() + } else { + style::image_effect_props(&raw) + }; + if f["isMask"] == true { + projected.visible = json!({"known":false,"reason":"mask-aperture-unavailable"}); + projected.reasons.push("mask-aperture-unavailable".into()); + } + if p.and_then(|p| p["filters"].as_object()) + .is_some_and(|fs| fs.values().any(|v| v.as_f64().is_some_and(|n| n != 0.))) + { + projected + .reasons + .push("figma-image-filters-unrepresentable".into()); + } + if matches!( + node["type"].as_str(), + Some("VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION") + ) { + projected + .reasons + .push("original-source-vector-shape-unrepresented".into()); + } + if f["cornerSmoothing"].as_f64().is_some_and(|n| n > 0.) { + projected + .reasons + .push("figma-corner-smoothing-approximated".into()); + } + if p.and_then(|p| p["blendMode"].as_str()) + .is_some_and(|m| matches!(m, "LINEAR_BURN" | "LINEAR_DODGE")) + { + projected + .reasons + .push("paint-blend-mode-unrepresentable".into()); + } + let intrinsic = if policy.original() { + valid_size(policy.output_pixel_size).or_else(|| valid_size(policy.source_pixel_size)) + } else { + valid_size(policy.output_pixel_size) + }; + let base = policy + .variants + .iter() + .find(|v| (v.density - 1.).abs() < 1e-9 && v.width > 0 && v.height > 0) + .map(|v| AssetPixelSize { + width: v.width as f64, + height: v.height as f64, + }) + .or_else(|| valid_size(policy.output_pixel_size)); + let mut variants = policy + .variants + .iter() + .filter(|v| { + !v.path.is_empty() + && v.width > 0 + && v.height > 0 + && v.density.is_finite() + && v.density > 0. + }) + .filter(|v| { + base.is_some_and(|b| { + (v.width as f64 - b.width * v.density).abs() <= 1. + && (v.height as f64 - b.height * v.density).abs() <= 1. + }) + }) + .collect::>(); + if variants.len() != policy.variants.len() { + projected + .reasons + .push("variant-density-inconsistent-with-encoded-dimensions".into()); + } + variants.sort_by(|a, b| a.density.total_cmp(&b.density)); + variants.dedup_by(|a, b| a.density == b.density); + let src_set = (!variants.is_empty()).then(|| { + variants + .iter() + .map(|v| format!("{} {}x", v.path, number(v.density))) + .collect::>() + .join(", ") + }); + let sizes = src_set + .as_ref() + .and_then(|_| box_size.map(|b| format!("{}px", number(b.width)))); + let effective = |s: Option| { + s.zip(box_size).map(|(s,b)|json!({"x":s.width/b.width,"y":s.height/b.height,"minimum":(s.width/b.width).min(s.height/b.height),"basis":"pixel-dimensions-divided-by-layout-node-box"})) + }; + let source_effective = effective(valid_size(policy.source_pixel_size)); + let output_effective = effective(valid_size(policy.output_pixel_size)); + let projected_scale = |pixels: Option| { + pixels.zip(box_size).zip(projected.sampling).map(|((s,b),m)| { + let a=s.width*m[0][0]/b.width;let c=s.width*m[0][1]/b.height; + let d=s.height*m[1][0]/b.width;let e=s.height*m[1][1]/b.height; + let trace=a*a+c*c+d*d+e*e;let det=a*e-c*d; + json!({"x":a.hypot(d),"y":c.hypot(e),"minimum":((trace-(trace*trace-4.*det*det).max(0.).sqrt())/2.).max(0.).sqrt(),"basis":"singular-values-of-source-pixels-per-node-pixel-normalized-sampling-transform"}) + }) + }; + let projected_source_effective = projected_scale(valid_size(policy.source_pixel_size)); + let projected_output_effective = if policy.original() { + projected_scale(valid_size(policy.output_pixel_size)) + } else { + output_effective.clone() + }; + if let Some(e) = if policy.original() { + projected_output_effective + .as_ref() + .or(projected_source_effective.as_ref()) + .or(source_effective.as_ref()) + } else { + output_effective.as_ref() + } && e["minimum"].as_f64().is_some_and(|n| n < 1.5) + { + projected.reasons.push("low-resolution-bitmap".into()); + } + if intrinsic.is_none() { + projected + .reasons + .push("intrinsic-pixel-dimensions-unavailable".into()); + } + let property_evidence=effects.iter().map(|(property,value)|json!({"property":property,"value":value,"appliedBy":"css","source":"node.effects/radii/strokes/opacity/blendMode"})).collect::>(); + json!({"assetId":asset_id,"nodeId":node_id,"instanceIdentity":node_id,"instancePath":f.get("instancePath").or_else(||f.get("parentId")),"assetPath":path, + "renderMode":policy.render_mode,"effectsBaked":policy.effects_baked,"effectsAppliedBy":if baked{json!("asset")}else if policy.original()||policy.effects_baked==Some(false){json!("css")}else{Value::Null}, + "sourcePixelSize":policy.source_pixel_size,"outputPixelSize":policy.output_pixel_size,"nodeBox":box_size, + "layoutSizing":{"horizontal":f["layoutSizingHorizontal"],"vertical":f["layoutSizingVertical"]}, + "captureNodeId":policy.capture_node_id,"captureBox":policy.capture_box,"contextScope":policy.context_scope, + "scaleMode":p.and_then(|p|p.get("scaleMode")),"imageTransform":p.and_then(|p|p.get("imageTransform")), + "paintLayer":{"fillIndex":asset_id.rsplit_once(":fills:").and_then(|(_,i)|i.parse::().ok()),"opacity":p.and_then(|p|p.get("opacity")),"blendMode":p.and_then(|p|p.get("blendMode")),"nodeOpacity":f["opacity"],"nodeBlendMode":f["blendMode"]}, + "coordinateSemantics":{"imageTransform":"CROP: normalized node viewport to normalized source; inverse projects source into viewport","cropConventionEvidence":"inferred normalized sampling convention; official Paint API documents applicability but not direction; no canvas parity claim","fillFit":"centered aspect-preserving cover/contain; imageTransform is inapplicable","cssMatrix":"conjugate normalized inverse by node pixel dimensions; percentage translation precedes linear matrix","coverage":"source unit square intersected with transformed viewport polygon","scope":"node paint only; ancestor masks/clips and canvas composition are not established"}, + "visibleSourceRatio":projected.visible,"cropApplied":if policy.original(){json!(false)}else{Value::Null},"cssCropApplied":policy.original()&&projected.forward.is_some(), + "css":{"wrapper":wrapper,"clip":clip,"image":projected.image,"effects":effects,"background":projected.background}, + "composition":{"applyEffectsTo":"outer-node-after-paint-clipping","paintOrder":"fills array bottom-to-top; live children above paint elements","affineImage":"full source in a 100% sized absolute Image inside a relative overflow-hidden clip","hugAxes":"clip uses measured nodeBox content dimension where the outer HUG axis has no definite CSS size","coverageScope":"rectangular-node-viewport-before-radius-mask-and-ancestor-composition","shapeClippingIncludedInCoverage":false}, + "intrinsicAttributes":intrinsic.map(|s|json!({"width":s.width,"height":s.height})),"srcSet":src_set,"sizes":sizes, + "sourceEffectiveScale":source_effective,"outputEffectiveScale":output_effective,"projectedSourceEffectiveScale":projected_source_effective,"projectedOutputEffectiveScale":projected_output_effective,"variants":variants, + "propertyEvidence":property_evidence,"warnings":projected.reasons,"unsupported":projected.reasons.iter().filter(|s|!matches!(s.as_str(),"image-transform-inapplicable-to-scale-mode"|"low-resolution-bitmap"|"intrinsic-pixel-dimensions-unavailable"|"effects-baked-state-unverified")).collect::>()}) +} + +pub(super) fn apply_image_props(props: &mut Vec, mapping: &Value) { + for (name, value) in mapping["css"]["image"].as_object().into_iter().flatten() { + if matches!(name.as_str(), "transform" | "transformOrigin" | "opacity") { + continue; + } + if let Some(v) = value.as_str() { + string_prop(props, name, v); + } + } + for name in ["width", "height"] { + if let Some(v) = mapping["intrinsicAttributes"][name].as_f64() { + string_prop(props, name, number(v)); + } + } + for name in ["srcSet", "sizes"] { + if let Some(v) = mapping[name].as_str() { + string_prop(props, name, v); + } + } + for name in ["minW", "minH", "aspectRatio"] { + if let Some(v) = mapping["css"]["wrapper"][name].as_str() { + string_prop(props, name, v); + } + } +} + +/// A HUG image contributes its measured display content, rather than the +/// unrelated pixel dimensions of the full source file. Embedded roots keep +/// their host sizing contract and never use this standalone content anchor. +pub(super) fn reserve_hug_content(props: &mut Vec, mapping: &Value) { + if mapping["layoutSizing"]["horizontal"] != "HUG" + || mapping["layoutSizing"]["vertical"] != "HUG" + || !mapping["css"]["image"]["transform"].is_null() + || props + .iter() + .any(|(n, _)| matches!(n.as_str(), "w" | "h" | "boxSize")) + { + return; + } + for (dimension, css) in [("width", "w"), ("height", "h")] { + if let Some(value) = mapping["nodeBox"][dimension].as_f64() { + string_prop(props, css, format!("{}px", number(value))); + } + } +} + +fn element(component: &str, props: &[Prop], children: Option<&str>, depth: usize) -> String { + let indent = " ".repeat(depth); + let (opening, multiline) = super::component::render_props(props, depth); + match children { + Some(content) => format!( + "{indent}<{component}{opening}{}>\n{content}\n{indent}", + if multiline { + format!("\n{indent}") + } else { + String::new() + } + ), + None => format!( + "{indent}<{component}{opening}{} />", + if multiline { + format!("\n{indent}") + } else { + String::new() + } + ), + } +} + +pub(super) fn render_affine_image( + props: &[Prop], + mapping: &Value, + depth: usize, + reserve_content: bool, +) -> Option { + mapping["css"]["image"]["transform"].as_str()?; + let image_names = [ + "src", + "srcSet", + "sizes", + "width", + "height", + "objectFit", + "objectPos", + "alt", + ]; + let mut outer = props + .iter() + .filter(|(n, _)| !image_names.contains(&n.as_str())) + .cloned() + .collect::>(); + // Layout transforms stay on the outer node; the paint transform belongs + // only to the inner full-source image. + if !outer.iter().any(|(n, _)| n == "pos") { + string_prop(&mut outer, "pos", "relative"); + } + let mut image = props + .iter() + .filter(|(n, _)| image_names.contains(&n.as_str())) + .cloned() + .collect::>(); + for (name, value) in mapping["css"]["image"].as_object().into_iter().flatten() { + if let Some(v) = value.as_str() { + string_prop(&mut image, name, v); + } + } + for (name, value) in [ + ("w", "100%"), + ("h", "100%"), + ("pos", "absolute"), + ("left", "0"), + ("top", "0"), + ("maxW", "none"), + ("display", "block"), + ] { + string_prop(&mut image, name, value); + } + let mut clip = Vec::new(); + for (name, value) in [ + ("w", "100%"), + ("h", "100%"), + ("pos", "relative"), + ("overflow", "hidden"), + ("borderRadius", "inherit"), + ] { + string_prop(&mut clip, name, value); + } + for (dimension, css, axis) in [("width", "w", "horizontal"), ("height", "h", "vertical")] { + if reserve_content + && mapping["layoutSizing"][axis] == "HUG" + && !outer.iter().any(|(n, _)| n == css || n == "boxSize") + && let Some(value) = mapping["nodeBox"][dimension].as_f64() + { + string_prop(&mut clip, css, format!("{}px", number(value))); + } + } + Some(element( + "Box", + &outer, + Some(&element( + "Box", + &clip, + Some(&element("Image", &image, None, depth + 2)), + depth + 1, + )), + depth, + )) +} + +/// Separate paint elements support per-paint opacity/blending and affine +/// images while keeping live children outside the image's crop wrapper. +pub(super) fn render_paint_layers( + snapshot: &Snapshot, + node: &RawNode, + options: style::StyleOptions<'_>, + used: &mut BTreeSet, + depth: usize, +) -> Option> { + if style::non_rendering_asset_reason(snapshot, node).is_some() { + return None; + } + let paints = node.typed_view().value("fills")?.as_array()?; + let images = paints + .iter() + .enumerate() + .filter(|(_, p)| p["type"] == "IMAGE" && p["visible"] != false) + .collect::>(); + if images.is_empty() + || images.iter().any(|(i, _)| { + !policy_for( + node, + &format!("{}:fills:{i}", node.id), + options.asset_policies, + ) + .original() + }) + { + return None; + } + let mut layers = Vec::new(); + for (index, paint) in paints + .iter() + .enumerate() + .filter(|(_, p)| p["visible"] != false && p["opacity"] != 0) + { + let mut layer = Vec::new(); + for (name, value) in [ + ("pos", "absolute"), + ("inset", "0"), + ("zIndex", "-1"), + ("pointerEvents", "none"), + ("overflow", "hidden"), + ("borderRadius", "inherit"), + ] { + string_prop(&mut layer, name, value); + } + if let Some(blend) = paint["blendMode"].as_str().and_then(style::blend_mode) { + string_prop(&mut layer, "mixBlendMode", blend); + } + let child = if paint["type"] == "IMAGE" && paint["scaleMode"] != "TILE" { + let mapping = map_for( + snapshot, + node, + Some(index), + options.asset_policies, + options.asset_names_per_node, + ); + let mut image = Vec::new(); + string_prop( + &mut image, + "src", + mapping["assetPath"].as_str().unwrap_or_default(), + ); + string_prop(&mut image, "alt", ""); + apply_image_props(&mut image, &mapping); + image.retain(|(n, _)| !matches!(n.as_str(), "aspectRatio" | "minW" | "minH")); + for (name, value) in mapping["css"]["image"].as_object().into_iter().flatten() { + if name == "opacity" { + continue; + } + if let Some(v) = value.as_str() { + string_prop(&mut image, name, v); + } + } + for (name, value) in [ + ("pos", "absolute"), + ("inset", "0"), + ("w", "100%"), + ("h", "100%"), + ("maxW", "none"), + ("display", "block"), + ] { + string_prop(&mut image, name, value); + } + if let Some(opacity) = paint["opacity"].as_f64() + && opacity < 1. + { + string_prop(&mut layer, "opacity", number(opacity)); + } + Some(element("Image", &image, None, depth + 1)) + } else { + if let Some(css) = style::paint_css(snapshot, node, paint, index, false, used, options) + { + string_prop(&mut layer, "bg", css); + } + None + }; + layers.push(element("Box", &layer, child.as_deref(), depth)); + } + Some(layers) +} + +/// Warn only for asset references and CSS on the same JSX element. Parsing +/// excludes comments/text and prevents unrelated ancestor effects matching. +pub fn validate_asset_effects( + tsx: &str, + policies: &BTreeMap, +) -> Vec { + let attrs = crate::provenance::attributes::generated_attributes(tsx); + let mut diagnostics = Vec::new(); + for (asset_id, policy) in policies { + if policy.effects_baked != Some(true) { + continue; + } + let paths = policy + .asset_path + .iter() + .map(String::as_str) + .chain(policy.variants.iter().map(|v| v.path.as_str())) + .collect::>(); + let elements = attrs + .iter() + .filter(|a| { + matches!( + a.name.as_str(), + "src" | "bg" | "background" | "backgroundImage" + ) && literal_attribute(&a.source).is_some_and(|v| { + paths.iter().any(|p| { + !p.is_empty() && (v == *p || background_urls(v).iter().any(|url| url == p)) + }) + }) + }) + .map(|a| a.element) + .collect::>(); + for element in elements { + let repeated = attrs + .iter() + .filter(|a| { + a.element == element + && matches!( + a.name.as_str(), + "filter" | "boxShadow" | "textShadow" | "backdropFilter" + ) + && literal_attribute(&a.source).is_some_and(|v| { + !matches!(v.trim(), "none" | "initial" | "inherit" | "unset" | "") + }) + }) + .map(|a| a.source.clone()) + .collect::>(); + if !repeated.is_empty() { + diagnostics.push(Diagnostic{code:"DEVUP_CODEGEN_DOUBLE_EFFECT".into(),message:"CSS effects on a baked asset create a double-effect risk; verify the effect kinds before applying them together.".into(),severity:Some(DiagnosticSeverity::Warning),details:Some(json!({"assetId":asset_id,"elementIndex":element,"effectsBaked":true,"generatedProperties":repeated})),..Diagnostic::default()}); + } + } + } + diagnostics +} + +fn literal_attribute(source: &str) -> Option<&str> { + let (_, value) = source.split_once('=')?; + let value = value.trim(); + let value = value + .strip_prefix('{') + .and_then(|v| v.strip_suffix('}')) + .unwrap_or(value) + .trim(); + value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .filter(|v| !v.contains(['"', '\\'])) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|v| v.strip_suffix('\'')) + .filter(|v| !v.contains(['\'', '\\'])) + }) +} +fn background_urls(value: &str) -> Vec<&str> { + value + .split("url(") + .skip(1) + .filter_map(|v| { + v.split_once(')') + .map(|(url, _)| url.trim().trim_matches(['\'', '"'])) + }) + .collect() +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/codegen/image/project_tree.rs b/crates/devup-mcp-devup-ui/src/codegen/image/project_tree.rs new file mode 100644 index 00000000..9f5d4c9c --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/image/project_tree.rs @@ -0,0 +1,416 @@ + + +/// Variant/responsive trees use the same representation and paint geometry +/// as the ordinary renderer. Synthetic paint elements inherit provenance +/// from their outer source node rather than inventing Figma node identities. +pub(super) fn project_tree( + mut tree: super::variant::Tree, + snapshot: &Snapshot, + node: &RawNode, + options: &super::CodegenOptions, +) -> super::variant::Tree { + if style::non_rendering_asset_reason(snapshot, node).is_some() { + return tree; + } + use super::variant::Tree; + let synthetic = + |component: &str, props: BTreeMap, children: Vec, name: &str| Tree { + node_id: String::new(), + node_name: name.into(), + source_node_ids: BTreeSet::new(), + component: component.into(), + props, + children, + content: None, + is_component: false, + visible_when: None, + drawn_when: Vec::new(), + leading_comment: None, + slots: Vec::new(), + }; + let visible = node.typed_view().value("fills").and_then(Value::as_array); + if tree.component == "Image" { + let index = visible.and_then(|fs| { + fs.iter() + .position(|p| p["type"] == "IMAGE" && p["visible"] != false) + }); + if index.is_none() { + return tree; + } + let mapping = map_for( + snapshot, + node, + index, + &options.asset_policies, + options.asset_names_per_node, + ); + if mapping["css"]["image"]["transform"].is_null() { + return tree; + } + let image_names = [ + "src", + "srcSet", + "sizes", + "width", + "height", + "objectFit", + "objectPos", + "alt", + ]; + let mut image = tree + .props + .iter() + .filter(|(n, _)| image_names.contains(&n.as_str())) + .map(|(n, v)| (n.clone(), v.clone())) + .collect::>(); + tree.props.retain(|n, _| !image_names.contains(&n.as_str())); + tree.component = "Box".into(); + tree.props.entry("pos".into()).or_insert("relative".into()); + for (n, v) in mapping["css"]["image"].as_object().into_iter().flatten() { + if let Some(v) = v.as_str() { + image.insert(n.clone(), v.into()); + } + } + for (n, v) in [ + ("w", "100%"), + ("h", "100%"), + ("pos", "absolute"), + ("left", "0"), + ("top", "0"), + ("maxW", "none"), + ("display", "block"), + ] { + image.insert(n.into(), v.into()); + } + let mut clip: BTreeMap = [ + ("w", "100%"), + ("h", "100%"), + ("pos", "relative"), + ("overflow", "hidden"), + ("borderRadius", "inherit"), + ] + .into_iter() + .map(|(n, v)| (n.into(), v.into())) + .collect(); + for (dimension, css, axis) in [("width", "w", "horizontal"), ("height", "h", "vertical")] { + if mapping["layoutSizing"][axis] == "HUG" + && !(options.root_layout == super::RootLayout::Embedded + && snapshot.roots.contains(&node.id)) + && !tree.props.contains_key(css) + && !tree.props.contains_key("boxSize") + && let Some(value) = mapping["nodeBox"][dimension].as_f64() + { + clip.insert(css.into(), format!("{}px", number(value))); + } + } + tree.children = vec![synthetic( + "Box", + clip, + vec![synthetic("Image", image, vec![], "source-image")], + "image-clip", + )]; + return tree; + } + if tree.component == "Text" { + return tree; + } + let Some(paints) = visible else { + return tree; + }; + if !paints.iter().enumerate().any(|(i, p)| { + p["type"] == "IMAGE" + && p["visible"] != false + && policy_for( + node, + &format!("{}:fills:{i}", node.id), + &options.asset_policies, + ) + .original() + }) { + return tree; + } + let style_options = style::StyleOptions { + reserve_image_content: !matches!(options.root_layout, super::RootLayout::Embedded), + asset_policies: &options.asset_policies, + variable_tokens: &options.variable_tokens, + length_tokens: &options.length_tokens, + asset_names_per_node: options.asset_names_per_node, + width_policy: options.width_policy.as_ref(), + }; + let mut layers = Vec::new(); + let mut used = BTreeSet::new(); + for (index, p) in paints + .iter() + .enumerate() + .filter(|(_, p)| p["visible"] != false && p["opacity"] != 0) + { + let mut props: BTreeMap = [ + ("pos", "absolute"), + ("inset", "0"), + ("zIndex", "-1"), + ("pointerEvents", "none"), + ("overflow", "hidden"), + ("borderRadius", "inherit"), + ] + .into_iter() + .map(|(n, v)| (n.into(), v.into())) + .collect(); + if let Some(blend) = p["blendMode"].as_str().and_then(style::blend_mode) { + props.insert("mixBlendMode".into(), blend); + } + let children = if p["type"] == "IMAGE" && p["scaleMode"] != "TILE" { + let mapping = map_for( + snapshot, + node, + Some(index), + &options.asset_policies, + options.asset_names_per_node, + ); + let mut image_props = Vec::new(); + apply_image_props(&mut image_props, &mapping); + let mut image = image_props + .into_iter() + .filter(|(n, _)| !matches!(n.as_str(), "aspectRatio" | "minW" | "minH")) + .map(|(n, super::component::PropValue::String(v))| (n, v)) + .collect::>(); + image.insert( + "src".into(), + mapping["assetPath"].as_str().unwrap_or_default().into(), + ); + image.insert("alt".into(), String::new()); + for (n, v) in mapping["css"]["image"].as_object().into_iter().flatten() { + if n == "opacity" { + continue; + } + if let Some(v) = v.as_str() { + image.insert(n.clone(), v.into()); + } + } + for (n, v) in [ + ("pos", "absolute"), + ("inset", "0"), + ("w", "100%"), + ("h", "100%"), + ("maxW", "none"), + ("display", "block"), + ] { + image.insert(n.into(), v.into()); + } + if let Some(opacity) = p["opacity"].as_f64() + && opacity < 1. + { + props.insert("opacity".into(), number(opacity)); + } + vec![synthetic( + "Image", + image, + vec![], + &format!("paint-{index}-image"), + )] + } else { + if let Some(css) = + style::paint_css(snapshot, node, p, index, false, &mut used, style_options) + { + props.insert("bg".into(), css); + } + vec![] + }; + layers.push(synthetic("Box", props, children, &format!("paint-{index}"))); + } + tree.props + .retain(|n, _| !matches!(n.as_str(), "bg" | "bgBlendMode" | "objectFit" | "objectPos")); + tree.props.entry("pos".into()).or_insert("relative".into()); + tree.props.insert("isolation".into(), "isolate".into()); + tree.children.splice(0..0, layers); + tree +} + +pub(super) fn map_for( + snapshot: &Snapshot, + node: &RawNode, + index: Option, + policies: &BTreeMap, + per_node: bool, +) -> Value { + let id = index + .map(|i| format!("{}:fills:{i}", node.id)) + .unwrap_or_else(|| node.id.clone()); + let default_path = index + .and_then(|i| style::image_fill_path(snapshot, &node.id, i, per_node)) + .or_else(|| style::asset_path(snapshot, &node.id, per_node)) + .unwrap_or_default(); + asset_css_map( + &serde_json::to_value(node).expect("RawNode serializes"), + &id, + &default_path, + &policy_for(node, &id, policies), + ) +} + +pub(super) fn attach_evidence( + snapshot: &Snapshot, + options: &super::CodegenOptions, + output: &mut super::CodegenOutput, +) { + use crate::provenance::{GeneratedRange, ProvenanceEntry}; + let attrs = crate::provenance::attributes::generated_attributes(&output.tsx); + let emitted = output + .source_map + .entries + .iter() + .filter(|e| e.property.is_none() && e.resolution == "node") + .filter_map(|e| Some((e.node_id.clone()?, e.generated_range.clone()?))) + .collect::>(); + for (id, range) in emitted { + let Some(node) = snapshot.nodes.get(&id) else { + continue; + }; + let indices = node + .typed_view() + .value("fills") + .and_then(Value::as_array) + .map(|fs| { + fs.iter() + .enumerate() + .filter(|(_, p)| p["type"] == "IMAGE" && p["visible"] != false) + .map(|(i, _)| Some(i)) + .collect::>() + }) + .unwrap_or_default(); + let indices = if indices.is_empty() + && style::asset_kind(snapshot, node) == Some(style::AssetKind::Png) + { + vec![None] + } else { + indices + }; + for index in indices { + let mut mapping = map_for( + snapshot, + node, + index, + &options.asset_policies, + options.asset_names_per_node, + ); + let path = mapping["assetPath"].as_str().unwrap_or_default().to_owned(); + let asset_id = mapping["assetId"].as_str().unwrap_or(&id).to_owned(); + // Nested nodes have their own range; only this node's properties + // can substantiate its mapping, even if another instance reuses bytes. + let own = attrs + .iter() + .filter(|a| range.start <= a.start && a.end <= range.end) + .filter(|a| { + !output.source_map.entries.iter().any(|e| { + e.property.is_none() + && e.node_id.as_deref() != Some(&id) + && e.generated_range.as_ref().is_some_and(|r| { + range.start <= r.start + && r.end <= range.end + && r.start <= a.start + && a.end <= r.end + }) + }) + }) + .collect::>(); + let references = own + .iter() + .filter(|a| { + matches!(a.name.as_str(), "src" | "bg") + && !path.is_empty() + && literal_attribute(&a.source).is_some_and(|value| { + value == path || background_urls(value).iter().any(|url| *url == path) + }) + }) + .collect::>(); + if references.is_empty() { + let policy = policy_for(node, &asset_id, &options.asset_policies); + if !policy.incompatible_capture(&id) { + continue; + } + mapping["emitted"] = json!(false); + mapping["effectsAppliedBy"] = Value::Null; + mapping["emittedProperties"] = json!([]); + mapping["propertyEvidence"] = json!([]); + output.diagnostics.push(Diagnostic{code:"DEVUP_CODEGEN_IMAGE_MAPPING_UNSUPPORTED".into(),message:"A visible capture of a different node cannot be composed into this leaf's layout box.".into(),node_id:Some(id.clone()),property:Some("fills".into()),fidelity_impact:Some(devup_mcp_figma::FidelityImpact::Lossy),details:Some(json!({"assetId":asset_id,"reason":"incompatible-visible-capture","captureNodeId":policy.capture_node_id,"captureBox":policy.capture_box,"emittedAssetReference":false})),..Diagnostic::default()}); + output.source_map.asset_css_mappings.push(mapping); + continue; + } + mapping["emitted"] = json!(true); + let properties=own.iter().map(|a|json!({"elementIndex":a.element,"name":a.name,"generatedProperty":a.source})).collect::>(); + mapping["emittedProperties"] = json!(properties); + // The public source map exposes verified emission, while the + // reusable recipe remains useful before code generation. + mapping["propertyEvidence"]=json!(own.iter().filter_map(|a| { + let source=match a.name.as_str(){"filter"|"boxShadow"|"backdropFilter"=>"effects","borderRadius"|"borderTopLeftRadius"|"borderTopRightRadius"|"borderBottomLeftRadius"|"borderBottomRightRadius"=>"radii","width"|"height"=>"acquired-pixel-dimensions","transform"|"transformOrigin"|"objectFit"|"objectPos"=>"image-paint-geometry","srcSet"|"sizes"=>"acquired-variants","opacity"=>"node-or-paint-opacity","mixBlendMode"|"bgBlendMode"=>"node-or-paint-blend-mode","bg"=>"paint-stack-gradient-or-solid",_=>return None}; + Some(json!({"property":a.name,"generatedProperty":a.source,"source":source,"elementIndex":a.element,"scope":"node-paint-stack","effectsAppliedBy":mapping["effectsAppliedBy"]})) + }).collect::>()); + for a in own.iter().filter(|a| { + matches!( + a.name.as_str(), + "width" + | "height" + | "srcSet" + | "sizes" + | "objectFit" + | "objectPos" + | "transform" + | "transformOrigin" + | "aspectRatio" + | "minW" + | "minH" + | "filter" + | "boxShadow" + | "backdropFilter" + ) + }) { + let property = match a.name.as_str() { + "width" | "height" => "sourcePixelSize", + "srcSet" | "sizes" => "assetVariants", + "filter" | "boxShadow" | "backdropFilter" => "effects", + _ => "fills", + }; + output.source_map.entries.push(ProvenanceEntry {generated_range:Some(GeneratedRange{start:a.start,end:a.end}),calculation:Some("Acquisition representation policy and image-paint geometry; see assetCssMappings for dimensions, coordinate semantics and limits.".into()),generated_property:Some(a.source.clone()),json_pointer:None,node_id:Some(id.clone()),property:Some(property.into()),variable_id:None,style_id:None,asset_id:Some(asset_id.clone()),resolution:"asset-css-projection".into(),element_index:Some(a.element)}); + } + for a in &references { + output.source_map.entries.push(ProvenanceEntry { + generated_range: Some(GeneratedRange { + start: a.start, + end: a.end, + }), + calculation: Some("Instance-specific acquired image path.".into()), + generated_property: Some(a.source.clone()), + json_pointer: None, + node_id: Some(id.clone()), + property: Some("fills".into()), + variable_id: None, + style_id: None, + asset_id: Some(asset_id.clone()), + resolution: "asset".into(), + element_index: Some(a.element), + }); + if mapping["effectsBaked"] == true { + let mut entry = output.source_map.entries.last().unwrap().clone(); + entry.property = Some("effects".into()); + entry.resolution = "effects-baked-into-asset".into(); + entry.calculation=Some("Supplied acquisition evidence confirms baked effects; the same CSS effects are suppressed.".into()); + output.source_map.entries.push(entry); + } + } + for reason in mapping["warnings"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + let unsupported = mapping["unsupported"] + .as_array() + .is_some_and(|xs| xs.iter().any(|x| x == reason)); + output.diagnostics.push(Diagnostic{code:if unsupported{"DEVUP_CODEGEN_IMAGE_MAPPING_UNSUPPORTED"}else{"DEVUP_CODEGEN_IMAGE_MAPPING_EVIDENCE"}.into(),message:format!("Image mapping: {reason}."),node_id:Some(id.clone()),property:Some("fills".into()),severity:Some(DiagnosticSeverity::Warning),fidelity_impact:Some(if unsupported{devup_mcp_figma::FidelityImpact::Lossy}else{devup_mcp_figma::FidelityImpact::Approximated}),details:Some(json!({"assetId":asset_id,"reason":reason,"renderMode":mapping["renderMode"],"evidenceLimit":mapping["coordinateSemantics"]["scope"]})),..Diagnostic::default()}); + } + output.source_map.asset_css_mappings.push(mapping); + } + } + output + .diagnostics + .extend(validate_asset_effects(&output.tsx, &options.asset_policies)); +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout.rs b/crates/devup-mcp-devup-ui/src/codegen/layout.rs index 838a0ee8..dcd32a4d 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/layout.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/layout.rs @@ -1,1744 +1,3 @@ -use devup_mcp_figma::{RawNode, Snapshot, TypedNode}; -use serde_json::Value; - -use super::component::{Prop, PropValue, RootLayout}; -use super::width::{Anchor, WidthPolicy}; - -/// A box in a parent's coordinates: left, top, width, height. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct Box4 { - pub x: f64, - pub y: f64, - pub w: f64, - pub h: f64, -} - -fn box4(value: Option<&Value>) -> Option { - let value = value?; - Some(Box4 { - x: value.get("x")?.as_f64()?, - y: value.get("y")?.as_f64()?, - w: value.get("width")?.as_f64()?, - h: value.get("height")?.as_f64()?, - }) -} - -/// What Figma exports for an asset node, in its parent's coordinates: the -/// node's render bounds, which frame its SVG and PNG exports - measured -/// against `exportAsync` on the official server, 2026-09-07: an instance of -/// 1373x98 whose vector sits inside it exports as 952x104, and a group of -/// 686x735 rotated four degrees exports as 759x585 with the rotation drawn -/// into the paths. `None` where the snapshot does not carry the bounds. -pub(crate) fn export_box(snapshot: &Snapshot, node: &RawNode) -> Option { - let view = node.typed_view(); - let render = box4(view.value("absoluteRenderBounds"))?; - let parent = view - .string("parentId") - .and_then(|parent_id| snapshot.nodes.get(parent_id))?; - let parent_box = box4(parent.typed_view().value("absoluteBoundingBox"))?; - Some(Box4 { - x: render.x - parent_box.x, - y: render.y - parent_box.y, - w: render.w, - h: render.h, - }) -} - -/// Where an in-flow asset's export sits inside the box the layout gives it, -/// and how large it is: the render bounds against the bounding box. `None` -/// when they coincide, which is every plain icon, or when the snapshot does -/// not carry them. -pub(super) fn export_offset(node: &RawNode) -> Option { - let view = node.typed_view(); - let render = box4(view.value("absoluteRenderBounds"))?; - let bounds = box4(view.value("absoluteBoundingBox"))?; - let close = |left: f64, right: f64| (left - right).abs() < 0.5; - if close(render.x, bounds.x) - && close(render.y, bounds.y) - && close(render.w, bounds.w) - && close(render.h, bounds.h) - { - return None; - } - Some(Box4 { - x: render.x - bounds.x, - y: render.y - bounds.y, - w: render.w, - h: render.h, - }) -} - -/// The box the layout gives an asset that is not positioned: its bounding -/// box, which is its own box unless it is rotated, when it is the box the -/// rotation sweeps - the box Figma's own layout gives it. -pub(crate) fn layout_box(node: &RawNode) -> Option { - box4(node.typed_view().value("absoluteBoundingBox")) -} - -// Root placement, width policy and image representation are independent -// inputs to this shared layout property writer. -#[allow(clippy::too_many_arguments)] -pub(super) fn push_layout_props( - snapshot: &Snapshot, - node: &RawNode, - component: &str, - props: &mut Vec, - root_layout: RootLayout, - is_render_root: bool, - policy: Option<&WidthPolicy>, - original_source: bool, -) { - let view = node.typed_view(); - let parent = snapshot.nodes.values().find(|candidate| { - candidate - .typed_view() - .child_ids() - .any(|child| child == node.id) - }); - let is_root = snapshot.roots.iter().any(|root| root == &node.id); - // 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"); - let fill_h = view.string("layoutSizingVertical") == Some("FILL"); - let absolute = view.string("layoutPositioning") == Some("ABSOLUTE") - || placed_by_a_free_layout(snapshot, node, parent, is_page_root); - let embedded_root = is_render_root && root_layout == RootLayout::Embedded; - let containing_block = holds_positioned_children(snapshot, node); - let mut width = None; - let mut height = None; - let mut policy_sizing = None; - - if embedded_root { - // The selected frame is being inserted into an existing page layout. - // Preserve its visual/layout semantics, but do not constrain the host - // with Figma canvas geometry or root positioning. - } else if absolute { - let is_asset = !original_source && super::style::asset_kind(snapshot, node).is_some(); - // An exported asset is placed by what the export frames. The plugin - // places it by the node's own box and rotates it again, and the - // report section's rotated illustration landed 120px low, squeezed - // into 686x735 where its export is 759x585. - let export = is_asset.then(|| export_box(snapshot, node)).flatten(); - let anchor = push_absolute(snapshot, node, parent, props, export, policy); - // Figma paints children in order, so a pinned picture drawn before - // its siblings sits behind them. CSS paints a positioned element - // after every in-flow sibling whatever the order, so the landing - // page's hero picture came out over its headline and the join-us - // badges over their buttons. Sent behind with `zIndex="-1"` - inside - // the stacking context the parent opens for it, below - it sits - // where Figma has it: above the parent's own background, under the - // content. - // - // Only under a parent that opens that context, including page roots. - if let Some(parent) = parent - && holds_positioned_children(snapshot, parent) - && sits_behind_in_flow_siblings(snapshot, parent, &node.id) - { - string_prop(props, "zIndex", "-1"); - } - if let Some(export) = export { - width = Some(px(export.w)); - height = Some(px(export.h)); - } - // The plugin's `_getLayoutProps` for a positioned node, as one rule - // rather than a branch per kind of node. Its width is its own only - // while the parent is wider and it is an asset or an empty frame; - // spilling past the parent it is `100%`, and a frame with children in - // it has no width said at all, its children being what sizes it. Its - // height is `100%` for a shape, its own for an empty frame, and unsaid - // once it has children. So the about hero picture, 418px in a 320px - // column, is `boxSize="100%"`, and a hidden 1920px frame in a 992px - // one is `w="100%" h="667px"`. - // - // Preserve the existing placement policy here. Only child-folded mask - // HUG axes are repaired below; other unproven sizes remain diagnostics. - let own_width = view.number("width"); - let parent_width = parent.and_then(|parent| parent.typed_view().number("width")); - if export.is_some() { - // Sized above, by the export. - } else if component == "Text" { - width = own_width.map(px); - height = Some("100%".to_owned()); - } else if view.node_type() == "INSTANCE" && is_asset { - // The plugin puts a positioned instance in a wrapper `Box` that - // carries the position, and draws the instance from its main - // component, which sizes itself by its own layout. An instance - // that folds to an asset is one element here, so it takes that - // size: a 651px logo pinned in a 360px banner is - // `w="651px" h="46px"`, not `w="100%"`. An instance kept as a - // component reference is sized as the wrapper is, below. - if fixed_w { - width = own_width.map(px); - } - if fixed_h { - height = view.number("height").map(px); - } - } else { - let parent_wider = matches!( - (own_width, parent_width), - (Some(width), Some(parent_width)) if parent_width > width - ); - let has_children = view.child_ids().next().is_some(); - let holds_children = matches!( - view.node_type(), - "FRAME" - | "GROUP" - | "INSTANCE" - | "COMPONENT" - | "COMPONENT_SET" - | "BOOLEAN_OPERATION" - ); - let empty_frame = holds_children && !has_children; - width = if parent_wider { - (is_asset || empty_frame) - .then(|| own_width.map(px)) - .flatten() - } else { - Some("100%".to_owned()) - }; - let wider_than_parent = matches!( - (own_width, parent_width), - (Some(width), Some(parent_width)) if width >= parent_width - ); - // A shape with nothing in it is as big as itself. `100%` is - // only the same thing when it covers its parent - the popup's - // dim overlay - and a 220px circle pinned in an 1,102px group - // said `h="100%"` and no width at all, which is no circle. - // - // Only inside a group. The plugin's rule says `100%` and no - // width for a small shape pinned in a frame as well, and the - // pinned corpus holds four such shapes; that is as wrong there, - // but no rendered screen in the corpus shows it, so it keeps - // byte parity until one does. A group is a different case in - // any event: it draws nothing and lays nothing out, so a shape - // in it can only ever be its own size. - let own_height = view.number("height"); - let parent_height = parent.and_then(|parent| parent.typed_view().number("height")); - let parent_taller = matches!( - (own_height, parent_height), - (Some(height), Some(parent_height)) if parent_height > height - ); - let in_a_group = - parent.is_some_and(|parent| parent.typed_view().node_type() == "GROUP"); - let leaf_shape = !has_children && !holds_children && !is_asset && in_a_group; - if leaf_shape && parent_wider { - width = own_width.map(px); - } - height = if has_children { - (is_asset && !wider_than_parent) - .then(|| own_height.map(px)) - .flatten() - } else if empty_frame || (leaf_shape && parent_taller) { - own_height.map(px) - } else { - 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. - // - // Ordinary frames may derive size from their content or padding. - // Child-folded HUG masks receive a bounded size repair below. - 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); - } - // The same fact on the height alone, for a frame that fills its - // parent's width and so was given one above. Its children size it in - // CSS where Figma pinned it: the notice header is 60 tall around a - // 24px row of logo and menu, and centring them in 24 rather than 60 - // put them 18px high of where Figma draws them. - // - // Child-folded HUG masks have a repair below. A FIXED container height - // is independent of the current child's HUG height and derived insets. - if fixed_h && height.is_none() && !is_asset && view.child_ids().next().is_some() { - // Derived insets describe the captured child geometry, not a height - // constraint. HUG content can change without changing this FIXED box. - height = view.number("height").map(px); - } - policy_sizing = anchor.zip(parent).map(|(anchor, parent)| { - let parent_view = parent.typed_view(); - ( - anchor, - super::width::placed_box(node, parent, export), - parent_view.number("width").unwrap_or(0.0), - parent_view.number("height").unwrap_or(0.0), - is_asset, - ) - }); - } else if is_page_root { - // Figma page roots define the component canvas; their editor dimensions - // are not emitted as runtime constraints: a root's width is the - // viewport's, and a root that lays its children out is as tall as - // they are, at any width. - // - // A root that lays nothing out has nothing in flow to give it a - // height — its children are placed absolutely, or its one child is - // centred in it. The plugin leaves it sizeless too, and the popup - // overlay, a 390×800 frame dimmed behind one centred card, comes out - // a box of no height whose dim is never drawn; the answer it wrote - // gave the height back as padding, `py="211.5px"` around a 377px - // card. The drawn height is the design, and it is kept, one value per - // width. The width is still the viewport's. This departs from the - // plugin on purpose. - if lays_nothing_out(node) - && view.child_ids().next().is_some() - && derived_padding(snapshot, node).is_none() - { - height = view.number("height").map(px); - } - } else if fixed_w || fixed_h { - if fixed_w { - width = view.number("width").map(px); - } - if fixed_h { - height = view.number("height").map(px); - } - // A rotated asset in flow takes the box its rotation sweeps, which - // is the box Figma's layout gives it and the box its export fills; - // its own width and height are the picture's before the turn. - if view - .number("rotation") - .is_some_and(|rotation| rotation.abs() > 0.01) - && super::style::asset_kind(snapshot, node).is_some() - && !original_source - && let Some(bounds) = layout_box(node) - { - if fixed_w { - width = Some(px(bounds.w)); - } - if fixed_h { - height = Some(px(bounds.h)); - } - } - if fill_w - && (view.value("maxWidth") != Some(&Value::Null) - || parent.is_some_and(|parent| child_shrinker(parent, "width"))) - { - width = Some("100%".to_owned()); - } - if fill_h - && (view.value("maxHeight") != Some(&Value::Null) - || parent.is_some_and(|parent| child_shrinker(parent, "height"))) - { - height = Some("100%".to_owned()); - } - } else if is_root { - let no_dimensions = view.number("width").is_none() && view.number("height").is_none(); - let has_children = view.child_ids().next().is_some(); - let implicit_text_fill = view.node_type() == "TEXT" - && view.value("layoutSizingHorizontal").is_none() - && view.value("layoutSizingVertical").is_none(); - let standalone_component = view.node_type() == "COMPONENT" - && !snapshot - .nodes - .values() - .any(|node| node.typed_view().node_type() == "COMPONENT_SET"); - if (no_dimensions && (view.node_type() != "COMPONENT" || standalone_component)) - || has_children - || fill_w - || fill_h - || view.node_type() == "GROUP" - || implicit_text_fill - { - width = Some("100%".to_owned()); - height = Some("100%".to_owned()); - } - } else { - if fill_w - && (view.value("maxWidth") != Some(&Value::Null) - || parent.is_some_and(|parent| child_shrinker(parent, "width"))) - { - width = Some("100%".to_owned()); - } else if fixed_w { - width = view.number("width").map(px); - } - if fill_h - && (view.value("maxHeight") != Some(&Value::Null) - || parent.is_some_and(|parent| child_shrinker(parent, "height"))) - { - height = Some("100%".to_owned()); - } else if fixed_h { - height = view.number("height").map(px); - } - if view.node_type() != "COMPONENT" - && view.number("width").is_none() - && view.number("height").is_none() - { - width = Some("100%".to_owned()); - height = Some("100%".to_owned()); - } - } - - // A canvas with positioned children is their coordinate system, not just - // a responsive page measurement. Preserve each FIXED axis even on a page - // or section root. Embedded mode explicitly delegates dimensions to host. - if containing_block && !embedded_root { - if fixed_w { - width = view.number("width").map(px); - } - if fixed_h { - height = view.number("height").map(px); - } - } - - // A FIXED column is also the height basis for in-flow FILL children. - // This relationship does not depend on positioned descendants (R6). - if !embedded_root && vertical_fill_container(snapshot, node) { - height = view.number("height").map(px); - } - let vertical_fill = !absolute - && !embedded_root - && fill_h - && parent.is_some_and(|p| p.typed_view().string("layoutMode") == Some("VERTICAL")); - if vertical_fill { - // Main-axis FILL means remaining space, not 100% of the whole parent. - height = None; - } - - if component == "Text" && fixed_w && fixed_h { - match view.string("textAutoResize") { - Some("WIDTH_AND_HEIGHT") => { - if view.number("width").is_some() || view.number("height").is_some() { - width = None; - height = None; - } - } - Some("HEIGHT") => { - if let Some(text_width) = view.number("width") { - width = Some(px(text_width)); - height = None; - } - } - Some("NONE" | "TRUNCATE") if !is_page_root => { - width = view.number("width").map(px).or(width); - height = view.number("height").map(px).or(height); - } - _ => {} - } - } - - // Only folding children into a CSS mask destroys HUG's intrinsic basis. - // FILL remains fluid; a vector's measured ratio supplies its lost HUG axis. - let mut restored_ratio = None; - if !embedded_root && let Some((w, h)) = folded_mask_dimensions(snapshot, node) { - let horizontal = view.string("layoutSizingHorizontal"); - let vertical = view.string("layoutSizingVertical"); - if horizontal == Some("FILL") && vertical == Some("HUG") { - width = Some("100%".into()); - height = None; - restored_ratio = Some(format!("{w} / {h}")); - } else if horizontal == Some("HUG") && vertical == Some("FILL") { - width = None; - height = Some("100%".into()); - restored_ratio = Some(format!("{w} / {h}")); - } else { - if horizontal == Some("HUG") { - width = Some(px(w)); - } - if vertical == Some("HUG") { - height = Some(px(h)); - } - } - } - - // Horizontal FILL is independent of the other axis. Parent alignment - // can disable implicit stretch; preserve the fluid allocation explicitly. - if !absolute - && !embedded_root - && fill_w - && width.is_none() - && parent.is_some_and(|p| p.typed_view().string("layoutMode") == Some("VERTICAL")) - && parent.is_some_and(|p| { - matches!( - p.typed_view().string("counterAxisAlignItems"), - Some("CENTER" | "MAX" | "BASELINE") - ) - }) - { - width = Some("100%".into()); - } - - // Cross-axis FILL must stretch even when the row centers other children. - // Percentage height cannot resolve against a HUG row's auto height. - if !absolute - && !embedded_root - && fill_h - && parent.is_some_and(|p| { - p.typed_view().string("layoutMode") == Some("HORIZONTAL") - && p.typed_view().string("layoutSizingVertical") == Some("HUG") - }) - { - height = None; - string_prop(props, "alignSelf", "stretch"); - } - - // Under a width policy the horizontal anchor decides the width, after - // every other rule: a stretch is its two edges, a scale its share of the - // parent, and any other anchor holds a childless shape at its own drawn - // size, which the plugin's `h="100%"` and no width does not - except on an - // axis where it spans its whole parent, which keeps following it as the - // default export already writes it. - if let Some((anchor, placed, parent_width, parent_height, is_asset)) = policy_sizing { - let shape = component != "Text" && !is_asset && view.child_ids().next().is_none(); - match anchor { - Anchor::Stretch => width = None, - Anchor::Scale => width = Some(super::width::percent(placed.w, parent_width)), - _ if shape && placed.w + 0.5 < parent_width => width = view.number("width").map(px), - _ => {} - } - if shape && placed.h + 0.5 < parent_height { - height = view.number("height").map(px); - } - } - // A root under a width policy takes its container's width; the drawn - // width stays the coordinate basis its positioned children were placed - // in, and the widthBehavior report says what that leaves unproven. - if policy.is_some() - && is_render_root - && !embedded_root - && width.as_deref().is_some_and(|width| width.ends_with("px")) - { - width = Some("100%".to_owned()); - } - // Whether the height was said outright, which decides below whether the - // node still needs to be told to take the space its parent leaves. - if original_source && absolute { - if fixed_w { - width = view.number("width").map(px); - } - if fixed_h { - height = view.number("height").map(px); - } - } - let wrote_height = height.is_some(); - let wrote_width = width.is_some(); - if let (Some(width), Some(height)) = (&width, &height) - && width == height - { - string_prop(props, "boxSize", width.clone()); - } else { - if let Some(width) = width { - string_prop(props, "w", width); - } - if let Some(height) = height { - string_prop(props, "h", height); - } - } - - // A positioned node has its size said outright — the plugin's absolute - // branch of `_getLayoutProps` never writes `aspectRatio` — so the ratio - // is only for a node in flow, where it stands in for a side that is not - // written. The about hero picture is `boxSize="100%"` with no ratio. - if let Some(ratio) = restored_ratio { - string_prop(props, "aspectRatio", ratio); - } else if !absolute - && let Some(aspect) = view.value("targetAspectRatio").and_then(Value::as_object) - && let (Some(x), Some(y)) = ( - aspect.get("x").and_then(Value::as_f64), - aspect.get("y").and_then(Value::as_f64), - ) - && y != 0.0 - { - string_prop( - props, - "aspectRatio", - format_number((x / y * 100.0).floor() / 100.0), - ); - } - for (field, prop) in [ - ("maxWidth", "maxW"), - ("maxHeight", "maxH"), - ("minWidth", "minW"), - ("minHeight", "minH"), - ] { - if let Some(value) = view.number(field) { - string_prop(props, prop, px(value)); - } - } - if view.string("parentId").is_some() - && let Some(parent) = parent - // A component set's grid is how Figma lays its variants out on the - // canvas, not how the component is built. A variant is drawn on its - // own wherever it is used, so carrying the cell it sat in would place - // every button at the coordinates of its row in the sheet. - && parent.typed_view().node_type() != "COMPONENT_SET" - && parent - .typed_view() - .value("inferredAutoLayout") - .and_then(Value::as_object) - .and_then(|layout| layout.get("layoutMode")) - .and_then(Value::as_str) - == Some("GRID") - { - let column = view.number("gridColumnAnchorIndex").unwrap_or(-1.0); - let row = view.number("gridRowAnchorIndex").unwrap_or(-1.0); - // How many tracks the child covers. The plugin writes `span 1` for - // every child, and a picture drawn across two columns came out in one, - // its neighbour pushed a row down and every `1fr` row stretched to - // the tallest picture: a 1086px grid rendered 2191px tall. The spans - // are Figma's own, absent from the snapshot when they are 1. - let column_span = view.number("gridColumnSpan").unwrap_or(1.0).max(1.0); - let row_span = view.number("gridRowSpan").unwrap_or(1.0).max(1.0); - let column_count = parent.typed_view().number("gridColumnCount").unwrap_or(0.0); - let current = column + row * column_count; - let natural = parent - .typed_view() - .child_ids() - .position(|child| child == node.id) - .map(|index| index as f64); - // A child at its natural cell needs no placement - unless it spans, - // which flow alone would not give it. - if column >= 0.0 - && row >= 0.0 - && (natural != Some(current) || column_span > 1.0 || row_span > 1.0) - { - string_prop( - props, - "gridColumn", - format!( - "{} / span {}", - format_number(column + 1.0), - format_number(column_span) - ), - ); - string_prop( - props, - "gridRow", - format!( - "{} / span {}", - format_number(row + 1.0), - format_number(row_span) - ), - ); - } - } - if fill_w - && parent - .is_some_and(|parent| parent.typed_view().string("layoutMode") == Some("HORIZONTAL")) - { - string_prop(props, "flex", "1"); - // `flex: 1` shares out the room left over, and Figma gives a filling - // child exactly that - here 123px - and lets anything wider inside it - // spill out. CSS will not shrink a flex item under its content - // (`min-width: auto`), so a 132px row inside a 123px share widened - // the share to 132 and took the 9px from the hugging sibling, whose - // `Devup-ui` then broke across two lines. `minW="0"` lets the share - // be the share, and the content spill as Figma draws it. Written - // only where the snapshot shows content wider than the box. A row - // can overflow collectively even when each child fits individually. - // Wrapping rows can distribute that content over multiple lines. - let child_overflows = view.number("width").is_some_and(|own| { - view.child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .any(|child| { - let child = child.typed_view(); - child.bool("visible") != Some(false) - && child.string("layoutPositioning") != Some("ABSOLUTE") - && child.number("width").is_some_and(|width| width > own + 0.5) - }) - }); - let row_overflows = view.string("layoutMode") == Some("HORIZONTAL") - && view.string("layoutWrap") != Some("WRAP") - && line_overflows(snapshot, node, "width", "paddingLeft", "paddingRight"); - if view.number("minWidth").is_none() && (child_overflows || row_overflows) { - string_prop(props, "minW", "0"); - } - } - // Main-axis FILL consumes remaining space, whether its own children are - // positioned or in flow. This keeps the completion screen's body ahead of - // the following bottom action without copying any canvas coordinates. - if vertical_fill { - string_prop(props, "flex", "1"); - if vertical_fill_needs_minimum_reset(snapshot, node) { - string_prop(props, "minH", "0"); - } - } - // A child Figma never shrinks, in a line that does not fit. Figma keeps a - // fixed size and lets the row spill past its parent, which clips it; CSS - // shrinks flex children to fit instead. The devup-ui landing page's - // comparison row is seven 240px cards in a 912px frame - 1,800px of - // content - and every one of them was squeezed to about 120px, their - // labels wrapped to two lines, and the row came out 58px taller than the - // design, carrying everything below it down with it. - // - // Only where the line actually overflows. Children that fit are not - // shrunk by CSS either, and saying so for every fixed child in the file - // would be noise. - if !absolute - && let Some(parent) = parent - && let Some(axis) = parent.typed_view().string("layoutMode") - && matches!(axis, "VERTICAL" | "HORIZONTAL") - { - let (sizing, size, near, far) = if axis == "HORIZONTAL" { - ( - "layoutSizingHorizontal", - "width", - "paddingLeft", - "paddingRight", - ) - } else { - ( - "layoutSizingVertical", - "height", - "paddingTop", - "paddingBottom", - ) - }; - if view.string(sizing) == Some("FIXED") && line_overflows(snapshot, parent, size, near, far) - { - string_prop(props, "flexShrink", "0"); - } - } - // A child that hugs across its parent's axis, in a parent that packs its - // children to the start, drawn narrower than the room it has, and drawn - // differently for being stretched into it. - // - // Figma's default counter-axis alignment is MIN, which it writes by - // leaving the field out - and leaving it out of the code too means CSS - // applies its own default, `align-items: stretch`, which is the - // opposite. The devup-ui landing page's `Get started` button, 247px wide - // in the 1360px column that holds it, was drawn 1360px wide. - // - // Most hugging children do not care. A line of left-aligned text in a - // box that paints nothing is the same picture at any width, and writing - // an alignment for every one of them - the notice desktop alone holds - // twenty - says nothing while burying the few that matter. So it is - // written where the wider box would show: where the node paints across - // it, or places its own content by it. - if !absolute - && let Some(parent) = parent - && let Some(axis) = parent.typed_view().string("layoutMode") - && matches!(axis, "VERTICAL" | "HORIZONTAL") - && matches!( - parent.typed_view().string("counterAxisAlignItems"), - None | Some("MIN") - ) - { - let across_is_horizontal = axis == "VERTICAL"; - let (sizing, size, near, far, wrote) = if across_is_horizontal { - ( - "layoutSizingHorizontal", - "width", - "paddingLeft", - "paddingRight", - wrote_width, - ) - } else { - ( - "layoutSizingVertical", - "height", - "paddingTop", - "paddingBottom", - wrote_height, - ) - }; - if view.string(sizing) == Some("HUG") - && !wrote - && let (Some(own), Some(room)) = - (view.number(size), inner_extent(parent, size, near, far)) - && own + 0.5 < room - && a_wider_box_would_show(&view, across_is_horizontal) - { - string_prop(props, "alignSelf", "flex-start"); - } - } - - push_auto_layout(snapshot, node, component, props); - push_padding(snapshot, node, props); - match view.string("overflowDirection") { - Some("VERTICAL_SCROLLING") => { - string_prop(props, "overflowY", "auto"); - if view.bool("clipsContent") == Some(true) { - string_prop(props, "overflowX", "hidden"); - } - } - Some("HORIZONTAL_SCROLLING") => { - string_prop(props, "overflowX", "auto"); - if view.bool("clipsContent") == Some(true) { - string_prop(props, "overflowY", "hidden"); - } - } - Some("HORIZONTAL_AND_VERTICAL_SCROLLING") => string_prop(props, "overflow", "auto"), - _ 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. - // - // A node that is itself positioned is already that ancestor, and saying - // `relative` over its `absolute` would put it back in flow: the join-us - // group of circles, pinned at -277,-187, took 1,102px of the page. - if containing_block && (!absolute || embedded_root) { - string_prop(props, "pos", "relative"); - } - // Position (relative or absolute) establishes a containing block, but - // needs an explicit z-index to contain negative-z children above its own - // background. Keep an existing z-index that places this entire subtree. - if containing_block - && !props.iter().any(|(name, _)| name == "zIndex") - && view - .child_ids() - .any(|child| sits_behind_in_flow_siblings(snapshot, node, child)) - { - string_prop(props, "zIndex", "0"); - } - // An export is drawn with its rotation in it, so an asset whose bounds - // the snapshot carries is not rotated again; without the bounds it is - // placed as the plugin places it, rotation and all. - let exported = !original_source - && super::style::asset_kind(snapshot, node).is_some() - && view.value("absoluteRenderBounds").is_some(); - if let Some(rotation) = view.number("rotation") - && rotation.abs() > 0.01 - && !exported - { - string_prop( - props, - "transform", - format!("rotate({}deg)", format_number(-rotation)), - ); - if absolute { - string_prop(props, "transformOrigin", "top left"); - } - } -} - -pub(crate) fn vertical_fill_container(snapshot: &Snapshot, node: &RawNode) -> bool { - let view = node.typed_view(); - view.string("layoutMode") == Some("VERTICAL") - && view.string("layoutSizingVertical") == Some("FIXED") - && view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .any(|child| { - let child = child.typed_view(); - child.bool("visible") != Some(false) - && child.string("layoutPositioning") != Some("ABSOLUTE") - && child.string("layoutSizingVertical") == Some("FILL") - }) -} - -/// CSS's automatic flex minimum must not enlarge a captured FILL allocation. -/// Preserve explicit source minimums; only reset the implicit content minimum -/// when collected in-flow content demonstrably overflows the allocated height. -pub(crate) fn vertical_fill_needs_minimum_reset(snapshot: &Snapshot, node: &RawNode) -> bool { - let view = node.typed_view(); - if view.number("minHeight").is_some() { - return false; - } - (view.string("layoutMode") == Some("VERTICAL") - && line_overflows(snapshot, node, "height", "paddingTop", "paddingBottom")) - || inner_extent(node, "height", "paddingTop", "paddingBottom").is_some_and(|room| { - view.child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .any(|child| { - let child = child.typed_view(); - child.bool("visible") != Some(false) - && child.string("layoutPositioning") != Some("ABSOLUTE") - && child - .number("height") - .is_some_and(|height| height > room + 0.5) - }) - }) -} - -/// Only children that can survive projection need a containing block. -pub(super) fn holds_positioned_children(snapshot: &Snapshot, node: &RawNode) -> bool { - super::style::asset_kind(snapshot, node).is_none() - && node.typed_view().child_ids().any(|id| { - snapshot.nodes.get(id).is_some_and(|child| { - let view = child.typed_view(); - view.bool("visible") != Some(false) - && (view.string("layoutPositioning") == Some("ABSOLUTE") - || placed_by_a_free_layout(snapshot, child, Some(node), false)) - }) - }) -} - -pub(super) fn absolute_layout_is_exact(snapshot: &Snapshot, node: &RawNode) -> bool { - let view = node.typed_view(); - if view.string("layoutPositioning") != Some("ABSOLUTE") { - return true; - } - let parent = snapshot.nodes.values().find(|candidate| { - candidate - .typed_view() - .child_ids() - .any(|child_id| child_id == node.id) - }); - let has_geometry = ["x", "y", "width", "height"] - .into_iter() - .all(|field| view.number(field).is_some_and(f64::is_finite)); - let constraints = view.value("constraints").and_then(Value::as_object); - let supported_constraint = |axis: &str| { - constraints - .and_then(|value| value.get(axis)) - .and_then(Value::as_str) - .is_none_or(|value| matches!(value, "MIN" | "MAX")) - }; - 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; - } - if view.child_ids().next().is_none() { - return true; - } - let inferred_auto_layout = view - .value("inferredAutoLayout") - .and_then(Value::as_object) - .is_some(); - let parent = parent.typed_view(); - let horizontal = view.string("layoutSizingHorizontal") == Some("HUG") - && inferred_auto_layout - || matches!( - (view.number("width"), parent.number("width")), - (Some(width), Some(parent_width)) if width == parent_width - ); - let vertical = view.string("layoutSizingVertical") == Some("HUG") && inferred_auto_layout; - horizontal && vertical - }); - parent.is_some() - && has_geometry - && supported_constraint("horizontal") - && supported_constraint("vertical") - && no_rotation - && exact_size -} - -fn child_shrinker(parent: &RawNode, dimension: &str) -> bool { - let inferred = parent - .typed_view() - .value("inferredAutoLayout") - .and_then(Value::as_object); - match dimension { - "width" => inferred.is_some_and(|layout| { - layout.get("layoutMode").and_then(Value::as_str) == Some("VERTICAL") - && layout.get("counterAxisAlignItems").and_then(Value::as_str) == Some("CENTER") - }), - "height" => inferred.is_some_and(|layout| { - layout.get("layoutMode").and_then(Value::as_str) == Some("HORIZONTAL") - && layout.get("counterAxisAlignItems").and_then(Value::as_str) == Some("CENTER") - }), - _ => false, - } -} - -pub(super) struct GridTemplate { - pub css: Option, - pub unverified: Vec<&'static str>, -} - -/// Preserve source tracks, including content sizing. A flexible column in a -/// non-HUG grid has a zero minimum so replaced content cannot enlarge its share -/// through CSS's automatic min-content minimum. Rows and HUG grids retain their -/// content minimum. Count-only grids retain the equal-share fallback, explicitly -/// diagnosed as a policy choice rather than a recovered source measurement. -pub(super) fn grid_template(node: &RawNode, columns: bool) -> GridTemplate { - // A codegen resource budget, not a Figma limit: unsupported input must be - // diagnosed instead of allocating a huge list or emitting a clamped grid. - const MAX_TRACKS: usize = 4096; - let view = node.typed_view(); - let (sizes, count, sizing) = if columns { - ( - "gridColumnSizes", - "gridColumnCount", - "layoutSizingHorizontal", - ) - } else { - ("gridRowSizes", "gridRowCount", "layoutSizingVertical") - }; - let hug = view.string(sizing) == Some("HUG"); - let flexible = |value| { - let fraction = format!("{}fr", format_number(value)); - if columns && !hug { - format!("minmax(0, {fraction})") - } else { - fraction - } - }; - let mut unverified = Vec::new(); - let count_value = view.value(count); - let count = view.number(count); - let sizes = view.value(sizes).and_then(Value::as_array); - if count.is_some_and(|count| count > MAX_TRACKS as f64) { - unverified.push("track-count-exceeds-codegen-budget"); - } - if sizes.is_some_and(|sizes| sizes.len() > MAX_TRACKS) { - unverified.push("track-list-exceeds-codegen-budget"); - } - if !unverified.is_empty() { - return GridTemplate { - css: None, - unverified, - }; - } - let valid_count = count.filter(|count| *count >= 1.0 && count.fract() == 0.0); - let tracks = if let Some(sizes) = sizes.filter(|sizes| !sizes.is_empty()) { - if count_value.is_some_and(|v| !v.is_null()) && valid_count != Some(sizes.len() as f64) { - unverified.push("track-count-mismatch"); - } - sizes - .iter() - .map(|track| { - let value = track.get("value").and_then(Value::as_f64); - match track.get("type").and_then(Value::as_str) { - Some("FIXED") if value.is_some_and(|v| v >= 0.0) => px(value.unwrap()), - Some("HUG") => "fit-content(100%)".to_owned(), - Some("AUTO") => "auto".to_owned(), - Some("FLEX") - if value.is_some_and(|v| v >= 0.0) - || track.get("value").is_none_or(Value::is_null) => - { - flexible(value.unwrap_or(1.0)) - } - _ => { - // Unknown track kinds or invalid fixed sizes are not - // evidence for either a pixel size or a flexible share. - unverified.push("unknown-or-invalid-track-size"); - "auto".to_owned() - } - } - }) - .collect::>() - } else { - unverified.push("track-sizes-not-collected"); - if valid_count.is_none() { - unverified.push("track-count-not-collected-or-invalid"); - } - // Count-only input needs no allocation proportional to an untrusted - // track count, and an absent count must not emit an empty CSS value. - let css = valid_count.map(|count| { - let track = if hug { - "auto".to_owned() - } else { - flexible(1.0) - }; - format!("repeat({}, {track})", format_number(count)) - }); - return GridTemplate { css, unverified }; - }; - unverified.sort_unstable(); - unverified.dedup(); - let css = if tracks.is_empty() { - None - } else if tracks.iter().all(|track| track == &tracks[0]) { - Some(format!("repeat({}, {})", tracks.len(), tracks[0])) - } else { - Some(tracks.join(" ")) - }; - GridTemplate { css, unverified } -} - -/// Figma centers a lone in-flow child for SPACE_BETWEEN; CSS flexbox starts it. -/// Hidden and absolutely positioned children do not participate in distribution. -pub(crate) fn centers_lone_space_between_child(snapshot: &Snapshot, node: &RawNode) -> bool { - let view = node.typed_view(); - matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) - && view.string("primaryAxisAlignItems") == Some("SPACE_BETWEEN") - && view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .filter(|child| { - let child = child.typed_view(); - child.bool("visible") != Some(false) - && child.string("layoutPositioning") != Some("ABSOLUTE") - }) - .count() - == 1 -} - -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; - }; - let mode = layout.get("layoutMode").and_then(Value::as_str); - if !matches!(mode, Some("HORIZONTAL" | "VERTICAL" | "GRID")) { - return; - } - if mode == Some("GRID") { - for (columns, prop) in [(true, "gridTemplateColumns"), (false, "gridTemplateRows")] { - if let Some(css) = grid_template(node, columns).css { - string_prop(props, prop, css); - } - } - let row = view.number("gridRowGap").unwrap_or(0.0); - let column = view.number("gridColumnGap").unwrap_or(0.0); - if row == column { - if row != 0.0 { - string_prop(props, "gap", px(row)); - } - } else { - string_prop(props, "rowGap", px(row)); - string_prop(props, "columnGap", px(column)); - } - return; - } - // Figma's two newer distributions came to the plugin after the pinned - // corpus (9214391); they are the CSS keywords of the same name. - let justify = match view.string("primaryAxisAlignItems") { - Some("MIN") => None, - Some("MAX") => Some("flex-end"), - Some("CENTER") => Some("center"), - Some("SPACE_BETWEEN") if centers_lone_space_between_child(snapshot, node) => Some("center"), - Some("SPACE_BETWEEN") => Some("space-between"), - Some("SPACE_AROUND") => Some("space-around"), - Some("SPACE_EVENLY") => Some("space-evenly"), - _ => None, - }; - let align = match view.string("counterAxisAlignItems") { - Some("MIN") => None, - Some("MAX") => Some("flex-end"), - Some("CENTER") => Some("center"), - Some("BASELINE") => Some("baseline"), - _ => None, - }; - if component != "Center" { - if let Some(value) = justify { - string_prop(props, "justifyContent", value); - } - if let Some(value) = align { - string_prop(props, "alignItems", value); - } - } - if component == "Center" && mode == Some("VERTICAL") { - string_prop(props, "flexDir", "column"); - } - // 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) - .or_else(|| view.number("itemSpacing")); - if let Some(gap) = gap.filter(|gap| *gap != 0.0) { - string_prop(props, "gap", px(gap)); - } - } -} - -/// 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. -/// How much room a frame leaves its children across one axis: its own size -/// less the padding on that axis. `None` when the size is not recorded. -fn inner_extent(node: &RawNode, size: &str, near: &str, far: &str) -> Option { - let view = node.typed_view(); - let inferred = view.value("inferredAutoLayout").and_then(Value::as_object); - let padding = |name: &str| { - inferred - .and_then(|layout| layout.get(name)) - .and_then(Value::as_f64) - .or_else(|| view.number(name)) - .unwrap_or(0.0) - }; - Some(view.number(size)? - padding(near) - padding(far)) -} - -/// Whether a node drawn into a wider box than it asked for would look any -/// different: either it paints across the box - a fill, a stroke, a shadow - -/// or it places its own content by the box's far edge or centre along that -/// axis. A left-aligned line of text in a box that paints nothing does not. -fn a_wider_box_would_show(view: &TypedNode<'_>, across_is_horizontal: bool) -> bool { - let visible = |value: Option<&Value>| { - value.and_then(Value::as_array).is_some_and(|entries| { - entries.iter().any(|entry| { - entry.get("visible").and_then(Value::as_bool) != Some(false) - && entry.get("type").and_then(Value::as_str) != Some("NONE") - }) - }) - }; - // A text node's fills are its ink, not its box, and a shadow follows the - // glyphs: none of them widen with the box. A centred line would move, - // and the pinned corpus holds three such texts - but no screen in the - // corpus renders one, so there is nothing to show that writing it helps, - // and it costs byte parity with the plugin on all three. Left alone - // until a rendered screen asks for it. - if view.node_type() == "TEXT" { - return false; - } - if visible(view.value("fills")) - || visible(view.value("strokes")) - || visible(view.value("effects")) - { - return true; - } - // Along its own main axis a node is placed by `primaryAxisAlignItems`, - // across it by `counterAxisAlignItems`; which of the two answers for the - // axis being stretched depends on which way the node itself runs. - let along_its_main_axis = match view.string("layoutMode") { - Some("HORIZONTAL") => across_is_horizontal, - Some("VERTICAL") => !across_is_horizontal, - _ => return false, - }; - if along_its_main_axis { - matches!( - view.string("primaryAxisAlignItems"), - Some("CENTER" | "MAX" | "SPACE_BETWEEN" | "SPACE_AROUND" | "SPACE_EVENLY") - ) - } else { - matches!(view.string("counterAxisAlignItems"), Some("CENTER" | "MAX")) - } -} - -/// Whether a frame's children, laid end to end along its own axis with the -/// gaps between them, come to more than the room it leaves. Figma lets them -/// spill and clips; CSS shrinks them to fit, so the two only agree while -/// they fit. Children out of flow or not drawn take no room. -fn line_overflows(snapshot: &Snapshot, node: &RawNode, size: &str, near: &str, far: &str) -> bool { - let view = node.typed_view(); - let Some(room) = inner_extent(node, size, near, far) else { - return false; - }; - let children = view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .filter(|child| { - let child = child.typed_view(); - child.bool("visible") != Some(false) - && child.string("layoutPositioning") != Some("ABSOLUTE") - }) - .collect::>(); - if children.len() < 2 { - return false; - } - let gap = view - .value("inferredAutoLayout") - .and_then(Value::as_object) - .and_then(|layout| layout.get("itemSpacing")) - .and_then(Value::as_f64) - .or_else(|| view.number("itemSpacing")) - .unwrap_or(0.0); - let mut extent = gap * (children.len() - 1) as f64; - for child in children { - let Some(own) = child.typed_view().number(size) else { - return false; - }; - extent += own; - } - extent > room + 0.5 -} - -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; - } - // Padding places one child. Two or more at their own positions cannot be - // put back by an inset around all of them — in flow they would stack — - // so they are placed one by one instead, see `placed_by_a_free_layout`. - only_visible_child(snapshot, node)?; - // A child the designer centred is centred, not padded: see - // `centres_its_only_child`. - if centres_its_only_child(snapshot, node) { - return None; - } - children_inset(snapshot, node) -} - -/// The one visible child of a frame, when there is exactly one. -fn only_visible_child<'a>(snapshot: &'a Snapshot, node: &RawNode) -> Option<&'a RawNode> { - let view = node.typed_view(); - let mut visible = view - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .filter(|child| child.typed_view().bool("visible") != Some(false)); - match (visible.next(), visible.next()) { - (Some(only), None) => Some(only), - _ => None, - } -} - -/// Whether a frame that lays nothing out holds one child the designer centred -/// on both axes — constraints `CENTER` / `CENTER`. -/// -/// Such a child is centred by its constraint, not by the inset it happened to -/// have at the width it was drawn: the popup card sits 36.5px in at 390px and -/// 742px in at 1920px, and a measured `pl="36.5px"` holds at the one width -/// and drifts at every other. The frame is written as a `Center` with the -/// child in flow, which centres it at any width and needs no positioned -/// ancestor; a page root keeps its drawn height so there is something to -/// centre in, see `push_layout_props`. -/// -/// This departs from the plugin, on purpose. Its `canBeAbsolute` writes the -/// child `pos="absolute" left="50%" top="50%" transform="translate(-50%, -/// -50%)"`, which resolves against whatever positioned ancestor the page is -/// given, and leaves the frame — a page root, so sizeless — a box of no -/// height whose dim is never drawn. The answer it wrote for the popup, from a -/// layout Figma has since stopped inferring, pads the card in by the inset at -/// each width and at desktop by none, which leaves the card at the left of a -/// 1920px screen. -/// -/// A child centred on one axis only, or pinned to an edge, keeps the measured -/// inset as padding, as before. -pub(crate) fn centres_its_only_child(snapshot: &Snapshot, node: &RawNode) -> bool { - let view = node.typed_view(); - if view.string("layoutMode") != Some("NONE") || !lays_nothing_out(node) { - return false; - } - if super::style::asset_kind(snapshot, node).is_some() { - return false; - } - only_visible_child(snapshot, node).is_some_and(|only| { - only.typed_view() - .value("constraints") - .and_then(Value::as_object) - .is_some_and(|constraints| { - ["horizontal", "vertical"] - .iter() - .all(|axis| constraints.get(*axis).and_then(Value::as_str) == Some("CENTER")) - }) - }) -} - -/// The plugin's `isFreelayout`: a frame in flow with no auto layout, whose -/// children sit where the designer left them. -fn lays_nothing_out(node: &RawNode) -> bool { - let view = node.typed_view(); - view.string("layoutPositioning") == Some("AUTO") - && view.number("width").is_some() - && view.number("height").is_some() - && view - .value("inferredAutoLayout") - .and_then(Value::as_object) - .is_none() - && !matches!( - view.string("layoutMode"), - Some("HORIZONTAL" | "VERTICAL" | "GRID") - ) -} - -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(mut top), - Some(mut right), - Some(mut bottom), - Some(mut left), - ] = [ - get("paddingTop"), - get("paddingRight"), - get("paddingBottom"), - get("paddingLeft"), - ] - else { - return; - }; - // An inside stroke is painted over the padding: a card 20px in from its - // edge with a 1px stroke inside is still 20px in, stroke and all, and - // its height is the content plus 40. CSS's border is added around the - // padding instead, so a hugging card came out 2px taller - the landing - // page's four feature cards put the join-us panel 8px down - and on a - // fixed axis the content sat 1px further in. Taking the stroke out of - // the padding puts the content where Figma has it on every axis: it - // starts `p` in and, hugging, the box is content plus `2p`. - if view.string("strokeAlign").unwrap_or("INSIDE") == "INSIDE" - && !super::style::inside_stroke_uses_outline(&view) - && view.node_type() != "LINE" - && let Some(weight) = view.number("strokeWeight").filter(|weight| *weight > 0.0) - && view - .value("strokes") - .and_then(Value::as_array) - .is_some_and(|strokes| { - strokes.iter().any(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("type").and_then(Value::as_str) == Some("SOLID") - }) - }) - { - top = (top - weight).max(0.0); - bottom = (bottom - weight).max(0.0); - left = (left - weight).max(0.0); - right = (right - weight).max(0.0); - } - if top == 0.0 && right == 0.0 && bottom == 0.0 && left == 0.0 { - return; - } - // A zero padding is the default, so naming it says nothing. Emitting it - // only because the other axis happened to be padded left props like - // `px="0px"` sitting next to a real `py`. - let mut push = |name: &str, value: f64| { - if value != 0.0 { - string_prop(props, name, px(value)); - } - }; - // 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 same(top, bottom) { - push("py", top); - } else { - push("pt", top); - push("pb", bottom); - } - if same(left, right) { - push("px", left); - } else { - push("pl", left); - push("pr", right); - } - } -} - -/// Whether `child_id` is a positioned child of `parent` that Figma draws -/// under everything else in it: nothing in flow comes before it, and -/// something in flow comes after. CSS paints a positioned element after every -/// in-flow sibling whatever the order, so such a child has to be sent behind -/// on purpose. -/// -/// `zIndex="-1"` sends it behind *every* in-flow sibling, not only the later -/// ones, so it is only right when there is nothing earlier to stay above. -/// The notice page's header is pinned second, after its banner: sent behind, -/// it vanished under the banner it is meant to sit on. It is left where CSS -/// puts it, above all of them, which nothing there overlaps anyway. -fn sits_behind_in_flow_siblings(snapshot: &Snapshot, parent: &RawNode, child_id: &str) -> bool { - let ids = parent.typed_view().child_ids().collect::>(); - let Some(index) = ids.iter().position(|id| *id == child_id) else { - return false; - }; - let positioned = |node: &RawNode| { - node.typed_view().string("layoutPositioning") == Some("ABSOLUTE") - || placed_by_a_free_layout(snapshot, node, Some(parent), false) - }; - let in_flow = - |node: &RawNode| node.typed_view().bool("visible") != Some(false) && !positioned(node); - let Some(child) = snapshot.nodes.get(child_id) else { - return false; - }; - if !positioned(child) { - return false; - } - let earlier_in_flow = ids[..index] - .iter() - .filter_map(|id| snapshot.nodes.get(*id)) - .any(in_flow); - let later_in_flow = ids[index + 1..] - .iter() - .filter_map(|id| snapshot.nodes.get(*id)) - .any(in_flow); - !earlier_in_flow && later_in_flow -} - -/// Whether a node in flow is nonetheless placed by its parent, because the -/// parent lays nothing out. -/// -/// A frame with no auto layout puts each child where the designer left it, -/// and the plugin's `canBeAbsolute` writes every such child at its -/// constraints — `pos="absolute"` with the edges it is pinned to — and gives -/// the frame `pos="relative"` to hold them. Here that was only done for a -/// child marked absolute, so the notice banner's title and its two logos, three -/// children of a free frame, were stacked in flow with no position at all. -/// -/// Two cases are kept out. A frame whose single child's inset can be measured -/// is written with that inset as padding and the child in flow, which puts it -/// in the same place and lets it size the frame; and a frame whose single -/// child is centred is written as a `Center` with the child in flow, see -/// `centres_its_only_child`. -pub(crate) fn placed_by_a_free_layout( - snapshot: &Snapshot, - node: &RawNode, - parent: Option<&RawNode>, - is_page_root: bool, -) -> bool { - let view = node.typed_view(); - let Some(parent) = parent else { - return false; - }; - // Whether the parent is itself placed by its own parent or pinned into - // it says nothing about whether it lays its children out. The devup-ui - // landing page's join-us panel holds a group of ten circles pinned to - // the card at -277,-187; because the group is `ABSOLUTE`, its children - // were read as being in flow, lost the coordinates they carry, stacked - // from the group's corner and were clipped away - the arcs and both - // badges were not drawn at all. - // - // This is only about the parent. `lays_nothing_out` also answers for a - // node about itself, where being pinned does decide what it is, so it is - // left alone. - let parent_view = parent.typed_view(); - let parent_lays_nothing_out = matches!( - parent_view.string("layoutPositioning"), - Some("AUTO" | "ABSOLUTE") - ) && parent_view.number("width").is_some() - && parent_view.number("height").is_some() - && parent_view - .value("inferredAutoLayout") - .and_then(Value::as_object) - .is_none() - && !matches!( - parent_view.string("layoutMode"), - Some("HORIZONTAL" | "VERTICAL" | "GRID") - ); - !is_page_root - && view.value("constraints").is_some() - && parent_lays_nothing_out - && derived_padding(snapshot, parent).is_none() - && !centres_its_only_child(snapshot, parent) -} - -/// Places a positioned node, and returns the anchor a width policy chose for -/// it, if one was given. -fn push_absolute( - snapshot: &Snapshot, - node: &RawNode, - parent: Option<&RawNode>, - props: &mut Vec, - placed_by: Option, - policy: Option<&WidthPolicy>, -) -> Option { - string_prop(props, "pos", "absolute"); - let view = node.typed_view(); - let parent_node = parent?; - // The box to place: the node's own, or the one handed in - an asset's - // export. A group's children carry their `x` and `y` in the group's - // parent's space, not the group's: the join-us panel's outermost circle, - // which is exactly the group, reads `-277,-187` - the group's own place in - // the card - where the group's own space would say `0,0`. Placed as read, - // every circle sat 277px left and 187px high of where Figma draws it. - // `placed_box` settles it from the absolute boxes, without depending on - // whose space a number is in. - let placed = super::width::placed_box(node, parent_node, placed_by); - let parent = parent_node.typed_view(); - // A group has no constraints of its own; the plugin's `getPositionProps` - // reads its first child's, and so does this. The report section's - // illustration is a group pinned to the bottom of its frame through its - // children, `bottom="-284.8px"`, where reading the group alone put it - // at `top`. - let constraints = view - .value("constraints") - .and_then(Value::as_object) - .or_else(|| { - view.child_ids() - .next() - .and_then(|child| snapshot.nodes.get(child))? - .typed_view() - .value("constraints") - .and_then(Value::as_object) - }); - let horizontal = constraints - .and_then(|value| value.get("horizontal")) - .and_then(Value::as_str) - .unwrap_or("MIN"); - let vertical = constraints - .and_then(|value| value.get("vertical")) - .and_then(Value::as_str) - .unwrap_or("MIN"); - let (x, y) = (placed.x, placed.y); - let anchor = - policy.map(|policy| super::width::effective_anchor(snapshot, node, parent_node, policy).0); - match (anchor, horizontal) { - (Some(anchor), _) => super::width::push_horizontal( - props, - anchor, - placed, - parent.number("width").unwrap_or(0.0), - ), - (None, "MAX") => string_prop( - props, - "right", - px(parent.number("width").unwrap_or(0.0) - x - placed.w), - ), - (None, "CENTER") => { - string_prop(props, "left", "50%"); - string_prop(props, "transform", "translateX(-50%)"); - } - (None, _) => string_prop(props, "left", px(x)), - } - match vertical { - "MAX" => string_prop( - props, - "bottom", - px(parent.number("height").unwrap_or(0.0) - y - placed.h), - ), - "CENTER" => { - string_prop(props, "top", "50%"); - if let Some((_, PropValue::String(value))) = - props.iter_mut().find(|(name, _)| name == "transform") - { - *value = "translate(-50%, -50%)".into(); - } else { - string_prop(props, "transform", "translateY(-50%)"); - } - } - _ => string_prop(props, "top", px(y)), - } - anchor -} - -pub(super) fn string_prop(props: &mut Vec, name: &str, value: impl Into) { - let value = PropValue::String(value.into()); - if let Some((_, existing)) = props.iter_mut().find(|(existing, _)| existing == name) { - *existing = value; - } else { - props.push((name.to_owned(), value)); - } -} - -pub(crate) fn px(value: f64) -> String { - format!("{}px", format_number(value)) -} - -pub(super) fn format_number(value: f64) -> String { - let rounded = (value * 100.0).round() / 100.0; - if rounded == 0.0 { - "0".to_owned() - } else if rounded.fract() == 0.0 { - format!("{rounded:.0}") - } else { - format!("{rounded:.2}").trim_end_matches('0').to_owned() - } -} - -/// Dimensions whose child-based HUG sizing is destroyed by mask projection. -/// Render bounds alone cannot establish layout size (effects may enlarge them). -pub(crate) fn folded_mask_dimensions(snapshot: &Snapshot, node: &RawNode) -> Option<(f64, f64)> { - let view = node.typed_view(); - if super::style::asset_kind(snapshot, node) != Some(super::style::AssetKind::SvgMask) - || view.child_ids().next().is_none() - // Rotated exports need a separate transform proof. - || view.number("rotation").is_some_and(|rotation| rotation.abs() > 0.01) - || !(view.string("layoutSizingHorizontal") == Some("HUG") - || view.string("layoutSizingVertical") == Some("HUG")) - { - return None; - } - if export_offset(node).is_some() { - // In-flow HUG/FIXED axes retain the measured layout box. The export's - // larger painted bounds are independently preserved by maskSize and - // maskPos. A FILL axis would scale that box while leaving pixel mask - // offsets unchanged; positioned assets instead use the export box. - let parent = view - .string("parentId") - .and_then(|id| snapshot.nodes.get(id)); - if !matches!(view.string("layoutSizingHorizontal"), Some("HUG" | "FIXED")) - || !matches!(view.string("layoutSizingVertical"), Some("HUG" | "FIXED")) - || view.string("layoutPositioning") == Some("ABSOLUTE") - || placed_by_a_free_layout(snapshot, node, parent, false) - { - return None; - } - } - let measured = |axis| { - view.number(axis) - .or_else(|| view.value("absoluteBoundingBox")?.get(axis)?.as_f64()) - .filter(|v| v.is_finite() && *v > 0.0) - }; - Some((measured("width")?, measured("height")?)) -} +// Source sections share this module to preserve privacy and test names. +include!("layout/layout_properties.rs"); +include!("layout/grid_template.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout/grid_template.rs b/crates/devup-mcp-devup-ui/src/codegen/layout/grid_template.rs new file mode 100644 index 00000000..630ac912 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/layout/grid_template.rs @@ -0,0 +1,781 @@ + + +/// Preserve source tracks, including content sizing. A flexible column in a +/// non-HUG grid has a zero minimum so replaced content cannot enlarge its share +/// through CSS's automatic min-content minimum. Rows and HUG grids retain their +/// content minimum. Count-only grids retain the equal-share fallback, explicitly +/// diagnosed as a policy choice rather than a recovered source measurement. +pub(super) fn grid_template(node: &RawNode, columns: bool) -> GridTemplate { + // A codegen resource budget, not a Figma limit: unsupported input must be + // diagnosed instead of allocating a huge list or emitting a clamped grid. + const MAX_TRACKS: usize = 4096; + let view = node.typed_view(); + let (sizes, count, sizing) = if columns { + ( + "gridColumnSizes", + "gridColumnCount", + "layoutSizingHorizontal", + ) + } else { + ("gridRowSizes", "gridRowCount", "layoutSizingVertical") + }; + let hug = view.string(sizing) == Some("HUG"); + let flexible = |value| { + let fraction = format!("{}fr", format_number(value)); + if columns && !hug { + format!("minmax(0, {fraction})") + } else { + fraction + } + }; + let mut unverified = Vec::new(); + let count_value = view.value(count); + let count = view.number(count); + let sizes = view.value(sizes).and_then(Value::as_array); + if count.is_some_and(|count| count > MAX_TRACKS as f64) { + unverified.push("track-count-exceeds-codegen-budget"); + } + if sizes.is_some_and(|sizes| sizes.len() > MAX_TRACKS) { + unverified.push("track-list-exceeds-codegen-budget"); + } + if !unverified.is_empty() { + return GridTemplate { + css: None, + unverified, + }; + } + let valid_count = count.filter(|count| *count >= 1.0 && count.fract() == 0.0); + let tracks = if let Some(sizes) = sizes.filter(|sizes| !sizes.is_empty()) { + if count_value.is_some_and(|v| !v.is_null()) && valid_count != Some(sizes.len() as f64) { + unverified.push("track-count-mismatch"); + } + sizes + .iter() + .map(|track| { + let value = track.get("value").and_then(Value::as_f64); + match track.get("type").and_then(Value::as_str) { + Some("FIXED") if value.is_some_and(|v| v >= 0.0) => px(value.unwrap()), + Some("HUG") => "fit-content(100%)".to_owned(), + Some("AUTO") => "auto".to_owned(), + Some("FLEX") + if value.is_some_and(|v| v >= 0.0) + || track.get("value").is_none_or(Value::is_null) => + { + flexible(value.unwrap_or(1.0)) + } + _ => { + // Unknown track kinds or invalid fixed sizes are not + // evidence for either a pixel size or a flexible share. + unverified.push("unknown-or-invalid-track-size"); + "auto".to_owned() + } + } + }) + .collect::>() + } else { + unverified.push("track-sizes-not-collected"); + if valid_count.is_none() { + unverified.push("track-count-not-collected-or-invalid"); + } + // Count-only input needs no allocation proportional to an untrusted + // track count, and an absent count must not emit an empty CSS value. + let css = valid_count.map(|count| { + let track = if hug { + "auto".to_owned() + } else { + flexible(1.0) + }; + format!("repeat({}, {track})", format_number(count)) + }); + return GridTemplate { css, unverified }; + }; + unverified.sort_unstable(); + unverified.dedup(); + let css = if tracks.is_empty() { + None + } else if tracks.iter().all(|track| track == &tracks[0]) { + Some(format!("repeat({}, {})", tracks.len(), tracks[0])) + } else { + Some(tracks.join(" ")) + }; + GridTemplate { css, unverified } +} + +/// Figma centers a lone in-flow child for SPACE_BETWEEN; CSS flexbox starts it. +/// Hidden and absolutely positioned children do not participate in distribution. +pub(crate) fn centers_lone_space_between_child(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) + && view.string("primaryAxisAlignItems") == Some("SPACE_BETWEEN") + && view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| { + let child = child.typed_view(); + child.bool("visible") != Some(false) + && child.string("layoutPositioning") != Some("ABSOLUTE") + }) + .count() + == 1 +} + +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; + }; + let mode = layout.get("layoutMode").and_then(Value::as_str); + if !matches!(mode, Some("HORIZONTAL" | "VERTICAL" | "GRID")) { + return; + } + if mode == Some("GRID") { + for (columns, prop) in [(true, "gridTemplateColumns"), (false, "gridTemplateRows")] { + if let Some(css) = grid_template(node, columns).css { + string_prop(props, prop, css); + } + } + let row = view.number("gridRowGap").unwrap_or(0.0); + let column = view.number("gridColumnGap").unwrap_or(0.0); + if row == column { + if row != 0.0 { + string_prop(props, "gap", px(row)); + } + } else { + string_prop(props, "rowGap", px(row)); + string_prop(props, "columnGap", px(column)); + } + return; + } + // Figma's two newer distributions came to the plugin after the pinned + // corpus (9214391); they are the CSS keywords of the same name. + let justify = match view.string("primaryAxisAlignItems") { + Some("MIN") => None, + Some("MAX") => Some("flex-end"), + Some("CENTER") => Some("center"), + Some("SPACE_BETWEEN") if centers_lone_space_between_child(snapshot, node) => Some("center"), + Some("SPACE_BETWEEN") => Some("space-between"), + Some("SPACE_AROUND") => Some("space-around"), + Some("SPACE_EVENLY") => Some("space-evenly"), + _ => None, + }; + let align = match view.string("counterAxisAlignItems") { + Some("MIN") => None, + Some("MAX") => Some("flex-end"), + Some("CENTER") => Some("center"), + Some("BASELINE") => Some("baseline"), + _ => None, + }; + if component != "Center" { + if let Some(value) = justify { + string_prop(props, "justifyContent", value); + } + if let Some(value) = align { + string_prop(props, "alignItems", value); + } + } + if component == "Center" && mode == Some("VERTICAL") { + string_prop(props, "flexDir", "column"); + } + // 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) + .or_else(|| view.number("itemSpacing")); + if let Some(gap) = gap.filter(|gap| *gap != 0.0) { + string_prop(props, "gap", px(gap)); + } + } +} + +/// 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. +/// How much room a frame leaves its children across one axis: its own size +/// less the padding on that axis. `None` when the size is not recorded. +fn inner_extent(node: &RawNode, size: &str, near: &str, far: &str) -> Option { + let view = node.typed_view(); + let inferred = view.value("inferredAutoLayout").and_then(Value::as_object); + let padding = |name: &str| { + inferred + .and_then(|layout| layout.get(name)) + .and_then(Value::as_f64) + .or_else(|| view.number(name)) + .unwrap_or(0.0) + }; + Some(view.number(size)? - padding(near) - padding(far)) +} + +/// Whether a node drawn into a wider box than it asked for would look any +/// different: either it paints across the box - a fill, a stroke, a shadow - +/// or it places its own content by the box's far edge or centre along that +/// axis. A left-aligned line of text in a box that paints nothing does not. +fn a_wider_box_would_show(view: &TypedNode<'_>, across_is_horizontal: bool) -> bool { + let visible = |value: Option<&Value>| { + value.and_then(Value::as_array).is_some_and(|entries| { + entries.iter().any(|entry| { + entry.get("visible").and_then(Value::as_bool) != Some(false) + && entry.get("type").and_then(Value::as_str) != Some("NONE") + }) + }) + }; + // A text node's fills are its ink, not its box, and a shadow follows the + // glyphs: none of them widen with the box. A centred line would move, + // and the pinned corpus holds three such texts - but no screen in the + // corpus renders one, so there is nothing to show that writing it helps, + // and it costs byte parity with the plugin on all three. Left alone + // until a rendered screen asks for it. + if view.node_type() == "TEXT" { + return false; + } + if visible(view.value("fills")) + || visible(view.value("strokes")) + || visible(view.value("effects")) + { + return true; + } + // Along its own main axis a node is placed by `primaryAxisAlignItems`, + // across it by `counterAxisAlignItems`; which of the two answers for the + // axis being stretched depends on which way the node itself runs. + let along_its_main_axis = match view.string("layoutMode") { + Some("HORIZONTAL") => across_is_horizontal, + Some("VERTICAL") => !across_is_horizontal, + _ => return false, + }; + if along_its_main_axis { + matches!( + view.string("primaryAxisAlignItems"), + Some("CENTER" | "MAX" | "SPACE_BETWEEN" | "SPACE_AROUND" | "SPACE_EVENLY") + ) + } else { + matches!(view.string("counterAxisAlignItems"), Some("CENTER" | "MAX")) + } +} + +/// Whether a frame's children, laid end to end along its own axis with the +/// gaps between them, come to more than the room it leaves. Figma lets them +/// spill and clips; CSS shrinks them to fit, so the two only agree while +/// they fit. Children out of flow or not drawn take no room. +fn line_overflows(snapshot: &Snapshot, node: &RawNode, size: &str, near: &str, far: &str) -> bool { + let view = node.typed_view(); + let Some(room) = inner_extent(node, size, near, far) else { + return false; + }; + let children = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| { + let child = child.typed_view(); + child.bool("visible") != Some(false) + && child.string("layoutPositioning") != Some("ABSOLUTE") + }) + .collect::>(); + if children.len() < 2 { + return false; + } + let gap = view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .and_then(|layout| layout.get("itemSpacing")) + .and_then(Value::as_f64) + .or_else(|| view.number("itemSpacing")) + .unwrap_or(0.0); + let mut extent = gap * (children.len() - 1) as f64; + for child in children { + let Some(own) = child.typed_view().number(size) else { + return false; + }; + extent += own; + } + extent > room + 0.5 +} + +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; + } + // Padding places one child. Two or more at their own positions cannot be + // put back by an inset around all of them — in flow they would stack — + // so they are placed one by one instead, see `placed_by_a_free_layout`. + only_visible_child(snapshot, node)?; + // A child the designer centred is centred, not padded: see + // `centres_its_only_child`. + if centres_its_only_child(snapshot, node) { + return None; + } + children_inset(snapshot, node) +} + +/// The one visible child of a frame, when there is exactly one. +fn only_visible_child<'a>(snapshot: &'a Snapshot, node: &RawNode) -> Option<&'a RawNode> { + let view = node.typed_view(); + let mut visible = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") != Some(false)); + match (visible.next(), visible.next()) { + (Some(only), None) => Some(only), + _ => None, + } +} + +/// Whether a frame that lays nothing out holds one child the designer centred +/// on both axes — constraints `CENTER` / `CENTER`. +/// +/// Such a child is centred by its constraint, not by the inset it happened to +/// have at the width it was drawn: the popup card sits 36.5px in at 390px and +/// 742px in at 1920px, and a measured `pl="36.5px"` holds at the one width +/// and drifts at every other. The frame is written as a `Center` with the +/// child in flow, which centres it at any width and needs no positioned +/// ancestor; a page root keeps its drawn height so there is something to +/// centre in, see `push_layout_props`. +/// +/// This departs from the plugin, on purpose. Its `canBeAbsolute` writes the +/// child `pos="absolute" left="50%" top="50%" transform="translate(-50%, +/// -50%)"`, which resolves against whatever positioned ancestor the page is +/// given, and leaves the frame — a page root, so sizeless — a box of no +/// height whose dim is never drawn. The answer it wrote for the popup, from a +/// layout Figma has since stopped inferring, pads the card in by the inset at +/// each width and at desktop by none, which leaves the card at the left of a +/// 1920px screen. +/// +/// A child centred on one axis only, or pinned to an edge, keeps the measured +/// inset as padding, as before. +pub(crate) fn centres_its_only_child(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + if view.string("layoutMode") != Some("NONE") || !lays_nothing_out(node) { + return false; + } + if super::style::asset_kind(snapshot, node).is_some() { + return false; + } + only_visible_child(snapshot, node).is_some_and(|only| { + only.typed_view() + .value("constraints") + .and_then(Value::as_object) + .is_some_and(|constraints| { + ["horizontal", "vertical"] + .iter() + .all(|axis| constraints.get(*axis).and_then(Value::as_str) == Some("CENTER")) + }) + }) +} + +/// The plugin's `isFreelayout`: a frame in flow with no auto layout, whose +/// children sit where the designer left them. +fn lays_nothing_out(node: &RawNode) -> bool { + let view = node.typed_view(); + view.string("layoutPositioning") == Some("AUTO") + && view.number("width").is_some() + && view.number("height").is_some() + && view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .is_none() + && !matches!( + view.string("layoutMode"), + Some("HORIZONTAL" | "VERTICAL" | "GRID") + ) +} + +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(mut top), + Some(mut right), + Some(mut bottom), + Some(mut left), + ] = [ + get("paddingTop"), + get("paddingRight"), + get("paddingBottom"), + get("paddingLeft"), + ] + else { + return; + }; + // An inside stroke is painted over the padding: a card 20px in from its + // edge with a 1px stroke inside is still 20px in, stroke and all, and + // its height is the content plus 40. CSS's border is added around the + // padding instead, so a hugging card came out 2px taller - the landing + // page's four feature cards put the join-us panel 8px down - and on a + // fixed axis the content sat 1px further in. Taking the stroke out of + // the padding puts the content where Figma has it on every axis: it + // starts `p` in and, hugging, the box is content plus `2p`. + if view.string("strokeAlign").unwrap_or("INSIDE") == "INSIDE" + && !super::style::inside_stroke_uses_outline(&view) + && view.node_type() != "LINE" + && let Some(weight) = view.number("strokeWeight").filter(|weight| *weight > 0.0) + && view + .value("strokes") + .and_then(Value::as_array) + .is_some_and(|strokes| { + strokes.iter().any(|paint| { + paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("type").and_then(Value::as_str) == Some("SOLID") + }) + }) + { + top = (top - weight).max(0.0); + bottom = (bottom - weight).max(0.0); + left = (left - weight).max(0.0); + right = (right - weight).max(0.0); + } + if top == 0.0 && right == 0.0 && bottom == 0.0 && left == 0.0 { + return; + } + // A zero padding is the default, so naming it says nothing. Emitting it + // only because the other axis happened to be padded left props like + // `px="0px"` sitting next to a real `py`. + let mut push = |name: &str, value: f64| { + if value != 0.0 { + string_prop(props, name, px(value)); + } + }; + // 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 same(top, bottom) { + push("py", top); + } else { + push("pt", top); + push("pb", bottom); + } + if same(left, right) { + push("px", left); + } else { + push("pl", left); + push("pr", right); + } + } +} + +/// Whether `child_id` is a positioned child of `parent` that Figma draws +/// under everything else in it: nothing in flow comes before it, and +/// something in flow comes after. CSS paints a positioned element after every +/// in-flow sibling whatever the order, so such a child has to be sent behind +/// on purpose. +/// +/// `zIndex="-1"` sends it behind *every* in-flow sibling, not only the later +/// ones, so it is only right when there is nothing earlier to stay above. +/// The notice page's header is pinned second, after its banner: sent behind, +/// it vanished under the banner it is meant to sit on. It is left where CSS +/// puts it, above all of them, which nothing there overlaps anyway. +fn sits_behind_in_flow_siblings(snapshot: &Snapshot, parent: &RawNode, child_id: &str) -> bool { + let ids = parent.typed_view().child_ids().collect::>(); + let Some(index) = ids.iter().position(|id| *id == child_id) else { + return false; + }; + let positioned = |node: &RawNode| { + node.typed_view().string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout(snapshot, node, Some(parent), false) + }; + let in_flow = + |node: &RawNode| node.typed_view().bool("visible") != Some(false) && !positioned(node); + let Some(child) = snapshot.nodes.get(child_id) else { + return false; + }; + if !positioned(child) { + return false; + } + let earlier_in_flow = ids[..index] + .iter() + .filter_map(|id| snapshot.nodes.get(*id)) + .any(in_flow); + let later_in_flow = ids[index + 1..] + .iter() + .filter_map(|id| snapshot.nodes.get(*id)) + .any(in_flow); + !earlier_in_flow && later_in_flow +} + +/// Whether a node in flow is nonetheless placed by its parent, because the +/// parent lays nothing out. +/// +/// A frame with no auto layout puts each child where the designer left it, +/// and the plugin's `canBeAbsolute` writes every such child at its +/// constraints — `pos="absolute"` with the edges it is pinned to — and gives +/// the frame `pos="relative"` to hold them. Here that was only done for a +/// child marked absolute, so the notice banner's title and its two logos, three +/// children of a free frame, were stacked in flow with no position at all. +/// +/// Two cases are kept out. A frame whose single child's inset can be measured +/// is written with that inset as padding and the child in flow, which puts it +/// in the same place and lets it size the frame; and a frame whose single +/// child is centred is written as a `Center` with the child in flow, see +/// `centres_its_only_child`. +pub(crate) fn placed_by_a_free_layout( + snapshot: &Snapshot, + node: &RawNode, + parent: Option<&RawNode>, + is_page_root: bool, +) -> bool { + let view = node.typed_view(); + let Some(parent) = parent else { + return false; + }; + // Whether the parent is itself placed by its own parent or pinned into + // it says nothing about whether it lays its children out. The devup-ui + // landing page's join-us panel holds a group of ten circles pinned to + // the card at -277,-187; because the group is `ABSOLUTE`, its children + // were read as being in flow, lost the coordinates they carry, stacked + // from the group's corner and were clipped away - the arcs and both + // badges were not drawn at all. + // + // This is only about the parent. `lays_nothing_out` also answers for a + // node about itself, where being pinned does decide what it is, so it is + // left alone. + let parent_view = parent.typed_view(); + let parent_lays_nothing_out = matches!( + parent_view.string("layoutPositioning"), + Some("AUTO" | "ABSOLUTE") + ) && parent_view.number("width").is_some() + && parent_view.number("height").is_some() + && parent_view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .is_none() + && !matches!( + parent_view.string("layoutMode"), + Some("HORIZONTAL" | "VERTICAL" | "GRID") + ); + !is_page_root + && view.value("constraints").is_some() + && parent_lays_nothing_out + && derived_padding(snapshot, parent).is_none() + && !centres_its_only_child(snapshot, parent) +} + +/// Places a positioned node, and returns the anchor a width policy chose for +/// it, if one was given. +fn push_absolute( + snapshot: &Snapshot, + node: &RawNode, + parent: Option<&RawNode>, + props: &mut Vec, + placed_by: Option, + policy: Option<&WidthPolicy>, +) -> Option { + string_prop(props, "pos", "absolute"); + let view = node.typed_view(); + let parent_node = parent?; + // The box to place: the node's own, or the one handed in - an asset's + // export. A group's children carry their `x` and `y` in the group's + // parent's space, not the group's: the join-us panel's outermost circle, + // which is exactly the group, reads `-277,-187` - the group's own place in + // the card - where the group's own space would say `0,0`. Placed as read, + // every circle sat 277px left and 187px high of where Figma draws it. + // `placed_box` settles it from the absolute boxes, without depending on + // whose space a number is in. + let placed = super::width::placed_box(node, parent_node, placed_by); + let parent = parent_node.typed_view(); + // A group has no constraints of its own; the plugin's `getPositionProps` + // reads its first child's, and so does this. The report section's + // illustration is a group pinned to the bottom of its frame through its + // children, `bottom="-284.8px"`, where reading the group alone put it + // at `top`. + let constraints = view + .value("constraints") + .and_then(Value::as_object) + .or_else(|| { + view.child_ids() + .next() + .and_then(|child| snapshot.nodes.get(child))? + .typed_view() + .value("constraints") + .and_then(Value::as_object) + }); + let horizontal = constraints + .and_then(|value| value.get("horizontal")) + .and_then(Value::as_str) + .unwrap_or("MIN"); + let vertical = constraints + .and_then(|value| value.get("vertical")) + .and_then(Value::as_str) + .unwrap_or("MIN"); + let (x, y) = (placed.x, placed.y); + let anchor = + policy.map(|policy| super::width::effective_anchor(snapshot, node, parent_node, policy).0); + match (anchor, horizontal) { + (Some(anchor), _) => super::width::push_horizontal( + props, + anchor, + placed, + parent.number("width").unwrap_or(0.0), + ), + (None, "MAX") => string_prop( + props, + "right", + px(parent.number("width").unwrap_or(0.0) - x - placed.w), + ), + (None, "CENTER") => { + string_prop(props, "left", "50%"); + string_prop(props, "transform", "translateX(-50%)"); + } + (None, _) => string_prop(props, "left", px(x)), + } + match vertical { + "MAX" => string_prop( + props, + "bottom", + px(parent.number("height").unwrap_or(0.0) - y - placed.h), + ), + "CENTER" => { + string_prop(props, "top", "50%"); + if let Some((_, PropValue::String(value))) = + props.iter_mut().find(|(name, _)| name == "transform") + { + *value = "translate(-50%, -50%)".into(); + } else { + string_prop(props, "transform", "translateY(-50%)"); + } + } + _ => string_prop(props, "top", px(y)), + } + anchor +} + +pub(super) fn string_prop(props: &mut Vec, name: &str, value: impl Into) { + let value = PropValue::String(value.into()); + if let Some((_, existing)) = props.iter_mut().find(|(existing, _)| existing == name) { + *existing = value; + } else { + props.push((name.to_owned(), value)); + } +} + +pub(crate) fn px(value: f64) -> String { + format!("{}px", format_number(value)) +} + +pub(super) fn format_number(value: f64) -> String { + let rounded = (value * 100.0).round() / 100.0; + if rounded == 0.0 { + "0".to_owned() + } else if rounded.fract() == 0.0 { + format!("{rounded:.0}") + } else { + format!("{rounded:.2}").trim_end_matches('0').to_owned() + } +} + +/// Dimensions whose child-based HUG sizing is destroyed by mask projection. +/// Render bounds alone cannot establish layout size (effects may enlarge them). +pub(crate) fn folded_mask_dimensions(snapshot: &Snapshot, node: &RawNode) -> Option<(f64, f64)> { + let view = node.typed_view(); + if super::style::asset_kind(snapshot, node) != Some(super::style::AssetKind::SvgMask) + || view.child_ids().next().is_none() + // Rotated exports need a separate transform proof. + || view.number("rotation").is_some_and(|rotation| rotation.abs() > 0.01) + || !(view.string("layoutSizingHorizontal") == Some("HUG") + || view.string("layoutSizingVertical") == Some("HUG")) + { + return None; + } + if export_offset(node).is_some() { + // In-flow HUG/FIXED axes retain the measured layout box. The export's + // larger painted bounds are independently preserved by maskSize and + // maskPos. A FILL axis would scale that box while leaving pixel mask + // offsets unchanged; positioned assets instead use the export box. + let parent = view + .string("parentId") + .and_then(|id| snapshot.nodes.get(id)); + if !matches!(view.string("layoutSizingHorizontal"), Some("HUG" | "FIXED")) + || !matches!(view.string("layoutSizingVertical"), Some("HUG" | "FIXED")) + || view.string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout(snapshot, node, parent, false) + { + return None; + } + } + let measured = |axis| { + view.number(axis) + .or_else(|| view.value("absoluteBoundingBox")?.get(axis)?.as_f64()) + .filter(|v| v.is_finite() && *v > 0.0) + }; + Some((measured("width")?, measured("height")?)) +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs b/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs new file mode 100644 index 00000000..10b3e02f --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/layout/layout_properties.rs @@ -0,0 +1,964 @@ +use devup_mcp_figma::{RawNode, Snapshot, TypedNode}; +use serde_json::Value; + +use super::component::{Prop, PropValue, RootLayout}; +use super::width::{Anchor, WidthPolicy}; + +/// A box in a parent's coordinates: left, top, width, height. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Box4 { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +fn box4(value: Option<&Value>) -> Option { + let value = value?; + Some(Box4 { + x: value.get("x")?.as_f64()?, + y: value.get("y")?.as_f64()?, + w: value.get("width")?.as_f64()?, + h: value.get("height")?.as_f64()?, + }) +} + +/// What Figma exports for an asset node, in its parent's coordinates: the +/// node's render bounds, which frame its SVG and PNG exports - measured +/// against `exportAsync` on the official server, 2026-09-07: an instance of +/// 1373x98 whose vector sits inside it exports as 952x104, and a group of +/// 686x735 rotated four degrees exports as 759x585 with the rotation drawn +/// into the paths. `None` where the snapshot does not carry the bounds. +pub(crate) fn export_box(snapshot: &Snapshot, node: &RawNode) -> Option { + let view = node.typed_view(); + let render = box4(view.value("absoluteRenderBounds"))?; + let parent = view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id))?; + let parent_box = box4(parent.typed_view().value("absoluteBoundingBox"))?; + Some(Box4 { + x: render.x - parent_box.x, + y: render.y - parent_box.y, + w: render.w, + h: render.h, + }) +} + +/// Where an in-flow asset's export sits inside the box the layout gives it, +/// and how large it is: the render bounds against the bounding box. `None` +/// when they coincide, which is every plain icon, or when the snapshot does +/// not carry them. +pub(super) fn export_offset(node: &RawNode) -> Option { + let view = node.typed_view(); + let render = box4(view.value("absoluteRenderBounds"))?; + let bounds = box4(view.value("absoluteBoundingBox"))?; + let close = |left: f64, right: f64| (left - right).abs() < 0.5; + if close(render.x, bounds.x) + && close(render.y, bounds.y) + && close(render.w, bounds.w) + && close(render.h, bounds.h) + { + return None; + } + Some(Box4 { + x: render.x - bounds.x, + y: render.y - bounds.y, + w: render.w, + h: render.h, + }) +} + +/// The box the layout gives an asset that is not positioned: its bounding +/// box, which is its own box unless it is rotated, when it is the box the +/// rotation sweeps - the box Figma's own layout gives it. +pub(crate) fn layout_box(node: &RawNode) -> Option { + box4(node.typed_view().value("absoluteBoundingBox")) +} + +// Root placement, width policy and image representation are independent +// inputs to this shared layout property writer. +#[allow(clippy::too_many_arguments)] +pub(super) fn push_layout_props( + snapshot: &Snapshot, + node: &RawNode, + component: &str, + props: &mut Vec, + root_layout: RootLayout, + is_render_root: bool, + policy: Option<&WidthPolicy>, + original_source: bool, +) { + let view = node.typed_view(); + let parent = snapshot.nodes.values().find(|candidate| { + candidate + .typed_view() + .child_ids() + .any(|child| child == node.id) + }); + let is_root = snapshot.roots.iter().any(|root| root == &node.id); + // 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"); + let fill_h = view.string("layoutSizingVertical") == Some("FILL"); + let absolute = view.string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout(snapshot, node, parent, is_page_root); + let embedded_root = is_render_root && root_layout == RootLayout::Embedded; + let containing_block = holds_positioned_children(snapshot, node); + let mut width = None; + let mut height = None; + let mut policy_sizing = None; + + if embedded_root { + // The selected frame is being inserted into an existing page layout. + // Preserve its visual/layout semantics, but do not constrain the host + // with Figma canvas geometry or root positioning. + } else if absolute { + let is_asset = !original_source && super::style::asset_kind(snapshot, node).is_some(); + // An exported asset is placed by what the export frames. The plugin + // places it by the node's own box and rotates it again, and the + // report section's rotated illustration landed 120px low, squeezed + // into 686x735 where its export is 759x585. + let export = is_asset.then(|| export_box(snapshot, node)).flatten(); + let anchor = push_absolute(snapshot, node, parent, props, export, policy); + // Figma paints children in order, so a pinned picture drawn before + // its siblings sits behind them. CSS paints a positioned element + // after every in-flow sibling whatever the order, so the landing + // page's hero picture came out over its headline and the join-us + // badges over their buttons. Sent behind with `zIndex="-1"` - inside + // the stacking context the parent opens for it, below - it sits + // where Figma has it: above the parent's own background, under the + // content. + // + // Only under a parent that opens that context, including page roots. + if let Some(parent) = parent + && holds_positioned_children(snapshot, parent) + && sits_behind_in_flow_siblings(snapshot, parent, &node.id) + { + string_prop(props, "zIndex", "-1"); + } + if let Some(export) = export { + width = Some(px(export.w)); + height = Some(px(export.h)); + } + // The plugin's `_getLayoutProps` for a positioned node, as one rule + // rather than a branch per kind of node. Its width is its own only + // while the parent is wider and it is an asset or an empty frame; + // spilling past the parent it is `100%`, and a frame with children in + // it has no width said at all, its children being what sizes it. Its + // height is `100%` for a shape, its own for an empty frame, and unsaid + // once it has children. So the about hero picture, 418px in a 320px + // column, is `boxSize="100%"`, and a hidden 1920px frame in a 992px + // one is `w="100%" h="667px"`. + // + // Preserve the existing placement policy here. Only child-folded mask + // HUG axes are repaired below; other unproven sizes remain diagnostics. + let own_width = view.number("width"); + let parent_width = parent.and_then(|parent| parent.typed_view().number("width")); + if export.is_some() { + // Sized above, by the export. + } else if component == "Text" { + width = own_width.map(px); + height = Some("100%".to_owned()); + } else if view.node_type() == "INSTANCE" && is_asset { + // The plugin puts a positioned instance in a wrapper `Box` that + // carries the position, and draws the instance from its main + // component, which sizes itself by its own layout. An instance + // that folds to an asset is one element here, so it takes that + // size: a 651px logo pinned in a 360px banner is + // `w="651px" h="46px"`, not `w="100%"`. An instance kept as a + // component reference is sized as the wrapper is, below. + if fixed_w { + width = own_width.map(px); + } + if fixed_h { + height = view.number("height").map(px); + } + } else { + let parent_wider = matches!( + (own_width, parent_width), + (Some(width), Some(parent_width)) if parent_width > width + ); + let has_children = view.child_ids().next().is_some(); + let holds_children = matches!( + view.node_type(), + "FRAME" + | "GROUP" + | "INSTANCE" + | "COMPONENT" + | "COMPONENT_SET" + | "BOOLEAN_OPERATION" + ); + let empty_frame = holds_children && !has_children; + width = if parent_wider { + (is_asset || empty_frame) + .then(|| own_width.map(px)) + .flatten() + } else { + Some("100%".to_owned()) + }; + let wider_than_parent = matches!( + (own_width, parent_width), + (Some(width), Some(parent_width)) if width >= parent_width + ); + // A shape with nothing in it is as big as itself. `100%` is + // only the same thing when it covers its parent - the popup's + // dim overlay - and a 220px circle pinned in an 1,102px group + // said `h="100%"` and no width at all, which is no circle. + // + // Only inside a group. The plugin's rule says `100%` and no + // width for a small shape pinned in a frame as well, and the + // pinned corpus holds four such shapes; that is as wrong there, + // but no rendered screen in the corpus shows it, so it keeps + // byte parity until one does. A group is a different case in + // any event: it draws nothing and lays nothing out, so a shape + // in it can only ever be its own size. + let own_height = view.number("height"); + let parent_height = parent.and_then(|parent| parent.typed_view().number("height")); + let parent_taller = matches!( + (own_height, parent_height), + (Some(height), Some(parent_height)) if parent_height > height + ); + let in_a_group = + parent.is_some_and(|parent| parent.typed_view().node_type() == "GROUP"); + let leaf_shape = !has_children && !holds_children && !is_asset && in_a_group; + if leaf_shape && parent_wider { + width = own_width.map(px); + } + height = if has_children { + (is_asset && !wider_than_parent) + .then(|| own_height.map(px)) + .flatten() + } else if empty_frame || (leaf_shape && parent_taller) { + own_height.map(px) + } else { + 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. + // + // Ordinary frames may derive size from their content or padding. + // Child-folded HUG masks receive a bounded size repair below. + 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); + } + // The same fact on the height alone, for a frame that fills its + // parent's width and so was given one above. Its children size it in + // CSS where Figma pinned it: the notice header is 60 tall around a + // 24px row of logo and menu, and centring them in 24 rather than 60 + // put them 18px high of where Figma draws them. + // + // Child-folded HUG masks have a repair below. A FIXED container height + // is independent of the current child's HUG height and derived insets. + if fixed_h && height.is_none() && !is_asset && view.child_ids().next().is_some() { + // Derived insets describe the captured child geometry, not a height + // constraint. HUG content can change without changing this FIXED box. + height = view.number("height").map(px); + } + policy_sizing = anchor.zip(parent).map(|(anchor, parent)| { + let parent_view = parent.typed_view(); + ( + anchor, + super::width::placed_box(node, parent, export), + parent_view.number("width").unwrap_or(0.0), + parent_view.number("height").unwrap_or(0.0), + is_asset, + ) + }); + } else if is_page_root { + // Figma page roots define the component canvas; their editor dimensions + // are not emitted as runtime constraints: a root's width is the + // viewport's, and a root that lays its children out is as tall as + // they are, at any width. + // + // A root that lays nothing out has nothing in flow to give it a + // height — its children are placed absolutely, or its one child is + // centred in it. The plugin leaves it sizeless too, and the popup + // overlay, a 390×800 frame dimmed behind one centred card, comes out + // a box of no height whose dim is never drawn; the answer it wrote + // gave the height back as padding, `py="211.5px"` around a 377px + // card. The drawn height is the design, and it is kept, one value per + // width. The width is still the viewport's. This departs from the + // plugin on purpose. + if lays_nothing_out(node) + && view.child_ids().next().is_some() + && derived_padding(snapshot, node).is_none() + { + height = view.number("height").map(px); + } + } else if fixed_w || fixed_h { + if fixed_w { + width = view.number("width").map(px); + } + if fixed_h { + height = view.number("height").map(px); + } + // A rotated asset in flow takes the box its rotation sweeps, which + // is the box Figma's layout gives it and the box its export fills; + // its own width and height are the picture's before the turn. + if view + .number("rotation") + .is_some_and(|rotation| rotation.abs() > 0.01) + && super::style::asset_kind(snapshot, node).is_some() + && !original_source + && let Some(bounds) = layout_box(node) + { + if fixed_w { + width = Some(px(bounds.w)); + } + if fixed_h { + height = Some(px(bounds.h)); + } + } + if fill_w + && (view.value("maxWidth") != Some(&Value::Null) + || parent.is_some_and(|parent| child_shrinker(parent, "width"))) + { + width = Some("100%".to_owned()); + } + if fill_h + && (view.value("maxHeight") != Some(&Value::Null) + || parent.is_some_and(|parent| child_shrinker(parent, "height"))) + { + height = Some("100%".to_owned()); + } + } else if is_root { + let no_dimensions = view.number("width").is_none() && view.number("height").is_none(); + let has_children = view.child_ids().next().is_some(); + let implicit_text_fill = view.node_type() == "TEXT" + && view.value("layoutSizingHorizontal").is_none() + && view.value("layoutSizingVertical").is_none(); + let standalone_component = view.node_type() == "COMPONENT" + && !snapshot + .nodes + .values() + .any(|node| node.typed_view().node_type() == "COMPONENT_SET"); + if (no_dimensions && (view.node_type() != "COMPONENT" || standalone_component)) + || has_children + || fill_w + || fill_h + || view.node_type() == "GROUP" + || implicit_text_fill + { + width = Some("100%".to_owned()); + height = Some("100%".to_owned()); + } + } else { + if fill_w + && (view.value("maxWidth") != Some(&Value::Null) + || parent.is_some_and(|parent| child_shrinker(parent, "width"))) + { + width = Some("100%".to_owned()); + } else if fixed_w { + width = view.number("width").map(px); + } + if fill_h + && (view.value("maxHeight") != Some(&Value::Null) + || parent.is_some_and(|parent| child_shrinker(parent, "height"))) + { + height = Some("100%".to_owned()); + } else if fixed_h { + height = view.number("height").map(px); + } + if view.node_type() != "COMPONENT" + && view.number("width").is_none() + && view.number("height").is_none() + { + width = Some("100%".to_owned()); + height = Some("100%".to_owned()); + } + } + + // A canvas with positioned children is their coordinate system, not just + // a responsive page measurement. Preserve each FIXED axis even on a page + // or section root. Embedded mode explicitly delegates dimensions to host. + if containing_block && !embedded_root { + if fixed_w { + width = view.number("width").map(px); + } + if fixed_h { + height = view.number("height").map(px); + } + } + + // A FIXED column is also the height basis for in-flow FILL children. + // This relationship does not depend on positioned descendants (R6). + if !embedded_root && vertical_fill_container(snapshot, node) { + height = view.number("height").map(px); + } + let vertical_fill = !absolute + && !embedded_root + && fill_h + && parent.is_some_and(|p| p.typed_view().string("layoutMode") == Some("VERTICAL")); + if vertical_fill { + // Main-axis FILL means remaining space, not 100% of the whole parent. + height = None; + } + + if component == "Text" && fixed_w && fixed_h { + match view.string("textAutoResize") { + Some("WIDTH_AND_HEIGHT") => { + if view.number("width").is_some() || view.number("height").is_some() { + width = None; + height = None; + } + } + Some("HEIGHT") => { + if let Some(text_width) = view.number("width") { + width = Some(px(text_width)); + height = None; + } + } + Some("NONE" | "TRUNCATE") if !is_page_root => { + width = view.number("width").map(px).or(width); + height = view.number("height").map(px).or(height); + } + _ => {} + } + } + + // Only folding children into a CSS mask destroys HUG's intrinsic basis. + // FILL remains fluid; a vector's measured ratio supplies its lost HUG axis. + let mut restored_ratio = None; + if !embedded_root && let Some((w, h)) = folded_mask_dimensions(snapshot, node) { + let horizontal = view.string("layoutSizingHorizontal"); + let vertical = view.string("layoutSizingVertical"); + if horizontal == Some("FILL") && vertical == Some("HUG") { + width = Some("100%".into()); + height = None; + restored_ratio = Some(format!("{w} / {h}")); + } else if horizontal == Some("HUG") && vertical == Some("FILL") { + width = None; + height = Some("100%".into()); + restored_ratio = Some(format!("{w} / {h}")); + } else { + if horizontal == Some("HUG") { + width = Some(px(w)); + } + if vertical == Some("HUG") { + height = Some(px(h)); + } + } + } + + // Horizontal FILL is independent of the other axis. Parent alignment + // can disable implicit stretch; preserve the fluid allocation explicitly. + if !absolute + && !embedded_root + && fill_w + && width.is_none() + && parent.is_some_and(|p| p.typed_view().string("layoutMode") == Some("VERTICAL")) + && parent.is_some_and(|p| { + matches!( + p.typed_view().string("counterAxisAlignItems"), + Some("CENTER" | "MAX" | "BASELINE") + ) + }) + { + width = Some("100%".into()); + } + + // Cross-axis FILL must stretch even when the row centers other children. + // Percentage height cannot resolve against a HUG row's auto height. + if !absolute + && !embedded_root + && fill_h + && parent.is_some_and(|p| { + p.typed_view().string("layoutMode") == Some("HORIZONTAL") + && p.typed_view().string("layoutSizingVertical") == Some("HUG") + }) + { + height = None; + string_prop(props, "alignSelf", "stretch"); + } + + // Under a width policy the horizontal anchor decides the width, after + // every other rule: a stretch is its two edges, a scale its share of the + // parent, and any other anchor holds a childless shape at its own drawn + // size, which the plugin's `h="100%"` and no width does not - except on an + // axis where it spans its whole parent, which keeps following it as the + // default export already writes it. + if let Some((anchor, placed, parent_width, parent_height, is_asset)) = policy_sizing { + let shape = component != "Text" && !is_asset && view.child_ids().next().is_none(); + match anchor { + Anchor::Stretch => width = None, + Anchor::Scale => width = Some(super::width::percent(placed.w, parent_width)), + _ if shape && placed.w + 0.5 < parent_width => width = view.number("width").map(px), + _ => {} + } + if shape && placed.h + 0.5 < parent_height { + height = view.number("height").map(px); + } + } + // A root under a width policy takes its container's width; the drawn + // width stays the coordinate basis its positioned children were placed + // in, and the widthBehavior report says what that leaves unproven. + if policy.is_some() + && is_render_root + && !embedded_root + && width.as_deref().is_some_and(|width| width.ends_with("px")) + { + width = Some("100%".to_owned()); + } + // Whether the height was said outright, which decides below whether the + // node still needs to be told to take the space its parent leaves. + if original_source && absolute { + if fixed_w { + width = view.number("width").map(px); + } + if fixed_h { + height = view.number("height").map(px); + } + } + let wrote_height = height.is_some(); + let wrote_width = width.is_some(); + if let (Some(width), Some(height)) = (&width, &height) + && width == height + { + string_prop(props, "boxSize", width.clone()); + } else { + if let Some(width) = width { + string_prop(props, "w", width); + } + if let Some(height) = height { + string_prop(props, "h", height); + } + } + + // A positioned node has its size said outright — the plugin's absolute + // branch of `_getLayoutProps` never writes `aspectRatio` — so the ratio + // is only for a node in flow, where it stands in for a side that is not + // written. The about hero picture is `boxSize="100%"` with no ratio. + if let Some(ratio) = restored_ratio { + string_prop(props, "aspectRatio", ratio); + } else if !absolute + && let Some(aspect) = view.value("targetAspectRatio").and_then(Value::as_object) + && let (Some(x), Some(y)) = ( + aspect.get("x").and_then(Value::as_f64), + aspect.get("y").and_then(Value::as_f64), + ) + && y != 0.0 + { + string_prop( + props, + "aspectRatio", + format_number((x / y * 100.0).floor() / 100.0), + ); + } + for (field, prop) in [ + ("maxWidth", "maxW"), + ("maxHeight", "maxH"), + ("minWidth", "minW"), + ("minHeight", "minH"), + ] { + if let Some(value) = view.number(field) { + string_prop(props, prop, px(value)); + } + } + if view.string("parentId").is_some() + && let Some(parent) = parent + // A component set's grid is how Figma lays its variants out on the + // canvas, not how the component is built. A variant is drawn on its + // own wherever it is used, so carrying the cell it sat in would place + // every button at the coordinates of its row in the sheet. + && parent.typed_view().node_type() != "COMPONENT_SET" + && parent + .typed_view() + .value("inferredAutoLayout") + .and_then(Value::as_object) + .and_then(|layout| layout.get("layoutMode")) + .and_then(Value::as_str) + == Some("GRID") + { + let column = view.number("gridColumnAnchorIndex").unwrap_or(-1.0); + let row = view.number("gridRowAnchorIndex").unwrap_or(-1.0); + // How many tracks the child covers. The plugin writes `span 1` for + // every child, and a picture drawn across two columns came out in one, + // its neighbour pushed a row down and every `1fr` row stretched to + // the tallest picture: a 1086px grid rendered 2191px tall. The spans + // are Figma's own, absent from the snapshot when they are 1. + let column_span = view.number("gridColumnSpan").unwrap_or(1.0).max(1.0); + let row_span = view.number("gridRowSpan").unwrap_or(1.0).max(1.0); + let column_count = parent.typed_view().number("gridColumnCount").unwrap_or(0.0); + let current = column + row * column_count; + let natural = parent + .typed_view() + .child_ids() + .position(|child| child == node.id) + .map(|index| index as f64); + // A child at its natural cell needs no placement - unless it spans, + // which flow alone would not give it. + if column >= 0.0 + && row >= 0.0 + && (natural != Some(current) || column_span > 1.0 || row_span > 1.0) + { + string_prop( + props, + "gridColumn", + format!( + "{} / span {}", + format_number(column + 1.0), + format_number(column_span) + ), + ); + string_prop( + props, + "gridRow", + format!( + "{} / span {}", + format_number(row + 1.0), + format_number(row_span) + ), + ); + } + } + if fill_w + && parent + .is_some_and(|parent| parent.typed_view().string("layoutMode") == Some("HORIZONTAL")) + { + string_prop(props, "flex", "1"); + // `flex: 1` shares out the room left over, and Figma gives a filling + // child exactly that - here 123px - and lets anything wider inside it + // spill out. CSS will not shrink a flex item under its content + // (`min-width: auto`), so a 132px row inside a 123px share widened + // the share to 132 and took the 9px from the hugging sibling, whose + // `Devup-ui` then broke across two lines. `minW="0"` lets the share + // be the share, and the content spill as Figma draws it. Written + // only where the snapshot shows content wider than the box. A row + // can overflow collectively even when each child fits individually. + // Wrapping rows can distribute that content over multiple lines. + let child_overflows = view.number("width").is_some_and(|own| { + view.child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .any(|child| { + let child = child.typed_view(); + child.bool("visible") != Some(false) + && child.string("layoutPositioning") != Some("ABSOLUTE") + && child.number("width").is_some_and(|width| width > own + 0.5) + }) + }); + let row_overflows = view.string("layoutMode") == Some("HORIZONTAL") + && view.string("layoutWrap") != Some("WRAP") + && line_overflows(snapshot, node, "width", "paddingLeft", "paddingRight"); + if view.number("minWidth").is_none() && (child_overflows || row_overflows) { + string_prop(props, "minW", "0"); + } + } + // Main-axis FILL consumes remaining space, whether its own children are + // positioned or in flow. This keeps the completion screen's body ahead of + // the following bottom action without copying any canvas coordinates. + if vertical_fill { + string_prop(props, "flex", "1"); + if vertical_fill_needs_minimum_reset(snapshot, node) { + string_prop(props, "minH", "0"); + } + } + // A child Figma never shrinks, in a line that does not fit. Figma keeps a + // fixed size and lets the row spill past its parent, which clips it; CSS + // shrinks flex children to fit instead. The devup-ui landing page's + // comparison row is seven 240px cards in a 912px frame - 1,800px of + // content - and every one of them was squeezed to about 120px, their + // labels wrapped to two lines, and the row came out 58px taller than the + // design, carrying everything below it down with it. + // + // Only where the line actually overflows. Children that fit are not + // shrunk by CSS either, and saying so for every fixed child in the file + // would be noise. + if !absolute + && let Some(parent) = parent + && let Some(axis) = parent.typed_view().string("layoutMode") + && matches!(axis, "VERTICAL" | "HORIZONTAL") + { + let (sizing, size, near, far) = if axis == "HORIZONTAL" { + ( + "layoutSizingHorizontal", + "width", + "paddingLeft", + "paddingRight", + ) + } else { + ( + "layoutSizingVertical", + "height", + "paddingTop", + "paddingBottom", + ) + }; + if view.string(sizing) == Some("FIXED") && line_overflows(snapshot, parent, size, near, far) + { + string_prop(props, "flexShrink", "0"); + } + } + // A child that hugs across its parent's axis, in a parent that packs its + // children to the start, drawn narrower than the room it has, and drawn + // differently for being stretched into it. + // + // Figma's default counter-axis alignment is MIN, which it writes by + // leaving the field out - and leaving it out of the code too means CSS + // applies its own default, `align-items: stretch`, which is the + // opposite. The devup-ui landing page's `Get started` button, 247px wide + // in the 1360px column that holds it, was drawn 1360px wide. + // + // Most hugging children do not care. A line of left-aligned text in a + // box that paints nothing is the same picture at any width, and writing + // an alignment for every one of them - the notice desktop alone holds + // twenty - says nothing while burying the few that matter. So it is + // written where the wider box would show: where the node paints across + // it, or places its own content by it. + if !absolute + && let Some(parent) = parent + && let Some(axis) = parent.typed_view().string("layoutMode") + && matches!(axis, "VERTICAL" | "HORIZONTAL") + && matches!( + parent.typed_view().string("counterAxisAlignItems"), + None | Some("MIN") + ) + { + let across_is_horizontal = axis == "VERTICAL"; + let (sizing, size, near, far, wrote) = if across_is_horizontal { + ( + "layoutSizingHorizontal", + "width", + "paddingLeft", + "paddingRight", + wrote_width, + ) + } else { + ( + "layoutSizingVertical", + "height", + "paddingTop", + "paddingBottom", + wrote_height, + ) + }; + if view.string(sizing) == Some("HUG") + && !wrote + && let (Some(own), Some(room)) = + (view.number(size), inner_extent(parent, size, near, far)) + && own + 0.5 < room + && a_wider_box_would_show(&view, across_is_horizontal) + { + string_prop(props, "alignSelf", "flex-start"); + } + } + + push_auto_layout(snapshot, node, component, props); + push_padding(snapshot, node, props); + match view.string("overflowDirection") { + Some("VERTICAL_SCROLLING") => { + string_prop(props, "overflowY", "auto"); + if view.bool("clipsContent") == Some(true) { + string_prop(props, "overflowX", "hidden"); + } + } + Some("HORIZONTAL_SCROLLING") => { + string_prop(props, "overflowX", "auto"); + if view.bool("clipsContent") == Some(true) { + string_prop(props, "overflowY", "hidden"); + } + } + Some("HORIZONTAL_AND_VERTICAL_SCROLLING") => string_prop(props, "overflow", "auto"), + _ 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. + // + // A node that is itself positioned is already that ancestor, and saying + // `relative` over its `absolute` would put it back in flow: the join-us + // group of circles, pinned at -277,-187, took 1,102px of the page. + if containing_block && (!absolute || embedded_root) { + string_prop(props, "pos", "relative"); + } + // Position (relative or absolute) establishes a containing block, but + // needs an explicit z-index to contain negative-z children above its own + // background. Keep an existing z-index that places this entire subtree. + if containing_block + && !props.iter().any(|(name, _)| name == "zIndex") + && view + .child_ids() + .any(|child| sits_behind_in_flow_siblings(snapshot, node, child)) + { + string_prop(props, "zIndex", "0"); + } + // An export is drawn with its rotation in it, so an asset whose bounds + // the snapshot carries is not rotated again; without the bounds it is + // placed as the plugin places it, rotation and all. + let exported = !original_source + && super::style::asset_kind(snapshot, node).is_some() + && view.value("absoluteRenderBounds").is_some(); + if let Some(rotation) = view.number("rotation") + && rotation.abs() > 0.01 + && !exported + { + string_prop( + props, + "transform", + format!("rotate({}deg)", format_number(-rotation)), + ); + if absolute { + string_prop(props, "transformOrigin", "top left"); + } + } +} + +pub(crate) fn vertical_fill_container(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + view.string("layoutMode") == Some("VERTICAL") + && view.string("layoutSizingVertical") == Some("FIXED") + && view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .any(|child| { + let child = child.typed_view(); + child.bool("visible") != Some(false) + && child.string("layoutPositioning") != Some("ABSOLUTE") + && child.string("layoutSizingVertical") == Some("FILL") + }) +} + +/// CSS's automatic flex minimum must not enlarge a captured FILL allocation. +/// Preserve explicit source minimums; only reset the implicit content minimum +/// when collected in-flow content demonstrably overflows the allocated height. +pub(crate) fn vertical_fill_needs_minimum_reset(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + if view.number("minHeight").is_some() { + return false; + } + (view.string("layoutMode") == Some("VERTICAL") + && line_overflows(snapshot, node, "height", "paddingTop", "paddingBottom")) + || inner_extent(node, "height", "paddingTop", "paddingBottom").is_some_and(|room| { + view.child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .any(|child| { + let child = child.typed_view(); + child.bool("visible") != Some(false) + && child.string("layoutPositioning") != Some("ABSOLUTE") + && child + .number("height") + .is_some_and(|height| height > room + 0.5) + }) + }) +} + +/// Only children that can survive projection need a containing block. +pub(super) fn holds_positioned_children(snapshot: &Snapshot, node: &RawNode) -> bool { + super::style::asset_kind(snapshot, node).is_none() + && node.typed_view().child_ids().any(|id| { + snapshot.nodes.get(id).is_some_and(|child| { + let view = child.typed_view(); + view.bool("visible") != Some(false) + && (view.string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout(snapshot, child, Some(node), false)) + }) + }) +} + +pub(super) fn absolute_layout_is_exact(snapshot: &Snapshot, node: &RawNode) -> bool { + let view = node.typed_view(); + if view.string("layoutPositioning") != Some("ABSOLUTE") { + return true; + } + let parent = snapshot.nodes.values().find(|candidate| { + candidate + .typed_view() + .child_ids() + .any(|child_id| child_id == node.id) + }); + let has_geometry = ["x", "y", "width", "height"] + .into_iter() + .all(|field| view.number(field).is_some_and(f64::is_finite)); + let constraints = view.value("constraints").and_then(Value::as_object); + let supported_constraint = |axis: &str| { + constraints + .and_then(|value| value.get(axis)) + .and_then(Value::as_str) + .is_none_or(|value| matches!(value, "MIN" | "MAX")) + }; + 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; + } + if view.child_ids().next().is_none() { + return true; + } + let inferred_auto_layout = view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .is_some(); + let parent = parent.typed_view(); + let horizontal = view.string("layoutSizingHorizontal") == Some("HUG") + && inferred_auto_layout + || matches!( + (view.number("width"), parent.number("width")), + (Some(width), Some(parent_width)) if width == parent_width + ); + let vertical = view.string("layoutSizingVertical") == Some("HUG") && inferred_auto_layout; + horizontal && vertical + }); + parent.is_some() + && has_geometry + && supported_constraint("horizontal") + && supported_constraint("vertical") + && no_rotation + && exact_size +} + +fn child_shrinker(parent: &RawNode, dimension: &str) -> bool { + let inferred = parent + .typed_view() + .value("inferredAutoLayout") + .and_then(Value::as_object); + match dimension { + "width" => inferred.is_some_and(|layout| { + layout.get("layoutMode").and_then(Value::as_str) == Some("VERTICAL") + && layout.get("counterAxisAlignItems").and_then(Value::as_str) == Some("CENTER") + }), + "height" => inferred.is_some_and(|layout| { + layout.get("layoutMode").and_then(Value::as_str) == Some("HORIZONTAL") + && layout.get("counterAxisAlignItems").and_then(Value::as_str) == Some("CENTER") + }), + _ => false, + } +} + +pub(super) struct GridTemplate { + pub css: Option, + pub unverified: Vec<&'static str>, +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs b/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs index f205b94d..63d2f997 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs @@ -93,18 +93,18 @@ pub(crate) fn property_derivations( "wordBreak" => (vec!["styledTextSegments"], "push_text_props: Korean segment text uses keep-all word breaking."), "boxShadow" if style::asset_kind(snapshot, node).is_none() && style::single_outside_stroke(&node.typed_view()).is_some() => (vec!["strokes", "strokeAlign", "strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight", "effects"], - "One solid square-cornered OUTSIDE edge becomes a zero-blur translated box shadow that consumes no layout space; real effects follow it in paint order. This does not claim a source shadow effect or pixel parity."), + include_str!("../text/property_derivations-property_derivations-f4103b4e.md")), "boxShadow" | "textShadow" | "filter" | "backdropFilter" => (vec!["effects"], - "push_effects: visible effects in source order; shadow offset.x/y, radius, spread and bound/resolved RGBA color; textShadow omits spread; blur uses radius. Existing effect-loss diagnostics still apply."), + include_str!("../text/property_derivations-property_derivations-a4bd5441.md")), "objectFit" | "objectPos" | "maskRepeat" | "maskSize" | "maskPos" => { if *stage != "style" { return None; } style_derivations.matching(&name, &value)?.description() }, "alignSelf" => (vec!["layoutAlign", "layoutSizingHorizontal", "layoutSizingVertical", "parentId"], "Layout cross-axis fill maps to alignSelf=stretch when required by parent alignment."), - "transform" | "transformOrigin" => (vec!["rotation", "relativeTransform", "absoluteBoundingBox", "absoluteRenderBounds", "constraints", "x", "y", "parentId"], "Layout/style transform composition from rotation, placement and CENTER constraints; asset exports may already bake rotation. The emitted expression is compared with the responsible stage."), + "transform" | "transformOrigin" => (vec!["rotation", "relativeTransform", "absoluteBoundingBox", "absoluteRenderBounds", "constraints", "x", "y", "parentId"], include_str!("../text/property_derivations-property_derivations-691f8d10.md")), "zIndex" => (vec!["layoutPositioning", "fills", "childrenIds", "parentId"], "Layout stacking policy for absolute backgrounds and overlapping children, evaluated by push_layout_props."), "gridTemplateColumns" | "gridTemplateRows" => - (vec!["inferredAutoLayout", "gridColumnSizes", "gridRowSizes", "gridColumnCount", "gridRowCount", "layoutSizingHorizontal", "layoutSizingVertical"], "push_auto_layout: explicit FIXED tracks retain pixels, HUG/AUTO tracks retain content sizing, and FLEX columns use minmax(0, Nfr) outside HUG grids to avoid automatic min-content expansion. Rows retain their content minimum. Count-only tracks use a diagnosed equal-share fallback (auto for HUG); unknown/invalid tracks use diagnosed auto sizing. Child widths and placement are independent of track sizing."), + (vec!["inferredAutoLayout", "gridColumnSizes", "gridRowSizes", "gridColumnCount", "gridRowCount", "layoutSizingHorizontal", "layoutSizingVertical"], include_str!("../text/property_derivations-property_derivations-a0f0555b.md")), "gridColumn" | "gridRow" | "columnGap" | "rowGap" => (vec!["layoutMode", "gridColumnAnchorIndex", "gridRowAnchorIndex", "gridColumnSpan", "gridRowSpan", "gridColumnSizes", "gridRowSizes", "gridColumnCount", "gridRowCount", "gridColumnGap", "gridRowGap", "parentId"], "push_layout_props: grid tracks, one-based anchors/spans and grid gaps; parent grid count participates in placement."), "bgBlendMode" => (vec!["fills"], "push_style_props: visible paint blendMode maps to CSS background blend mode."), @@ -114,12 +114,12 @@ pub(crate) fn property_derivations( "visibility" | "display" => (vec!["visible", "opacity", "absoluteRenderBounds", "fills", "textTruncation", "maxLines"], "Visibility/non-rendering or text truncation policy in the named stage; existing visibility classification is retained."), "WebkitTextStroke" | "paintOrder" => (vec!["strokes", "strokeWeight", "styledTextSegments"], "push_text_props composes text stroke width/color and stroke-before-fill paint order."), "WebkitBoxOrient" | "WebkitLineClamp" | "textOverflow" => (vec!["textTruncation", "maxLines", "styledTextSegments"], "push_text_props projects truncation and maximum line count into the emitted clamp properties."), - "whiteSpace" if text::preserves_hard_break_spaces(&node.typed_view()) => (vec!["characters", "styledTextSegments", "textAutoResize", "maxLines"], "Spaces adjacent to explicit breaks in non-list, unclamped text with fixed inline width use pre-wrap. This preserves source whitespace without changing Korean word-breaking policy; it does not establish glyph or wrapping parity."), + "whiteSpace" if text::preserves_hard_break_spaces(&node.typed_view()) => (vec!["characters", "styledTextSegments", "textAutoResize", "maxLines"], include_str!("../text/property_derivations-property_derivations-2ec1244f.md")), "textDecoration" => (vec!["textDecoration", "styledTextSegments"], "Text UNDERLINE/STRIKETHROUGH maps to underline/line-through."), "textTransform" => (vec!["textCase", "styledTextSegments"], "Text case projection follows push_text_props."), "as" | "my" => (vec!["styledTextSegments", "listOptions", "paragraphSpacing"], "Text list/paragraph projection uses the emitted semantic tag and spacing policy."), "animationName" | "animationDuration" | "animationTimingFunction" | "animationFillMode" | "animationDelay" | "animationIterationCount" => - (vec!["reactions", "relativeTransform", "rotation", "childrenIds"], "push_animation_props follows timed Smart Animate destinations in the collected snapshot, computes changed property keyframes and timing. Existing unreachable/unsupported animation diagnostics still apply."), + (vec!["reactions", "relativeTransform", "rotation", "childrenIds"], include_str!("../text/property_derivations-property_derivations-f0db063f.md")), // No generic 'the renderer wrote it' proof. A newly introduced // property must register its source fields/calculation or receives // PROPERTY_UNMAPPED from the final AST audit. diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs index 11b19938..14e3f86b 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs @@ -22,2148 +22,7 @@ //! The rules here are ported from `devup-figma-plugin` at the commit this //! repo's corpus pins — `src/codegen/responsive/`. -use std::collections::{BTreeMap, BTreeSet, VecDeque}; - -use devup_mcp_figma::{DevupError, RawNode, Snapshot}; -use serde_json::{Value, json}; - -use super::{ - component::{CodegenOptions, generate_component}, - variant::{ - POSITION_PROPS, Tree, instance_variant_props, project_tree_keeping_instances, - referenced_component_name, structure_signature, - }, -}; -use crate::provenance::attributes::{ - GeneratedAttribute, OwnedElement, element_tags, generated_attributes, opening_elements, - owned_elements, -}; - -/// 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"]; - -/// How many slots a devup-ui responsive array has: `[mobile, sm, tablet, lg, -/// PC]`. -pub const SLOTS: usize = 5; - -/// The width each slot runs up to, from `devup-figma-plugin`'s `BREAKPOINTS`. -/// The last slot has no bound. -const SLOT_BOUNDS: [u32; SLOTS - 1] = [480, 768, 992, 1280]; - -/// Which slot a frame of this width occupies. -/// -/// A width is placed by how wide it is, not by what its frame is called. The -/// same three names — `mobile` / `tablet` / `desktop` — land on slots 0/2/4 in -/// one screen and 0/1/4 in another, so reading the name and assuming a slot -/// puts every value of the second screen a band too wide. -pub fn slot_of_width(width: u32) -> usize { - SLOT_BOUNDS - .iter() - .position(|bound| width <= *bound) - .unwrap_or(SLOTS - 1) -} - -/// The props a disappearing value must be cleared for, from the plugin's -/// `SPECIAL_PROPS_WITH_INITIAL`, plus visibility for suppressed assets. A colour -/// that stops being set is left to inherit rather than reset. -const CLEARED_WHEN_DROPPED: [&str; 42] = [ - "display", - "visibility", - "position", - "pos", - "transform", - "w", - "h", - "textAlign", - "flexDir", - "flexWrap", - "justify", - "alignItems", - "alignContent", - "alignSelf", - "gap", - "rowGap", - "columnGap", - "flex", - "flexGrow", - "flexShrink", - "flexBasis", - "order", - "gridTemplateColumns", - "gridTemplateRows", - "gridColumn", - "gridRow", - "gridArea", - "top", - "right", - "bottom", - "left", - "zIndex", - "overflow", - "overflowX", - "overflowY", - "p", - "pt", - "pr", - "pb", - "pl", - "px", - "py", -]; - -/// The margin props, kept apart only because the array above is already at the -/// length rustfmt likes to fight over. -const CLEARED_WHEN_DROPPED_MARGINS: [&str; 7] = ["m", "mt", "mr", "mb", "ml", "mx", "my"]; - -/// A value that need not be written because it is what the prop already is, -/// from the plugin's `DEFAULT_PROPS_MAP`. Its padding and margin entries are -/// commented out upstream, so they are absent here too. -fn is_default(prop: &str, value: &str) -> bool { - match prop { - "alignItems" | "justifyContent" => value == "flex-start", - "flexDir" => value == "row", - "gap" => value == "0" || value == "0px", - "textDecorationSkipInk" - | "textDecorationThickness" - | "textDecorationColor" - | "textUnderlineOffset" => value == "auto", - "textDecorationStyle" => value == "solid", - _ => false, - } -} - -fn cleared_when_dropped(prop: &str) -> bool { - CLEARED_WHEN_DROPPED.contains(&prop) || CLEARED_WHEN_DROPPED_MARGINS.contains(&prop) -} - -/// What one width has to say about one prop. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Drawn<'a> { - /// No width is drawn at this slot at all. - Absent, - /// A width is drawn here and leaves the prop unset. - Unset, - /// A width is drawn here and sets the prop. - Set(&'a str), -} - -/// One prop, after the widths have been compared. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Merged { - /// Every width that is drawn says the same thing, so no array is needed. - Same(Option), - /// The widths disagree, and these are the slots to write. - Array(Vec>), -} - -/// Lay one prop's values across the widths as a devup-ui responsive array. -/// -/// A slot is written only when it changes what is in effect, because `null` -/// does not mean "no value" — it means "whatever the slot before it said". The -/// same rule is why a prop cannot simply stop: dropping it would leave the -/// narrower width's value inherited, so a layout prop that a wider width no -/// longer sets is written `"initial"`. Without it `pl={["36.5px"]}` would -/// silently keep the mobile padding at every width. -/// -/// This follows `devup-figma-plugin`'s `mergePropsToResponsive` and -/// `optimizeResponsiveValue`, with one of its limits kept: only the props in -/// [`CLEARED_WHEN_DROPPED`] are cleared, so a `bg` that stops being set still -/// inherits. The other is not kept. The plugin places exactly one -/// `"initial"`, after the last value, so a prop set, dropped, then set again -/// wider kept the narrower value at the width that dropped it; on `about` that -/// hid the one layer only the tablet draws at every width. Here every drawn -/// width that leaves the prop unset after a narrower one set it is cleared. -/// Visibility deliberately differs: every drawn, unset width restores visible -/// paint. -pub fn merge_slots(prop: &str, widths: &[Drawn<'_>; SLOTS]) -> Merged { - // One width is a screen, not a screen that changes. The reference hands - // its props back untouched rather than wrapping each in a one-slot array. - let mut drawn = widths.iter().filter(|width| **width != Drawn::Absent); - if let Some(only) = drawn.next() - && drawn.next().is_none() - { - return Merged::Same(match only { - Drawn::Set(value) => Some((*value).to_owned()), - Drawn::Absent | Drawn::Unset => None, - }); - } - - let mut slots: Vec> = widths - .iter() - .map(|width| match width { - Drawn::Set(value) => Some((*value).to_owned()), - Drawn::Unset if prop == "visibility" => Some("initial".to_owned()), - Drawn::Absent | Drawn::Unset => None, - }) - .collect(); - - // The clear lands only on a width that is actually drawn; one on a slot no - // frame occupies would say nothing. Repeats collapse below. - if cleared_when_dropped(prop) { - let mut set_before = false; - for (slot, width) in widths.iter().enumerate() { - match width { - Drawn::Set(_) => set_before = true, - Drawn::Unset if set_before => slots[slot] = Some("initial".to_owned()), - Drawn::Unset | Drawn::Absent => {} - } - } - } - - // A slot that repeats what is already in effect says nothing. - let mut carried: Option = None; - for slot in &mut slots { - let Some(value) = slot.clone() else { - continue; - }; - if carried.as_deref() == Some(value.as_str()) { - *slot = None; - } else { - carried = Some(value); - } - } - if slots - .first() - .and_then(Option::as_deref) - .is_some_and(|value| is_default(prop, value)) - { - slots[0] = None; - } - while slots.last().is_some_and(Option::is_none) { - slots.pop(); - } - - match slots.len() { - 0 => Merged::Same(None), - 1 => Merged::Same(slots.swap_remove(0)), - _ => Merged::Array(slots), - } -} - -/// 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`]; narrowest is 0. - 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) - .or_else(|| { - // Some Sections label their middle design by a width range instead - // of "tablet". Recognize only explicit numeric ranges; a number in - // an unrelated screen name is not a breakpoint declaration. - let (start, end) = name.split_once(['~', '–'])?; - let (start, end) = ( - start.trim().parse::().ok()?, - end.trim().parse::().ok()?, - ); - (start > 0 && end >= start).then_some(1) - }) -} - -pub fn is_breakpoint_name(name: &str) -> bool { - rank_of(name).is_some() -} - -/// Explicit width suffixes can describe one named screen. No fuzzy matching. -pub fn family_name(name: &str) -> String { - let name = name.trim().to_ascii_lowercase(); - if is_breakpoint_name(&name) { - return "@breakpoint-labels".into(); - } - for suffix in ["desktop", "tablet", "mobile"] { - let trimmed = name.trim_end_matches(')'); - if let Some(base) = trimmed.strip_suffix(suffix) - && base.ends_with([' ', '-', '/', '(']) - { - return base.trim_end_matches([' ', '-', '/', '(']).to_owned(); - } - } - name -} - -/// Whether roots with these names and widths could be a breakpoint family. -/// -/// The question [`breakpoints`] answers, asked without a snapshot, so it can -/// be put to a Section index — which already carries every candidate's name -/// and width — before anything is collected. -/// -/// It has to be answerable there. The refusal lived in projection, which runs -/// after the collection it needs: asking for `responsiveTsx` over two frames -/// of one Braillify Studio screen spent 77 seconds and ten Figma reads before -/// reporting that the snapshot had no mergeable breakpoint frames — a verdict -/// the frames' own names and widths settle in full. -/// -/// Keep this in step with [`breakpoints`]. `the_predicate_agrees_with_ -/// discovery` in `tests/responsive_prevalidate.rs` holds the two together, so -/// a change to either rule that forgets the other fails there rather than in -/// a 77-second export. -pub fn breakpoint_family_is_possible(frames: &[(&str, f64)]) -> bool { - if frames.len() < 2 - || frames - .iter() - .any(|(_, w)| !w.is_finite() || *w <= 0.0 || *w > f64::from(u32::MAX)) - { - return false; - } - let mut slots = BTreeSet::new(); - for (_, width) in frames { - #[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "validated finite positive pixel width" - )] - if !slots.insert(slot_of_width(*width as u32)) { - return false; - } - } - let mut ranks: Vec = Vec::new(); - for (name, _width) in frames { - let Some(rank) = rank_of(name) else { - continue; - }; - if ranks.contains(&rank) { - continue; - } - ranks.push(rank); - } - if ranks.len() == frames.len() { - return true; - } - width_family_is_possible(frames) -} - -/// The fallback [`breakpoints_by_width`] applies, asked of names and widths -/// alone: one name across two or more distinct width bands. -/// -/// Its two refusals are kept exactly, because either one loosened here would -/// let a collection start that the projection then refuses — the cost this -/// predicate exists to avoid. -fn width_family_is_possible(frames: &[(&str, f64)]) -> bool { - let mut shared_name: Option = None; - let mut slots: Vec = Vec::new(); - for (name, width) in frames { - let name = family_name(name); - if *shared_name.get_or_insert_with(|| name.clone()) != name { - return false; - } - if *width <= 0.0 { - return false; - } - #[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "a frame is a small positive number of pixels wide" - )] - let slot = slot_of_width(*width as u32); - if slots.contains(&slot) { - return false; - } - slots.push(slot); - } - slots.len() >= 2 -} - -/// The breakpoint roots this snapshot carries, in the order the Section holds -/// them. -/// -/// The order is kept on purpose. Where a width does not draw a node, the -/// reference gives that width a hidden copy of the node from the *first* -/// width that does — first in the Section's own layer order, which is how the -/// plugin walks it — and every value of that copy, not only its `display`, -/// lands in the array. The about hero is drawn at tablet and desktop, and its -/// picture is `w={["770px", null, "778px", null, "770px"]}`: the mobile slot -/// says 770 because desktop comes first in that Section. Sorting the roots by -/// width put tablet's 778 there instead. -/// -/// 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 frames = snapshot - .roots - .iter() - .filter_map(|id| { - let view = snapshot.nodes.get(id)?.typed_view(); - Some((view.name()?, view.number("width")?)) - }) - .collect::>(); - if frames.len() != snapshot.roots.len() || !breakpoint_family_is_possible(&frames) { - return Vec::new(); - } - let mut found: Vec = Vec::new(); - for id in &snapshot.roots { - let Some(node) = snapshot.nodes.get(id) else { - continue; - }; - let Some(rank) = node.typed_view().name().and_then(rank_of) else { - continue; - }; - // Two frames with one name: the first keeps it, as in the plugin. - if found.iter().any(|breakpoint| breakpoint.rank == rank) { - continue; - } - found.push(Breakpoint { - rank, - node_id: id.clone(), - }); - } - if found.len() < 2 { - return breakpoints_by_width(snapshot); - } - found -} - -/// The same screen drawn at several widths, for a file that does not use the -/// plugin's breakpoint names. -/// -/// `slot_of_width` already places a width "by how wide it is, not by what its -/// frame is called", but discovery read the name alone, so a file naming its -/// frames anything else had no family at all: Braillify Studio draws `AUTH-01` -/// at 1920 and again at 375, both directly under one Section, and asking for -/// `responsiveTsx` over the two answered that there was nothing to merge. -/// -/// Two conditions keep this from folding things the design never drew, and -/// either one failing leaves the snapshot with no family rather than a -/// guessed one: -/// -/// - **One name.** `AUTH-01` and `AUTH-02` are two designs, not one design at -/// two sizes. -/// - **Distinct width bands.** The same screen appears twice at 375 — a light -/// and a dark rendering — and neither is a breakpoint of the other. A band -/// claimed twice is ambiguous, and guessing which copy wins is the failure -/// this is meant to avoid. -/// -/// The roots keep the order the snapshot carries them in, for the reason -/// [`breakpoints`] documents: the first width that draws a node supplies the -/// widths that do not, and sorting changes which one that is. -fn breakpoints_by_width(snapshot: &Snapshot) -> Vec { - let mut shared_name: Option = None; - let mut found: Vec = Vec::new(); - for id in &snapshot.roots { - let Some(node) = snapshot.nodes.get(id) else { - return Vec::new(); - }; - let view = node.typed_view(); - let (Some(name), Some(width)) = (view.name(), view.number("width")) else { - return Vec::new(); - }; - let name = family_name(name); - if *shared_name.get_or_insert_with(|| name.clone()) != name { - return Vec::new(); - } - if width <= 0.0 { - return Vec::new(); - } - #[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "a frame is a small positive number of pixels wide" - )] - let slot = slot_of_width(width as u32); - if found.iter().any(|breakpoint| breakpoint.rank == slot) { - return Vec::new(); - } - found.push(Breakpoint { - rank: slot, - node_id: id.clone(), - }); - } - 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.iter().max_by_key(|breakpoint| breakpoint.rank) 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(); - } -} - -// --------------------------------------------------------------------------- -// Joining the widths into one tree -// --------------------------------------------------------------------------- - -/// One node's counterparts, by the slot each is drawn at. -type BySlot = [Option; SLOTS]; - -/// Where each slot's width comes in the Section's layer order; `usize::MAX` -/// for a slot no width occupies. The lowest goes first, as the plugin's -/// `firstMapValue` does: it is the width whose values fill in for a width that -/// does not draw a node, and whose element name a merged node keeps. -type Precedence = [usize; SLOTS]; - -/// The slot that comes first in the Section's order among those present. -fn first_slot(by_slot: &BySlot, precedence: &Precedence) -> Option { - (0..SLOTS) - .filter(|slot| by_slot[*slot].is_some()) - .min_by_key(|slot| precedence[*slot]) -} - -/// A node's children, grouped under the key its counterparts will be found by. -/// -/// A child whose shape is unique among its siblings is keyed by that shape, so -/// renaming it in one width does not lose it. Where several siblings share a -/// shape the shape cannot tell them apart, so their name is used instead and -/// they are paired in order: the second `quote` with the second `quote`. Each -/// keeps a key of its own, so siblings drawn between them stay between them; -/// one key for all of them emitted `“ ” title` for `“ title ”`. Insertion -/// order is kept, because it is what the ordering below reads. -fn children_to_map(tree: &Tree) -> Vec<(String, Vec<&Tree>)> { - let mut counts: BTreeMap = BTreeMap::new(); - for child in &tree.children { - *counts.entry(structure_signature(child)).or_default() += 1; - } - let mut grouped: Vec<(String, Vec<&Tree>)> = Vec::new(); - let mut seen: BTreeMap = BTreeMap::new(); - for child in &tree.children { - let signature = structure_signature(child); - let mut key = if counts.get(&signature) == Some(&1) { - format!("sig:{signature}") - } else { - child.node_name.clone() - }; - // `src` is an HTML attribute, not a responsive CSS property. Keep - // distinct sources as distinct children; the existing missing-child - // merge supplies their visibility slots without array-valued URLs. - if child.component == "Image" - && let Some(source) = child.props.get("src") - { - key = serde_json::to_string(&( - key, - source, - child.props.get("srcSet"), - child.props.get("sizes"), - child.props.get("width"), - child.props.get("height"), - )) - .expect("image identity is serializable"); - } - let occurrence = seen.entry(key.clone()).or_insert(0usize); - grouped.push((format!("{key}#{occurrence}"), vec![child])); - *occurrence += 1; - } - grouped -} - -/// One order for children that every width agrees with. -/// -/// Each width gives an order over the children it has, and no width need have -/// them all. Taking any single width's order would drop the others' children; -/// concatenating would scramble them. So the orders are read as edges of a -/// graph and sorted topologically, with a child's average position across the -/// widths breaking ties. This is `mergeChildNameOrder`. -fn merge_child_order(per_width: &[Vec]) -> Vec { - let mut names: Vec = Vec::new(); - for order in per_width { - for name in order { - if !names.contains(name) { - names.push(name.clone()); - } - } - } - if names.len() < 2 { - return names; - } - - let mut edges: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); - let mut incoming: BTreeMap<&str, usize> = names.iter().map(|name| (name.as_str(), 0)).collect(); - for order in per_width { - for pair in order.windows(2) { - if edges - .entry(pair[0].as_str()) - .or_default() - .insert(pair[1].as_str()) - { - *incoming.entry(pair[1].as_str()).or_default() += 1; - } - } - } - - let position = |name: &str| { - let mut total = 0.0; - let mut seen = 0.0; - for order in per_width { - if let Some(index) = order.iter().position(|other| other == name) { - total += if order.len() > 1 { - index as f64 / (order.len() - 1) as f64 - } else { - 0.5 - }; - seen += 1.0; - } - } - if seen > 0.0 { total / seen } else { 0.5 } - }; - - let mut ready = names - .iter() - .filter(|name| incoming.get(name.as_str()) == Some(&0)) - .map(String::as_str) - .collect::>(); - let mut sorted: Vec = Vec::new(); - while !ready.is_empty() { - ready.sort_by(|left, right| { - position(left) - .partial_cmp(&position(right)) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let next = ready.remove(0); - sorted.push(next.to_owned()); - for neighbour in edges.get(next).cloned().unwrap_or_default() { - let degree = incoming.entry(neighbour).or_insert(1); - *degree = degree.saturating_sub(1); - if *degree == 0 { - ready.push(neighbour); - } - } - } - // A cycle means the widths order two children oppositely. Nothing is - // dropped on that account; the rest keep the order they were found in. - for name in &names { - if !sorted.contains(name) { - sorted.push(name.clone()); - } - } - sorted -} - -/// Render one merged prop as the attribute text a JSX element carries. -/// -/// An array is written one slot to a line, as the plugin's `JSON.stringify` -/// with an indent of two does; `render_merged` indents the continuation -/// lines to the attribute. That is also what makes the element itself -/// multi-line, whatever its prop count. -fn render_attribute(merged: &Merged) -> Option { - match merged { - Merged::Same(None) => None, - Merged::Same(Some(value)) => Some(format!("=\"{value}\"")), - Merged::Array(slots) => { - let written = slots - .iter() - .map(|slot| { - slot.as_ref() - .map_or_else(|| " null".to_owned(), |value| format!(" \"{value}\"")) - }) - .collect::>() - .join(",\n"); - Some(format!("={{[\n{written}\n]}}")) - } - } -} - -/// What a layout element does by itself besides its `display`. -fn implied_layout(component: &str) -> &'static [(&'static str, &'static str)] { - match component { - "VStack" => &[("flexDir", "column")], - "Center" => &[("alignItems", "center"), ("justifyContent", "center")], - _ => &[], - } -} - -/// The `display` a devup-ui element already carries by being itself. -fn implied_display(component: &str) -> Option<&'static str> { - match component { - "Flex" | "VStack" | "Center" => Some("flex"), - "Grid" => Some("grid"), - _ => None, - } -} - -/// What `display` an element goes back to when a wider width stops hiding it. -/// -/// `merge_slots` clears a dropped layout prop to `"initial"`, which is right -/// for the other forty and wrong for this one. `initial` is the value the CSS -/// specification gives a property, not the value the element has: for `w` that -/// is `auto` and for `p` it is `0`, which are what "unset" should mean, but for -/// `display` it is `inline` whatever the element is. An element's own display -/// comes from the user-agent stylesheet instead, so a `Box` shown only from -/// tablet up would come back inline rather than block. -/// -/// `revert` is the keyword that would mean what is wanted, and it cannot be -/// used either: it rolls the cascade back past the author origin, which is -/// where devup-ui puts `VStack`'s own `display: flex`. So the value is written -/// out, and `display` is never cleared to `initial`. -fn natural_display(component: &str) -> &'static str { - match component { - "Flex" | "VStack" | "Center" => "flex", - "Grid" => "grid", - // `Image` is an `img`, which is inline. Everything else devup-ui draws - // is a `div`, and `Text` is a `p`. - "Image" => "inline", - _ => "block", - } -} - -/// Put back the `display` the element name stands for, before any merging. -/// -/// The plugin keeps `display` in a node's props and picks the element from it; -/// this projection picks the element first and drops the value. That is the -/// same thing for one width, but not across several: a width where the node is -/// hidden would leave the others with nothing to go back to, and `merge_slots` -/// would clear them to `"initial"` — which for `display` is `inline`, not -/// `flex`. The node would stop being a flex container at every width it is -/// shown. Rendering drops the value again where the element implies it, so -/// nothing is written that was not written before. -fn restore_implied_display(tree: &mut Tree) { - if let Some(display) = implied_display(&tree.component) { - tree.props - .entry("display".to_owned()) - .or_insert_with(|| display.to_owned()); - } - for child in &mut tree.children { - restore_implied_display(child); - } -} - -/// Fold one node's counterparts into a single node whose differing values have -/// become arrays. -fn merge_trees( - by_slot: &BySlot, - precedence: &Precedence, - notes: &mut Vec, -) -> Option { - // The merged element is the first width's. Where another width draws it as - // a different layout element, what each element does by itself has to be - // written out, or the first one's would apply at every width: a `VStack` - // root stacking a row the desktop width draws as a `Flex`. - let restated: BySlot; - let components = by_slot - .iter() - .flatten() - .map(|tree| tree.component.as_str()) - .collect::>(); - let by_slot = if components.len() > 1 - && components.iter().all(|name| LAYOUT_ELEMENTS.contains(name)) - && by_slot.iter().flatten().all(|tree| !tree.is_component) - { - restated = std::array::from_fn(|slot| { - by_slot[slot].clone().map(|mut tree| { - for (name, value) in implied_layout(&tree.component) { - tree.props - .entry((*name).to_owned()) - .or_insert_with(|| (*value).to_owned()); - } - tree.props - .entry("display".to_owned()) - .or_insert_with(|| natural_display(&tree.component).to_owned()); - tree - }) - }); - &restated - } else { - by_slot - }; - let first = by_slot[first_slot(by_slot, precedence)?].clone()?; - - let mut keys = BTreeSet::new(); - for tree in by_slot.iter().flatten() { - keys.extend(tree.props.keys().cloned()); - } - let mut props = BTreeMap::new(); - for key in keys { - // A component's own props are not style props. devup-ui reads an array - // only where it applies CSS, so `property1={[...]}` on `
` is - // read as the literal array and the component sees nonsense. The widest - // width drawn wins and the rest are reported, because a screen that - // really does want a different variant per width is asking for - // something the target cannot express. - if first.is_component && !is_placement_prop(&key) { - let mut seen = Vec::new(); - for tree in by_slot.iter().flatten() { - if let Some(value) = tree.props.get(&key) { - seen.push(value.clone()); - } - } - if let Some(widest) = seen.last().cloned() { - if seen.iter().any(|value| *value != widest) { - notes.push(Unrepresented { - node_id: first.node_id.clone(), - detail: format!( - "{} takes {key} {} at different widths; a component prop cannot be responsive, so {widest} is used.", - first.component, - seen.join(" / ") - ), - }); - } - props.insert(key, format!("=\"{widest}\"")); - } - continue; - } - let widths: [Drawn<'_>; SLOTS] = std::array::from_fn(|slot| match &by_slot[slot] { - None => Drawn::Absent, - Some(tree) => tree - .props - .get(&key) - .map_or(Drawn::Unset, |value| Drawn::Set(value)), - }); - let mut merged = merge_slots(&key, &widths); - if key == "display" - && let Merged::Array(slots) = &mut merged - { - // A component reference cannot carry `display`; it lands on the - // `Box` that will be wrapped around it below. - let natural = if first.is_component { - "block" - } else { - natural_display(&first.component) - }; - for slot in &mut *slots { - if slot.as_deref() == Some("initial") { - *slot = Some(natural.to_owned()); - } - } - } - // Every width agreeing on the `display` the element already has is the - // state this started in, so it goes back to being unwritten. - if key == "display" - && matches!(&merged, Merged::Same(Some(value)) - if implied_display(&first.component) == Some(value.as_str())) - { - continue; - } - if matches!(&merged, Merged::Same(Some(value)) - if implied_layout(&first.component).contains(&(key.as_str(), value.as_str()))) - { - continue; - } - if let Some(attribute) = render_attribute(&merged) { - props.insert(key, attribute); - } - } - - let mut source_node_ids = BTreeSet::new(); - for tree in by_slot.iter().flatten() { - source_node_ids.extend(tree.source_node_ids.iter().cloned()); - } - let slots = by_slot - .iter() - .map(|tree| { - tree.as_ref() - .map(|tree| (tree.node_id.clone(), tree.content.clone())) - }) - .collect::>(); - - let content = merge_content(by_slot, &first, notes); - - // A component reference has nothing below it to line up: each width picks - // its own variant, and the component answers for its own widths. - if first.is_component { - // Where it sits belongs to a wrapper; what variant it is belongs to the - // reference. Only the first is merged — passing an array to a component - // prop does nothing, so a variant that differs by width cannot be - // expressed and the widest one drawn is kept. - let (placement, variants): (BTreeMap<_, _>, BTreeMap<_, _>) = props - .into_iter() - .partition(|(name, _)| is_placement_prop(name)); - let reference = Tree { - props: variants, - children: Vec::new(), - source_node_ids: source_node_ids.clone(), - slots: slots.clone(), - ..first.clone() - }; - if placement.is_empty() { - return Some(reference); - } - return Some(Tree { - component: "Box".to_owned(), - props: placement, - children: vec![reference], - source_node_ids, - is_component: false, - content: None, - slots, - ..first - }); - } - - Some(Tree { - props, - children: merge_children(by_slot, precedence, notes), - source_node_ids, - content, - slots, - ..first - }) -} - -/// What a text says across the widths. -/// -/// Words are not a style prop and have no array to go into, so the widths -/// have to agree — with one allowance. A designer breaks a line at one width -/// and not another, and the same words then differ only by where `
` -/// falls; a break some widths have and others do not is written as -/// `` shown at the widths that have it. This is what the -/// plugin's `buildResponsiveTextChildren` sets out to do and does not: it -/// lines the texts up by character index, which the very break it is looking -/// for shifts, and it works on the rendered JSX of the widths, whose own -/// newlines it then rewrites as breaks. -/// -/// Words that differ by more than their breaks keep the first width's, and -/// the difference is reported rather than silently overruled. -fn merge_content( - by_slot: &BySlot, - first: &Tree, - notes: &mut Vec, -) -> Option { - let first_content = first.content.as_ref()?; - let drawn = by_slot - .iter() - .enumerate() - .filter_map(|(slot, tree)| { - tree.as_ref() - .and_then(|tree| tree.content.as_deref()) - .map(|content| (slot, content)) - }) - .collect::>(); - if drawn.iter().all(|(_, content)| *content == first_content) { - return Some(first_content.clone()); - } - - const BREAK: &str = "
"; - // The words with their breaks taken out, and where each break was: the - // offset, in characters of the words, that it sat before. - fn without_breaks(content: &str) -> (String, Vec) { - let mut words = String::with_capacity(content.len()); - let mut breaks = Vec::new(); - let mut rest = content; - let mut offset = 0; - while !rest.is_empty() { - if let Some(after) = rest.strip_prefix(BREAK) { - breaks.push(offset); - rest = after; - continue; - } - let character = rest.chars().next().expect("non-empty"); - words.push(character); - offset += 1; - rest = &rest[character.len_utf8()..]; - } - (words, breaks) - } - let stripped = drawn - .iter() - .map(|(slot, content)| (*slot, without_breaks(content))) - .collect::>(); - let (words, _) = &stripped[0].1; - if stripped.iter().any(|(_, (other, _))| other != words) { - notes.push(Unrepresented { - node_id: first.node_id.clone(), - detail: format!( - "{} reads differently at different widths; the first width's text is used.", - first.component - ), - }); - return Some(first_content.clone()); - } - - let breaks_at = |slot: usize, offset: usize| { - stripped - .iter() - .find(|(other, _)| *other == slot) - .map_or(0, |(_, (_, breaks))| { - breaks.iter().filter(|at| **at == offset).count() - }) - }; - let characters = words.chars().collect::>(); - let mut merged = String::with_capacity(first_content.len()); - for offset in 0..=characters.len() { - let most = stripped - .iter() - .map(|(slot, _)| breaks_at(*slot, offset)) - .max() - .unwrap_or_default(); - for nth in 1..=most { - let shown: BySlot = std::array::from_fn(|slot| { - by_slot[slot] - .as_ref() - .map(|_| breaks_at(slot, offset) >= nth) - }); - if shown.iter().flatten().all(|shown| *shown) { - merged.push_str(BREAK); - continue; - } - // Shown where the width breaks, `none` where it does not; a slot - // repeating the one in effect says nothing, and the first slot - // shown needs no value at all — a break is shown by default. - let mut slots: Vec> = shown - .iter() - .map(|shown| shown.map(|shown| if shown { "initial" } else { "none" }.to_owned())) - .collect(); - let mut carried: Option = None; - for slot in &mut slots { - let Some(value) = slot.clone() else { - continue; - }; - if carried.as_deref() == Some(value.as_str()) { - *slot = None; - } else { - carried = Some(value); - } - } - if slots.first().and_then(Option::as_deref) == Some("initial") { - slots[0] = None; - } - while slots.last().is_some_and(Option::is_none) { - slots.pop(); - } - let written = slots - .iter() - .map(|slot| { - slot.as_ref() - .map_or_else(|| "null".to_owned(), |value| format!("\"{value}\"")) - }) - .collect::>() - .join(", "); - merged.push_str(&format!("")); - } - if let Some(character) = characters.get(offset) { - merged.push(*character); - } - } - Some(merged) -} - -fn is_placement_prop(name: &str) -> bool { - super::variant::POSITION_PROPS.contains(&name) || name == "w" || name == "display" -} - -fn merge_children( - by_slot: &BySlot, - precedence: &Precedence, - notes: &mut Vec, -) -> Vec { - let maps: BySlot)>> = - std::array::from_fn(|slot| by_slot[slot].as_ref().map(children_to_map)); - let orders = maps - .iter() - .flatten() - .map(|map| map.iter().map(|(key, _)| key.clone()).collect::>()) - .collect::>(); - - let mut merged = Vec::new(); - for key in merge_child_order(&orders) { - let bucket = |slot: usize| { - maps[slot] - .as_ref() - .and_then(|map| map.iter().find(|(name, _)| *name == key)) - .map(|(_, children)| children) - }; - let count = (0..SLOTS) - .filter_map(|slot| bucket(slot).map(Vec::len)) - .max() - .unwrap_or_default(); - - for index in 0..count { - let mut children: BySlot = std::array::from_fn(|slot| { - bucket(slot).and_then(|list| list.get(index).cloned().cloned()) - }); - // A layer hidden at every width that has it is drawn at none, and - // `tsx` leaves it out — with its asset references, which a hidden - // layer has none of to deliver. A copy hidden everywhere would - // say nothing but `display: none`. - if children.iter().flatten().all(|child| { - child - .props - .get("display") - .is_some_and(|value| value == "none") - }) { - continue; - } - if (0..SLOTS).any(|slot| by_slot[slot].is_some() && children[slot].is_none()) { - for child in children.iter_mut().flatten() { - if child.component == "Image" { - // A present source must restore visibility even when - // it is absent on both sides of this drawn slot. - child - .props - .entry("display".to_owned()) - .or_insert_with(|| natural_display("Image").to_owned()); - } - } - } - // A width that does not draw this child is given a copy of the - // first one that does — first in the Section's order — hidden. The - // copies then merge like anything else: the `display` array falls - // out of the ordinary prop merge rather than from a branch of its - // own, and so do the copy's other values, which is why the choice - // of width to copy shows in the output. - if let Some(shown) = - first_slot(&children, precedence).and_then(|slot| children[slot].clone()) - { - for slot in 0..SLOTS { - if by_slot[slot].is_some() && children[slot].is_none() { - let mut hidden = shown.clone(); - hidden.props.insert("display".to_owned(), "none".to_owned()); - children[slot] = Some(hidden); - } - } - } - merged.extend(merge_trees(&children, precedence, notes)); - } - } - merged -} - -/// Write a merged tree as JSX, in the house style: props sorted, spread over -/// lines once there are five, and childless elements closed on themselves. -fn render_merged(tree: &Tree, depth: usize) -> String { - let indent = " ".repeat(depth); - let attributes = tree - .props - .iter() - .map(|(name, attribute)| format!("{name}{attribute}")) - .collect::>(); - // Five props, or any array, and the props go one to a line — the - // plugin's `propsToString` separator rule. - let multiline = - attributes.len() >= 5 || attributes.iter().any(|attribute| attribute.contains('\n')); - let opening = if attributes.is_empty() { - String::new() - } else if multiline { - let prefix = " ".repeat(depth + 1); - let padded = attributes - .iter() - .map(|attribute| attribute.replace('\n', &format!("\n{prefix}"))) - .collect::>(); - format!("\n{prefix}{}", padded.join(&format!("\n{prefix}"))) - } else { - format!(" {}", attributes.join(" ")) - }; - - let mut children = tree - .children - .iter() - .map(|child| render_merged(child, depth + 1)) - .collect::>(); - if let Some(content) = &tree.content { - let prefix = " ".repeat(depth + 1); - children.push( - content - .lines() - .map(|line| format!("{prefix}{line}")) - .collect::>() - .join("\n"), - ); - } - - // A shape spelled out in place of the component it came from still says - // which component that was, or a reader has nothing to go on when the - // change belongs upstream. - let comment = match &tree.leading_comment { - Some(comment) => format!("{indent}{{/* {comment} */}}\n"), - None => String::new(), - }; - let component = &tree.component; - if children.is_empty() { - if multiline { - format!("{comment}{indent}<{component}{opening}\n{indent}/>") - } else { - format!("{comment}{indent}<{component}{opening} />") - } - } else { - let close_open = if multiline { - format!("\n{indent}>") - } else { - ">".to_owned() - }; - format!( - "{comment}{indent}<{component}{opening}{close_open}\n{}\n{indent}", - children.join("\n") - ) - } -} - -/// Something the widths asked for that a single tree cannot say. -/// -/// Keeping these is the point of the exercise: an export that quietly picks one -/// width and moves on looks like a success and hides the thing worth fixing. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Unrepresented { - pub node_id: String, - pub detail: String, -} - -/// A screen's widths, folded into one tree. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MergedScreen { - /// The merged JSX, indented for a component body. - pub tsx: String, - /// Every element name the JSX mentions, primitives and components alike. - pub components: BTreeSet, - /// The slot each width was placed in, narrowest first. - pub slots: Vec, - /// What could not be carried across, and why. - pub unrepresented: Vec, - /// Every attribute of `tsx`, as `attribute_contract` lists one, with - /// whether each width's own verified TSX writes what it resolves to at - /// that width's slot. - pub contract: Vec, - /// Read at each width's slot, the module writes that width's own TSX: - /// the same elements with the same values, nothing added, nothing - /// missing, and the same text. - pub verified: bool, - /// Where a width and the module part company, when they do. - pub gaps: Vec, - /// Each opening element of `tsx`, in document order: the node every - /// drawn width made it from, and the widths it is drawn hidden at. - pub elements: Vec, -} - -/// One merged element and where it came from. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MergedElement { - /// Inside another element's text (a `
`), not a node of its own. - pub in_text: bool, - /// (frame width, node id) for each width that draws it, widest first. - pub ids: Vec<(u32, String)>, - /// The drawn widths it is present but hidden at. - pub hidden_at: Vec, -} - -/// Everything `project_tree` can name. Anything else the merged tree mentions -/// is a component of the design system and is imported from `@/components`. -const DEVUP_PRIMITIVES: [&str; 7] = ["Box", "Center", "Flex", "Grid", "Image", "Text", "VStack"]; - -impl MergedScreen { - /// The elements that come from devup-ui itself. - pub fn primitives(&self) -> Vec<&str> { - self.components - .iter() - .map(String::as_str) - .filter(|name| DEVUP_PRIMITIVES.contains(name)) - .collect() - } - - /// The elements that are components of this design, one file each. - pub fn referenced_components(&self) -> Vec<&str> { - self.components - .iter() - .map(String::as_str) - .filter(|name| !DEVUP_PRIMITIVES.contains(name)) - .collect() - } - - /// The whole file: what it imports, and the screen as a default export. - pub fn module(&self, component_name: &str) -> String { - let mut lines = Vec::new(); - let primitives = self.primitives(); - if !primitives.is_empty() { - lines.push(format!( - "import {{ {} }} from '@devup-ui/react'", - primitives.join(", ") - )); - } - lines.extend( - self.referenced_components() - .iter() - .map(|name| format!("import {{ {name} }} from '@/components/{name}'")), - ); - let imports = if lines.is_empty() { - String::new() - } else { - format!("{}\n\n", lines.join("\n")) - }; - format!( - "{imports}export default function {component_name}() {{\n return (\n{}\n )\n}}\n", - self.tsx - ) - } -} - -fn collect_components(tree: &Tree, into: &mut BTreeSet) { - into.insert(tree.component.clone()); - for child in &tree.children { - collect_components(child, into); - } -} - -/// Fold a screen drawn at several widths into one tree. -/// -/// `None` when the snapshot holds fewer than two widths — that is a screen, not -/// a screen that changes, and it has an ordinary single-width conversion. -pub fn merge_breakpoints( - snapshot: &Snapshot, - options: &CodegenOptions, -) -> Result, DevupError> { - let found = breakpoints(snapshot); - if found.len() < 2 { - return Ok(None); - } - let mut by_slot: BySlot = std::array::from_fn(|_| None); - let mut roots: BySlot = std::array::from_fn(|_| None); - let mut precedence: Precedence = [usize::MAX; SLOTS]; - let mut slot_widths: BySlot = [None; SLOTS]; - let mut slots = Vec::new(); - for (position, breakpoint) in found.iter().enumerate() { - let Some(node) = snapshot.nodes.get(&breakpoint.node_id) else { - continue; - }; - let width = node.typed_view().number("width").unwrap_or_default(); - #[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "a frame is a small positive number of pixels wide" - )] - let slot = slot_of_width(width.max(0.0) as u32); - slots.push(slot); - // Two frames in one band would overwrite each other. The first keeps - // the slot; reporting the clash is left to the caller, which knows the - // names, rather than silently dropping a width here. - if by_slot[slot].is_none() { - let mut tree = project_tree_keeping_instances(snapshot, node, options, true)?; - restore_implied_display(&mut tree); - by_slot[slot] = Some(tree); - roots[slot] = Some(breakpoint.node_id.clone()); - precedence[slot] = position; - #[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "a frame is a small positive number of pixels wide" - )] - { - slot_widths[slot] = Some(width.max(0.0).round() as u32); - } - } - } - slots.sort_unstable(); - let mut unrepresented = Vec::new(); - let Some(tree) = merge_trees(&by_slot, &precedence, &mut unrepresented) else { - return Ok(None); - }; - let mut components = BTreeSet::new(); - collect_components(&tree, &mut components); - let tsx = render_merged(&tree, 2); - let (contract, gaps) = verify_merge(snapshot, options, &roots, &tree, &tsx); - let elements = merged_elements(&tree, &slot_widths); - Ok(Some(MergedScreen { - elements, - verified: gaps.is_empty() && contract.iter().all(|entry| entry["verified"] == true), - tsx, - components, - slots, - unrepresented, - contract, - gaps, - })) -} - -/// Where each element of the merged module came from, in the order -/// `render_merged` writes the opening elements. -fn merged_elements(tree: &Tree, slot_widths: &BySlot) -> Vec { - let mut placed = Vec::new(); - place(tree, None, [true; SLOTS], &mut placed); - placed - .iter() - .map(|placed| { - if placed.in_text { - return MergedElement { - in_text: true, - ids: Vec::new(), - hidden_at: Vec::new(), - }; - } - let mut ids = Vec::new(); - let mut hidden_at = Vec::new(); - for (slot, width) in slot_widths.iter().enumerate() { - let Some(width) = width else { - continue; - }; - match placed.tree.slots.get(slot).cloned().flatten() { - Some((node, _)) if placed.shown[slot] => ids.push((*width, node)), - _ if !placed.shown[slot] => hidden_at.push(*width), - // Shown with no node of its own at that width: nothing - // in that frame to compare it with. - _ => {} - } - } - ids.sort_by_key(|(width, _)| std::cmp::Reverse(*width)); - hidden_at.sort_by_key(|width| std::cmp::Reverse(*width)); - MergedElement { - in_text: false, - ids, - hidden_at, - } - }) - .collect() -} - -/// One opening element of the merged module, in document order. -struct Placed<'a> { - tree: &'a Tree, - /// Inside the tree's text rather than the tree's own element. - in_text: bool, - /// Whether the element is drawn at each slot. - shown: [bool; SLOTS], - parent_shown: [bool; SLOTS], - /// The element it sits in, by its place in the same order. - parent: Option, -} - -/// The merged elements in the order `render_merged` writes them: an element, -/// then its children, then whatever its text holds. -fn place<'a>( - tree: &'a Tree, - parent: Option, - parent_shown: [bool; SLOTS], - placed: &mut Vec>, -) { - let display = tree - .props - .get("display") - .and_then(|value| slot_values(value)); - let shown = std::array::from_fn(|slot| { - parent_shown[slot] - && display - .as_deref() - .and_then(|values| effective(values, slot)) - != Some("none") - }); - let index = placed.len(); - placed.push(Placed { - tree, - in_text: false, - shown, - parent_shown, - parent, - }); - for child in &tree.children { - place(child, Some(index), shown, placed); - } - if let Some(content) = &tree.content { - for _ in opening_elements(&format!("<>{content}")) { - placed.push(Placed { - tree, - in_text: true, - shown, - parent_shown: shown, - parent: Some(index), - }); - } - } -} - -/// A merged value as written after its name: `="v"` for every width, or -/// `={[...]}` with one entry per slot, `null` keeping the narrower one's. -fn slot_values(written: &str) -> Option>> { - if let Some(value) = written - .strip_prefix("=\"") - .and_then(|rest| rest.strip_suffix('"')) - { - return Some(vec![Some(value.to_owned())]); - } - let mut rest = written - .strip_prefix("={[")? - .strip_suffix("]}")? - .trim_start(); - let mut values = Vec::new(); - while !rest.is_empty() { - if let Some(after) = rest.strip_prefix("null") { - values.push(None); - rest = after; - } else { - let after = rest.strip_prefix('"')?; - let end = after.find('"')?; - values.push(Some(after[..end].to_owned())); - rest = &after[end + 1..]; - } - rest = rest.trim_start(); - rest = rest.strip_prefix(',').unwrap_or(rest).trim_start(); - } - Some(values) -} - -/// What devup-ui applies at a slot: the last value at or below it. -fn effective(values: &[Option], slot: usize) -> Option<&str> { - values - .iter() - .take(slot + 1) - .rev() - .find_map(Option::as_deref) -} - -/// A value as the element ends up with it, so that two spellings CSS draws -/// the same compare equal and nothing else does. `None` is the property's -/// CSS initial value. An element that writes nothing has what its own class -/// sets — `Center` centres, `VStack` stacks — and `initial` undoes that -/// class, except for `display`, whose initial value is `inline` whatever the -/// element is. An inherited `initial` is settled by the caller, which knows -/// the ancestors. -fn settled(prop: &str, value: Option<&str>, tag: &str) -> Option { - let value = match value { - None if prop == "display" => return Some(natural_display(tag).to_owned()), - None => implied_layout(tag) - .iter() - .find(|(name, _)| *name == prop) - .map(|(_, value)| *value), - Some("initial") if prop == "display" => return Some("inline".to_owned()), - Some("initial") if INHERITED.contains(&prop) => Some("initial"), - Some("initial") => None, - value => value, - }; - match (prop, value) { - ("flexDir", Some("row")) - | ("gap", Some("0" | "0px")) - | ( - "textDecorationSkipInk" | "textDecorationThickness" | "textUnderlineOffset", - Some("auto"), - ) - | ("textDecorationStyle", Some("solid")) => None, - (_, value) => value.map(str::to_owned), - } -} - -/// The devup-ui elements that are one `div` laid out by their props, so one -/// stands for another wherever the props say what the other does by itself. -const LAYOUT_ELEMENTS: [&str; 5] = ["Box", "Flex", "VStack", "Center", "Grid"]; - -/// The props in [`CLEARED_WHEN_DROPPED`] an element inherits when it does -/// not set them, where `initial` is not the same as leaving them unset. -const INHERITED: [&str; 2] = ["textAlign", "visibility"]; - -/// The value a generated attribute writes, as JSX reads it. -fn written(attribute: &GeneratedAttribute) -> String { - attribute - .source - .strip_prefix(attribute.name.as_str()) - .and_then(|rest| rest.strip_prefix("=\"")) - .and_then(|rest| rest.strip_suffix('"')) - .map_or_else( - || attribute.source.clone(), - |value| { - value - .replace(""", "\"") - .replace("<", "<") - .replace(">", ">") - .replace("&", "&") - }, - ) -} - -/// A text read at one slot: each break written for some widths becomes a -/// `
` where it is shown and nothing where it is not. -fn content_at(content: &str, slot: usize) -> String { - const OPEN: &str = ""); - } - rest = &after[end + CLOSE.len()..]; - } - read.push_str(rest); - read -} - -fn descends(snapshot: &Snapshot, node: &str, ancestor: &str) -> bool { - let mut current = Some(node); - for _ in 0..1024 { - let Some(id) = current else { - return false; - }; - if id == ancestor { - return true; - } - current = snapshot - .nodes - .get(id) - .and_then(|node| node.typed_view().string("parentId")); - } - false -} - -/// The `Box` a component reference is wrapped in to carry where it sits. -fn wraps_a_reference(tree: &Tree) -> bool { - !tree.is_component - && tree.children.len() == 1 - && tree.children[0].is_component - && tree.children[0].node_id == tree.node_id -} - -/// The merged module against each width's own TSX. -/// -/// Each width is converted on its own, instances spelled out as in `tsx`, -/// and its elements are taken in order, by the node they were drawn from. -/// The module is then read at that width's slot — every array resolved the -/// way devup-ui resolves it, elements hidden there skipped — and the two -/// must agree: the same elements, each with the same element name and the -/// same settled values, every value one the width's own contract verifies, -/// the same text, and no element of the width left over. A component -/// reference stands for the instance's whole subtree. Its name, variant and -/// placement are compared, but what it draws belongs to a file this module -/// does not contain, so a module that shows one is not verified. An element -/// no collected width draws is read at none of them, so its values are not -/// verified. -/// -/// This reads the code; it does not render it. It is what makes the merged -/// attributes as verified as the per-width ones, and no more. -fn verify_merge( - snapshot: &Snapshot, - options: &CodegenOptions, - roots: &BySlot, - tree: &Tree, - tsx: &str, -) -> (Vec, Vec) { - let attributes = generated_attributes(tsx); - let tags = element_tags(tsx); - let mut placed = Vec::new(); - place(tree, None, [true; SLOTS], &mut placed); - let mut ok = vec![true; attributes.len()]; - let mut gaps = Vec::new(); - if placed.len() == tags.len() { - let mut by_element = vec![Vec::new(); placed.len()]; - for (index, attribute) in attributes.iter().enumerate() { - by_element[attribute.element].push(index); - } - let reference = CodegenOptions { - inline_instances: true, - ..options.clone() - }; - for (slot, root) in roots.iter().enumerate() { - let Some(root) = root else { - continue; - }; - match generate_component(snapshot, root, &reference) { - Ok(output) => { - let check = WidthCheck { - snapshot, - slot, - placed: &placed, - tags: &tags, - attributes: &attributes, - by_element: &by_element, - }; - check.run(&owned_elements(&output), &mut ok, &mut gaps); - } - Err(error) => { - ok.fill(false); - gaps.push(json!({"reason":"width-not-converted","slot":slot, - "widthNodeId":root,"detail":error.message})); - } - } - } - for (index, element) in placed.iter().enumerate() { - if !(0..SLOTS).any(|slot| roots[slot].is_some() && element.shown[slot]) { - for attribute in &by_element[index] { - if attributes[*attribute].name != "display" { - ok[*attribute] = false; - } - } - gaps.push(json!({"reason":"drawn-at-no-width","element":index, - "nodeId":element.tree.node_id,"tag":tags[index]})); - } - } - } else { - ok.fill(false); - gaps.push( - json!({"reason":"elements-not-aligned","placed":placed.len(),"written":tags.len()}), - ); - } - let aligned = placed.len() == tags.len(); - let contract = attributes - .iter() - .zip(&ok) - .map(|(attribute, verified)| { - json!({"elementIndex":attribute.element,"tag":attribute.tag, - "nodeId":aligned.then(|| placed[attribute.element].tree.node_id.as_str()), - "generatedProperty":attribute.source,"verified":verified}) - }) - .collect(); - (contract, gaps) -} - -struct WidthCheck<'a> { - snapshot: &'a Snapshot, - slot: usize, - placed: &'a [Placed<'a>], - tags: &'a [String], - attributes: &'a [GeneratedAttribute], - by_element: &'a [Vec], -} - -impl WidthCheck<'_> { - fn run(&self, width: &[OwnedElement], ok: &mut [bool], gaps: &mut Vec) { - let slot = self.slot; - let mut queue: BTreeMap<&str, VecDeque> = BTreeMap::new(); - for (index, element) in width.iter().enumerate() { - if let Some(node) = element.node_id.as_deref() { - queue.entry(node).or_default().push_back(index); - } - } - let mut matched = vec![false; width.len()]; - // The width's elements are taken in the module's order, so each one - // taken must come after the last: a node drawn in another place, or - // siblings the widths order differently, is not the same code. - let mut last: Option = None; - let mut text_differs = BTreeSet::new(); - let fail = |indexes: &[usize], ok: &mut [bool], gap: Value, gaps: &mut Vec| { - for index in indexes { - ok[*index] = false; - } - gaps.push(gap); - }; - for (index, element) in self.placed.iter().enumerate() { - let own = &self.by_element[index]; - let tag = self.tags[index].as_str(); - let Some(Some((node, text))) = element.tree.slots.get(slot) else { - if element.shown[slot] { - fail( - own, - ok, - json!({"reason":"no-source-at-slot","slot":slot, - "element":index,"tag":tag}), - gaps, - ); - } - continue; - }; - let gap = |reason: &str| json!({"reason":reason,"slot":slot,"widthNodeId":node,"element":index,"tag":tag}); - if !element.shown[slot] { - if element.parent_shown[slot] - && !element.in_text - && queue - .get(node.as_str()) - .is_some_and(|waiting| waiting.iter().any(|w| !matched[*w])) - { - let display = own - .iter() - .copied() - .filter(|a| self.attributes[*a].name == "display") - .collect::>(); - fail(&display, ok, gap("hidden-but-drawn-at-width"), gaps); - } - continue; - } - let waiting = queue.entry(node.as_str()).or_default(); - if element.in_text { - if tag == "Box" { - if text_differs.contains(&(element.tree as *const Tree)) { - for a in own { - ok[*a] = false; - } - } - let display = own - .iter() - .map(|a| &self.attributes[*a]) - .find(|attribute| attribute.name == "display") - .and_then(|attribute| slot_values(&attribute.source["display".len()..])); - if display - .as_deref() - .and_then(|values| effective(values, slot)) - != Some("none") - { - match waiting.pop_front() { - Some(w) - if width[w].tag == "br" - && width[w].attributes.is_empty() - && last.is_none_or(|last| w > last) => - { - matched[w] = true; - last = Some(w); - } - other => { - if let Some(w) = other { - matched[w] = true; - } - fail(own, ok, gap("break-differs-at-width"), gaps); - } - } - } - continue; - } - let Some(w) = waiting.pop_front() else { - fail(own, ok, gap("element-only-in-merge"), gaps); - continue; - }; - matched[w] = true; - if !last.is_none_or(|last| w > last) { - fail(own, ok, gap("order-differs-at-width"), gaps); - } - last = Some(w); - let mine = own - .iter() - .map(|a| self.attributes[*a].source.as_str()) - .collect::>(); - let theirs = width[w] - .attributes - .iter() - .map(|(attribute, _)| attribute.source.as_str()) - .collect::>(); - if width[w].tag != tag - || mine != theirs - || width[w].attributes.iter().any(|(_, verified)| !verified) - { - fail(own, ok, gap("text-element-differs-at-width"), gaps); - } - continue; - } - if element.tree.is_component { - let drawn = self.reference(element, node, own, width, &mut matched, ok, gaps); - if let (Some(first), Some(end)) = (drawn.first(), drawn.last()) { - if !last.is_none_or(|last| *first > last) { - fail(own, ok, gap("order-differs-at-width"), gaps); - } - last = Some(*end); - } - continue; - } - if wraps_a_reference(element.tree) { - let Some(&w) = waiting.front() else { - fail(own, ok, gap("element-only-in-merge"), gaps); - continue; - }; - self.compare(own, &width[w], tag, true, node, index, ok, gaps); - continue; - } - let Some(w) = waiting.pop_front() else { - fail(own, ok, gap("element-only-in-merge"), gaps); - continue; - }; - matched[w] = true; - if !last.is_none_or(|last| w > last) { - fail(own, ok, gap("order-differs-at-width"), gaps); - } - last = Some(w); - if width[w].tag != tag - && !(LAYOUT_ELEMENTS.contains(&tag) - && LAYOUT_ELEMENTS.contains(&width[w].tag.as_str())) - { - let mut gap = gap("element-differs-at-width"); - gap["widthTag"] = json!(width[w].tag); - fail(own, ok, gap, gaps); - continue; - } - self.compare(own, &width[w], tag, false, node, index, ok, gaps); - if let Some(content) = &element.tree.content - && content_at(content, slot) != text.as_deref().unwrap_or_default() - { - text_differs.insert(element.tree as *const Tree); - gaps.push(gap("text-differs-at-width")); - } - } - for (index, element) in width.iter().enumerate() { - if !matched[index] { - gaps.push(json!({"reason":"element-only-at-width","slot":slot, - "widthNodeId":element.node_id,"widthTag":element.tag})); - } - } - } - - /// One element's values at this slot against the width's element for - /// the same node. A wrapper carries only where its component sits. - #[allow(clippy::too_many_arguments)] - fn compare( - &self, - own: &[usize], - theirs: &OwnedElement, - tag: &str, - placement_only: bool, - node: &str, - element: usize, - ok: &mut [bool], - gaps: &mut Vec, - ) { - let slot = self.slot; - let their_tag = theirs.tag.as_str(); - // A reference's wrapper carries where an absolute instance sits, and - // nothing of how the component lays itself out. - let absolute = - self.snapshot.nodes.get(node).is_some_and(|node| { - node.typed_view().string("layoutPositioning") == Some("ABSOLUTE") - }); - let placement = |name: &str, value: &str| { - absolute && (POSITION_PROPS.contains(&name) || (name == "w" && value == "100%")) - }; - let theirs = theirs - .attributes - .iter() - .map(|(attribute, verified)| (attribute.name.as_str(), (written(attribute), *verified))) - .filter(|(name, (value, _))| !placement_only || placement(name, value.as_str())) - .collect::>(); - let mut names = BTreeSet::new(); - for index in own { - let attribute = &self.attributes[*index]; - let name = attribute.name.as_str(); - names.insert(name); - if placement_only && name == "display" { - continue; - } - let here = slot_values(&attribute.source[name.len()..]).map(|values| { - let value = effective(&values, slot); - let unset = value == Some("initial") - && INHERITED.contains(&name) - && !self.inherited(element, name); - settled(name, if unset { None } else { value }, tag) - }); - let (there, verified) = theirs.get(name).map_or((None, true), |(value, verified)| { - (Some(value.as_str()), *verified) - }); - let there = settled(name, there, their_tag); - if here.as_ref() != Some(&there) || !verified { - ok[*index] = false; - gaps.push(json!({"reason":if verified {"value-differs-at-width"} else {"unverified-at-width"}, - "slot":slot,"widthNodeId":node,"element":element,"tag":tag,"property":name, - "merged":here,"width":there})); - } - } - for (name, (value, _)) in &theirs { - if !names.contains(name) - && settled(name, Some(value.as_str()), their_tag) != settled(name, None, tag) - { - gaps.push( - json!({"reason":"attribute-only-at-width","slot":slot,"widthNodeId":node, - "element":element,"tag":tag,"property":name,"width":value}), - ); - } - } - // Two layout elements differ only in what each does unwritten. - if !placement_only && their_tag != tag { - for name in implied_layout(tag) - .iter() - .chain(implied_layout(their_tag)) - .map(|(name, _)| *name) - .chain(["display"]) - { - if !names.contains(name) - && !theirs.contains_key(name) - && settled(name, None, tag) != settled(name, None, their_tag) - { - gaps.push(json!({"reason":"element-differs-at-width","slot":slot, - "widthNodeId":node,"element":element,"tag":tag,"widthTag":their_tag, - "property":name})); - } - } - } - } - - /// Whether an element this one sits in sets `name` at this slot, so that - /// leaving it unset inherits that rather than the initial value. - fn inherited(&self, element: usize, name: &str) -> bool { - let mut current = self.placed[element].parent; - while let Some(index) = current { - if self.by_element[index].iter().any(|a| { - let attribute = &self.attributes[*a]; - attribute.name == name - && slot_values(&attribute.source[name.len()..]) - .as_deref() - .and_then(|values| effective(values, self.slot)) - .is_some_and(|value| value != "initial") - }) { - return true; - } - current = self.placed[index].parent; - } - false - } - - /// A component reference: the component and the variant this width's - /// instance picked. Everything the instance draws is the component's, - /// so the width's elements for it are accounted for here; they are - /// returned in order. - #[allow(clippy::too_many_arguments)] - fn reference( - &self, - element: &Placed<'_>, - node: &str, - own: &[usize], - width: &[OwnedElement], - matched: &mut [bool], - ok: &mut [bool], - gaps: &mut Vec, - ) -> Vec { - let slot = self.slot; - let Some(instance) = self.snapshot.nodes.get(node) else { - for index in own { - ok[*index] = false; - } - gaps.push(json!({"reason":"no-source-at-slot","slot":slot,"widthNodeId":node})); - return Vec::new(); - }; - let view = instance.typed_view(); - let drawn = width - .iter() - .enumerate() - .filter(|(_, element)| { - element - .node_id - .as_deref() - .is_some_and(|owner| descends(self.snapshot, owner, node)) - }) - .map(|(index, _)| index) - .collect::>(); - for index in &drawn { - matched[*index] = true; - } - let component = referenced_component_name(self.snapshot, view.name()); - if drawn.is_empty() || component != element.tree.component { - for index in own { - ok[*index] = false; - } - gaps.push(json!({"reason":if drawn.is_empty() {"element-only-in-merge"} else {"component-differs-at-width"}, - "slot":slot,"widthNodeId":node,"tag":element.tree.component,"widthComponent":component})); - return drawn; - } - let expected = instance_variant_props(&view); - let mut names = BTreeSet::new(); - for index in own { - let attribute = &self.attributes[*index]; - names.insert(attribute.name.as_str()); - let here = slot_values(&attribute.source[attribute.name.len()..]) - .and_then(|values| effective(&values, slot).map(str::to_owned)); - if here.as_deref() != expected.get(&attribute.name).map(String::as_str) { - ok[*index] = false; - gaps.push( - json!({"reason":"variant-differs-at-width","slot":slot,"widthNodeId":node, - "property":attribute.name,"merged":here,"width":expected.get(&attribute.name)}), - ); - } - } - for (name, value) in &expected { - if !names.contains(name.as_str()) { - gaps.push( - json!({"reason":"variant-only-at-width","slot":slot,"widthNodeId":node, - "property":name,"width":value}), - ); - } - } - // The reference draws whatever its component's own file draws, which - // this module does not contain, and an instance can override its - // component's text, size or paint where the reference cannot say so. - // Matching the name and variant is not the same drawing. - gaps.push( - json!({"reason":"component-body-unverified","slot":slot,"widthNodeId":node, - "component":component,"drawnElements":drawn.len()}), - ); - drawn - } -} +// Source sections share this module to preserve privacy and test names. +include!("responsive/breakpoint_merge.rs"); +include!("responsive/merge_content.rs"); +include!("responsive/impl.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/breakpoint_merge.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/breakpoint_merge.rs new file mode 100644 index 00000000..555f0703 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/breakpoint_merge.rs @@ -0,0 +1,986 @@ + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +use devup_mcp_figma::{DevupError, RawNode, Snapshot}; +use serde_json::{Value, json}; + +use super::{ + component::{CodegenOptions, generate_component}, + variant::{ + POSITION_PROPS, Tree, instance_variant_props, project_tree_keeping_instances, + referenced_component_name, structure_signature, + }, +}; +use crate::provenance::attributes::{ + GeneratedAttribute, OwnedElement, element_tags, generated_attributes, opening_elements, + owned_elements, +}; + +/// 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"]; + +/// How many slots a devup-ui responsive array has: `[mobile, sm, tablet, lg, +/// PC]`. +pub const SLOTS: usize = 5; + +/// The width each slot runs up to, from `devup-figma-plugin`'s `BREAKPOINTS`. +/// The last slot has no bound. +const SLOT_BOUNDS: [u32; SLOTS - 1] = [480, 768, 992, 1280]; + +/// Which slot a frame of this width occupies. +/// +/// A width is placed by how wide it is, not by what its frame is called. The +/// same three names — `mobile` / `tablet` / `desktop` — land on slots 0/2/4 in +/// one screen and 0/1/4 in another, so reading the name and assuming a slot +/// puts every value of the second screen a band too wide. +pub fn slot_of_width(width: u32) -> usize { + SLOT_BOUNDS + .iter() + .position(|bound| width <= *bound) + .unwrap_or(SLOTS - 1) +} + +/// The props a disappearing value must be cleared for, from the plugin's +/// `SPECIAL_PROPS_WITH_INITIAL`, plus visibility for suppressed assets. A colour +/// that stops being set is left to inherit rather than reset. +const CLEARED_WHEN_DROPPED: [&str; 42] = [ + "display", + "visibility", + "position", + "pos", + "transform", + "w", + "h", + "textAlign", + "flexDir", + "flexWrap", + "justify", + "alignItems", + "alignContent", + "alignSelf", + "gap", + "rowGap", + "columnGap", + "flex", + "flexGrow", + "flexShrink", + "flexBasis", + "order", + "gridTemplateColumns", + "gridTemplateRows", + "gridColumn", + "gridRow", + "gridArea", + "top", + "right", + "bottom", + "left", + "zIndex", + "overflow", + "overflowX", + "overflowY", + "p", + "pt", + "pr", + "pb", + "pl", + "px", + "py", +]; + +/// The margin props, kept apart only because the array above is already at the +/// length rustfmt likes to fight over. +const CLEARED_WHEN_DROPPED_MARGINS: [&str; 7] = ["m", "mt", "mr", "mb", "ml", "mx", "my"]; + +/// A value that need not be written because it is what the prop already is, +/// from the plugin's `DEFAULT_PROPS_MAP`. Its padding and margin entries are +/// commented out upstream, so they are absent here too. +fn is_default(prop: &str, value: &str) -> bool { + match prop { + "alignItems" | "justifyContent" => value == "flex-start", + "flexDir" => value == "row", + "gap" => value == "0" || value == "0px", + "textDecorationSkipInk" + | "textDecorationThickness" + | "textDecorationColor" + | "textUnderlineOffset" => value == "auto", + "textDecorationStyle" => value == "solid", + _ => false, + } +} + +fn cleared_when_dropped(prop: &str) -> bool { + CLEARED_WHEN_DROPPED.contains(&prop) || CLEARED_WHEN_DROPPED_MARGINS.contains(&prop) +} + +/// What one width has to say about one prop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Drawn<'a> { + /// No width is drawn at this slot at all. + Absent, + /// A width is drawn here and leaves the prop unset. + Unset, + /// A width is drawn here and sets the prop. + Set(&'a str), +} + +/// One prop, after the widths have been compared. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Merged { + /// Every width that is drawn says the same thing, so no array is needed. + Same(Option), + /// The widths disagree, and these are the slots to write. + Array(Vec>), +} + +/// Lay one prop's values across the widths as a devup-ui responsive array. +/// +/// A slot is written only when it changes what is in effect, because `null` +/// does not mean "no value" — it means "whatever the slot before it said". The +/// same rule is why a prop cannot simply stop: dropping it would leave the +/// narrower width's value inherited, so a layout prop that a wider width no +/// longer sets is written `"initial"`. Without it `pl={["36.5px"]}` would +/// silently keep the mobile padding at every width. +/// +/// This follows `devup-figma-plugin`'s `mergePropsToResponsive` and +/// `optimizeResponsiveValue`, with one of its limits kept: only the props in +/// [`CLEARED_WHEN_DROPPED`] are cleared, so a `bg` that stops being set still +/// inherits. The other is not kept. The plugin places exactly one +/// `"initial"`, after the last value, so a prop set, dropped, then set again +/// wider kept the narrower value at the width that dropped it; on `about` that +/// hid the one layer only the tablet draws at every width. Here every drawn +/// width that leaves the prop unset after a narrower one set it is cleared. +/// Visibility deliberately differs: every drawn, unset width restores visible +/// paint. +pub fn merge_slots(prop: &str, widths: &[Drawn<'_>; SLOTS]) -> Merged { + // One width is a screen, not a screen that changes. The reference hands + // its props back untouched rather than wrapping each in a one-slot array. + let mut drawn = widths.iter().filter(|width| **width != Drawn::Absent); + if let Some(only) = drawn.next() + && drawn.next().is_none() + { + return Merged::Same(match only { + Drawn::Set(value) => Some((*value).to_owned()), + Drawn::Absent | Drawn::Unset => None, + }); + } + + let mut slots: Vec> = widths + .iter() + .map(|width| match width { + Drawn::Set(value) => Some((*value).to_owned()), + Drawn::Unset if prop == "visibility" => Some("initial".to_owned()), + Drawn::Absent | Drawn::Unset => None, + }) + .collect(); + + // The clear lands only on a width that is actually drawn; one on a slot no + // frame occupies would say nothing. Repeats collapse below. + if cleared_when_dropped(prop) { + let mut set_before = false; + for (slot, width) in widths.iter().enumerate() { + match width { + Drawn::Set(_) => set_before = true, + Drawn::Unset if set_before => slots[slot] = Some("initial".to_owned()), + Drawn::Unset | Drawn::Absent => {} + } + } + } + + // A slot that repeats what is already in effect says nothing. + let mut carried: Option = None; + for slot in &mut slots { + let Some(value) = slot.clone() else { + continue; + }; + if carried.as_deref() == Some(value.as_str()) { + *slot = None; + } else { + carried = Some(value); + } + } + if slots + .first() + .and_then(Option::as_deref) + .is_some_and(|value| is_default(prop, value)) + { + slots[0] = None; + } + while slots.last().is_some_and(Option::is_none) { + slots.pop(); + } + + match slots.len() { + 0 => Merged::Same(None), + 1 => Merged::Same(slots.swap_remove(0)), + _ => Merged::Array(slots), + } +} + +/// 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`]; narrowest is 0. + 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) + .or_else(|| { + // Some Sections label their middle design by a width range instead + // of "tablet". Recognize only explicit numeric ranges; a number in + // an unrelated screen name is not a breakpoint declaration. + let (start, end) = name.split_once(['~', '–'])?; + let (start, end) = ( + start.trim().parse::().ok()?, + end.trim().parse::().ok()?, + ); + (start > 0 && end >= start).then_some(1) + }) +} + +pub fn is_breakpoint_name(name: &str) -> bool { + rank_of(name).is_some() +} + +/// Explicit width suffixes can describe one named screen. No fuzzy matching. +pub fn family_name(name: &str) -> String { + let name = name.trim().to_ascii_lowercase(); + if is_breakpoint_name(&name) { + return "@breakpoint-labels".into(); + } + for suffix in ["desktop", "tablet", "mobile"] { + let trimmed = name.trim_end_matches(')'); + if let Some(base) = trimmed.strip_suffix(suffix) + && base.ends_with([' ', '-', '/', '(']) + { + return base.trim_end_matches([' ', '-', '/', '(']).to_owned(); + } + } + name +} + +/// Whether roots with these names and widths could be a breakpoint family. +/// +/// The question [`breakpoints`] answers, asked without a snapshot, so it can +/// be put to a Section index — which already carries every candidate's name +/// and width — before anything is collected. +/// +/// It has to be answerable there. The refusal lived in projection, which runs +/// after the collection it needs: asking for `responsiveTsx` over two frames +/// of one Braillify Studio screen spent 77 seconds and ten Figma reads before +/// reporting that the snapshot had no mergeable breakpoint frames — a verdict +/// the frames' own names and widths settle in full. +/// +/// Keep this in step with [`breakpoints`]. `the_predicate_agrees_with_ +/// discovery` in `tests/responsive_prevalidate.rs` holds the two together, so +/// a change to either rule that forgets the other fails there rather than in +/// a 77-second export. +pub fn breakpoint_family_is_possible(frames: &[(&str, f64)]) -> bool { + if frames.len() < 2 + || frames + .iter() + .any(|(_, w)| !w.is_finite() || *w <= 0.0 || *w > f64::from(u32::MAX)) + { + return false; + } + let mut slots = BTreeSet::new(); + for (_, width) in frames { + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated finite positive pixel width" + )] + if !slots.insert(slot_of_width(*width as u32)) { + return false; + } + } + let mut ranks: Vec = Vec::new(); + for (name, _width) in frames { + let Some(rank) = rank_of(name) else { + continue; + }; + if ranks.contains(&rank) { + continue; + } + ranks.push(rank); + } + if ranks.len() == frames.len() { + return true; + } + width_family_is_possible(frames) +} + +/// The fallback [`breakpoints_by_width`] applies, asked of names and widths +/// alone: one name across two or more distinct width bands. +/// +/// Its two refusals are kept exactly, because either one loosened here would +/// let a collection start that the projection then refuses — the cost this +/// predicate exists to avoid. +fn width_family_is_possible(frames: &[(&str, f64)]) -> bool { + let mut shared_name: Option = None; + let mut slots: Vec = Vec::new(); + for (name, width) in frames { + let name = family_name(name); + if *shared_name.get_or_insert_with(|| name.clone()) != name { + return false; + } + if *width <= 0.0 { + return false; + } + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "a frame is a small positive number of pixels wide" + )] + let slot = slot_of_width(*width as u32); + if slots.contains(&slot) { + return false; + } + slots.push(slot); + } + slots.len() >= 2 +} + +/// The breakpoint roots this snapshot carries, in the order the Section holds +/// them. +/// +/// The order is kept on purpose. Where a width does not draw a node, the +/// reference gives that width a hidden copy of the node from the *first* +/// width that does — first in the Section's own layer order, which is how the +/// plugin walks it — and every value of that copy, not only its `display`, +/// lands in the array. The about hero is drawn at tablet and desktop, and its +/// picture is `w={["770px", null, "778px", null, "770px"]}`: the mobile slot +/// says 770 because desktop comes first in that Section. Sorting the roots by +/// width put tablet's 778 there instead. +/// +/// 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 frames = snapshot + .roots + .iter() + .filter_map(|id| { + let view = snapshot.nodes.get(id)?.typed_view(); + Some((view.name()?, view.number("width")?)) + }) + .collect::>(); + if frames.len() != snapshot.roots.len() || !breakpoint_family_is_possible(&frames) { + return Vec::new(); + } + let mut found: Vec = Vec::new(); + for id in &snapshot.roots { + let Some(node) = snapshot.nodes.get(id) else { + continue; + }; + let Some(rank) = node.typed_view().name().and_then(rank_of) else { + continue; + }; + // Two frames with one name: the first keeps it, as in the plugin. + if found.iter().any(|breakpoint| breakpoint.rank == rank) { + continue; + } + found.push(Breakpoint { + rank, + node_id: id.clone(), + }); + } + if found.len() < 2 { + return breakpoints_by_width(snapshot); + } + found +} + +/// The same screen drawn at several widths, for a file that does not use the +/// plugin's breakpoint names. +/// +/// `slot_of_width` already places a width "by how wide it is, not by what its +/// frame is called", but discovery read the name alone, so a file naming its +/// frames anything else had no family at all: Braillify Studio draws `AUTH-01` +/// at 1920 and again at 375, both directly under one Section, and asking for +/// `responsiveTsx` over the two answered that there was nothing to merge. +/// +/// Two conditions keep this from folding things the design never drew, and +/// either one failing leaves the snapshot with no family rather than a +/// guessed one: +/// +/// - **One name.** `AUTH-01` and `AUTH-02` are two designs, not one design at +/// two sizes. +/// - **Distinct width bands.** The same screen appears twice at 375 — a light +/// and a dark rendering — and neither is a breakpoint of the other. A band +/// claimed twice is ambiguous, and guessing which copy wins is the failure +/// this is meant to avoid. +/// +/// The roots keep the order the snapshot carries them in, for the reason +/// [`breakpoints`] documents: the first width that draws a node supplies the +/// widths that do not, and sorting changes which one that is. +fn breakpoints_by_width(snapshot: &Snapshot) -> Vec { + let mut shared_name: Option = None; + let mut found: Vec = Vec::new(); + for id in &snapshot.roots { + let Some(node) = snapshot.nodes.get(id) else { + return Vec::new(); + }; + let view = node.typed_view(); + let (Some(name), Some(width)) = (view.name(), view.number("width")) else { + return Vec::new(); + }; + let name = family_name(name); + if *shared_name.get_or_insert_with(|| name.clone()) != name { + return Vec::new(); + } + if width <= 0.0 { + return Vec::new(); + } + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "a frame is a small positive number of pixels wide" + )] + let slot = slot_of_width(width as u32); + if found.iter().any(|breakpoint| breakpoint.rank == slot) { + return Vec::new(); + } + found.push(Breakpoint { + rank: slot, + node_id: id.clone(), + }); + } + 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.iter().max_by_key(|breakpoint| breakpoint.rank) 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(); + } +} + +// --------------------------------------------------------------------------- +// Joining the widths into one tree +// --------------------------------------------------------------------------- + +/// One node's counterparts, by the slot each is drawn at. +type BySlot = [Option; SLOTS]; + +/// Where each slot's width comes in the Section's layer order; `usize::MAX` +/// for a slot no width occupies. The lowest goes first, as the plugin's +/// `firstMapValue` does: it is the width whose values fill in for a width that +/// does not draw a node, and whose element name a merged node keeps. +type Precedence = [usize; SLOTS]; + +/// The slot that comes first in the Section's order among those present. +fn first_slot(by_slot: &BySlot, precedence: &Precedence) -> Option { + (0..SLOTS) + .filter(|slot| by_slot[*slot].is_some()) + .min_by_key(|slot| precedence[*slot]) +} + +/// A node's children, grouped under the key its counterparts will be found by. +/// +/// A child whose shape is unique among its siblings is keyed by that shape, so +/// renaming it in one width does not lose it. Where several siblings share a +/// shape the shape cannot tell them apart, so their name is used instead and +/// they are paired in order: the second `quote` with the second `quote`. Each +/// keeps a key of its own, so siblings drawn between them stay between them; +/// one key for all of them emitted `“ ” title` for `“ title ”`. Insertion +/// order is kept, because it is what the ordering below reads. +fn children_to_map(tree: &Tree) -> Vec<(String, Vec<&Tree>)> { + let mut counts: BTreeMap = BTreeMap::new(); + for child in &tree.children { + *counts.entry(structure_signature(child)).or_default() += 1; + } + let mut grouped: Vec<(String, Vec<&Tree>)> = Vec::new(); + let mut seen: BTreeMap = BTreeMap::new(); + for child in &tree.children { + let signature = structure_signature(child); + let mut key = if counts.get(&signature) == Some(&1) { + format!("sig:{signature}") + } else { + child.node_name.clone() + }; + // `src` is an HTML attribute, not a responsive CSS property. Keep + // distinct sources as distinct children; the existing missing-child + // merge supplies their visibility slots without array-valued URLs. + if child.component == "Image" + && let Some(source) = child.props.get("src") + { + key = serde_json::to_string(&( + key, + source, + child.props.get("srcSet"), + child.props.get("sizes"), + child.props.get("width"), + child.props.get("height"), + )) + .expect("image identity is serializable"); + } + let occurrence = seen.entry(key.clone()).or_insert(0usize); + grouped.push((format!("{key}#{occurrence}"), vec![child])); + *occurrence += 1; + } + grouped +} + +/// One order for children that every width agrees with. +/// +/// Each width gives an order over the children it has, and no width need have +/// them all. Taking any single width's order would drop the others' children; +/// concatenating would scramble them. So the orders are read as edges of a +/// graph and sorted topologically, with a child's average position across the +/// widths breaking ties. This is `mergeChildNameOrder`. +fn merge_child_order(per_width: &[Vec]) -> Vec { + let mut names: Vec = Vec::new(); + for order in per_width { + for name in order { + if !names.contains(name) { + names.push(name.clone()); + } + } + } + if names.len() < 2 { + return names; + } + + let mut edges: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + let mut incoming: BTreeMap<&str, usize> = names.iter().map(|name| (name.as_str(), 0)).collect(); + for order in per_width { + for pair in order.windows(2) { + if edges + .entry(pair[0].as_str()) + .or_default() + .insert(pair[1].as_str()) + { + *incoming.entry(pair[1].as_str()).or_default() += 1; + } + } + } + + let position = |name: &str| { + let mut total = 0.0; + let mut seen = 0.0; + for order in per_width { + if let Some(index) = order.iter().position(|other| other == name) { + total += if order.len() > 1 { + index as f64 / (order.len() - 1) as f64 + } else { + 0.5 + }; + seen += 1.0; + } + } + if seen > 0.0 { total / seen } else { 0.5 } + }; + + let mut ready = names + .iter() + .filter(|name| incoming.get(name.as_str()) == Some(&0)) + .map(String::as_str) + .collect::>(); + let mut sorted: Vec = Vec::new(); + while !ready.is_empty() { + ready.sort_by(|left, right| { + position(left) + .partial_cmp(&position(right)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let next = ready.remove(0); + sorted.push(next.to_owned()); + for neighbour in edges.get(next).cloned().unwrap_or_default() { + let degree = incoming.entry(neighbour).or_insert(1); + *degree = degree.saturating_sub(1); + if *degree == 0 { + ready.push(neighbour); + } + } + } + // A cycle means the widths order two children oppositely. Nothing is + // dropped on that account; the rest keep the order they were found in. + for name in &names { + if !sorted.contains(name) { + sorted.push(name.clone()); + } + } + sorted +} + +/// Render one merged prop as the attribute text a JSX element carries. +/// +/// An array is written one slot to a line, as the plugin's `JSON.stringify` +/// with an indent of two does; `render_merged` indents the continuation +/// lines to the attribute. That is also what makes the element itself +/// multi-line, whatever its prop count. +fn render_attribute(merged: &Merged) -> Option { + match merged { + Merged::Same(None) => None, + Merged::Same(Some(value)) => Some(format!("=\"{value}\"")), + Merged::Array(slots) => { + let written = slots + .iter() + .map(|slot| { + slot.as_ref() + .map_or_else(|| " null".to_owned(), |value| format!(" \"{value}\"")) + }) + .collect::>() + .join(",\n"); + Some(format!("={{[\n{written}\n]}}")) + } + } +} + +/// What a layout element does by itself besides its `display`. +fn implied_layout(component: &str) -> &'static [(&'static str, &'static str)] { + match component { + "VStack" => &[("flexDir", "column")], + "Center" => &[("alignItems", "center"), ("justifyContent", "center")], + _ => &[], + } +} + +/// The `display` a devup-ui element already carries by being itself. +fn implied_display(component: &str) -> Option<&'static str> { + match component { + "Flex" | "VStack" | "Center" => Some("flex"), + "Grid" => Some("grid"), + _ => None, + } +} + +/// What `display` an element goes back to when a wider width stops hiding it. +/// +/// `merge_slots` clears a dropped layout prop to `"initial"`, which is right +/// for the other forty and wrong for this one. `initial` is the value the CSS +/// specification gives a property, not the value the element has: for `w` that +/// is `auto` and for `p` it is `0`, which are what "unset" should mean, but for +/// `display` it is `inline` whatever the element is. An element's own display +/// comes from the user-agent stylesheet instead, so a `Box` shown only from +/// tablet up would come back inline rather than block. +/// +/// `revert` is the keyword that would mean what is wanted, and it cannot be +/// used either: it rolls the cascade back past the author origin, which is +/// where devup-ui puts `VStack`'s own `display: flex`. So the value is written +/// out, and `display` is never cleared to `initial`. +fn natural_display(component: &str) -> &'static str { + match component { + "Flex" | "VStack" | "Center" => "flex", + "Grid" => "grid", + // `Image` is an `img`, which is inline. Everything else devup-ui draws + // is a `div`, and `Text` is a `p`. + "Image" => "inline", + _ => "block", + } +} + +/// Put back the `display` the element name stands for, before any merging. +/// +/// The plugin keeps `display` in a node's props and picks the element from it; +/// this projection picks the element first and drops the value. That is the +/// same thing for one width, but not across several: a width where the node is +/// hidden would leave the others with nothing to go back to, and `merge_slots` +/// would clear them to `"initial"` — which for `display` is `inline`, not +/// `flex`. The node would stop being a flex container at every width it is +/// shown. Rendering drops the value again where the element implies it, so +/// nothing is written that was not written before. +fn restore_implied_display(tree: &mut Tree) { + if let Some(display) = implied_display(&tree.component) { + tree.props + .entry("display".to_owned()) + .or_insert_with(|| display.to_owned()); + } + for child in &mut tree.children { + restore_implied_display(child); + } +} + +/// Fold one node's counterparts into a single node whose differing values have +/// become arrays. +fn merge_trees( + by_slot: &BySlot, + precedence: &Precedence, + notes: &mut Vec, +) -> Option { + // The merged element is the first width's. Where another width draws it as + // a different layout element, what each element does by itself has to be + // written out, or the first one's would apply at every width: a `VStack` + // root stacking a row the desktop width draws as a `Flex`. + let restated: BySlot; + let components = by_slot + .iter() + .flatten() + .map(|tree| tree.component.as_str()) + .collect::>(); + let by_slot = if components.len() > 1 + && components.iter().all(|name| LAYOUT_ELEMENTS.contains(name)) + && by_slot.iter().flatten().all(|tree| !tree.is_component) + { + restated = std::array::from_fn(|slot| { + by_slot[slot].clone().map(|mut tree| { + for (name, value) in implied_layout(&tree.component) { + tree.props + .entry((*name).to_owned()) + .or_insert_with(|| (*value).to_owned()); + } + tree.props + .entry("display".to_owned()) + .or_insert_with(|| natural_display(&tree.component).to_owned()); + tree + }) + }); + &restated + } else { + by_slot + }; + let first = by_slot[first_slot(by_slot, precedence)?].clone()?; + + let mut keys = BTreeSet::new(); + for tree in by_slot.iter().flatten() { + keys.extend(tree.props.keys().cloned()); + } + let mut props = BTreeMap::new(); + for key in keys { + // A component's own props are not style props. devup-ui reads an array + // only where it applies CSS, so `property1={[...]}` on `
` is + // read as the literal array and the component sees nonsense. The widest + // width drawn wins and the rest are reported, because a screen that + // really does want a different variant per width is asking for + // something the target cannot express. + if first.is_component && !is_placement_prop(&key) { + let mut seen = Vec::new(); + for tree in by_slot.iter().flatten() { + if let Some(value) = tree.props.get(&key) { + seen.push(value.clone()); + } + } + if let Some(widest) = seen.last().cloned() { + if seen.iter().any(|value| *value != widest) { + notes.push(Unrepresented { + node_id: first.node_id.clone(), + detail: format!( + "{} takes {key} {} at different widths; a component prop cannot be responsive, so {widest} is used.", + first.component, + seen.join(" / ") + ), + }); + } + props.insert(key, format!("=\"{widest}\"")); + } + continue; + } + let widths: [Drawn<'_>; SLOTS] = std::array::from_fn(|slot| match &by_slot[slot] { + None => Drawn::Absent, + Some(tree) => tree + .props + .get(&key) + .map_or(Drawn::Unset, |value| Drawn::Set(value)), + }); + let mut merged = merge_slots(&key, &widths); + if key == "display" + && let Merged::Array(slots) = &mut merged + { + // A component reference cannot carry `display`; it lands on the + // `Box` that will be wrapped around it below. + let natural = if first.is_component { + "block" + } else { + natural_display(&first.component) + }; + for slot in &mut *slots { + if slot.as_deref() == Some("initial") { + *slot = Some(natural.to_owned()); + } + } + } + // Every width agreeing on the `display` the element already has is the + // state this started in, so it goes back to being unwritten. + if key == "display" + && matches!(&merged, Merged::Same(Some(value)) + if implied_display(&first.component) == Some(value.as_str())) + { + continue; + } + if matches!(&merged, Merged::Same(Some(value)) + if implied_layout(&first.component).contains(&(key.as_str(), value.as_str()))) + { + continue; + } + if let Some(attribute) = render_attribute(&merged) { + props.insert(key, attribute); + } + } + + let mut source_node_ids = BTreeSet::new(); + for tree in by_slot.iter().flatten() { + source_node_ids.extend(tree.source_node_ids.iter().cloned()); + } + let slots = by_slot + .iter() + .map(|tree| { + tree.as_ref() + .map(|tree| (tree.node_id.clone(), tree.content.clone())) + }) + .collect::>(); + + let content = merge_content(by_slot, &first, notes); + + // A component reference has nothing below it to line up: each width picks + // its own variant, and the component answers for its own widths. + if first.is_component { + // Where it sits belongs to a wrapper; what variant it is belongs to the + // reference. Only the first is merged — passing an array to a component + // prop does nothing, so a variant that differs by width cannot be + // expressed and the widest one drawn is kept. + let (placement, variants): (BTreeMap<_, _>, BTreeMap<_, _>) = props + .into_iter() + .partition(|(name, _)| is_placement_prop(name)); + let reference = Tree { + props: variants, + children: Vec::new(), + source_node_ids: source_node_ids.clone(), + slots: slots.clone(), + ..first.clone() + }; + if placement.is_empty() { + return Some(reference); + } + return Some(Tree { + component: "Box".to_owned(), + props: placement, + children: vec![reference], + source_node_ids, + is_component: false, + content: None, + slots, + ..first + }); + } + + Some(Tree { + props, + children: merge_children(by_slot, precedence, notes), + source_node_ids, + content, + slots, + ..first + }) +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/impl.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/impl.rs new file mode 100644 index 00000000..e30110e8 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/impl.rs @@ -0,0 +1,358 @@ + + +impl WidthCheck<'_> { + fn run(&self, width: &[OwnedElement], ok: &mut [bool], gaps: &mut Vec) { + let slot = self.slot; + let mut queue: BTreeMap<&str, VecDeque> = BTreeMap::new(); + for (index, element) in width.iter().enumerate() { + if let Some(node) = element.node_id.as_deref() { + queue.entry(node).or_default().push_back(index); + } + } + let mut matched = vec![false; width.len()]; + // The width's elements are taken in the module's order, so each one + // taken must come after the last: a node drawn in another place, or + // siblings the widths order differently, is not the same code. + let mut last: Option = None; + let mut text_differs = BTreeSet::new(); + let fail = |indexes: &[usize], ok: &mut [bool], gap: Value, gaps: &mut Vec| { + for index in indexes { + ok[*index] = false; + } + gaps.push(gap); + }; + for (index, element) in self.placed.iter().enumerate() { + let own = &self.by_element[index]; + let tag = self.tags[index].as_str(); + let Some(Some((node, text))) = element.tree.slots.get(slot) else { + if element.shown[slot] { + fail( + own, + ok, + json!({"reason":"no-source-at-slot","slot":slot, + "element":index,"tag":tag}), + gaps, + ); + } + continue; + }; + let gap = |reason: &str| json!({"reason":reason,"slot":slot,"widthNodeId":node,"element":index,"tag":tag}); + if !element.shown[slot] { + if element.parent_shown[slot] + && !element.in_text + && queue + .get(node.as_str()) + .is_some_and(|waiting| waiting.iter().any(|w| !matched[*w])) + { + let display = own + .iter() + .copied() + .filter(|a| self.attributes[*a].name == "display") + .collect::>(); + fail(&display, ok, gap("hidden-but-drawn-at-width"), gaps); + } + continue; + } + let waiting = queue.entry(node.as_str()).or_default(); + if element.in_text { + if tag == "Box" { + if text_differs.contains(&(element.tree as *const Tree)) { + for a in own { + ok[*a] = false; + } + } + let display = own + .iter() + .map(|a| &self.attributes[*a]) + .find(|attribute| attribute.name == "display") + .and_then(|attribute| slot_values(&attribute.source["display".len()..])); + if display + .as_deref() + .and_then(|values| effective(values, slot)) + != Some("none") + { + match waiting.pop_front() { + Some(w) + if width[w].tag == "br" + && width[w].attributes.is_empty() + && last.is_none_or(|last| w > last) => + { + matched[w] = true; + last = Some(w); + } + other => { + if let Some(w) = other { + matched[w] = true; + } + fail(own, ok, gap("break-differs-at-width"), gaps); + } + } + } + continue; + } + let Some(w) = waiting.pop_front() else { + fail(own, ok, gap("element-only-in-merge"), gaps); + continue; + }; + matched[w] = true; + if !last.is_none_or(|last| w > last) { + fail(own, ok, gap("order-differs-at-width"), gaps); + } + last = Some(w); + let mine = own + .iter() + .map(|a| self.attributes[*a].source.as_str()) + .collect::>(); + let theirs = width[w] + .attributes + .iter() + .map(|(attribute, _)| attribute.source.as_str()) + .collect::>(); + if width[w].tag != tag + || mine != theirs + || width[w].attributes.iter().any(|(_, verified)| !verified) + { + fail(own, ok, gap("text-element-differs-at-width"), gaps); + } + continue; + } + if element.tree.is_component { + let drawn = self.reference(element, node, own, width, &mut matched, ok, gaps); + if let (Some(first), Some(end)) = (drawn.first(), drawn.last()) { + if !last.is_none_or(|last| *first > last) { + fail(own, ok, gap("order-differs-at-width"), gaps); + } + last = Some(*end); + } + continue; + } + if wraps_a_reference(element.tree) { + let Some(&w) = waiting.front() else { + fail(own, ok, gap("element-only-in-merge"), gaps); + continue; + }; + self.compare(own, &width[w], tag, true, node, index, ok, gaps); + continue; + } + let Some(w) = waiting.pop_front() else { + fail(own, ok, gap("element-only-in-merge"), gaps); + continue; + }; + matched[w] = true; + if !last.is_none_or(|last| w > last) { + fail(own, ok, gap("order-differs-at-width"), gaps); + } + last = Some(w); + if width[w].tag != tag + && !(LAYOUT_ELEMENTS.contains(&tag) + && LAYOUT_ELEMENTS.contains(&width[w].tag.as_str())) + { + let mut gap = gap("element-differs-at-width"); + gap["widthTag"] = json!(width[w].tag); + fail(own, ok, gap, gaps); + continue; + } + self.compare(own, &width[w], tag, false, node, index, ok, gaps); + if let Some(content) = &element.tree.content + && content_at(content, slot) != text.as_deref().unwrap_or_default() + { + text_differs.insert(element.tree as *const Tree); + gaps.push(gap("text-differs-at-width")); + } + } + for (index, element) in width.iter().enumerate() { + if !matched[index] { + gaps.push(json!({"reason":"element-only-at-width","slot":slot, + "widthNodeId":element.node_id,"widthTag":element.tag})); + } + } + } + + /// One element's values at this slot against the width's element for + /// the same node. A wrapper carries only where its component sits. + #[allow(clippy::too_many_arguments)] + fn compare( + &self, + own: &[usize], + theirs: &OwnedElement, + tag: &str, + placement_only: bool, + node: &str, + element: usize, + ok: &mut [bool], + gaps: &mut Vec, + ) { + let slot = self.slot; + let their_tag = theirs.tag.as_str(); + // A reference's wrapper carries where an absolute instance sits, and + // nothing of how the component lays itself out. + let absolute = + self.snapshot.nodes.get(node).is_some_and(|node| { + node.typed_view().string("layoutPositioning") == Some("ABSOLUTE") + }); + let placement = |name: &str, value: &str| { + absolute && (POSITION_PROPS.contains(&name) || (name == "w" && value == "100%")) + }; + let theirs = theirs + .attributes + .iter() + .map(|(attribute, verified)| (attribute.name.as_str(), (written(attribute), *verified))) + .filter(|(name, (value, _))| !placement_only || placement(name, value.as_str())) + .collect::>(); + let mut names = BTreeSet::new(); + for index in own { + let attribute = &self.attributes[*index]; + let name = attribute.name.as_str(); + names.insert(name); + if placement_only && name == "display" { + continue; + } + let here = slot_values(&attribute.source[name.len()..]).map(|values| { + let value = effective(&values, slot); + let unset = value == Some("initial") + && INHERITED.contains(&name) + && !self.inherited(element, name); + settled(name, if unset { None } else { value }, tag) + }); + let (there, verified) = theirs.get(name).map_or((None, true), |(value, verified)| { + (Some(value.as_str()), *verified) + }); + let there = settled(name, there, their_tag); + if here.as_ref() != Some(&there) || !verified { + ok[*index] = false; + gaps.push(json!({"reason":if verified {"value-differs-at-width"} else {"unverified-at-width"}, + "slot":slot,"widthNodeId":node,"element":element,"tag":tag,"property":name, + "merged":here,"width":there})); + } + } + for (name, (value, _)) in &theirs { + if !names.contains(name) + && settled(name, Some(value.as_str()), their_tag) != settled(name, None, tag) + { + gaps.push( + json!({"reason":"attribute-only-at-width","slot":slot,"widthNodeId":node, + "element":element,"tag":tag,"property":name,"width":value}), + ); + } + } + // Two layout elements differ only in what each does unwritten. + if !placement_only && their_tag != tag { + for name in implied_layout(tag) + .iter() + .chain(implied_layout(their_tag)) + .map(|(name, _)| *name) + .chain(["display"]) + { + if !names.contains(name) + && !theirs.contains_key(name) + && settled(name, None, tag) != settled(name, None, their_tag) + { + gaps.push(json!({"reason":"element-differs-at-width","slot":slot, + "widthNodeId":node,"element":element,"tag":tag,"widthTag":their_tag, + "property":name})); + } + } + } + } + + /// Whether an element this one sits in sets `name` at this slot, so that + /// leaving it unset inherits that rather than the initial value. + fn inherited(&self, element: usize, name: &str) -> bool { + let mut current = self.placed[element].parent; + while let Some(index) = current { + if self.by_element[index].iter().any(|a| { + let attribute = &self.attributes[*a]; + attribute.name == name + && slot_values(&attribute.source[name.len()..]) + .as_deref() + .and_then(|values| effective(values, self.slot)) + .is_some_and(|value| value != "initial") + }) { + return true; + } + current = self.placed[index].parent; + } + false + } + + /// A component reference: the component and the variant this width's + /// instance picked. Everything the instance draws is the component's, + /// so the width's elements for it are accounted for here; they are + /// returned in order. + #[allow(clippy::too_many_arguments)] + fn reference( + &self, + element: &Placed<'_>, + node: &str, + own: &[usize], + width: &[OwnedElement], + matched: &mut [bool], + ok: &mut [bool], + gaps: &mut Vec, + ) -> Vec { + let slot = self.slot; + let Some(instance) = self.snapshot.nodes.get(node) else { + for index in own { + ok[*index] = false; + } + gaps.push(json!({"reason":"no-source-at-slot","slot":slot,"widthNodeId":node})); + return Vec::new(); + }; + let view = instance.typed_view(); + let drawn = width + .iter() + .enumerate() + .filter(|(_, element)| { + element + .node_id + .as_deref() + .is_some_and(|owner| descends(self.snapshot, owner, node)) + }) + .map(|(index, _)| index) + .collect::>(); + for index in &drawn { + matched[*index] = true; + } + let component = referenced_component_name(self.snapshot, view.name()); + if drawn.is_empty() || component != element.tree.component { + for index in own { + ok[*index] = false; + } + gaps.push(json!({"reason":if drawn.is_empty() {"element-only-in-merge"} else {"component-differs-at-width"}, + "slot":slot,"widthNodeId":node,"tag":element.tree.component,"widthComponent":component})); + return drawn; + } + let expected = instance_variant_props(&view); + let mut names = BTreeSet::new(); + for index in own { + let attribute = &self.attributes[*index]; + names.insert(attribute.name.as_str()); + let here = slot_values(&attribute.source[attribute.name.len()..]) + .and_then(|values| effective(&values, slot).map(str::to_owned)); + if here.as_deref() != expected.get(&attribute.name).map(String::as_str) { + ok[*index] = false; + gaps.push( + json!({"reason":"variant-differs-at-width","slot":slot,"widthNodeId":node, + "property":attribute.name,"merged":here,"width":expected.get(&attribute.name)}), + ); + } + } + for (name, value) in &expected { + if !names.contains(name.as_str()) { + gaps.push( + json!({"reason":"variant-only-at-width","slot":slot,"widthNodeId":node, + "property":name,"width":value}), + ); + } + } + // The reference draws whatever its component's own file draws, which + // this module does not contain, and an instance can override its + // component's text, size or paint where the reference cannot say so. + // Matching the name and variant is not the same drawing. + gaps.push( + json!({"reason":"component-body-unverified","slot":slot,"widthNodeId":node, + "component":component,"drawnElements":drawn.len()}), + ); + drawn + } +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive/merge_content.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive/merge_content.rs new file mode 100644 index 00000000..64c47c82 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive/merge_content.rs @@ -0,0 +1,804 @@ + + +/// What a text says across the widths. +/// +/// Words are not a style prop and have no array to go into, so the widths +/// have to agree — with one allowance. A designer breaks a line at one width +/// and not another, and the same words then differ only by where `
` +/// falls; a break some widths have and others do not is written as +/// `` shown at the widths that have it. This is what the +/// plugin's `buildResponsiveTextChildren` sets out to do and does not: it +/// lines the texts up by character index, which the very break it is looking +/// for shifts, and it works on the rendered JSX of the widths, whose own +/// newlines it then rewrites as breaks. +/// +/// Words that differ by more than their breaks keep the first width's, and +/// the difference is reported rather than silently overruled. +fn merge_content( + by_slot: &BySlot, + first: &Tree, + notes: &mut Vec, +) -> Option { + let first_content = first.content.as_ref()?; + let drawn = by_slot + .iter() + .enumerate() + .filter_map(|(slot, tree)| { + tree.as_ref() + .and_then(|tree| tree.content.as_deref()) + .map(|content| (slot, content)) + }) + .collect::>(); + if drawn.iter().all(|(_, content)| *content == first_content) { + return Some(first_content.clone()); + } + + const BREAK: &str = "
"; + // The words with their breaks taken out, and where each break was: the + // offset, in characters of the words, that it sat before. + fn without_breaks(content: &str) -> (String, Vec) { + let mut words = String::with_capacity(content.len()); + let mut breaks = Vec::new(); + let mut rest = content; + let mut offset = 0; + while !rest.is_empty() { + if let Some(after) = rest.strip_prefix(BREAK) { + breaks.push(offset); + rest = after; + continue; + } + let character = rest.chars().next().expect("non-empty"); + words.push(character); + offset += 1; + rest = &rest[character.len_utf8()..]; + } + (words, breaks) + } + let stripped = drawn + .iter() + .map(|(slot, content)| (*slot, without_breaks(content))) + .collect::>(); + let (words, _) = &stripped[0].1; + if stripped.iter().any(|(_, (other, _))| other != words) { + notes.push(Unrepresented { + node_id: first.node_id.clone(), + detail: format!( + "{} reads differently at different widths; the first width's text is used.", + first.component + ), + }); + return Some(first_content.clone()); + } + + let breaks_at = |slot: usize, offset: usize| { + stripped + .iter() + .find(|(other, _)| *other == slot) + .map_or(0, |(_, (_, breaks))| { + breaks.iter().filter(|at| **at == offset).count() + }) + }; + let characters = words.chars().collect::>(); + let mut merged = String::with_capacity(first_content.len()); + for offset in 0..=characters.len() { + let most = stripped + .iter() + .map(|(slot, _)| breaks_at(*slot, offset)) + .max() + .unwrap_or_default(); + for nth in 1..=most { + let shown: BySlot = std::array::from_fn(|slot| { + by_slot[slot] + .as_ref() + .map(|_| breaks_at(slot, offset) >= nth) + }); + if shown.iter().flatten().all(|shown| *shown) { + merged.push_str(BREAK); + continue; + } + // Shown where the width breaks, `none` where it does not; a slot + // repeating the one in effect says nothing, and the first slot + // shown needs no value at all — a break is shown by default. + let mut slots: Vec> = shown + .iter() + .map(|shown| shown.map(|shown| if shown { "initial" } else { "none" }.to_owned())) + .collect(); + let mut carried: Option = None; + for slot in &mut slots { + let Some(value) = slot.clone() else { + continue; + }; + if carried.as_deref() == Some(value.as_str()) { + *slot = None; + } else { + carried = Some(value); + } + } + if slots.first().and_then(Option::as_deref) == Some("initial") { + slots[0] = None; + } + while slots.last().is_some_and(Option::is_none) { + slots.pop(); + } + let written = slots + .iter() + .map(|slot| { + slot.as_ref() + .map_or_else(|| "null".to_owned(), |value| format!("\"{value}\"")) + }) + .collect::>() + .join(", "); + merged.push_str(&format!("")); + } + if let Some(character) = characters.get(offset) { + merged.push(*character); + } + } + Some(merged) +} + +fn is_placement_prop(name: &str) -> bool { + super::variant::POSITION_PROPS.contains(&name) || name == "w" || name == "display" +} + +fn merge_children( + by_slot: &BySlot, + precedence: &Precedence, + notes: &mut Vec, +) -> Vec { + let maps: BySlot)>> = + std::array::from_fn(|slot| by_slot[slot].as_ref().map(children_to_map)); + let orders = maps + .iter() + .flatten() + .map(|map| map.iter().map(|(key, _)| key.clone()).collect::>()) + .collect::>(); + + let mut merged = Vec::new(); + for key in merge_child_order(&orders) { + let bucket = |slot: usize| { + maps[slot] + .as_ref() + .and_then(|map| map.iter().find(|(name, _)| *name == key)) + .map(|(_, children)| children) + }; + let count = (0..SLOTS) + .filter_map(|slot| bucket(slot).map(Vec::len)) + .max() + .unwrap_or_default(); + + for index in 0..count { + let mut children: BySlot = std::array::from_fn(|slot| { + bucket(slot).and_then(|list| list.get(index).cloned().cloned()) + }); + // A layer hidden at every width that has it is drawn at none, and + // `tsx` leaves it out — with its asset references, which a hidden + // layer has none of to deliver. A copy hidden everywhere would + // say nothing but `display: none`. + if children.iter().flatten().all(|child| { + child + .props + .get("display") + .is_some_and(|value| value == "none") + }) { + continue; + } + if (0..SLOTS).any(|slot| by_slot[slot].is_some() && children[slot].is_none()) { + for child in children.iter_mut().flatten() { + if child.component == "Image" { + // A present source must restore visibility even when + // it is absent on both sides of this drawn slot. + child + .props + .entry("display".to_owned()) + .or_insert_with(|| natural_display("Image").to_owned()); + } + } + } + // A width that does not draw this child is given a copy of the + // first one that does — first in the Section's order — hidden. The + // copies then merge like anything else: the `display` array falls + // out of the ordinary prop merge rather than from a branch of its + // own, and so do the copy's other values, which is why the choice + // of width to copy shows in the output. + if let Some(shown) = + first_slot(&children, precedence).and_then(|slot| children[slot].clone()) + { + for slot in 0..SLOTS { + if by_slot[slot].is_some() && children[slot].is_none() { + let mut hidden = shown.clone(); + hidden.props.insert("display".to_owned(), "none".to_owned()); + children[slot] = Some(hidden); + } + } + } + merged.extend(merge_trees(&children, precedence, notes)); + } + } + merged +} + +/// Write a merged tree as JSX, in the house style: props sorted, spread over +/// lines once there are five, and childless elements closed on themselves. +fn render_merged(tree: &Tree, depth: usize) -> String { + let indent = " ".repeat(depth); + let attributes = tree + .props + .iter() + .map(|(name, attribute)| format!("{name}{attribute}")) + .collect::>(); + // Five props, or any array, and the props go one to a line — the + // plugin's `propsToString` separator rule. + let multiline = + attributes.len() >= 5 || attributes.iter().any(|attribute| attribute.contains('\n')); + let opening = if attributes.is_empty() { + String::new() + } else if multiline { + let prefix = " ".repeat(depth + 1); + let padded = attributes + .iter() + .map(|attribute| attribute.replace('\n', &format!("\n{prefix}"))) + .collect::>(); + format!("\n{prefix}{}", padded.join(&format!("\n{prefix}"))) + } else { + format!(" {}", attributes.join(" ")) + }; + + let mut children = tree + .children + .iter() + .map(|child| render_merged(child, depth + 1)) + .collect::>(); + if let Some(content) = &tree.content { + let prefix = " ".repeat(depth + 1); + children.push( + content + .lines() + .map(|line| format!("{prefix}{line}")) + .collect::>() + .join("\n"), + ); + } + + // A shape spelled out in place of the component it came from still says + // which component that was, or a reader has nothing to go on when the + // change belongs upstream. + let comment = match &tree.leading_comment { + Some(comment) => format!("{indent}{{/* {comment} */}}\n"), + None => String::new(), + }; + let component = &tree.component; + if children.is_empty() { + if multiline { + format!("{comment}{indent}<{component}{opening}\n{indent}/>") + } else { + format!("{comment}{indent}<{component}{opening} />") + } + } else { + let close_open = if multiline { + format!("\n{indent}>") + } else { + ">".to_owned() + }; + format!( + "{comment}{indent}<{component}{opening}{close_open}\n{}\n{indent}", + children.join("\n") + ) + } +} + +/// Something the widths asked for that a single tree cannot say. +/// +/// Keeping these is the point of the exercise: an export that quietly picks one +/// width and moves on looks like a success and hides the thing worth fixing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Unrepresented { + pub node_id: String, + pub detail: String, +} + +/// A screen's widths, folded into one tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergedScreen { + /// The merged JSX, indented for a component body. + pub tsx: String, + /// Every element name the JSX mentions, primitives and components alike. + pub components: BTreeSet, + /// The slot each width was placed in, narrowest first. + pub slots: Vec, + /// What could not be carried across, and why. + pub unrepresented: Vec, + /// Every attribute of `tsx`, as `attribute_contract` lists one, with + /// whether each width's own verified TSX writes what it resolves to at + /// that width's slot. + pub contract: Vec, + /// Read at each width's slot, the module writes that width's own TSX: + /// the same elements with the same values, nothing added, nothing + /// missing, and the same text. + pub verified: bool, + /// Where a width and the module part company, when they do. + pub gaps: Vec, + /// Each opening element of `tsx`, in document order: the node every + /// drawn width made it from, and the widths it is drawn hidden at. + pub elements: Vec, +} + +/// One merged element and where it came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergedElement { + /// Inside another element's text (a `
`), not a node of its own. + pub in_text: bool, + /// (frame width, node id) for each width that draws it, widest first. + pub ids: Vec<(u32, String)>, + /// The drawn widths it is present but hidden at. + pub hidden_at: Vec, +} + +/// Everything `project_tree` can name. Anything else the merged tree mentions +/// is a component of the design system and is imported from `@/components`. +const DEVUP_PRIMITIVES: [&str; 7] = ["Box", "Center", "Flex", "Grid", "Image", "Text", "VStack"]; + +impl MergedScreen { + /// The elements that come from devup-ui itself. + pub fn primitives(&self) -> Vec<&str> { + self.components + .iter() + .map(String::as_str) + .filter(|name| DEVUP_PRIMITIVES.contains(name)) + .collect() + } + + /// The elements that are components of this design, one file each. + pub fn referenced_components(&self) -> Vec<&str> { + self.components + .iter() + .map(String::as_str) + .filter(|name| !DEVUP_PRIMITIVES.contains(name)) + .collect() + } + + /// The whole file: what it imports, and the screen as a default export. + pub fn module(&self, component_name: &str) -> String { + let mut lines = Vec::new(); + let primitives = self.primitives(); + if !primitives.is_empty() { + lines.push(format!( + "import {{ {} }} from '@devup-ui/react'", + primitives.join(", ") + )); + } + lines.extend( + self.referenced_components() + .iter() + .map(|name| format!("import {{ {name} }} from '@/components/{name}'")), + ); + let imports = if lines.is_empty() { + String::new() + } else { + format!("{}\n\n", lines.join("\n")) + }; + format!( + "{imports}export default function {component_name}() {{\n return (\n{}\n )\n}}\n", + self.tsx + ) + } +} + +fn collect_components(tree: &Tree, into: &mut BTreeSet) { + into.insert(tree.component.clone()); + for child in &tree.children { + collect_components(child, into); + } +} + +/// Fold a screen drawn at several widths into one tree. +/// +/// `None` when the snapshot holds fewer than two widths — that is a screen, not +/// a screen that changes, and it has an ordinary single-width conversion. +pub fn merge_breakpoints( + snapshot: &Snapshot, + options: &CodegenOptions, +) -> Result, DevupError> { + let found = breakpoints(snapshot); + if found.len() < 2 { + return Ok(None); + } + let mut by_slot: BySlot = std::array::from_fn(|_| None); + let mut roots: BySlot = std::array::from_fn(|_| None); + let mut precedence: Precedence = [usize::MAX; SLOTS]; + let mut slot_widths: BySlot = [None; SLOTS]; + let mut slots = Vec::new(); + for (position, breakpoint) in found.iter().enumerate() { + let Some(node) = snapshot.nodes.get(&breakpoint.node_id) else { + continue; + }; + let width = node.typed_view().number("width").unwrap_or_default(); + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "a frame is a small positive number of pixels wide" + )] + let slot = slot_of_width(width.max(0.0) as u32); + slots.push(slot); + // Two frames in one band would overwrite each other. The first keeps + // the slot; reporting the clash is left to the caller, which knows the + // names, rather than silently dropping a width here. + if by_slot[slot].is_none() { + let mut tree = project_tree_keeping_instances(snapshot, node, options, true)?; + restore_implied_display(&mut tree); + by_slot[slot] = Some(tree); + roots[slot] = Some(breakpoint.node_id.clone()); + precedence[slot] = position; + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "a frame is a small positive number of pixels wide" + )] + { + slot_widths[slot] = Some(width.max(0.0).round() as u32); + } + } + } + slots.sort_unstable(); + let mut unrepresented = Vec::new(); + let Some(tree) = merge_trees(&by_slot, &precedence, &mut unrepresented) else { + return Ok(None); + }; + let mut components = BTreeSet::new(); + collect_components(&tree, &mut components); + let tsx = render_merged(&tree, 2); + let (contract, gaps) = verify_merge(snapshot, options, &roots, &tree, &tsx); + let elements = merged_elements(&tree, &slot_widths); + Ok(Some(MergedScreen { + elements, + verified: gaps.is_empty() && contract.iter().all(|entry| entry["verified"] == true), + tsx, + components, + slots, + unrepresented, + contract, + gaps, + })) +} + +/// Where each element of the merged module came from, in the order +/// `render_merged` writes the opening elements. +fn merged_elements(tree: &Tree, slot_widths: &BySlot) -> Vec { + let mut placed = Vec::new(); + place(tree, None, [true; SLOTS], &mut placed); + placed + .iter() + .map(|placed| { + if placed.in_text { + return MergedElement { + in_text: true, + ids: Vec::new(), + hidden_at: Vec::new(), + }; + } + let mut ids = Vec::new(); + let mut hidden_at = Vec::new(); + for (slot, width) in slot_widths.iter().enumerate() { + let Some(width) = width else { + continue; + }; + match placed.tree.slots.get(slot).cloned().flatten() { + Some((node, _)) if placed.shown[slot] => ids.push((*width, node)), + _ if !placed.shown[slot] => hidden_at.push(*width), + // Shown with no node of its own at that width: nothing + // in that frame to compare it with. + _ => {} + } + } + ids.sort_by_key(|(width, _)| std::cmp::Reverse(*width)); + hidden_at.sort_by_key(|width| std::cmp::Reverse(*width)); + MergedElement { + in_text: false, + ids, + hidden_at, + } + }) + .collect() +} + +/// One opening element of the merged module, in document order. +struct Placed<'a> { + tree: &'a Tree, + /// Inside the tree's text rather than the tree's own element. + in_text: bool, + /// Whether the element is drawn at each slot. + shown: [bool; SLOTS], + parent_shown: [bool; SLOTS], + /// The element it sits in, by its place in the same order. + parent: Option, +} + +/// The merged elements in the order `render_merged` writes them: an element, +/// then its children, then whatever its text holds. +fn place<'a>( + tree: &'a Tree, + parent: Option, + parent_shown: [bool; SLOTS], + placed: &mut Vec>, +) { + let display = tree + .props + .get("display") + .and_then(|value| slot_values(value)); + let shown = std::array::from_fn(|slot| { + parent_shown[slot] + && display + .as_deref() + .and_then(|values| effective(values, slot)) + != Some("none") + }); + let index = placed.len(); + placed.push(Placed { + tree, + in_text: false, + shown, + parent_shown, + parent, + }); + for child in &tree.children { + place(child, Some(index), shown, placed); + } + if let Some(content) = &tree.content { + for _ in opening_elements(&format!("<>{content}")) { + placed.push(Placed { + tree, + in_text: true, + shown, + parent_shown: shown, + parent: Some(index), + }); + } + } +} + +/// A merged value as written after its name: `="v"` for every width, or +/// `={[...]}` with one entry per slot, `null` keeping the narrower one's. +fn slot_values(written: &str) -> Option>> { + if let Some(value) = written + .strip_prefix("=\"") + .and_then(|rest| rest.strip_suffix('"')) + { + return Some(vec![Some(value.to_owned())]); + } + let mut rest = written + .strip_prefix("={[")? + .strip_suffix("]}")? + .trim_start(); + let mut values = Vec::new(); + while !rest.is_empty() { + if let Some(after) = rest.strip_prefix("null") { + values.push(None); + rest = after; + } else { + let after = rest.strip_prefix('"')?; + let end = after.find('"')?; + values.push(Some(after[..end].to_owned())); + rest = &after[end + 1..]; + } + rest = rest.trim_start(); + rest = rest.strip_prefix(',').unwrap_or(rest).trim_start(); + } + Some(values) +} + +/// What devup-ui applies at a slot: the last value at or below it. +fn effective(values: &[Option], slot: usize) -> Option<&str> { + values + .iter() + .take(slot + 1) + .rev() + .find_map(Option::as_deref) +} + +/// A value as the element ends up with it, so that two spellings CSS draws +/// the same compare equal and nothing else does. `None` is the property's +/// CSS initial value. An element that writes nothing has what its own class +/// sets — `Center` centres, `VStack` stacks — and `initial` undoes that +/// class, except for `display`, whose initial value is `inline` whatever the +/// element is. An inherited `initial` is settled by the caller, which knows +/// the ancestors. +fn settled(prop: &str, value: Option<&str>, tag: &str) -> Option { + let value = match value { + None if prop == "display" => return Some(natural_display(tag).to_owned()), + None => implied_layout(tag) + .iter() + .find(|(name, _)| *name == prop) + .map(|(_, value)| *value), + Some("initial") if prop == "display" => return Some("inline".to_owned()), + Some("initial") if INHERITED.contains(&prop) => Some("initial"), + Some("initial") => None, + value => value, + }; + match (prop, value) { + ("flexDir", Some("row")) + | ("gap", Some("0" | "0px")) + | ( + "textDecorationSkipInk" | "textDecorationThickness" | "textUnderlineOffset", + Some("auto"), + ) + | ("textDecorationStyle", Some("solid")) => None, + (_, value) => value.map(str::to_owned), + } +} + +/// The devup-ui elements that are one `div` laid out by their props, so one +/// stands for another wherever the props say what the other does by itself. +const LAYOUT_ELEMENTS: [&str; 5] = ["Box", "Flex", "VStack", "Center", "Grid"]; + +/// The props in [`CLEARED_WHEN_DROPPED`] an element inherits when it does +/// not set them, where `initial` is not the same as leaving them unset. +const INHERITED: [&str; 2] = ["textAlign", "visibility"]; + +/// The value a generated attribute writes, as JSX reads it. +fn written(attribute: &GeneratedAttribute) -> String { + attribute + .source + .strip_prefix(attribute.name.as_str()) + .and_then(|rest| rest.strip_prefix("=\"")) + .and_then(|rest| rest.strip_suffix('"')) + .map_or_else( + || attribute.source.clone(), + |value| { + value + .replace(""", "\"") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + }, + ) +} + +/// A text read at one slot: each break written for some widths becomes a +/// `
` where it is shown and nothing where it is not. +fn content_at(content: &str, slot: usize) -> String { + const OPEN: &str = ""); + } + rest = &after[end + CLOSE.len()..]; + } + read.push_str(rest); + read +} + +fn descends(snapshot: &Snapshot, node: &str, ancestor: &str) -> bool { + let mut current = Some(node); + for _ in 0..1024 { + let Some(id) = current else { + return false; + }; + if id == ancestor { + return true; + } + current = snapshot + .nodes + .get(id) + .and_then(|node| node.typed_view().string("parentId")); + } + false +} + +/// The `Box` a component reference is wrapped in to carry where it sits. +fn wraps_a_reference(tree: &Tree) -> bool { + !tree.is_component + && tree.children.len() == 1 + && tree.children[0].is_component + && tree.children[0].node_id == tree.node_id +} + +/// The merged module against each width's own TSX. +/// +/// Each width is converted on its own, instances spelled out as in `tsx`, +/// and its elements are taken in order, by the node they were drawn from. +/// The module is then read at that width's slot — every array resolved the +/// way devup-ui resolves it, elements hidden there skipped — and the two +/// must agree: the same elements, each with the same element name and the +/// same settled values, every value one the width's own contract verifies, +/// the same text, and no element of the width left over. A component +/// reference stands for the instance's whole subtree. Its name, variant and +/// placement are compared, but what it draws belongs to a file this module +/// does not contain, so a module that shows one is not verified. An element +/// no collected width draws is read at none of them, so its values are not +/// verified. +/// +/// This reads the code; it does not render it. It is what makes the merged +/// attributes as verified as the per-width ones, and no more. +fn verify_merge( + snapshot: &Snapshot, + options: &CodegenOptions, + roots: &BySlot, + tree: &Tree, + tsx: &str, +) -> (Vec, Vec) { + let attributes = generated_attributes(tsx); + let tags = element_tags(tsx); + let mut placed = Vec::new(); + place(tree, None, [true; SLOTS], &mut placed); + let mut ok = vec![true; attributes.len()]; + let mut gaps = Vec::new(); + if placed.len() == tags.len() { + let mut by_element = vec![Vec::new(); placed.len()]; + for (index, attribute) in attributes.iter().enumerate() { + by_element[attribute.element].push(index); + } + let reference = CodegenOptions { + inline_instances: true, + ..options.clone() + }; + for (slot, root) in roots.iter().enumerate() { + let Some(root) = root else { + continue; + }; + match generate_component(snapshot, root, &reference) { + Ok(output) => { + let check = WidthCheck { + snapshot, + slot, + placed: &placed, + tags: &tags, + attributes: &attributes, + by_element: &by_element, + }; + check.run(&owned_elements(&output), &mut ok, &mut gaps); + } + Err(error) => { + ok.fill(false); + gaps.push(json!({"reason":"width-not-converted","slot":slot, + "widthNodeId":root,"detail":error.message})); + } + } + } + for (index, element) in placed.iter().enumerate() { + if !(0..SLOTS).any(|slot| roots[slot].is_some() && element.shown[slot]) { + for attribute in &by_element[index] { + if attributes[*attribute].name != "display" { + ok[*attribute] = false; + } + } + gaps.push(json!({"reason":"drawn-at-no-width","element":index, + "nodeId":element.tree.node_id,"tag":tags[index]})); + } + } + } else { + ok.fill(false); + gaps.push( + json!({"reason":"elements-not-aligned","placed":placed.len(),"written":tags.len()}), + ); + } + let aligned = placed.len() == tags.len(); + let contract = attributes + .iter() + .zip(&ok) + .map(|(attribute, verified)| { + json!({"elementIndex":attribute.element,"tag":attribute.tag, + "nodeId":aligned.then(|| placed[attribute.element].tree.node_id.as_str()), + "generatedProperty":attribute.source,"verified":verified}) + }) + .collect(); + (contract, gaps) +} + +struct WidthCheck<'a> { + snapshot: &'a Snapshot, + slot: usize, + placed: &'a [Placed<'a>], + tags: &'a [String], + attributes: &'a [GeneratedAttribute], + by_element: &'a [Vec], +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index ad4abd80..490c6ae5 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -1,2167 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use devup_mcp_figma::{RawNode, Snapshot, TypedNode}; -use serde_json::Value; - -use super::{ - component::{LengthToken, Prop, PropValue}, - layout::{format_number, px, string_prop}, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum AssetKind { - Svg, - SvgMask, - Png, -} - -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") - || 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" | "BOOLEAN_OPERATION" - ) { - return Some(svg_asset_kind(snapshot, node, nested)); - } - - 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(svg_asset_kind(snapshot, node, nested)); - } - - 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; - } - - return match snapshot - .nodes - .get(child_ids[0]) - .and_then(|child| asset_kind_nested(snapshot, child, true)) - { - Some(AssetKind::Png) => Some(AssetKind::Png), - Some(AssetKind::Svg | AssetKind::SvgMask) => { - Some(svg_asset_kind(snapshot, node, nested)) - } - 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); - } - } - - visible_children - .into_iter() - .all(|child| { - matches!( - asset_kind_nested(snapshot, child, true), - Some(AssetKind::Svg | AssetKind::SvgMask) - ) - }) - .then(|| svg_asset_kind(snapshot, node, nested)) -} - -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 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, nested)); - } - - return Some(svg_asset_kind(snapshot, node, nested)); - } - - (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, nested)) -} - -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, nested: bool) -> AssetKind { - if matches!( - same_color(snapshot, node, nested, None), - SameColor::Color(_) - ) { - AssetKind::SvgMask - } else { - AssetKind::Svg - } -} - -/// What an asset is painted in, if it is one thing. -/// -/// This is the `sameColor` half of the plugin's `computeAssetAnalysis`, -/// which decides whether an icon is drawn as an `` or as a Box masked -/// to its shape and filled with one colour. `Null` is a subtree that settled -/// on nothing, `False` one whose paints disagree, and only a `Color` makes a -/// mask. The two non-answers are not the same: a `Null` child leaves a -/// running colour alone, a `False` one spoils it. -/// -/// A paint that is not one flat colour (a gradient, an image, a solid with -/// no readable colour) is `False`, not `Null`. A mask keeps only the shape -/// and fills it with one colour, so letting such a paint stay neutral merged -/// a gradient face and a solid outline into one solid blob. -#[derive(Debug, Clone, PartialEq, Eq)] -enum SameColor { - Null, - False, - Color(String), -} - -/// The plugin's `mergeSameColor`, except that a `Null` (a subtree with no -/// paint) is neutral wherever it falls. Taken literally, a `Null` after the -/// first colour spoiled it while one before was skipped, so the order of the -/// layers decided whether an icon became a mask. -fn merge_same_color(current: SameColor, next: SameColor) -> SameColor { - match (current, next) { - (_, SameColor::False) => SameColor::False, - (SameColor::Null, next) => next, - (current, SameColor::Null) => current, - (current, next) if current == next => current, - _ => SameColor::False, - } -} - -/// The plugin's `analyzeOwnSameColor`: the node's own fills and strokes. -enum OwnColor { - /// No visible paint at all. - None, - /// Two flat colours that differ, or a paint that is not a flat colour. - False, - Color(String), -} - -impl OwnColor { - fn same_color(self) -> SameColor { - match self { - Self::None => SameColor::Null, - Self::False => SameColor::False, - Self::Color(color) => SameColor::Color(color), - } - } -} - -fn own_same_color( - node: &RawNode, - variable_tokens: Option<&std::collections::BTreeMap>, -) -> OwnColor { - let view = node.typed_view(); - let mut target: Option = None; - for field in ["fills", "strokes"] { - let Some(paints) = view.value(field).and_then(Value::as_array) else { - continue; - }; - for paint in paints { - if paint.get("visible").and_then(Value::as_bool) != Some(true) { - continue; - } - if paint.get("type").and_then(Value::as_str) != Some("SOLID") { - return OwnColor::False; - } - let Some(color) = paint_string(paint, variable_tokens) else { - return OwnColor::False; - }; - match &target { - None => target = Some(color), - Some(current) if *current != color => return OwnColor::False, - Some(_) => {} - } - } - } - target.map_or(OwnColor::None, OwnColor::Color) -} - -/// A solid paint as the plugin's `solidToString` spells it: the variable it -/// is bound to as `$token`, or else the colour. Two paints are the same -/// colour to the plugin only when these agree, so a paint bound to a -/// variable and a raw paint of the same hex are *not* the same. -/// -/// Without a token map the variable id stands in for its name; that keeps -/// the comparison right when only the shape is being decided and the names -/// are not to hand. -pub(super) fn paint_string( - paint: &Value, - variable_tokens: Option<&std::collections::BTreeMap>, -) -> Option { - if let Some(id) = paint - .get("boundVariables") - .and_then(|bound| bound.get("color")) - .and_then(|color| color.get("id")) - .and_then(Value::as_str) - { - match variable_tokens { - None => return Some(format!("${id}")), - Some(tokens) => { - if let Some(token) = tokens.get(id) { - return Some(format!("${token}")); - } - } - } - } - if paint.get("opacity").and_then(Value::as_f64) == Some(0.0) { - return Some("transparent".to_owned()); - } - color_from_paint(paint) -} - -fn same_color( - snapshot: &Snapshot, - node: &RawNode, - nested: bool, - variable_tokens: Option<&std::collections::BTreeMap>, -) -> SameColor { - let view = node.typed_view(); - let own = || own_same_color(node, variable_tokens).same_color(); - if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") - || view - .value("inferredAutoLayout") - .and_then(|layout| layout.get("layoutMode")) - .and_then(Value::as_str) - == Some("GRID") - || 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 SameColor::Null; - } - // Boolean operands define geometry; the result's own paint colors it. - if matches!( - view.node_type(), - "VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION" - ) { - return own(); - } - 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 own(); - } - - let child_ids = view.child_ids().collect::>(); - if child_ids.is_empty() { - 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 SameColor::Null; - } - if view.bool("isAsset") == Some(true) { - let Some(node_fills) = node_fills else { - return SameColor::Null; - }; - if node_fills.iter().any(|fill| { - is_visible_fill(fill) - && fill_type(fill) == Some("IMAGE") - && fill.get("scaleMode").and_then(Value::as_str) != Some("TILE") - }) { - // A picture inside an icon has colours a mask cannot keep. - return SameColor::False; - } - if node_fills.iter().all(|fill| { - fill.get("visible").and_then(Value::as_bool) == Some(true) - && fill_type(fill) == Some("SOLID") - }) { - return if nested { own() } else { SameColor::Null }; - } - return own(); - } - if nested - && node_fills.is_some_and(|fills| { - !fills.iter().any(|fill| { - is_visible_fill(fill) - && matches!(fill_type(fill), Some("IMAGE" | "VIDEO" | "PATTERN")) - }) - }) - { - return own(); - } - return SameColor::Null; - } - - 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 SameColor::Null; - } - return snapshot - .nodes - .get(child_ids[0]) - .map_or(SameColor::Null, |child| { - same_color(snapshot, child, true, variable_tokens) - }); - } - - let mut same = match own_same_color(node, variable_tokens) { - OwnColor::False => return SameColor::False, - own => own.same_color(), - }; - for child in child_ids - .into_iter() - .filter_map(|id| snapshot.nodes.get(id)) - .filter(|child| child.typed_view().bool("visible") != Some(false)) - { - same = merge_same_color(same, same_color(snapshot, child, true, variable_tokens)); - } - same -} - -/// What the style pass needs beyond the node itself: which variables carry a -/// token name, and how assets are to be named. Carried together so that -/// adding to it does not lengthen every signature it passes through. -#[derive(Clone, Copy)] -pub(super) struct StyleOptions<'a> { - pub reserve_image_content: bool, - pub asset_policies: &'a BTreeMap, - pub variable_tokens: &'a std::collections::BTreeMap, - pub length_tokens: &'a std::collections::BTreeMap, - pub asset_names_per_node: bool, - pub width_policy: Option<&'a super::width::WidthPolicy>, -} - -// Record the route at the property writer; available geometry alone is not -// evidence that a boundary override actually ran. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum StyleDerivation { - SvgMaskPolicy, - InFlowExportBoundary, - ImagePaintScale, - NativeRaster, - WidthPolicyLine, -} - -impl StyleDerivation { - pub(super) fn path(self) -> &'static str { - match self { - Self::SvgMaskPolicy => "svg-mask-policy", - Self::InFlowExportBoundary => "in-flow-export-boundary", - Self::ImagePaintScale => "image-paint-scale", - Self::NativeRaster => "native-raster-boundary", - Self::WidthPolicyLine => "width-policy-straight-line", - } - } - - pub(super) fn description(self) -> (Vec<&'static str>, &'static str) { - match self { - Self::SvgMaskPolicy => ( - vec![ - "fills", - "childrenIds", - "isAsset", - "absoluteRenderBounds", - "absoluteBoundingBox", - ], - "SVG mask projection policy from asset_kind/same_color: maskRepeat=no-repeat, maskSize=contain, maskPos=center unless export boundary derivation overrides them.", - ), - Self::InFlowExportBoundary => ( - vec![ - "absoluteRenderBounds", - "absoluteBoundingBox", - "layoutPositioning", - "parentId", - "fills", - ], - "In-flow export boundary: offset=(render.x-bounds.x, render.y-bounds.y); exported size=(render.width, render.height). Image uses objectFit=none and objectPos=offset; mask uses maskSize=exported size and maskPos=offset. Absolute/free-layout placement is handled by layout.", - ), - Self::NativeRaster => ( - vec!["isAsset", "fills", "absoluteRenderBounds"], - "Native raster boundary: use the already rendered pixel box without applying the source IMAGE paint fit a second time; in-flow export offsets override this placement when present.", - ), - Self::ImagePaintScale => ( - vec!["isAsset", "fills"], - "push_object_fit: first visible IMAGE paint scaleMode FIT -> contain; CROP -> cover.", - ), - Self::WidthPolicyLine => ( - vec![ - "absoluteBoundingBox", - "strokes", - "strokeWeight", - "rotation", - "constraints", - ], - "widthPolicy: a vector with a zero-height bounding box and a solid stroke is a straight rule; its box follows the parent's width under a stretch or scale anchor, so the mask is sized to the box (100% 100%) instead of keeping its drawn length. Stroke caps, if any, scale along the line.", - ), - } - } -} - -#[derive(Default)] -pub(super) struct StyleDerivations(BTreeMap); - -impl StyleDerivations { - fn push( - &mut self, - props: &mut Vec, - name: &str, - value: impl Into, - route: StyleDerivation, - ) { - let value = value.into(); - string_prop(props, name, value.clone()); - // Last writer wins for both the value and its explanation. - self.0.insert(name.to_owned(), (value, route)); - } - - pub(super) fn matching(&self, name: &str, value: &str) -> Option { - let (written, route) = self.0.get(name)?; - (written == value).then_some(*route) - } -} - -pub(super) fn push_style_props( - snapshot: &Snapshot, - node: &RawNode, - component: &str, - asset: Option, - props: &mut Vec, - used_tokens: &mut BTreeSet, - style: StyleOptions<'_>, -) -> StyleDerivations { - let mut derivations = StyleDerivations::default(); - let StyleOptions { - variable_tokens, - length_tokens, - asset_names_per_node: per_node, - width_policy, - asset_policies, - reserve_image_content, - } = style; - let view = node.typed_view(); - let lengths = Lengths { - tokens: length_tokens, - viewport: drawn_viewport(snapshot, node), - }; - if view.bool("visible") == Some(false) { - string_prop(props, "display", "none"); - } - if non_rendering_asset_reason(snapshot, node).is_some() { - // Background paints and the variant tree also pass here. Do not - // leave a URL behind merely because the node is not an Image leaf. - string_prop(props, "visibility", "hidden"); - return derivations; - } - if let Some(asset) = asset { - let image_index = super::image::selected_image_index(node, asset_policies); - let image_id = image_index - .map(|i| format!("{}:fills:{i}", node.id)) - .unwrap_or_else(|| node.id.clone()); - let policy = super::image::policy_for(node, &image_id, asset_policies); - let original = asset == AssetKind::Png && image_index.is_some() && policy.original(); - if policy.incompatible_capture(&node.id) { - string_prop(props, "visibility", "hidden"); - return derivations; - } - let folder = if matches!(asset, AssetKind::Svg | AssetKind::SvgMask) { - "icons" - } else { - "images" - }; - let extension = if matches!(asset, AssetKind::Svg | AssetKind::SvgMask) { - "svg" - } else { - "png" - }; - let source = (asset == AssetKind::Png) - .then(|| policy.asset_path.clone()) - .flatten() - .unwrap_or_else(|| asset_source(snapshot, node, folder, extension, per_node)); - if asset == AssetKind::SvgMask { - if let SameColor::Color(color) = - same_color(snapshot, node, false, Some(variable_tokens)) - { - if let Some(token) = color.strip_prefix('$') { - used_tokens.insert(token.to_owned()); - } - string_prop(props, "bg", color); - } - let url = if source.contains(' ') { - format!("url('{source}')") - } else { - format!("url({source})") - }; - string_prop(props, "maskImage", url); - derivations.push( - props, - "maskRepeat", - "no-repeat", - StyleDerivation::SvgMaskPolicy, - ); - derivations.push(props, "maskSize", "contain", StyleDerivation::SvgMaskPolicy); - derivations.push(props, "maskPos", "center", StyleDerivation::SvgMaskPolicy); - if width_policy - .is_some_and(|policy| super::width::line_follows_width(snapshot, node, policy)) - { - derivations.push( - props, - "maskSize", - "100% 100%", - StyleDerivation::WidthPolicyLine, - ); - } - } else { - string_prop(props, "src", source); - } - if original { - let mapping = - super::image::map_for(snapshot, node, image_index, asset_policies, per_node); - if let Some(path) = mapping["assetPath"].as_str() { - string_prop(props, "src", path); - } - super::image::apply_image_props(props, &mapping); - if reserve_image_content { - super::image::reserve_hug_content(props, &mapping); - } - if policy.effects_baked != Some(true) { - let node_opacity = view.number("opacity").unwrap_or(1.); - let paint_opacity = if mapping["css"]["image"]["transform"].is_null() { - mapping["css"]["image"]["opacity"] - .as_str() - .and_then(|s| s.parse::().ok()) - .unwrap_or(1.) - } else { - 1. - }; - if node_opacity * paint_opacity < 1. { - string_prop( - props, - "opacity", - format_number(node_opacity * paint_opacity), - ); - } - } - } else if asset != AssetKind::Png { - push_object_fit(&view, props, &mut derivations); - } else { - derivations.push(props, "objectFit", "fill", StyleDerivation::NativeRaster); - let mapping = - super::image::map_for(snapshot, node, image_index, asset_policies, per_node); - super::image::apply_image_props(props, &mapping); - } - // An export in flow is drawn where it sits in the box the layout gives - // the node. They coincide for a plain icon; the notice logo is an - // instance of 1373x98 whose vector is 952x104 at 425px in, and - // `contain` centred a 1373-wide picture of a 952-wide logo. The - // element keeps the layout's box, and the picture is placed inside it - // at the export's own size and offset. An element the layout - // positions is placed by its export outright, in `codegen::layout`. - if !original - && view.string("layoutPositioning") != Some("ABSOLUTE") - && !super::layout::placed_by_a_free_layout( - snapshot, - node, - view.string("parentId") - .and_then(|parent_id| snapshot.nodes.get(parent_id)), - false, - ) - && let Some(offset) = super::layout::export_offset(node) - { - let size = format!("{} {}", px(offset.w), px(offset.h)); - let position = format!("{} {}", px(offset.x), px(offset.y)); - if asset == AssetKind::SvgMask { - derivations.push( - props, - "maskSize", - size, - StyleDerivation::InFlowExportBoundary, - ); - derivations.push( - props, - "maskPos", - position, - StyleDerivation::InFlowExportBoundary, - ); - } else { - derivations.push( - props, - "objectFit", - "none", - StyleDerivation::InFlowExportBoundary, - ); - derivations.push( - props, - "objectPos", - position, - StyleDerivation::InFlowExportBoundary, - ); - } - } - if asset != AssetKind::Png - || (policy.effects_baked != Some(true) - && (policy.effects_baked == Some(false) || original)) - { - push_radius(&view, props); - // The exported asset has its own painted bounds. The live-container - // outside-edge correction does not establish how to compose those. - push_strokes(&view, None, props, used_tokens, variable_tokens); - // Native PNGs flatten this entire subtree, including its own blur and - // shadows. Layout ancestors still render their effects in their own - // branch below. SVG masks are alpha masks rather than raster pictures; - // retain their existing compositing policy. - if asset != AssetKind::Png || original || policy.effects_baked == Some(false) { - push_effects( - &view, - component, - props, - used_tokens, - variable_tokens, - lengths, - ); - } - if !original - && policy.effects_baked == Some(false) - && let Some(opacity) = view.number("opacity").filter(|n| *n < 1.) - { - string_prop(props, "opacity", format_number(opacity)); - } - } - // An export carries the node's own opacity: Figma writes it into the - // SVG as `` and into a PNG's alpha. Written on the element - // as well it is applied twice - a decoration at 0.2 came out at 0.04, - // which is nothing, and the landing page's hero at 0.8 came out at - // 0.64. The plugin writes it twice too. A mask is the same: the - // SVG's own opacity thins the mask, so the colour painted through it - // already shows at the node's opacity. - push_blend_mode(&view, props); - return derivations; - } - - push_object_fit(&view, props, &mut derivations); - let color_prop = if component == "Text" { "color" } else { "bg" }; - // A background is every visible paint, back to front, as the plugin's - // `getBackgroundProps` composes it. Reading only the bound variable - // dropped the photo that sits on top of a `$gray200` plate: the about - // member cards are `url(...) center/cover no-repeat, $gray200`, and were - // coming out as the plate alone. - let layered = component != "Text" - && view - .value("fills") - .and_then(Value::as_array) - .is_some_and(|fills| { - fills - .iter() - .filter(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("opacity").and_then(Value::as_f64) != Some(0.0) - }) - .count() - > 1 - }); - if layered { - if let Some(background) = background_css(snapshot, node, used_tokens, style) { - string_prop(props, "bg", background); - } - } else if let Some(token) = view - .value("devupTokens") - .and_then(Value::as_object) - .and_then(|tokens| tokens.get("fills")) - .and_then(Value::as_str) - { - used_tokens.insert(token.to_owned()); - string_prop(props, color_prop, format!("${token}")); - } else if let Some(color) = bound_color_token(&view, variable_tokens) { - used_tokens.insert(color.clone()); - string_prop(props, color_prop, format!("${color}")); - } else if component == "Text" && has_non_solid_fill(&view) { - if let Some(background) = background_css(snapshot, node, used_tokens, style) { - string_prop(props, "bg", background); - string_prop(props, "bgClip", "text"); - string_prop(props, "WebkitTextFillColor", "transparent"); - } - } else if component == "Text" { - let transparent = view - .value("fills") - .and_then(Value::as_array) - .is_some_and(|fills| { - fills.iter().any(|paint| { - paint.get("type").and_then(Value::as_str) == Some("SOLID") - && paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("opacity").and_then(Value::as_f64) == Some(0.0) - }) - }); - if transparent { - string_prop(props, color_prop, "transparent"); - } else if let Some(color) = first_solid_color(view.value("fills")) { - string_prop(props, color_prop, color); - } - } else if let Some(background) = background_css(snapshot, node, used_tokens, style) { - string_prop(props, "bg", background); - } - if let Some(mode) = view - .value("fills") - .and_then(Value::as_array) - .and_then(|fills| { - fills.iter().rev().find_map(|paint| { - (paint.get("visible").and_then(Value::as_bool) != Some(false)) - .then(|| paint.get("blendMode").and_then(Value::as_str)) - .flatten() - .and_then(blend_mode) - }) - }) - { - string_prop(props, "bgBlendMode", mode); - } - - push_radius(&view, props); - if component != "Text" { - push_strokes( - &view, - single_outside_stroke(&view), - props, - used_tokens, - variable_tokens, - ); - } - push_effects( - &view, - component, - props, - used_tokens, - variable_tokens, - lengths, - ); - if let Some(opacity) = view.number("opacity") - && opacity < 1.0 - { - string_prop(props, "opacity", format_number(opacity)); - } - push_blend_mode(&view, props); - derivations -} - -pub(super) fn non_rendering_asset_reason( - snapshot: &Snapshot, - node: &RawNode, -) -> Option<&'static str> { - let reason = if node - .typed_view() - .value("absoluteRenderBounds") - .is_some_and(Value::is_null) - { - "no-render-bounds" - } else { - devup_mcp_figma::asset_exclusion_reason(node)? - }; - (asset_kind(snapshot, node).is_some() - || fills(node) - .is_some_and(|paints| paints.iter().any(|paint| fill_type(paint) == Some("IMAGE")))) - .then_some(reason) -} - -/// The plugin's `getObjectFitProps`: how the first visible image fill of a -/// node Figma calls an asset is scaled. It is written whatever element the -/// node became — the about member cards are a `Box` whose photo sits on a -/// `$gray200` plate, and the reference gives them `objectFit="cover"` all the -/// same. `FILL` and `TILE` say nothing. -fn push_object_fit( - view: &TypedNode<'_>, - props: &mut Vec, - derivations: &mut StyleDerivations, -) { - if view.bool("isAsset") != Some(true) { - return; - } - let Some(scale) = view - .value("fills") - .and_then(Value::as_array) - .and_then(|fills| { - fills.iter().find(|paint| { - paint.get("type").and_then(Value::as_str) == Some("IMAGE") - && paint.get("visible").and_then(Value::as_bool) == Some(true) - }) - }) - .and_then(|paint| paint.get("scaleMode")) - .and_then(Value::as_str) - else { - return; - }; - match scale { - "FIT" => derivations.push( - props, - "objectFit", - "contain", - StyleDerivation::ImagePaintScale, - ), - "CROP" => derivations.push( - props, - "objectFit", - "cover", - StyleDerivation::ImagePaintScale, - ), - _ => {} - } -} - -fn push_blend_mode(view: &TypedNode<'_>, props: &mut Vec) { - let Some(blend) = view.string("blendMode") else { - return; - }; - let css = blend_mode(blend); - if let Some(css) = css { - string_prop(props, "mixBlendMode", css); - } -} - -fn has_non_solid_fill(view: &TypedNode<'_>) -> bool { - view.value("fills") - .and_then(Value::as_array) - .is_some_and(|fills| { - fills.iter().any(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("opacity").and_then(Value::as_f64) != Some(0.0) - && paint.get("type").and_then(Value::as_str) != Some("SOLID") - }) - }) -} - -fn background_css( - snapshot: &Snapshot, - node: &RawNode, - used_tokens: &mut BTreeSet, - style: StyleOptions<'_>, -) -> 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() - .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 (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, - used_tokens, - style, - ) { - 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( - snapshot: &Snapshot, - node: &RawNode, - fill_index: usize, - per_node: bool, -) -> String { - let source = if fill_index == 0 { - asset_source(snapshot, node, "images", "png", per_node) - } else { - let stem = asset_stem(snapshot, node, per_node); - format!("/images/{stem}-{fill_index}.png") - }; - if source.contains(' ') { - format!("'{source}'") - } else { - source - } -} - -/// The file an asset node is drawn from: `/{folder}/{stem}.{extension}`. -/// -/// The plugin draws an instance from its main component and names the file -/// after that node, so every instance of a variant is `Property 1=search.svg`: -/// one file for the icon wherever it is used, but the same file for every -/// component set that has a `search` variant, each overwriting the last. The -/// layer name a designer gave the node is used instead, and where two assets -/// in the snapshot that are not the same thing would share it, each says what -/// it is: see `asset_stem`. -pub(crate) fn asset_source( - snapshot: &Snapshot, - node: &RawNode, - folder: &str, - extension: &str, - per_node: bool, -) -> String { - format!( - "/{folder}/{}.{extension}", - asset_stem(snapshot, node, per_node) - ) -} - -/// The path the generated code refers to for an asset node - `/icons/x.svg` -/// for a vector, `/images/x.png` for the first image fill - so a manifest -/// can say where the code expects each asset. `None` for a node the code -/// does not draw from a file. -pub fn asset_path(snapshot: &Snapshot, node_id: &str, per_node: bool) -> Option { - let node = snapshot.nodes.get(node_id)?; - let kind = asset_kind(snapshot, node)?; - let (folder, extension) = match kind { - AssetKind::Svg | AssetKind::SvgMask => ("icons", "svg"), - _ => ("images", "png"), - }; - Some(asset_source(snapshot, node, folder, extension, per_node)) -} - -/// Where the code draws one of a node's image fills from: `/images/x.png` -/// for the first fill and `/images/x-2.png` past it, the same name -/// `image_fill_source` writes into the code but without the quoting a CSS -/// `url()` puts around a name with a space in it. -/// -/// This answers for any node that carries the fill, where `asset_path` only -/// answers for a node the code draws entirely from a file. A section painted -/// over a photograph is a layout box holding children, not an asset - but the -/// photograph on it is still a file the code points at, and a caller has to -/// be told where. -pub fn image_fill_path( - snapshot: &Snapshot, - node_id: &str, - fill_index: usize, - per_node: bool, -) -> Option { - let node = snapshot.nodes.get(node_id)?; - let stem = asset_stem(snapshot, node, per_node); - Some(if fill_index == 0 { - format!("/images/{stem}.png") - } else { - format!("/images/{stem}-{fill_index}.png") - }) -} - -/// The file name an asset node gets, without folder or extension. -/// -/// The layer name, unless another asset in the snapshot has the same name -/// and is a different thing. Three cards each hold an `Icons` instance at a -/// different variant - chart, clock, lightning - and all three were -/// `/icons/Icons.svg`, one file overwriting the next, and every card drew the -/// chart. An instance whose name is shared then carries its variant, `Icons=chart`; -/// a node that is not an instance carries its id. Instances of one variant -/// share a name and a file, as the same icon at three widths should. -pub(crate) fn asset_stem(snapshot: &Snapshot, node: &RawNode, per_node: bool) -> String { - let view = node.typed_view(); - let name = view.name().unwrap_or("Asset"); - let identity = asset_identity(node); - let raster_instance = - view.node_type() == "INSTANCE" && asset_kind(snapshot, node) == Some(AssetKind::Png); - let shared = per_node - || snapshot.nodes.values().any(|other| { - other.id != node.id - && other.typed_view().name() == Some(name) - && (asset_identity(other) != identity - || raster_instance - || (fills(node).is_some_and(|fs| fs.iter().any(|p| p["type"] == "IMAGE")) - && fills(other) != fills(node))) - && asset_kind(snapshot, other).is_some() - }); - if !shared { - return name.to_owned(); - } - match identity { - // Raster instances can override the image, crop, size and effects while - // retaining the same component variant. Only verified exported bytes - // establish reuse; a shared variant name cannot establish identity. - Some(variant) if raster_instance => { - format!("{name}={variant}-{}", node.id.replace([':', ';'], "-")) - } - Some(variant) => format!("{name}={variant}"), - None => format!("{name}-{}", node.id.replace([':', ';'], "-")), - } -} - -/// What makes an instance the thing it is: its variant, as `chart` or -/// `lg,primary`. `None` for a node that is not an instance of a variant. -fn asset_identity(node: &RawNode) -> Option { - let view = node.typed_view(); - let properties = view.value("variantProperties")?.as_object()?; - let values = properties - .values() - .filter_map(Value::as_str) - .collect::>(); - (!values.is_empty()).then(|| values.join(",")) -} - -pub(super) fn paint_css( - snapshot: &Snapshot, - node: &RawNode, - paint: &Value, - fill_index: usize, - last: bool, - used_tokens: &mut BTreeSet, - style: StyleOptions<'_>, -) -> Option { - let StyleOptions { - variable_tokens, - asset_names_per_node: per_node, - .. - } = style; - let kind = paint.get("type")?.as_str()?; - match kind { - "SOLID" => { - let color = bound_paint_token(paint, variable_tokens) - .map(|token| { - used_tokens.insert(token.clone()); - format!("${token}") - }) - .or_else(|| color_from_paint(paint))?; - Some(if last { - color - } else { - format!("linear-gradient({color}, {color})") - }) - } - "GRADIENT_LINEAR" => gradient_css(node, paint, "linear", variable_tokens), - "GRADIENT_RADIAL" => gradient_css(node, paint, "radial", variable_tokens), - "GRADIENT_ANGULAR" => gradient_css(node, paint, "angular", variable_tokens), - "GRADIENT_DIAMOND" => gradient_css(node, paint, "diamond", variable_tokens), - "IMAGE" => { - let mapping = super::image::map_for( - snapshot, - node, - Some(fill_index), - style.asset_policies, - per_node, - ); - let source = mapping["assetPath"] - .as_str() - .map(str::to_owned) - .unwrap_or_else(|| image_fill_source(snapshot, node, fill_index, per_node)); - let fit = mapping["css"]["background"] - .as_str() - .or_else(|| (paint["scaleMode"] == "TILE").then_some("repeat"))?; - Some(format!("url({source}) {fit}")) - } - "PATTERN" => { - let source_id = paint.get("sourceNodeId").and_then(Value::as_str)?; - let source = snapshot.nodes.get(source_id); - let name = source - .and_then(|node| node.typed_view().name()) - .unwrap_or("pattern"); - // 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)) - .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")) - .and_then(Value::as_f64) - .unwrap_or(0.0); - let y = spacing - .and_then(|value| value.get("y")) - .and_then(Value::as_f64) - .unwrap_or(0.0); - let horizontal = position_part( - paint - .get("horizontalAlignment") - .and_then(Value::as_str) - .unwrap_or("START"), - x, - ["left", "center", "right"], - ); - let vertical = position_part( - paint - .get("verticalAlignment") - .and_then(Value::as_str) - .unwrap_or("START"), - y, - ["top", "center", "bottom"], - ); - let position = [horizontal, vertical] - .into_iter() - .flatten() - .collect::>() - .join(" "); - Some(format!( - "url(/{folder}/{name}.{extension}){} repeat", - if position.is_empty() { - String::new() - } else { - format!(" {position}") - } - )) - } - _ => None, - } -} - -fn position_part(alignment: &str, spacing: f64, values: [&str; 3]) -> Option { - if alignment == "START" && spacing == 0.0 { - return None; - } - let value = match alignment { - "CENTER" => values[1], - "END" => values[2], - _ => values[0], - }; - Some(format!("{value} {}%", format_number(spacing * 100.0))) -} - -fn gradient_css( - node: &RawNode, - paint: &Value, - kind: &str, - variable_tokens: &std::collections::BTreeMap, -) -> Option { - let opacity = paint.get("opacity").and_then(Value::as_f64).unwrap_or(1.0); - let raw_stops = paint - .get("gradientStops")? - .as_array()? - .iter() - .filter_map(|stop| { - let mut color = stop.get("color")?.clone(); - let alpha = color.get("a").and_then(Value::as_f64).unwrap_or(1.0) * opacity; - color - .as_object_mut()? - .insert("a".to_owned(), Value::from(alpha)); - // A stop bound to a variable is the token — and where the stop or - // the paint is translucent, the token mixed with transparent by - // that much, as the plugin's `processGradientStopColor` writes it: - // a token names an opaque colour, and the alpha would be lost with - // it. The report section's backdrop is a 50% gradient between two - // tokens, `color-mix(in srgb, $primaryBg, transparent 50%)`. - let color = bound_paint_token(stop, variable_tokens) - .map(|token| { - if alpha < 1.0 { - format!( - "color-mix(in srgb, ${token}, transparent {}%)", - format_number((1.0 - alpha) * 100.0) - ) - } else { - format!("${token}") - } - }) - .or_else(|| color_from(&color))?; - Some((stop.get("position")?.as_f64()?, color)) - }) - .collect::>(); - let transform = paint - .get("gradientTransform") - .and_then(parse_transform) - .unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]); - let view = node.typed_view(); - let width = view.number("width").unwrap_or(1.0); - let height = view.number("height").unwrap_or(1.0); - let (prefix, positions) = match kind { - "linear" => { - let (angle, positions) = linear_geometry(transform, width, height, &raw_stops)?; - ( - format!("linear-gradient({}deg, ", format_number(angle)), - positions, - ) - } - "radial" => { - let inverse = inverse_transform(transform)?; - let center = apply_transform(inverse, [0.5, 0.5]); - let radius = apply_transform(inverse, [1.0, 1.0]); - ( - format!( - "radial-gradient({}% {}% at {}% {}%, ", - format_number((radius[0] - center[0]).abs() * 100.0), - format_number((radius[1] - center[1]).abs() * 100.0), - format_number(center[0] * 100.0), - format_number(center[1] * 100.0) - ), - raw_stops - .iter() - .map(|(position, _)| position * 100.0) - .collect(), - ) - } - "angular" => { - let inverse = inverse_transform(transform)?; - let center = apply_transform(inverse, [0.5, 0.5]); - let start = apply_transform(inverse, [1.0, 0.5]); - let mut angle = ((start[1] - center[1]) * height) - .atan2((start[0] - center[0]) * width) - .to_degrees() - + 90.0; - angle %= 360.0; - if angle < 0.0 { - angle += 360.0; - } - ( - format!( - "conic-gradient(from {}deg at {}% {}%, ", - format_number(angle), - format_number(center[0] * 100.0), - format_number(center[1] * 100.0) - ), - raw_stops - .iter() - .map(|(position, _)| position * 100.0) - .collect(), - ) - } - "diamond" => ( - String::new(), - raw_stops - .iter() - .map(|(position, _)| position * 50.0) - .collect(), - ), - _ => return None, - }; - let stops = raw_stops - .iter() - .zip(positions) - .map(|((_, color), position)| format!("{color} {}%", format_number(position))) - .collect::>() - .join(", "); - Some(match kind { - "linear" | "radial" | "angular" => format!("{prefix}{stops})"), - // A diamond is four linear gradients, one into each corner. The pairs - // are written as pairs rather than as `"corner|direction"` strings - // split back apart at runtime. Nothing outside this array ever reached - // that split — the design comes in through `raw_stops`, `transform` - // and `kind`, all of which are already `Option`-handled above — so the - // only way it could have failed was a typo in the four lines below, - // and it would have failed by killing the server rather than by - // drawing a wrong gradient. Said as tuples, the same four facts cannot - // be written malformed at all. - "diamond" => [ - ("bottom right", "to bottom right"), - ("bottom left", "to bottom left"), - ("top left", "to top left"), - ("top right", "to top right"), - ] - .map(|(position, direction)| { - format!("linear-gradient({direction}, {stops}) {position} / 50.1% 50.1% no-repeat") - }) - .join(", "), - _ => return None, - }) -} - -fn parse_transform(value: &Value) -> Option<[[f64; 3]; 2]> { - let rows = value.as_array()?; - let row = |index: usize| -> Option<[f64; 3]> { - let values = rows.get(index)?.as_array()?; - Some([ - values.first()?.as_f64()?, - values.get(1)?.as_f64()?, - values.get(2)?.as_f64()?, - ]) - }; - Some([row(0)?, row(1)?]) -} - -fn inverse_transform(matrix: [[f64; 3]; 2]) -> Option<[[f64; 3]; 2]> { - let [[a, b, c], [d, e, f]] = matrix; - let determinant = a * e - b * d; - (determinant.abs() > f64::EPSILON).then_some([ - [ - e / determinant, - -b / determinant, - (b * f - c * e) / determinant, - ], - [ - -d / determinant, - a / determinant, - (c * d - a * f) / determinant, - ], - ]) -} - -fn apply_transform(matrix: [[f64; 3]; 2], point: [f64; 2]) -> [f64; 2] { - [ - matrix[0][0] * point[0] + matrix[0][1] * point[1] + matrix[0][2], - matrix[1][0] * point[0] + matrix[1][1] * point[1] + matrix[1][2], - ] -} - -fn linear_geometry( - transform: [[f64; 3]; 2], - width: f64, - height: f64, - stops: &[(f64, String)], -) -> Option<(f64, Vec)> { - let inverse = inverse_transform(transform)?; - let normalized_start = apply_transform(inverse, [0.0, 0.5]); - let normalized_end = apply_transform(inverse, [1.0, 0.5]); - let start = [normalized_start[0] * width, normalized_start[1] * height]; - let end = [normalized_end[0] * width, normalized_end[1] * height]; - let mut figma_angle = (end[1] - start[1]).atan2(end[0] - start[0]).to_degrees() - 90.0; - figma_angle %= 360.0; - if figma_angle < 0.0 { - figma_angle += 360.0; - } - let angle = ((figma_angle - 180.0) % 360.0).round(); - let radians = angle.to_radians(); - let half = ((width * radians.sin()).abs() + (height * radians.cos()).abs()) / 2.0; - let center = [width / 2.0, height / 2.0]; - let css_radians = (angle - 90.0).to_radians(); - let css_start = [ - center[0] - half * css_radians.cos(), - center[1] - half * css_radians.sin(), - ]; - let css_end = [ - center[0] + half * css_radians.cos(), - center[1] + half * css_radians.sin(), - ]; - let figma_vector = [end[0] - start[0], end[1] - start[1]]; - let css_vector = [css_end[0] - css_start[0], css_end[1] - css_start[1]]; - let denominator = css_vector[0].powi(2) + css_vector[1].powi(2); - let positions = stops - .iter() - .map(|(position, _)| { - let point = [ - start[0] + figma_vector[0] * position, - start[1] + figma_vector[1] * position, - ]; - let relative = [point[0] - css_start[0], point[1] - css_start[1]]; - if denominator == 0.0 { - 0.0 - } else { - (relative[0] * css_vector[0] + relative[1] * css_vector[1]) / denominator * 100.0 - } - }) - .collect(); - Some((angle, positions)) -} - -fn color_from_paint(paint: &Value) -> Option { - let mut color = paint.get("color")?.clone(); - 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); - color - .as_object_mut()? - .insert("a".to_owned(), Value::from(alpha)); - color_from(&color) -} - -pub(super) fn blend_mode(mode: &str) -> Option { - match mode { - "PASS_THROUGH" | "NORMAL" => None, - "LINEAR_BURN" | "LINEAR_DODGE" => None, - "COLOR_BURN" => Some("color-burn".to_owned()), - "COLOR_DODGE" => Some("color-dodge".to_owned()), - "SOFT_LIGHT" => Some("soft-light".to_owned()), - "HARD_LIGHT" => Some("hard-light".to_owned()), - value => Some(value.to_ascii_lowercase()), - } -} - -fn bound_color_token( - view: &TypedNode<'_>, - variable_tokens: &std::collections::BTreeMap, -) -> Option { - let id = view.value("fills")?.as_array()?.iter().find_map(|paint| { - paint - .get("boundVariables")? - .get("color")? - .get("id")? - .as_str() - })?; - variable_tokens - .get(id) - .cloned() - .or_else(|| (id == "var1").then(|| "primaryColor".to_owned())) - .or_else(|| Some(id.split([':', '/']).next_back().unwrap_or(id).to_owned())) -} - -fn bound_paint_token( - paint: &Value, - variable_tokens: &std::collections::BTreeMap, -) -> Option { - let id = paint - .get("boundVariables")? - .get("color")? - .get("id")? - .as_str()?; - variable_tokens.get(id).cloned() -} - -/// Length tokens and the width the screen was drawn for, carried together -/// to every length the style pass writes. -#[derive(Clone, Copy)] -pub(super) struct Lengths<'a> { - tokens: &'a std::collections::BTreeMap, - viewport: Option, -} - -impl Lengths<'_> { - /// `$token` in place of a CSS length `literal`, when every binding the - /// literal stands for is a variable named for one length token and that - /// token comes to exactly the literal's value at the width the screen was - /// drawn for. `None` keeps the literal. - /// - /// Exactly, not after rounding: the literal is the drawn value rounded to - /// two decimals, and a token of `20.004px` compared that way passed for a - /// literal `20px` it does not render as. - fn token<'v>( - self, - bindings: impl IntoIterator>, - literal: f64, - ) -> Option { - let mut found: Option<&LengthToken> = None; - for binding in bindings { - let token = self.tokens.get(binding_id(binding?)?)?; - if found.is_some_and(|found| found.token != token.token) { - return None; - } - found = Some(token); - } - let token = found?; - let value = length_at(&token.value, self.viewport?)?; - (value.is_finite() && value == literal).then(|| format!("${}", token.token)) - } -} - -/// The number a CSS length literal says: `12px` is 12, a bare `0` is 0. -fn css_length(literal: &str) -> Option { - literal - .strip_suffix("px") - .unwrap_or(literal) - .parse::() - .ok() - .filter(|number| number.is_finite()) -} - -/// The variable a binding names. A text node's binding can be a list, one -/// per run of text; it names a variable when every run names the same one. -fn binding_id(binding: &Value) -> Option<&str> { - match binding { - Value::Array(bindings) => { - let first = binding_id(bindings.first()?)?; - bindings - .iter() - .all(|binding| binding_id(binding) == Some(first)) - .then_some(first) - } - binding => binding.get("id")?.as_str(), - } -} - -/// What a length token comes to at a viewport `width`: devup-ui writes slot -/// `i` of a responsive array from `min-width` 0, 480, 768, 992, 1280 and -/// 1600px, and a slot left `null` keeps the one before it. -fn length_at(value: &Value, width: f64) -> Option { - const BREAKPOINTS: [f64; 6] = [0.0, 480.0, 768.0, 992.0, 1280.0, 1600.0]; - let length = |value: &Value| value.as_str()?.strip_suffix("px")?.parse::().ok(); - match value { - Value::Array(slots) => { - let slot = BREAKPOINTS.iter().rposition(|min| *min <= width)?; - slots - .iter() - .take(slot + 1) - .rev() - .find(|value| !value.is_null()) - .and_then(length) - } - value => length(value), - } -} - -/// The width the screen holding `node` was drawn at: the outermost frame -/// collected above it, short of the Section, page or component set it sits -/// in. A length token is checked at that width, as a page shows it. -fn drawn_viewport(snapshot: &Snapshot, node: &RawNode) -> Option { - let mut screen = node; - for _ in 0..snapshot.nodes.len() { - let Some(parent) = screen - .typed_view() - .string("parentId") - .and_then(|id| snapshot.nodes.get(id)) - else { - break; - }; - if matches!( - parent.typed_view().node_type(), - "SECTION" | "PAGE" | "DOCUMENT" | "COMPONENT_SET" - ) { - break; - } - screen = parent; - } - screen.typed_view().number("width") -} - -/// Writes a length bound to a variable as the variable's `$token` where the -/// token reproduces it: padding, gap, a single corner radius and a font -/// size. A token that is responsive then follows the screen width as the -/// design's breakpoint modes do, where the drawn pixels would hold one -/// width's value at every width. Shadows are done where they are composed. -pub(super) fn tokenize_lengths( - snapshot: &Snapshot, - node: &RawNode, - props: &mut [Prop], - tokens: &std::collections::BTreeMap, - used_tokens: &mut BTreeSet, -) { - if tokens.is_empty() { - return; - } - let view = node.typed_view(); - let lengths = Lengths { - tokens, - viewport: drawn_viewport(snapshot, node), - }; - let grid = view.string("layoutMode") == Some("GRID"); - let single_run = view - .value("styledTextSegments") - .and_then(Value::as_array) - .filter(|runs| runs.len() == 1) - .and_then(|runs| runs.first()); - let binding = |field: &str| { - view.value("boundVariables") - .and_then(|bound| bound.get(field)) - .or_else(|| single_run?.get("boundVariables")?.get(field)) - }; - for (name, PropValue::String(value)) in props.iter_mut() { - let fields: &[&str] = match name.as_str() { - "p" => &["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"], - "px" => &["paddingLeft", "paddingRight"], - "py" => &["paddingTop", "paddingBottom"], - "pt" => &["paddingTop"], - "pr" => &["paddingRight"], - "pb" => &["paddingBottom"], - "pl" => &["paddingLeft"], - "gap" if grid => &["gridRowGap", "gridColumnGap"], - "gap" => &["itemSpacing"], - "rowGap" if grid => &["gridRowGap"], - "columnGap" if grid => &["gridColumnGap"], - "borderRadius" => &[ - "topLeftRadius", - "topRightRadius", - "bottomRightRadius", - "bottomLeftRadius", - ], - "fontSize" if view.node_type() == "TEXT" => &["fontSize"], - _ => continue, - }; - let Some(literal) = value.ends_with("px").then(|| css_length(value)).flatten() else { - continue; - }; - if let Some(token) = lengths.token(fields.iter().map(|field| binding(field)), literal) { - used_tokens.insert(token[1..].to_owned()); - *value = token; - } - } -} - -fn push_radius(view: &TypedNode<'_>, props: &mut Vec) { - if view.node_type() == "ELLIPSE" - && view - .value("arcData") - .and_then(|value| value.get("innerRadius")) - .and_then(Value::as_f64) - .is_none_or(|radius| radius == 0.0) - { - string_prop(props, "borderRadius", "50%"); - return; - } - if let Some(radius) = view.number("cornerRadius") - && radius != 0.0 - { - string_prop(props, "borderRadius", px(radius)); - return; - } - let radii = [ - view.number("topLeftRadius"), - view.number("topRightRadius"), - view.number("bottomRightRadius"), - view.number("bottomLeftRadius"), - ]; - if let [Some(a), Some(b), Some(c), Some(d)] = radii { - if a == 0.0 && b == 0.0 && c == 0.0 && d == 0.0 { - return; - } - let value = if a == b && b == c && c == d { - px(a) - } else if a == c && b == d { - format!("{} {}", px(a), px(b)) - } else if b == d { - format!("{} {} {}", px(a), px(b), px(c)) - } else { - [a, b, c, d].map(px).join(" ") - }; - string_prop(props, "borderRadius", value); - } -} - -/// An inside stroke cannot consume layout space when the padding is too small -/// to absorb a CSS border. Paint it inward with an outline instead. -pub(super) fn inside_stroke_uses_outline(view: &TypedNode<'_>) -> bool { - view.string("strokeAlign").unwrap_or("INSIDE") == "INSIDE" - && matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) - && view.number("strokeWeight").is_some_and(|weight| { - weight > 0.0 - && ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] - .iter() - .any(|field| view.number(field).is_some_and(|padding| padding < weight)) - }) -} - -/// A single straight outside edge is a translated, zero-blur box shadow. -/// Unlike a CSS border it does not reduce a fixed content box or grow a HUG -/// box. Rounded corners, dashes and multi-edge joins need a different paint -/// construction, so retain their existing emission rather than approximate it. -pub(crate) fn single_outside_stroke(view: &TypedNode<'_>) -> Option<(usize, f64)> { - if view.string("strokeAlign") != Some("OUTSIDE") - || !matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) - || view.number("strokeWeight").is_some() - || [ - "cornerRadius", - "topLeftRadius", - "topRightRadius", - "bottomRightRadius", - "bottomLeftRadius", - ] - .iter() - .any(|field| view.number(field).is_some_and(|radius| radius != 0.0)) - || view - .value("dashPattern") - .and_then(Value::as_array) - .is_some_and(|dash| !dash.is_empty()) - || !view - .value("strokes") - .and_then(Value::as_array) - .is_some_and(|paints| { - // Match push_strokes' selected paint, not a later paint that - // it will never reach after rejecting an unpaintable first one. - paints - .iter() - .find(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("type").and_then(Value::as_str) == Some("SOLID") - }) - .is_some_and(|paint| color_from_paint(paint).is_some()) - }) - { - return None; - } - let mut edge = None; - for (index, field) in [ - "strokeTopWeight", - "strokeRightWeight", - "strokeBottomWeight", - "strokeLeftWeight", - ] - .iter() - .enumerate() - { - let weight = view.number(field)?; - if !weight.is_finite() || weight < 0.0 { - return None; - } - if weight > 0.0 { - if edge.is_some() { - return None; - } - edge = Some((index, weight)); - } - } - edge -} - -fn push_strokes( - view: &TypedNode<'_>, - outside_edge: Option<(usize, f64)>, - props: &mut Vec, - used_tokens: &mut BTreeSet, - variable_tokens: &std::collections::BTreeMap, -) { - let Some(paint) = view - .value("strokes") - .and_then(Value::as_array) - .and_then(|strokes| { - strokes.iter().find(|paint| { - paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("type").and_then(Value::as_str) == Some("SOLID") - }) - }) - else { - return; - }; - let color = if let Some(token) = bound_paint_token(paint, variable_tokens) { - used_tokens.insert(token.clone()); - format!("${token}") - } else if let Some(color) = color_from_paint(paint) { - color - } else { - return; - }; - let style = if view - .value("dashPattern") - .and_then(Value::as_array) - .is_some_and(|value| !value.is_empty()) - { - "dashed" - } else { - "solid" - }; - let align = view.string("strokeAlign").unwrap_or("INSIDE"); - let explicit_weight = view.number("strokeWeight"); - if let Some((edge, weight)) = outside_edge { - let (x, y) = [(0.0, -weight), (weight, 0.0), (0.0, weight), (-weight, 0.0)][edge]; - string_prop( - props, - "boxShadow", - format!("{} {} 0 0 {color}", zero_or_px(x), zero_or_px(y)), - ); - return; - } - if explicit_weight.is_none() { - let sides = [ - ("strokeTopWeight", "borderTop"), - ("strokeRightWeight", "borderRight"), - ("strokeBottomWeight", "borderBottom"), - ("strokeLeftWeight", "borderLeft"), - ]; - if sides.iter().all(|(field, _)| view.number(field).is_some()) { - for (field, prop) in sides { - let weight = view.number(field).unwrap_or(0.0); - if weight != 0.0 { - string_prop(props, prop, format!("{style} {} {color}", px(weight))); - } - } - } - return; - } - let weight = explicit_weight.unwrap_or(0.0); - if view.node_type() == "LINE" { - string_prop(props, "outline", format!("{style} {} {color}", px(weight))); - let base = if view.string("layoutSizingHorizontal") == Some("FIXED") { - view.number("width") - .map(px) - .unwrap_or_else(|| "100%".to_owned()) - } else { - "100%".to_owned() - }; - string_prop( - props, - "maxW", - format!("calc({base} - {})", px(weight * 2.0)), - ); - if view - .number("rotation") - .is_none_or(|rotation| rotation.abs() <= 0.01) - { - string_prop( - props, - "transform", - format!("translate({}, {})", px(weight), px(-weight)), - ); - } - return; - } - if inside_stroke_uses_outline(view) { - string_prop(props, "outline", format!("{style} {} {color}", px(weight))); - string_prop(props, "outlineOffset", px(-weight)); - } else if align == "INSIDE" { - string_prop(props, "border", format!("{style} {} {color}", px(weight))); - } else { - string_prop(props, "outline", format!("{style} {} {color}", px(weight))); - if align == "CENTER" { - string_prop(props, "outlineOffset", px(-weight / 2.0)); - } - } -} - -fn push_effects( - view: &TypedNode<'_>, - component: &str, - props: &mut Vec, - used_tokens: &mut BTreeSet, - variable_tokens: &std::collections::BTreeMap, - lengths: Lengths<'_>, -) { - let Some(effects) = view.value("effects").and_then(Value::as_array) else { - return; - }; - let visible = effects - .iter() - .filter(|effect| effect.get("visible").and_then(Value::as_bool) != Some(false)) - .collect::>(); - let shadows = visible - .iter() - .filter(|effect| { - matches!( - effect.get("type").and_then(Value::as_str), - Some("DROP_SHADOW" | "INNER_SHADOW") - ) - }) - .filter_map(|effect| { - let offset = effect.get("offset")?; - let x = offset.get("x")?.as_f64()?; - let y = offset.get("y")?.as_f64()?; - let radius = effect.get("radius")?.as_f64()?; - let spread = effect.get("spread").and_then(Value::as_f64).unwrap_or(0.0); - // A shadow's colour can be bound to a variable, exactly as a fill - // or a stroke can, and then the token is what the design means: - // the landing page's cards are `$shadow`, one value the theme can - // move for dark mode. Written as the resolved `#87878740` they - // were a colour nothing could reach. - let color = if let Some(token) = bound_paint_token(effect, variable_tokens) { - used_tokens.insert(token.clone()); - format!("${token}") - } else { - color_from(effect.get("color")?)? - }; - let inset = if effect.get("type").and_then(Value::as_str) == Some("INNER_SHADOW") { - "inset " - } else { - "" - }; - let mut length = |field: &str, value: f64| { - let literal = zero_or_px(value); - match css_length(&literal).and_then(|number| { - lengths.token( - [effect.pointer(&format!("/boundVariables/{field}"))], - number, - ) - }) { - Some(token) => { - used_tokens.insert(token[1..].to_owned()); - token - } - None => literal, - } - }; - let (x, y, radius) = ( - length("offsetX", x), - length("offsetY", y), - length("radius", radius), - ); - Some(if component == "Text" { - format!("{inset}{x} {y} {radius} {color}") - } else { - format!( - "{inset}{x} {y} {radius} {} {color}", - length("spread", spread) - ) - }) - }) - .collect::>(); - if !shadows.is_empty() { - let mut composed = shadows.join(", "); - if component != "Text" - && single_outside_stroke(view).is_some() - && let Some((_, super::component::PropValue::String(stroke))) = - props.iter().find(|(name, _)| name == "boxShadow") - { - composed = format!("{stroke}, {composed}"); - } - string_prop( - props, - if component == "Text" { - "textShadow" - } else { - "boxShadow" - }, - composed, - ); - } - for effect in visible { - let radius = effect.get("radius").and_then(Value::as_f64).unwrap_or(0.0); - match effect.get("type").and_then(Value::as_str) { - Some("LAYER_BLUR") => string_prop(props, "filter", format!("blur({})", px(radius))), - Some("BACKGROUND_BLUR" | "GLASS") => { - string_prop(props, "backdropFilter", format!("blur({})", px(radius))) - } - Some("NOISE" | "TEXTURE") => { - string_prop(props, "filter", "contrast(100%) brightness(100%)") - } - _ => {} - } - } -} - -/// Shared literal effect projection for the public CSS recipe. The renderer -/// may replace these literals with bound theme tokens from its own resources. -pub(super) fn image_effect_props(node: &RawNode) -> BTreeMap { - let mut props = Vec::new(); - let mut used = BTreeSet::new(); - let tokens = BTreeMap::new(); - let lengths = BTreeMap::new(); - let view = node.typed_view(); - push_radius(&view, &mut props); - push_strokes(&view, None, &mut props, &mut used, &tokens); - push_effects( - &view, - "Box", - &mut props, - &mut used, - &tokens, - Lengths { - tokens: &lengths, - viewport: None, - }, - ); - if let Some(opacity) = view.number("opacity") - && opacity < 1.0 - { - string_prop(&mut props, "opacity", format_number(opacity)); - } - push_blend_mode(&view, &mut props); - props - .into_iter() - .map(|(key, PropValue::String(value))| (key, value)) - .collect() -} - -/// 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() - } else { - px(value) - } -} - -pub(super) fn first_solid_color(value: Option<&Value>) -> Option { - let paint = value?.as_array()?.iter().find(|paint| { - paint.get("type").and_then(Value::as_str) == Some("SOLID") - && paint.get("visible").and_then(Value::as_bool) != Some(false) - && paint.get("opacity").and_then(Value::as_f64) != Some(0.0) - })?; - color_from_paint(paint) -} - -fn color_from(color: &Value) -> Option { - let channel = |name: &str| -> Option { - Some((color.get(name)?.as_f64()?.clamp(0.0, 1.0) * 255.0).round() as u8) - }; - let alpha = color.get("a").and_then(Value::as_f64).unwrap_or(1.0); - let mut hex = format!( - "#{:02X}{:02X}{:02X}", - channel("r")?, - channel("g")?, - channel("b")? - ); - if alpha < 1.0 { - hex.push_str(&format!( - "{:02X}", - (alpha.clamp(0.0, 1.0) * 255.0).round() as u8 - )); - } - let chars = hex.as_bytes(); - if (hex.len() == 7 || hex.len() == 9) - && (1..hex.len()) - .step_by(2) - .all(|index| chars[index] == chars[index + 1]) - { - let mut short = String::from("#"); - for index in (1..hex.len()).step_by(2) { - short.push(chars[index] as char); - } - Some(short) - } else { - Some(hex) - } -} +// Source sections share this module to preserve privacy and test names. +include!("style/style_properties.rs"); +include!("style/image_fill_source.rs"); +include!("style/push_effects.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs b/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs new file mode 100644 index 00000000..158b9014 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/style/image_fill_source.rs @@ -0,0 +1,928 @@ + + +/// 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( + snapshot: &Snapshot, + node: &RawNode, + fill_index: usize, + per_node: bool, +) -> String { + let source = if fill_index == 0 { + asset_source(snapshot, node, "images", "png", per_node) + } else { + let stem = asset_stem(snapshot, node, per_node); + format!("/images/{stem}-{fill_index}.png") + }; + if source.contains(' ') { + format!("'{source}'") + } else { + source + } +} + +/// The file an asset node is drawn from: `/{folder}/{stem}.{extension}`. +/// +/// The plugin draws an instance from its main component and names the file +/// after that node, so every instance of a variant is `Property 1=search.svg`: +/// one file for the icon wherever it is used, but the same file for every +/// component set that has a `search` variant, each overwriting the last. The +/// layer name a designer gave the node is used instead, and where two assets +/// in the snapshot that are not the same thing would share it, each says what +/// it is: see `asset_stem`. +pub(crate) fn asset_source( + snapshot: &Snapshot, + node: &RawNode, + folder: &str, + extension: &str, + per_node: bool, +) -> String { + format!( + "/{folder}/{}.{extension}", + asset_stem(snapshot, node, per_node) + ) +} + +/// The path the generated code refers to for an asset node - `/icons/x.svg` +/// for a vector, `/images/x.png` for the first image fill - so a manifest +/// can say where the code expects each asset. `None` for a node the code +/// does not draw from a file. +pub fn asset_path(snapshot: &Snapshot, node_id: &str, per_node: bool) -> Option { + let node = snapshot.nodes.get(node_id)?; + let kind = asset_kind(snapshot, node)?; + let (folder, extension) = match kind { + AssetKind::Svg | AssetKind::SvgMask => ("icons", "svg"), + _ => ("images", "png"), + }; + Some(asset_source(snapshot, node, folder, extension, per_node)) +} + +/// Where the code draws one of a node's image fills from: `/images/x.png` +/// for the first fill and `/images/x-2.png` past it, the same name +/// `image_fill_source` writes into the code but without the quoting a CSS +/// `url()` puts around a name with a space in it. +/// +/// This answers for any node that carries the fill, where `asset_path` only +/// answers for a node the code draws entirely from a file. A section painted +/// over a photograph is a layout box holding children, not an asset - but the +/// photograph on it is still a file the code points at, and a caller has to +/// be told where. +pub fn image_fill_path( + snapshot: &Snapshot, + node_id: &str, + fill_index: usize, + per_node: bool, +) -> Option { + let node = snapshot.nodes.get(node_id)?; + let stem = asset_stem(snapshot, node, per_node); + Some(if fill_index == 0 { + format!("/images/{stem}.png") + } else { + format!("/images/{stem}-{fill_index}.png") + }) +} + +/// The file name an asset node gets, without folder or extension. +/// +/// The layer name, unless another asset in the snapshot has the same name +/// and is a different thing. Three cards each hold an `Icons` instance at a +/// different variant - chart, clock, lightning - and all three were +/// `/icons/Icons.svg`, one file overwriting the next, and every card drew the +/// chart. An instance whose name is shared then carries its variant, `Icons=chart`; +/// a node that is not an instance carries its id. Instances of one variant +/// share a name and a file, as the same icon at three widths should. +pub(crate) fn asset_stem(snapshot: &Snapshot, node: &RawNode, per_node: bool) -> String { + let view = node.typed_view(); + let name = view.name().unwrap_or("Asset"); + let identity = asset_identity(node); + let raster_instance = + view.node_type() == "INSTANCE" && asset_kind(snapshot, node) == Some(AssetKind::Png); + let shared = per_node + || snapshot.nodes.values().any(|other| { + other.id != node.id + && other.typed_view().name() == Some(name) + && (asset_identity(other) != identity + || raster_instance + || (fills(node).is_some_and(|fs| fs.iter().any(|p| p["type"] == "IMAGE")) + && fills(other) != fills(node))) + && asset_kind(snapshot, other).is_some() + }); + if !shared { + return name.to_owned(); + } + match identity { + // Raster instances can override the image, crop, size and effects while + // retaining the same component variant. Only verified exported bytes + // establish reuse; a shared variant name cannot establish identity. + Some(variant) if raster_instance => { + format!("{name}={variant}-{}", node.id.replace([':', ';'], "-")) + } + Some(variant) => format!("{name}={variant}"), + None => format!("{name}-{}", node.id.replace([':', ';'], "-")), + } +} + +/// What makes an instance the thing it is: its variant, as `chart` or +/// `lg,primary`. `None` for a node that is not an instance of a variant. +fn asset_identity(node: &RawNode) -> Option { + let view = node.typed_view(); + let properties = view.value("variantProperties")?.as_object()?; + let values = properties + .values() + .filter_map(Value::as_str) + .collect::>(); + (!values.is_empty()).then(|| values.join(",")) +} + +pub(super) fn paint_css( + snapshot: &Snapshot, + node: &RawNode, + paint: &Value, + fill_index: usize, + last: bool, + used_tokens: &mut BTreeSet, + style: StyleOptions<'_>, +) -> Option { + let StyleOptions { + variable_tokens, + asset_names_per_node: per_node, + .. + } = style; + let kind = paint.get("type")?.as_str()?; + match kind { + "SOLID" => { + let color = bound_paint_token(paint, variable_tokens) + .map(|token| { + used_tokens.insert(token.clone()); + format!("${token}") + }) + .or_else(|| color_from_paint(paint))?; + Some(if last { + color + } else { + format!("linear-gradient({color}, {color})") + }) + } + "GRADIENT_LINEAR" => gradient_css(node, paint, "linear", variable_tokens), + "GRADIENT_RADIAL" => gradient_css(node, paint, "radial", variable_tokens), + "GRADIENT_ANGULAR" => gradient_css(node, paint, "angular", variable_tokens), + "GRADIENT_DIAMOND" => gradient_css(node, paint, "diamond", variable_tokens), + "IMAGE" => { + let mapping = super::image::map_for( + snapshot, + node, + Some(fill_index), + style.asset_policies, + per_node, + ); + let source = mapping["assetPath"] + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| image_fill_source(snapshot, node, fill_index, per_node)); + let fit = mapping["css"]["background"] + .as_str() + .or_else(|| (paint["scaleMode"] == "TILE").then_some("repeat"))?; + Some(format!("url({source}) {fit}")) + } + "PATTERN" => { + let source_id = paint.get("sourceNodeId").and_then(Value::as_str)?; + let source = snapshot.nodes.get(source_id); + let name = source + .and_then(|node| node.typed_view().name()) + .unwrap_or("pattern"); + // 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)) + .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")) + .and_then(Value::as_f64) + .unwrap_or(0.0); + let y = spacing + .and_then(|value| value.get("y")) + .and_then(Value::as_f64) + .unwrap_or(0.0); + let horizontal = position_part( + paint + .get("horizontalAlignment") + .and_then(Value::as_str) + .unwrap_or("START"), + x, + ["left", "center", "right"], + ); + let vertical = position_part( + paint + .get("verticalAlignment") + .and_then(Value::as_str) + .unwrap_or("START"), + y, + ["top", "center", "bottom"], + ); + let position = [horizontal, vertical] + .into_iter() + .flatten() + .collect::>() + .join(" "); + Some(format!( + "url(/{folder}/{name}.{extension}){} repeat", + if position.is_empty() { + String::new() + } else { + format!(" {position}") + } + )) + } + _ => None, + } +} + +fn position_part(alignment: &str, spacing: f64, values: [&str; 3]) -> Option { + if alignment == "START" && spacing == 0.0 { + return None; + } + let value = match alignment { + "CENTER" => values[1], + "END" => values[2], + _ => values[0], + }; + Some(format!("{value} {}%", format_number(spacing * 100.0))) +} + +fn gradient_css( + node: &RawNode, + paint: &Value, + kind: &str, + variable_tokens: &std::collections::BTreeMap, +) -> Option { + let opacity = paint.get("opacity").and_then(Value::as_f64).unwrap_or(1.0); + let raw_stops = paint + .get("gradientStops")? + .as_array()? + .iter() + .filter_map(|stop| { + let mut color = stop.get("color")?.clone(); + let alpha = color.get("a").and_then(Value::as_f64).unwrap_or(1.0) * opacity; + color + .as_object_mut()? + .insert("a".to_owned(), Value::from(alpha)); + // A stop bound to a variable is the token — and where the stop or + // the paint is translucent, the token mixed with transparent by + // that much, as the plugin's `processGradientStopColor` writes it: + // a token names an opaque colour, and the alpha would be lost with + // it. The report section's backdrop is a 50% gradient between two + // tokens, `color-mix(in srgb, $primaryBg, transparent 50%)`. + let color = bound_paint_token(stop, variable_tokens) + .map(|token| { + if alpha < 1.0 { + format!( + "color-mix(in srgb, ${token}, transparent {}%)", + format_number((1.0 - alpha) * 100.0) + ) + } else { + format!("${token}") + } + }) + .or_else(|| color_from(&color))?; + Some((stop.get("position")?.as_f64()?, color)) + }) + .collect::>(); + let transform = paint + .get("gradientTransform") + .and_then(parse_transform) + .unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]); + let view = node.typed_view(); + let width = view.number("width").unwrap_or(1.0); + let height = view.number("height").unwrap_or(1.0); + let (prefix, positions) = match kind { + "linear" => { + let (angle, positions) = linear_geometry(transform, width, height, &raw_stops)?; + ( + format!("linear-gradient({}deg, ", format_number(angle)), + positions, + ) + } + "radial" => { + let inverse = inverse_transform(transform)?; + let center = apply_transform(inverse, [0.5, 0.5]); + let radius = apply_transform(inverse, [1.0, 1.0]); + ( + format!( + "radial-gradient({}% {}% at {}% {}%, ", + format_number((radius[0] - center[0]).abs() * 100.0), + format_number((radius[1] - center[1]).abs() * 100.0), + format_number(center[0] * 100.0), + format_number(center[1] * 100.0) + ), + raw_stops + .iter() + .map(|(position, _)| position * 100.0) + .collect(), + ) + } + "angular" => { + let inverse = inverse_transform(transform)?; + let center = apply_transform(inverse, [0.5, 0.5]); + let start = apply_transform(inverse, [1.0, 0.5]); + let mut angle = ((start[1] - center[1]) * height) + .atan2((start[0] - center[0]) * width) + .to_degrees() + + 90.0; + angle %= 360.0; + if angle < 0.0 { + angle += 360.0; + } + ( + format!( + "conic-gradient(from {}deg at {}% {}%, ", + format_number(angle), + format_number(center[0] * 100.0), + format_number(center[1] * 100.0) + ), + raw_stops + .iter() + .map(|(position, _)| position * 100.0) + .collect(), + ) + } + "diamond" => ( + String::new(), + raw_stops + .iter() + .map(|(position, _)| position * 50.0) + .collect(), + ), + _ => return None, + }; + let stops = raw_stops + .iter() + .zip(positions) + .map(|((_, color), position)| format!("{color} {}%", format_number(position))) + .collect::>() + .join(", "); + Some(match kind { + "linear" | "radial" | "angular" => format!("{prefix}{stops})"), + // A diamond is four linear gradients, one into each corner. The pairs + // are written as pairs rather than as `"corner|direction"` strings + // split back apart at runtime. Nothing outside this array ever reached + // that split — the design comes in through `raw_stops`, `transform` + // and `kind`, all of which are already `Option`-handled above — so the + // only way it could have failed was a typo in the four lines below, + // and it would have failed by killing the server rather than by + // drawing a wrong gradient. Said as tuples, the same four facts cannot + // be written malformed at all. + "diamond" => [ + ("bottom right", "to bottom right"), + ("bottom left", "to bottom left"), + ("top left", "to top left"), + ("top right", "to top right"), + ] + .map(|(position, direction)| { + format!("linear-gradient({direction}, {stops}) {position} / 50.1% 50.1% no-repeat") + }) + .join(", "), + _ => return None, + }) +} + +fn parse_transform(value: &Value) -> Option<[[f64; 3]; 2]> { + let rows = value.as_array()?; + let row = |index: usize| -> Option<[f64; 3]> { + let values = rows.get(index)?.as_array()?; + Some([ + values.first()?.as_f64()?, + values.get(1)?.as_f64()?, + values.get(2)?.as_f64()?, + ]) + }; + Some([row(0)?, row(1)?]) +} + +fn inverse_transform(matrix: [[f64; 3]; 2]) -> Option<[[f64; 3]; 2]> { + let [[a, b, c], [d, e, f]] = matrix; + let determinant = a * e - b * d; + (determinant.abs() > f64::EPSILON).then_some([ + [ + e / determinant, + -b / determinant, + (b * f - c * e) / determinant, + ], + [ + -d / determinant, + a / determinant, + (c * d - a * f) / determinant, + ], + ]) +} + +fn apply_transform(matrix: [[f64; 3]; 2], point: [f64; 2]) -> [f64; 2] { + [ + matrix[0][0] * point[0] + matrix[0][1] * point[1] + matrix[0][2], + matrix[1][0] * point[0] + matrix[1][1] * point[1] + matrix[1][2], + ] +} + +fn linear_geometry( + transform: [[f64; 3]; 2], + width: f64, + height: f64, + stops: &[(f64, String)], +) -> Option<(f64, Vec)> { + let inverse = inverse_transform(transform)?; + let normalized_start = apply_transform(inverse, [0.0, 0.5]); + let normalized_end = apply_transform(inverse, [1.0, 0.5]); + let start = [normalized_start[0] * width, normalized_start[1] * height]; + let end = [normalized_end[0] * width, normalized_end[1] * height]; + let mut figma_angle = (end[1] - start[1]).atan2(end[0] - start[0]).to_degrees() - 90.0; + figma_angle %= 360.0; + if figma_angle < 0.0 { + figma_angle += 360.0; + } + let angle = ((figma_angle - 180.0) % 360.0).round(); + let radians = angle.to_radians(); + let half = ((width * radians.sin()).abs() + (height * radians.cos()).abs()) / 2.0; + let center = [width / 2.0, height / 2.0]; + let css_radians = (angle - 90.0).to_radians(); + let css_start = [ + center[0] - half * css_radians.cos(), + center[1] - half * css_radians.sin(), + ]; + let css_end = [ + center[0] + half * css_radians.cos(), + center[1] + half * css_radians.sin(), + ]; + let figma_vector = [end[0] - start[0], end[1] - start[1]]; + let css_vector = [css_end[0] - css_start[0], css_end[1] - css_start[1]]; + let denominator = css_vector[0].powi(2) + css_vector[1].powi(2); + let positions = stops + .iter() + .map(|(position, _)| { + let point = [ + start[0] + figma_vector[0] * position, + start[1] + figma_vector[1] * position, + ]; + let relative = [point[0] - css_start[0], point[1] - css_start[1]]; + if denominator == 0.0 { + 0.0 + } else { + (relative[0] * css_vector[0] + relative[1] * css_vector[1]) / denominator * 100.0 + } + }) + .collect(); + Some((angle, positions)) +} + +fn color_from_paint(paint: &Value) -> Option { + let mut color = paint.get("color")?.clone(); + 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); + color + .as_object_mut()? + .insert("a".to_owned(), Value::from(alpha)); + color_from(&color) +} + +pub(super) fn blend_mode(mode: &str) -> Option { + match mode { + "PASS_THROUGH" | "NORMAL" => None, + "LINEAR_BURN" | "LINEAR_DODGE" => None, + "COLOR_BURN" => Some("color-burn".to_owned()), + "COLOR_DODGE" => Some("color-dodge".to_owned()), + "SOFT_LIGHT" => Some("soft-light".to_owned()), + "HARD_LIGHT" => Some("hard-light".to_owned()), + value => Some(value.to_ascii_lowercase()), + } +} + +fn bound_color_token( + view: &TypedNode<'_>, + variable_tokens: &std::collections::BTreeMap, +) -> Option { + let id = view.value("fills")?.as_array()?.iter().find_map(|paint| { + paint + .get("boundVariables")? + .get("color")? + .get("id")? + .as_str() + })?; + variable_tokens + .get(id) + .cloned() + .or_else(|| (id == "var1").then(|| "primaryColor".to_owned())) + .or_else(|| Some(id.split([':', '/']).next_back().unwrap_or(id).to_owned())) +} + +fn bound_paint_token( + paint: &Value, + variable_tokens: &std::collections::BTreeMap, +) -> Option { + let id = paint + .get("boundVariables")? + .get("color")? + .get("id")? + .as_str()?; + variable_tokens.get(id).cloned() +} + +/// Length tokens and the width the screen was drawn for, carried together +/// to every length the style pass writes. +#[derive(Clone, Copy)] +pub(super) struct Lengths<'a> { + tokens: &'a std::collections::BTreeMap, + viewport: Option, +} + +impl Lengths<'_> { + /// `$token` in place of a CSS length `literal`, when every binding the + /// literal stands for is a variable named for one length token and that + /// token comes to exactly the literal's value at the width the screen was + /// drawn for. `None` keeps the literal. + /// + /// Exactly, not after rounding: the literal is the drawn value rounded to + /// two decimals, and a token of `20.004px` compared that way passed for a + /// literal `20px` it does not render as. + fn token<'v>( + self, + bindings: impl IntoIterator>, + literal: f64, + ) -> Option { + let mut found: Option<&LengthToken> = None; + for binding in bindings { + let token = self.tokens.get(binding_id(binding?)?)?; + if found.is_some_and(|found| found.token != token.token) { + return None; + } + found = Some(token); + } + let token = found?; + let value = length_at(&token.value, self.viewport?)?; + (value.is_finite() && value == literal).then(|| format!("${}", token.token)) + } +} + +/// The number a CSS length literal says: `12px` is 12, a bare `0` is 0. +fn css_length(literal: &str) -> Option { + literal + .strip_suffix("px") + .unwrap_or(literal) + .parse::() + .ok() + .filter(|number| number.is_finite()) +} + +/// The variable a binding names. A text node's binding can be a list, one +/// per run of text; it names a variable when every run names the same one. +fn binding_id(binding: &Value) -> Option<&str> { + match binding { + Value::Array(bindings) => { + let first = binding_id(bindings.first()?)?; + bindings + .iter() + .all(|binding| binding_id(binding) == Some(first)) + .then_some(first) + } + binding => binding.get("id")?.as_str(), + } +} + +/// What a length token comes to at a viewport `width`: devup-ui writes slot +/// `i` of a responsive array from `min-width` 0, 480, 768, 992, 1280 and +/// 1600px, and a slot left `null` keeps the one before it. +fn length_at(value: &Value, width: f64) -> Option { + const BREAKPOINTS: [f64; 6] = [0.0, 480.0, 768.0, 992.0, 1280.0, 1600.0]; + let length = |value: &Value| value.as_str()?.strip_suffix("px")?.parse::().ok(); + match value { + Value::Array(slots) => { + let slot = BREAKPOINTS.iter().rposition(|min| *min <= width)?; + slots + .iter() + .take(slot + 1) + .rev() + .find(|value| !value.is_null()) + .and_then(length) + } + value => length(value), + } +} + +/// The width the screen holding `node` was drawn at: the outermost frame +/// collected above it, short of the Section, page or component set it sits +/// in. A length token is checked at that width, as a page shows it. +fn drawn_viewport(snapshot: &Snapshot, node: &RawNode) -> Option { + let mut screen = node; + for _ in 0..snapshot.nodes.len() { + let Some(parent) = screen + .typed_view() + .string("parentId") + .and_then(|id| snapshot.nodes.get(id)) + else { + break; + }; + if matches!( + parent.typed_view().node_type(), + "SECTION" | "PAGE" | "DOCUMENT" | "COMPONENT_SET" + ) { + break; + } + screen = parent; + } + screen.typed_view().number("width") +} + +/// Writes a length bound to a variable as the variable's `$token` where the +/// token reproduces it: padding, gap, a single corner radius and a font +/// size. A token that is responsive then follows the screen width as the +/// design's breakpoint modes do, where the drawn pixels would hold one +/// width's value at every width. Shadows are done where they are composed. +pub(super) fn tokenize_lengths( + snapshot: &Snapshot, + node: &RawNode, + props: &mut [Prop], + tokens: &std::collections::BTreeMap, + used_tokens: &mut BTreeSet, +) { + if tokens.is_empty() { + return; + } + let view = node.typed_view(); + let lengths = Lengths { + tokens, + viewport: drawn_viewport(snapshot, node), + }; + let grid = view.string("layoutMode") == Some("GRID"); + let single_run = view + .value("styledTextSegments") + .and_then(Value::as_array) + .filter(|runs| runs.len() == 1) + .and_then(|runs| runs.first()); + let binding = |field: &str| { + view.value("boundVariables") + .and_then(|bound| bound.get(field)) + .or_else(|| single_run?.get("boundVariables")?.get(field)) + }; + for (name, PropValue::String(value)) in props.iter_mut() { + let fields: &[&str] = match name.as_str() { + "p" => &["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"], + "px" => &["paddingLeft", "paddingRight"], + "py" => &["paddingTop", "paddingBottom"], + "pt" => &["paddingTop"], + "pr" => &["paddingRight"], + "pb" => &["paddingBottom"], + "pl" => &["paddingLeft"], + "gap" if grid => &["gridRowGap", "gridColumnGap"], + "gap" => &["itemSpacing"], + "rowGap" if grid => &["gridRowGap"], + "columnGap" if grid => &["gridColumnGap"], + "borderRadius" => &[ + "topLeftRadius", + "topRightRadius", + "bottomRightRadius", + "bottomLeftRadius", + ], + "fontSize" if view.node_type() == "TEXT" => &["fontSize"], + _ => continue, + }; + let Some(literal) = value.ends_with("px").then(|| css_length(value)).flatten() else { + continue; + }; + if let Some(token) = lengths.token(fields.iter().map(|field| binding(field)), literal) { + used_tokens.insert(token[1..].to_owned()); + *value = token; + } + } +} + +fn push_radius(view: &TypedNode<'_>, props: &mut Vec) { + if view.node_type() == "ELLIPSE" + && view + .value("arcData") + .and_then(|value| value.get("innerRadius")) + .and_then(Value::as_f64) + .is_none_or(|radius| radius == 0.0) + { + string_prop(props, "borderRadius", "50%"); + return; + } + if let Some(radius) = view.number("cornerRadius") + && radius != 0.0 + { + string_prop(props, "borderRadius", px(radius)); + return; + } + let radii = [ + view.number("topLeftRadius"), + view.number("topRightRadius"), + view.number("bottomRightRadius"), + view.number("bottomLeftRadius"), + ]; + if let [Some(a), Some(b), Some(c), Some(d)] = radii { + if a == 0.0 && b == 0.0 && c == 0.0 && d == 0.0 { + return; + } + let value = if a == b && b == c && c == d { + px(a) + } else if a == c && b == d { + format!("{} {}", px(a), px(b)) + } else if b == d { + format!("{} {} {}", px(a), px(b), px(c)) + } else { + [a, b, c, d].map(px).join(" ") + }; + string_prop(props, "borderRadius", value); + } +} + +/// An inside stroke cannot consume layout space when the padding is too small +/// to absorb a CSS border. Paint it inward with an outline instead. +pub(super) fn inside_stroke_uses_outline(view: &TypedNode<'_>) -> bool { + view.string("strokeAlign").unwrap_or("INSIDE") == "INSIDE" + && matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) + && view.number("strokeWeight").is_some_and(|weight| { + weight > 0.0 + && ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] + .iter() + .any(|field| view.number(field).is_some_and(|padding| padding < weight)) + }) +} + +/// A single straight outside edge is a translated, zero-blur box shadow. +/// Unlike a CSS border it does not reduce a fixed content box or grow a HUG +/// box. Rounded corners, dashes and multi-edge joins need a different paint +/// construction, so retain their existing emission rather than approximate it. +pub(crate) fn single_outside_stroke(view: &TypedNode<'_>) -> Option<(usize, f64)> { + if view.string("strokeAlign") != Some("OUTSIDE") + || !matches!(view.string("layoutMode"), Some("HORIZONTAL" | "VERTICAL")) + || view.number("strokeWeight").is_some() + || [ + "cornerRadius", + "topLeftRadius", + "topRightRadius", + "bottomRightRadius", + "bottomLeftRadius", + ] + .iter() + .any(|field| view.number(field).is_some_and(|radius| radius != 0.0)) + || view + .value("dashPattern") + .and_then(Value::as_array) + .is_some_and(|dash| !dash.is_empty()) + || !view + .value("strokes") + .and_then(Value::as_array) + .is_some_and(|paints| { + // Match push_strokes' selected paint, not a later paint that + // it will never reach after rejecting an unpaintable first one. + paints + .iter() + .find(|paint| { + paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("type").and_then(Value::as_str) == Some("SOLID") + }) + .is_some_and(|paint| color_from_paint(paint).is_some()) + }) + { + return None; + } + let mut edge = None; + for (index, field) in [ + "strokeTopWeight", + "strokeRightWeight", + "strokeBottomWeight", + "strokeLeftWeight", + ] + .iter() + .enumerate() + { + let weight = view.number(field)?; + if !weight.is_finite() || weight < 0.0 { + return None; + } + if weight > 0.0 { + if edge.is_some() { + return None; + } + edge = Some((index, weight)); + } + } + edge +} + +fn push_strokes( + view: &TypedNode<'_>, + outside_edge: Option<(usize, f64)>, + props: &mut Vec, + used_tokens: &mut BTreeSet, + variable_tokens: &std::collections::BTreeMap, +) { + let Some(paint) = view + .value("strokes") + .and_then(Value::as_array) + .and_then(|strokes| { + strokes.iter().find(|paint| { + paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("type").and_then(Value::as_str) == Some("SOLID") + }) + }) + else { + return; + }; + let color = if let Some(token) = bound_paint_token(paint, variable_tokens) { + used_tokens.insert(token.clone()); + format!("${token}") + } else if let Some(color) = color_from_paint(paint) { + color + } else { + return; + }; + let style = if view + .value("dashPattern") + .and_then(Value::as_array) + .is_some_and(|value| !value.is_empty()) + { + "dashed" + } else { + "solid" + }; + let align = view.string("strokeAlign").unwrap_or("INSIDE"); + let explicit_weight = view.number("strokeWeight"); + if let Some((edge, weight)) = outside_edge { + let (x, y) = [(0.0, -weight), (weight, 0.0), (0.0, weight), (-weight, 0.0)][edge]; + string_prop( + props, + "boxShadow", + format!("{} {} 0 0 {color}", zero_or_px(x), zero_or_px(y)), + ); + return; + } + if explicit_weight.is_none() { + let sides = [ + ("strokeTopWeight", "borderTop"), + ("strokeRightWeight", "borderRight"), + ("strokeBottomWeight", "borderBottom"), + ("strokeLeftWeight", "borderLeft"), + ]; + if sides.iter().all(|(field, _)| view.number(field).is_some()) { + for (field, prop) in sides { + let weight = view.number(field).unwrap_or(0.0); + if weight != 0.0 { + string_prop(props, prop, format!("{style} {} {color}", px(weight))); + } + } + } + return; + } + let weight = explicit_weight.unwrap_or(0.0); + if view.node_type() == "LINE" { + string_prop(props, "outline", format!("{style} {} {color}", px(weight))); + let base = if view.string("layoutSizingHorizontal") == Some("FIXED") { + view.number("width") + .map(px) + .unwrap_or_else(|| "100%".to_owned()) + } else { + "100%".to_owned() + }; + string_prop( + props, + "maxW", + format!("calc({base} - {})", px(weight * 2.0)), + ); + if view + .number("rotation") + .is_none_or(|rotation| rotation.abs() <= 0.01) + { + string_prop( + props, + "transform", + format!("translate({}, {})", px(weight), px(-weight)), + ); + } + return; + } + if inside_stroke_uses_outline(view) { + string_prop(props, "outline", format!("{style} {} {color}", px(weight))); + string_prop(props, "outlineOffset", px(-weight)); + } else if align == "INSIDE" { + string_prop(props, "border", format!("{style} {} {color}", px(weight))); + } else { + string_prop(props, "outline", format!("{style} {} {color}", px(weight))); + if align == "CENTER" { + string_prop(props, "outlineOffset", px(-weight / 2.0)); + } + } +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/codegen/style/push_effects.rs b/crates/devup-mcp-devup-ui/src/codegen/style/push_effects.rs new file mode 100644 index 00000000..7fa80824 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/style/push_effects.rs @@ -0,0 +1,269 @@ + + +fn push_effects( + view: &TypedNode<'_>, + component: &str, + props: &mut Vec, + used_tokens: &mut BTreeSet, + variable_tokens: &std::collections::BTreeMap, + lengths: Lengths<'_>, +) { + let Some(effects) = view.value("effects").and_then(Value::as_array) else { + return; + }; + let visible = effects + .iter() + .filter(|effect| effect.get("visible").and_then(Value::as_bool) != Some(false)) + .collect::>(); + let shadows = visible + .iter() + .filter(|effect| { + matches!( + effect.get("type").and_then(Value::as_str), + Some("DROP_SHADOW" | "INNER_SHADOW") + ) + }) + .filter_map(|effect| { + let offset = effect.get("offset")?; + let x = offset.get("x")?.as_f64()?; + let y = offset.get("y")?.as_f64()?; + let radius = effect.get("radius")?.as_f64()?; + let spread = effect.get("spread").and_then(Value::as_f64).unwrap_or(0.0); + // A shadow's colour can be bound to a variable, exactly as a fill + // or a stroke can, and then the token is what the design means: + // the landing page's cards are `$shadow`, one value the theme can + // move for dark mode. Written as the resolved `#87878740` they + // were a colour nothing could reach. + let color = if let Some(token) = bound_paint_token(effect, variable_tokens) { + used_tokens.insert(token.clone()); + format!("${token}") + } else { + color_from(effect.get("color")?)? + }; + let inset = if effect.get("type").and_then(Value::as_str) == Some("INNER_SHADOW") { + "inset " + } else { + "" + }; + let mut length = |field: &str, value: f64| { + let literal = zero_or_px(value); + match css_length(&literal).and_then(|number| { + lengths.token( + [effect.pointer(&format!("/boundVariables/{field}"))], + number, + ) + }) { + Some(token) => { + used_tokens.insert(token[1..].to_owned()); + token + } + None => literal, + } + }; + let (x, y, radius) = ( + length("offsetX", x), + length("offsetY", y), + length("radius", radius), + ); + Some(if component == "Text" { + format!("{inset}{x} {y} {radius} {color}") + } else { + format!( + "{inset}{x} {y} {radius} {} {color}", + length("spread", spread) + ) + }) + }) + .collect::>(); + if !shadows.is_empty() { + let mut composed = shadows.join(", "); + if component != "Text" + && single_outside_stroke(view).is_some() + && let Some((_, super::component::PropValue::String(stroke))) = + props.iter().find(|(name, _)| name == "boxShadow") + { + composed = format!("{stroke}, {composed}"); + } + string_prop( + props, + if component == "Text" { + "textShadow" + } else { + "boxShadow" + }, + composed, + ); + } + for effect in visible { + let radius = effect.get("radius").and_then(Value::as_f64).unwrap_or(0.0); + match effect.get("type").and_then(Value::as_str) { + Some("LAYER_BLUR") => string_prop(props, "filter", format!("blur({})", px(radius))), + Some("BACKGROUND_BLUR" | "GLASS") => { + string_prop(props, "backdropFilter", format!("blur({})", px(radius))) + } + Some("NOISE" | "TEXTURE") => { + string_prop(props, "filter", "contrast(100%) brightness(100%)") + } + _ => {} + } + } +} + +/// Shared literal effect projection for the public CSS recipe. The renderer +/// may replace these literals with bound theme tokens from its own resources. +pub(super) fn image_effect_props(node: &RawNode) -> BTreeMap { + let mut props = Vec::new(); + let mut used = BTreeSet::new(); + let tokens = BTreeMap::new(); + let lengths = BTreeMap::new(); + let view = node.typed_view(); + push_radius(&view, &mut props); + push_strokes(&view, None, &mut props, &mut used, &tokens); + push_effects( + &view, + "Box", + &mut props, + &mut used, + &tokens, + Lengths { + tokens: &lengths, + viewport: None, + }, + ); + if let Some(opacity) = view.number("opacity") + && opacity < 1.0 + { + string_prop(&mut props, "opacity", format_number(opacity)); + } + push_blend_mode(&view, &mut props); + props + .into_iter() + .map(|(key, PropValue::String(value))| (key, value)) + .collect() +} + +/// 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() + } else { + px(value) + } +} + +pub(super) fn first_solid_color(value: Option<&Value>) -> Option { + let paint = value?.as_array()?.iter().find(|paint| { + paint.get("type").and_then(Value::as_str) == Some("SOLID") + && paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("opacity").and_then(Value::as_f64) != Some(0.0) + })?; + color_from_paint(paint) +} + +fn color_from(color: &Value) -> Option { + let channel = |name: &str| -> Option { + Some((color.get(name)?.as_f64()?.clamp(0.0, 1.0) * 255.0).round() as u8) + }; + let alpha = color.get("a").and_then(Value::as_f64).unwrap_or(1.0); + let mut hex = format!( + "#{:02X}{:02X}{:02X}", + channel("r")?, + channel("g")?, + channel("b")? + ); + if alpha < 1.0 { + hex.push_str(&format!( + "{:02X}", + (alpha.clamp(0.0, 1.0) * 255.0).round() as u8 + )); + } + let chars = hex.as_bytes(); + if (hex.len() == 7 || hex.len() == 9) + && (1..hex.len()) + .step_by(2) + .all(|index| chars[index] == chars[index + 1]) + { + let mut short = String::from("#"); + for index in (1..hex.len()).step_by(2) { + short.push(chars[index] as char); + } + Some(short) + } else { + Some(hex) + } +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs b/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs new file mode 100644 index 00000000..b71d0715 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/style/style_properties.rs @@ -0,0 +1,972 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use devup_mcp_figma::{RawNode, Snapshot, TypedNode}; +use serde_json::Value; + +use super::{ + component::{LengthToken, Prop, PropValue}, + layout::{format_number, px, string_prop}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AssetKind { + Svg, + SvgMask, + Png, +} + +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") + || 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" | "BOOLEAN_OPERATION" + ) { + return Some(svg_asset_kind(snapshot, node, nested)); + } + + 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(svg_asset_kind(snapshot, node, nested)); + } + + 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; + } + + return match snapshot + .nodes + .get(child_ids[0]) + .and_then(|child| asset_kind_nested(snapshot, child, true)) + { + Some(AssetKind::Png) => Some(AssetKind::Png), + Some(AssetKind::Svg | AssetKind::SvgMask) => { + Some(svg_asset_kind(snapshot, node, nested)) + } + 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); + } + } + + visible_children + .into_iter() + .all(|child| { + matches!( + asset_kind_nested(snapshot, child, true), + Some(AssetKind::Svg | AssetKind::SvgMask) + ) + }) + .then(|| svg_asset_kind(snapshot, node, nested)) +} + +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 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, nested)); + } + + return Some(svg_asset_kind(snapshot, node, nested)); + } + + (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, nested)) +} + +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, nested: bool) -> AssetKind { + if matches!( + same_color(snapshot, node, nested, None), + SameColor::Color(_) + ) { + AssetKind::SvgMask + } else { + AssetKind::Svg + } +} + +/// What an asset is painted in, if it is one thing. +/// +/// This is the `sameColor` half of the plugin's `computeAssetAnalysis`, +/// which decides whether an icon is drawn as an `` or as a Box masked +/// to its shape and filled with one colour. `Null` is a subtree that settled +/// on nothing, `False` one whose paints disagree, and only a `Color` makes a +/// mask. The two non-answers are not the same: a `Null` child leaves a +/// running colour alone, a `False` one spoils it. +/// +/// A paint that is not one flat colour (a gradient, an image, a solid with +/// no readable colour) is `False`, not `Null`. A mask keeps only the shape +/// and fills it with one colour, so letting such a paint stay neutral merged +/// a gradient face and a solid outline into one solid blob. +#[derive(Debug, Clone, PartialEq, Eq)] +enum SameColor { + Null, + False, + Color(String), +} + +/// The plugin's `mergeSameColor`, except that a `Null` (a subtree with no +/// paint) is neutral wherever it falls. Taken literally, a `Null` after the +/// first colour spoiled it while one before was skipped, so the order of the +/// layers decided whether an icon became a mask. +fn merge_same_color(current: SameColor, next: SameColor) -> SameColor { + match (current, next) { + (_, SameColor::False) => SameColor::False, + (SameColor::Null, next) => next, + (current, SameColor::Null) => current, + (current, next) if current == next => current, + _ => SameColor::False, + } +} + +/// The plugin's `analyzeOwnSameColor`: the node's own fills and strokes. +enum OwnColor { + /// No visible paint at all. + None, + /// Two flat colours that differ, or a paint that is not a flat colour. + False, + Color(String), +} + +impl OwnColor { + fn same_color(self) -> SameColor { + match self { + Self::None => SameColor::Null, + Self::False => SameColor::False, + Self::Color(color) => SameColor::Color(color), + } + } +} + +fn own_same_color( + node: &RawNode, + variable_tokens: Option<&std::collections::BTreeMap>, +) -> OwnColor { + let view = node.typed_view(); + let mut target: Option = None; + for field in ["fills", "strokes"] { + let Some(paints) = view.value(field).and_then(Value::as_array) else { + continue; + }; + for paint in paints { + if paint.get("visible").and_then(Value::as_bool) != Some(true) { + continue; + } + if paint.get("type").and_then(Value::as_str) != Some("SOLID") { + return OwnColor::False; + } + let Some(color) = paint_string(paint, variable_tokens) else { + return OwnColor::False; + }; + match &target { + None => target = Some(color), + Some(current) if *current != color => return OwnColor::False, + Some(_) => {} + } + } + } + target.map_or(OwnColor::None, OwnColor::Color) +} + +/// A solid paint as the plugin's `solidToString` spells it: the variable it +/// is bound to as `$token`, or else the colour. Two paints are the same +/// colour to the plugin only when these agree, so a paint bound to a +/// variable and a raw paint of the same hex are *not* the same. +/// +/// Without a token map the variable id stands in for its name; that keeps +/// the comparison right when only the shape is being decided and the names +/// are not to hand. +pub(super) fn paint_string( + paint: &Value, + variable_tokens: Option<&std::collections::BTreeMap>, +) -> Option { + if let Some(id) = paint + .get("boundVariables") + .and_then(|bound| bound.get("color")) + .and_then(|color| color.get("id")) + .and_then(Value::as_str) + { + match variable_tokens { + None => return Some(format!("${id}")), + Some(tokens) => { + if let Some(token) = tokens.get(id) { + return Some(format!("${token}")); + } + } + } + } + if paint.get("opacity").and_then(Value::as_f64) == Some(0.0) { + return Some("transparent".to_owned()); + } + color_from_paint(paint) +} + +fn same_color( + snapshot: &Snapshot, + node: &RawNode, + nested: bool, + variable_tokens: Option<&std::collections::BTreeMap>, +) -> SameColor { + let view = node.typed_view(); + let own = || own_same_color(node, variable_tokens).same_color(); + if matches!(view.node_type(), "TEXT" | "COMPONENT_SET") + || view + .value("inferredAutoLayout") + .and_then(|layout| layout.get("layoutMode")) + .and_then(Value::as_str) + == Some("GRID") + || 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 SameColor::Null; + } + // Boolean operands define geometry; the result's own paint colors it. + if matches!( + view.node_type(), + "VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION" + ) { + return own(); + } + 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 own(); + } + + let child_ids = view.child_ids().collect::>(); + if child_ids.is_empty() { + 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 SameColor::Null; + } + if view.bool("isAsset") == Some(true) { + let Some(node_fills) = node_fills else { + return SameColor::Null; + }; + if node_fills.iter().any(|fill| { + is_visible_fill(fill) + && fill_type(fill) == Some("IMAGE") + && fill.get("scaleMode").and_then(Value::as_str) != Some("TILE") + }) { + // A picture inside an icon has colours a mask cannot keep. + return SameColor::False; + } + if node_fills.iter().all(|fill| { + fill.get("visible").and_then(Value::as_bool) == Some(true) + && fill_type(fill) == Some("SOLID") + }) { + return if nested { own() } else { SameColor::Null }; + } + return own(); + } + if nested + && node_fills.is_some_and(|fills| { + !fills.iter().any(|fill| { + is_visible_fill(fill) + && matches!(fill_type(fill), Some("IMAGE" | "VIDEO" | "PATTERN")) + }) + }) + { + return own(); + } + return SameColor::Null; + } + + 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 SameColor::Null; + } + return snapshot + .nodes + .get(child_ids[0]) + .map_or(SameColor::Null, |child| { + same_color(snapshot, child, true, variable_tokens) + }); + } + + let mut same = match own_same_color(node, variable_tokens) { + OwnColor::False => return SameColor::False, + own => own.same_color(), + }; + for child in child_ids + .into_iter() + .filter_map(|id| snapshot.nodes.get(id)) + .filter(|child| child.typed_view().bool("visible") != Some(false)) + { + same = merge_same_color(same, same_color(snapshot, child, true, variable_tokens)); + } + same +} + +/// What the style pass needs beyond the node itself: which variables carry a +/// token name, and how assets are to be named. Carried together so that +/// adding to it does not lengthen every signature it passes through. +#[derive(Clone, Copy)] +pub(super) struct StyleOptions<'a> { + pub reserve_image_content: bool, + pub asset_policies: &'a BTreeMap, + pub variable_tokens: &'a std::collections::BTreeMap, + pub length_tokens: &'a std::collections::BTreeMap, + pub asset_names_per_node: bool, + pub width_policy: Option<&'a super::width::WidthPolicy>, +} + +// Record the route at the property writer; available geometry alone is not +// evidence that a boundary override actually ran. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum StyleDerivation { + SvgMaskPolicy, + InFlowExportBoundary, + ImagePaintScale, + NativeRaster, + WidthPolicyLine, +} + +impl StyleDerivation { + pub(super) fn path(self) -> &'static str { + match self { + Self::SvgMaskPolicy => "svg-mask-policy", + Self::InFlowExportBoundary => "in-flow-export-boundary", + Self::ImagePaintScale => "image-paint-scale", + Self::NativeRaster => "native-raster-boundary", + Self::WidthPolicyLine => "width-policy-straight-line", + } + } + + pub(super) fn description(self) -> (Vec<&'static str>, &'static str) { + match self { + Self::SvgMaskPolicy => ( + vec![ + "fills", + "childrenIds", + "isAsset", + "absoluteRenderBounds", + "absoluteBoundingBox", + ], + "SVG mask projection policy from asset_kind/same_color: maskRepeat=no-repeat, maskSize=contain, maskPos=center unless export boundary derivation overrides them.", + ), + Self::InFlowExportBoundary => ( + vec![ + "absoluteRenderBounds", + "absoluteBoundingBox", + "layoutPositioning", + "parentId", + "fills", + ], + include_str!("../../text/style-description-5ad1a929.md"), + ), + Self::NativeRaster => ( + vec!["isAsset", "fills", "absoluteRenderBounds"], + include_str!("../../text/style-description-49ef22f0.md"), + ), + Self::ImagePaintScale => ( + vec!["isAsset", "fills"], + "push_object_fit: first visible IMAGE paint scaleMode FIT -> contain; CROP -> cover.", + ), + Self::WidthPolicyLine => ( + vec![ + "absoluteBoundingBox", + "strokes", + "strokeWeight", + "rotation", + "constraints", + ], + include_str!("../../text/style-description-b7e8995c.md"), + ), + } + } +} + +#[derive(Default)] +pub(super) struct StyleDerivations(BTreeMap); + +impl StyleDerivations { + fn push( + &mut self, + props: &mut Vec, + name: &str, + value: impl Into, + route: StyleDerivation, + ) { + let value = value.into(); + string_prop(props, name, value.clone()); + // Last writer wins for both the value and its explanation. + self.0.insert(name.to_owned(), (value, route)); + } + + pub(super) fn matching(&self, name: &str, value: &str) -> Option { + let (written, route) = self.0.get(name)?; + (written == value).then_some(*route) + } +} + +pub(super) fn push_style_props( + snapshot: &Snapshot, + node: &RawNode, + component: &str, + asset: Option, + props: &mut Vec, + used_tokens: &mut BTreeSet, + style: StyleOptions<'_>, +) -> StyleDerivations { + let mut derivations = StyleDerivations::default(); + let StyleOptions { + variable_tokens, + length_tokens, + asset_names_per_node: per_node, + width_policy, + asset_policies, + reserve_image_content, + } = style; + let view = node.typed_view(); + let lengths = Lengths { + tokens: length_tokens, + viewport: drawn_viewport(snapshot, node), + }; + if view.bool("visible") == Some(false) { + string_prop(props, "display", "none"); + } + if non_rendering_asset_reason(snapshot, node).is_some() { + // Background paints and the variant tree also pass here. Do not + // leave a URL behind merely because the node is not an Image leaf. + string_prop(props, "visibility", "hidden"); + return derivations; + } + if let Some(asset) = asset { + let image_index = super::image::selected_image_index(node, asset_policies); + let image_id = image_index + .map(|i| format!("{}:fills:{i}", node.id)) + .unwrap_or_else(|| node.id.clone()); + let policy = super::image::policy_for(node, &image_id, asset_policies); + let original = asset == AssetKind::Png && image_index.is_some() && policy.original(); + if policy.incompatible_capture(&node.id) { + string_prop(props, "visibility", "hidden"); + return derivations; + } + let folder = if matches!(asset, AssetKind::Svg | AssetKind::SvgMask) { + "icons" + } else { + "images" + }; + let extension = if matches!(asset, AssetKind::Svg | AssetKind::SvgMask) { + "svg" + } else { + "png" + }; + let source = (asset == AssetKind::Png) + .then(|| policy.asset_path.clone()) + .flatten() + .unwrap_or_else(|| asset_source(snapshot, node, folder, extension, per_node)); + if asset == AssetKind::SvgMask { + if let SameColor::Color(color) = + same_color(snapshot, node, false, Some(variable_tokens)) + { + if let Some(token) = color.strip_prefix('$') { + used_tokens.insert(token.to_owned()); + } + string_prop(props, "bg", color); + } + let url = if source.contains(' ') { + format!("url('{source}')") + } else { + format!("url({source})") + }; + string_prop(props, "maskImage", url); + derivations.push( + props, + "maskRepeat", + "no-repeat", + StyleDerivation::SvgMaskPolicy, + ); + derivations.push(props, "maskSize", "contain", StyleDerivation::SvgMaskPolicy); + derivations.push(props, "maskPos", "center", StyleDerivation::SvgMaskPolicy); + if width_policy + .is_some_and(|policy| super::width::line_follows_width(snapshot, node, policy)) + { + derivations.push( + props, + "maskSize", + "100% 100%", + StyleDerivation::WidthPolicyLine, + ); + } + } else { + string_prop(props, "src", source); + } + if original { + let mapping = + super::image::map_for(snapshot, node, image_index, asset_policies, per_node); + if let Some(path) = mapping["assetPath"].as_str() { + string_prop(props, "src", path); + } + super::image::apply_image_props(props, &mapping); + if reserve_image_content { + super::image::reserve_hug_content(props, &mapping); + } + if policy.effects_baked != Some(true) { + let node_opacity = view.number("opacity").unwrap_or(1.); + let paint_opacity = if mapping["css"]["image"]["transform"].is_null() { + mapping["css"]["image"]["opacity"] + .as_str() + .and_then(|s| s.parse::().ok()) + .unwrap_or(1.) + } else { + 1. + }; + if node_opacity * paint_opacity < 1. { + string_prop( + props, + "opacity", + format_number(node_opacity * paint_opacity), + ); + } + } + } else if asset != AssetKind::Png { + push_object_fit(&view, props, &mut derivations); + } else { + derivations.push(props, "objectFit", "fill", StyleDerivation::NativeRaster); + let mapping = + super::image::map_for(snapshot, node, image_index, asset_policies, per_node); + super::image::apply_image_props(props, &mapping); + } + // An export in flow is drawn where it sits in the box the layout gives + // the node. They coincide for a plain icon; the notice logo is an + // instance of 1373x98 whose vector is 952x104 at 425px in, and + // `contain` centred a 1373-wide picture of a 952-wide logo. The + // element keeps the layout's box, and the picture is placed inside it + // at the export's own size and offset. An element the layout + // positions is placed by its export outright, in `codegen::layout`. + if !original + && view.string("layoutPositioning") != Some("ABSOLUTE") + && !super::layout::placed_by_a_free_layout( + snapshot, + node, + view.string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)), + false, + ) + && let Some(offset) = super::layout::export_offset(node) + { + let size = format!("{} {}", px(offset.w), px(offset.h)); + let position = format!("{} {}", px(offset.x), px(offset.y)); + if asset == AssetKind::SvgMask { + derivations.push( + props, + "maskSize", + size, + StyleDerivation::InFlowExportBoundary, + ); + derivations.push( + props, + "maskPos", + position, + StyleDerivation::InFlowExportBoundary, + ); + } else { + derivations.push( + props, + "objectFit", + "none", + StyleDerivation::InFlowExportBoundary, + ); + derivations.push( + props, + "objectPos", + position, + StyleDerivation::InFlowExportBoundary, + ); + } + } + if asset != AssetKind::Png + || (policy.effects_baked != Some(true) + && (policy.effects_baked == Some(false) || original)) + { + push_radius(&view, props); + // The exported asset has its own painted bounds. The live-container + // outside-edge correction does not establish how to compose those. + push_strokes(&view, None, props, used_tokens, variable_tokens); + // Native PNGs flatten this entire subtree, including its own blur and + // shadows. Layout ancestors still render their effects in their own + // branch below. SVG masks are alpha masks rather than raster pictures; + // retain their existing compositing policy. + if asset != AssetKind::Png || original || policy.effects_baked == Some(false) { + push_effects( + &view, + component, + props, + used_tokens, + variable_tokens, + lengths, + ); + } + if !original + && policy.effects_baked == Some(false) + && let Some(opacity) = view.number("opacity").filter(|n| *n < 1.) + { + string_prop(props, "opacity", format_number(opacity)); + } + } + // An export carries the node's own opacity: Figma writes it into the + // SVG as `` and into a PNG's alpha. Written on the element + // as well it is applied twice - a decoration at 0.2 came out at 0.04, + // which is nothing, and the landing page's hero at 0.8 came out at + // 0.64. The plugin writes it twice too. A mask is the same: the + // SVG's own opacity thins the mask, so the colour painted through it + // already shows at the node's opacity. + push_blend_mode(&view, props); + return derivations; + } + + push_object_fit(&view, props, &mut derivations); + let color_prop = if component == "Text" { "color" } else { "bg" }; + // A background is every visible paint, back to front, as the plugin's + // `getBackgroundProps` composes it. Reading only the bound variable + // dropped the photo that sits on top of a `$gray200` plate: the about + // member cards are `url(...) center/cover no-repeat, $gray200`, and were + // coming out as the plate alone. + let layered = component != "Text" + && view + .value("fills") + .and_then(Value::as_array) + .is_some_and(|fills| { + fills + .iter() + .filter(|paint| { + paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("opacity").and_then(Value::as_f64) != Some(0.0) + }) + .count() + > 1 + }); + if layered { + if let Some(background) = background_css(snapshot, node, used_tokens, style) { + string_prop(props, "bg", background); + } + } else if let Some(token) = view + .value("devupTokens") + .and_then(Value::as_object) + .and_then(|tokens| tokens.get("fills")) + .and_then(Value::as_str) + { + used_tokens.insert(token.to_owned()); + string_prop(props, color_prop, format!("${token}")); + } else if let Some(color) = bound_color_token(&view, variable_tokens) { + used_tokens.insert(color.clone()); + string_prop(props, color_prop, format!("${color}")); + } else if component == "Text" && has_non_solid_fill(&view) { + if let Some(background) = background_css(snapshot, node, used_tokens, style) { + string_prop(props, "bg", background); + string_prop(props, "bgClip", "text"); + string_prop(props, "WebkitTextFillColor", "transparent"); + } + } else if component == "Text" { + let transparent = view + .value("fills") + .and_then(Value::as_array) + .is_some_and(|fills| { + fills.iter().any(|paint| { + paint.get("type").and_then(Value::as_str) == Some("SOLID") + && paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("opacity").and_then(Value::as_f64) == Some(0.0) + }) + }); + if transparent { + string_prop(props, color_prop, "transparent"); + } else if let Some(color) = first_solid_color(view.value("fills")) { + string_prop(props, color_prop, color); + } + } else if let Some(background) = background_css(snapshot, node, used_tokens, style) { + string_prop(props, "bg", background); + } + if let Some(mode) = view + .value("fills") + .and_then(Value::as_array) + .and_then(|fills| { + fills.iter().rev().find_map(|paint| { + (paint.get("visible").and_then(Value::as_bool) != Some(false)) + .then(|| paint.get("blendMode").and_then(Value::as_str)) + .flatten() + .and_then(blend_mode) + }) + }) + { + string_prop(props, "bgBlendMode", mode); + } + + push_radius(&view, props); + if component != "Text" { + push_strokes( + &view, + single_outside_stroke(&view), + props, + used_tokens, + variable_tokens, + ); + } + push_effects( + &view, + component, + props, + used_tokens, + variable_tokens, + lengths, + ); + if let Some(opacity) = view.number("opacity") + && opacity < 1.0 + { + string_prop(props, "opacity", format_number(opacity)); + } + push_blend_mode(&view, props); + derivations +} + +pub(super) fn non_rendering_asset_reason( + snapshot: &Snapshot, + node: &RawNode, +) -> Option<&'static str> { + let reason = if node + .typed_view() + .value("absoluteRenderBounds") + .is_some_and(Value::is_null) + { + "no-render-bounds" + } else { + devup_mcp_figma::asset_exclusion_reason(node)? + }; + (asset_kind(snapshot, node).is_some() + || fills(node) + .is_some_and(|paints| paints.iter().any(|paint| fill_type(paint) == Some("IMAGE")))) + .then_some(reason) +} + +/// The plugin's `getObjectFitProps`: how the first visible image fill of a +/// node Figma calls an asset is scaled. It is written whatever element the +/// node became — the about member cards are a `Box` whose photo sits on a +/// `$gray200` plate, and the reference gives them `objectFit="cover"` all the +/// same. `FILL` and `TILE` say nothing. +fn push_object_fit( + view: &TypedNode<'_>, + props: &mut Vec, + derivations: &mut StyleDerivations, +) { + if view.bool("isAsset") != Some(true) { + return; + } + let Some(scale) = view + .value("fills") + .and_then(Value::as_array) + .and_then(|fills| { + fills.iter().find(|paint| { + paint.get("type").and_then(Value::as_str) == Some("IMAGE") + && paint.get("visible").and_then(Value::as_bool) == Some(true) + }) + }) + .and_then(|paint| paint.get("scaleMode")) + .and_then(Value::as_str) + else { + return; + }; + match scale { + "FIT" => derivations.push( + props, + "objectFit", + "contain", + StyleDerivation::ImagePaintScale, + ), + "CROP" => derivations.push( + props, + "objectFit", + "cover", + StyleDerivation::ImagePaintScale, + ), + _ => {} + } +} + +fn push_blend_mode(view: &TypedNode<'_>, props: &mut Vec) { + let Some(blend) = view.string("blendMode") else { + return; + }; + let css = blend_mode(blend); + if let Some(css) = css { + string_prop(props, "mixBlendMode", css); + } +} + +fn has_non_solid_fill(view: &TypedNode<'_>) -> bool { + view.value("fills") + .and_then(Value::as_array) + .is_some_and(|fills| { + fills.iter().any(|paint| { + paint.get("visible").and_then(Value::as_bool) != Some(false) + && paint.get("opacity").and_then(Value::as_f64) != Some(0.0) + && paint.get("type").and_then(Value::as_str) != Some("SOLID") + }) + }) +} + +fn background_css( + snapshot: &Snapshot, + node: &RawNode, + used_tokens: &mut BTreeSet, + style: StyleOptions<'_>, +) -> 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() + .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 (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, + used_tokens, + style, + ) { + css.push(value); + } + } + (!css.is_empty()).then(|| css.join(", ")) +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant.rs b/crates/devup-mcp-devup-ui/src/codegen/variant.rs index d9e11662..d59663cf 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant.rs @@ -1,1757 +1,3 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use devup_mcp_figma::{ - DevupError, Diagnostic, DiagnosticSeverity, ErrorCode, FidelityImpact, RawNode, Snapshot, -}; -use serde_json::Value; - -use super::{ - component::{CodegenOptions, CodegenOutput, PropValue, legacy_component_name}, - layout, style, text, -}; -use crate::provenance::mark_node; - -#[derive(Clone, Debug)] -pub(super) struct Tree { - pub node_id: String, - /// What the node is called in Figma. Only used to pair a node with its - /// counterpart at another width when shape alone cannot tell them apart. - pub node_name: String, - pub source_node_ids: BTreeSet, - pub component: String, - pub props: BTreeMap, - pub children: Vec, - pub content: Option, - /// True when `component` names a component to reference rather than a - /// devup-ui primitive, so the renderer must not descend into it. - pub is_component: bool, - /// The boolean property that decides whether this node is drawn, if one - /// does. Figma keeps it on the child rather than on the set. - pub visible_when: Option, - /// The variant options this node is drawn at, when it is not drawn at all - /// of them. Rendered as the condition guarding it. - pub drawn_when: Vec, - /// A JSX comment to write immediately above this node, naming the - /// component it was spelled out from. - pub leading_comment: Option, - /// In a responsive merge, the node each slot's width drew this element - /// from and that node's text; empty everywhere else. - pub slots: Vec)>>, -} - -#[derive(Clone, Debug)] -struct Record { - values: BTreeMap, - tree: Tree, - node_id: String, -} - -#[derive(Clone, Debug)] -struct Definition { - /// The name as it is written in code. - name: String, - default: String, - options: Vec, - /// A switch rather than a choice: it has no options, and a child names it - /// to say when it is drawn. - boolean: bool, - /// The name Figma knows it by, which a child's `visible` reference uses and - /// which may carry a `#id` suffix that `name` has dropped. - source: String, -} - -#[derive(Clone, Debug)] -enum Expression { - Literal(String), - /// A devup-ui responsive array, written out slot by slot, narrowest first. - /// `None` is the literal `null`, which is not "no value" but "whatever the - /// slot before it said" — so where a value sits decides the width it starts - /// applying at, and the slots have to be placed, not merely listed. - Responsive(Vec>), - Variant(String, Vec<(String, Expression)>), - Conditional(String, String, String), -} - -pub(super) fn generate_variant_component_set( - snapshot: &Snapshot, - set_id: &str, - options: &CodegenOptions, -) -> Result, DevupError> { - let set = snapshot.nodes.get(set_id).ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Variant component set was not found.", - false, - ) - })?; - let definitions = definitions(set); - if !definitions.iter().any(|definition| { - matches!( - definition.name.to_ascii_lowercase().as_str(), - "effect" | "viewport" - ) - }) { - return Ok(None); - } - let records = set - .typed_view() - .child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .filter_map(|node| { - let values = node - .typed_view() - .value("variantProperties")? - .as_object()? - .iter() - .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_owned()))) - .collect::>(); - Some((node, values)) - }) - .map(|(node, values)| { - Ok(Record { - values, - tree: project_tree(snapshot, node, options, true)?, - node_id: node.id.clone(), - }) - }) - .collect::, DevupError>>()?; - // A boolean property is not part of a variant's identity — it never appears - // in `variantProperties` — so filtering records by it matches nothing. - let default_filters = definitions - .iter() - .filter(|definition| !definition.boolean) - .map(|definition| (definition.source.clone(), definition.default.clone())) - .collect::>(); - let default = find_record(&records, &default_filters) - .or_else(|| records.first()) - .ok_or_else(|| { - DevupError::new( - ErrorCode::DevupCodegenFailed, - "Component set has no variant components.", - false, - ) - })?; - let dimensions = definitions - .iter() - .filter(|definition| { - !matches!( - definition.name.to_ascii_lowercase().as_str(), - "effect" | "viewport" - ) - }) - .cloned() - .collect::>(); - let effect = definitions - .iter() - .find(|definition| definition.name.eq_ignore_ascii_case("effect")); - let viewport = definitions - .iter() - .find(|definition| definition.name.eq_ignore_ascii_case("viewport")); - - let mut root = default.tree.clone(); - let mut unrepresented_variant_nodes = BTreeSet::new(); - merge_source_node_ids( - &mut root, - &records, - &[], - &mut unrepresented_variant_nodes, - effect.map(|definition| definition.name.as_str()), - &default.values, - ); - root.props = merged_props( - &records, - &definitions, - &dimensions, - viewport, - effect.map_or("default", |definition| definition.default.as_str()), - None, - &default.tree.props, - ); - merge_child_props( - &mut root, - &records, - &definitions, - &dimensions, - viewport, - effect.map_or("default", |definition| definition.default.as_str()), - &[], - ); - - let mut selectors = BTreeMap::new(); - let mut transition_props = BTreeSet::new(); - if let Some(effect) = effect { - for effect_value in &effect.options { - if effect_value == &effect.default { - continue; - } - let changed = changed_effect_props( - &records, - &definitions, - &dimensions, - viewport, - &effect.default, - effect_value, - ); - if !changed.is_empty() { - transition_props.extend(changed.keys().cloned()); - selectors.insert(format!("_{effect_value}"), changed); - } - } - } - let transition = transition(snapshot, &default.node_id, &transition_props); - let component_name = legacy_component_name(set.typed_view().name().unwrap_or("Component")); - let code = mark_node( - &set.id, - render_component( - &component_name, - &dimensions, - &root, - &selectors, - transition.as_ref(), - ), - ); - let mut imports = BTreeSet::new(); - collect_imports(&root, &mut imports); - Ok(Some(CodegenOutput { - tsx: code, - imports: imports.into_iter().collect(), - used_tokens: BTreeSet::new(), - diagnostics: unrepresented_variant_nodes - .into_iter() - .map(|node_id| Diagnostic { - code: "DEVUP_CODEGEN_VARIANT_CHILD_FALLBACK".to_owned(), - 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), - fidelity_impact: Some(FidelityImpact::Lossy), - fallback: Some("default-variant-structure".to_owned()), - ..Diagnostic::default() - }) - .collect(), - source_map: crate::provenance::SourceMap::empty(), - projection_trace: crate::provenance::ProjectionTrace::default(), - fidelity_report: crate::provenance::FidelityReport::default(), - width_policy: None, - })) -} - -fn definitions(set: &RawNode) -> Vec { - set.typed_view() - .value("componentPropertyDefinitions") - .and_then(Value::as_object) - .into_iter() - .flatten() - .filter_map(|(name, definition)| { - let boolean = match definition.get("type").and_then(Value::as_str)? { - "VARIANT" => false, - // A boolean property does not name variants; it switches a - // child on and off through `componentPropertyReferences`. It - // belongs in the props all the same, or the component cannot be - // told to leave its icon out. - "BOOLEAN" => true, - _ => return None, - }; - Some(Definition { - name: instance_property_name(name), - default: definition - .get("defaultValue") - .and_then(|value| { - value - .as_str() - .map(str::to_owned) - .or_else(|| value.as_bool().map(|value| value.to_string())) - }) - .unwrap_or_default(), - options: definition - .get("variantOptions") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .map(str::to_owned) - .collect(), - boolean, - source: name.clone(), - }) - }) - .collect() -} - -pub(super) fn project_tree( - snapshot: &Snapshot, - node: &RawNode, - options: &CodegenOptions, - is_render_root: bool, -) -> Result { - project_tree_inner(snapshot, node, options, is_render_root, false) -} - -/// As [`project_tree`], but instances become component references instead of -/// being expanded. -/// -/// The variant path must keep expanding them — a component set's variants are -/// merged from the inside, and the pinned corpus records that output — so this -/// is a second entry rather than a change to the first. -pub(super) fn project_tree_keeping_instances( - snapshot: &Snapshot, - node: &RawNode, - options: &CodegenOptions, - is_render_root: bool, -) -> Result { - project_tree_inner(snapshot, node, options, is_render_root, true) -} - -/// The boolean component property that decides whether a node is drawn. -/// -/// Figma records this on the child, as a reference back to a property the set -/// declares, which is why a set's definitions alone never reveal what a boolean -/// property actually does. -fn visible_when(view: &devup_mcp_figma::TypedNode<'_>) -> Option { - view.value("componentPropertyReferences") - .and_then(Value::as_object) - .and_then(|references| references.get("visible")) - .and_then(Value::as_str) - .map(instance_property_name) -} - -/// Whether a projected node is just an asset: one shape and nothing else. -/// This is the plugin's `isAssetLeafTree`. -fn is_asset_leaf(tree: &Tree) -> bool { - tree.children.is_empty() - && tree.content.is_none() - && ((tree.component == "Image" && tree.props.contains_key("src")) - || (tree.component == "Box" && tree.props.contains_key("maskImage"))) -} - -/// Variant properties a component set uses to describe itself rather than to -/// be told something, so an instance must not pass them. -/// -/// `effect` names the interaction state a variant stands for, and the whole -/// point of it is that the *definition* folds those variants into `_hover` and -/// `_active` blocks. A call site has no state to pass — writing -/// `` asks the component for a prop it does not have. -/// `viewport` is the same story for widths. -const RESERVED_VARIANT_KEYS: [&str; 2] = ["effect", "viewport"]; - -/// What a placed instance carries about where it sits rather than what it is. -/// The renderer moves these onto a wrapping `Box`, since a component reference -/// has nowhere to put them. -pub(super) const POSITION_PROPS: [&str; 7] = [ - "pos", - "top", - "right", - "bottom", - "left", - "transform", - "transformOrigin", -]; - -/// A component property's name as it is written in code. -/// -/// This is the plugin's `sanitizePropertyName`. Figma names the first variant -/// property for the editor's language, so a file authored in Korean calls it -/// `속성 1` — and a file where someone typed it in English calls it -/// `Property 1`. Both become one identifier, and which one matters: the -/// definition the plugin emits for the same component declares that exact name. -/// -/// Note what it does *not* do. The first word keeps its case, so `Property 1` -/// is `Property1` and not `property1`; folding it turned `leftIcon` into -/// `lefticon`. And only a trailing `#:` is stripped, since -/// that is Figma's own suffix rather than a character a name may not contain. -fn instance_property_name(raw: &str) -> String { - let stripped = raw - .rsplit_once('#') - .filter(|(_, suffix)| { - suffix.split_once(':').is_some_and(|(left, right)| { - !left.is_empty() - && !right.is_empty() - && left.bytes().all(|byte| byte.is_ascii_digit()) - && right.bytes().all(|byte| byte.is_ascii_digit()) - }) - }) - .map_or(raw, |(base, _)| base); - - // The Korean word takes any space after it with it, so `속성 1` is - // `property1` rather than `property 1` waiting to be camel-cased. - let mut normalized = String::with_capacity(stripped.len()); - let mut rest = stripped.trim(); - while let Some(at) = rest.find("속성") { - normalized.push_str(&rest[..at]); - normalized.push_str("property"); - rest = rest[at + "속성".len()..].trim_start(); - } - normalized.push_str(rest); - - let mut name = String::with_capacity(normalized.len()); - let mut capitalize = false; - for character in normalized.chars() { - if character.is_whitespace() || character == '-' || character == '_' { - capitalize = true; - continue; - } - if capitalize { - name.extend(character.to_uppercase()); - capitalize = false; - } else { - name.push(character); - } - } - if name.starts_with(|character: char| character.is_ascii_digit()) { - name.insert(0, '_'); - } - name.retain(|character| { - character.is_ascii_alphanumeric() || character == '_' || character == '$' - }); - if name.is_empty() || name.bytes().all(|byte| byte.is_ascii_digit()) { - return "variant".to_owned(); - } - name -} - -/// The component an instance refers to: the longest `COMPONENT` name the -/// instance's own name starts with, which is how `render_node` resolves it. -pub(super) fn referenced_component_name(snapshot: &Snapshot, view_name: Option<&str>) -> String { - snapshot - .nodes - .values() - .filter(|candidate| candidate.typed_view().node_type() == "COMPONENT") - .filter_map(|candidate| candidate.typed_view().name()) - .filter(|name| view_name.is_some_and(|instance| instance.starts_with(name))) - .max_by_key(|name| name.len()) - .map_or_else( - || legacy_component_name(view_name.unwrap_or("Component")), - legacy_component_name, - ) -} - -fn project_tree_inner( - snapshot: &Snapshot, - node: &RawNode, - options: &CodegenOptions, - is_render_root: bool, - keep_instances: bool, -) -> Result { - let view = node.typed_view(); - if keep_instances && view.node_type() == "INSTANCE" { - // An instance whose component is nothing but a shape is spelled out - // rather than referenced. `` would be a component whose entire - // body is one masked Box, so the reference costs a file and an import - // and says less than the Box does. The plugin does the same, and marks - // the spot with a `{/* */}` comment. - let mut props = instance_variant_props(&view); - let mut expanded = project_tree_inner(snapshot, node, options, is_render_root, false)?; - if is_asset_leaf(&expanded) || style::non_rendering_asset_reason(snapshot, node).is_some() { - // Spelling the shape out loses which component it came from, and - // that is the one thing a reader needs to change it in the right - // place. The reference leaves the call it declined to write. - let attributes = props - .iter() - .map(|(name, value)| format!(" {name}=\"{value}\"")) - .collect::>() - .join(""); - expanded.leading_comment = Some(format!( - "<{}{attributes} />", - referenced_component_name(snapshot, view.name()) - )); - return Ok(expanded); - } - // The variant each width picked, collected above. These are the - // instance's own choice, not something to merge — a component answers - // for its own widths. - - // Where the instance sits. `
` has nowhere to put this, so the - // renderer gives it a Box; keeping the values here lets them merge - // across widths first. - props.extend(instance_placement(snapshot, node, options, is_render_root)); - return Ok(Tree { - node_id: node.id.clone(), - node_name: view.name().unwrap_or_default().to_owned(), - source_node_ids: BTreeSet::from([node.id.clone()]), - component: referenced_component_name(snapshot, view.name()), - props, - children: Vec::new(), - content: None, - is_component: true, - visible_when: visible_when(&view), - drawn_when: Vec::new(), - leading_comment: None, - slots: Vec::new(), - }); - } - let asset = super::image::asset_kind(snapshot, node, &options.asset_policies); - let inferred_mode = view - .value("inferredAutoLayout") - .and_then(Value::as_object) - .and_then(|layout| layout.get("layoutMode")) - .and_then(Value::as_str); - let inferred_align = |name: &str| { - view.string(name).or_else(|| { - view.value("inferredAutoLayout") - .and_then(Value::as_object) - .and_then(|layout| layout.get(name)) - .and_then(Value::as_str) - }) - }; - let component = if asset == Some(style::AssetKind::SvgMask) - || (asset.is_some() && style::non_rendering_asset_reason(snapshot, node).is_some()) - { - "Box" - } else if asset.is_some() { - "Image" - } else if view.node_type() == "TEXT" { - "Text" - } else if layout::centres_its_only_child(snapshot, node) { - "Center" - } else { - match inferred_mode.or_else(|| view.string("layoutMode")) { - Some("GRID") => "Grid", - Some("HORIZONTAL" | "VERTICAL") - if inferred_align("primaryAxisAlignItems") == Some("CENTER") - && inferred_align("counterAxisAlignItems") == Some("CENTER") => - { - "Center" - } - Some("VERTICAL") => "VStack", - Some("HORIZONTAL") => "Flex", - _ => "Box", - } - } - .to_owned(); - let mut props = Vec::new(); - layout::push_layout_props( - snapshot, - node, - &component, - &mut props, - options.root_layout, - is_render_root, - None, - super::image::original_image(node, &options.asset_policies), - ); - let mut used_tokens = BTreeSet::new(); - style::push_style_props( - snapshot, - node, - &component, - asset, - &mut props, - &mut used_tokens, - style::StyleOptions { - reserve_image_content: !(is_render_root - && options.root_layout == super::RootLayout::Embedded), - asset_policies: &options.asset_policies, - variable_tokens: &options.variable_tokens, - length_tokens: &options.length_tokens, - asset_names_per_node: options.asset_names_per_node, - width_policy: None, - }, - ); - text::push_text_props( - &view, - &options.text_style_tokens, - &options.typography_values, - &options.variable_tokens, - &mut used_tokens, - &mut props, - ); - style::tokenize_lengths( - snapshot, - node, - &mut props, - &options.length_tokens, - &mut used_tokens, - ); - if asset.is_some() { - props.retain(|(name, _)| { - !matches!( - name.as_str(), - "alignItems" - | "justifyContent" - | "flexDir" - | "gap" - | "outline" - | "outlineOffset" - | "overflow" - | "p" - | "px" - | "py" - | "pt" - | "pr" - | "pb" - | "pl" - ) - }); - } - let props = props - .into_iter() - .map(|(name, value)| match value { - PropValue::String(value) => (name, value), - }) - .collect(); - let children = if asset.is_some() { - Vec::new() - } else { - view.child_ids() - .filter_map(|id| snapshot.nodes.get(id)) - .map(|child| project_tree_inner(snapshot, child, options, false, keep_instances)) - .collect::, _>>()? - }; - let content = (view.node_type() == "TEXT").then(|| { - text::render_text_children( - &view, - &options.text_style_tokens, - &options.typography_values, - &options.variable_tokens, - &mut used_tokens, - 0, - ) - }); - let tree = Tree { - node_id: node.id.clone(), - node_name: view.name().unwrap_or_default().to_owned(), - source_node_ids: BTreeSet::from([node.id.clone()]), - component, - props, - children, - content, - is_component: false, - visible_when: visible_when(&view), - drawn_when: Vec::new(), - leading_comment: None, - slots: Vec::new(), - }; - Ok(super::image::project_tree(tree, snapshot, node, options)) -} - -/// The variant an instance picked, by the name a reference to its component -/// passes it as. -pub(super) fn instance_variant_props( - view: &devup_mcp_figma::TypedNode<'_>, -) -> BTreeMap { - let mut props = BTreeMap::new(); - for (raw, definition) in view - .value("componentProperties") - .and_then(Value::as_object) - .into_iter() - .flatten() - { - if definition.get("type").and_then(Value::as_str) != Some("VARIANT") { - continue; - } - let name = instance_property_name(raw); - if RESERVED_VARIANT_KEYS - .iter() - .any(|reserved| name.eq_ignore_ascii_case(reserved)) - { - continue; - } - if let Some(value) = definition.get("value").and_then(Value::as_str) { - props.insert(name, value.to_owned()); - } - } - props -} - -/// Where a referenced instance sits in its parent: what the `Box` wrapped -/// around the reference carries, since the reference has nowhere to put it. -pub(super) fn instance_placement( - snapshot: &Snapshot, - node: &RawNode, - options: &CodegenOptions, - is_render_root: bool, -) -> BTreeMap { - if node.typed_view().string("layoutPositioning") != Some("ABSOLUTE") { - return BTreeMap::new(); - } - let mut placement = Vec::new(); - layout::push_layout_props( - snapshot, - node, - "Box", - &mut placement, - options.root_layout, - is_render_root, - None, - super::image::original_image(node, &options.asset_policies), - ); - placement - .into_iter() - .filter_map(|(name, PropValue::String(value))| { - (POSITION_PROPS.contains(&name.as_str()) || (name == "w" && value == "100%")) - .then_some((name, value)) - }) - .collect() -} - -fn merge_source_node_ids( - tree: &mut Tree, - records: &[Record], - path: &NodePath, - unrepresented: &mut BTreeSet, - effect_name: Option<&str>, - default_values: &BTreeMap, -) { - for record in records { - let Some(source) = tree_at(&record.tree, path) else { - // A variant that does not hold this node at all is not a shape - // difference the merge hid: `drawn_when` states the condition it is - // drawn under, so the absence is carried rather than lost. - continue; - }; - if !same_rendered_node_kind(tree, source) { - unrepresented.insert(source.node_id.clone()); - continue; - } - // Differing values are usually the point of the exercise — they become - // the maps — so a value difference is not on its own something the - // merge papered over. The exception is a difference that only an - // interaction state accounts for, below the root: selector blocks are - // written for the component's own props, so a state that changes a - // *child* has nowhere to say so and the value really is dropped. - if !path.is_empty() - && tree.props != source.props - && effect_name.is_some_and(|effect| { - record - .values - .iter() - .all(|(name, value)| name == effect || default_values.get(name) == Some(value)) - }) - && record.values.get(effect_name.unwrap_or_default()) - != default_values.get(effect_name.unwrap_or_default()) - { - unrepresented.insert(source.node_id.clone()); - continue; - } - tree.source_node_ids.insert(source.node_id.clone()); - } - let keys = child_keys(tree); - for (index, child) in tree.children.iter_mut().enumerate() { - let mut child_path = path.to_vec(); - let Some(key) = keys.get(index).cloned() else { - continue; - }; - child_path.push(key); - merge_source_node_ids( - child, - records, - &child_path, - unrepresented, - effect_name, - default_values, - ); - } -} - -/// Whether two nodes are the same shape. What a text node *says* is a value -/// that gets folded like any other, so only whether it is a text node at all -/// counts here — comparing the words themselves reported every button whose -/// label differs by size, which is all of them. -fn same_rendered_node_kind(left: &Tree, right: &Tree) -> bool { - left.component == right.component - && left.content.is_some() == right.content.is_some() - && left.children.len() == right.children.len() -} - -fn find_record<'a>( - records: &'a [Record], - filters: &BTreeMap, -) -> Option<&'a Record> { - records.iter().find(|record| { - filters - .iter() - .all(|(key, value)| record.values.get(key) == Some(value)) - }) -} - -fn merged_props( - records: &[Record], - definitions: &[Definition], - dimensions: &[Definition], - viewport: Option<&Definition>, - effect_value: &str, - path: Option<&NodePath>, - defaults: &BTreeMap, -) -> BTreeMap { - let mut keys = defaults.keys().cloned().collect::>(); - for record in records { - if let Some(tree) = tree_at(&record.tree, path.unwrap_or_default()) { - keys.extend(tree.props.keys().cloned()); - } - } - keys.into_iter() - .filter_map(|prop| { - let expression = expression_for_prop( - records, - definitions, - dimensions, - viewport, - effect_value, - // The base props are the whole value, not a difference from - // anything, so nothing is filtered out as unchanged. - None, - path.unwrap_or_default(), - &prop, - )?; - Some((prop.clone(), render_expression_attr(&prop, &expression))) - }) - .collect() -} - -/// Keys the plugin's prop getters write whether or not they have a value. -/// -/// The plugin keys a node's shape on `Object.keys(tree.props)`, and each of -/// its getters answers with every key it knows, `null` or `undefined` where -/// there is nothing to say: `getTextAlignProps` is always `{textAlign, -/// alignContent}`, `getMinMaxProps` always all four bounds, `getLayoutProps` -/// always `w`, `h`, `aspectRatio` and `flex`. The render step drops the empty -/// ones afterwards. So these keys are on every node of their kind and never -/// tell two apart — a `` right-aligned at one width and not at the next -/// is the same shape to the plugin. Our props only ever hold what renders, so -/// the same keys are left out of the signature here to get the same answer. -const KEYS_THE_PLUGIN_ALWAYS_WRITES: &[&str] = &[ - // getTextAlignProps - "textAlign", - "alignContent", - // getMinMaxProps - "maxW", - "maxH", - "minW", - "minH", - // getLayoutProps; `boxSize` replaces `w` and `h` only when both are set and - // equal, so it still counts - "w", - "h", - "aspectRatio", - "flex", - // getBlendProps - "opacity", - "mixBlendMode", - // getAutoLayoutProps; the component name already says whether a node is - // laid out and which way - "flexDir", - "gap", - "rowGap", - "columnGap", - "justifyContent", - "alignItems", - // getBackgroundProps; `bg` says whether there is paint - "bgBlendMode", - "WebkitTextFillColor", - "bgClip", - // getBorderProps; `outline` says whether there is a stroke - "outlineOffset", - // getPaddingProps writes `p` even when every side is 0 - "p", -]; - -/// `getPositionProps` writes all of these on an absolutely placed node, -/// whichever edges it is pinned to; only `pos` itself tells the shape. -const KEYS_THE_PLUGIN_WRITES_ON_AN_ABSOLUTE_NODE: &[&str] = - &["left", "right", "top", "bottom", "transform"]; - -/// `getAutoLayoutProps` writes `display` on every laid-out node and the -/// renderer drops the `flex`; a hidden one differs by value, not by key. -const COMPONENTS_THE_PLUGIN_ALWAYS_GIVES_DISPLAY: &[&str] = &["Flex", "VStack", "Center", "Grid"]; - -/// What a node looks like, ignoring the values it holds. -/// -/// This is the plugin's `getChildStructureSignature`. Prop *names* count but -/// their values do not, so a node that only changed a colour still matches -/// itself in another variant — less the names the plugin writes on every -/// node regardless, see [`KEYS_THE_PLUGIN_ALWAYS_WRITES`]. -pub(super) fn structure_signature(tree: &Tree) -> String { - let absolute = tree - .props - .get("pos") - .is_some_and(|value| value == "absolute"); - let laid_out = COMPONENTS_THE_PLUGIN_ALWAYS_GIVES_DISPLAY.contains(&tree.component.as_str()); - let props = tree - .props - .keys() - .filter(|name| !KEYS_THE_PLUGIN_ALWAYS_WRITES.contains(&name.as_str())) - .filter(|name| { - !(absolute && KEYS_THE_PLUGIN_WRITES_ON_AN_ABSOLUTE_NODE.contains(&name.as_str())) - }) - .filter(|name| !(laid_out && name.as_str() == "display")) - // `getObjectFitProps` answers for every image-filled asset, `null` - // for the scale modes that need nothing said. - .filter(|name| !(tree.component == "Image" && name.as_str() == "objectFit")) - .cloned() - .collect::>() - .join(","); - let children = tree - .children - .iter() - .map(structure_signature) - .collect::>() - .join("|"); - let kind = if tree.is_component { - "component" - } else { - "node" - }; - let text = if tree.content.is_some() { - "text" - } else { - "notext" - }; - format!("{}::{kind}::{text}::{props}::{children}", tree.component) -} - -/// How each child is found again in another variant's tree. -/// -/// Addressing a child by its position breaks the moment a variant leaves one -/// out: a `tag` button has no icon, so its text sits where the icon sat and -/// every lookup after that reads the wrong node. A child is identified by its -/// shape where that is unique among its siblings, and by its name where it is -/// not, with the occurrence to tell repeats apart. This is the plugin's -/// `treeChildrenToMap`. -fn child_keys(tree: &Tree) -> Vec<(String, usize)> { - let mut counts: BTreeMap = BTreeMap::new(); - for child in &tree.children { - *counts.entry(structure_signature(child)).or_default() += 1; - } - let mut seen: BTreeMap = BTreeMap::new(); - tree.children - .iter() - .map(|child| { - let signature = structure_signature(child); - let key = if counts.get(&signature) == Some(&1) { - format!("sig:{signature}") - } else { - child.node_name.clone() - }; - let occurrence = seen.entry(key.clone()).or_default(); - let at = *occurrence; - *occurrence += 1; - (key, at) - }) - .collect() -} - -/// Where a node sits, said in a way another variant can follow. -type NodePath = [(String, usize)]; - -/// The name under which a text node's own words are folded like any other -/// value. No CSS property can be called this, so it cannot collide with one. -const CONTENT_FIELD: &str = "\u{1}content"; - -/// What a node says for a field, whether that is a prop or its own words. -fn tree_field(tree: &Tree, field: &str) -> Option { - if field == CONTENT_FIELD { - return tree.content.clone(); - } - tree.props.get(field).cloned() -} - -/// The conditions under which a node is drawn at all. -/// -/// Not every variant holds every node: a `tag`-sized button has no icon, so the -/// merged tree — built from the default variant, which does — would draw one at -/// every size. The dimensions whose options disagree about the node's existence -/// become the condition guarding it. -/// -/// Existence is judged by what the node *is* rather than by whether something -/// sits at the same index, because a variant that dropped a child shifts every -/// child after it up one place. -fn drawn_when( - records: &[Record], - dimensions: &[Definition], - effect_value: &str, - path: &NodePath, - child: &Tree, -) -> Vec { - let mut conditions = Vec::new(); - for dimension in dimensions.iter().filter(|dimension| !dimension.boolean) { - let parent_path = &path[..path.len().saturating_sub(1)]; - let at = |option: &String, want_node: bool| { - records.iter().any(|record| { - record.values.get(&dimension.source) == Some(option) - && record.values.iter().all(|(name, value)| { - !name.eq_ignore_ascii_case("effect") || value == effect_value - }) - && (!want_node - || tree_at(&record.tree, parent_path).is_some_and(|parent| { - parent - .children - .iter() - .any(|sibling| sibling.component == child.component) - })) - }) - }; - let drawn = dimension - .options - .iter() - .filter(|option| at(option, false)) - .collect::>(); - let holding = drawn - .iter() - .filter(|option| at(option, true)) - .collect::>(); - if holding.is_empty() || holding.len() == drawn.len() { - continue; - } - conditions.push(format!( - "({})", - holding - .iter() - .map(|option| format!("{} === \"{option}\"", dimension.name)) - .collect::>() - .join(" || ") - )); - } - conditions -} - -fn merge_child_props( - tree: &mut Tree, - records: &[Record], - definitions: &[Definition], - dimensions: &[Definition], - viewport: Option<&Definition>, - effect_value: &str, - path: &NodePath, -) { - let keys = child_keys(tree); - for (index, child) in tree.children.iter_mut().enumerate() { - let mut child_path = path.to_vec(); - let Some(key) = keys.get(index).cloned() else { - continue; - }; - child_path.push(key); - child.drawn_when = drawn_when(records, dimensions, effect_value, &child_path, child); - // A text node's own words are as much a variant as the colour around - // them: `lg` says "buttonLg" where `tag` says "Tag". - if child.content.is_some() - && let Some(expression) = expression_for_prop( - records, - definitions, - dimensions, - viewport, - effect_value, - None, - &child_path, - CONTENT_FIELD, - ) - { - child.content = Some(match &expression { - Expression::Literal(value) => value.clone(), - other => format!("{{{}}}", render_expression_value(other, 0)), - }); - } - child.props = merged_props( - records, - definitions, - dimensions, - viewport, - effect_value, - Some(&child_path), - &child.props, - ); - merge_child_props( - child, - records, - definitions, - dimensions, - viewport, - effect_value, - &child_path, - ); - } -} - -fn changed_effect_props( - records: &[Record], - definitions: &[Definition], - dimensions: &[Definition], - viewport: Option<&Definition>, - default_effect: &str, - effect_value: &str, -) -> BTreeMap { - let effect_name = definitions - .iter() - .find(|definition| definition.name.eq_ignore_ascii_case("effect")) - .map(|definition| definition.name.clone()); - let keys = records - .iter() - .filter(|record| { - effect_name.as_ref().is_some_and(|name| { - record.values.get(name).map(String::as_str) == Some(effect_value) - }) - }) - .flat_map(|effect_record| { - let mut filters = effect_record.values.clone(); - if let Some(effect_name) = &effect_name { - filters.insert(effect_name.clone(), default_effect.to_owned()); - } - let base = find_record(records, &filters); - effect_record - .tree - .props - .iter() - .filter(move |(key, value)| { - base.and_then(|record| record.tree.props.get(*key)) != Some(*value) - }) - .map(|(key, _)| key.clone()) - }) - .collect::>(); - keys.into_iter() - .filter_map(|prop| { - let effect = expression_for_prop( - records, - definitions, - dimensions, - viewport, - effect_value, - // A selector block says what this state changes, so a value the - // state leaves alone is left out of it entirely. - Some(default_effect), - &[], - &prop, - )?; - Some((prop, effect)) - }) - .collect() -} - -#[allow(clippy::too_many_arguments)] -fn expression_for_prop( - records: &[Record], - definitions: &[Definition], - dimensions: &[Definition], - viewport: Option<&Definition>, - effect_value: &str, - baseline: Option<&str>, - path: &NodePath, - prop: &str, -) -> Option { - let effect_name = definitions - .iter() - .find(|definition| definition.name.eq_ignore_ascii_case("effect")) - .map(|definition| definition.name.as_str()); - let dependencies = dimensions - .iter() - .filter(|dimension| !dimension.boolean) - .filter(|dimension| { - dimension_depends( - records, - definitions, - effect_name, - effect_value, - baseline, - path, - prop, - dimension, - ) - }) - .collect::>(); - if !dependencies.is_empty() { - return nested_expression( - records, - definitions, - effect_name, - effect_value, - path, - prop, - baseline, - &dependencies, - &BTreeMap::new(), - viewport, - ); - } - viewport_expression( - records, - definitions, - effect_name, - effect_value, - path, - prop, - baseline, - &BTreeMap::new(), - viewport, - ) -} - -/// What one record says about a prop, or nothing when a baseline is given and -/// it says the same as the baseline does. -/// -/// A `_hover` block is a difference, not a restatement. Asked for the whole -/// hover value it would repeat every size and colour that hover leaves alone, -/// and the one thing hover actually changes would be buried in it. -fn record_value( - records: &[Record], - record: &Record, - effect_name: Option<&str>, - baseline: Option<&str>, - path: &NodePath, - prop: &str, -) -> Option { - let value = tree_at(&record.tree, path).and_then(|tree| tree_field(tree, prop)); - let (Some(baseline), Some(effect_name)) = (baseline, effect_name) else { - return value; - }; - let mut filters = record.values.clone(); - filters.insert(effect_name.to_owned(), baseline.to_owned()); - let unchanged = find_record(records, &filters) - .and_then(|record| tree_at(&record.tree, path)) - .and_then(|tree| tree_field(tree, prop)); - if value == unchanged { None } else { value } -} - -#[allow(clippy::too_many_arguments)] -fn dimension_depends( - records: &[Record], - definitions: &[Definition], - effect_name: Option<&str>, - effect_value: &str, - baseline: Option<&str>, - path: &NodePath, - prop: &str, - dimension: &Definition, -) -> bool { - // A variant that does not draw the node has nothing to say about the - // prop: the node is guarded so that it is never rendered there, and - // counting its silence as a difference wrote the button icon's - // `aspectRatio={{lg: "1", md: "1", sm: "1"}[size]}` for a value every - // variant that draws it agrees on. A variant that draws the node and - // leaves the prop unset is a difference, and stays one. - let sets = dimension - .options - .iter() - .filter_map(|option| { - let set = records - .iter() - .filter(|record| { - record.values.get(&dimension.name) == Some(option) - && effect_name.is_none_or(|name| { - record.values.get(name).map(String::as_str) == Some(effect_value) - }) - && tree_at(&record.tree, path).is_some() - }) - .map(|record| record_value(records, record, effect_name, baseline, path, prop)) - .collect::>(); - (!set.is_empty()).then_some(set) - }) - .collect::>(); - let _ = definitions; - sets.iter().skip(1).any(|set| set != &sets[0]) -} - -/// How deeply an expression nests, so that the shallower of two ways to write -/// the same thing can be preferred. A plain value costs nothing; a map costs -/// one plus the worst of what it holds. -fn nesting_cost(expression: &Expression) -> usize { - match expression { - Expression::Variant(_, values) => { - 1 + values - .iter() - .map(|(_, value)| nesting_cost(value)) - .max() - .unwrap_or_default() - } - _ => 0, - } -} - -/// One prop's value where several dimensions decide it at once. -/// -/// A value that depends on two dimensions cannot be written as one map, and -/// writing it as the default's literal loses every other combination — which is -/// what this used to do. It is written as a map inside a map instead. -/// -/// Which dimension goes on the outside is not free: `{lg: {...}[varient], tag: -/// "10px"}[size]` and its transpose say the same thing at different sizes. Each -/// candidate outer dimension is costed by how deep the result nests, with the -/// number of entries as a tiebreak, and the cheapest is kept. That is the -/// plugin's `createNestedVariantProp`. -#[allow(clippy::too_many_arguments)] -fn nested_expression( - records: &[Record], - definitions: &[Definition], - effect_name: Option<&str>, - effect_value: &str, - path: &NodePath, - prop: &str, - baseline: Option<&str>, - remaining: &[&Definition], - pinned: &BTreeMap, - viewport: Option<&Definition>, -) -> Option { - let Some((first, rest)) = remaining.split_first() else { - return viewport_expression( - records, - definitions, - effect_name, - effect_value, - path, - prop, - baseline, - pinned, - viewport, - ); - }; - - // Whether the set actually holds a variant for this combination. An option - // with no value can mean two different things, and they call for opposite - // answers: a variant that exists and leaves the prop unset is a hole, and - // filling it would give that variant a value it refused; a combination the - // designer never drew is not a hole at all, and leaving a map entry for it - // only makes the map longer. So presence is asked of the record, not of the - // prop. - let drawn = |pinned: &BTreeMap| { - let filters = definitions - .iter() - .filter(|definition| !definition.boolean) - .map(|definition| { - let value = if Some(definition.name.as_str()) == effect_name { - effect_value - } else if let Some(value) = pinned.get(&definition.name) { - value.as_str() - } else { - &definition.default - }; - (definition.name.clone(), value.to_owned()) - }) - .collect::>(); - find_record(records, &filters).is_some() - }; - - let branch = |dimension: &Definition, others: Vec<&Definition>| { - dimension - .options - .iter() - .map(|option| { - let mut pinned = pinned.clone(); - pinned.insert(dimension.name.clone(), option.clone()); - ( - option.clone(), - nested_expression( - records, - definitions, - effect_name, - effect_value, - path, - prop, - baseline, - &others, - &pinned, - viewport, - ), - ) - }) - .filter_map(|(option, value)| Some((option, value?))) - .collect::>() - }; - - if rest.is_empty() { - let values = branch(first, Vec::new()); - if values.is_empty() { - return None; - } - // Every option that was drawn saying the same thing is not a choice at - // all. Counted against the options that exist rather than all of them, - // so a prop only one variant sets stays a choice while a value shared - // by every variant there is collapses. - let drawn_options = first - .options - .iter() - .filter(|option| { - let mut pinned = pinned.clone(); - pinned.insert(first.name.clone(), (*option).clone()); - drawn(&pinned) - }) - .count(); - if values.len() == drawn_options - && values.iter().all(|(_, value)| { - matches!((value, &values[0].1), (Expression::Literal(left), Expression::Literal(right)) if left == right) - }) - { - return Some(values[0].1.clone()); - } - // One option carrying the only value reads better as the condition it - // is than as a map with a single entry and a hole where the rest were. - if values.len() == 1 - && let Expression::Literal(value) = &values[0].1 - { - return Some(Expression::Conditional( - first.name.clone(), - values[0].0.clone(), - value.clone(), - )); - } - return Some(Expression::Variant(first.name.clone(), values)); - } - - let mut best: Option<(usize, Expression)> = None; - for candidate in remaining { - let others = remaining - .iter() - .filter(|other| other.name != candidate.name) - .copied() - .collect::>(); - let values = branch(candidate, others); - if values.is_empty() { - continue; - } - // The plugin's cost: every branch that still nests counts, and the - // number of entries breaks ties at a tenth of the weight. Kept in - // tenths so the comparison is exact rather than a float one. - let cost = values - .iter() - .map(|(_, value)| nesting_cost(value) * 10) - .sum::() - + values.len(); - let expression = Expression::Variant(candidate.name.clone(), values); - if best.as_ref().is_none_or(|(best_cost, _)| cost < *best_cost) { - best = Some((cost, expression)); - } - } - best.map(|(_, expression)| expression) -} - -#[allow(clippy::too_many_arguments)] -fn viewport_expression( - records: &[Record], - definitions: &[Definition], - effect_name: Option<&str>, - effect_value: &str, - path: &NodePath, - prop: &str, - baseline: Option<&str>, - pinned: &BTreeMap, - viewport: Option<&Definition>, -) -> Option { - let lookup = |viewport_value: Option<&str>| { - let filters = definitions - .iter() - .filter(|definition| !definition.boolean) - .map(|definition| { - let value = if Some(definition.name.as_str()) == effect_name { - effect_value - } else if definition.name.eq_ignore_ascii_case("viewport") { - viewport_value.unwrap_or(&definition.default) - } else if let Some(value) = pinned.get(&definition.name) { - value.as_str() - } else { - &definition.default - }; - (definition.name.clone(), value.to_owned()) - }) - .collect::>(); - find_record(records, &filters) - .and_then(|record| record_value(records, record, effect_name, baseline, path, prop)) - }; - let Some(viewport) = viewport else { - return lookup(None).map(Expression::Literal); - }; - let mobile = viewport - .options - .iter() - .find(|value| value.eq_ignore_ascii_case("mobile")) - .and_then(|value| lookup(Some(value))); - let desktop = viewport - .options - .iter() - .find(|value| !value.eq_ignore_ascii_case("mobile")) - .and_then(|value| lookup(Some(value))); - if mobile == desktop { - mobile.or(desktop).map(Expression::Literal) - } else { - // A component's `viewport` variant only ever names two widths, and the - // wider one is written at the last slot rather than the second, so the - // value it carries starts applying at the widest breakpoint. - Some(Expression::Responsive(vec![ - mobile, None, None, None, desktop, - ])) - } -} - -fn tree_at<'a>(tree: &'a Tree, path: &NodePath) -> Option<&'a Tree> { - path.iter().try_fold(tree, |current, (key, occurrence)| { - let index = child_keys(current) - .into_iter() - .position(|(candidate, at)| candidate == *key && at == *occurrence)?; - current.children.get(index) - }) -} - -fn transition( - snapshot: &Snapshot, - default_id: &str, - props: &BTreeSet, -) -> Option<(String, String)> { - let node = snapshot.nodes.get(default_id)?; - let transition = node - .typed_view() - .value("reactions")? - .as_array()? - .iter() - .find_map(|reaction| { - reaction - .get("transition") - .or_else(|| reaction.get("action")?.get("transition")) - })?; - let duration = transition.get("duration")?.as_f64()?; - let easing = transition - .get("easing") - .and_then(|easing| easing.get("type")) - .and_then(Value::as_str) - .unwrap_or("EASE_IN_OUT") - .trim_start_matches("EASE_") - .to_ascii_lowercase() - .replace('_', "-"); - let properties = props - .iter() - .map(|prop| match prop.as_str() { - "bg" => "background", - "boxShadow" => "box-shadow", - value => value, - }) - .collect::>() - .join(","); - Some(( - format!("{}ms ease-{easing}", layout::format_number(duration)), - properties, - )) -} - -fn render_component( - name: &str, - dimensions: &[Definition], - tree: &Tree, - selectors: &BTreeMap>, - transition: Option<&(String, String)>, -) -> String { - let interface = if dimensions.is_empty() { - String::new() - } else { - let fields = dimensions - .iter() - .map(|definition| { - if definition.boolean { - // Optional, because leaving it out is how a caller says the - // child should not be drawn. - return format!(" {}?: boolean", definition.name); - } - format!( - " {}: {}", - definition.name, - definition - .options - .iter() - .map(|value| format!("'{value}'")) - .collect::>() - .join(" | ") - ) - }) - .collect::>() - .join("\n"); - format!("export interface {name}Props {{\n{fields}\n}}\n\n") - }; - let signature = if dimensions.is_empty() { - format!("export function {name}()") - } else { - let props = dimensions - .iter() - .map(|definition| definition.name.as_str()) - .collect::>() - .join(", "); - format!("export function {name}({{ {props} }}: {name}Props)") - }; - let body = render_tree(tree, selectors, transition, 2); - format!("{interface}{signature} {{\n return (\n{body}\n )\n}}") -} - -fn render_tree( - tree: &Tree, - selectors: &BTreeMap>, - transition: Option<&(String, String)>, - depth: usize, -) -> String { - let indent = " ".repeat(depth); - let child_indent = " ".repeat(depth + 1); - let mut rendered_props = Vec::new(); - for (selector, props) in selectors { - let values = props - .iter() - .map(|(name, expression)| { - format!( - "{}\"{name}\": {}", - " ".repeat(depth + 2), - render_expression_value(expression, depth + 2) - ) - }) - .collect::>() - .join(",\n"); - rendered_props.push(format!( - "{child_indent}{selector}={{{{\n{values}\n{child_indent}}}}}" - )); - } - for (name, expression) in &tree.props { - rendered_props.push(format!( - "{child_indent}{name}{}", - indent_attribute_expression(expression, depth + 1) - )); - } - if let Some((value, properties)) = transition { - rendered_props.push(format!("{child_indent}transition=\"{value}\"")); - rendered_props.push(format!("{child_indent}transitionProperty=\"{properties}\"")); - } - let mut children = tree - .children - .iter() - .map(|child| render_tree(child, &BTreeMap::new(), None, depth + 1)) - .collect::>(); - if let Some(content) = &tree.content { - children.push( - content - .lines() - .map(|line| format!("{child_indent}{line}")) - .collect::>() - .join("\n"), - ); - } - // An element with nothing in it closes on itself, as the plugin's - // `renderNode` closes it — a folded icon was being written as an open - // and a close tag with nothing between. - let rendered = match (rendered_props.is_empty(), children.is_empty()) { - (true, true) => format!("{indent}<{} />", tree.component), - (false, true) => format!( - "{indent}<{}\n{}\n{indent}/>", - tree.component, - rendered_props.join("\n") - ), - (true, false) => format!( - "{indent}<{}>\n{}\n{indent}", - tree.component, - children.join("\n"), - tree.component - ), - (false, false) => format!( - "{indent}<{}\n{}\n{indent}>\n{}\n{indent}", - tree.component, - rendered_props.join("\n"), - children.join("\n"), - tree.component - ), - }; - // A node a boolean property switches on is written as that condition. The - // brace has to wrap the whole element, so it is applied after the element - // is rendered rather than woven into it. - let rendered = match &tree.leading_comment { - Some(comment) => format!("{indent}{{/* {comment} */}}\n{rendered}"), - None => rendered, - }; - let guards = tree - .visible_when - .iter() - .cloned() - .chain(tree.drawn_when.iter().cloned()) - .collect::>(); - let rendered = if guards.is_empty() { - rendered - } else { - let inner = rendered - .lines() - .map(|line| format!(" {line}")) - .collect::>() - .join("\n"); - format!( - "{indent}{{{} && (\n{inner}\n{indent})}}", - guards.join(" && ") - ) - }; - tree.source_node_ids - .iter() - .rev() - .fold(rendered, |rendered, node_id| mark_node(node_id, rendered)) -} - -/// `typography` names a token, and devup-ui types it by the literal. Handing it -/// a map indexed at runtime widens every entry to `string` and the token is no -/// longer one, so the object is frozen: `({ lg: "buttonLg", … } as const)[size]`. -/// It is the only prop that needs it, which is how the reference writes it too. -fn needs_const(prop: &str) -> bool { - prop == "typography" -} - -fn render_expression_attr(prop: &str, expression: &Expression) -> String { - match expression { - Expression::Literal(value) => format!("=\"{value}\""), - Expression::Conditional(name, option, value) => { - format!("={{{name} === '{option}' && \"{value}\"}}") - } - Expression::Variant(name, _) if needs_const(prop) => { - let rendered = render_expression_value(expression, 0); - let object = rendered - .strip_suffix(&format!("[{name}]")) - .unwrap_or(&rendered); - format!("={{({object} as const)[{name}]}}") - } - _ => format!("={{{}}}", render_expression_value(expression, 0)), - } -} - -fn indent_attribute_expression(expression: &str, depth: usize) -> String { - if !expression.contains('\n') { - return expression.to_owned(); - } - let indent = " ".repeat(depth); - let child_indent = " ".repeat(depth + 1); - expression - .lines() - .enumerate() - .map(|(index, line)| { - if index == 0 { - line.to_owned() - } else if let Some(line) = line.strip_prefix(" ") { - format!("{child_indent}{line}") - } else { - format!("{indent}{line}") - } - }) - .collect::>() - .join("\n") -} - -fn render_expression_value(expression: &Expression, depth: usize) -> String { - match expression { - Expression::Literal(value) => format!("\"{value}\""), - Expression::Responsive(slots) => format!( - "[{}]", - slots - .iter() - .map(|slot| slot - .as_ref() - .map_or("null".to_owned(), |value| format!("\"{value}\""))) - .collect::>() - .join(", ") - ), - Expression::Variant(prop, values) => { - let indent = " ".repeat(depth); - let child_indent = " ".repeat(depth + 1); - let lines = values - .iter() - .map(|(key, value)| { - format!( - "{child_indent}{key}: {}", - render_expression_value(value, depth + 1) - ) - }) - .collect::>() - .join(",\n"); - format!("{{\n{lines}\n{indent}}}[{prop}]") - } - Expression::Conditional(prop, option, value) => { - format!("{prop} === '{option}' && \"{value}\"") - } - } -} - -fn collect_imports(tree: &Tree, imports: &mut BTreeSet) { - imports.insert(tree.component.clone()); - for child in &tree.children { - collect_imports(child, imports); - } -} +// Source sections share this module to preserve privacy and test names. +include!("variant/variant_projection.rs"); +include!("variant/drawn_when.rs"); diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs new file mode 100644 index 00000000..dcc95879 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/drawn_when.rs @@ -0,0 +1,812 @@ + + +/// The conditions under which a node is drawn at all. +/// +/// Not every variant holds every node: a `tag`-sized button has no icon, so the +/// merged tree — built from the default variant, which does — would draw one at +/// every size. The dimensions whose options disagree about the node's existence +/// become the condition guarding it. +/// +/// Existence is judged by what the node *is* rather than by whether something +/// sits at the same index, because a variant that dropped a child shifts every +/// child after it up one place. +fn drawn_when( + records: &[Record], + dimensions: &[Definition], + effect_value: &str, + path: &NodePath, + child: &Tree, +) -> Vec { + let mut conditions = Vec::new(); + for dimension in dimensions.iter().filter(|dimension| !dimension.boolean) { + let parent_path = &path[..path.len().saturating_sub(1)]; + let at = |option: &String, want_node: bool| { + records.iter().any(|record| { + record.values.get(&dimension.source) == Some(option) + && record.values.iter().all(|(name, value)| { + !name.eq_ignore_ascii_case("effect") || value == effect_value + }) + && (!want_node + || tree_at(&record.tree, parent_path).is_some_and(|parent| { + parent + .children + .iter() + .any(|sibling| sibling.component == child.component) + })) + }) + }; + let drawn = dimension + .options + .iter() + .filter(|option| at(option, false)) + .collect::>(); + let holding = drawn + .iter() + .filter(|option| at(option, true)) + .collect::>(); + if holding.is_empty() || holding.len() == drawn.len() { + continue; + } + conditions.push(format!( + "({})", + holding + .iter() + .map(|option| format!("{} === \"{option}\"", dimension.name)) + .collect::>() + .join(" || ") + )); + } + conditions +} + +fn merge_child_props( + tree: &mut Tree, + records: &[Record], + definitions: &[Definition], + dimensions: &[Definition], + viewport: Option<&Definition>, + effect_value: &str, + path: &NodePath, +) { + let keys = child_keys(tree); + for (index, child) in tree.children.iter_mut().enumerate() { + let mut child_path = path.to_vec(); + let Some(key) = keys.get(index).cloned() else { + continue; + }; + child_path.push(key); + child.drawn_when = drawn_when(records, dimensions, effect_value, &child_path, child); + // A text node's own words are as much a variant as the colour around + // them: `lg` says "buttonLg" where `tag` says "Tag". + if child.content.is_some() + && let Some(expression) = expression_for_prop( + records, + definitions, + dimensions, + viewport, + effect_value, + None, + &child_path, + CONTENT_FIELD, + ) + { + child.content = Some(match &expression { + Expression::Literal(value) => value.clone(), + other => format!("{{{}}}", render_expression_value(other, 0)), + }); + } + child.props = merged_props( + records, + definitions, + dimensions, + viewport, + effect_value, + Some(&child_path), + &child.props, + ); + merge_child_props( + child, + records, + definitions, + dimensions, + viewport, + effect_value, + &child_path, + ); + } +} + +fn changed_effect_props( + records: &[Record], + definitions: &[Definition], + dimensions: &[Definition], + viewport: Option<&Definition>, + default_effect: &str, + effect_value: &str, +) -> BTreeMap { + let effect_name = definitions + .iter() + .find(|definition| definition.name.eq_ignore_ascii_case("effect")) + .map(|definition| definition.name.clone()); + let keys = records + .iter() + .filter(|record| { + effect_name.as_ref().is_some_and(|name| { + record.values.get(name).map(String::as_str) == Some(effect_value) + }) + }) + .flat_map(|effect_record| { + let mut filters = effect_record.values.clone(); + if let Some(effect_name) = &effect_name { + filters.insert(effect_name.clone(), default_effect.to_owned()); + } + let base = find_record(records, &filters); + effect_record + .tree + .props + .iter() + .filter(move |(key, value)| { + base.and_then(|record| record.tree.props.get(*key)) != Some(*value) + }) + .map(|(key, _)| key.clone()) + }) + .collect::>(); + keys.into_iter() + .filter_map(|prop| { + let effect = expression_for_prop( + records, + definitions, + dimensions, + viewport, + effect_value, + // A selector block says what this state changes, so a value the + // state leaves alone is left out of it entirely. + Some(default_effect), + &[], + &prop, + )?; + Some((prop, effect)) + }) + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn expression_for_prop( + records: &[Record], + definitions: &[Definition], + dimensions: &[Definition], + viewport: Option<&Definition>, + effect_value: &str, + baseline: Option<&str>, + path: &NodePath, + prop: &str, +) -> Option { + let effect_name = definitions + .iter() + .find(|definition| definition.name.eq_ignore_ascii_case("effect")) + .map(|definition| definition.name.as_str()); + let dependencies = dimensions + .iter() + .filter(|dimension| !dimension.boolean) + .filter(|dimension| { + dimension_depends( + records, + definitions, + effect_name, + effect_value, + baseline, + path, + prop, + dimension, + ) + }) + .collect::>(); + if !dependencies.is_empty() { + return nested_expression( + records, + definitions, + effect_name, + effect_value, + path, + prop, + baseline, + &dependencies, + &BTreeMap::new(), + viewport, + ); + } + viewport_expression( + records, + definitions, + effect_name, + effect_value, + path, + prop, + baseline, + &BTreeMap::new(), + viewport, + ) +} + +/// What one record says about a prop, or nothing when a baseline is given and +/// it says the same as the baseline does. +/// +/// A `_hover` block is a difference, not a restatement. Asked for the whole +/// hover value it would repeat every size and colour that hover leaves alone, +/// and the one thing hover actually changes would be buried in it. +fn record_value( + records: &[Record], + record: &Record, + effect_name: Option<&str>, + baseline: Option<&str>, + path: &NodePath, + prop: &str, +) -> Option { + let value = tree_at(&record.tree, path).and_then(|tree| tree_field(tree, prop)); + let (Some(baseline), Some(effect_name)) = (baseline, effect_name) else { + return value; + }; + let mut filters = record.values.clone(); + filters.insert(effect_name.to_owned(), baseline.to_owned()); + let unchanged = find_record(records, &filters) + .and_then(|record| tree_at(&record.tree, path)) + .and_then(|tree| tree_field(tree, prop)); + if value == unchanged { None } else { value } +} + +#[allow(clippy::too_many_arguments)] +fn dimension_depends( + records: &[Record], + definitions: &[Definition], + effect_name: Option<&str>, + effect_value: &str, + baseline: Option<&str>, + path: &NodePath, + prop: &str, + dimension: &Definition, +) -> bool { + // A variant that does not draw the node has nothing to say about the + // prop: the node is guarded so that it is never rendered there, and + // counting its silence as a difference wrote the button icon's + // `aspectRatio={{lg: "1", md: "1", sm: "1"}[size]}` for a value every + // variant that draws it agrees on. A variant that draws the node and + // leaves the prop unset is a difference, and stays one. + let sets = dimension + .options + .iter() + .filter_map(|option| { + let set = records + .iter() + .filter(|record| { + record.values.get(&dimension.name) == Some(option) + && effect_name.is_none_or(|name| { + record.values.get(name).map(String::as_str) == Some(effect_value) + }) + && tree_at(&record.tree, path).is_some() + }) + .map(|record| record_value(records, record, effect_name, baseline, path, prop)) + .collect::>(); + (!set.is_empty()).then_some(set) + }) + .collect::>(); + let _ = definitions; + sets.iter().skip(1).any(|set| set != &sets[0]) +} + +/// How deeply an expression nests, so that the shallower of two ways to write +/// the same thing can be preferred. A plain value costs nothing; a map costs +/// one plus the worst of what it holds. +fn nesting_cost(expression: &Expression) -> usize { + match expression { + Expression::Variant(_, values) => { + 1 + values + .iter() + .map(|(_, value)| nesting_cost(value)) + .max() + .unwrap_or_default() + } + _ => 0, + } +} + +/// One prop's value where several dimensions decide it at once. +/// +/// A value that depends on two dimensions cannot be written as one map, and +/// writing it as the default's literal loses every other combination — which is +/// what this used to do. It is written as a map inside a map instead. +/// +/// Which dimension goes on the outside is not free: `{lg: {...}[varient], tag: +/// "10px"}[size]` and its transpose say the same thing at different sizes. Each +/// candidate outer dimension is costed by how deep the result nests, with the +/// number of entries as a tiebreak, and the cheapest is kept. That is the +/// plugin's `createNestedVariantProp`. +#[allow(clippy::too_many_arguments)] +fn nested_expression( + records: &[Record], + definitions: &[Definition], + effect_name: Option<&str>, + effect_value: &str, + path: &NodePath, + prop: &str, + baseline: Option<&str>, + remaining: &[&Definition], + pinned: &BTreeMap, + viewport: Option<&Definition>, +) -> Option { + let Some((first, rest)) = remaining.split_first() else { + return viewport_expression( + records, + definitions, + effect_name, + effect_value, + path, + prop, + baseline, + pinned, + viewport, + ); + }; + + // Whether the set actually holds a variant for this combination. An option + // with no value can mean two different things, and they call for opposite + // answers: a variant that exists and leaves the prop unset is a hole, and + // filling it would give that variant a value it refused; a combination the + // designer never drew is not a hole at all, and leaving a map entry for it + // only makes the map longer. So presence is asked of the record, not of the + // prop. + let drawn = |pinned: &BTreeMap| { + let filters = definitions + .iter() + .filter(|definition| !definition.boolean) + .map(|definition| { + let value = if Some(definition.name.as_str()) == effect_name { + effect_value + } else if let Some(value) = pinned.get(&definition.name) { + value.as_str() + } else { + &definition.default + }; + (definition.name.clone(), value.to_owned()) + }) + .collect::>(); + find_record(records, &filters).is_some() + }; + + let branch = |dimension: &Definition, others: Vec<&Definition>| { + dimension + .options + .iter() + .map(|option| { + let mut pinned = pinned.clone(); + pinned.insert(dimension.name.clone(), option.clone()); + ( + option.clone(), + nested_expression( + records, + definitions, + effect_name, + effect_value, + path, + prop, + baseline, + &others, + &pinned, + viewport, + ), + ) + }) + .filter_map(|(option, value)| Some((option, value?))) + .collect::>() + }; + + if rest.is_empty() { + let values = branch(first, Vec::new()); + if values.is_empty() { + return None; + } + // Every option that was drawn saying the same thing is not a choice at + // all. Counted against the options that exist rather than all of them, + // so a prop only one variant sets stays a choice while a value shared + // by every variant there is collapses. + let drawn_options = first + .options + .iter() + .filter(|option| { + let mut pinned = pinned.clone(); + pinned.insert(first.name.clone(), (*option).clone()); + drawn(&pinned) + }) + .count(); + if values.len() == drawn_options + && values.iter().all(|(_, value)| { + matches!((value, &values[0].1), (Expression::Literal(left), Expression::Literal(right)) if left == right) + }) + { + return Some(values[0].1.clone()); + } + // One option carrying the only value reads better as the condition it + // is than as a map with a single entry and a hole where the rest were. + if values.len() == 1 + && let Expression::Literal(value) = &values[0].1 + { + return Some(Expression::Conditional( + first.name.clone(), + values[0].0.clone(), + value.clone(), + )); + } + return Some(Expression::Variant(first.name.clone(), values)); + } + + let mut best: Option<(usize, Expression)> = None; + for candidate in remaining { + let others = remaining + .iter() + .filter(|other| other.name != candidate.name) + .copied() + .collect::>(); + let values = branch(candidate, others); + if values.is_empty() { + continue; + } + // The plugin's cost: every branch that still nests counts, and the + // number of entries breaks ties at a tenth of the weight. Kept in + // tenths so the comparison is exact rather than a float one. + let cost = values + .iter() + .map(|(_, value)| nesting_cost(value) * 10) + .sum::() + + values.len(); + let expression = Expression::Variant(candidate.name.clone(), values); + if best.as_ref().is_none_or(|(best_cost, _)| cost < *best_cost) { + best = Some((cost, expression)); + } + } + best.map(|(_, expression)| expression) +} + +#[allow(clippy::too_many_arguments)] +fn viewport_expression( + records: &[Record], + definitions: &[Definition], + effect_name: Option<&str>, + effect_value: &str, + path: &NodePath, + prop: &str, + baseline: Option<&str>, + pinned: &BTreeMap, + viewport: Option<&Definition>, +) -> Option { + let lookup = |viewport_value: Option<&str>| { + let filters = definitions + .iter() + .filter(|definition| !definition.boolean) + .map(|definition| { + let value = if Some(definition.name.as_str()) == effect_name { + effect_value + } else if definition.name.eq_ignore_ascii_case("viewport") { + viewport_value.unwrap_or(&definition.default) + } else if let Some(value) = pinned.get(&definition.name) { + value.as_str() + } else { + &definition.default + }; + (definition.name.clone(), value.to_owned()) + }) + .collect::>(); + find_record(records, &filters) + .and_then(|record| record_value(records, record, effect_name, baseline, path, prop)) + }; + let Some(viewport) = viewport else { + return lookup(None).map(Expression::Literal); + }; + let mobile = viewport + .options + .iter() + .find(|value| value.eq_ignore_ascii_case("mobile")) + .and_then(|value| lookup(Some(value))); + let desktop = viewport + .options + .iter() + .find(|value| !value.eq_ignore_ascii_case("mobile")) + .and_then(|value| lookup(Some(value))); + if mobile == desktop { + mobile.or(desktop).map(Expression::Literal) + } else { + // A component's `viewport` variant only ever names two widths, and the + // wider one is written at the last slot rather than the second, so the + // value it carries starts applying at the widest breakpoint. + Some(Expression::Responsive(vec![ + mobile, None, None, None, desktop, + ])) + } +} + +fn tree_at<'a>(tree: &'a Tree, path: &NodePath) -> Option<&'a Tree> { + path.iter().try_fold(tree, |current, (key, occurrence)| { + let index = child_keys(current) + .into_iter() + .position(|(candidate, at)| candidate == *key && at == *occurrence)?; + current.children.get(index) + }) +} + +fn transition( + snapshot: &Snapshot, + default_id: &str, + props: &BTreeSet, +) -> Option<(String, String)> { + let node = snapshot.nodes.get(default_id)?; + let transition = node + .typed_view() + .value("reactions")? + .as_array()? + .iter() + .find_map(|reaction| { + reaction + .get("transition") + .or_else(|| reaction.get("action")?.get("transition")) + })?; + let duration = transition.get("duration")?.as_f64()?; + let easing = transition + .get("easing") + .and_then(|easing| easing.get("type")) + .and_then(Value::as_str) + .unwrap_or("EASE_IN_OUT") + .trim_start_matches("EASE_") + .to_ascii_lowercase() + .replace('_', "-"); + let properties = props + .iter() + .map(|prop| match prop.as_str() { + "bg" => "background", + "boxShadow" => "box-shadow", + value => value, + }) + .collect::>() + .join(","); + Some(( + format!("{}ms ease-{easing}", layout::format_number(duration)), + properties, + )) +} + +fn render_component( + name: &str, + dimensions: &[Definition], + tree: &Tree, + selectors: &BTreeMap>, + transition: Option<&(String, String)>, +) -> String { + let interface = if dimensions.is_empty() { + String::new() + } else { + let fields = dimensions + .iter() + .map(|definition| { + if definition.boolean { + // Optional, because leaving it out is how a caller says the + // child should not be drawn. + return format!(" {}?: boolean", definition.name); + } + format!( + " {}: {}", + definition.name, + definition + .options + .iter() + .map(|value| format!("'{value}'")) + .collect::>() + .join(" | ") + ) + }) + .collect::>() + .join("\n"); + format!("export interface {name}Props {{\n{fields}\n}}\n\n") + }; + let signature = if dimensions.is_empty() { + format!("export function {name}()") + } else { + let props = dimensions + .iter() + .map(|definition| definition.name.as_str()) + .collect::>() + .join(", "); + format!("export function {name}({{ {props} }}: {name}Props)") + }; + let body = render_tree(tree, selectors, transition, 2); + format!("{interface}{signature} {{\n return (\n{body}\n )\n}}") +} + +fn render_tree( + tree: &Tree, + selectors: &BTreeMap>, + transition: Option<&(String, String)>, + depth: usize, +) -> String { + let indent = " ".repeat(depth); + let child_indent = " ".repeat(depth + 1); + let mut rendered_props = Vec::new(); + for (selector, props) in selectors { + let values = props + .iter() + .map(|(name, expression)| { + format!( + "{}\"{name}\": {}", + " ".repeat(depth + 2), + render_expression_value(expression, depth + 2) + ) + }) + .collect::>() + .join(",\n"); + rendered_props.push(format!( + "{child_indent}{selector}={{{{\n{values}\n{child_indent}}}}}" + )); + } + for (name, expression) in &tree.props { + rendered_props.push(format!( + "{child_indent}{name}{}", + indent_attribute_expression(expression, depth + 1) + )); + } + if let Some((value, properties)) = transition { + rendered_props.push(format!("{child_indent}transition=\"{value}\"")); + rendered_props.push(format!("{child_indent}transitionProperty=\"{properties}\"")); + } + let mut children = tree + .children + .iter() + .map(|child| render_tree(child, &BTreeMap::new(), None, depth + 1)) + .collect::>(); + if let Some(content) = &tree.content { + children.push( + content + .lines() + .map(|line| format!("{child_indent}{line}")) + .collect::>() + .join("\n"), + ); + } + // An element with nothing in it closes on itself, as the plugin's + // `renderNode` closes it — a folded icon was being written as an open + // and a close tag with nothing between. + let rendered = match (rendered_props.is_empty(), children.is_empty()) { + (true, true) => format!("{indent}<{} />", tree.component), + (false, true) => format!( + "{indent}<{}\n{}\n{indent}/>", + tree.component, + rendered_props.join("\n") + ), + (true, false) => format!( + "{indent}<{}>\n{}\n{indent}", + tree.component, + children.join("\n"), + tree.component + ), + (false, false) => format!( + "{indent}<{}\n{}\n{indent}>\n{}\n{indent}", + tree.component, + rendered_props.join("\n"), + children.join("\n"), + tree.component + ), + }; + // A node a boolean property switches on is written as that condition. The + // brace has to wrap the whole element, so it is applied after the element + // is rendered rather than woven into it. + let rendered = match &tree.leading_comment { + Some(comment) => format!("{indent}{{/* {comment} */}}\n{rendered}"), + None => rendered, + }; + let guards = tree + .visible_when + .iter() + .cloned() + .chain(tree.drawn_when.iter().cloned()) + .collect::>(); + let rendered = if guards.is_empty() { + rendered + } else { + let inner = rendered + .lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n"); + format!( + "{indent}{{{} && (\n{inner}\n{indent})}}", + guards.join(" && ") + ) + }; + tree.source_node_ids + .iter() + .rev() + .fold(rendered, |rendered, node_id| mark_node(node_id, rendered)) +} + +/// `typography` names a token, and devup-ui types it by the literal. Handing it +/// a map indexed at runtime widens every entry to `string` and the token is no +/// longer one, so the object is frozen: `({ lg: "buttonLg", … } as const)[size]`. +/// It is the only prop that needs it, which is how the reference writes it too. +fn needs_const(prop: &str) -> bool { + prop == "typography" +} + +fn render_expression_attr(prop: &str, expression: &Expression) -> String { + match expression { + Expression::Literal(value) => format!("=\"{value}\""), + Expression::Conditional(name, option, value) => { + format!("={{{name} === '{option}' && \"{value}\"}}") + } + Expression::Variant(name, _) if needs_const(prop) => { + let rendered = render_expression_value(expression, 0); + let object = rendered + .strip_suffix(&format!("[{name}]")) + .unwrap_or(&rendered); + format!("={{({object} as const)[{name}]}}") + } + _ => format!("={{{}}}", render_expression_value(expression, 0)), + } +} + +fn indent_attribute_expression(expression: &str, depth: usize) -> String { + if !expression.contains('\n') { + return expression.to_owned(); + } + let indent = " ".repeat(depth); + let child_indent = " ".repeat(depth + 1); + expression + .lines() + .enumerate() + .map(|(index, line)| { + if index == 0 { + line.to_owned() + } else if let Some(line) = line.strip_prefix(" ") { + format!("{child_indent}{line}") + } else { + format!("{indent}{line}") + } + }) + .collect::>() + .join("\n") +} + +fn render_expression_value(expression: &Expression, depth: usize) -> String { + match expression { + Expression::Literal(value) => format!("\"{value}\""), + Expression::Responsive(slots) => format!( + "[{}]", + slots + .iter() + .map(|slot| slot + .as_ref() + .map_or("null".to_owned(), |value| format!("\"{value}\""))) + .collect::>() + .join(", ") + ), + Expression::Variant(prop, values) => { + let indent = " ".repeat(depth); + let child_indent = " ".repeat(depth + 1); + let lines = values + .iter() + .map(|(key, value)| { + format!( + "{child_indent}{key}: {}", + render_expression_value(value, depth + 1) + ) + }) + .collect::>() + .join(",\n"); + format!("{{\n{lines}\n{indent}}}[{prop}]") + } + Expression::Conditional(prop, option, value) => { + format!("{prop} === '{option}' && \"{value}\"") + } + } +} + +fn collect_imports(tree: &Tree, imports: &mut BTreeSet) { + imports.insert(tree.component.clone()); + for child in &tree.children { + collect_imports(child, imports); + } +} diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs b/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs new file mode 100644 index 00000000..2b3e099b --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/codegen/variant/variant_projection.rs @@ -0,0 +1,946 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use devup_mcp_figma::{ + DevupError, Diagnostic, DiagnosticSeverity, ErrorCode, FidelityImpact, RawNode, Snapshot, +}; +use serde_json::Value; + +use super::{ + component::{CodegenOptions, CodegenOutput, PropValue, legacy_component_name}, + layout, style, text, +}; +use crate::provenance::mark_node; + +#[derive(Clone, Debug)] +pub(super) struct Tree { + pub node_id: String, + /// What the node is called in Figma. Only used to pair a node with its + /// counterpart at another width when shape alone cannot tell them apart. + pub node_name: String, + pub source_node_ids: BTreeSet, + pub component: String, + pub props: BTreeMap, + pub children: Vec, + pub content: Option, + /// True when `component` names a component to reference rather than a + /// devup-ui primitive, so the renderer must not descend into it. + pub is_component: bool, + /// The boolean property that decides whether this node is drawn, if one + /// does. Figma keeps it on the child rather than on the set. + pub visible_when: Option, + /// The variant options this node is drawn at, when it is not drawn at all + /// of them. Rendered as the condition guarding it. + pub drawn_when: Vec, + /// A JSX comment to write immediately above this node, naming the + /// component it was spelled out from. + pub leading_comment: Option, + /// In a responsive merge, the node each slot's width drew this element + /// from and that node's text; empty everywhere else. + pub slots: Vec)>>, +} + +#[derive(Clone, Debug)] +struct Record { + values: BTreeMap, + tree: Tree, + node_id: String, +} + +#[derive(Clone, Debug)] +struct Definition { + /// The name as it is written in code. + name: String, + default: String, + options: Vec, + /// A switch rather than a choice: it has no options, and a child names it + /// to say when it is drawn. + boolean: bool, + /// The name Figma knows it by, which a child's `visible` reference uses and + /// which may carry a `#id` suffix that `name` has dropped. + source: String, +} + +#[derive(Clone, Debug)] +enum Expression { + Literal(String), + /// A devup-ui responsive array, written out slot by slot, narrowest first. + /// `None` is the literal `null`, which is not "no value" but "whatever the + /// slot before it said" — so where a value sits decides the width it starts + /// applying at, and the slots have to be placed, not merely listed. + Responsive(Vec>), + Variant(String, Vec<(String, Expression)>), + Conditional(String, String, String), +} + +pub(super) fn generate_variant_component_set( + snapshot: &Snapshot, + set_id: &str, + options: &CodegenOptions, +) -> Result, DevupError> { + let set = snapshot.nodes.get(set_id).ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Variant component set was not found.", + false, + ) + })?; + let definitions = definitions(set); + if !definitions.iter().any(|definition| { + matches!( + definition.name.to_ascii_lowercase().as_str(), + "effect" | "viewport" + ) + }) { + return Ok(None); + } + let records = set + .typed_view() + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .filter_map(|node| { + let values = node + .typed_view() + .value("variantProperties")? + .as_object()? + .iter() + .filter_map(|(key, value)| Some((key.clone(), value.as_str()?.to_owned()))) + .collect::>(); + Some((node, values)) + }) + .map(|(node, values)| { + Ok(Record { + values, + tree: project_tree(snapshot, node, options, true)?, + node_id: node.id.clone(), + }) + }) + .collect::, DevupError>>()?; + // A boolean property is not part of a variant's identity — it never appears + // in `variantProperties` — so filtering records by it matches nothing. + let default_filters = definitions + .iter() + .filter(|definition| !definition.boolean) + .map(|definition| (definition.source.clone(), definition.default.clone())) + .collect::>(); + let default = find_record(&records, &default_filters) + .or_else(|| records.first()) + .ok_or_else(|| { + DevupError::new( + ErrorCode::DevupCodegenFailed, + "Component set has no variant components.", + false, + ) + })?; + let dimensions = definitions + .iter() + .filter(|definition| { + !matches!( + definition.name.to_ascii_lowercase().as_str(), + "effect" | "viewport" + ) + }) + .cloned() + .collect::>(); + let effect = definitions + .iter() + .find(|definition| definition.name.eq_ignore_ascii_case("effect")); + let viewport = definitions + .iter() + .find(|definition| definition.name.eq_ignore_ascii_case("viewport")); + + let mut root = default.tree.clone(); + let mut unrepresented_variant_nodes = BTreeSet::new(); + merge_source_node_ids( + &mut root, + &records, + &[], + &mut unrepresented_variant_nodes, + effect.map(|definition| definition.name.as_str()), + &default.values, + ); + root.props = merged_props( + &records, + &definitions, + &dimensions, + viewport, + effect.map_or("default", |definition| definition.default.as_str()), + None, + &default.tree.props, + ); + merge_child_props( + &mut root, + &records, + &definitions, + &dimensions, + viewport, + effect.map_or("default", |definition| definition.default.as_str()), + &[], + ); + + let mut selectors = BTreeMap::new(); + let mut transition_props = BTreeSet::new(); + if let Some(effect) = effect { + for effect_value in &effect.options { + if effect_value == &effect.default { + continue; + } + let changed = changed_effect_props( + &records, + &definitions, + &dimensions, + viewport, + &effect.default, + effect_value, + ); + if !changed.is_empty() { + transition_props.extend(changed.keys().cloned()); + selectors.insert(format!("_{effect_value}"), changed); + } + } + } + let transition = transition(snapshot, &default.node_id, &transition_props); + let component_name = legacy_component_name(set.typed_view().name().unwrap_or("Component")); + let code = mark_node( + &set.id, + render_component( + &component_name, + &dimensions, + &root, + &selectors, + transition.as_ref(), + ), + ); + let mut imports = BTreeSet::new(); + collect_imports(&root, &mut imports); + Ok(Some(CodegenOutput { + tsx: code, + imports: imports.into_iter().collect(), + used_tokens: BTreeSet::new(), + diagnostics: unrepresented_variant_nodes + .into_iter() + .map(|node_id| Diagnostic { + code: "DEVUP_CODEGEN_VARIANT_CHILD_FALLBACK".to_owned(), + 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), + fidelity_impact: Some(FidelityImpact::Lossy), + fallback: Some("default-variant-structure".to_owned()), + ..Diagnostic::default() + }) + .collect(), + source_map: crate::provenance::SourceMap::empty(), + projection_trace: crate::provenance::ProjectionTrace::default(), + fidelity_report: crate::provenance::FidelityReport::default(), + width_policy: None, + })) +} + +fn definitions(set: &RawNode) -> Vec { + set.typed_view() + .value("componentPropertyDefinitions") + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter_map(|(name, definition)| { + let boolean = match definition.get("type").and_then(Value::as_str)? { + "VARIANT" => false, + // A boolean property does not name variants; it switches a + // child on and off through `componentPropertyReferences`. It + // belongs in the props all the same, or the component cannot be + // told to leave its icon out. + "BOOLEAN" => true, + _ => return None, + }; + Some(Definition { + name: instance_property_name(name), + default: definition + .get("defaultValue") + .and_then(|value| { + value + .as_str() + .map(str::to_owned) + .or_else(|| value.as_bool().map(|value| value.to_string())) + }) + .unwrap_or_default(), + options: definition + .get("variantOptions") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + boolean, + source: name.clone(), + }) + }) + .collect() +} + +pub(super) fn project_tree( + snapshot: &Snapshot, + node: &RawNode, + options: &CodegenOptions, + is_render_root: bool, +) -> Result { + project_tree_inner(snapshot, node, options, is_render_root, false) +} + +/// As [`project_tree`], but instances become component references instead of +/// being expanded. +/// +/// The variant path must keep expanding them — a component set's variants are +/// merged from the inside, and the pinned corpus records that output — so this +/// is a second entry rather than a change to the first. +pub(super) fn project_tree_keeping_instances( + snapshot: &Snapshot, + node: &RawNode, + options: &CodegenOptions, + is_render_root: bool, +) -> Result { + project_tree_inner(snapshot, node, options, is_render_root, true) +} + +/// The boolean component property that decides whether a node is drawn. +/// +/// Figma records this on the child, as a reference back to a property the set +/// declares, which is why a set's definitions alone never reveal what a boolean +/// property actually does. +fn visible_when(view: &devup_mcp_figma::TypedNode<'_>) -> Option { + view.value("componentPropertyReferences") + .and_then(Value::as_object) + .and_then(|references| references.get("visible")) + .and_then(Value::as_str) + .map(instance_property_name) +} + +/// Whether a projected node is just an asset: one shape and nothing else. +/// This is the plugin's `isAssetLeafTree`. +fn is_asset_leaf(tree: &Tree) -> bool { + tree.children.is_empty() + && tree.content.is_none() + && ((tree.component == "Image" && tree.props.contains_key("src")) + || (tree.component == "Box" && tree.props.contains_key("maskImage"))) +} + +/// Variant properties a component set uses to describe itself rather than to +/// be told something, so an instance must not pass them. +/// +/// `effect` names the interaction state a variant stands for, and the whole +/// point of it is that the *definition* folds those variants into `_hover` and +/// `_active` blocks. A call site has no state to pass — writing +/// `` asks the component for a prop it does not have. +/// `viewport` is the same story for widths. +const RESERVED_VARIANT_KEYS: [&str; 2] = ["effect", "viewport"]; + +/// What a placed instance carries about where it sits rather than what it is. +/// The renderer moves these onto a wrapping `Box`, since a component reference +/// has nowhere to put them. +pub(super) const POSITION_PROPS: [&str; 7] = [ + "pos", + "top", + "right", + "bottom", + "left", + "transform", + "transformOrigin", +]; + +/// A component property's name as it is written in code. +/// +/// This is the plugin's `sanitizePropertyName`. Figma names the first variant +/// property for the editor's language, so a file authored in Korean calls it +/// `속성 1` — and a file where someone typed it in English calls it +/// `Property 1`. Both become one identifier, and which one matters: the +/// definition the plugin emits for the same component declares that exact name. +/// +/// Note what it does *not* do. The first word keeps its case, so `Property 1` +/// is `Property1` and not `property1`; folding it turned `leftIcon` into +/// `lefticon`. And only a trailing `#:` is stripped, since +/// that is Figma's own suffix rather than a character a name may not contain. +fn instance_property_name(raw: &str) -> String { + let stripped = raw + .rsplit_once('#') + .filter(|(_, suffix)| { + suffix.split_once(':').is_some_and(|(left, right)| { + !left.is_empty() + && !right.is_empty() + && left.bytes().all(|byte| byte.is_ascii_digit()) + && right.bytes().all(|byte| byte.is_ascii_digit()) + }) + }) + .map_or(raw, |(base, _)| base); + + // The Korean word takes any space after it with it, so `속성 1` is + // `property1` rather than `property 1` waiting to be camel-cased. + let mut normalized = String::with_capacity(stripped.len()); + let mut rest = stripped.trim(); + while let Some(at) = rest.find("속성") { + normalized.push_str(&rest[..at]); + normalized.push_str("property"); + rest = rest[at + "속성".len()..].trim_start(); + } + normalized.push_str(rest); + + let mut name = String::with_capacity(normalized.len()); + let mut capitalize = false; + for character in normalized.chars() { + if character.is_whitespace() || character == '-' || character == '_' { + capitalize = true; + continue; + } + if capitalize { + name.extend(character.to_uppercase()); + capitalize = false; + } else { + name.push(character); + } + } + if name.starts_with(|character: char| character.is_ascii_digit()) { + name.insert(0, '_'); + } + name.retain(|character| { + character.is_ascii_alphanumeric() || character == '_' || character == '$' + }); + if name.is_empty() || name.bytes().all(|byte| byte.is_ascii_digit()) { + return "variant".to_owned(); + } + name +} + +/// The component an instance refers to: the longest `COMPONENT` name the +/// instance's own name starts with, which is how `render_node` resolves it. +pub(super) fn referenced_component_name(snapshot: &Snapshot, view_name: Option<&str>) -> String { + snapshot + .nodes + .values() + .filter(|candidate| candidate.typed_view().node_type() == "COMPONENT") + .filter_map(|candidate| candidate.typed_view().name()) + .filter(|name| view_name.is_some_and(|instance| instance.starts_with(name))) + .max_by_key(|name| name.len()) + .map_or_else( + || legacy_component_name(view_name.unwrap_or("Component")), + legacy_component_name, + ) +} + +fn project_tree_inner( + snapshot: &Snapshot, + node: &RawNode, + options: &CodegenOptions, + is_render_root: bool, + keep_instances: bool, +) -> Result { + let view = node.typed_view(); + if keep_instances && view.node_type() == "INSTANCE" { + // An instance whose component is nothing but a shape is spelled out + // rather than referenced. `` would be a component whose entire + // body is one masked Box, so the reference costs a file and an import + // and says less than the Box does. The plugin does the same, and marks + // the spot with a `{/* */}` comment. + let mut props = instance_variant_props(&view); + let mut expanded = project_tree_inner(snapshot, node, options, is_render_root, false)?; + if is_asset_leaf(&expanded) || style::non_rendering_asset_reason(snapshot, node).is_some() { + // Spelling the shape out loses which component it came from, and + // that is the one thing a reader needs to change it in the right + // place. The reference leaves the call it declined to write. + let attributes = props + .iter() + .map(|(name, value)| format!(" {name}=\"{value}\"")) + .collect::>() + .join(""); + expanded.leading_comment = Some(format!( + "<{}{attributes} />", + referenced_component_name(snapshot, view.name()) + )); + return Ok(expanded); + } + // The variant each width picked, collected above. These are the + // instance's own choice, not something to merge — a component answers + // for its own widths. + + // Where the instance sits. `
` has nowhere to put this, so the + // renderer gives it a Box; keeping the values here lets them merge + // across widths first. + props.extend(instance_placement(snapshot, node, options, is_render_root)); + return Ok(Tree { + node_id: node.id.clone(), + node_name: view.name().unwrap_or_default().to_owned(), + source_node_ids: BTreeSet::from([node.id.clone()]), + component: referenced_component_name(snapshot, view.name()), + props, + children: Vec::new(), + content: None, + is_component: true, + visible_when: visible_when(&view), + drawn_when: Vec::new(), + leading_comment: None, + slots: Vec::new(), + }); + } + let asset = super::image::asset_kind(snapshot, node, &options.asset_policies); + let inferred_mode = view + .value("inferredAutoLayout") + .and_then(Value::as_object) + .and_then(|layout| layout.get("layoutMode")) + .and_then(Value::as_str); + let inferred_align = |name: &str| { + view.string(name).or_else(|| { + view.value("inferredAutoLayout") + .and_then(Value::as_object) + .and_then(|layout| layout.get(name)) + .and_then(Value::as_str) + }) + }; + let component = if asset == Some(style::AssetKind::SvgMask) + || (asset.is_some() && style::non_rendering_asset_reason(snapshot, node).is_some()) + { + "Box" + } else if asset.is_some() { + "Image" + } else if view.node_type() == "TEXT" { + "Text" + } else if layout::centres_its_only_child(snapshot, node) { + "Center" + } else { + match inferred_mode.or_else(|| view.string("layoutMode")) { + Some("GRID") => "Grid", + Some("HORIZONTAL" | "VERTICAL") + if inferred_align("primaryAxisAlignItems") == Some("CENTER") + && inferred_align("counterAxisAlignItems") == Some("CENTER") => + { + "Center" + } + Some("VERTICAL") => "VStack", + Some("HORIZONTAL") => "Flex", + _ => "Box", + } + } + .to_owned(); + let mut props = Vec::new(); + layout::push_layout_props( + snapshot, + node, + &component, + &mut props, + options.root_layout, + is_render_root, + None, + super::image::original_image(node, &options.asset_policies), + ); + let mut used_tokens = BTreeSet::new(); + style::push_style_props( + snapshot, + node, + &component, + asset, + &mut props, + &mut used_tokens, + style::StyleOptions { + reserve_image_content: !(is_render_root + && options.root_layout == super::RootLayout::Embedded), + asset_policies: &options.asset_policies, + variable_tokens: &options.variable_tokens, + length_tokens: &options.length_tokens, + asset_names_per_node: options.asset_names_per_node, + width_policy: None, + }, + ); + text::push_text_props( + &view, + &options.text_style_tokens, + &options.typography_values, + &options.variable_tokens, + &mut used_tokens, + &mut props, + ); + style::tokenize_lengths( + snapshot, + node, + &mut props, + &options.length_tokens, + &mut used_tokens, + ); + if asset.is_some() { + props.retain(|(name, _)| { + !matches!( + name.as_str(), + "alignItems" + | "justifyContent" + | "flexDir" + | "gap" + | "outline" + | "outlineOffset" + | "overflow" + | "p" + | "px" + | "py" + | "pt" + | "pr" + | "pb" + | "pl" + ) + }); + } + let props = props + .into_iter() + .map(|(name, value)| match value { + PropValue::String(value) => (name, value), + }) + .collect(); + let children = if asset.is_some() { + Vec::new() + } else { + view.child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .map(|child| project_tree_inner(snapshot, child, options, false, keep_instances)) + .collect::, _>>()? + }; + let content = (view.node_type() == "TEXT").then(|| { + text::render_text_children( + &view, + &options.text_style_tokens, + &options.typography_values, + &options.variable_tokens, + &mut used_tokens, + 0, + ) + }); + let tree = Tree { + node_id: node.id.clone(), + node_name: view.name().unwrap_or_default().to_owned(), + source_node_ids: BTreeSet::from([node.id.clone()]), + component, + props, + children, + content, + is_component: false, + visible_when: visible_when(&view), + drawn_when: Vec::new(), + leading_comment: None, + slots: Vec::new(), + }; + Ok(super::image::project_tree(tree, snapshot, node, options)) +} + +/// The variant an instance picked, by the name a reference to its component +/// passes it as. +pub(super) fn instance_variant_props( + view: &devup_mcp_figma::TypedNode<'_>, +) -> BTreeMap { + let mut props = BTreeMap::new(); + for (raw, definition) in view + .value("componentProperties") + .and_then(Value::as_object) + .into_iter() + .flatten() + { + if definition.get("type").and_then(Value::as_str) != Some("VARIANT") { + continue; + } + let name = instance_property_name(raw); + if RESERVED_VARIANT_KEYS + .iter() + .any(|reserved| name.eq_ignore_ascii_case(reserved)) + { + continue; + } + if let Some(value) = definition.get("value").and_then(Value::as_str) { + props.insert(name, value.to_owned()); + } + } + props +} + +/// Where a referenced instance sits in its parent: what the `Box` wrapped +/// around the reference carries, since the reference has nowhere to put it. +pub(super) fn instance_placement( + snapshot: &Snapshot, + node: &RawNode, + options: &CodegenOptions, + is_render_root: bool, +) -> BTreeMap { + if node.typed_view().string("layoutPositioning") != Some("ABSOLUTE") { + return BTreeMap::new(); + } + let mut placement = Vec::new(); + layout::push_layout_props( + snapshot, + node, + "Box", + &mut placement, + options.root_layout, + is_render_root, + None, + super::image::original_image(node, &options.asset_policies), + ); + placement + .into_iter() + .filter_map(|(name, PropValue::String(value))| { + (POSITION_PROPS.contains(&name.as_str()) || (name == "w" && value == "100%")) + .then_some((name, value)) + }) + .collect() +} + +fn merge_source_node_ids( + tree: &mut Tree, + records: &[Record], + path: &NodePath, + unrepresented: &mut BTreeSet, + effect_name: Option<&str>, + default_values: &BTreeMap, +) { + for record in records { + let Some(source) = tree_at(&record.tree, path) else { + // A variant that does not hold this node at all is not a shape + // difference the merge hid: `drawn_when` states the condition it is + // drawn under, so the absence is carried rather than lost. + continue; + }; + if !same_rendered_node_kind(tree, source) { + unrepresented.insert(source.node_id.clone()); + continue; + } + // Differing values are usually the point of the exercise — they become + // the maps — so a value difference is not on its own something the + // merge papered over. The exception is a difference that only an + // interaction state accounts for, below the root: selector blocks are + // written for the component's own props, so a state that changes a + // *child* has nowhere to say so and the value really is dropped. + if !path.is_empty() + && tree.props != source.props + && effect_name.is_some_and(|effect| { + record + .values + .iter() + .all(|(name, value)| name == effect || default_values.get(name) == Some(value)) + }) + && record.values.get(effect_name.unwrap_or_default()) + != default_values.get(effect_name.unwrap_or_default()) + { + unrepresented.insert(source.node_id.clone()); + continue; + } + tree.source_node_ids.insert(source.node_id.clone()); + } + let keys = child_keys(tree); + for (index, child) in tree.children.iter_mut().enumerate() { + let mut child_path = path.to_vec(); + let Some(key) = keys.get(index).cloned() else { + continue; + }; + child_path.push(key); + merge_source_node_ids( + child, + records, + &child_path, + unrepresented, + effect_name, + default_values, + ); + } +} + +/// Whether two nodes are the same shape. What a text node *says* is a value +/// that gets folded like any other, so only whether it is a text node at all +/// counts here — comparing the words themselves reported every button whose +/// label differs by size, which is all of them. +fn same_rendered_node_kind(left: &Tree, right: &Tree) -> bool { + left.component == right.component + && left.content.is_some() == right.content.is_some() + && left.children.len() == right.children.len() +} + +fn find_record<'a>( + records: &'a [Record], + filters: &BTreeMap, +) -> Option<&'a Record> { + records.iter().find(|record| { + filters + .iter() + .all(|(key, value)| record.values.get(key) == Some(value)) + }) +} + +fn merged_props( + records: &[Record], + definitions: &[Definition], + dimensions: &[Definition], + viewport: Option<&Definition>, + effect_value: &str, + path: Option<&NodePath>, + defaults: &BTreeMap, +) -> BTreeMap { + let mut keys = defaults.keys().cloned().collect::>(); + for record in records { + if let Some(tree) = tree_at(&record.tree, path.unwrap_or_default()) { + keys.extend(tree.props.keys().cloned()); + } + } + keys.into_iter() + .filter_map(|prop| { + let expression = expression_for_prop( + records, + definitions, + dimensions, + viewport, + effect_value, + // The base props are the whole value, not a difference from + // anything, so nothing is filtered out as unchanged. + None, + path.unwrap_or_default(), + &prop, + )?; + Some((prop.clone(), render_expression_attr(&prop, &expression))) + }) + .collect() +} + +/// Keys the plugin's prop getters write whether or not they have a value. +/// +/// The plugin keys a node's shape on `Object.keys(tree.props)`, and each of +/// its getters answers with every key it knows, `null` or `undefined` where +/// there is nothing to say: `getTextAlignProps` is always `{textAlign, +/// alignContent}`, `getMinMaxProps` always all four bounds, `getLayoutProps` +/// always `w`, `h`, `aspectRatio` and `flex`. The render step drops the empty +/// ones afterwards. So these keys are on every node of their kind and never +/// tell two apart — a `` right-aligned at one width and not at the next +/// is the same shape to the plugin. Our props only ever hold what renders, so +/// the same keys are left out of the signature here to get the same answer. +const KEYS_THE_PLUGIN_ALWAYS_WRITES: &[&str] = &[ + // getTextAlignProps + "textAlign", + "alignContent", + // getMinMaxProps + "maxW", + "maxH", + "minW", + "minH", + // getLayoutProps; `boxSize` replaces `w` and `h` only when both are set and + // equal, so it still counts + "w", + "h", + "aspectRatio", + "flex", + // getBlendProps + "opacity", + "mixBlendMode", + // getAutoLayoutProps; the component name already says whether a node is + // laid out and which way + "flexDir", + "gap", + "rowGap", + "columnGap", + "justifyContent", + "alignItems", + // getBackgroundProps; `bg` says whether there is paint + "bgBlendMode", + "WebkitTextFillColor", + "bgClip", + // getBorderProps; `outline` says whether there is a stroke + "outlineOffset", + // getPaddingProps writes `p` even when every side is 0 + "p", +]; + +/// `getPositionProps` writes all of these on an absolutely placed node, +/// whichever edges it is pinned to; only `pos` itself tells the shape. +const KEYS_THE_PLUGIN_WRITES_ON_AN_ABSOLUTE_NODE: &[&str] = + &["left", "right", "top", "bottom", "transform"]; + +/// `getAutoLayoutProps` writes `display` on every laid-out node and the +/// renderer drops the `flex`; a hidden one differs by value, not by key. +const COMPONENTS_THE_PLUGIN_ALWAYS_GIVES_DISPLAY: &[&str] = &["Flex", "VStack", "Center", "Grid"]; + +/// What a node looks like, ignoring the values it holds. +/// +/// This is the plugin's `getChildStructureSignature`. Prop *names* count but +/// their values do not, so a node that only changed a colour still matches +/// itself in another variant — less the names the plugin writes on every +/// node regardless, see [`KEYS_THE_PLUGIN_ALWAYS_WRITES`]. +pub(super) fn structure_signature(tree: &Tree) -> String { + let absolute = tree + .props + .get("pos") + .is_some_and(|value| value == "absolute"); + let laid_out = COMPONENTS_THE_PLUGIN_ALWAYS_GIVES_DISPLAY.contains(&tree.component.as_str()); + let props = tree + .props + .keys() + .filter(|name| !KEYS_THE_PLUGIN_ALWAYS_WRITES.contains(&name.as_str())) + .filter(|name| { + !(absolute && KEYS_THE_PLUGIN_WRITES_ON_AN_ABSOLUTE_NODE.contains(&name.as_str())) + }) + .filter(|name| !(laid_out && name.as_str() == "display")) + // `getObjectFitProps` answers for every image-filled asset, `null` + // for the scale modes that need nothing said. + .filter(|name| !(tree.component == "Image" && name.as_str() == "objectFit")) + .cloned() + .collect::>() + .join(","); + let children = tree + .children + .iter() + .map(structure_signature) + .collect::>() + .join("|"); + let kind = if tree.is_component { + "component" + } else { + "node" + }; + let text = if tree.content.is_some() { + "text" + } else { + "notext" + }; + format!("{}::{kind}::{text}::{props}::{children}", tree.component) +} + +/// How each child is found again in another variant's tree. +/// +/// Addressing a child by its position breaks the moment a variant leaves one +/// out: a `tag` button has no icon, so its text sits where the icon sat and +/// every lookup after that reads the wrong node. A child is identified by its +/// shape where that is unique among its siblings, and by its name where it is +/// not, with the occurrence to tell repeats apart. This is the plugin's +/// `treeChildrenToMap`. +fn child_keys(tree: &Tree) -> Vec<(String, usize)> { + let mut counts: BTreeMap = BTreeMap::new(); + for child in &tree.children { + *counts.entry(structure_signature(child)).or_default() += 1; + } + let mut seen: BTreeMap = BTreeMap::new(); + tree.children + .iter() + .map(|child| { + let signature = structure_signature(child); + let key = if counts.get(&signature) == Some(&1) { + format!("sig:{signature}") + } else { + child.node_name.clone() + }; + let occurrence = seen.entry(key.clone()).or_default(); + let at = *occurrence; + *occurrence += 1; + (key, at) + }) + .collect() +} + +/// Where a node sits, said in a way another variant can follow. +type NodePath = [(String, usize)]; + +/// The name under which a text node's own words are folded like any other +/// value. No CSS property can be called this, so it cannot collide with one. +const CONTENT_FIELD: &str = "\u{1}content"; + +/// What a node says for a field, whether that is a prop or its own words. +fn tree_field(tree: &Tree, field: &str) -> Option { + if field == CONTENT_FIELD { + return tree.content.clone(); + } + tree.props.get(field).cloned() +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/codegen/width.rs b/crates/devup-mcp-devup-ui/src/codegen/width.rs index f8631e7d..732c763b 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/width.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/width.rs @@ -356,7 +356,7 @@ pub(crate) fn policy_width_proof( } else { "width share / 100 * drawn parent width = drawn width" }, - "reason": "A widthPolicy anchor writes this width as the node's edges or share of its parent; at the drawn parent width it resolves to the drawn width. Other widths follow the policy and are not a claim about the design.", + "reason": include_str!("../text/width-policy_width_proof-d2d9e821.md"), }) }) } @@ -409,7 +409,7 @@ pub(crate) fn policy_horizontal_proof( .iter() .map(|(name, super::component::PropValue::String(value))| (name.clone(), value.clone())) .collect::>(), - "reason": "The widthPolicy anchor's props reproduce the drawn offset at the drawn parent width. Which edge holds the node at other widths is the caller's policy, not the design's constraint.", + "reason": include_str!("../text/width-policy_horizontal_proof-6cd374a8.md"), }) }) } @@ -544,11 +544,11 @@ pub fn width_behavior(snapshot: &Snapshot, root_id: &str, output: &CodegenOutput "fluid": root_fluid, "componentSized": component_sized, "note": if component_sized { - "Drawn narrower than any screen (320px): a component its host places and sizes, not a viewport. Findings that ask where a part goes on a wider screen are left out; summary.componentRootSuppressed counts them." + include_str!("../text/width-width_behavior-fdf7cb4e.md") } else if root_fluid { "The root takes its container's width, so every finding below applies at any viewport other than the drawn width." } else { - "The root keeps its drawn width in px, so the screen renders at that width at every viewport. Findings describe what changes once the root takes its container's width - with widthPolicy, or by editing the root." + include_str!("../text/width-width_behavior-8f8e0a0f.md") }, }, "exactnessScope": "quality.projection grades the mapping at the drawn width only. It is not a claim about other viewport widths.", @@ -564,7 +564,7 @@ pub fn width_behavior(snapshot: &Snapshot, root_id: &str, output: &CodegenOutput omitted_shapes.truncate(10); report["shapeSizeOmitted"] = json!({ "nodeIds": omitted_shapes, - "note": "Positioned childless shapes are written h=\"100%\" with no width, as the plugin writes them; projectionIssues reports their drawn size. widthPolicy writes each one at its own size.", + "note": include_str!("../text/width-width_behavior-d9663795.md"), }); } if let Some(policy) = policy { @@ -711,7 +711,7 @@ fn positioned_finding( "right-aligned-but-held-left", "consumer-policy-required", "medium", - "Drawn near the right edge, but its constraint holds it at its left offset, so at a wider width it stays where it was drawn. The design does not say which is meant. Anchoring it right in widthPolicy also carries what is drawn inside it.", + include_str!("../text/width-positioned_finding-7a9e6965.md"), json!([ {"anchor": "left", "source": "design-constraint"}, {"anchor": "right", "rightPx": round(right_gap)}, diff --git a/crates/devup-mcp-devup-ui/src/html_props.rs b/crates/devup-mcp-devup-ui/src/html_props.rs index 5590925d..fa3eb3d7 100644 --- a/crates/devup-mcp-devup-ui/src/html_props.rs +++ b/crates/devup-mcp-devup-ui/src/html_props.rs @@ -40,7 +40,7 @@ pub(crate) fn is_html_prop(tag: &str, prop: &str) -> bool { "alt crossOrigin decoding fetchPriority height loading referrerPolicy sizes src srcSet useMap width" } "input" => { - "accept alt autoComplete capture checked disabled form formAction formEncType formMethod formNoValidate formTarget height list max maxLength min minLength multiple name pattern placeholder readOnly required size src step type value width" + include_str!("text/html_props-is_html_prop-9108b590.md") } "keygen" => "challenge disabled form keyParams keyType name", "label" => "form htmlFor", @@ -76,9 +76,12 @@ pub(crate) fn is_html_prop(tag: &str, prop: &str) -> bool { "autoPlay controls controlsList crossOrigin disablePictureInPicture disableRemotePlayback height loop mediaGroup muted playsInline poster preload src width" } "webview" => { - "allowFullScreen allowpopups autosize blinkfeatures disableblinkfeatures disableguestresize disablewebsecurity guestinstance httpreferrer nodeintegration partition plugins preload src useragent webpreferences" + include_str!("text/html_props-is_html_prop-113d7813.md") } _ => return false, }; - "about accessKey autoCapitalize autoCorrect autoFocus autoSave children className color content contentEditable contextMenu dangerouslySetInnerHTML datatype defaultChecked defaultValue dir draggable enterKeyHint exportparts hidden id inert inlist inputMode is itemID itemProp itemRef itemScope itemType key lang nonce part popover popoverTarget popoverTargetAction prefix property radioGroup ref rel resource results rev role security slot spellCheck style suppressContentEditableWarning suppressHydrationWarning tabIndex title translate typeof unselectable vocab".split_whitespace().chain(specific.split_whitespace()).any(|known| known == prop) + include_str!("text/html_props-is_html_prop-2afffb93.md") + .split_whitespace() + .chain(specific.split_whitespace()) + .any(|known| known == prop) } diff --git a/crates/devup-mcp-devup-ui/src/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index 87d19825..3273a7eb 100644 --- a/crates/devup-mcp-devup-ui/src/provenance.rs +++ b/crates/devup-mcp-devup-ui/src/provenance.rs @@ -1,2520 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use devup_mcp_figma::{DevupError, ErrorCode, FidelityImpact, Snapshot, discover_asset_manifest}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use crate::codegen::{ - AssetKind, CodegenOutput, asset_kind, derived_padding, placed_by_a_free_layout, -}; - -mod absolute_bounds; -pub mod attributes; -mod fingerprint; -mod resolution; -mod sizing; -pub use fingerprint::design_fingerprints; -pub use resolution::resolution_semantics; -pub(crate) use sizing::absolute_component_verification; -pub(crate) use sizing::prop as opening_prop; -pub(crate) use sizing::{account_for_sizing, implicit_css_verification}; - -const START: &str = "\u{e000}DEVUP_PROVENANCE_START:"; -const END: &str = "\u{e000}DEVUP_PROVENANCE_END:"; -const CLOSE: char = '\u{e001}'; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GeneratedRange { - pub start: usize, - pub end: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProvenanceEntry { - /// Internal renderer/validator bookkeeping; never a consumer-facing offset. - #[serde(skip)] - pub generated_range: Option, - /// How a generated value was computed from the raw one, when that is not a - /// plain copy (e.g. a percent line-height written as px). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub calculation: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub generated_property: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub json_pointer: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub node_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub property: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub variable_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub style_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub asset_id: Option, - pub resolution: String, - /// Ordinal of the JSX opening element (document order, from 0) whose tag - /// or attribute this entry maps, in the TSX it was generated with. The - /// same ordinal `devup_ui_validate` reports for a violation, so the two - /// can be joined without byte offsets. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub element_index: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SourceMap { - pub version: u32, - pub entries: Vec, - /// Acquisition policy plus the properties actually emitted for each image. - #[serde(default)] - pub asset_css_mappings: Vec, - /// Trusted resource identities retained for in-process fidelity validation. - /// A deserialized map without this context cannot establish token identity. - #[serde(skip)] - pub variable_tokens: BTreeMap, - #[serde(skip)] - pub style_tokens: BTreeMap, -} - -impl Serialize for SourceMap { - fn serialize(&self, serializer: S) -> Result { - use serde::ser::SerializeStruct; - let mut map = serializer.serialize_struct("SourceMap", 5)?; - map.serialize_field("version", &self.version)?; - map.serialize_field("entries", &self.property_entries())?; - map.serialize_field("resolutionSemantics", &resolution_semantics())?; - map.serialize_field("rasterizedAssets", &self.rasterized_assets())?; - map.serialize_field("assetCssMappings", &self.asset_css_mappings)?; - map.end() - } -} - -impl SourceMap { - pub fn rasterized_assets(&self) -> Vec { - self.asset_css_mappings.iter().filter(|m|m["effectsBaked"]==true && m["emitted"]!=false).map(|m|json!({"nodeId":m["nodeId"],"assetId":m["assetId"],"effectsBakedIntoAsset":true,"effectsAppliedBy":"asset","scope":m["contextScope"],"evidence":"supplied-acquisition-representation-policy","cssEffectsSuppressed":true})).collect() - } - pub fn property_entries(&self) -> Vec { - self.entries - .iter() - .filter(|e| e.property.is_some() || e.json_pointer.is_some()) - .cloned() - .collect() - } - - /// Freeze field-to-generated-property facts while the renderer's private - /// positions still refer to its own code. Delivery rewrites the property - /// strings together with TSX; no positions cross the public boundary. - pub(crate) fn describe_properties(&mut self, tsx: &str) { - let elements = attributes::opening_elements(tsx); - for entry in &mut self.entries { - entry.element_index = entry.generated_range.as_ref().and_then(|range| { - elements - .iter() - .filter(|element| element.start <= range.start && range.end <= element.end) - .min_by_key(|element| element.end - element.start) - .map(|element| element.ordinal) - }); - let Some(property) = entry.property.as_deref() else { - continue; - }; - let source = entry - .generated_range - .as_ref() - .and_then(|r| tsx.get(r.start..r.end)); - entry.generated_property = match (entry.resolution.as_str(), property, source) { - ("accounted-for-content-sizing", _, Some(_)) => { - Some("implicit:text-content-sizing".into()) - } - ("accounted-for-implicit-flex-stretch", _, Some(_)) => { - Some("implicit:align-self:stretch".into()) - } - ("accounted-for-implicit-flex-grow", _, Some(source)) => { - source.find("flex=\"").and_then(|at| { - source[at + 6..] - .find('"') - .map(|end| source[at..at + 7 + end].to_owned()) - }) - } - ("derived-hard-break-whitespace", _, Some(source)) => Some(source.trim().into()), - (_, "characters", Some(_)) => Some("children".into()), - (_, _, Some(source)) if !source.trim().is_empty() => Some(source.trim().into()), - _ => None, - }; - if entry.generated_property.is_none() && entry.resolution == "exact" { - entry.resolution = "unverified-property-mapping".into(); - } - } - } - - /// Explain unit conversions the generator performs, so a reader can - /// reproduce `lineHeight="26px"` from the raw `{unit: PERCENT, value: 160}`. - pub(crate) fn describe_conversions(&mut self, snapshot: &devup_mcp_figma::Snapshot) { - for entry in &mut self.entries { - if entry.property.as_deref() != Some("lineHeight") { - continue; - } - let Some(node) = entry - .node_id - .as_deref() - .and_then(|id| snapshot.nodes.get(id)) - else { - continue; - }; - let view = node.typed_view(); - let Some(line_height) = view.value("lineHeight") else { - continue; - }; - if line_height["unit"] != "PERCENT" { - continue; - } - let percent = - (line_height["value"].as_f64().unwrap_or_default() * 100.0).round() / 100.0; - let bound = view - .value("boundVariables") - .and_then(|b| b.get("fontSize")) - .is_some(); - entry.calculation = Some( - match ( - view.value("fontSize").and_then(serde_json::Value::as_f64), - bound, - ) { - (Some(size), false) => format!( - "round(fontSize * percent / 100) = round({size} * {percent} / 100) = {}px", - (size * percent / 100.0).round() - ), - _ => format!( - "{percent}% kept as a percentage because fontSize is variable-bound or unknown" - ), - }, - ); - } - } - - pub fn empty() -> Self { - Self { - version: 2, - entries: Vec::new(), - asset_css_mappings: Vec::new(), - variable_tokens: BTreeMap::new(), - style_tokens: BTreeMap::new(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum ProjectionDisposition { - Emitted, - Flattened, - Ignored, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectionTraceEntry { - pub node_id: String, - pub disposition: ProjectionDisposition, - pub reason: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub generated_range: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectionTrace { - pub root_node_id: String, - pub entries: Vec, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FidelityCoverage { - pub total: usize, - pub covered: usize, - pub basis_points: u16, -} - -impl FidelityCoverage { - fn new(total: usize, covered: usize) -> Self { - let basis_points = if total == 0 { - 10_000 - } else { - ((covered.min(total) as u128 * 10_000) / total as u128) as u16 - }; - Self { - total, - covered: covered.min(total), - basis_points, - } - } - - pub fn complete(self) -> bool { - self.total == self.covered - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FidelityImpactCounts { - pub none: usize, - pub approximated: usize, - pub lossy: usize, - pub failed: usize, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FidelityReport { - pub syntax_valid: bool, - pub nodes: FidelityCoverage, - pub text: FidelityCoverage, - pub variables: FidelityCoverage, - pub typography: FidelityCoverage, - pub assets: FidelityCoverage, - pub layout: FidelityCoverage, - pub impacts: FidelityImpactCounts, - /// Declared children absent from both the snapshot and an asset projection. - /// Unlike an intentionally flattened SVG operand, these are visual losses. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub uncovered_node_ids: Vec, - /// 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 - && self.nodes.complete() - && self.text.complete() - && self.variables.complete() - && self.typography.complete() - && self.assets.complete() - && self.layout.complete() - && self.impacts.approximated == 0 - && self.impacts.lossy == 0 - && self.impacts.failed == 0 - } -} - -pub(crate) fn build_projection_trace( - snapshot: &Snapshot, - root_id: &str, - tsx: &str, - source_map: &SourceMap, -) -> ProjectionTrace { - let active = active_nodes(snapshot, root_id); - let emitted = source_map - .entries - .iter() - .filter(|entry| entry.property.is_none() && entry.resolution == "node") - .filter_map(|entry| Some((entry.node_id.clone()?, entry.generated_range.clone()?))) - .collect::>(); - let asset_nodes = discover_asset_manifest(snapshot) - .assets - .into_iter() - .map(|asset| asset.node_id) - .collect::>(); - let mut entries = Vec::new(); - let mut represented_by_parent = BTreeSet::new(); - let mut ignored_by_parent = BTreeSet::new(); - for (node_id, parent_id) in active { - let node = snapshot.nodes.get(&node_id); - let range = emitted.get(&node_id); - let projection = - if node_id == root_id && node.is_some_and(|node| node.node_type == "SECTION") { - Some((ProjectionDisposition::Flattened, "section-root", None)) - } else if node.is_some_and(|node| node.typed_view().bool("visible") == Some(false)) { - Some((ProjectionDisposition::Ignored, "hidden", None)) - } else if let Some(range) = range.filter(|range| range.start < range.end) { - Some(( - ProjectionDisposition::Emitted, - "direct", - Some((*range).clone()), - )) - } else if range.is_some() - || node.is_some_and(|node| node.typed_view().bool("visible") == Some(false)) - { - Some((ProjectionDisposition::Ignored, "hidden", None)) - } else if parent_id - .as_ref() - .is_some_and(|parent_id| ignored_by_parent.contains(parent_id)) - { - Some((ProjectionDisposition::Ignored, "hidden-ancestor", None)) - } else if parent_id.as_ref().is_some_and(|parent_id| { - asset_nodes.contains(parent_id) - || represented_by_parent.contains(parent_id) - || is_variant_component(snapshot, parent_id) - || snapshot.nodes.get(parent_id).is_some_and(|parent| { - parent.node_type == "COMPONENT_SET" && emitted.contains_key(parent_id) - }) - || snapshot.nodes.get(parent_id).is_some_and(|parent| { - parent.node_type == "INSTANCE" - || parent.typed_view().bool("isAsset") == Some(true) - }) - || emitted.get(parent_id).is_some_and(|range| { - range.end <= tsx.len() - && (tsx[range.start..range.end].contains("maskImage=") - || tsx[range.start..range.end].contains(" bool { - let Some(node) = snapshot.nodes.get(node_id) else { - return false; - }; - if node.node_type != "COMPONENT" { - return false; - } - node.typed_view() - .string("parentId") - .and_then(|parent_id| snapshot.nodes.get(parent_id)) - .is_some_and(|parent| parent.node_type == "COMPONENT_SET") - || snapshot.nodes.values().any(|parent| { - parent.node_type == "COMPONENT_SET" - && parent - .typed_view() - .child_ids() - .any(|child| child == node_id) - }) -} - -pub fn validate_fidelity( - snapshot: &Snapshot, - root_id: &str, - output: &CodegenOutput, -) -> Result { - let expected = active_nodes(snapshot, root_id) - .into_iter() - .map(|(node_id, _)| node_id) - .collect::>(); - let mut observed = BTreeSet::new(); - let mut duplicates = Vec::new(); - let mut invalid_ranges = Vec::new(); - for entry in &output.projection_trace.entries { - if !observed.insert(entry.node_id.clone()) { - duplicates.push(entry.node_id.clone()); - } - if let Some(range) = &entry.generated_range - && (range.start >= range.end - || range.end > output.tsx.len() - || !output.tsx.is_char_boundary(range.start) - || !output.tsx.is_char_boundary(range.end)) - { - invalid_ranges.push(entry.node_id.clone()); - } - } - let missing = expected.difference(&observed).cloned().collect::>(); - let unexpected = observed.difference(&expected).cloned().collect::>(); - if !duplicates.is_empty() - || !missing.is_empty() - || !unexpected.is_empty() - || !invalid_ranges.is_empty() - { - return Err(DevupError::with_details( - ErrorCode::DevupCodegenFailed, - "Projection trace did not account for each source node exactly once.", - false, - json!({ - "missingNodeIds": missing, - "duplicateNodeIds": duplicates, - "unexpectedNodeIds": unexpected, - "invalidRangeNodeIds": invalid_ranges - }), - )); - } - - let semantic_nodes = semantic_nodes(snapshot, root_id); - let text_nodes = semantic_nodes - .iter() - .filter(|node_id| { - snapshot - .nodes - .get(**node_id) - .is_some_and(|node| node.node_type == "TEXT") - }) - .copied() - .collect::>(); - let text_segments = text_sources(snapshot, &text_nodes); - let mut consumed_text_entries = BTreeSet::new(); - let covered_text = text_segments - .iter() - .filter(|(node_id, characters)| { - output - .source_map - .entries - .iter() - .enumerate() - .find(|(index, entry)| { - !consumed_text_entries.contains(index) - && entry.node_id.as_deref() == Some(node_id.as_str()) - && entry.property.as_deref() == Some("characters") - && entry_range(entry, &output.tsx) - .is_some_and(|source| source_covers_text(source, characters)) - }) - .is_some_and(|(index, _)| consumed_text_entries.insert(index)) - }) - .count(); - let baked = baked_into_assets(snapshot, root_id); - let variables = variable_sources(snapshot, &semantic_nodes) - .into_iter() - .filter(|(node_id, _)| !baked.contains(node_id)) - .collect::>(); - let covered_variables = variables - .iter() - .filter(|(node_id, variable_id)| { - output.source_map.entries.iter().any(|entry| { - entry.node_id.as_deref() == Some(node_id.as_str()) - && entry.variable_id.as_deref() == Some(variable_id.as_str()) - && entry.resolution == "variable-token" - && entry_range(entry, &output.tsx).is_some_and(|source| { - !source.contains(['<', '>', '\n']) - && output - .source_map - .variable_tokens - .get(variable_id) - .is_some_and(|token| { - find_resource_id( - source, - &BTreeMap::from([(variable_id.clone(), token.clone())]), - '$', - ) - .as_ref() - == Some(variable_id) - }) - && source.ends_with('"') - }) - }) - }) - .count(); - let typography = typography_sources(snapshot, &text_nodes); - let covered_typography = typography - .iter() - .filter(|(node_id, style_id)| { - output.source_map.entries.iter().any(|entry| { - entry.node_id.as_deref() == Some(*node_id) - && entry.style_id.as_deref() == Some(*style_id) - && entry.resolution == "style-token" - && entry_range(entry, &output.tsx).is_some_and(|source| { - output - .source_map - .style_tokens - .get(*style_id) - .is_some_and(|token| source == format!("typography=\"{token}\"")) - }) - }) - }) - .count(); - let assets = discover_asset_manifest(snapshot) - .assets - .into_iter() - .filter(|asset| semantic_nodes.contains(asset.node_id.as_str())) - .map(|asset| (asset.node_id, asset.asset_id)) - .collect::>(); - let covered_assets = assets - .iter() - .filter(|(node_id, asset_id)| { - if sizing::non_rendering_asset_accounted(snapshot, output, node_id) { - return true; - } - output.source_map.entries.iter().any(|entry| { - entry.node_id.as_deref() == Some(node_id.as_str()) - && entry.asset_id.as_deref() == Some(asset_id.as_str()) - && entry.resolution == "asset" - && entry_range(entry, &output.tsx).is_some_and(|source| { - asset_reference_matches(snapshot, output, entry, node_id, asset_id, source) - }) - }) - }) - .count(); - let asset_nodes = snapshot - .nodes - .values() - .filter(|node| projects_as_asset(snapshot, node)) - .map(|node| node.id.clone()) - .collect::>(); - let parents = source_parents(snapshot); - // Coverage is otherwise measured only over nodes that arrived. Count - // declared holes too, unless an actual asset projection represents them. - // Snapshot completeness still reports every missing operand in either case. - let unrepresented_children = semantic_nodes - .iter() - .filter(|node_id| { - !asset_nodes.contains(**node_id) && !has_asset_ancestor(node_id, &parents, &asset_nodes) - }) - .filter_map(|node_id| snapshot.nodes.get(*node_id)) - .flat_map(|node| node.typed_view().child_ids().collect::>()) - .filter(|child_id| !snapshot.nodes.contains_key(*child_id)) - .collect::>(); - let mut layout = semantic_nodes - .iter() - .filter(|node_id| !has_asset_ancestor(node_id, &parents, &asset_nodes)) - .flat_map(|node_id| { - let is_asset = asset_nodes.contains(*node_id); - LAYOUT_FIELDS.iter().filter_map(move |field| { - snapshot - .nodes - .get(*node_id) - .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::>(); - // Previously claimed implicit or explicit dimensions remain obligations when emitted CSS - // is revalidated. Replacing a Box by an unknown/replaced tag must not make - // its size disappear from coverage merely because it is no longer a Box. - for entry in &output.source_map.entries { - if matches!( - entry.resolution.as_str(), - "accounted-for-implicit-flex-stretch" - | "accounted-for-implicit-flex-grow" - | "accounted-for-content-sizing" - | "verified-explicit-dimension" - ) && let (Some(id), Some(field)) = (entry.node_id.as_deref(), entry.property.as_deref()) - && matches!(field, "width" | "height") - && semantic_nodes.contains(id) - { - layout.insert((id.to_owned(), field.to_owned())); - } - } - // A visible childless painted Box has no intrinsic content, even when it - // was not classified as an asset (for example an empty HUG frame). - for node_id in &semantic_nodes { - let Some(node) = snapshot.nodes.get(*node_id) else { - continue; - }; - if devup_mcp_figma::asset_exclusion_reason(node).is_some() { - continue; - } - let empty_box = empty_layout_leaf(output, node_id); - if empty_box { - for axis in ["width", "height"] { - let view = node.typed_view(); - let sizing = if axis == "width" { - "layoutSizingHorizontal" - } else { - "layoutSizingVertical" - }; - if view - .value("absoluteRenderBounds") - .and_then(|b| b.get(axis)) - .and_then(serde_json::Value::as_f64) - .is_some_and(|v| v > 0.0) - || (view.string(sizing) == Some("HUG") - && (view.number(axis).is_some_and(|v| v > 0.0) - || view - .value("absoluteBoundingBox") - .and_then(|b| b.get(axis)) - .and_then(serde_json::Value::as_f64) - .is_some_and(|v| v > 0.0))) - { - layout.insert(((*node_id).to_owned(), axis.to_owned())); - } - } - } - } - 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 policy_width = property == "width" - && crate::codegen::width::policy_width_proof(snapshot, output, node_id).is_some(); - let represented = policy_width - || 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| { - if entry.resolution == "accounted-for-content-sizing" { - return sizing::content_sizing_accounted( - snapshot, output, node_id, property, - ); - } - if entry.resolution == "verified-explicit-dimension" { - return layout_source_matches(property, source) - && dimension_value_matches( - snapshot, output, node_id, property, source, - ) - && snapshot.nodes.get(node_id).is_some_and(|node| { - !node.field_errors.contains_key(property) - && source - .split_once("=\"") - .and_then(|(_, v)| v.strip_suffix("px\"")) - .and_then(|v| v.parse::().ok()) - .zip(node.typed_view().number(property)) - .is_some_and(|(a, b)| b.is_finite() && a == b) - }); - } - if matches!( - entry.resolution.as_str(), - "accounted-for-implicit-flex-stretch" - | "accounted-for-implicit-flex-grow" - ) { - return implicit_css_verification( - snapshot, output, node_id, property, - )["state"] - == "accounted-for"; - } - if matches!( - property.as_str(), - "layoutSizingHorizontal" | "layoutSizingVertical" | "layoutGrow" - ) { - return sizing::sizing_mapping_matches( - snapshot, output, node_id, property, source, - ); - } - layout_source_matches(property, source) - && dimension_value_matches( - snapshot, output, node_id, property, source, - ) - }) - }); - 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 { - let mut impact = diagnostic.fidelity_impact(); - if impact == FidelityImpact::None && diagnostic.code == "DEVUP_CODEGEN_ABSOLUTE_VERIFIED" { - let components = absolute_component_verification( - snapshot, - output, - diagnostic.node_id.as_deref().unwrap_or(root_id), - ); - if ["height", "width", "horizontal", "vertical"] - .iter() - .any(|name| components[*name]["fidelityImpact"] != "none") - { - impact = FidelityImpact::Approximated; - } - } - if impact == FidelityImpact::None && diagnostic.code == "DEVUP_CODEGEN_NON_RENDERING_ASSET" - { - let id = diagnostic.node_id.as_deref().unwrap_or(root_id); - let hidden = snapshot.nodes.get(id).is_some_and(|node| { - !node.field_errors.contains_key("visible") - && node.typed_view().bool("visible") == Some(false) - }); - if !hidden && !sizing::non_rendering_asset_accounted(snapshot, output, id) { - impact = FidelityImpact::Approximated; - } - } - match impact { - FidelityImpact::None => impacts.none += 1, - FidelityImpact::Approximated => impacts.approximated += 1, - FidelityImpact::Lossy => impacts.lossy += 1, - FidelityImpact::Failed => impacts.failed += 1, - } - } - impacts.lossy += unrepresented_children.len(); - Ok(FidelityReport { - syntax_valid: true, - nodes: FidelityCoverage::new( - expected.len() + unrepresented_children.len(), - observed.len(), - ), - uncovered_node_ids: unrepresented_children - .into_iter() - .take(MAX_REPORTED_UNCOVERED) - .map(str::to_owned) - .collect(), - text: FidelityCoverage::new(text_segments.len(), covered_text), - variables: FidelityCoverage::new(variables.len(), covered_variables), - typography: FidelityCoverage::new(typography.len(), covered_typography), - assets: FidelityCoverage::new(assets.len(), covered_assets), - layout: FidelityCoverage::new(layout.len(), covered_layout), - impacts, - uncovered_layout, - }) -} - -fn has_asset_ancestor( - node_id: &str, - parents: &BTreeMap, - asset_nodes: &BTreeSet, -) -> bool { - let mut parent = parents.get(node_id).map(String::as_str); - while let Some(parent_id) = parent { - if asset_nodes.contains(parent_id) { - return true; - } - parent = parents.get(parent_id).map(String::as_str); - } - 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, - field: &str, -) -> bool { - let view = node.typed_view(); - // Asset internals may be baked into the export, but the CSS element's - // two axes are never internal. Check before HUG/canvas/padding exemptions. - if matches!(field, "width" | "height") - && projects_as_asset(snapshot, node) - && devup_mcp_figma::asset_exclusion_reason(node).is_none() - { - return view.number(field).is_some_and(|v| v > 0.0) - || view - .value("absoluteBoundingBox") - .and_then(|b| b.get(field)) - .and_then(serde_json::Value::as_f64) - .is_some_and(|v| v > 0.0) - || view - .value("absoluteRenderBounds") - .and_then(|b| b.get(field)) - .and_then(serde_json::Value::as_f64) - .is_some_and(|v| v > 0.0); - } - let component_set_parent = view - .string("parentId") - .and_then(|parent_id| snapshot.nodes.get(parent_id)) - .is_some_and(|parent| parent.node_type == "COMPONENT_SET") - || snapshot.nodes.values().any(|parent| { - parent.node_type == "COMPONENT_SET" - && parent - .typed_view() - .child_ids() - .any(|child| child == node.id) - }); - // 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. - let placed_out_of_flow = view.string("layoutPositioning") == Some("ABSOLUTE") - || placed_by_a_free_layout( - snapshot, - node, - view.string("parentId") - .and_then(|parent_id| snapshot.nodes.get(parent_id)), - canvas_parent, - ); - if field == "height" - && placed_out_of_flow - && view.child_ids().next().is_some() - && !projects_as_asset(snapshot, node) - { - return false; - } - match field { - "layoutSizingHorizontal" => view.string(field) == Some("FILL"), - "layoutSizingVertical" => { - view.string(field) == Some("FILL") - || crate::codegen::vertical_fill_container(snapshot, node) - } - // Growth is shared out by an auto-layout parent, and the code has one - // only where that parent is in it. A root's parent is outside the - // export, and a frame without auto-layout - or a pinned child - takes - // no part in it: the value is inert in Figma and has nothing to say in - // the code. An instance placed on the canvas keeps the 1 its component - // had in a row, and that alone graded a standalone StatusCard lossy. - // Kept in step with `codegen::layout`, which writes `flex` only there. - "layoutGrow" => { - view.number(field).is_some_and(|v| v > 0.0) - && view.string("layoutPositioning") != Some("ABSOLUTE") - && view - .string("parentId") - .and_then(|parent_id| snapshot.nodes.get(parent_id)) - .is_some_and(|parent| { - matches!( - parent.typed_view().string("layoutMode"), - Some("HORIZONTAL" | "VERTICAL") - ) - }) - } - "layoutMode" => matches!(view.string(field), Some("HORIZONTAL" | "VERTICAL" | "GRID")), - "layoutPositioning" => view.string(field) == Some("ABSOLUTE"), - "width" => { - view.value(field).is_some() - && view - .string("layoutSizingHorizontal") - .is_none_or(|value| value == "FIXED") - } - "height" => { - view.value(field).is_some() - && view - .string("layoutSizingVertical") - .is_none_or(|value| value == "FIXED") - } - "itemSpacing" => { - // 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) - } - "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { - view.number(field).is_some_and(|value| value != 0.0) - } - _ => false, - } -} - -fn projects_as_asset(snapshot: &Snapshot, node: &devup_mcp_figma::RawNode) -> bool { - asset_kind(snapshot, node).is_some() -} - -fn semantic_nodes<'a>(snapshot: &'a Snapshot, root_id: &str) -> BTreeSet<&'a str> { - let mut visible = BTreeSet::new(); - let mut hidden = BTreeSet::new(); - for (node_id, parent_id) in active_nodes(snapshot, root_id) { - let is_hidden = parent_id - .as_ref() - .is_some_and(|parent_id| hidden.contains(parent_id.as_str())) - || snapshot - .nodes - .get(&node_id) - .is_some_and(|node| node.typed_view().bool("visible") == Some(false)); - if is_hidden { - hidden.insert(node_id); - } else if let Some((node_id, _)) = snapshot.nodes.get_key_value(&node_id) { - visible.insert(node_id.as_str()); - } - } - visible -} - -/// Nodes whose variable bindings no correct generator could write out. -/// -/// An asset drawn in one colour becomes a Box masked to its shape with `bg` -/// set from that colour, so a token binding inside it survives as a token. -/// An asset drawn in more than one has no such form - a CSS mask carries -/// alpha only, and an SVG loaded through `` renders in its own -/// document where neither `currentColor` nor a CSS variable reaches it - so -/// it becomes an `` and every colour inside is baked into the file. -/// -/// Counting those bindings as expected-but-missing asks the generator for -/// something the platform does not offer, and it is not a one-off: measured -/// on a real screen, two play buttons each hid a bound circle behind a white -/// glyph, and the shortfall they produced put the whole fidelity report on -/// the response with nothing in it anyone could act on. -fn baked_into_assets(snapshot: &Snapshot, root_id: &str) -> BTreeSet { - let mut baked = BTreeSet::new(); - let mut pending = vec![root_id.to_owned()]; - while let Some(node_id) = pending.pop() { - let Some(node) = snapshot.nodes.get(&node_id) else { - continue; - }; - // A mask keeps its one colour in CSS, so keep looking inside it. - if !matches!(asset_kind(snapshot, node), None | Some(AssetKind::SvgMask)) { - let mut inside = vec![node_id]; - while let Some(current) = inside.pop() { - let Some(node) = snapshot.nodes.get(¤t) else { - continue; - }; - inside.extend(node.typed_view().child_ids().map(str::to_owned)); - baked.insert(current); - } - continue; - } - pending.extend(node.typed_view().child_ids().map(str::to_owned)); - } - baked -} - -fn variable_sources( - snapshot: &Snapshot, - semantic_nodes: &BTreeSet<&str>, -) -> BTreeSet<(String, String)> { - fn scan(node_id: &str, value: &serde_json::Value, output: &mut BTreeSet<(String, String)>) { - match value { - serde_json::Value::Object(object) => { - if object.get("type").and_then(serde_json::Value::as_str) == Some("VARIABLE_ALIAS") - && let Some(id) = object - .get("id") - .and_then(serde_json::Value::as_str) - .filter(|id| !id.is_empty() && *id != "figma.mixed" && *id != "MIXED") - { - output.insert((node_id.to_owned(), id.to_owned())); - } - for child in object.values() { - scan(node_id, child, output); - } - } - serde_json::Value::Array(values) => { - for child in values { - scan(node_id, child, output); - } - } - _ => {} - } - } - - let mut output = BTreeSet::new(); - for node_id in semantic_nodes { - if let Some(node) = snapshot.nodes.get(*node_id) { - let view = node.typed_view(); - // A mixed text-level fill is not a paint when every character is - // covered by explicit segment fills. Retain all other bindings. - let overridden = node.node_type == "TEXT" - && view.value("fills").is_some_and(|v| !v.is_array()) - && view - .value("styledTextSegments") - .and_then(serde_json::Value::as_array) - .is_some_and(|segments| { - !segments.is_empty() - && segments.iter().all(|segment| { - segment - .get("fills") - .and_then(serde_json::Value::as_array) - .is_some_and(|fills| !fills.is_empty()) - }) - && segments - .iter() - .filter_map(|segment| { - segment - .get("characters") - .and_then(serde_json::Value::as_str) - }) - .collect::() - == view.string("characters").unwrap_or("") - }); - for (field, value) in node.fields.iter().chain(node.extra.iter()) { - if overridden && field == "fills" { - continue; - } - if overridden - && field == "boundVariables" - && let Some(bindings) = value.as_object() - { - for (name, binding) in bindings { - if name != "fills" { - scan(node_id, binding, &mut output); - } - } - } else { - scan(node_id, value, &mut output); - } - } - } - } - output -} - -fn text_sources(snapshot: &Snapshot, text_nodes: &BTreeSet<&str>) -> Vec<(String, String)> { - let mut output = Vec::new(); - for node_id in text_nodes { - let Some(node) = snapshot.nodes.get(*node_id) else { - continue; - }; - let segments = node - .typed_view() - .value("styledTextSegments") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(|segment| { - segment - .get("characters") - .and_then(serde_json::Value::as_str) - }) - .filter(|characters| !characters.is_empty()) - .map(str::to_owned) - .collect::>(); - if segments.is_empty() { - if let Some(characters) = node - .typed_view() - .string("characters") - .filter(|characters| !characters.is_empty()) - { - output.push(((*node_id).to_owned(), characters.to_owned())); - } - } else { - output.extend( - segments - .into_iter() - .map(|characters| ((*node_id).to_owned(), characters)), - ); - } - } - output -} - -fn entry_range<'a>(entry: &ProvenanceEntry, tsx: &'a str) -> Option<&'a str> { - let range = entry.generated_range.as_ref()?; - (range.start < range.end - && range.end <= tsx.len() - && tsx.is_char_boundary(range.start) - && tsx.is_char_boundary(range.end)) - .then(|| &tsx[range.start..range.end]) -} - -fn source_covers_text(source: &str, characters: &str) -> bool { - if source == encode_jsx_text(characters) { - return true; - } - if !characters.contains(['\r', '\n', '\u{2028}', '\u{2029}']) { - return false; - } - let normalized = characters - .replace("\r\n", "\n") - .replace(['\r', '\u{2028}', '\u{2029}'], "\n"); - source - .split("
") - .map(str::trim) - .eq(normalized.split('\n').map(str::trim).map(encode_jsx_text)) -} - -/// The text as the converter writes it, so a match is against the one rule -/// both sides follow rather than a copy of it kept here. -fn encode_jsx_text(input: &str) -> String { - crate::codegen::escape_jsx_text(input) -} - -fn empty_layout_leaf(output: &CodegenOutput, node_id: &str) -> bool { - output.source_map.entries.iter().any(|entry| { - entry.node_id.as_deref() == Some(node_id) - && entry.property.is_none() - && entry_range(entry, &output.tsx).is_some_and(|source| { - let source = source.trim(); - ["") - && !source[1..].contains('<') - }) - }) -} - -fn asset_reference_matches( - snapshot: &Snapshot, - output: &CodegenOutput, - entry: &ProvenanceEntry, - node_id: &str, - asset_id: &str, - source: &str, -) -> bool { - if !(source.starts_with("src=\"") - || source.starts_with("maskImage=\"") - || source.starts_with("bg=\"")) - { - return false; - } - let Some(range) = entry.generated_range.as_ref() else { - return false; - }; - let owner = output - .source_map - .entries - .iter() - .filter(|e| e.property.is_none() && e.resolution == "node") - .filter_map(|e| Some((e.node_id.as_deref()?, e.generated_range.as_ref()?))) - .filter(|(_, r)| r.start <= range.start && r.end >= range.end) - .min_by_key(|(_, r)| r.end - r.start) - .map(|(id, _)| id); - let Some(owner) = owner else { - return false; - }; - if owner != node_id { - let parents = source_parents(snapshot); - if !snapshot - .nodes - .get(owner) - .is_some_and(|n| projects_as_asset(snapshot, n)) - || !has_asset_ancestor(node_id, &parents, &BTreeSet::from([owner.to_owned()])) - { - return false; - } - } - if output.source_map.asset_css_mappings.iter().any(|mapping| { - mapping["nodeId"] == owner - && mapping["assetId"] == asset_id - && mapping["assetPath"].as_str().is_some_and(|path| { - source == format!("src=\"{path}\"") - || source.contains(&format!("url('{path}')")) - || source.contains(&format!("url({path})")) - }) - }) { - return true; - } - [false, true].into_iter().any(|per_node| { - let path = crate::codegen::asset_path(snapshot, owner, per_node).or_else(|| { - let index = asset_id.rsplit_once(":fills:")?.1.parse().ok()?; - crate::codegen::image_fill_path(snapshot, owner, index, per_node) - }); - path.is_some_and(|path| { - source == format!("src=\"{path}\"") - || source.contains(&format!("url('{path}')")) - || source.contains(&format!("url({path})")) - }) - }) -} - -// A property name or a source-map range alone does not prove size preservation. -pub(crate) fn node_opening<'a>(output: &'a CodegenOutput, node_id: &str) -> Option<&'a str> { - output.source_map.entries.iter().find_map(|entry| { - if entry.node_id.as_deref() != Some(node_id) || entry.property.is_some() { - return None; - } - let source = entry_range(entry, &output.tsx)?; - source.split_once('>').map(|(opening, _)| opening) - }) -} - -fn fill_axis_is_established( - snapshot: &Snapshot, - output: &CodegenOutput, - node_id: &str, - axis: &str, -) -> bool { - fn established( - snapshot: &Snapshot, - output: &CodegenOutput, - node_id: &str, - axis: &str, - seen: &mut BTreeSet, - ) -> bool { - if !seen.insert(node_id.into()) { - return false; - } - let Some(parent) = snapshot - .nodes - .values() - .find(|parent| parent.typed_view().child_ids().any(|id| id == node_id)) - else { - // Normal block roots have a host width, but no implicit height. - return axis == "width"; - }; - let Some(opening) = node_opening(output, &parent.id) else { - return false; - }; - let prop = if axis == "width" { "w" } else { "h" }; - for prop in [prop, "boxSize"] { - let needle = format!("{prop}=\""); - if let Some(start) = find_prop(opening, &needle) { - let Some((value, _)) = opening[start + needle.len()..].split_once('"') else { - return false; - }; - if value == "100%" { - return established(snapshot, output, &parent.id, axis, seen); - } - return value - .strip_suffix("px") - .and_then(|value| value.parse::().ok()) - .is_some_and(|value| value.is_finite() && value > 0.0); - } - } - // A definite flex main size also provides the basis for nested FILL. - // Follow the emitted flex chain; a HUG ancestor still ends this proof. - if axis == "height" && sizing::has_vertical_flex_basis(snapshot, output, &parent.id) { - return established(snapshot, output, &parent.id, axis, seen); - } - // HUG/shrink-to-fit width and percentage children are a cycle, not an - // independent size anchor. Percentage height cannot use an auto parent. - axis == "width" - && parent.typed_view().string("layoutSizingHorizontal") != Some("HUG") - && !opening.contains("pos=\"absolute\"") - && !opening.contains("pos=\"fixed\"") - && !opening.contains("display=\"inline") - && !opening.contains("float=") - && established(snapshot, output, &parent.id, axis, seen) - } - established(snapshot, output, node_id, axis, &mut BTreeSet::new()) -} - -fn dimension_value_matches( - snapshot: &Snapshot, - output: &CodegenOutput, - node_id: &str, - axis: &str, - source: &str, -) -> bool { - if !matches!(axis, "width" | "height") { - return true; - } - let Some(node) = snapshot.nodes.get(node_id) else { - return false; - }; - let view = node.typed_view(); - let sizing = if axis == "width" { - "layoutSizingHorizontal" - } else { - "layoutSizingVertical" - }; - let strict_leaf = empty_layout_leaf(output, node_id) - && (view.string(sizing) == Some("HUG") - || view - .value("absoluteRenderBounds") - .is_some_and(|v| v.is_object())); - let requires_measured_size = projects_as_asset(snapshot, node) || strict_leaf; - let Some(value) = source - .split_once("=\"") - .and_then(|(_, v)| v.strip_suffix('"')) - else { - return !requires_measured_size; - }; - if view.string(sizing) == Some("FILL") && requires_measured_size { - // A measured pixel width is not equivalent to filling the parent. - return value == "100%" && fill_axis_is_established(snapshot, output, node_id, axis); - } - if source.starts_with("aspectRatio=") && requires_measured_size { - let Some((w, h)) = crate::codegen::folded_mask_dimensions(snapshot, node) else { - return false; - }; - let Some((x, y)) = value.split_once('/').and_then(|(x, y)| { - Some((x.trim().parse::().ok()?, y.trim().parse::().ok()?)) - }) else { - return false; - }; - let (opposite, opposite_sizing, prop) = if axis == "height" { - ("width", "layoutSizingHorizontal", "w") - } else { - ("height", "layoutSizingVertical", "h") - }; - return view.string(sizing) == Some("HUG") - && view.string(opposite_sizing) == Some("FILL") - && x.is_finite() - && y.is_finite() - && x > 0.0 - && y > 0.0 - && (x / y - w / h).abs() < 1e-9 - && node_opening(output, node_id).is_some_and(|opening| { - find_prop(opening, &format!("{prop}=\"100%\"")).is_some() - && find_prop(opening, if axis == "height" { "h=" } else { "w=" }).is_none() - && find_prop(opening, "boxSize=").is_none() - }) - && fill_axis_is_established(snapshot, output, node_id, opposite); - } - if let Some(px) = value.strip_suffix("px") { - let Ok(actual) = px.parse::() else { - return false; - }; - if !actual.is_finite() { - return false; - } - let view = node.typed_view(); - if view.number(axis).is_some_and(|v| v > 0.0) && actual <= 0.0 { - return false; - } - if requires_measured_size { - let parent = snapshot - .nodes - .values() - .find(|candidate| candidate.typed_view().child_ids().any(|id| id == node_id)); - let canvas = parent - .map(|p| p.node_type.as_str()) - .or_else(|| view.string("parentType")) - .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE" | "COMPONENT_SET")); - let absolute = view.string("layoutPositioning") == Some("ABSOLUTE") - || placed_by_a_free_layout(snapshot, node, parent, canvas); - let bounds = if projects_as_asset(snapshot, node) && absolute { - crate::codegen::export_box(snapshot, node) - } else if view.number("rotation").is_some_and(|r| r.abs() > 0.01) { - crate::codegen::layout_box(node) - } else { - None - }; - let expected = bounds - .map(|b| if axis == "width" { b.w } else { b.h }) - .or_else(|| view.number(axis)) - .or_else(|| view.value("absoluteBoundingBox")?.get(axis)?.as_f64()); - return expected - .is_some_and(|expected| actual > 0.0 && (actual - expected).abs() < 0.01); - } - return true; - } - // Percentages, expressions and a ratio without a proven opposing axis - // depend on a host. They cannot prove an exported leaf's measured size. - !requires_measured_size -} - -fn layout_source_matches(property: &str, source: &str) -> bool { - let is_prop = |name: &str| { - source.starts_with(&format!("{name}=\"")) - || source.starts_with(&format!("{name}={{")) - || source.starts_with(&format!("\"{name}\":")) - }; - match property { - "layoutMode" => { - is_prop("flexDir") || matches!(source, "VStack" | "Flex" | "Grid" | "Center") - } - "layoutPositioning" => is_prop("pos"), - "width" => is_prop("w") || is_prop("boxSize") || is_prop("aspectRatio"), - "height" => is_prop("h") || is_prop("boxSize") || is_prop("aspectRatio"), - "itemSpacing" => is_prop("gap") || is_prop("m"), - "paddingTop" => is_prop("p") || is_prop("py") || is_prop("pt"), - "paddingRight" => is_prop("p") || is_prop("px") || is_prop("pr"), - "paddingBottom" => is_prop("p") || is_prop("py") || is_prop("pb"), - "paddingLeft" => is_prop("p") || is_prop("px") || is_prop("pl"), - _ => false, - } -} - -fn active_nodes(snapshot: &Snapshot, root_id: &str) -> Vec<(String, Option)> { - let mut output = Vec::new(); - let Some(root) = snapshot.nodes.get(root_id) else { - return output; - }; - output.push((root_id.to_owned(), None)); - let start = if root.node_type == "SECTION" { - root.typed_view().child_ids().next() - } else { - Some(root_id) - }; - let Some(start) = start else { - return output; - }; - let mut stack = if start == root_id { - let mut children = root - .typed_view() - .child_ids() - .map(|child| (child.to_owned(), Some(root_id.to_owned()))) - .collect::>(); - children.reverse(); - children - } else { - vec![(start.to_owned(), Some(root_id.to_owned()))] - }; - let mut seen = BTreeSet::from([root_id.to_owned()]); - while let Some((node_id, parent_id)) = stack.pop() { - if !seen.insert(node_id.clone()) { - continue; - } - let Some(node) = snapshot.nodes.get(&node_id) else { - continue; - }; - output.push((node_id.clone(), parent_id)); - let mut children = node.typed_view().child_ids().collect::>(); - children.reverse(); - for child_id in children { - stack.push((child_id.to_owned(), Some(node_id.clone()))); - } - } - output -} - -fn typography_sources<'a>( - snapshot: &'a Snapshot, - text_nodes: &BTreeSet<&'a str>, -) -> BTreeSet<(&'a str, &'a str)> { - let mut styles = BTreeSet::new(); - for node_id in text_nodes { - let Some(node) = snapshot.nodes.get(*node_id) else { - continue; - }; - if let Some(style_id) = node - .typed_view() - .string("textStyleId") - .filter(|id| !id.is_empty()) - { - styles.insert((*node_id, style_id)); - } - if let Some(segments) = node - .typed_view() - .value("styledTextSegments") - .and_then(serde_json::Value::as_array) - { - for style_id in segments.iter().filter_map(|segment| { - segment - .get("textStyleId") - .and_then(serde_json::Value::as_str) - .filter(|id| !id.is_empty()) - }) { - styles.insert((*node_id, style_id)); - } - } - } - styles -} - -const LAYOUT_FIELDS: &[&str] = &[ - "layoutSizingHorizontal", - "layoutSizingVertical", - "layoutGrow", - "layoutMode", - "layoutPositioning", - "width", - "height", - "itemSpacing", - "paddingTop", - "paddingRight", - "paddingBottom", - "paddingLeft", -]; - -pub(crate) fn mark_node(node_id: &str, rendered: String) -> String { - format!("{START}{node_id}{CLOSE}{rendered}{END}{node_id}{CLOSE}") -} - -pub(crate) fn finalize_tsx( - marked: &str, - snapshot: &Snapshot, - variable_tokens: &BTreeMap, - style_tokens: &BTreeMap, -) -> (String, SourceMap) { - let (tsx, node_ranges) = strip_markers(marked); - let emitted_ranges = node_ranges - .iter() - .cloned() - .collect::>(); - let mut assets_by_node = BTreeMap::>::new(); - for asset in discover_asset_manifest(snapshot).assets { - assets_by_node - .entry(asset.node_id.clone()) - .or_default() - .push(asset); - } - let mut entries = Vec::new(); - for (node_id, range) in node_ranges { - let Some(node) = snapshot.nodes.get(&node_id) else { - continue; - }; - entries.push(ProvenanceEntry { - element_index: None, - calculation: None, - generated_property: None, - generated_range: Some(range.clone()), - json_pointer: None, - node_id: Some(node_id.clone()), - property: None, - variable_id: None, - style_id: None, - asset_id: None, - resolution: "node".to_owned(), - }); - if node.node_type == "COMPONENT_SET" { - continue; - } - let node_source = &tsx[range.start..range.end]; - let Some(open_relative) = node_source.find('<') else { - continue; - }; - let Some(close_relative) = node_source[open_relative..].find('>') else { - continue; - }; - let opening_end = open_relative + close_relative + 1; - let opening = &node_source[open_relative..opening_end]; - let component_start = open_relative + 1; - let component_end = opening[1..] - .find(|character: char| character.is_whitespace() || matches!(character, '>' | '/')) - .map_or(open_relative + opening.len(), |offset| { - open_relative + offset + 1 - }); - if component_end > component_start { - entries.push(generated_entry( - range.start + component_start, - range.start + component_end, - &node_id, - "type", - None, - None, - "exact", - )); - if matches!( - node.typed_view().string("layoutMode"), - Some("HORIZONTAL" | "VERTICAL" | "GRID") - ) { - entries.push(generated_entry( - range.start + component_start, - range.start + component_end, - &node_id, - "layoutMode", - None, - None, - "raw-fallback", - )); - } - } - - let mut prop_sources = PROP_SOURCES.to_vec(); - if crate::codegen::asset_kind(snapshot, node).is_none() - && crate::codegen::single_outside_stroke(&node.typed_view()).is_some() - { - prop_sources.push(("boxShadow", "strokes")); - if node - .typed_view() - .value("effects") - .and_then(serde_json::Value::as_array) - .is_some_and(|effects| !effects.is_empty()) - { - prop_sources.push(("boxShadow", "effects")); - } - } - let mut selector_properties = BTreeSet::new(); - if let Some(selector) = non_default_variant_selector(snapshot, node) { - for (prop, property) in &prop_sources { - let Some((start, end)) = selector_prop_range(opening, &selector, prop) else { - continue; - }; - selector_properties.insert(*property); - entries.push(generated_entry( - range.start + open_relative + start, - range.start + open_relative + end, - &node_id, - property, - None, - None, - "variant-selector", - )); - } - } - - for (prop, property) in &prop_sources { - if *property == "overflowDirection" - && node.typed_view().string("overflowDirection").is_none() - { - continue; - } - if *prop == "aspectRatio" && node.typed_view().value("targetAspectRatio").is_none() { - continue; - } - if selector_properties.contains(property) { - continue; - } - let needle = format!("{prop}=\""); - let Some(start) = find_prop(opening, &needle) else { - let expression = format!("{prop}={{"); - if let Some(start) = find_prop(opening, &expression) { - entries.push(generated_entry( - range.start + open_relative + start, - range.start + open_relative + start + expression.len(), - &node_id, - property, - None, - None, - "raw-fallback", - )); - } - continue; - }; - let value_start = start + needle.len(); - let Some(value_end) = opening[value_start..].find('"') else { - continue; - }; - let end = value_start + value_end + 1; - let value = &opening[value_start..value_start + value_end]; - let variable_id = find_resource_id(value, variable_tokens, '$'); - let style_id = (*prop == "typography") - .then(|| { - style_tokens - .iter() - .find_map(|(id, token)| (token == value).then(|| id.clone())) - }) - .flatten(); - let resolution = if *prop == "whiteSpace" - && value == "pre-wrap" - && crate::codegen::preserves_hard_break_spaces(&node.typed_view()) - { - "derived-hard-break-whitespace" - } else if *prop == "boxShadow" - && crate::codegen::asset_kind(snapshot, node).is_none() - && crate::codegen::single_outside_stroke(&node.typed_view()).is_some() - { - "derived-single-edge-outside-stroke" - } else if variable_id.is_some() { - "variable-token" - } else if style_id.is_some() { - "style-token" - } else if *prop == "justifyContent" - && value == "center" - && crate::codegen::centers_lone_space_between_child(snapshot, node) - { - "derived-lone-child-center" - } else if matches!(*property, "width" | "height") - && !node.field_errors.contains_key(*property) - && matches!(*prop, "w" | "h" | "boxSize") - && value - .strip_suffix("px") - .and_then(|v| v.parse::().ok()) - .zip(node.typed_view().number(property)) - .is_some_and(|(generated, original)| { - original.is_finite() && original == generated - }) - { - "verified-explicit-dimension" - } else { - "raw-fallback" - }; - let source_property = if *prop == "whiteSpace" - && value == "pre-wrap" - && crate::codegen::preserves_hard_break_spaces(&node.typed_view()) - { - if node.typed_view().string("characters").is_some() { - "characters" - } else { - "styledTextSegments" - } - } else if *prop == "minW" - && value == "0" - && node.typed_view().number("minWidth").is_none() - && node.typed_view().string("layoutSizingHorizontal") == Some("FILL") - { - "layoutSizingHorizontal" - } else if *prop == "fontWeight" - && node.node_type == "TEXT" - && node.typed_view().number(property).is_none() - { - "styledTextSegments" - } else { - property - }; - entries.push(generated_entry( - range.start + open_relative + start, - range.start + open_relative + end, - &node_id, - source_property, - variable_id, - style_id, - resolution, - )); - } - - if crate::codegen::folded_mask_dimensions(snapshot, node).is_some() { - for (axis, sizing, prop) in [ - ("width", "layoutSizingHorizontal", "w"), - ("height", "layoutSizingVertical", "h"), - ] { - if node.typed_view().string(sizing) != Some("HUG") { - continue; - } - let derived_prop = if find_prop(opening, "aspectRatio=\"").is_some() { - "aspectRatio" - } else { - prop - }; - let needle = format!("{derived_prop}=\""); - if let Some(start) = find_prop(opening, &needle) - && let Some(end) = opening[start + needle.len()..].find('"') - { - let end = start + needle.len() + end + 1; - for field in [axis, "width", "height", sizing, "childrenIds"] { - let mut entry = generated_entry( - range.start + open_relative + start, - range.start + open_relative + end, - &node_id, - field, - None, - None, - "restored-hug-after-mask-child-folding", - ); - if matches!(field, "width" | "height") - && node.typed_view().number(field).is_none() - { - entry.resolution = - "restored-hug-after-mask-child-folding-from-absoluteBoundingBox" - .into(); - } - if !entries.contains(&entry) { - entries.push(entry); - } - } - } - } - } - - if let Some(asset) = assets_by_node - .get(&node_id) - .and_then(|assets| assets.first()) - && let Some((start, end)) = asset_prop_range(opening) - { - entries.push(ProvenanceEntry { - element_index: None, - calculation: None, - generated_property: None, - generated_range: Some(GeneratedRange { - start: range.start + open_relative + start, - end: range.start + open_relative + end, - }), - json_pointer: None, - node_id: Some(node_id.clone()), - property: Some(asset.field.clone()), - variable_id: None, - style_id: None, - asset_id: Some(asset.asset_id.clone()), - resolution: "asset".to_owned(), - }); - } - - if node.node_type == "TEXT" { - add_text_entries( - &tsx, - &range, - &node_id, - node, - variable_tokens, - style_tokens, - &mut entries, - ); - } - } - add_flattened_resource_entries( - &tsx, - snapshot, - &emitted_ranges, - variable_tokens, - &assets_by_node, - &mut entries, - ); - entries.sort_by(|left, right| { - let left_range = left.generated_range.as_ref(); - let right_range = right.generated_range.as_ref(); - left_range - .map(|range| (range.start, range.end)) - .cmp(&right_range.map(|range| (range.start, range.end))) - .then_with(|| left.node_id.cmp(&right.node_id)) - .then_with(|| left.property.cmp(&right.property)) - }); - ( - tsx, - SourceMap { - version: 2, - entries, - asset_css_mappings: Vec::new(), - variable_tokens: variable_tokens.clone(), - style_tokens: style_tokens.clone(), - }, - ) -} - -fn add_flattened_resource_entries( - tsx: &str, - snapshot: &Snapshot, - emitted_ranges: &BTreeMap, - variable_tokens: &BTreeMap, - assets_by_node: &BTreeMap>, - entries: &mut Vec, -) { - let parents = source_parents(snapshot); - let all_nodes = snapshot - .nodes - .keys() - .map(String::as_str) - .collect::>(); - for (node_id, variable_id) in variable_sources(snapshot, &all_nodes) { - if entries.iter().any(|entry| { - entry.node_id.as_deref() == Some(node_id.as_str()) - && entry.variable_id.as_deref() == Some(variable_id.as_str()) - && entry.resolution == "variable-token" - }) { - continue; - } - let Some(token) = variable_tokens.get(&variable_id) else { - continue; - }; - let needle = format!("${token}"); - let mut represented_by = Some(node_id.as_str()); - while let Some(candidate_id) = represented_by { - if let Some(range) = emitted_ranges.get(candidate_id) - && (candidate_id == node_id - || snapshot - .nodes - .get(candidate_id) - .is_some_and(|n| projects_as_asset(snapshot, n))) - { - let source = &tsx[range.start..range.end]; - // Original images can move their solid/gradient token into an - // owned paint layer. Only inspect this node's opening elements; - // a descendant's identical token cannot prove its parent's fill. - let token_range = attributes::opening_elements(source) - .into_iter() - .filter(|opening| { - !emitted_ranges.iter().any(|(other, child)| { - other != candidate_id - && range.start <= child.start - && child.end <= range.end - && child.start <= range.start + opening.start - && range.start + opening.end <= child.end - }) - }) - .find_map(|opening| { - token_prop_range(&source[opening.start..opening.end], &needle) - .map(|(start, end)| (opening.start + start, opening.start + end)) - }); - if let Some((start, end)) = token_range { - entries.push(generated_entry( - range.start + start, - range.start + end, - &node_id, - variable_property(snapshot, &node_id, &variable_id), - Some(variable_id.clone()), - None, - "variable-token", - )); - break; - } - } - represented_by = parents.get(candidate_id).map(String::as_str); - } - } - - for (node_id, assets) in assets_by_node { - for asset in assets { - if entries.iter().any(|entry| { - entry.node_id.as_deref() == Some(node_id.as_str()) - && entry.asset_id.as_deref() == Some(asset.asset_id.as_str()) - && entry.resolution == "asset" - }) { - continue; - } - let mut represented_by = parents.get(node_id.as_str()).map(String::as_str); - while let Some(candidate_id) = represented_by { - if let Some(range) = emitted_ranges.get(candidate_id) - && snapshot - .nodes - .get(candidate_id) - .is_some_and(|n| projects_as_asset(snapshot, n)) - { - let source = &tsx[range.start..range.end]; - if let Some((start, end)) = asset_range_in_node_source(source) { - entries.push(ProvenanceEntry { - element_index: None, - calculation: None, - generated_property: None, - generated_range: Some(GeneratedRange { - start: range.start + start, - end: range.start + end, - }), - json_pointer: None, - node_id: Some(node_id.clone()), - property: Some(asset.field.clone()), - variable_id: None, - style_id: None, - asset_id: Some(asset.asset_id.clone()), - resolution: "asset".to_owned(), - }); - break; - } - } - represented_by = parents.get(candidate_id).map(String::as_str); - } - } - } -} - -fn source_parents(snapshot: &Snapshot) -> BTreeMap { - let mut parents = BTreeMap::new(); - for node in snapshot.nodes.values() { - for child_id in node.typed_view().child_ids() { - parents - .entry(child_id.to_owned()) - .or_insert_with(|| node.id.clone()); - } - } - for node in snapshot.nodes.values() { - if let Some(parent_id) = node.typed_view().string("parentId") { - parents - .entry(node.id.clone()) - .or_insert_with(|| parent_id.to_owned()); - } - } - parents -} - -fn variable_property<'a>(snapshot: &'a Snapshot, node_id: &str, variable_id: &str) -> &'a str { - fn contains_alias(value: &serde_json::Value, variable_id: &str) -> bool { - match value { - serde_json::Value::Object(object) => { - (object.get("type").and_then(serde_json::Value::as_str) == Some("VARIABLE_ALIAS") - && object.get("id").and_then(serde_json::Value::as_str) == Some(variable_id)) - || object - .values() - .any(|value| contains_alias(value, variable_id)) - } - serde_json::Value::Array(values) => values - .iter() - .any(|value| contains_alias(value, variable_id)), - _ => false, - } - } - - let Some(node) = snapshot.nodes.get(node_id) else { - return "boundVariables"; - }; - for field in ["fills", "strokes", "effects", "boundVariables"] { - if node - .typed_view() - .value(field) - .is_some_and(|value| contains_alias(value, variable_id)) - { - return field; - } - } - node.fields - .iter() - .chain(&node.extra) - .find_map(|(field, value)| contains_alias(value, variable_id).then_some(field.as_str())) - .unwrap_or("boundVariables") -} - -fn non_default_variant_selector( - snapshot: &Snapshot, - node: &devup_mcp_figma::RawNode, -) -> Option { - let parent = snapshot.nodes.get(node.typed_view().string("parentId")?)?; - if parent.node_type != "COMPONENT_SET" { - return None; - } - let definitions = parent - .typed_view() - .value("componentPropertyDefinitions")? - .as_object()?; - let (effect_name, definition) = definitions.iter().find(|(name, definition)| { - name.eq_ignore_ascii_case("effect") - && definition.get("type").and_then(serde_json::Value::as_str) == Some("VARIANT") - })?; - let default = definition - .get("defaultValue") - .and_then(serde_json::Value::as_str)?; - let value = node - .typed_view() - .value("variantProperties")? - .get(effect_name)? - .as_str()?; - (value != default).then(|| format!("_{value}")) -} - -fn selector_prop_range(opening: &str, selector: &str, prop: &str) -> Option<(usize, usize)> { - let selector_needle = format!("{selector}={{{{"); - let selector_start = find_prop(opening, &selector_needle)?; - let block = &opening[selector_start..]; - let mut depth = 0_u32; - let mut quoted = false; - let mut escaped = false; - let mut block_end = None; - for (offset, character) in block.char_indices() { - if quoted { - if escaped { - escaped = false; - } else if character == '\\' { - escaped = true; - } else if character == '"' { - quoted = false; - } - continue; - } - match character { - '"' => quoted = true, - '{' => depth += 1, - '}' if depth > 0 => { - depth -= 1; - if depth == 0 { - block_end = Some(offset + character.len_utf8()); - break; - } - } - _ => {} - } - } - let block = &block[..block_end?]; - let needle = format!("\"{prop}\":"); - let relative = block.find(&needle)?; - Some(( - selector_start + relative, - selector_start + relative + needle.len(), - )) -} - -const PROP_SOURCES: &[(&str, &str)] = &[ - ("alignContent", "textAlignVertical"), - ("alignItems", "counterAxisAlignItems"), - ("aspectRatio", "targetAspectRatio"), - ("bg", "fills"), - ("border", "strokes"), - ("borderBottom", "strokes"), - ("borderLeft", "strokes"), - ("borderRight", "strokes"), - ("borderTop", "strokes"), - ("borderRadius", "cornerRadius"), - ("bottom", "y"), - ("boxSize", "width"), - ("boxSize", "height"), - ("color", "fills"), - ("flex", "layoutGrow"), - ("flexDir", "layoutMode"), - ("fontFamily", "fontName"), - ("fontSize", "fontSize"), - ("fontStyle", "fontName"), - ("fontWeight", "fontWeight"), - ("gap", "itemSpacing"), - ("h", "height"), - ("justifyContent", "primaryAxisAlignItems"), - ("left", "x"), - ("letterSpacing", "letterSpacing"), - ("lineHeight", "lineHeight"), - ("m", "itemSpacing"), - ("maxH", "maxHeight"), - ("maxW", "maxWidth"), - ("minH", "minHeight"), - ("minW", "minWidth"), - ("opacity", "opacity"), - ("outline", "strokes"), - ("outlineOffset", "strokeWeight"), - ("overflow", "clipsContent"), - ("overflow", "overflowDirection"), - ("overflowX", "overflowDirection"), - ("overflowY", "overflowDirection"), - ("p", "paddingTop"), - ("p", "paddingRight"), - ("p", "paddingBottom"), - ("p", "paddingLeft"), - ("pb", "paddingBottom"), - ("pl", "paddingLeft"), - ("pos", "layoutPositioning"), - ("pr", "paddingRight"), - ("pt", "paddingTop"), - ("px", "paddingLeft"), - ("px", "paddingRight"), - ("py", "paddingTop"), - ("py", "paddingBottom"), - ("right", "x"), - ("textAlign", "textAlignHorizontal"), - ("top", "y"), - ("typography", "textStyleId"), - ("w", "width"), - ("whiteSpace", "textAutoResize"), - ("wordBreak", "characters"), -]; - -fn add_text_entries( - tsx: &str, - range: &GeneratedRange, - node_id: &str, - node: &devup_mcp_figma::RawNode, - variable_tokens: &BTreeMap, - style_tokens: &BTreeMap, - entries: &mut Vec, -) { - let source = &tsx[range.start..range.end]; - let view = node.typed_view(); - let mut text_segments = view - .value("styledTextSegments") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(|segment| { - segment - .get("characters") - .and_then(serde_json::Value::as_str) - .map(|characters| (characters, Some(segment))) - }) - .filter(|(characters, _)| !characters.is_empty()) - .collect::>(); - if text_segments.is_empty() - && let Some(characters) = view - .string("characters") - .filter(|characters| !characters.is_empty()) - { - text_segments.push((characters, None)); - } - let mut cursor = 0; - for (characters, segment) in text_segments { - if let Some((start, end)) = find_text_span(source, characters, cursor) { - // A rich-text wrapper can carry its own advance and weight. - // Attribute ownership belongs to the segment whose text directly - // follows this opening, rather than the node's default metrics. - if let Some(segment) = segment - && let Some(open_start) = source[..start].rfind("') - && source[open_start + close + 1..start].trim().is_empty() - { - let opening = &source[open_start..open_start + close]; - for field in ["fontSize", "lineHeight", "fontWeight"] { - if segment.get(field).is_none() { - continue; - } - let needle = format!("{field}=\""); - if let Some(attr_start) = find_prop(opening, &needle) - && let Some(attr_end) = opening[attr_start + needle.len()..].find('"') - { - entries.push(generated_entry( - range.start + open_start + attr_start, - range.start + open_start + attr_start + needle.len() + attr_end + 1, - node_id, - "styledTextSegments", - None, - None, - "raw-fallback", - )); - } - } - } - entries.push(generated_entry( - range.start + start, - range.start + end, - node_id, - "characters", - None, - None, - "exact", - )); - cursor = end; - } - } - for (variable_id, token) in variable_tokens { - let needle = format!("${token}"); - for start in match_indices(source, &needle) { - if source[start + needle.len()..] - .chars() - .next() - .is_some_and(is_token_character) - { - continue; - } - let prop_start = source[..start] - .rfind(|character: char| character.is_whitespace() || character == '<') - .map_or(start, |offset| offset + 1); - let prop_end = source[start..] - .find('"') - .map_or(start + needle.len(), |offset| start + offset + 1); - entries.push(generated_entry( - range.start + prop_start, - range.start + prop_end, - node_id, - "styledTextSegments", - Some(variable_id.clone()), - None, - "variable-token", - )); - } - } - for (style_id, token) in style_tokens { - let needle = format!("typography=\"{token}\""); - for start in match_indices(source, &needle) { - entries.push(generated_entry( - range.start + start, - range.start + start + needle.len(), - node_id, - "styledTextSegments", - None, - Some(style_id.clone()), - "style-token", - )); - } - } -} - -fn find_text_span(source: &str, characters: &str, search_start: usize) -> Option<(usize, usize)> { - let rendered = encode_jsx_text(characters); - if let Some(start) = source[search_start..].find(&rendered) { - let start = search_start + start; - return Some((start, start + rendered.len())); - } - let fragments = characters - .split(['\r', '\n', '\u{2028}', '\u{2029}']) - .map(str::trim) - .filter(|fragment| !fragment.is_empty()) - .map(encode_jsx_text) - .collect::>(); - let mut cursor = search_start; - let mut start = None; - let mut end = None; - for fragment in fragments { - let found = source[cursor..].find(&fragment)? + cursor; - start.get_or_insert(found); - end = Some(found + fragment.len()); - cursor = found + fragment.len(); - } - Some((start?, end?)) -} - -fn asset_prop_range(opening: &str) -> Option<(usize, usize)> { - for prop in ["src", "maskImage", "bg"] { - let needle = format!("{prop}=\""); - let Some(start) = find_prop(opening, &needle) else { - continue; - }; - let value_start = start + needle.len(); - let Some(value_end) = opening[value_start..].find('"') else { - continue; - }; - let end = value_start + value_end + 1; - if prop != "bg" || opening[value_start..value_start + value_end].contains("url(") { - return Some((start, end)); - } - } - None -} - -fn asset_range_in_node_source(source: &str) -> Option<(usize, usize)> { - let opening_start = source.find('<')?; - let opening_end = opening_start + source[opening_start..].find('>')? + 1; - let (start, end) = asset_prop_range(&source[opening_start..opening_end])?; - Some((opening_start + start, opening_start + end)) -} - -fn token_prop_range(source: &str, needle: &str) -> Option<(usize, usize)> { - let token_start = source.find(needle)?; - let prop_start = source[..token_start] - .rfind(|character: char| character.is_whitespace() || character == '<') - .map_or(token_start, |offset| offset + 1); - let prop_end = source[token_start..] - .find('"') - .map_or(token_start + needle.len(), |offset| { - token_start + offset + 1 - }); - Some((prop_start, prop_end)) -} - -fn find_resource_id( - value: &str, - tokens: &BTreeMap, - prefix: char, -) -> Option { - tokens.iter().find_map(|(id, token)| { - let needle = format!("{prefix}{token}"); - match_indices(value, &needle) - .into_iter() - .any(|start| { - value[start + needle.len()..] - .chars() - .next() - .is_none_or(|character| !is_token_character(character)) - }) - .then(|| id.clone()) - }) -} - -fn find_prop(opening: &str, needle: &str) -> Option { - match_indices(opening, needle).into_iter().find(|start| { - *start > 0 - && opening[..*start] - .chars() - .next_back() - .is_some_and(char::is_whitespace) - }) -} - -fn is_token_character(character: char) -> bool { - character.is_alphanumeric() || matches!(character, '_' | '-') -} - -fn match_indices(source: &str, needle: &str) -> Vec { - let mut result = Vec::new(); - let mut offset = 0; - while let Some(found) = source[offset..].find(needle) { - let absolute = offset + found; - result.push(absolute); - offset = absolute + needle.len(); - } - result -} - -fn generated_entry( - start: usize, - end: usize, - node_id: &str, - property: &str, - variable_id: Option, - style_id: Option, - resolution: &str, -) -> ProvenanceEntry { - ProvenanceEntry { - element_index: None, - calculation: None, - generated_property: None, - generated_range: Some(GeneratedRange { start, end }), - json_pointer: None, - node_id: Some(node_id.to_owned()), - property: Some(property.to_owned()), - variable_id, - style_id, - asset_id: None, - resolution: resolution.to_owned(), - } -} - -fn strip_markers(marked: &str) -> (String, Vec<(String, GeneratedRange)>) { - let mut output = String::with_capacity(marked.len()); - let mut stack = Vec::<(String, usize)>::new(); - let mut ranges = Vec::new(); - let mut cursor = 0; - while cursor < marked.len() { - let rest = &marked[cursor..]; - let next_start = rest.find(START); - let next_end = rest.find(END); - let next = match (next_start, next_end) { - (Some(start), Some(end)) => start.min(end), - (Some(start), None) => start, - (None, Some(end)) => end, - (None, None) => { - output.push_str(rest); - break; - } - }; - output.push_str(&rest[..next]); - cursor += next; - let is_start = marked[cursor..].starts_with(START); - let prefix = if is_start { START } else { END }; - cursor += prefix.len(); - let Some(close) = marked[cursor..].find(CLOSE) else { - output.push_str(&marked[cursor - prefix.len()..]); - break; - }; - let node_id = marked[cursor..cursor + close].to_owned(); - cursor += close + CLOSE.len_utf8(); - if is_start { - stack.push((node_id, output.len())); - } else if let Some(index) = stack.iter().rposition(|(id, _)| id == &node_id) { - let (_, start) = stack.remove(index); - ranges.push(( - node_id, - GeneratedRange { - start, - end: output.len(), - }, - )); - } - } - ranges.sort_by(|left, right| { - left.1 - .start - .cmp(&right.1.start) - .then_with(|| right.1.end.cmp(&left.1.end)) - .then_with(|| left.0.cmp(&right.0)) - }); - (output, ranges) -} - -pub(crate) fn json_pointer_segment(value: &str) -> String { - value.replace('~', "~0").replace('/', "~1") -} - -#[cfg(test)] -mod line_height_calculation_tests { - use super::*; - use serde_json::json; - - fn entry(node: &str) -> ProvenanceEntry { - ProvenanceEntry { - element_index: None, - calculation: None, - generated_property: Some("lineHeight=\"26px\"".into()), - generated_range: None, - json_pointer: None, - node_id: Some(node.into()), - property: Some("lineHeight".into()), - variable_id: None, - style_id: None, - asset_id: None, - resolution: "raw-fallback".into(), - } - } - - #[test] - fn percent_line_height_records_how_px_was_derived() { - let snapshot: devup_mcp_figma::Snapshot = serde_json::from_value(json!({ - "fileKey":"k","roots":["t","b"],"diagnostics":[],"nodes":{ - "t":{"id":"t","type":"TEXT","fields":{"fontSize":16.25,"lineHeight":{"unit":"PERCENT","value":160}}}, - "b":{"id":"b","type":"TEXT","fields":{"fontSize":16,"lineHeight":{"unit":"PERCENT","value":150}, - "boundVariables":{"fontSize":{"id":"v"}}}}} - })).unwrap(); - let mut map = SourceMap::empty(); - map.entries = vec![entry("t"), entry("b")]; - map.describe_conversions(&snapshot); - assert!( - map.entries[0] - .calculation - .as_deref() - .unwrap() - .ends_with("= 26px") - ); - assert!( - map.entries[1] - .calculation - .as_deref() - .unwrap() - .contains("kept as a percentage") - ); - } -} +// Source sections share this module to preserve privacy and test names. +include!("provenance/source_map_and_contracts.rs"); +include!("provenance/baked_into_assets.rs"); +include!("provenance/add_flattened_resource_entries.rs"); diff --git a/crates/devup-mcp-devup-ui/src/provenance/absolute_bounds.rs b/crates/devup-mcp-devup-ui/src/provenance/absolute_bounds.rs index ed7c23db..1fd49c9a 100644 --- a/crates/devup-mcp-devup-ui/src/provenance/absolute_bounds.rs +++ b/crates/devup-mcp-devup-ui/src/provenance/absolute_bounds.rs @@ -83,7 +83,7 @@ pub(super) fn annotate( "selectedValue":selected,"sourceFields":source_fields,"originalValue":original, "layoutDimension":view.number(axis), "boundaryDeltaPixels":selected.zip(view.number(axis)).map(|(a,b)|a-b), - "calculation":format!("Absolute asset export policy: generated {css} = px(absoluteRenderBounds.{axis}); export origin = render.xy - parent.absoluteBoundingBox.xy. Bounding-box differences are boundary selection, not rounding."), + "calculation":format!(include_str!("../text/absolute_bounds-annotate-8cf6eb51.md"), axis = axis, css = css), "evidenceLimit":"Verifies the collected export-boundary projection, not equality with the layout bounding box or browser/SVG composition."}); component["sourceFields"] = source_fields.clone(); component["calculation"] = component["boundary"]["calculation"].clone(); @@ -209,7 +209,7 @@ pub(super) fn annotate( "declaredValue":declared.and_then(|v|v.get(axis)),"effectiveValue":effective, "state":if origin == "node" && !node.field_errors.contains_key("constraints") {"declared"} else {"assumed"}, "sourceFields":["constraints", "childrenIds", "children[0].constraints"], - "calculation":"Use node constraint object; otherwise first child's constraint object; missing axis defaults to MIN. SCALE/STRETCH currently emit the near-edge offset, without a responsive scaling proof.", + "calculation":include_str!("../text/absolute_bounds-annotate-f08cae30.md"), "resolutionCondition":"An inherited/default constraint is generation policy only; establish that it represents this GROUP's placement intent before verifying the axis."}); component["boundary"] = json!({"state":if position_boundary_ok {"verified"} else {"unverified"}, "selectedField":"absoluteRenderBounds","sourceFields":position_fields,"originalValue":original, @@ -233,12 +233,10 @@ pub(super) fn annotate( if !verified { component["state"] = json!("approximated"); component["fidelityImpact"] = json!("approximated"); - component["reason"] = json!( - "Export-boundary arithmetic is reported separately. Inherited/default constraints, SCALE/STRETCH, changed local geometry or an unverified containing block do not prove source placement intent." - ); - component["resolutionCondition"] = json!( - "Prove the effective constraint belongs to this node, and that render-relative offsets preserve its positioning in the emitted containing block. A successful rounding check alone is insufficient." - ); + component["reason"] = + json!(include_str!("../text/absolute_bounds-annotate-9c9cb9c4.md")); + component["resolutionCondition"] = + json!(include_str!("../text/absolute_bounds-annotate-35afe622.md")); } } } diff --git a/crates/devup-mcp-devup-ui/src/provenance/add_flattened_resource_entries.rs b/crates/devup-mcp-devup-ui/src/provenance/add_flattened_resource_entries.rs new file mode 100644 index 00000000..f905e7d9 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/provenance/add_flattened_resource_entries.rs @@ -0,0 +1,654 @@ + + +fn add_flattened_resource_entries( + tsx: &str, + snapshot: &Snapshot, + emitted_ranges: &BTreeMap, + variable_tokens: &BTreeMap, + assets_by_node: &BTreeMap>, + entries: &mut Vec, +) { + let parents = source_parents(snapshot); + let all_nodes = snapshot + .nodes + .keys() + .map(String::as_str) + .collect::>(); + for (node_id, variable_id) in variable_sources(snapshot, &all_nodes) { + if entries.iter().any(|entry| { + entry.node_id.as_deref() == Some(node_id.as_str()) + && entry.variable_id.as_deref() == Some(variable_id.as_str()) + && entry.resolution == "variable-token" + }) { + continue; + } + let Some(token) = variable_tokens.get(&variable_id) else { + continue; + }; + let needle = format!("${token}"); + let mut represented_by = Some(node_id.as_str()); + while let Some(candidate_id) = represented_by { + if let Some(range) = emitted_ranges.get(candidate_id) + && (candidate_id == node_id + || snapshot + .nodes + .get(candidate_id) + .is_some_and(|n| projects_as_asset(snapshot, n))) + { + let source = &tsx[range.start..range.end]; + // Original images can move their solid/gradient token into an + // owned paint layer. Only inspect this node's opening elements; + // a descendant's identical token cannot prove its parent's fill. + let token_range = attributes::opening_elements(source) + .into_iter() + .filter(|opening| { + !emitted_ranges.iter().any(|(other, child)| { + other != candidate_id + && range.start <= child.start + && child.end <= range.end + && child.start <= range.start + opening.start + && range.start + opening.end <= child.end + }) + }) + .find_map(|opening| { + token_prop_range(&source[opening.start..opening.end], &needle) + .map(|(start, end)| (opening.start + start, opening.start + end)) + }); + if let Some((start, end)) = token_range { + entries.push(generated_entry( + range.start + start, + range.start + end, + &node_id, + variable_property(snapshot, &node_id, &variable_id), + Some(variable_id.clone()), + None, + "variable-token", + )); + break; + } + } + represented_by = parents.get(candidate_id).map(String::as_str); + } + } + + for (node_id, assets) in assets_by_node { + for asset in assets { + if entries.iter().any(|entry| { + entry.node_id.as_deref() == Some(node_id.as_str()) + && entry.asset_id.as_deref() == Some(asset.asset_id.as_str()) + && entry.resolution == "asset" + }) { + continue; + } + let mut represented_by = parents.get(node_id.as_str()).map(String::as_str); + while let Some(candidate_id) = represented_by { + if let Some(range) = emitted_ranges.get(candidate_id) + && snapshot + .nodes + .get(candidate_id) + .is_some_and(|n| projects_as_asset(snapshot, n)) + { + let source = &tsx[range.start..range.end]; + if let Some((start, end)) = asset_range_in_node_source(source) { + entries.push(ProvenanceEntry { + element_index: None, + calculation: None, + generated_property: None, + generated_range: Some(GeneratedRange { + start: range.start + start, + end: range.start + end, + }), + json_pointer: None, + node_id: Some(node_id.clone()), + property: Some(asset.field.clone()), + variable_id: None, + style_id: None, + asset_id: Some(asset.asset_id.clone()), + resolution: "asset".to_owned(), + }); + break; + } + } + represented_by = parents.get(candidate_id).map(String::as_str); + } + } + } +} + +fn source_parents(snapshot: &Snapshot) -> BTreeMap { + let mut parents = BTreeMap::new(); + for node in snapshot.nodes.values() { + for child_id in node.typed_view().child_ids() { + parents + .entry(child_id.to_owned()) + .or_insert_with(|| node.id.clone()); + } + } + for node in snapshot.nodes.values() { + if let Some(parent_id) = node.typed_view().string("parentId") { + parents + .entry(node.id.clone()) + .or_insert_with(|| parent_id.to_owned()); + } + } + parents +} + +fn variable_property<'a>(snapshot: &'a Snapshot, node_id: &str, variable_id: &str) -> &'a str { + fn contains_alias(value: &serde_json::Value, variable_id: &str) -> bool { + match value { + serde_json::Value::Object(object) => { + (object.get("type").and_then(serde_json::Value::as_str) == Some("VARIABLE_ALIAS") + && object.get("id").and_then(serde_json::Value::as_str) == Some(variable_id)) + || object + .values() + .any(|value| contains_alias(value, variable_id)) + } + serde_json::Value::Array(values) => values + .iter() + .any(|value| contains_alias(value, variable_id)), + _ => false, + } + } + + let Some(node) = snapshot.nodes.get(node_id) else { + return "boundVariables"; + }; + for field in ["fills", "strokes", "effects", "boundVariables"] { + if node + .typed_view() + .value(field) + .is_some_and(|value| contains_alias(value, variable_id)) + { + return field; + } + } + node.fields + .iter() + .chain(&node.extra) + .find_map(|(field, value)| contains_alias(value, variable_id).then_some(field.as_str())) + .unwrap_or("boundVariables") +} + +fn non_default_variant_selector( + snapshot: &Snapshot, + node: &devup_mcp_figma::RawNode, +) -> Option { + let parent = snapshot.nodes.get(node.typed_view().string("parentId")?)?; + if parent.node_type != "COMPONENT_SET" { + return None; + } + let definitions = parent + .typed_view() + .value("componentPropertyDefinitions")? + .as_object()?; + let (effect_name, definition) = definitions.iter().find(|(name, definition)| { + name.eq_ignore_ascii_case("effect") + && definition.get("type").and_then(serde_json::Value::as_str) == Some("VARIANT") + })?; + let default = definition + .get("defaultValue") + .and_then(serde_json::Value::as_str)?; + let value = node + .typed_view() + .value("variantProperties")? + .get(effect_name)? + .as_str()?; + (value != default).then(|| format!("_{value}")) +} + +fn selector_prop_range(opening: &str, selector: &str, prop: &str) -> Option<(usize, usize)> { + let selector_needle = format!("{selector}={{{{"); + let selector_start = find_prop(opening, &selector_needle)?; + let block = &opening[selector_start..]; + let mut depth = 0_u32; + let mut quoted = false; + let mut escaped = false; + let mut block_end = None; + for (offset, character) in block.char_indices() { + if quoted { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + quoted = false; + } + continue; + } + match character { + '"' => quoted = true, + '{' => depth += 1, + '}' if depth > 0 => { + depth -= 1; + if depth == 0 { + block_end = Some(offset + character.len_utf8()); + break; + } + } + _ => {} + } + } + let block = &block[..block_end?]; + let needle = format!("\"{prop}\":"); + let relative = block.find(&needle)?; + Some(( + selector_start + relative, + selector_start + relative + needle.len(), + )) +} + +const PROP_SOURCES: &[(&str, &str)] = &[ + ("alignContent", "textAlignVertical"), + ("alignItems", "counterAxisAlignItems"), + ("aspectRatio", "targetAspectRatio"), + ("bg", "fills"), + ("border", "strokes"), + ("borderBottom", "strokes"), + ("borderLeft", "strokes"), + ("borderRight", "strokes"), + ("borderTop", "strokes"), + ("borderRadius", "cornerRadius"), + ("bottom", "y"), + ("boxSize", "width"), + ("boxSize", "height"), + ("color", "fills"), + ("flex", "layoutGrow"), + ("flexDir", "layoutMode"), + ("fontFamily", "fontName"), + ("fontSize", "fontSize"), + ("fontStyle", "fontName"), + ("fontWeight", "fontWeight"), + ("gap", "itemSpacing"), + ("h", "height"), + ("justifyContent", "primaryAxisAlignItems"), + ("left", "x"), + ("letterSpacing", "letterSpacing"), + ("lineHeight", "lineHeight"), + ("m", "itemSpacing"), + ("maxH", "maxHeight"), + ("maxW", "maxWidth"), + ("minH", "minHeight"), + ("minW", "minWidth"), + ("opacity", "opacity"), + ("outline", "strokes"), + ("outlineOffset", "strokeWeight"), + ("overflow", "clipsContent"), + ("overflow", "overflowDirection"), + ("overflowX", "overflowDirection"), + ("overflowY", "overflowDirection"), + ("p", "paddingTop"), + ("p", "paddingRight"), + ("p", "paddingBottom"), + ("p", "paddingLeft"), + ("pb", "paddingBottom"), + ("pl", "paddingLeft"), + ("pos", "layoutPositioning"), + ("pr", "paddingRight"), + ("pt", "paddingTop"), + ("px", "paddingLeft"), + ("px", "paddingRight"), + ("py", "paddingTop"), + ("py", "paddingBottom"), + ("right", "x"), + ("textAlign", "textAlignHorizontal"), + ("top", "y"), + ("typography", "textStyleId"), + ("w", "width"), + ("whiteSpace", "textAutoResize"), + ("wordBreak", "characters"), +]; + +fn add_text_entries( + tsx: &str, + range: &GeneratedRange, + node_id: &str, + node: &devup_mcp_figma::RawNode, + variable_tokens: &BTreeMap, + style_tokens: &BTreeMap, + entries: &mut Vec, +) { + let source = &tsx[range.start..range.end]; + let view = node.typed_view(); + let mut text_segments = view + .value("styledTextSegments") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|segment| { + segment + .get("characters") + .and_then(serde_json::Value::as_str) + .map(|characters| (characters, Some(segment))) + }) + .filter(|(characters, _)| !characters.is_empty()) + .collect::>(); + if text_segments.is_empty() + && let Some(characters) = view + .string("characters") + .filter(|characters| !characters.is_empty()) + { + text_segments.push((characters, None)); + } + let mut cursor = 0; + for (characters, segment) in text_segments { + if let Some((start, end)) = find_text_span(source, characters, cursor) { + // A rich-text wrapper can carry its own advance and weight. + // Attribute ownership belongs to the segment whose text directly + // follows this opening, rather than the node's default metrics. + if let Some(segment) = segment + && let Some(open_start) = source[..start].rfind("') + && source[open_start + close + 1..start].trim().is_empty() + { + let opening = &source[open_start..open_start + close]; + for field in ["fontSize", "lineHeight", "fontWeight"] { + if segment.get(field).is_none() { + continue; + } + let needle = format!("{field}=\""); + if let Some(attr_start) = find_prop(opening, &needle) + && let Some(attr_end) = opening[attr_start + needle.len()..].find('"') + { + entries.push(generated_entry( + range.start + open_start + attr_start, + range.start + open_start + attr_start + needle.len() + attr_end + 1, + node_id, + "styledTextSegments", + None, + None, + "raw-fallback", + )); + } + } + } + entries.push(generated_entry( + range.start + start, + range.start + end, + node_id, + "characters", + None, + None, + "exact", + )); + cursor = end; + } + } + for (variable_id, token) in variable_tokens { + let needle = format!("${token}"); + for start in match_indices(source, &needle) { + if source[start + needle.len()..] + .chars() + .next() + .is_some_and(is_token_character) + { + continue; + } + let prop_start = source[..start] + .rfind(|character: char| character.is_whitespace() || character == '<') + .map_or(start, |offset| offset + 1); + let prop_end = source[start..] + .find('"') + .map_or(start + needle.len(), |offset| start + offset + 1); + entries.push(generated_entry( + range.start + prop_start, + range.start + prop_end, + node_id, + "styledTextSegments", + Some(variable_id.clone()), + None, + "variable-token", + )); + } + } + for (style_id, token) in style_tokens { + let needle = format!("typography=\"{token}\""); + for start in match_indices(source, &needle) { + entries.push(generated_entry( + range.start + start, + range.start + start + needle.len(), + node_id, + "styledTextSegments", + None, + Some(style_id.clone()), + "style-token", + )); + } + } +} + +fn find_text_span(source: &str, characters: &str, search_start: usize) -> Option<(usize, usize)> { + let rendered = encode_jsx_text(characters); + if let Some(start) = source[search_start..].find(&rendered) { + let start = search_start + start; + return Some((start, start + rendered.len())); + } + let fragments = characters + .split(['\r', '\n', '\u{2028}', '\u{2029}']) + .map(str::trim) + .filter(|fragment| !fragment.is_empty()) + .map(encode_jsx_text) + .collect::>(); + let mut cursor = search_start; + let mut start = None; + let mut end = None; + for fragment in fragments { + let found = source[cursor..].find(&fragment)? + cursor; + start.get_or_insert(found); + end = Some(found + fragment.len()); + cursor = found + fragment.len(); + } + Some((start?, end?)) +} + +fn asset_prop_range(opening: &str) -> Option<(usize, usize)> { + for prop in ["src", "maskImage", "bg"] { + let needle = format!("{prop}=\""); + let Some(start) = find_prop(opening, &needle) else { + continue; + }; + let value_start = start + needle.len(); + let Some(value_end) = opening[value_start..].find('"') else { + continue; + }; + let end = value_start + value_end + 1; + if prop != "bg" || opening[value_start..value_start + value_end].contains("url(") { + return Some((start, end)); + } + } + None +} + +fn asset_range_in_node_source(source: &str) -> Option<(usize, usize)> { + let opening_start = source.find('<')?; + let opening_end = opening_start + source[opening_start..].find('>')? + 1; + let (start, end) = asset_prop_range(&source[opening_start..opening_end])?; + Some((opening_start + start, opening_start + end)) +} + +fn token_prop_range(source: &str, needle: &str) -> Option<(usize, usize)> { + let token_start = source.find(needle)?; + let prop_start = source[..token_start] + .rfind(|character: char| character.is_whitespace() || character == '<') + .map_or(token_start, |offset| offset + 1); + let prop_end = source[token_start..] + .find('"') + .map_or(token_start + needle.len(), |offset| { + token_start + offset + 1 + }); + Some((prop_start, prop_end)) +} + +fn find_resource_id( + value: &str, + tokens: &BTreeMap, + prefix: char, +) -> Option { + tokens.iter().find_map(|(id, token)| { + let needle = format!("{prefix}{token}"); + match_indices(value, &needle) + .into_iter() + .any(|start| { + value[start + needle.len()..] + .chars() + .next() + .is_none_or(|character| !is_token_character(character)) + }) + .then(|| id.clone()) + }) +} + +fn find_prop(opening: &str, needle: &str) -> Option { + match_indices(opening, needle).into_iter().find(|start| { + *start > 0 + && opening[..*start] + .chars() + .next_back() + .is_some_and(char::is_whitespace) + }) +} + +fn is_token_character(character: char) -> bool { + character.is_alphanumeric() || matches!(character, '_' | '-') +} + +fn match_indices(source: &str, needle: &str) -> Vec { + let mut result = Vec::new(); + let mut offset = 0; + while let Some(found) = source[offset..].find(needle) { + let absolute = offset + found; + result.push(absolute); + offset = absolute + needle.len(); + } + result +} + +fn generated_entry( + start: usize, + end: usize, + node_id: &str, + property: &str, + variable_id: Option, + style_id: Option, + resolution: &str, +) -> ProvenanceEntry { + ProvenanceEntry { + element_index: None, + calculation: None, + generated_property: None, + generated_range: Some(GeneratedRange { start, end }), + json_pointer: None, + node_id: Some(node_id.to_owned()), + property: Some(property.to_owned()), + variable_id, + style_id, + asset_id: None, + resolution: resolution.to_owned(), + } +} + +fn strip_markers(marked: &str) -> (String, Vec<(String, GeneratedRange)>) { + let mut output = String::with_capacity(marked.len()); + let mut stack = Vec::<(String, usize)>::new(); + let mut ranges = Vec::new(); + let mut cursor = 0; + while cursor < marked.len() { + let rest = &marked[cursor..]; + let next_start = rest.find(START); + let next_end = rest.find(END); + let next = match (next_start, next_end) { + (Some(start), Some(end)) => start.min(end), + (Some(start), None) => start, + (None, Some(end)) => end, + (None, None) => { + output.push_str(rest); + break; + } + }; + output.push_str(&rest[..next]); + cursor += next; + let is_start = marked[cursor..].starts_with(START); + let prefix = if is_start { START } else { END }; + cursor += prefix.len(); + let Some(close) = marked[cursor..].find(CLOSE) else { + output.push_str(&marked[cursor - prefix.len()..]); + break; + }; + let node_id = marked[cursor..cursor + close].to_owned(); + cursor += close + CLOSE.len_utf8(); + if is_start { + stack.push((node_id, output.len())); + } else if let Some(index) = stack.iter().rposition(|(id, _)| id == &node_id) { + let (_, start) = stack.remove(index); + ranges.push(( + node_id, + GeneratedRange { + start, + end: output.len(), + }, + )); + } + } + ranges.sort_by(|left, right| { + left.1 + .start + .cmp(&right.1.start) + .then_with(|| right.1.end.cmp(&left.1.end)) + .then_with(|| left.0.cmp(&right.0)) + }); + (output, ranges) +} + +pub(crate) fn json_pointer_segment(value: &str) -> String { + value.replace('~', "~0").replace('/', "~1") +} + +#[cfg(test)] +mod line_height_calculation_tests { + use super::*; + use serde_json::json; + + fn entry(node: &str) -> ProvenanceEntry { + ProvenanceEntry { + element_index: None, + calculation: None, + generated_property: Some("lineHeight=\"26px\"".into()), + generated_range: None, + json_pointer: None, + node_id: Some(node.into()), + property: Some("lineHeight".into()), + variable_id: None, + style_id: None, + asset_id: None, + resolution: "raw-fallback".into(), + } + } + + #[test] + fn percent_line_height_records_how_px_was_derived() { + let snapshot: devup_mcp_figma::Snapshot = serde_json::from_value(json!({ + "fileKey":"k","roots":["t","b"],"diagnostics":[],"nodes":{ + "t":{"id":"t","type":"TEXT","fields":{"fontSize":16.25,"lineHeight":{"unit":"PERCENT","value":160}}}, + "b":{"id":"b","type":"TEXT","fields":{"fontSize":16,"lineHeight":{"unit":"PERCENT","value":150}, + "boundVariables":{"fontSize":{"id":"v"}}}}} + })).unwrap(); + let mut map = SourceMap::empty(); + map.entries = vec![entry("t"), entry("b")]; + map.describe_conversions(&snapshot); + assert!( + map.entries[0] + .calculation + .as_deref() + .unwrap() + .ends_with("= 26px") + ); + assert!( + map.entries[1] + .calculation + .as_deref() + .unwrap() + .contains("kept as a percentage") + ); + } +} diff --git a/crates/devup-mcp-devup-ui/src/provenance/baked_into_assets.rs b/crates/devup-mcp-devup-ui/src/provenance/baked_into_assets.rs new file mode 100644 index 00000000..5439516c --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/provenance/baked_into_assets.rs @@ -0,0 +1,893 @@ + + +/// Nodes whose variable bindings no correct generator could write out. +/// +/// An asset drawn in one colour becomes a Box masked to its shape with `bg` +/// set from that colour, so a token binding inside it survives as a token. +/// An asset drawn in more than one has no such form - a CSS mask carries +/// alpha only, and an SVG loaded through `` renders in its own +/// document where neither `currentColor` nor a CSS variable reaches it - so +/// it becomes an `` and every colour inside is baked into the file. +/// +/// Counting those bindings as expected-but-missing asks the generator for +/// something the platform does not offer, and it is not a one-off: measured +/// on a real screen, two play buttons each hid a bound circle behind a white +/// glyph, and the shortfall they produced put the whole fidelity report on +/// the response with nothing in it anyone could act on. +fn baked_into_assets(snapshot: &Snapshot, root_id: &str) -> BTreeSet { + let mut baked = BTreeSet::new(); + let mut pending = vec![root_id.to_owned()]; + while let Some(node_id) = pending.pop() { + let Some(node) = snapshot.nodes.get(&node_id) else { + continue; + }; + // A mask keeps its one colour in CSS, so keep looking inside it. + if !matches!(asset_kind(snapshot, node), None | Some(AssetKind::SvgMask)) { + let mut inside = vec![node_id]; + while let Some(current) = inside.pop() { + let Some(node) = snapshot.nodes.get(¤t) else { + continue; + }; + inside.extend(node.typed_view().child_ids().map(str::to_owned)); + baked.insert(current); + } + continue; + } + pending.extend(node.typed_view().child_ids().map(str::to_owned)); + } + baked +} + +fn variable_sources( + snapshot: &Snapshot, + semantic_nodes: &BTreeSet<&str>, +) -> BTreeSet<(String, String)> { + fn scan(node_id: &str, value: &serde_json::Value, output: &mut BTreeSet<(String, String)>) { + match value { + serde_json::Value::Object(object) => { + if object.get("type").and_then(serde_json::Value::as_str) == Some("VARIABLE_ALIAS") + && let Some(id) = object + .get("id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty() && *id != "figma.mixed" && *id != "MIXED") + { + output.insert((node_id.to_owned(), id.to_owned())); + } + for child in object.values() { + scan(node_id, child, output); + } + } + serde_json::Value::Array(values) => { + for child in values { + scan(node_id, child, output); + } + } + _ => {} + } + } + + let mut output = BTreeSet::new(); + for node_id in semantic_nodes { + if let Some(node) = snapshot.nodes.get(*node_id) { + let view = node.typed_view(); + // A mixed text-level fill is not a paint when every character is + // covered by explicit segment fills. Retain all other bindings. + let overridden = node.node_type == "TEXT" + && view.value("fills").is_some_and(|v| !v.is_array()) + && view + .value("styledTextSegments") + .and_then(serde_json::Value::as_array) + .is_some_and(|segments| { + !segments.is_empty() + && segments.iter().all(|segment| { + segment + .get("fills") + .and_then(serde_json::Value::as_array) + .is_some_and(|fills| !fills.is_empty()) + }) + && segments + .iter() + .filter_map(|segment| { + segment + .get("characters") + .and_then(serde_json::Value::as_str) + }) + .collect::() + == view.string("characters").unwrap_or("") + }); + for (field, value) in node.fields.iter().chain(node.extra.iter()) { + if overridden && field == "fills" { + continue; + } + if overridden + && field == "boundVariables" + && let Some(bindings) = value.as_object() + { + for (name, binding) in bindings { + if name != "fills" { + scan(node_id, binding, &mut output); + } + } + } else { + scan(node_id, value, &mut output); + } + } + } + } + output +} + +fn text_sources(snapshot: &Snapshot, text_nodes: &BTreeSet<&str>) -> Vec<(String, String)> { + let mut output = Vec::new(); + for node_id in text_nodes { + let Some(node) = snapshot.nodes.get(*node_id) else { + continue; + }; + let segments = node + .typed_view() + .value("styledTextSegments") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + .filter_map(|segment| { + segment + .get("characters") + .and_then(serde_json::Value::as_str) + }) + .filter(|characters| !characters.is_empty()) + .map(str::to_owned) + .collect::>(); + if segments.is_empty() { + if let Some(characters) = node + .typed_view() + .string("characters") + .filter(|characters| !characters.is_empty()) + { + output.push(((*node_id).to_owned(), characters.to_owned())); + } + } else { + output.extend( + segments + .into_iter() + .map(|characters| ((*node_id).to_owned(), characters)), + ); + } + } + output +} + +fn entry_range<'a>(entry: &ProvenanceEntry, tsx: &'a str) -> Option<&'a str> { + let range = entry.generated_range.as_ref()?; + (range.start < range.end + && range.end <= tsx.len() + && tsx.is_char_boundary(range.start) + && tsx.is_char_boundary(range.end)) + .then(|| &tsx[range.start..range.end]) +} + +fn source_covers_text(source: &str, characters: &str) -> bool { + if source == encode_jsx_text(characters) { + return true; + } + if !characters.contains(['\r', '\n', '\u{2028}', '\u{2029}']) { + return false; + } + let normalized = characters + .replace("\r\n", "\n") + .replace(['\r', '\u{2028}', '\u{2029}'], "\n"); + source + .split("
") + .map(str::trim) + .eq(normalized.split('\n').map(str::trim).map(encode_jsx_text)) +} + +/// The text as the converter writes it, so a match is against the one rule +/// both sides follow rather than a copy of it kept here. +fn encode_jsx_text(input: &str) -> String { + crate::codegen::escape_jsx_text(input) +} + +fn empty_layout_leaf(output: &CodegenOutput, node_id: &str) -> bool { + output.source_map.entries.iter().any(|entry| { + entry.node_id.as_deref() == Some(node_id) + && entry.property.is_none() + && entry_range(entry, &output.tsx).is_some_and(|source| { + let source = source.trim(); + ["") + && !source[1..].contains('<') + }) + }) +} + +fn asset_reference_matches( + snapshot: &Snapshot, + output: &CodegenOutput, + entry: &ProvenanceEntry, + node_id: &str, + asset_id: &str, + source: &str, +) -> bool { + if !(source.starts_with("src=\"") + || source.starts_with("maskImage=\"") + || source.starts_with("bg=\"")) + { + return false; + } + let Some(range) = entry.generated_range.as_ref() else { + return false; + }; + let owner = output + .source_map + .entries + .iter() + .filter(|e| e.property.is_none() && e.resolution == "node") + .filter_map(|e| Some((e.node_id.as_deref()?, e.generated_range.as_ref()?))) + .filter(|(_, r)| r.start <= range.start && r.end >= range.end) + .min_by_key(|(_, r)| r.end - r.start) + .map(|(id, _)| id); + let Some(owner) = owner else { + return false; + }; + if owner != node_id { + let parents = source_parents(snapshot); + if !snapshot + .nodes + .get(owner) + .is_some_and(|n| projects_as_asset(snapshot, n)) + || !has_asset_ancestor(node_id, &parents, &BTreeSet::from([owner.to_owned()])) + { + return false; + } + } + if output.source_map.asset_css_mappings.iter().any(|mapping| { + mapping["nodeId"] == owner + && mapping["assetId"] == asset_id + && mapping["assetPath"].as_str().is_some_and(|path| { + source == format!("src=\"{path}\"") + || source.contains(&format!("url('{path}')")) + || source.contains(&format!("url({path})")) + }) + }) { + return true; + } + [false, true].into_iter().any(|per_node| { + let path = crate::codegen::asset_path(snapshot, owner, per_node).or_else(|| { + let index = asset_id.rsplit_once(":fills:")?.1.parse().ok()?; + crate::codegen::image_fill_path(snapshot, owner, index, per_node) + }); + path.is_some_and(|path| { + source == format!("src=\"{path}\"") + || source.contains(&format!("url('{path}')")) + || source.contains(&format!("url({path})")) + }) + }) +} + +// A property name or a source-map range alone does not prove size preservation. +pub(crate) fn node_opening<'a>(output: &'a CodegenOutput, node_id: &str) -> Option<&'a str> { + output.source_map.entries.iter().find_map(|entry| { + if entry.node_id.as_deref() != Some(node_id) || entry.property.is_some() { + return None; + } + let source = entry_range(entry, &output.tsx)?; + source.split_once('>').map(|(opening, _)| opening) + }) +} + +fn fill_axis_is_established( + snapshot: &Snapshot, + output: &CodegenOutput, + node_id: &str, + axis: &str, +) -> bool { + fn established( + snapshot: &Snapshot, + output: &CodegenOutput, + node_id: &str, + axis: &str, + seen: &mut BTreeSet, + ) -> bool { + if !seen.insert(node_id.into()) { + return false; + } + let Some(parent) = snapshot + .nodes + .values() + .find(|parent| parent.typed_view().child_ids().any(|id| id == node_id)) + else { + // Normal block roots have a host width, but no implicit height. + return axis == "width"; + }; + let Some(opening) = node_opening(output, &parent.id) else { + return false; + }; + let prop = if axis == "width" { "w" } else { "h" }; + for prop in [prop, "boxSize"] { + let needle = format!("{prop}=\""); + if let Some(start) = find_prop(opening, &needle) { + let Some((value, _)) = opening[start + needle.len()..].split_once('"') else { + return false; + }; + if value == "100%" { + return established(snapshot, output, &parent.id, axis, seen); + } + return value + .strip_suffix("px") + .and_then(|value| value.parse::().ok()) + .is_some_and(|value| value.is_finite() && value > 0.0); + } + } + // A definite flex main size also provides the basis for nested FILL. + // Follow the emitted flex chain; a HUG ancestor still ends this proof. + if axis == "height" && sizing::has_vertical_flex_basis(snapshot, output, &parent.id) { + return established(snapshot, output, &parent.id, axis, seen); + } + // HUG/shrink-to-fit width and percentage children are a cycle, not an + // independent size anchor. Percentage height cannot use an auto parent. + axis == "width" + && parent.typed_view().string("layoutSizingHorizontal") != Some("HUG") + && !opening.contains("pos=\"absolute\"") + && !opening.contains("pos=\"fixed\"") + && !opening.contains("display=\"inline") + && !opening.contains("float=") + && established(snapshot, output, &parent.id, axis, seen) + } + established(snapshot, output, node_id, axis, &mut BTreeSet::new()) +} + +fn dimension_value_matches( + snapshot: &Snapshot, + output: &CodegenOutput, + node_id: &str, + axis: &str, + source: &str, +) -> bool { + if !matches!(axis, "width" | "height") { + return true; + } + let Some(node) = snapshot.nodes.get(node_id) else { + return false; + }; + let view = node.typed_view(); + let sizing = if axis == "width" { + "layoutSizingHorizontal" + } else { + "layoutSizingVertical" + }; + let strict_leaf = empty_layout_leaf(output, node_id) + && (view.string(sizing) == Some("HUG") + || view + .value("absoluteRenderBounds") + .is_some_and(|v| v.is_object())); + let requires_measured_size = projects_as_asset(snapshot, node) || strict_leaf; + let Some(value) = source + .split_once("=\"") + .and_then(|(_, v)| v.strip_suffix('"')) + else { + return !requires_measured_size; + }; + if view.string(sizing) == Some("FILL") && requires_measured_size { + // A measured pixel width is not equivalent to filling the parent. + return value == "100%" && fill_axis_is_established(snapshot, output, node_id, axis); + } + if source.starts_with("aspectRatio=") && requires_measured_size { + let Some((w, h)) = crate::codegen::folded_mask_dimensions(snapshot, node) else { + return false; + }; + let Some((x, y)) = value.split_once('/').and_then(|(x, y)| { + Some((x.trim().parse::().ok()?, y.trim().parse::().ok()?)) + }) else { + return false; + }; + let (opposite, opposite_sizing, prop) = if axis == "height" { + ("width", "layoutSizingHorizontal", "w") + } else { + ("height", "layoutSizingVertical", "h") + }; + return view.string(sizing) == Some("HUG") + && view.string(opposite_sizing) == Some("FILL") + && x.is_finite() + && y.is_finite() + && x > 0.0 + && y > 0.0 + && (x / y - w / h).abs() < 1e-9 + && node_opening(output, node_id).is_some_and(|opening| { + find_prop(opening, &format!("{prop}=\"100%\"")).is_some() + && find_prop(opening, if axis == "height" { "h=" } else { "w=" }).is_none() + && find_prop(opening, "boxSize=").is_none() + }) + && fill_axis_is_established(snapshot, output, node_id, opposite); + } + if let Some(px) = value.strip_suffix("px") { + let Ok(actual) = px.parse::() else { + return false; + }; + if !actual.is_finite() { + return false; + } + let view = node.typed_view(); + if view.number(axis).is_some_and(|v| v > 0.0) && actual <= 0.0 { + return false; + } + if requires_measured_size { + let parent = snapshot + .nodes + .values() + .find(|candidate| candidate.typed_view().child_ids().any(|id| id == node_id)); + let canvas = parent + .map(|p| p.node_type.as_str()) + .or_else(|| view.string("parentType")) + .is_some_and(|kind| matches!(kind, "SECTION" | "PAGE" | "COMPONENT_SET")); + let absolute = view.string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout(snapshot, node, parent, canvas); + let bounds = if projects_as_asset(snapshot, node) && absolute { + crate::codegen::export_box(snapshot, node) + } else if view.number("rotation").is_some_and(|r| r.abs() > 0.01) { + crate::codegen::layout_box(node) + } else { + None + }; + let expected = bounds + .map(|b| if axis == "width" { b.w } else { b.h }) + .or_else(|| view.number(axis)) + .or_else(|| view.value("absoluteBoundingBox")?.get(axis)?.as_f64()); + return expected + .is_some_and(|expected| actual > 0.0 && (actual - expected).abs() < 0.01); + } + return true; + } + // Percentages, expressions and a ratio without a proven opposing axis + // depend on a host. They cannot prove an exported leaf's measured size. + !requires_measured_size +} + +fn layout_source_matches(property: &str, source: &str) -> bool { + let is_prop = |name: &str| { + source.starts_with(&format!("{name}=\"")) + || source.starts_with(&format!("{name}={{")) + || source.starts_with(&format!("\"{name}\":")) + }; + match property { + "layoutMode" => { + is_prop("flexDir") || matches!(source, "VStack" | "Flex" | "Grid" | "Center") + } + "layoutPositioning" => is_prop("pos"), + "width" => is_prop("w") || is_prop("boxSize") || is_prop("aspectRatio"), + "height" => is_prop("h") || is_prop("boxSize") || is_prop("aspectRatio"), + "itemSpacing" => is_prop("gap") || is_prop("m"), + "paddingTop" => is_prop("p") || is_prop("py") || is_prop("pt"), + "paddingRight" => is_prop("p") || is_prop("px") || is_prop("pr"), + "paddingBottom" => is_prop("p") || is_prop("py") || is_prop("pb"), + "paddingLeft" => is_prop("p") || is_prop("px") || is_prop("pl"), + _ => false, + } +} + +fn active_nodes(snapshot: &Snapshot, root_id: &str) -> Vec<(String, Option)> { + let mut output = Vec::new(); + let Some(root) = snapshot.nodes.get(root_id) else { + return output; + }; + output.push((root_id.to_owned(), None)); + let start = if root.node_type == "SECTION" { + root.typed_view().child_ids().next() + } else { + Some(root_id) + }; + let Some(start) = start else { + return output; + }; + let mut stack = if start == root_id { + let mut children = root + .typed_view() + .child_ids() + .map(|child| (child.to_owned(), Some(root_id.to_owned()))) + .collect::>(); + children.reverse(); + children + } else { + vec![(start.to_owned(), Some(root_id.to_owned()))] + }; + let mut seen = BTreeSet::from([root_id.to_owned()]); + while let Some((node_id, parent_id)) = stack.pop() { + if !seen.insert(node_id.clone()) { + continue; + } + let Some(node) = snapshot.nodes.get(&node_id) else { + continue; + }; + output.push((node_id.clone(), parent_id)); + let mut children = node.typed_view().child_ids().collect::>(); + children.reverse(); + for child_id in children { + stack.push((child_id.to_owned(), Some(node_id.clone()))); + } + } + output +} + +fn typography_sources<'a>( + snapshot: &'a Snapshot, + text_nodes: &BTreeSet<&'a str>, +) -> BTreeSet<(&'a str, &'a str)> { + let mut styles = BTreeSet::new(); + for node_id in text_nodes { + let Some(node) = snapshot.nodes.get(*node_id) else { + continue; + }; + if let Some(style_id) = node + .typed_view() + .string("textStyleId") + .filter(|id| !id.is_empty()) + { + styles.insert((*node_id, style_id)); + } + if let Some(segments) = node + .typed_view() + .value("styledTextSegments") + .and_then(serde_json::Value::as_array) + { + for style_id in segments.iter().filter_map(|segment| { + segment + .get("textStyleId") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + }) { + styles.insert((*node_id, style_id)); + } + } + } + styles +} + +const LAYOUT_FIELDS: &[&str] = &[ + "layoutSizingHorizontal", + "layoutSizingVertical", + "layoutGrow", + "layoutMode", + "layoutPositioning", + "width", + "height", + "itemSpacing", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", +]; + +pub(crate) fn mark_node(node_id: &str, rendered: String) -> String { + format!("{START}{node_id}{CLOSE}{rendered}{END}{node_id}{CLOSE}") +} + +pub(crate) fn finalize_tsx( + marked: &str, + snapshot: &Snapshot, + variable_tokens: &BTreeMap, + style_tokens: &BTreeMap, +) -> (String, SourceMap) { + let (tsx, node_ranges) = strip_markers(marked); + let emitted_ranges = node_ranges + .iter() + .cloned() + .collect::>(); + let mut assets_by_node = BTreeMap::>::new(); + for asset in discover_asset_manifest(snapshot).assets { + assets_by_node + .entry(asset.node_id.clone()) + .or_default() + .push(asset); + } + let mut entries = Vec::new(); + for (node_id, range) in node_ranges { + let Some(node) = snapshot.nodes.get(&node_id) else { + continue; + }; + entries.push(ProvenanceEntry { + element_index: None, + calculation: None, + generated_property: None, + generated_range: Some(range.clone()), + json_pointer: None, + node_id: Some(node_id.clone()), + property: None, + variable_id: None, + style_id: None, + asset_id: None, + resolution: "node".to_owned(), + }); + if node.node_type == "COMPONENT_SET" { + continue; + } + let node_source = &tsx[range.start..range.end]; + let Some(open_relative) = node_source.find('<') else { + continue; + }; + let Some(close_relative) = node_source[open_relative..].find('>') else { + continue; + }; + let opening_end = open_relative + close_relative + 1; + let opening = &node_source[open_relative..opening_end]; + let component_start = open_relative + 1; + let component_end = opening[1..] + .find(|character: char| character.is_whitespace() || matches!(character, '>' | '/')) + .map_or(open_relative + opening.len(), |offset| { + open_relative + offset + 1 + }); + if component_end > component_start { + entries.push(generated_entry( + range.start + component_start, + range.start + component_end, + &node_id, + "type", + None, + None, + "exact", + )); + if matches!( + node.typed_view().string("layoutMode"), + Some("HORIZONTAL" | "VERTICAL" | "GRID") + ) { + entries.push(generated_entry( + range.start + component_start, + range.start + component_end, + &node_id, + "layoutMode", + None, + None, + "raw-fallback", + )); + } + } + + let mut prop_sources = PROP_SOURCES.to_vec(); + if crate::codegen::asset_kind(snapshot, node).is_none() + && crate::codegen::single_outside_stroke(&node.typed_view()).is_some() + { + prop_sources.push(("boxShadow", "strokes")); + if node + .typed_view() + .value("effects") + .and_then(serde_json::Value::as_array) + .is_some_and(|effects| !effects.is_empty()) + { + prop_sources.push(("boxShadow", "effects")); + } + } + let mut selector_properties = BTreeSet::new(); + if let Some(selector) = non_default_variant_selector(snapshot, node) { + for (prop, property) in &prop_sources { + let Some((start, end)) = selector_prop_range(opening, &selector, prop) else { + continue; + }; + selector_properties.insert(*property); + entries.push(generated_entry( + range.start + open_relative + start, + range.start + open_relative + end, + &node_id, + property, + None, + None, + "variant-selector", + )); + } + } + + for (prop, property) in &prop_sources { + if *property == "overflowDirection" + && node.typed_view().string("overflowDirection").is_none() + { + continue; + } + if *prop == "aspectRatio" && node.typed_view().value("targetAspectRatio").is_none() { + continue; + } + if selector_properties.contains(property) { + continue; + } + let needle = format!("{prop}=\""); + let Some(start) = find_prop(opening, &needle) else { + let expression = format!("{prop}={{"); + if let Some(start) = find_prop(opening, &expression) { + entries.push(generated_entry( + range.start + open_relative + start, + range.start + open_relative + start + expression.len(), + &node_id, + property, + None, + None, + "raw-fallback", + )); + } + continue; + }; + let value_start = start + needle.len(); + let Some(value_end) = opening[value_start..].find('"') else { + continue; + }; + let end = value_start + value_end + 1; + let value = &opening[value_start..value_start + value_end]; + let variable_id = find_resource_id(value, variable_tokens, '$'); + let style_id = (*prop == "typography") + .then(|| { + style_tokens + .iter() + .find_map(|(id, token)| (token == value).then(|| id.clone())) + }) + .flatten(); + let resolution = if *prop == "whiteSpace" + && value == "pre-wrap" + && crate::codegen::preserves_hard_break_spaces(&node.typed_view()) + { + "derived-hard-break-whitespace" + } else if *prop == "boxShadow" + && crate::codegen::asset_kind(snapshot, node).is_none() + && crate::codegen::single_outside_stroke(&node.typed_view()).is_some() + { + "derived-single-edge-outside-stroke" + } else if variable_id.is_some() { + "variable-token" + } else if style_id.is_some() { + "style-token" + } else if *prop == "justifyContent" + && value == "center" + && crate::codegen::centers_lone_space_between_child(snapshot, node) + { + "derived-lone-child-center" + } else if matches!(*property, "width" | "height") + && !node.field_errors.contains_key(*property) + && matches!(*prop, "w" | "h" | "boxSize") + && value + .strip_suffix("px") + .and_then(|v| v.parse::().ok()) + .zip(node.typed_view().number(property)) + .is_some_and(|(generated, original)| { + original.is_finite() && original == generated + }) + { + "verified-explicit-dimension" + } else { + "raw-fallback" + }; + let source_property = if *prop == "whiteSpace" + && value == "pre-wrap" + && crate::codegen::preserves_hard_break_spaces(&node.typed_view()) + { + if node.typed_view().string("characters").is_some() { + "characters" + } else { + "styledTextSegments" + } + } else if *prop == "minW" + && value == "0" + && node.typed_view().number("minWidth").is_none() + && node.typed_view().string("layoutSizingHorizontal") == Some("FILL") + { + "layoutSizingHorizontal" + } else if *prop == "fontWeight" + && node.node_type == "TEXT" + && node.typed_view().number(property).is_none() + { + "styledTextSegments" + } else { + property + }; + entries.push(generated_entry( + range.start + open_relative + start, + range.start + open_relative + end, + &node_id, + source_property, + variable_id, + style_id, + resolution, + )); + } + + if crate::codegen::folded_mask_dimensions(snapshot, node).is_some() { + for (axis, sizing, prop) in [ + ("width", "layoutSizingHorizontal", "w"), + ("height", "layoutSizingVertical", "h"), + ] { + if node.typed_view().string(sizing) != Some("HUG") { + continue; + } + let derived_prop = if find_prop(opening, "aspectRatio=\"").is_some() { + "aspectRatio" + } else { + prop + }; + let needle = format!("{derived_prop}=\""); + if let Some(start) = find_prop(opening, &needle) + && let Some(end) = opening[start + needle.len()..].find('"') + { + let end = start + needle.len() + end + 1; + for field in [axis, "width", "height", sizing, "childrenIds"] { + let mut entry = generated_entry( + range.start + open_relative + start, + range.start + open_relative + end, + &node_id, + field, + None, + None, + "restored-hug-after-mask-child-folding", + ); + if matches!(field, "width" | "height") + && node.typed_view().number(field).is_none() + { + entry.resolution = + "restored-hug-after-mask-child-folding-from-absoluteBoundingBox" + .into(); + } + if !entries.contains(&entry) { + entries.push(entry); + } + } + } + } + } + + if let Some(asset) = assets_by_node + .get(&node_id) + .and_then(|assets| assets.first()) + && let Some((start, end)) = asset_prop_range(opening) + { + entries.push(ProvenanceEntry { + element_index: None, + calculation: None, + generated_property: None, + generated_range: Some(GeneratedRange { + start: range.start + open_relative + start, + end: range.start + open_relative + end, + }), + json_pointer: None, + node_id: Some(node_id.clone()), + property: Some(asset.field.clone()), + variable_id: None, + style_id: None, + asset_id: Some(asset.asset_id.clone()), + resolution: "asset".to_owned(), + }); + } + + if node.node_type == "TEXT" { + add_text_entries( + &tsx, + &range, + &node_id, + node, + variable_tokens, + style_tokens, + &mut entries, + ); + } + } + add_flattened_resource_entries( + &tsx, + snapshot, + &emitted_ranges, + variable_tokens, + &assets_by_node, + &mut entries, + ); + entries.sort_by(|left, right| { + let left_range = left.generated_range.as_ref(); + let right_range = right.generated_range.as_ref(); + left_range + .map(|range| (range.start, range.end)) + .cmp(&right_range.map(|range| (range.start, range.end))) + .then_with(|| left.node_id.cmp(&right.node_id)) + .then_with(|| left.property.cmp(&right.property)) + }); + ( + tsx, + SourceMap { + version: 2, + entries, + asset_css_mappings: Vec::new(), + variable_tokens: variable_tokens.clone(), + style_tokens: style_tokens.clone(), + }, + ) +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/provenance/resolution.rs b/crates/devup-mcp-devup-ui/src/provenance/resolution.rs index 635b38b3..9ca108ff 100644 --- a/crates/devup-mcp-devup-ui/src/provenance/resolution.rs +++ b/crates/devup-mcp-devup-ui/src/provenance/resolution.rs @@ -9,9 +9,9 @@ pub fn resolution_semantics() -> Value { "values":{ "exact":"Tag/text mapping.", "raw-fallback":"Value/policy mapping, not fidelity; ABSOLUTE can separately be verified.", - "derived-lone-child-center":"Horizontal/vertical SPACE_BETWEEN becomes center for exactly one visible non-ABSOLUTE child; child membership, visibility and positioning determine the count. This mapping does not claim pixel parity.", - "derived-single-edge-outside-stroke":"One solid, square-cornered OUTSIDE edge on a non-asset auto-layout node paints as a zero-blur translated box shadow without consuming layout space; side weights and stroke paint determine the translation and color. Existing effects are composed after the stroke. This is a stroke projection, not an assertion that Figma supplied a shadow effect, a claim about exported-asset composition, or a claim of pixel parity.", - "derived-hard-break-whitespace":"Source spaces adjacent to explicit line breaks select pre-wrap for non-list, unclamped text whose inline width is fixed and height is automatic. Characters come from the node or collected styled segments. This preserves source whitespace without changing Korean keep-all; it does not claim intrinsic HUG sizing, glyph parity or identical wrapping.", + "derived-lone-child-center":include_str!("../text/resolution-resolution_semantics-2373f8c7.md"), + "derived-single-edge-outside-stroke":include_str!("../text/resolution-resolution_semantics-6489c0ec.md"), + "derived-hard-break-whitespace":include_str!("../text/resolution-resolution_semantics-2c171ecb.md"), "verified-explicit-dimension":"Error-free source = emitted px.", "verified-layout-sizing":"FIXED/FILL/layoutGrow to dimension/flex.", "accounted-for-content-sizing":"textAutoResize omission; pixels unmeasured.", @@ -22,8 +22,8 @@ pub fn resolution_semantics() -> Value { "variable-token":"Variable token.", "style-token":"Style token.", "asset":"Asset prop; binary state separate.", - "asset-css-projection":"Acquired image representation plus supported paint geometry maps to the exact emitted property; assetCssMappings records pixel sizes, normalized coordinates, baking evidence and explicit limits.", - "effects-baked-into-asset":"Supplied acquisition evidence confirms baked effects. The same CSS effects are suppressed at this asset boundary; layout ancestors retain their effects. Missing baking evidence is unknown; no canvas equality is implied.", + "asset-css-projection":include_str!("../text/resolution-resolution_semantics-1fe5685d.md"), + "effects-baked-into-asset":include_str!("../text/resolution-resolution_semantics-16da012c.md"), "variant-selector":"Variant to selector.", "unverified-property-mapping":"Generated property unproven.", "variable":"Variable to JSON pointer.", diff --git a/crates/devup-mcp-devup-ui/src/provenance/sizing.rs b/crates/devup-mcp-devup-ui/src/provenance/sizing.rs index af8d9a76..9f46b911 100644 --- a/crates/devup-mcp-devup-ui/src/provenance/sizing.rs +++ b/crates/devup-mcp-devup-ui/src/provenance/sizing.rs @@ -1,1237 +1,4 @@ -use super::*; -use serde_json::Value; -pub(super) fn non_rendering_asset_accounted( - snapshot: &Snapshot, - output: &CodegenOutput, - id: &str, -) -> bool { - snapshot.nodes.get(id).is_some_and(|node| { - !node.field_errors.contains_key("absoluteRenderBounds") - && node - .typed_view() - .value("absoluteRenderBounds") - .is_some_and(Value::is_null) - && node_opening(output, id).is_some_and(|tag| { - prop(tag, "visibility") == Some("hidden") - && ["src", "bgImage", "maskImage"] - .iter() - .all(|name| prop(tag, name).is_none()) - }) - }) -} - -/// Verify emitted props against collected geometry, never against a resolution label. -/// This is a bounded constraint proof, not a browser measurement. -pub(crate) fn absolute_component_verification( - snapshot: &Snapshot, - output: &CodegenOutput, - id: &str, -) -> Value { - let Some(node) = snapshot.nodes.get(id) else { - return Value::Null; - }; - let view = node.typed_view(); - let tag = node_opening(output, id).unwrap_or(""); - let owner = parent(snapshot, output, id); - let parent_tag = owner - .and_then(|p| node_opening(output, &p.id)) - .unwrap_or(""); - let no_parent_border = [ - "border", - "borderWidth", - "borderLeft", - "borderRight", - "borderTop", - "borderBottom", - "borderLeftWidth", - "borderRightWidth", - "borderTopWidth", - "borderBottomWidth", - ] - .iter() - .all(|name| prop(parent_tag, name).is_none()); - let dimension = |axis: &str, name: &str, sizing: &str| { - let original = view.number(axis); - let generated = prop(tag, name).or_else(|| prop(tag, "boxSize")); - let preserved = !node.field_errors.contains_key(axis) - && !node.field_errors.contains_key(sizing) - && view.string(sizing) == Some("FIXED") - && original - .zip(generated.and_then(|v| v.strip_suffix("px")?.parse::().ok())) - .is_some_and(|(a, b)| a.is_finite() && a == b); - let auto_layout = !node.field_errors.contains_key("layoutMode") - && flex_axis(tag).is_some_and(|mode| view.string("layoutMode") == Some(mode)); - let intrinsic = auto_layout - && generated.is_none() - && view.string(sizing) == Some("HUG") - && !node.field_errors.contains_key(sizing) - && ["minW", "maxW", "minH", "maxH", "flex"] - .iter() - .all(|name| prop(tag, name).is_none()); - // An absolute Box with derived padding need not be an auto-layout - // container. Its percentage still resolves in the emitted positioned - // parent. Keep this extension on width; retain the prior height proof. - let absolute_width = axis == "width" - && !node.field_errors.contains_key("layoutMode") - && prop(tag, "pos") == Some("absolute") - && view.string("layoutPositioning") == Some("ABSOLUTE") - && !node.field_errors.contains_key("layoutPositioning") - && matches!( - prop(parent_tag, "pos"), - Some("relative" | "absolute" | "fixed") - ) - && prop(parent_tag, "transform").is_none(); - let percentage = (auto_layout || absolute_width) - && no_parent_border - && generated == Some("100%") - && view.string(sizing) == Some("FIXED") - && !node.field_errors.contains_key(axis) - && !node.field_errors.contains_key(sizing) - && ["minW", "maxW", "minH", "maxH"] - .iter() - .all(|name| prop(tag, name).is_none()) - && owner.is_some_and(|p| { - let pv = p.typed_view(); - !p.field_errors.contains_key(axis) - && !p.field_errors.contains_key(sizing) - && pv.string(sizing) == Some("FIXED") - && original - .zip(pv.number(axis)) - .is_some_and(|(a, b)| a.is_finite() && a == b) - && original - .zip( - prop(parent_tag, name) - .or_else(|| prop(parent_tag, "boxSize")) - .and_then(|v| v.strip_suffix("px")?.parse::().ok()), - ) - .is_some_and(|(a, b)| a == b) - && [ - "minW", "maxW", "minH", "maxH", "p", "px", "py", "pl", "pr", "pt", "pb", - ] - .iter() - .all(|name| prop(parent_tag, name).is_none()) - }); - // Explain failure without changing any of the acceptance predicates above. - // Report the first blocker, not an assertion that other checks passed. - let child_override = ["minW", "maxW", "minH", "maxH"] - .iter() - .any(|p| prop(tag, p).is_some()); - let parent_override = ["minW", "maxW", "minH", "maxH"] - .iter() - .any(|p| prop(parent_tag, p).is_some()); - let parent_pixels = prop(parent_tag, name) - .or_else(|| prop(parent_tag, "boxSize")) - .and_then(|v| v.strip_suffix("px")?.parse::().ok()) - .filter(|v| v.is_finite()); - let parent_source = owner - .and_then(|p| p.typed_view().number(axis)) - .filter(|v| v.is_finite()); - let blocked_by = if preserved || intrinsic || percentage { - None - } else if [axis, sizing] - .iter() - .any(|f| node.field_errors.contains_key(*f)) - || (generated == Some("100%") - && owner.is_some_and(|p| { - [axis, sizing] - .iter() - .any(|f| p.field_errors.contains_key(*f)) - })) - || (!auto_layout - && ["layoutMode", "layoutPositioning"] - .iter() - .any(|f| node.field_errors.contains_key(*f))) - { - Some("read-error") - } else if view.string(sizing) == Some("HUG") && generated.is_none() { - Some(if child_override || prop(tag, "flex").is_some() { - "conflicting-dimension-props" - } else { - "intrinsic-layout-unproven" - }) - } else if view.string(sizing) != Some("FIXED") - || (generated == Some("100%") - && owner.is_some_and(|p| p.typed_view().string(sizing) != Some("FIXED"))) - { - Some("non-fixed-sizing") - } else if original.is_none_or(|v| !v.is_finite()) { - Some("source-dimension-unknown") - } else if generated != Some("100%") { - Some("generated-dimension-unproven") - } else if child_override || parent_override { - Some("conflicting-dimension-props") - } else if parent_pixels.is_none() || parent_source.is_none() { - Some(if axis == "width" { - "parent-width-unknown" - } else { - "parent-height-unknown" - }) - } else if original != parent_pixels || original != parent_source { - Some(if axis == "width" { - "parent-width-unequal" - } else { - "parent-height-unequal" - }) - } else if !no_parent_border - || ["p", "px", "py", "pl", "pr", "pt", "pb"] - .iter() - .any(|p| prop(parent_tag, p).is_some()) - { - Some("parent-padding-or-border") - } else { - Some("containing-block-unproven") - }; - let mut evidence = json!({"state":if preserved {"preserved"} else if intrinsic || percentage {"verified"} else {"approximated"},"fidelityImpact":if preserved || intrinsic || percentage {"none"} else {"approximated"}, - "sourceSizing":view.string(sizing),"sourceValue":original,"generatedValue":generated, - "reason":if preserved {"Explicit generated pixels equal the collected FIXED dimension."} else if intrinsic {"Source HUG is emitted as intrinsic size on a matching auto-layout container without dimension overrides; this verifies sizing intent, not measured pixels."} else if percentage && absolute_width {"Absolute percentage resolves to the collected FIXED width in the emitted immediate positioned parent with equal explicit FIXED pixels, no parent padding/borders or dimension overrides."} else if percentage {"Auto-layout percentage equals the source FIXED dimension in an explicit equal-sized FIXED parent without padding or borders."} else {"No verified explicit FIXED dimension; percentage, intrinsic or missing sizes need a separate sizing proof."}, - "resolutionCondition":"Verify the emitted dimension against source sizing; percentage sizing also requires a proven containing-block size and responsive relation."}); - evidence["blockedBy"] = json!(blocked_by); - if axis == "width" { - evidence["sourceFields"] = json!([ - "width", - "layoutSizingHorizontal", - "layoutPositioning", - "parentId", - "parent.width", - "parent.layoutSizingHorizontal" - ]); - evidence["parentGeneratedValue"] = - json!(prop(parent_tag, name).or_else(|| prop(parent_tag, "boxSize"))); - evidence["resolvedPixels"] = json!(if percentage || preserved { - original - } else { - None - }); - evidence["calculation"] = json!(if preserved { - "Generated explicit px width equals the source FIXED width." - } else if intrinsic { - "No explicit width on a matching HUG auto-layout container; intrinsic sizing intent is verified without resolving pixels." - } else { - "For a proven containing block: resolved width = 100 / 100 * generated parent width in px = source FIXED width. Unknown or unequal parent widths, read errors, non-FIXED sizing and conflicting dimension props do not establish this relation." - }); - } - evidence - }; - let common = prop(tag, "pos") == Some("absolute") - && owner.is_some_and(|p| p.node_type == "FRAME") - && matches!( - prop(parent_tag, "pos"), - Some("relative" | "absolute" | "fixed") - ) - && [node].into_iter().chain(owner).all(|n| { - let v = n.typed_view(); - !["x", "y", "width", "height", "rotation", "constraints"] - .iter() - .any(|f| n.field_errors.contains_key(*f)) - && v.number("rotation").is_none_or(|r| r == 0.0) - }) - && no_parent_border - && prop(parent_tag, "transform").is_none() - && ["m", "mx", "my", "ml", "mr", "mt", "mb", "inset"] - .iter() - .all(|p| prop(tag, p).is_none()); - let horizontal = view - .value("constraints") - .and_then(|v| v.get("horizontal")) - .and_then(Value::as_str) - .or_else(|| view.value("constraints").is_none().then_some("MIN")); - let vertical = view - .value("constraints") - .and_then(|v| v.get("vertical")) - .and_then(Value::as_str) - .or_else(|| view.value("constraints").is_none().then_some("MIN")); - let expected_transform = match (horizontal, vertical) { - (Some("CENTER"), Some("CENTER")) => Some("translate(-50%, -50%)"), - (Some("CENTER"), _) => Some("translateX(-50%)"), - (_, Some("CENTER")) => Some("translateY(-50%)"), - _ => None, - }; - let width = dimension("width", "w", "layoutSizingHorizontal"); - let height = dimension("height", "h", "layoutSizingVertical"); - let axis = |constraint: Option<&str>, - coordinate: &str, - size: &str, - css: &str, - opposite: &str| { - let offset = view.number(coordinate); - let own_size = view.number(size); - let parent_size = owner.and_then(|p| p.typed_view().number(size)); - let (css, opposite) = if constraint == Some("MAX") { - (opposite, css) - } else { - (css, opposite) - }; - let generated = prop(tag, css); - let verified = common - && prop(tag, opposite).is_none() - && prop(tag, "transform") == expected_transform - && match constraint { - Some("CENTER") => { - generated == Some("50%") - && offset - .zip(own_size) - .zip(parent_size) - .is_some_and(|((x, w), p)| { - x.is_finite() - && w.is_finite() - && p.is_finite() - && (x + w / 2.0 - p / 2.0).abs() < 1e-6 - }) - } - Some("MIN") => offset - .zip(generated.and_then(|v| v.strip_suffix("px")?.parse::().ok())) - .is_some_and(|(a, b)| a.is_finite() && a == b), - Some("MAX") => { - (if size == "width" { &width } else { &height })["fidelityImpact"] == "none" - && offset - .zip(own_size) - .zip(parent_size) - .zip(generated.and_then(|v| v.strip_suffix("px")?.parse::().ok())) - .is_some_and(|(((x, w), p), margin)| { - x.is_finite() - && w.is_finite() - && p.is_finite() - && (p - x - w - margin).abs() < 1e-6 - }) - } - _ => false, - }; - json!({"state":if verified {"verified"} else {"approximated"},"fidelityImpact":if verified {"none"} else {"approximated"}, - "constraint":constraint,"constraintDeclared":view.value("constraints").is_some(),"sourceOffset":offset,"sourceSize":own_size,"parentSize":parent_size, - "generatedProperty":css,"generatedValue":generated,"transform":prop(tag,"transform"), - "reason":if verified {"Collected offset and emitted CSS agree in the emitted immediate positioned parent; without a declared constraint only the captured local offset is verified."} else {"Constraint, geometry, read errors, containing block or emitted props do not satisfy the bounded CENTER/MIN/MAX proof; STRETCH/SCALE and MAX with unverified sizing require separate proofs."}, - "resolutionCondition":"Collect error-free local geometry and constraints; establish the emitted immediate containing block; verify MIN offset, zero CENTER offset with exact translation, or MAX margin with verified sizing, without conflicting props."}) - }; - let mut components = json!({"height":height,"width":width, - "horizontal":axis(horizontal,"x","width","left","right"),"vertical":axis(vertical,"y","height","top","bottom"), - "containingBlock":{"parentId":owner.map(|p|&p.id),"generatedParent":parent_tag,"generatedChild":tag}}); - if let Some(proof) = crate::codegen::width::policy_horizontal_proof(snapshot, output, id) { - components["horizontal"] = proof; - } - if let Some(proof) = crate::codegen::width::policy_width_proof(snapshot, output, id) { - components["width"] = proof; - } - super::absolute_bounds::annotate(snapshot, node, owner, tag, parent_tag, &mut components); - components -} - -pub(crate) fn prop<'a>(opening: &'a str, name: &str) -> Option<&'a str> { - let needle = format!("{name}=\""); - let start = find_prop(opening, &needle)? + needle.len(); - opening[start..].split_once('"').map(|(v, _)| v) -} - -fn flex_axis(opening: &str) -> Option<&'static str> { - let tag = opening - .trim_start() - .strip_prefix('<')? - .split(|c: char| c.is_whitespace() || c == '/') - .next()?; - if !matches!(tag, "VStack" | "Flex" | "Center") { - return None; - } - match prop(opening, "flexDir") { - Some("column") => Some("VERTICAL"), - Some("row") => Some("HORIZONTAL"), - None if tag == "VStack" => Some("VERTICAL"), - None => Some("HORIZONTAL"), - _ => None, - } -} - -fn parent<'a>( - snapshot: &'a Snapshot, - output: &CodegenOutput, - id: &str, -) -> Option<&'a devup_mcp_figma::RawNode> { - let parent = snapshot - .nodes - .values() - .find(|p| p.typed_view().child_ids().any(|c| c == id))?; - let range = output - .source_map - .entries - .iter() - .find(|e| e.node_id.as_deref() == Some(id) && e.property.is_none())? - .generated_range - .as_ref()?; - let owner = output - .source_map - .entries - .iter() - .filter(|e| e.property.is_none() && e.node_id.as_deref() != Some(id)) - .filter_map(|e| Some((e.node_id.as_deref()?, e.generated_range.as_ref()?))) - .filter(|(_, r)| r.start <= range.start && r.end >= range.end) - .min_by_key(|(_, r)| r.end - r.start)? - .0; - (owner == parent.id).then_some(parent) -} - -pub(super) fn has_vertical_flex_basis( - snapshot: &Snapshot, - output: &CodegenOutput, - id: &str, -) -> bool { - let Some(parent) = parent(snapshot, output, id) else { - return false; - }; - parent.typed_view().string("layoutMode") == Some("VERTICAL") - && node_opening(output, &parent.id).is_some_and(|s| flex_axis(s) == Some("VERTICAL")) - && node_opening(output, id).is_some_and(|s| { - prop(s, "flex") == Some("1") && !matches!(prop(s, "pos"), Some("absolute" | "fixed")) - }) - && snapshot - .nodes - .get(id) - .is_some_and(|n| n.typed_view().string("layoutSizingVertical") == Some("FILL")) -} - -fn css_px(value: &str) -> Option { - let n = value - .strip_suffix("px") - .unwrap_or(value) - .parse::() - .ok()?; - n.is_finite().then_some(n) -} - -fn horizontal_allocation( - snapshot: &Snapshot, - output: &CodegenOutput, - id: &str, - container: &devup_mcp_figma::RawNode, - opening: &str, -) -> Value { - let mut proof = json!({"state":"not-accounted-for", "cssBehavior":"flex main-axis grow allocation", - "parentId":container.id,"parentLayoutMode":"HORIZONTAL", "childSizing":"FILL", - "generatedParent":opening,"generatedChild":node_opening(output,id), - "flexBasis":"0%","flexShrink":1, - "evidenceLimit":"Verifies the responsive allocation contract at the captured parent/sibling sizes, not browser font metrics or pixel equivalence."}); - let check = || -> Result { - let pv = container.typed_view(); - if pv.string("layoutMode") != Some("HORIZONTAL") { - return Err("source-direction-mismatch"); - } - if prop(opening, "flexWrap").is_some_and(|v| v != "nowrap") - || pv.string("layoutWrap") == Some("WRAP") - { - return Err("wrapped-flex-line"); - } - if !fill_axis_is_established(snapshot, output, id, "width") { - return Err("parent-width-unestablished"); - } - let width = pv - .number("width") - .filter(|n| n.is_finite() && *n > 0.0) - .ok_or("parent-width-missing")?; - if let Some(w) = prop(opening, "w").or_else(|| prop(opening, "boxSize")) - && w != "100%" - && css_px(w).is_none_or(|w| (w - width).abs() > 0.5) - { - return Err("parent-width-mismatch"); - } - if [ - "minW", - "maxW", - "border", - "borderWidth", - "borderLeft", - "borderRight", - "borderLeftWidth", - "borderRightWidth", - ] - .iter() - .any(|p| prop(opening, p).is_some()) - { - return Err("parent-box-constraint"); - } - let mut padding = 0.0; - for (field, side) in [("paddingLeft", "pl"), ("paddingRight", "pr")] { - let source = pv.number(field).unwrap_or(0.0); - let emitted = prop(opening, side) - .or_else(|| prop(opening, "px")) - .or_else(|| prop(opening, "p")) - .map(css_px) - .unwrap_or(Some(0.0)) - .ok_or("parent-padding-unverified")?; - if (source - emitted).abs() > 0.5 { - return Err("parent-padding-mismatch"); - } - padding += emitted; - } - let gap = prop(opening, "columnGap") - .or_else(|| prop(opening, "gap")) - .map(css_px) - .unwrap_or(Some(0.0)) - .ok_or("gap-unverified")?; - // Spacing sits between the children laid out in the row. With one - // of them there is nothing to space, the code rightly writes no gap - // (`codegen::layout` omits it for the same reason), and the gap takes - // no part in the allocation below. A StatusCard whose second child is - // a hidden badge failed here on the 12px it does not need. - let in_flow = pv - .child_ids() - .filter_map(|sid| snapshot.nodes.get(sid)) - .filter(|sibling| { - let sv = sibling.typed_view(); - sv.bool("visible") != Some(false) - && sv.string("layoutPositioning") != Some("ABSOLUTE") - }) - .count(); - if gap < 0.0 || (in_flow > 1 && (gap - pv.number("itemSpacing").unwrap_or(0.0)).abs() > 0.5) - { - return Err("gap-mismatch"); - } - let mut siblings = Vec::new(); - let mut fixed = 0.0; - let mut total_grow = 0.0; - let mut own_grow = 0.0; - for sid in pv.child_ids() { - let sibling = snapshot.nodes.get(sid).ok_or("sibling-missing")?; - let sv = sibling.typed_view(); - if sv.bool("visible") == Some(false) - || sv.string("layoutPositioning") == Some("ABSOLUTE") - { - continue; - } - if parent(snapshot, output, sid).is_none_or(|p| p.id != container.id) { - return Err("sibling-parent-mismatch"); - } - let tag = node_opening(output, sid).ok_or("sibling-css-missing")?; - if matches!(prop(tag, "pos"), Some("absolute" | "fixed")) { - return Err("sibling-position-mismatch"); - } - if [ - "m", - "mx", - "ml", - "mr", - "minW", - "maxW", - "flexBasis", - "flexGrow", - ] - .iter() - .any(|p| prop(tag, p).is_some()) - { - return Err("sibling-flex-constraint"); - } - if prop(tag, "flexShrink").is_some_and(|v| !matches!(v, "0" | "1")) { - return Err("sibling-shrink-unverified"); - } - let measured = sv - .number("width") - .filter(|w| w.is_finite() && *w >= 0.0) - .ok_or("sibling-width-missing")?; - if sv.string("layoutSizingHorizontal") == Some("FILL") { - let primitive = tag - .trim_start() - .strip_prefix('<') - .unwrap_or("") - .split(|c: char| c.is_whitespace() || c == '/') - .next() - .unwrap_or(""); - if !matches!(primitive, "Box" | "Flex" | "Center" | "VStack") { - return Err("intrinsic-replaced-minimum-unverified"); - } - let grow = prop(tag, "flex") - .and_then(|s| s.parse::().ok()) - .filter(|g| g.is_finite() && *g > 0.0) - .ok_or("flex-shorthand-unverified")?; - if prop(tag, "w").is_some() - || prop(tag, "boxSize").is_some() - || prop(tag, "aspectRatio").is_some() - || sv.child_ids().next().is_some() - || sibling.node_type == "TEXT" - || [ - "p", - "px", - "pl", - "pr", - "border", - "borderWidth", - "borderLeft", - "borderRight", - "borderLeftWidth", - "borderRightWidth", - ] - .iter() - .any(|p| prop(tag, p).is_some()) - { - return Err("flex-minimum-unverified"); - } - if sv - .number("layoutGrow") - .is_some_and(|g| (g - grow).abs() > 0.001) - { - return Err("flex-grow-mismatch"); - } - total_grow += grow; - if sid == id { - own_grow = grow; - } - siblings.push(json!({"nodeId":sid,"grow":grow,"basis":"0%","shrink":prop(tag,"flexShrink").unwrap_or("1"),"capturedWidth":measured})); - } else { - if prop(tag, "flex").is_some() { - return Err("sibling-flex-basis-unverified"); - } - let explicit = prop(tag, "w") - .or_else(|| prop(tag, "boxSize")) - .and_then(css_px); - let intrinsic_text = sibling.node_type == "TEXT" - && sv.string("layoutSizingHorizontal") == Some("HUG") - && sv.string("characters").is_some_and(|s| !s.contains('\n')) - && prop(tag, "flex").is_none() - && prop(tag, "w").is_none() - && prop(tag, "boxSize").is_none() - && (prop(tag, "typography").is_some() || prop(tag, "fontSize").is_some()); - if !intrinsic_text && explicit.is_none_or(|w| (w - measured).abs() > 0.5) { - return Err("sibling-basis-unverified"); - } - fixed += measured; - siblings.push(json!({"nodeId":sid,"basis":if intrinsic_text {"intrinsic-text"}else{"explicit-width"},"capturedWidth":measured,"shrink":prop(tag,"flexShrink").unwrap_or("1")})); - } - } - if own_grow <= 0.0 { - return Err("child-grow-missing"); - } - let free = width - padding - gap * (siblings.len().saturating_sub(1) as f64) - fixed; - if free < 0.0 { - return Err("negative-free-space-shrink-unverified"); - } - // With positive free space, shrink does not participate. Empty flex items - // have zero automatic minimum; constraints and intrinsic children reject above. - let divisor = total_grow.max(1.0); - for sibling in &siblings { - if let Some(g) = sibling["grow"].as_f64() - && (free * g / divisor - sibling["capturedWidth"].as_f64().unwrap()).abs() > 0.5 - { - return Err("allocated-width-mismatch"); - } - } - Ok( - json!({"parentWidth":width,"padding":padding,"gap":gap,"siblings":siblings,"freeSpace":free, - "allocatedWidth":free*own_grow/divisor,"totalGrow":total_grow,"shrinkActive":false}), - ) - }; - match check() { - Ok(allocation) => { - proof["state"] = json!("accounted-for"); - proof["allocation"] = allocation; - proof["reasonCode"] = json!("verified-main-axis-allocation"); - proof["reason"] = json!( - "The emitted flex shorthand, parent width, padding, siblings and gaps preserve positive remaining-space allocation; no minimum or shrink constraint overrides it." - ); - } - Err(reason) => { - proof["reasonCode"] = json!(reason); - proof["reason"] = json!(format!("Cannot prove main-axis allocation: {reason}.")); - } - } - proof -} - -fn intrinsic_height( - snapshot: &Snapshot, - output: &CodegenOutput, - id: &str, - seen: &mut BTreeSet, -) -> bool { - if !seen.insert(id.into()) { - return false; - } - let Some(n) = snapshot.nodes.get(id) else { - return false; - }; - let v = n.typed_view(); - let Some(tag) = node_opening(output, id) else { - return false; - }; - if v.bool("visible") == Some(false) - || v.string("layoutPositioning") == Some("ABSOLUTE") - || matches!(prop(tag, "pos"), Some("absolute" | "fixed")) - { - return false; - } - if prop(tag, "h") - .or_else(|| prop(tag, "boxSize")) - .and_then(css_px) - .is_some_and(|h| h > 0.0) - { - return true; - } - if prop(tag, "h").is_some() - || prop(tag, "boxSize").is_some() - || v.string("layoutSizingVertical") != Some("HUG") - { - return false; - } - if n.node_type == "TEXT" { - return v.string("characters").is_some_and(|s| !s.is_empty()) - && (prop(tag, "typography").is_some() || prop(tag, "fontSize").is_some()); - } - flex_axis(tag).is_some() - && v.child_ids().any(|sid| { - parent(snapshot, output, sid).is_some() && intrinsic_height(snapshot, output, sid, seen) - }) -} - -fn hug_row_has_height( - snapshot: &Snapshot, - output: &CodegenOutput, - container: &devup_mcp_figma::RawNode, - id: &str, -) -> bool { - let Some(tag) = node_opening(output, &container.id) else { - return false; - }; - container.typed_view().string("layoutSizingVertical") == Some("HUG") - && prop(tag, "flexWrap").is_none() - && container.typed_view().string("layoutWrap") != Some("WRAP") - && container - .typed_view() - .child_ids() - .filter(|sid| *sid != id) - .any(|sid| { - parent(snapshot, output, sid).is_some() - && intrinsic_height(snapshot, output, sid, &mut BTreeSet::new()) - }) -} - -/// The result is rechecked against emitted CSS during fidelity validation; -/// a resolution string or an arbitrary property range is never proof. -pub(crate) fn implicit_css_verification( - snapshot: &Snapshot, - output: &CodegenOutput, - id: &str, - field: &str, -) -> Value { - let Some(node) = snapshot.nodes.get(id) else { - return json!({"state":"not-verifiable","reason":"Source node was not collected."}); - }; - let Some(parent) = parent(snapshot, output, id) else { - return json!({"state":"not-verifiable","reason":"The source parent is missing or is not the emitted immediate parent."}); - }; - let (Some(child_tag), Some(parent_tag)) = - (node_opening(output, id), node_opening(output, &parent.id)) - else { - return json!({"state":"not-verifiable","reason":"Generated parent or child CSS is unavailable."}); - }; - let Some(axis) = flex_axis(parent_tag) else { - return json!({"state":"not-verifiable","parentId":parent.id,"reason":"The emitted parent is not a known flex primitive; its CSS cannot be verified."}); - }; - let sizing = match field { - "width" | "layoutSizingHorizontal" => "layoutSizingHorizontal", - "height" | "layoutSizingVertical" => "layoutSizingVertical", - _ => { - return json!({"state":"not-accounted-for","reason":"Flex stretch does not account for this field."}); - } - }; - let width = matches!(field, "width" | "layoutSizingHorizontal"); - let view = node.typed_view(); - let source_mode = parent.typed_view().string("layoutMode"); - let cross = if width { - axis == "VERTICAL" - } else { - axis == "HORIZONTAL" - }; - if width && !cross && view.string(sizing) == Some("FILL") { - return horizontal_allocation(snapshot, output, id, parent, parent_tag); - } - let default_center = parent_tag.trim_start().starts_with(" true, - None | Some("auto") => { - prop(parent_tag, "alignItems").map_or(!default_center, |v| v == "stretch") - } - _ => false, - }; - let blocked = [ - if width { "w" } else { "h" }, - "boxSize", - if width { "maxW" } else { "maxH" }, - if width { "minW" } else { "minH" }, - "m", - "mx", - "my", - "ml", - "mr", - "mt", - "mb", - ] - .iter() - .any(|p| find_prop(child_tag, &format!("{p}=")).is_some()); - let accounted = cross - && source_mode == Some(axis) - && view.string(sizing) == Some("FILL") - && !matches!(prop(child_tag, "pos"), Some("absolute" | "fixed")) - && view.string("layoutPositioning") != Some("ABSOLUTE") - && aligned - && !blocked - && (fill_axis_is_established(snapshot, output, id, if width { "width" } else { "height" }) - || (!width - && field == "layoutSizingVertical" - && hug_row_has_height(snapshot, output, parent, id))); - json!({"state":if accounted {"accounted-for"} else {"not-accounted-for"}, - "parentId":parent.id,"parentLayoutMode":source_mode,"childSizing":view.string(sizing), - "generatedParent":parent_tag,"generatedChild":child_tag, - "cssBehavior":"flex cross-axis align-items: stretch (or align-self: stretch)", - "reason":if accounted {"The emitted immediate flex parent stretches this in-flow FILL child across an established cross axis; no explicit size, margin or min/max constraint overrides stretch."} else {"Checked emitted flex direction, FILL sizing, alignment, positioning, explicit dimensions, margins, constraints and parent size basis; these do not prove implicit stretch."}}) -} - -pub(super) fn sizing_mapping_matches( - snapshot: &Snapshot, - output: &CodegenOutput, - id: &str, - field: &str, - source: &str, -) -> bool { - let Some(child_tag) = node_opening(output, id) else { - return false; - }; - let Some(node) = snapshot.nodes.get(id) else { - return false; - }; - let view = node.typed_view(); - if field == "layoutSizingHorizontal" { - if view.string(field) != Some("FILL") { - return false; - } - if matches!(source, "w=\"100%\"" | "boxSize=\"100%\"") { - return fill_axis_is_established(snapshot, output, id, "width") - && (matches!(prop(child_tag, "pos"), Some("absolute" | "fixed")) - || parent(snapshot, output, id) - .and_then(|p| node_opening(output, &p.id)) - .is_some_and(|s| flex_axis(s) == Some("VERTICAL"))); - } - let parent_axis = parent(snapshot, output, id) - .and_then(|p| node_opening(output, &p.id)) - .and_then(flex_axis); - return matches!( - (source, parent_axis), - ("flex=\"1\"", Some("HORIZONTAL")) | ("alignSelf=\"stretch\"", Some("VERTICAL")) - ) && implicit_css_verification(snapshot, output, id, field)["state"] - == "accounted-for"; - } - if field == "layoutSizingVertical" && view.string(field) == Some("FIXED") { - return view.number("height").is_some_and(|h| { - let h = crate::codegen::px(h); - source == format!("h=\"{h}\"") || source == format!("boxSize=\"{h}\"") - }); - } - if field == "layoutSizingVertical" - && view.string(field) == Some("FILL") - && matches!(source, "h=\"100%\"" | "boxSize=\"100%\"") - && fill_axis_is_established(snapshot, output, id, "height") - { - return matches!(prop(child_tag, "pos"), Some("absolute" | "fixed")) - || parent(snapshot, output, id) - .and_then(|p| node_opening(output, &p.id)) - .is_some_and(|s| flex_axis(s) == Some("HORIZONTAL")); - } - if matches!(prop(child_tag, "pos"), Some("absolute" | "fixed")) { - return false; - } - let Some(parent) = parent(snapshot, output, id) else { - return false; - }; - let Some(opening) = node_opening(output, &parent.id) else { - return false; - }; - let Some(axis) = flex_axis(opening) else { - return false; - }; - if parent.typed_view().string("layoutMode") != Some(axis) { - return false; - } - if field == "layoutGrow" { - return view - .number(field) - .is_some_and(|grow| grow > 0.0 && source == format!("flex=\"{grow}\"")); - } - view.string(field) == Some("FILL") - && axis == "VERTICAL" - && source == "flex=\"1\"" - && (!crate::codegen::vertical_fill_needs_minimum_reset(snapshot, node) - || prop(child_tag, "minH") == Some("0")) - && fill_axis_is_established(snapshot, output, id, "height") -} - -pub(crate) fn account_for_sizing(snapshot: &Snapshot, output: &mut CodegenOutput) { - let mut entries = Vec::new(); - let mut diagnostics = Vec::new(); - let nodes: Vec<_> = output - .source_map - .entries - .iter() - .filter(|e| e.property.is_none()) - .cloned() - .collect(); - for entry in nodes { - let Some(id) = entry.node_id.as_deref() else { - continue; - }; - let Some(node) = snapshot.nodes.get(id) else { - continue; - }; - let Some(opening) = node_opening(output, id) else { - continue; - }; - let Some(range) = entry.generated_range.as_ref() else { - continue; - }; - for field in ["width", "height"] { - if !content_sizing_accounted(snapshot, output, id, field) { - continue; - } - entries.push(generated_entry( - range.start, - range.start + opening.len(), - id, - field, - None, - None, - "accounted-for-content-sizing", - )); - diagnostics.push(devup_mcp_figma::Diagnostic { - code: "DEVUP_CODEGEN_LAYOUT_ACCOUNTED_FOR".into(), - node_id: Some(id.into()), property: Some(field.into()), - message: "Source text auto-resize is represented by intentionally omitted fixed CSS sizing; pixel equivalence remains unmeasured.".into(), - severity: Some(devup_mcp_figma::DiagnosticSeverity::Info), - fidelity_impact: Some(FidelityImpact::None), - details: Some(json!({"classification":"text-auto-size", "resolution":"accounted-for-content-sizing", - "originalValue":node.typed_view().value(field), "textAutoResize":node.typed_view().string("textAutoResize"), - "appliedValue":{"state":"emitted", "generatedNodeId":id,"generatedSource":opening,"propertyMappingVerified":true}, - "reason":"The collected textAutoResize mode directs content sizing on this axis and the generated Text intentionally omits its fixed dimension.", - "verification":{"state":"unverified","reasonCode":"font-metrics-not-measured", - "reason":"Browser font metrics and pixel dimensions have not been measured; content-sizing semantics are accounted for, not pixel equivalence."}})), - ..Default::default() - }); - } - for field in [ - "layoutSizingHorizontal", - "layoutSizingVertical", - "layoutGrow", - ] { - if !layout_field_is_semantic(snapshot, node, field) { - continue; - } - for name in ["w", "h", "boxSize", "flex", "alignSelf"] { - let needle = format!("{name}=\""); - let Some(start) = find_prop(opening, &needle) else { - continue; - }; - let Some(end) = opening[start + needle.len()..].find('"') else { - continue; - }; - let end = start + needle.len() + end + 1; - if sizing_mapping_matches(snapshot, output, id, field, &opening[start..end]) { - entries.push(generated_entry( - range.start + start, - range.start + end, - id, - field, - None, - None, - "verified-layout-sizing", - )); - } - } - } - for field in [ - "width", - "height", - "layoutSizingHorizontal", - "layoutSizingVertical", - ] { - if field == "layoutSizingHorizontal" - && entries.iter().any(|e| { - e.node_id.as_deref() == Some(id) && e.property.as_deref() == Some(field) - }) - { - continue; - } - if !matches!(field, "layoutSizingHorizontal" | "layoutSizingVertical") - && !empty_layout_leaf(output, id) - && !projects_as_asset(snapshot, node) - { - continue; - } - if implicit_css_verification(snapshot, output, id, field)["state"] != "accounted-for" { - continue; - } - entries.push(generated_entry( - range.start, - range.start + opening.len(), - id, - field, - None, - None, - if matches!(field, "width" | "layoutSizingHorizontal") - && parent(snapshot, output, id) - .is_some_and(|p| p.typed_view().string("layoutMode") == Some("HORIZONTAL")) - { - "accounted-for-implicit-flex-grow" - } else { - "accounted-for-implicit-flex-stretch" - }, - )); - // The new horizontal obligation is carried by the verified source-map - // entry. Avoid duplicating full parent/child excerpts in every response - // envelope; keep all pre-existing R6/R7 dimension diagnostics intact. - if field == "layoutSizingHorizontal" { - continue; - } - diagnostics.push(devup_mcp_figma::Diagnostic { - code:"DEVUP_CODEGEN_LAYOUT_ACCOUNTED_FOR".into(), - node_id:Some(id.into()),property:Some(field.into()), - message:"Verified implicit flex sizing accounts for this FILL dimension.".into(), - fidelity_impact:Some(FidelityImpact::None), - details:Some(json!({"classification":"accounted-for","originalValue":node.typed_view().value(field), - "implicitCssVerification":implicit_css_verification(snapshot,output,id,field)})), - ..Default::default() - }); - } - } - output.source_map.entries.extend(entries); - output.diagnostics.extend(diagnostics); -} - -/// Mirror the renderer's intentional text-size omission and recheck the actual -/// element. This accounts for a sizing instruction, never its measured pixels. -pub(super) fn content_sizing_accounted( - snapshot: &Snapshot, - output: &CodegenOutput, - id: &str, - field: &str, -) -> bool { - let Some(node) = snapshot.nodes.get(id) else { - return false; - }; - let view = node.typed_view(); - let Some(tag) = node_opening(output, id) else { - return false; - }; - let mode = view.string("textAutoResize"); - node.node_type == "TEXT" - && tag - .trim_start() - .strip_prefix("().ok()) - == Some(w) - }))) - && [ - if field == "width" { "w" } else { "h" }, - "boxSize", - "width", - "height", - "style", - "css", - "as", - ] - .iter() - .all(|name| find_prop(tag, &format!("{name}=")).is_none()) -} - -#[cfg(test)] -mod r14_tests { - use super::*; - use crate::codegen::{CodegenOptions, generate_component}; - - #[test] - fn r14_rounding_rejects_a_centipixel_change_in_generated_height() { - let s: Snapshot = - serde_json::from_str(include_str!("../../../../fixtures/r14/asset-snapshot.json")) - .unwrap(); - let mut o = generate_component(&s, "3997:46667", &CodegenOptions::default()).unwrap(); - let before = absolute_component_verification(&s, &o, "3997:46668"); - assert_eq!(before["height"]["rounding"]["state"], "verified"); - // Same byte length keeps source ranges valid, exercising the audit of - // actual emitted values instead of trusting a resolution label. - o.tsx = o.tsx.replace("117.67px", "117.66px"); - let after = absolute_component_verification(&s, &o, "3997:46668"); - assert_eq!(after["height"]["rounding"]["state"], "unverified"); - assert_eq!(after["height"]["state"], "approximated"); - } - - #[test] - fn r14_percentage_rechecks_actual_parent_pixels() { - let s: Snapshot = - serde_json::from_str(include_str!("../../../../fixtures/r8/modal-snapshot.json")) - .unwrap(); - let mut o = generate_component( - &s, - "3997:46582", - &CodegenOptions { - inline_instances: true, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!( - absolute_component_verification(&s, &o, "3997:46621")["width"]["state"], - "verified" - ); - o.tsx = o.tsx.replacen("w=\"360px\"", "w=\"359px\"", 1); - assert_eq!( - absolute_component_verification(&s, &o, "3997:46621")["width"]["state"], - "approximated" - ); - } -} - -#[cfg(test)] -mod r16_tests { - use super::*; - use crate::codegen::{CodegenOptions, generate_component}; - - fn width_case(case: &str, expected: &str) { - let mut s: Snapshot = - serde_json::from_str(include_str!("../../../../fixtures/r8/modal-snapshot.json")) - .unwrap(); - let mut o = generate_component( - &s, - "3997:46582", - &CodegenOptions { - inline_instances: true, - ..Default::default() - }, - ) - .unwrap(); - match case { - "unknown" => o.tsx = o.tsx.replacen("w=\"360px\"", "w=\"100% \"", 1), - "unequal" => o.tsx = o.tsx.replacen("w=\"360px\"", "w=\"359px\"", 1), - "read" => { - s.nodes - .get_mut("3997:46582") - .unwrap() - .field_errors - .insert("width".into(), "unavailable".into()); - } - "child-read" => { - s.nodes - .get_mut("3997:46621") - .unwrap() - .field_errors - .insert("width".into(), "unavailable".into()); - } - "child-sizing" => { - s.nodes - .get_mut("3997:46621") - .unwrap() - .fields - .insert("layoutSizingHorizontal".into(), json!("FILL")); - } - "sizing" => { - s.nodes - .get_mut("3997:46582") - .unwrap() - .fields - .insert("layoutSizingHorizontal".into(), json!("FILL")); - } - // Equal byte length keeps private provenance ranges valid. - "conflict" => { - o.tsx = o - .tsx - .replacen("overflow=\"hidden\"", "minW=\"360px\" ", 1) - } - "padding" => { - o.tsx = o - .tsx - .replacen("overflow=\"hidden\"", "p=\"1px\" ", 1) - } - "containing" => o.tsx = o.tsx.replacen("pos=\"relative\"", "pos=\"static \"", 1), - _ => panic!("unknown case"), - } - let c = absolute_component_verification(&s, &o, "3997:46621"); - assert_eq!(c["width"]["state"], "approximated"); - assert_eq!(c["width"]["resolvedPixels"], Value::Null); - assert_eq!(c["width"]["blockedBy"], expected); - } - #[test] - fn r16_parent_width_unknown() { - width_case("unknown", "parent-width-unknown"); - } - #[test] - fn r16_parent_width_unequal() { - width_case("unequal", "parent-width-unequal"); - } - #[test] - fn r16_width_read_error() { - width_case("read", "read-error"); - } - #[test] - fn r16_child_width_read_error() { - width_case("child-read", "read-error"); - } - #[test] - fn r16_child_width_non_fixed_sizing() { - width_case("child-sizing", "non-fixed-sizing"); - } - #[test] - fn r16_width_non_fixed_sizing() { - width_case("sizing", "non-fixed-sizing"); - } - #[test] - fn r16_width_conflicting_dimension_props() { - width_case("conflict", "conflicting-dimension-props"); - } - #[test] - fn r16_width_parent_padding() { - width_case("padding", "parent-padding-or-border"); - } - #[test] - fn r16_width_containing_block() { - width_case("containing", "containing-block-unproven"); - } - - #[test] - fn r16_asset_blockers_follow_the_selected_boundary_proof() { - for (field, expected) in [ - ("absoluteRenderBounds", "read-error"), - ("layoutSizingHorizontal", "non-fixed-sizing"), - ] { - let mut s: Snapshot = - serde_json::from_str(include_str!("../../../../fixtures/r14/asset-snapshot.json")) - .unwrap(); - let o = generate_component(&s, "3997:46667", &CodegenOptions::default()).unwrap(); - if field == "absoluteRenderBounds" { - s.nodes - .get_mut("3997:46668") - .unwrap() - .field_errors - .insert(field.into(), "unavailable".into()); - } else { - s.nodes - .get_mut("3997:46668") - .unwrap() - .fields - .insert(field.into(), json!("FILL")); - } - let c = absolute_component_verification(&s, &o, "3997:46668"); - assert_eq!(c["width"]["state"], "approximated"); - assert_eq!(c["width"]["blockedBy"], expected); - } - } -} +// Source sections share this module to preserve privacy and test names. +include!("sizing/types_and_helpers.rs"); +include!("sizing/account_for_sizing.rs"); diff --git a/crates/devup-mcp-devup-ui/src/provenance/sizing/account_for_sizing.rs b/crates/devup-mcp-devup-ui/src/provenance/sizing/account_for_sizing.rs new file mode 100644 index 00000000..0e9a9c90 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/provenance/sizing/account_for_sizing.rs @@ -0,0 +1,379 @@ + + +pub(crate) fn account_for_sizing(snapshot: &Snapshot, output: &mut CodegenOutput) { + let mut entries = Vec::new(); + let mut diagnostics = Vec::new(); + let nodes: Vec<_> = output + .source_map + .entries + .iter() + .filter(|e| e.property.is_none()) + .cloned() + .collect(); + for entry in nodes { + let Some(id) = entry.node_id.as_deref() else { + continue; + }; + let Some(node) = snapshot.nodes.get(id) else { + continue; + }; + let Some(opening) = node_opening(output, id) else { + continue; + }; + let Some(range) = entry.generated_range.as_ref() else { + continue; + }; + for field in ["width", "height"] { + if !content_sizing_accounted(snapshot, output, id, field) { + continue; + } + entries.push(generated_entry( + range.start, + range.start + opening.len(), + id, + field, + None, + None, + "accounted-for-content-sizing", + )); + diagnostics.push(devup_mcp_figma::Diagnostic { + code: "DEVUP_CODEGEN_LAYOUT_ACCOUNTED_FOR".into(), + node_id: Some(id.into()), property: Some(field.into()), + message: "Source text auto-resize is represented by intentionally omitted fixed CSS sizing; pixel equivalence remains unmeasured.".into(), + severity: Some(devup_mcp_figma::DiagnosticSeverity::Info), + fidelity_impact: Some(FidelityImpact::None), + details: Some(json!({"classification":"text-auto-size", "resolution":"accounted-for-content-sizing", + "originalValue":node.typed_view().value(field), "textAutoResize":node.typed_view().string("textAutoResize"), + "appliedValue":{"state":"emitted", "generatedNodeId":id,"generatedSource":opening,"propertyMappingVerified":true}, + "reason":"The collected textAutoResize mode directs content sizing on this axis and the generated Text intentionally omits its fixed dimension.", + "verification":{"state":"unverified","reasonCode":"font-metrics-not-measured", + "reason":"Browser font metrics and pixel dimensions have not been measured; content-sizing semantics are accounted for, not pixel equivalence."}})), + ..Default::default() + }); + } + for field in [ + "layoutSizingHorizontal", + "layoutSizingVertical", + "layoutGrow", + ] { + if !layout_field_is_semantic(snapshot, node, field) { + continue; + } + for name in ["w", "h", "boxSize", "flex", "alignSelf"] { + let needle = format!("{name}=\""); + let Some(start) = find_prop(opening, &needle) else { + continue; + }; + let Some(end) = opening[start + needle.len()..].find('"') else { + continue; + }; + let end = start + needle.len() + end + 1; + if sizing_mapping_matches(snapshot, output, id, field, &opening[start..end]) { + entries.push(generated_entry( + range.start + start, + range.start + end, + id, + field, + None, + None, + "verified-layout-sizing", + )); + } + } + } + for field in [ + "width", + "height", + "layoutSizingHorizontal", + "layoutSizingVertical", + ] { + if field == "layoutSizingHorizontal" + && entries.iter().any(|e| { + e.node_id.as_deref() == Some(id) && e.property.as_deref() == Some(field) + }) + { + continue; + } + if !matches!(field, "layoutSizingHorizontal" | "layoutSizingVertical") + && !empty_layout_leaf(output, id) + && !projects_as_asset(snapshot, node) + { + continue; + } + if implicit_css_verification(snapshot, output, id, field)["state"] != "accounted-for" { + continue; + } + entries.push(generated_entry( + range.start, + range.start + opening.len(), + id, + field, + None, + None, + if matches!(field, "width" | "layoutSizingHorizontal") + && parent(snapshot, output, id) + .is_some_and(|p| p.typed_view().string("layoutMode") == Some("HORIZONTAL")) + { + "accounted-for-implicit-flex-grow" + } else { + "accounted-for-implicit-flex-stretch" + }, + )); + // The new horizontal obligation is carried by the verified source-map + // entry. Avoid duplicating full parent/child excerpts in every response + // envelope; keep all pre-existing R6/R7 dimension diagnostics intact. + if field == "layoutSizingHorizontal" { + continue; + } + diagnostics.push(devup_mcp_figma::Diagnostic { + code:"DEVUP_CODEGEN_LAYOUT_ACCOUNTED_FOR".into(), + node_id:Some(id.into()),property:Some(field.into()), + message:"Verified implicit flex sizing accounts for this FILL dimension.".into(), + fidelity_impact:Some(FidelityImpact::None), + details:Some(json!({"classification":"accounted-for","originalValue":node.typed_view().value(field), + "implicitCssVerification":implicit_css_verification(snapshot,output,id,field)})), + ..Default::default() + }); + } + } + output.source_map.entries.extend(entries); + output.diagnostics.extend(diagnostics); +} + +/// Mirror the renderer's intentional text-size omission and recheck the actual +/// element. This accounts for a sizing instruction, never its measured pixels. +pub(super) fn content_sizing_accounted( + snapshot: &Snapshot, + output: &CodegenOutput, + id: &str, + field: &str, +) -> bool { + let Some(node) = snapshot.nodes.get(id) else { + return false; + }; + let view = node.typed_view(); + let Some(tag) = node_opening(output, id) else { + return false; + }; + let mode = view.string("textAutoResize"); + node.node_type == "TEXT" + && tag + .trim_start() + .strip_prefix("().ok()) + == Some(w) + }))) + && [ + if field == "width" { "w" } else { "h" }, + "boxSize", + "width", + "height", + "style", + "css", + "as", + ] + .iter() + .all(|name| find_prop(tag, &format!("{name}=")).is_none()) +} + +#[cfg(test)] +mod r14_tests { + use super::*; + use crate::codegen::{CodegenOptions, generate_component}; + + #[test] + fn r14_rounding_rejects_a_centipixel_change_in_generated_height() { + let s: Snapshot = + serde_json::from_str(include_str!("../../../../../fixtures/r14/asset-snapshot.json")) + .unwrap(); + let mut o = generate_component(&s, "3997:46667", &CodegenOptions::default()).unwrap(); + let before = absolute_component_verification(&s, &o, "3997:46668"); + assert_eq!(before["height"]["rounding"]["state"], "verified"); + // Same byte length keeps source ranges valid, exercising the audit of + // actual emitted values instead of trusting a resolution label. + o.tsx = o.tsx.replace("117.67px", "117.66px"); + let after = absolute_component_verification(&s, &o, "3997:46668"); + assert_eq!(after["height"]["rounding"]["state"], "unverified"); + assert_eq!(after["height"]["state"], "approximated"); + } + + #[test] + fn r14_percentage_rechecks_actual_parent_pixels() { + let s: Snapshot = + serde_json::from_str(include_str!("../../../../../fixtures/r8/modal-snapshot.json")) + .unwrap(); + let mut o = generate_component( + &s, + "3997:46582", + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + absolute_component_verification(&s, &o, "3997:46621")["width"]["state"], + "verified" + ); + o.tsx = o.tsx.replacen("w=\"360px\"", "w=\"359px\"", 1); + assert_eq!( + absolute_component_verification(&s, &o, "3997:46621")["width"]["state"], + "approximated" + ); + } +} + +#[cfg(test)] +mod r16_tests { + use super::*; + use crate::codegen::{CodegenOptions, generate_component}; + + fn width_case(case: &str, expected: &str) { + let mut s: Snapshot = + serde_json::from_str(include_str!("../../../../../fixtures/r8/modal-snapshot.json")) + .unwrap(); + let mut o = generate_component( + &s, + "3997:46582", + &CodegenOptions { + inline_instances: true, + ..Default::default() + }, + ) + .unwrap(); + match case { + "unknown" => o.tsx = o.tsx.replacen("w=\"360px\"", "w=\"100% \"", 1), + "unequal" => o.tsx = o.tsx.replacen("w=\"360px\"", "w=\"359px\"", 1), + "read" => { + s.nodes + .get_mut("3997:46582") + .unwrap() + .field_errors + .insert("width".into(), "unavailable".into()); + } + "child-read" => { + s.nodes + .get_mut("3997:46621") + .unwrap() + .field_errors + .insert("width".into(), "unavailable".into()); + } + "child-sizing" => { + s.nodes + .get_mut("3997:46621") + .unwrap() + .fields + .insert("layoutSizingHorizontal".into(), json!("FILL")); + } + "sizing" => { + s.nodes + .get_mut("3997:46582") + .unwrap() + .fields + .insert("layoutSizingHorizontal".into(), json!("FILL")); + } + // Equal byte length keeps private provenance ranges valid. + "conflict" => { + o.tsx = o + .tsx + .replacen("overflow=\"hidden\"", "minW=\"360px\" ", 1) + } + "padding" => { + o.tsx = o + .tsx + .replacen("overflow=\"hidden\"", "p=\"1px\" ", 1) + } + "containing" => o.tsx = o.tsx.replacen("pos=\"relative\"", "pos=\"static \"", 1), + _ => panic!("unknown case"), + } + let c = absolute_component_verification(&s, &o, "3997:46621"); + assert_eq!(c["width"]["state"], "approximated"); + assert_eq!(c["width"]["resolvedPixels"], Value::Null); + assert_eq!(c["width"]["blockedBy"], expected); + } + #[test] + fn r16_parent_width_unknown() { + width_case("unknown", "parent-width-unknown"); + } + #[test] + fn r16_parent_width_unequal() { + width_case("unequal", "parent-width-unequal"); + } + #[test] + fn r16_width_read_error() { + width_case("read", "read-error"); + } + #[test] + fn r16_child_width_read_error() { + width_case("child-read", "read-error"); + } + #[test] + fn r16_child_width_non_fixed_sizing() { + width_case("child-sizing", "non-fixed-sizing"); + } + #[test] + fn r16_width_non_fixed_sizing() { + width_case("sizing", "non-fixed-sizing"); + } + #[test] + fn r16_width_conflicting_dimension_props() { + width_case("conflict", "conflicting-dimension-props"); + } + #[test] + fn r16_width_parent_padding() { + width_case("padding", "parent-padding-or-border"); + } + #[test] + fn r16_width_containing_block() { + width_case("containing", "containing-block-unproven"); + } + + #[test] + fn r16_asset_blockers_follow_the_selected_boundary_proof() { + for (field, expected) in [ + ("absoluteRenderBounds", "read-error"), + ("layoutSizingHorizontal", "non-fixed-sizing"), + ] { + let mut s: Snapshot = + serde_json::from_str(include_str!("../../../../../fixtures/r14/asset-snapshot.json")) + .unwrap(); + let o = generate_component(&s, "3997:46667", &CodegenOptions::default()).unwrap(); + if field == "absoluteRenderBounds" { + s.nodes + .get_mut("3997:46668") + .unwrap() + .field_errors + .insert(field.into(), "unavailable".into()); + } else { + s.nodes + .get_mut("3997:46668") + .unwrap() + .fields + .insert(field.into(), json!("FILL")); + } + let c = absolute_component_verification(&s, &o, "3997:46668"); + assert_eq!(c["width"]["state"], "approximated"); + assert_eq!(c["width"]["blockedBy"], expected); + } + } +} diff --git a/crates/devup-mcp-devup-ui/src/provenance/sizing/types_and_helpers.rs b/crates/devup-mcp-devup-ui/src/provenance/sizing/types_and_helpers.rs new file mode 100644 index 00000000..0ce5f74b --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/provenance/sizing/types_and_helpers.rs @@ -0,0 +1,859 @@ +use super::*; +use serde_json::Value; + +pub(super) fn non_rendering_asset_accounted( + snapshot: &Snapshot, + output: &CodegenOutput, + id: &str, +) -> bool { + snapshot.nodes.get(id).is_some_and(|node| { + !node.field_errors.contains_key("absoluteRenderBounds") + && node + .typed_view() + .value("absoluteRenderBounds") + .is_some_and(Value::is_null) + && node_opening(output, id).is_some_and(|tag| { + prop(tag, "visibility") == Some("hidden") + && ["src", "bgImage", "maskImage"] + .iter() + .all(|name| prop(tag, name).is_none()) + }) + }) +} + +/// Verify emitted props against collected geometry, never against a resolution label. +/// This is a bounded constraint proof, not a browser measurement. +pub(crate) fn absolute_component_verification( + snapshot: &Snapshot, + output: &CodegenOutput, + id: &str, +) -> Value { + let Some(node) = snapshot.nodes.get(id) else { + return Value::Null; + }; + let view = node.typed_view(); + let tag = node_opening(output, id).unwrap_or(""); + let owner = parent(snapshot, output, id); + let parent_tag = owner + .and_then(|p| node_opening(output, &p.id)) + .unwrap_or(""); + let no_parent_border = [ + "border", + "borderWidth", + "borderLeft", + "borderRight", + "borderTop", + "borderBottom", + "borderLeftWidth", + "borderRightWidth", + "borderTopWidth", + "borderBottomWidth", + ] + .iter() + .all(|name| prop(parent_tag, name).is_none()); + let dimension = |axis: &str, name: &str, sizing: &str| { + let original = view.number(axis); + let generated = prop(tag, name).or_else(|| prop(tag, "boxSize")); + let preserved = !node.field_errors.contains_key(axis) + && !node.field_errors.contains_key(sizing) + && view.string(sizing) == Some("FIXED") + && original + .zip(generated.and_then(|v| v.strip_suffix("px")?.parse::().ok())) + .is_some_and(|(a, b)| a.is_finite() && a == b); + let auto_layout = !node.field_errors.contains_key("layoutMode") + && flex_axis(tag).is_some_and(|mode| view.string("layoutMode") == Some(mode)); + let intrinsic = auto_layout + && generated.is_none() + && view.string(sizing) == Some("HUG") + && !node.field_errors.contains_key(sizing) + && ["minW", "maxW", "minH", "maxH", "flex"] + .iter() + .all(|name| prop(tag, name).is_none()); + // An absolute Box with derived padding need not be an auto-layout + // container. Its percentage still resolves in the emitted positioned + // parent. Keep this extension on width; retain the prior height proof. + let absolute_width = axis == "width" + && !node.field_errors.contains_key("layoutMode") + && prop(tag, "pos") == Some("absolute") + && view.string("layoutPositioning") == Some("ABSOLUTE") + && !node.field_errors.contains_key("layoutPositioning") + && matches!( + prop(parent_tag, "pos"), + Some("relative" | "absolute" | "fixed") + ) + && prop(parent_tag, "transform").is_none(); + let percentage = (auto_layout || absolute_width) + && no_parent_border + && generated == Some("100%") + && view.string(sizing) == Some("FIXED") + && !node.field_errors.contains_key(axis) + && !node.field_errors.contains_key(sizing) + && ["minW", "maxW", "minH", "maxH"] + .iter() + .all(|name| prop(tag, name).is_none()) + && owner.is_some_and(|p| { + let pv = p.typed_view(); + !p.field_errors.contains_key(axis) + && !p.field_errors.contains_key(sizing) + && pv.string(sizing) == Some("FIXED") + && original + .zip(pv.number(axis)) + .is_some_and(|(a, b)| a.is_finite() && a == b) + && original + .zip( + prop(parent_tag, name) + .or_else(|| prop(parent_tag, "boxSize")) + .and_then(|v| v.strip_suffix("px")?.parse::().ok()), + ) + .is_some_and(|(a, b)| a == b) + && [ + "minW", "maxW", "minH", "maxH", "p", "px", "py", "pl", "pr", "pt", "pb", + ] + .iter() + .all(|name| prop(parent_tag, name).is_none()) + }); + // Explain failure without changing any of the acceptance predicates above. + // Report the first blocker, not an assertion that other checks passed. + let child_override = ["minW", "maxW", "minH", "maxH"] + .iter() + .any(|p| prop(tag, p).is_some()); + let parent_override = ["minW", "maxW", "minH", "maxH"] + .iter() + .any(|p| prop(parent_tag, p).is_some()); + let parent_pixels = prop(parent_tag, name) + .or_else(|| prop(parent_tag, "boxSize")) + .and_then(|v| v.strip_suffix("px")?.parse::().ok()) + .filter(|v| v.is_finite()); + let parent_source = owner + .and_then(|p| p.typed_view().number(axis)) + .filter(|v| v.is_finite()); + let blocked_by = if preserved || intrinsic || percentage { + None + } else if [axis, sizing] + .iter() + .any(|f| node.field_errors.contains_key(*f)) + || (generated == Some("100%") + && owner.is_some_and(|p| { + [axis, sizing] + .iter() + .any(|f| p.field_errors.contains_key(*f)) + })) + || (!auto_layout + && ["layoutMode", "layoutPositioning"] + .iter() + .any(|f| node.field_errors.contains_key(*f))) + { + Some("read-error") + } else if view.string(sizing) == Some("HUG") && generated.is_none() { + Some(if child_override || prop(tag, "flex").is_some() { + "conflicting-dimension-props" + } else { + "intrinsic-layout-unproven" + }) + } else if view.string(sizing) != Some("FIXED") + || (generated == Some("100%") + && owner.is_some_and(|p| p.typed_view().string(sizing) != Some("FIXED"))) + { + Some("non-fixed-sizing") + } else if original.is_none_or(|v| !v.is_finite()) { + Some("source-dimension-unknown") + } else if generated != Some("100%") { + Some("generated-dimension-unproven") + } else if child_override || parent_override { + Some("conflicting-dimension-props") + } else if parent_pixels.is_none() || parent_source.is_none() { + Some(if axis == "width" { + "parent-width-unknown" + } else { + "parent-height-unknown" + }) + } else if original != parent_pixels || original != parent_source { + Some(if axis == "width" { + "parent-width-unequal" + } else { + "parent-height-unequal" + }) + } else if !no_parent_border + || ["p", "px", "py", "pl", "pr", "pt", "pb"] + .iter() + .any(|p| prop(parent_tag, p).is_some()) + { + Some("parent-padding-or-border") + } else { + Some("containing-block-unproven") + }; + let mut evidence = json!({"state":if preserved {"preserved"} else if intrinsic || percentage {"verified"} else {"approximated"},"fidelityImpact":if preserved || intrinsic || percentage {"none"} else {"approximated"}, + "sourceSizing":view.string(sizing),"sourceValue":original,"generatedValue":generated, + "reason":if preserved {"Explicit generated pixels equal the collected FIXED dimension."} else if intrinsic {"Source HUG is emitted as intrinsic size on a matching auto-layout container without dimension overrides; this verifies sizing intent, not measured pixels."} else if percentage && absolute_width {include_str!("../../text/sizing-absolute_component_verification-cfd19786.md")} else if percentage {"Auto-layout percentage equals the source FIXED dimension in an explicit equal-sized FIXED parent without padding or borders."} else {"No verified explicit FIXED dimension; percentage, intrinsic or missing sizes need a separate sizing proof."}, + "resolutionCondition":"Verify the emitted dimension against source sizing; percentage sizing also requires a proven containing-block size and responsive relation."}); + evidence["blockedBy"] = json!(blocked_by); + if axis == "width" { + evidence["sourceFields"] = json!([ + "width", + "layoutSizingHorizontal", + "layoutPositioning", + "parentId", + "parent.width", + "parent.layoutSizingHorizontal" + ]); + evidence["parentGeneratedValue"] = + json!(prop(parent_tag, name).or_else(|| prop(parent_tag, "boxSize"))); + evidence["resolvedPixels"] = json!(if percentage || preserved { + original + } else { + None + }); + evidence["calculation"] = json!(if preserved { + "Generated explicit px width equals the source FIXED width." + } else if intrinsic { + "No explicit width on a matching HUG auto-layout container; intrinsic sizing intent is verified without resolving pixels." + } else { + include_str!("../../text/sizing-absolute_component_verification-bdf7d908.md") + }); + } + evidence + }; + let common = prop(tag, "pos") == Some("absolute") + && owner.is_some_and(|p| p.node_type == "FRAME") + && matches!( + prop(parent_tag, "pos"), + Some("relative" | "absolute" | "fixed") + ) + && [node].into_iter().chain(owner).all(|n| { + let v = n.typed_view(); + !["x", "y", "width", "height", "rotation", "constraints"] + .iter() + .any(|f| n.field_errors.contains_key(*f)) + && v.number("rotation").is_none_or(|r| r == 0.0) + }) + && no_parent_border + && prop(parent_tag, "transform").is_none() + && ["m", "mx", "my", "ml", "mr", "mt", "mb", "inset"] + .iter() + .all(|p| prop(tag, p).is_none()); + let horizontal = view + .value("constraints") + .and_then(|v| v.get("horizontal")) + .and_then(Value::as_str) + .or_else(|| view.value("constraints").is_none().then_some("MIN")); + let vertical = view + .value("constraints") + .and_then(|v| v.get("vertical")) + .and_then(Value::as_str) + .or_else(|| view.value("constraints").is_none().then_some("MIN")); + let expected_transform = match (horizontal, vertical) { + (Some("CENTER"), Some("CENTER")) => Some("translate(-50%, -50%)"), + (Some("CENTER"), _) => Some("translateX(-50%)"), + (_, Some("CENTER")) => Some("translateY(-50%)"), + _ => None, + }; + let width = dimension("width", "w", "layoutSizingHorizontal"); + let height = dimension("height", "h", "layoutSizingVertical"); + let axis = |constraint: Option<&str>, + coordinate: &str, + size: &str, + css: &str, + opposite: &str| { + let offset = view.number(coordinate); + let own_size = view.number(size); + let parent_size = owner.and_then(|p| p.typed_view().number(size)); + let (css, opposite) = if constraint == Some("MAX") { + (opposite, css) + } else { + (css, opposite) + }; + let generated = prop(tag, css); + let verified = common + && prop(tag, opposite).is_none() + && prop(tag, "transform") == expected_transform + && match constraint { + Some("CENTER") => { + generated == Some("50%") + && offset + .zip(own_size) + .zip(parent_size) + .is_some_and(|((x, w), p)| { + x.is_finite() + && w.is_finite() + && p.is_finite() + && (x + w / 2.0 - p / 2.0).abs() < 1e-6 + }) + } + Some("MIN") => offset + .zip(generated.and_then(|v| v.strip_suffix("px")?.parse::().ok())) + .is_some_and(|(a, b)| a.is_finite() && a == b), + Some("MAX") => { + (if size == "width" { &width } else { &height })["fidelityImpact"] == "none" + && offset + .zip(own_size) + .zip(parent_size) + .zip(generated.and_then(|v| v.strip_suffix("px")?.parse::().ok())) + .is_some_and(|(((x, w), p), margin)| { + x.is_finite() + && w.is_finite() + && p.is_finite() + && (p - x - w - margin).abs() < 1e-6 + }) + } + _ => false, + }; + json!({"state":if verified {"verified"} else {"approximated"},"fidelityImpact":if verified {"none"} else {"approximated"}, + "constraint":constraint,"constraintDeclared":view.value("constraints").is_some(),"sourceOffset":offset,"sourceSize":own_size,"parentSize":parent_size, + "generatedProperty":css,"generatedValue":generated,"transform":prop(tag,"transform"), + "reason":if verified {"Collected offset and emitted CSS agree in the emitted immediate positioned parent; without a declared constraint only the captured local offset is verified."} else {include_str!("../../text/sizing-absolute_component_verification-65b6e30c.md")}, + "resolutionCondition":include_str!("../../text/sizing-absolute_component_verification-13b3029b.md")}) + }; + let mut components = json!({"height":height,"width":width, + "horizontal":axis(horizontal,"x","width","left","right"),"vertical":axis(vertical,"y","height","top","bottom"), + "containingBlock":{"parentId":owner.map(|p|&p.id),"generatedParent":parent_tag,"generatedChild":tag}}); + if let Some(proof) = crate::codegen::width::policy_horizontal_proof(snapshot, output, id) { + components["horizontal"] = proof; + } + if let Some(proof) = crate::codegen::width::policy_width_proof(snapshot, output, id) { + components["width"] = proof; + } + super::absolute_bounds::annotate(snapshot, node, owner, tag, parent_tag, &mut components); + components +} + +pub(crate) fn prop<'a>(opening: &'a str, name: &str) -> Option<&'a str> { + let needle = format!("{name}=\""); + let start = find_prop(opening, &needle)? + needle.len(); + opening[start..].split_once('"').map(|(v, _)| v) +} + +fn flex_axis(opening: &str) -> Option<&'static str> { + let tag = opening + .trim_start() + .strip_prefix('<')? + .split(|c: char| c.is_whitespace() || c == '/') + .next()?; + if !matches!(tag, "VStack" | "Flex" | "Center") { + return None; + } + match prop(opening, "flexDir") { + Some("column") => Some("VERTICAL"), + Some("row") => Some("HORIZONTAL"), + None if tag == "VStack" => Some("VERTICAL"), + None => Some("HORIZONTAL"), + _ => None, + } +} + +fn parent<'a>( + snapshot: &'a Snapshot, + output: &CodegenOutput, + id: &str, +) -> Option<&'a devup_mcp_figma::RawNode> { + let parent = snapshot + .nodes + .values() + .find(|p| p.typed_view().child_ids().any(|c| c == id))?; + let range = output + .source_map + .entries + .iter() + .find(|e| e.node_id.as_deref() == Some(id) && e.property.is_none())? + .generated_range + .as_ref()?; + let owner = output + .source_map + .entries + .iter() + .filter(|e| e.property.is_none() && e.node_id.as_deref() != Some(id)) + .filter_map(|e| Some((e.node_id.as_deref()?, e.generated_range.as_ref()?))) + .filter(|(_, r)| r.start <= range.start && r.end >= range.end) + .min_by_key(|(_, r)| r.end - r.start)? + .0; + (owner == parent.id).then_some(parent) +} + +pub(super) fn has_vertical_flex_basis( + snapshot: &Snapshot, + output: &CodegenOutput, + id: &str, +) -> bool { + let Some(parent) = parent(snapshot, output, id) else { + return false; + }; + parent.typed_view().string("layoutMode") == Some("VERTICAL") + && node_opening(output, &parent.id).is_some_and(|s| flex_axis(s) == Some("VERTICAL")) + && node_opening(output, id).is_some_and(|s| { + prop(s, "flex") == Some("1") && !matches!(prop(s, "pos"), Some("absolute" | "fixed")) + }) + && snapshot + .nodes + .get(id) + .is_some_and(|n| n.typed_view().string("layoutSizingVertical") == Some("FILL")) +} + +fn css_px(value: &str) -> Option { + let n = value + .strip_suffix("px") + .unwrap_or(value) + .parse::() + .ok()?; + n.is_finite().then_some(n) +} + +fn horizontal_allocation( + snapshot: &Snapshot, + output: &CodegenOutput, + id: &str, + container: &devup_mcp_figma::RawNode, + opening: &str, +) -> Value { + let mut proof = json!({"state":"not-accounted-for", "cssBehavior":"flex main-axis grow allocation", + "parentId":container.id,"parentLayoutMode":"HORIZONTAL", "childSizing":"FILL", + "generatedParent":opening,"generatedChild":node_opening(output,id), + "flexBasis":"0%","flexShrink":1, + "evidenceLimit":"Verifies the responsive allocation contract at the captured parent/sibling sizes, not browser font metrics or pixel equivalence."}); + let check = || -> Result { + let pv = container.typed_view(); + if pv.string("layoutMode") != Some("HORIZONTAL") { + return Err("source-direction-mismatch"); + } + if prop(opening, "flexWrap").is_some_and(|v| v != "nowrap") + || pv.string("layoutWrap") == Some("WRAP") + { + return Err("wrapped-flex-line"); + } + if !fill_axis_is_established(snapshot, output, id, "width") { + return Err("parent-width-unestablished"); + } + let width = pv + .number("width") + .filter(|n| n.is_finite() && *n > 0.0) + .ok_or("parent-width-missing")?; + if let Some(w) = prop(opening, "w").or_else(|| prop(opening, "boxSize")) + && w != "100%" + && css_px(w).is_none_or(|w| (w - width).abs() > 0.5) + { + return Err("parent-width-mismatch"); + } + if [ + "minW", + "maxW", + "border", + "borderWidth", + "borderLeft", + "borderRight", + "borderLeftWidth", + "borderRightWidth", + ] + .iter() + .any(|p| prop(opening, p).is_some()) + { + return Err("parent-box-constraint"); + } + let mut padding = 0.0; + for (field, side) in [("paddingLeft", "pl"), ("paddingRight", "pr")] { + let source = pv.number(field).unwrap_or(0.0); + let emitted = prop(opening, side) + .or_else(|| prop(opening, "px")) + .or_else(|| prop(opening, "p")) + .map(css_px) + .unwrap_or(Some(0.0)) + .ok_or("parent-padding-unverified")?; + if (source - emitted).abs() > 0.5 { + return Err("parent-padding-mismatch"); + } + padding += emitted; + } + let gap = prop(opening, "columnGap") + .or_else(|| prop(opening, "gap")) + .map(css_px) + .unwrap_or(Some(0.0)) + .ok_or("gap-unverified")?; + // Spacing sits between the children laid out in the row. With one + // of them there is nothing to space, the code rightly writes no gap + // (`codegen::layout` omits it for the same reason), and the gap takes + // no part in the allocation below. A StatusCard whose second child is + // a hidden badge failed here on the 12px it does not need. + let in_flow = pv + .child_ids() + .filter_map(|sid| snapshot.nodes.get(sid)) + .filter(|sibling| { + let sv = sibling.typed_view(); + sv.bool("visible") != Some(false) + && sv.string("layoutPositioning") != Some("ABSOLUTE") + }) + .count(); + if gap < 0.0 || (in_flow > 1 && (gap - pv.number("itemSpacing").unwrap_or(0.0)).abs() > 0.5) + { + return Err("gap-mismatch"); + } + let mut siblings = Vec::new(); + let mut fixed = 0.0; + let mut total_grow = 0.0; + let mut own_grow = 0.0; + for sid in pv.child_ids() { + let sibling = snapshot.nodes.get(sid).ok_or("sibling-missing")?; + let sv = sibling.typed_view(); + if sv.bool("visible") == Some(false) + || sv.string("layoutPositioning") == Some("ABSOLUTE") + { + continue; + } + if parent(snapshot, output, sid).is_none_or(|p| p.id != container.id) { + return Err("sibling-parent-mismatch"); + } + let tag = node_opening(output, sid).ok_or("sibling-css-missing")?; + if matches!(prop(tag, "pos"), Some("absolute" | "fixed")) { + return Err("sibling-position-mismatch"); + } + if [ + "m", + "mx", + "ml", + "mr", + "minW", + "maxW", + "flexBasis", + "flexGrow", + ] + .iter() + .any(|p| prop(tag, p).is_some()) + { + return Err("sibling-flex-constraint"); + } + if prop(tag, "flexShrink").is_some_and(|v| !matches!(v, "0" | "1")) { + return Err("sibling-shrink-unverified"); + } + let measured = sv + .number("width") + .filter(|w| w.is_finite() && *w >= 0.0) + .ok_or("sibling-width-missing")?; + if sv.string("layoutSizingHorizontal") == Some("FILL") { + let primitive = tag + .trim_start() + .strip_prefix('<') + .unwrap_or("") + .split(|c: char| c.is_whitespace() || c == '/') + .next() + .unwrap_or(""); + if !matches!(primitive, "Box" | "Flex" | "Center" | "VStack") { + return Err("intrinsic-replaced-minimum-unverified"); + } + let grow = prop(tag, "flex") + .and_then(|s| s.parse::().ok()) + .filter(|g| g.is_finite() && *g > 0.0) + .ok_or("flex-shorthand-unverified")?; + if prop(tag, "w").is_some() + || prop(tag, "boxSize").is_some() + || prop(tag, "aspectRatio").is_some() + || sv.child_ids().next().is_some() + || sibling.node_type == "TEXT" + || [ + "p", + "px", + "pl", + "pr", + "border", + "borderWidth", + "borderLeft", + "borderRight", + "borderLeftWidth", + "borderRightWidth", + ] + .iter() + .any(|p| prop(tag, p).is_some()) + { + return Err("flex-minimum-unverified"); + } + if sv + .number("layoutGrow") + .is_some_and(|g| (g - grow).abs() > 0.001) + { + return Err("flex-grow-mismatch"); + } + total_grow += grow; + if sid == id { + own_grow = grow; + } + siblings.push(json!({"nodeId":sid,"grow":grow,"basis":"0%","shrink":prop(tag,"flexShrink").unwrap_or("1"),"capturedWidth":measured})); + } else { + if prop(tag, "flex").is_some() { + return Err("sibling-flex-basis-unverified"); + } + let explicit = prop(tag, "w") + .or_else(|| prop(tag, "boxSize")) + .and_then(css_px); + let intrinsic_text = sibling.node_type == "TEXT" + && sv.string("layoutSizingHorizontal") == Some("HUG") + && sv.string("characters").is_some_and(|s| !s.contains('\n')) + && prop(tag, "flex").is_none() + && prop(tag, "w").is_none() + && prop(tag, "boxSize").is_none() + && (prop(tag, "typography").is_some() || prop(tag, "fontSize").is_some()); + if !intrinsic_text && explicit.is_none_or(|w| (w - measured).abs() > 0.5) { + return Err("sibling-basis-unverified"); + } + fixed += measured; + siblings.push(json!({"nodeId":sid,"basis":if intrinsic_text {"intrinsic-text"}else{"explicit-width"},"capturedWidth":measured,"shrink":prop(tag,"flexShrink").unwrap_or("1")})); + } + } + if own_grow <= 0.0 { + return Err("child-grow-missing"); + } + let free = width - padding - gap * (siblings.len().saturating_sub(1) as f64) - fixed; + if free < 0.0 { + return Err("negative-free-space-shrink-unverified"); + } + // With positive free space, shrink does not participate. Empty flex items + // have zero automatic minimum; constraints and intrinsic children reject above. + let divisor = total_grow.max(1.0); + for sibling in &siblings { + if let Some(g) = sibling["grow"].as_f64() + && (free * g / divisor - sibling["capturedWidth"].as_f64().unwrap()).abs() > 0.5 + { + return Err("allocated-width-mismatch"); + } + } + Ok( + json!({"parentWidth":width,"padding":padding,"gap":gap,"siblings":siblings,"freeSpace":free, + "allocatedWidth":free*own_grow/divisor,"totalGrow":total_grow,"shrinkActive":false}), + ) + }; + match check() { + Ok(allocation) => { + proof["state"] = json!("accounted-for"); + proof["allocation"] = allocation; + proof["reasonCode"] = json!("verified-main-axis-allocation"); + proof["reason"] = json!( + "The emitted flex shorthand, parent width, padding, siblings and gaps preserve positive remaining-space allocation; no minimum or shrink constraint overrides it." + ); + } + Err(reason) => { + proof["reasonCode"] = json!(reason); + proof["reason"] = json!(format!("Cannot prove main-axis allocation: {reason}.")); + } + } + proof +} + +fn intrinsic_height( + snapshot: &Snapshot, + output: &CodegenOutput, + id: &str, + seen: &mut BTreeSet, +) -> bool { + if !seen.insert(id.into()) { + return false; + } + let Some(n) = snapshot.nodes.get(id) else { + return false; + }; + let v = n.typed_view(); + let Some(tag) = node_opening(output, id) else { + return false; + }; + if v.bool("visible") == Some(false) + || v.string("layoutPositioning") == Some("ABSOLUTE") + || matches!(prop(tag, "pos"), Some("absolute" | "fixed")) + { + return false; + } + if prop(tag, "h") + .or_else(|| prop(tag, "boxSize")) + .and_then(css_px) + .is_some_and(|h| h > 0.0) + { + return true; + } + if prop(tag, "h").is_some() + || prop(tag, "boxSize").is_some() + || v.string("layoutSizingVertical") != Some("HUG") + { + return false; + } + if n.node_type == "TEXT" { + return v.string("characters").is_some_and(|s| !s.is_empty()) + && (prop(tag, "typography").is_some() || prop(tag, "fontSize").is_some()); + } + flex_axis(tag).is_some() + && v.child_ids().any(|sid| { + parent(snapshot, output, sid).is_some() && intrinsic_height(snapshot, output, sid, seen) + }) +} + +fn hug_row_has_height( + snapshot: &Snapshot, + output: &CodegenOutput, + container: &devup_mcp_figma::RawNode, + id: &str, +) -> bool { + let Some(tag) = node_opening(output, &container.id) else { + return false; + }; + container.typed_view().string("layoutSizingVertical") == Some("HUG") + && prop(tag, "flexWrap").is_none() + && container.typed_view().string("layoutWrap") != Some("WRAP") + && container + .typed_view() + .child_ids() + .filter(|sid| *sid != id) + .any(|sid| { + parent(snapshot, output, sid).is_some() + && intrinsic_height(snapshot, output, sid, &mut BTreeSet::new()) + }) +} + +/// The result is rechecked against emitted CSS during fidelity validation; +/// a resolution string or an arbitrary property range is never proof. +pub(crate) fn implicit_css_verification( + snapshot: &Snapshot, + output: &CodegenOutput, + id: &str, + field: &str, +) -> Value { + let Some(node) = snapshot.nodes.get(id) else { + return json!({"state":"not-verifiable","reason":"Source node was not collected."}); + }; + let Some(parent) = parent(snapshot, output, id) else { + return json!({"state":"not-verifiable","reason":"The source parent is missing or is not the emitted immediate parent."}); + }; + let (Some(child_tag), Some(parent_tag)) = + (node_opening(output, id), node_opening(output, &parent.id)) + else { + return json!({"state":"not-verifiable","reason":"Generated parent or child CSS is unavailable."}); + }; + let Some(axis) = flex_axis(parent_tag) else { + return json!({"state":"not-verifiable","parentId":parent.id,"reason":"The emitted parent is not a known flex primitive; its CSS cannot be verified."}); + }; + let sizing = match field { + "width" | "layoutSizingHorizontal" => "layoutSizingHorizontal", + "height" | "layoutSizingVertical" => "layoutSizingVertical", + _ => { + return json!({"state":"not-accounted-for","reason":"Flex stretch does not account for this field."}); + } + }; + let width = matches!(field, "width" | "layoutSizingHorizontal"); + let view = node.typed_view(); + let source_mode = parent.typed_view().string("layoutMode"); + let cross = if width { + axis == "VERTICAL" + } else { + axis == "HORIZONTAL" + }; + if width && !cross && view.string(sizing) == Some("FILL") { + return horizontal_allocation(snapshot, output, id, parent, parent_tag); + } + let default_center = parent_tag.trim_start().starts_with(" true, + None | Some("auto") => { + prop(parent_tag, "alignItems").map_or(!default_center, |v| v == "stretch") + } + _ => false, + }; + let blocked = [ + if width { "w" } else { "h" }, + "boxSize", + if width { "maxW" } else { "maxH" }, + if width { "minW" } else { "minH" }, + "m", + "mx", + "my", + "ml", + "mr", + "mt", + "mb", + ] + .iter() + .any(|p| find_prop(child_tag, &format!("{p}=")).is_some()); + let accounted = cross + && source_mode == Some(axis) + && view.string(sizing) == Some("FILL") + && !matches!(prop(child_tag, "pos"), Some("absolute" | "fixed")) + && view.string("layoutPositioning") != Some("ABSOLUTE") + && aligned + && !blocked + && (fill_axis_is_established(snapshot, output, id, if width { "width" } else { "height" }) + || (!width + && field == "layoutSizingVertical" + && hug_row_has_height(snapshot, output, parent, id))); + json!({"state":if accounted {"accounted-for"} else {"not-accounted-for"}, + "parentId":parent.id,"parentLayoutMode":source_mode,"childSizing":view.string(sizing), + "generatedParent":parent_tag,"generatedChild":child_tag, + "cssBehavior":"flex cross-axis align-items: stretch (or align-self: stretch)", + "reason":if accounted {"The emitted immediate flex parent stretches this in-flow FILL child across an established cross axis; no explicit size, margin or min/max constraint overrides stretch."} else {"Checked emitted flex direction, FILL sizing, alignment, positioning, explicit dimensions, margins, constraints and parent size basis; these do not prove implicit stretch."}}) +} + +pub(super) fn sizing_mapping_matches( + snapshot: &Snapshot, + output: &CodegenOutput, + id: &str, + field: &str, + source: &str, +) -> bool { + let Some(child_tag) = node_opening(output, id) else { + return false; + }; + let Some(node) = snapshot.nodes.get(id) else { + return false; + }; + let view = node.typed_view(); + if field == "layoutSizingHorizontal" { + if view.string(field) != Some("FILL") { + return false; + } + if matches!(source, "w=\"100%\"" | "boxSize=\"100%\"") { + return fill_axis_is_established(snapshot, output, id, "width") + && (matches!(prop(child_tag, "pos"), Some("absolute" | "fixed")) + || parent(snapshot, output, id) + .and_then(|p| node_opening(output, &p.id)) + .is_some_and(|s| flex_axis(s) == Some("VERTICAL"))); + } + let parent_axis = parent(snapshot, output, id) + .and_then(|p| node_opening(output, &p.id)) + .and_then(flex_axis); + return matches!( + (source, parent_axis), + ("flex=\"1\"", Some("HORIZONTAL")) | ("alignSelf=\"stretch\"", Some("VERTICAL")) + ) && implicit_css_verification(snapshot, output, id, field)["state"] + == "accounted-for"; + } + if field == "layoutSizingVertical" && view.string(field) == Some("FIXED") { + return view.number("height").is_some_and(|h| { + let h = crate::codegen::px(h); + source == format!("h=\"{h}\"") || source == format!("boxSize=\"{h}\"") + }); + } + if field == "layoutSizingVertical" + && view.string(field) == Some("FILL") + && matches!(source, "h=\"100%\"" | "boxSize=\"100%\"") + && fill_axis_is_established(snapshot, output, id, "height") + { + return matches!(prop(child_tag, "pos"), Some("absolute" | "fixed")) + || parent(snapshot, output, id) + .and_then(|p| node_opening(output, &p.id)) + .is_some_and(|s| flex_axis(s) == Some("HORIZONTAL")); + } + if matches!(prop(child_tag, "pos"), Some("absolute" | "fixed")) { + return false; + } + let Some(parent) = parent(snapshot, output, id) else { + return false; + }; + let Some(opening) = node_opening(output, &parent.id) else { + return false; + }; + let Some(axis) = flex_axis(opening) else { + return false; + }; + if parent.typed_view().string("layoutMode") != Some(axis) { + return false; + } + if field == "layoutGrow" { + return view + .number(field) + .is_some_and(|grow| grow > 0.0 && source == format!("flex=\"{grow}\"")); + } + view.string(field) == Some("FILL") + && axis == "VERTICAL" + && source == "flex=\"1\"" + && (!crate::codegen::vertical_fill_needs_minimum_reset(snapshot, node) + || prop(child_tag, "minH") == Some("0")) + && fill_axis_is_established(snapshot, output, id, "height") +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/provenance/source_map_and_contracts.rs b/crates/devup-mcp-devup-ui/src/provenance/source_map_and_contracts.rs new file mode 100644 index 00000000..fb15dc50 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/provenance/source_map_and_contracts.rs @@ -0,0 +1,980 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use devup_mcp_figma::{DevupError, ErrorCode, FidelityImpact, Snapshot, discover_asset_manifest}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::codegen::{ + AssetKind, CodegenOutput, asset_kind, derived_padding, placed_by_a_free_layout, +}; + +#[path = "absolute_bounds.rs"] +mod absolute_bounds; +#[path = "attributes.rs"] +pub mod attributes; +#[path = "fingerprint.rs"] +mod fingerprint; +#[path = "resolution.rs"] +mod resolution; +#[path = "sizing.rs"] +mod sizing; +pub use fingerprint::design_fingerprints; +pub use resolution::resolution_semantics; +pub(crate) use sizing::absolute_component_verification; +pub(crate) use sizing::prop as opening_prop; +pub(crate) use sizing::{account_for_sizing, implicit_css_verification}; + +const START: &str = "\u{e000}DEVUP_PROVENANCE_START:"; +const END: &str = "\u{e000}DEVUP_PROVENANCE_END:"; +const CLOSE: char = '\u{e001}'; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GeneratedRange { + pub start: usize, + pub end: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProvenanceEntry { + /// Internal renderer/validator bookkeeping; never a consumer-facing offset. + #[serde(skip)] + pub generated_range: Option, + /// How a generated value was computed from the raw one, when that is not a + /// plain copy (e.g. a percent line-height written as px). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub calculation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generated_property: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub json_pointer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub property: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variable_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub style_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_id: Option, + pub resolution: String, + /// Ordinal of the JSX opening element (document order, from 0) whose tag + /// or attribute this entry maps, in the TSX it was generated with. The + /// same ordinal `devup_ui_validate` reports for a violation, so the two + /// can be joined without byte offsets. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub element_index: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SourceMap { + pub version: u32, + pub entries: Vec, + /// Acquisition policy plus the properties actually emitted for each image. + #[serde(default)] + pub asset_css_mappings: Vec, + /// Trusted resource identities retained for in-process fidelity validation. + /// A deserialized map without this context cannot establish token identity. + #[serde(skip)] + pub variable_tokens: BTreeMap, + #[serde(skip)] + pub style_tokens: BTreeMap, +} + +impl Serialize for SourceMap { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut map = serializer.serialize_struct("SourceMap", 5)?; + map.serialize_field("version", &self.version)?; + map.serialize_field("entries", &self.property_entries())?; + map.serialize_field("resolutionSemantics", &resolution_semantics())?; + map.serialize_field("rasterizedAssets", &self.rasterized_assets())?; + map.serialize_field("assetCssMappings", &self.asset_css_mappings)?; + map.end() + } +} + +impl SourceMap { + pub fn rasterized_assets(&self) -> Vec { + self.asset_css_mappings.iter().filter(|m|m["effectsBaked"]==true && m["emitted"]!=false).map(|m|json!({"nodeId":m["nodeId"],"assetId":m["assetId"],"effectsBakedIntoAsset":true,"effectsAppliedBy":"asset","scope":m["contextScope"],"evidence":"supplied-acquisition-representation-policy","cssEffectsSuppressed":true})).collect() + } + pub fn property_entries(&self) -> Vec { + self.entries + .iter() + .filter(|e| e.property.is_some() || e.json_pointer.is_some()) + .cloned() + .collect() + } + + /// Freeze field-to-generated-property facts while the renderer's private + /// positions still refer to its own code. Delivery rewrites the property + /// strings together with TSX; no positions cross the public boundary. + pub(crate) fn describe_properties(&mut self, tsx: &str) { + let elements = attributes::opening_elements(tsx); + for entry in &mut self.entries { + entry.element_index = entry.generated_range.as_ref().and_then(|range| { + elements + .iter() + .filter(|element| element.start <= range.start && range.end <= element.end) + .min_by_key(|element| element.end - element.start) + .map(|element| element.ordinal) + }); + let Some(property) = entry.property.as_deref() else { + continue; + }; + let source = entry + .generated_range + .as_ref() + .and_then(|r| tsx.get(r.start..r.end)); + entry.generated_property = match (entry.resolution.as_str(), property, source) { + ("accounted-for-content-sizing", _, Some(_)) => { + Some("implicit:text-content-sizing".into()) + } + ("accounted-for-implicit-flex-stretch", _, Some(_)) => { + Some("implicit:align-self:stretch".into()) + } + ("accounted-for-implicit-flex-grow", _, Some(source)) => { + source.find("flex=\"").and_then(|at| { + source[at + 6..] + .find('"') + .map(|end| source[at..at + 7 + end].to_owned()) + }) + } + ("derived-hard-break-whitespace", _, Some(source)) => Some(source.trim().into()), + (_, "characters", Some(_)) => Some("children".into()), + (_, _, Some(source)) if !source.trim().is_empty() => Some(source.trim().into()), + _ => None, + }; + if entry.generated_property.is_none() && entry.resolution == "exact" { + entry.resolution = "unverified-property-mapping".into(); + } + } + } + + /// Explain unit conversions the generator performs, so a reader can + /// reproduce `lineHeight="26px"` from the raw `{unit: PERCENT, value: 160}`. + pub(crate) fn describe_conversions(&mut self, snapshot: &devup_mcp_figma::Snapshot) { + for entry in &mut self.entries { + if entry.property.as_deref() != Some("lineHeight") { + continue; + } + let Some(node) = entry + .node_id + .as_deref() + .and_then(|id| snapshot.nodes.get(id)) + else { + continue; + }; + let view = node.typed_view(); + let Some(line_height) = view.value("lineHeight") else { + continue; + }; + if line_height["unit"] != "PERCENT" { + continue; + } + let percent = + (line_height["value"].as_f64().unwrap_or_default() * 100.0).round() / 100.0; + let bound = view + .value("boundVariables") + .and_then(|b| b.get("fontSize")) + .is_some(); + entry.calculation = Some( + match ( + view.value("fontSize").and_then(serde_json::Value::as_f64), + bound, + ) { + (Some(size), false) => format!( + "round(fontSize * percent / 100) = round({size} * {percent} / 100) = {}px", + (size * percent / 100.0).round() + ), + _ => format!( + "{percent}% kept as a percentage because fontSize is variable-bound or unknown" + ), + }, + ); + } + } + + pub fn empty() -> Self { + Self { + version: 2, + entries: Vec::new(), + asset_css_mappings: Vec::new(), + variable_tokens: BTreeMap::new(), + style_tokens: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ProjectionDisposition { + Emitted, + Flattened, + Ignored, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectionTraceEntry { + pub node_id: String, + pub disposition: ProjectionDisposition, + pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generated_range: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectionTrace { + pub root_node_id: String, + pub entries: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FidelityCoverage { + pub total: usize, + pub covered: usize, + pub basis_points: u16, +} + +impl FidelityCoverage { + fn new(total: usize, covered: usize) -> Self { + let basis_points = if total == 0 { + 10_000 + } else { + ((covered.min(total) as u128 * 10_000) / total as u128) as u16 + }; + Self { + total, + covered: covered.min(total), + basis_points, + } + } + + pub fn complete(self) -> bool { + self.total == self.covered + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FidelityImpactCounts { + pub none: usize, + pub approximated: usize, + pub lossy: usize, + pub failed: usize, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FidelityReport { + pub syntax_valid: bool, + pub nodes: FidelityCoverage, + pub text: FidelityCoverage, + pub variables: FidelityCoverage, + pub typography: FidelityCoverage, + pub assets: FidelityCoverage, + pub layout: FidelityCoverage, + pub impacts: FidelityImpactCounts, + /// Declared children absent from both the snapshot and an asset projection. + /// Unlike an intentionally flattened SVG operand, these are visual losses. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub uncovered_node_ids: Vec, + /// 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 + && self.nodes.complete() + && self.text.complete() + && self.variables.complete() + && self.typography.complete() + && self.assets.complete() + && self.layout.complete() + && self.impacts.approximated == 0 + && self.impacts.lossy == 0 + && self.impacts.failed == 0 + } +} + +pub(crate) fn build_projection_trace( + snapshot: &Snapshot, + root_id: &str, + tsx: &str, + source_map: &SourceMap, +) -> ProjectionTrace { + let active = active_nodes(snapshot, root_id); + let emitted = source_map + .entries + .iter() + .filter(|entry| entry.property.is_none() && entry.resolution == "node") + .filter_map(|entry| Some((entry.node_id.clone()?, entry.generated_range.clone()?))) + .collect::>(); + let asset_nodes = discover_asset_manifest(snapshot) + .assets + .into_iter() + .map(|asset| asset.node_id) + .collect::>(); + let mut entries = Vec::new(); + let mut represented_by_parent = BTreeSet::new(); + let mut ignored_by_parent = BTreeSet::new(); + for (node_id, parent_id) in active { + let node = snapshot.nodes.get(&node_id); + let range = emitted.get(&node_id); + let projection = + if node_id == root_id && node.is_some_and(|node| node.node_type == "SECTION") { + Some((ProjectionDisposition::Flattened, "section-root", None)) + } else if node.is_some_and(|node| node.typed_view().bool("visible") == Some(false)) { + Some((ProjectionDisposition::Ignored, "hidden", None)) + } else if let Some(range) = range.filter(|range| range.start < range.end) { + Some(( + ProjectionDisposition::Emitted, + "direct", + Some((*range).clone()), + )) + } else if range.is_some() + || node.is_some_and(|node| node.typed_view().bool("visible") == Some(false)) + { + Some((ProjectionDisposition::Ignored, "hidden", None)) + } else if parent_id + .as_ref() + .is_some_and(|parent_id| ignored_by_parent.contains(parent_id)) + { + Some((ProjectionDisposition::Ignored, "hidden-ancestor", None)) + } else if parent_id.as_ref().is_some_and(|parent_id| { + asset_nodes.contains(parent_id) + || represented_by_parent.contains(parent_id) + || is_variant_component(snapshot, parent_id) + || snapshot.nodes.get(parent_id).is_some_and(|parent| { + parent.node_type == "COMPONENT_SET" && emitted.contains_key(parent_id) + }) + || snapshot.nodes.get(parent_id).is_some_and(|parent| { + parent.node_type == "INSTANCE" + || parent.typed_view().bool("isAsset") == Some(true) + }) + || emitted.get(parent_id).is_some_and(|range| { + range.end <= tsx.len() + && (tsx[range.start..range.end].contains("maskImage=") + || tsx[range.start..range.end].contains(" bool { + let Some(node) = snapshot.nodes.get(node_id) else { + return false; + }; + if node.node_type != "COMPONENT" { + return false; + } + node.typed_view() + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .is_some_and(|parent| parent.node_type == "COMPONENT_SET") + || snapshot.nodes.values().any(|parent| { + parent.node_type == "COMPONENT_SET" + && parent + .typed_view() + .child_ids() + .any(|child| child == node_id) + }) +} + +pub fn validate_fidelity( + snapshot: &Snapshot, + root_id: &str, + output: &CodegenOutput, +) -> Result { + let expected = active_nodes(snapshot, root_id) + .into_iter() + .map(|(node_id, _)| node_id) + .collect::>(); + let mut observed = BTreeSet::new(); + let mut duplicates = Vec::new(); + let mut invalid_ranges = Vec::new(); + for entry in &output.projection_trace.entries { + if !observed.insert(entry.node_id.clone()) { + duplicates.push(entry.node_id.clone()); + } + if let Some(range) = &entry.generated_range + && (range.start >= range.end + || range.end > output.tsx.len() + || !output.tsx.is_char_boundary(range.start) + || !output.tsx.is_char_boundary(range.end)) + { + invalid_ranges.push(entry.node_id.clone()); + } + } + let missing = expected.difference(&observed).cloned().collect::>(); + let unexpected = observed.difference(&expected).cloned().collect::>(); + if !duplicates.is_empty() + || !missing.is_empty() + || !unexpected.is_empty() + || !invalid_ranges.is_empty() + { + return Err(DevupError::with_details( + ErrorCode::DevupCodegenFailed, + "Projection trace did not account for each source node exactly once.", + false, + json!({ + "missingNodeIds": missing, + "duplicateNodeIds": duplicates, + "unexpectedNodeIds": unexpected, + "invalidRangeNodeIds": invalid_ranges + }), + )); + } + + let semantic_nodes = semantic_nodes(snapshot, root_id); + let text_nodes = semantic_nodes + .iter() + .filter(|node_id| { + snapshot + .nodes + .get(**node_id) + .is_some_and(|node| node.node_type == "TEXT") + }) + .copied() + .collect::>(); + let text_segments = text_sources(snapshot, &text_nodes); + let mut consumed_text_entries = BTreeSet::new(); + let covered_text = text_segments + .iter() + .filter(|(node_id, characters)| { + output + .source_map + .entries + .iter() + .enumerate() + .find(|(index, entry)| { + !consumed_text_entries.contains(index) + && entry.node_id.as_deref() == Some(node_id.as_str()) + && entry.property.as_deref() == Some("characters") + && entry_range(entry, &output.tsx) + .is_some_and(|source| source_covers_text(source, characters)) + }) + .is_some_and(|(index, _)| consumed_text_entries.insert(index)) + }) + .count(); + let baked = baked_into_assets(snapshot, root_id); + let variables = variable_sources(snapshot, &semantic_nodes) + .into_iter() + .filter(|(node_id, _)| !baked.contains(node_id)) + .collect::>(); + let covered_variables = variables + .iter() + .filter(|(node_id, variable_id)| { + output.source_map.entries.iter().any(|entry| { + entry.node_id.as_deref() == Some(node_id.as_str()) + && entry.variable_id.as_deref() == Some(variable_id.as_str()) + && entry.resolution == "variable-token" + && entry_range(entry, &output.tsx).is_some_and(|source| { + !source.contains(['<', '>', '\n']) + && output + .source_map + .variable_tokens + .get(variable_id) + .is_some_and(|token| { + find_resource_id( + source, + &BTreeMap::from([(variable_id.clone(), token.clone())]), + '$', + ) + .as_ref() + == Some(variable_id) + }) + && source.ends_with('"') + }) + }) + }) + .count(); + let typography = typography_sources(snapshot, &text_nodes); + let covered_typography = typography + .iter() + .filter(|(node_id, style_id)| { + output.source_map.entries.iter().any(|entry| { + entry.node_id.as_deref() == Some(*node_id) + && entry.style_id.as_deref() == Some(*style_id) + && entry.resolution == "style-token" + && entry_range(entry, &output.tsx).is_some_and(|source| { + output + .source_map + .style_tokens + .get(*style_id) + .is_some_and(|token| source == format!("typography=\"{token}\"")) + }) + }) + }) + .count(); + let assets = discover_asset_manifest(snapshot) + .assets + .into_iter() + .filter(|asset| semantic_nodes.contains(asset.node_id.as_str())) + .map(|asset| (asset.node_id, asset.asset_id)) + .collect::>(); + let covered_assets = assets + .iter() + .filter(|(node_id, asset_id)| { + if sizing::non_rendering_asset_accounted(snapshot, output, node_id) { + return true; + } + output.source_map.entries.iter().any(|entry| { + entry.node_id.as_deref() == Some(node_id.as_str()) + && entry.asset_id.as_deref() == Some(asset_id.as_str()) + && entry.resolution == "asset" + && entry_range(entry, &output.tsx).is_some_and(|source| { + asset_reference_matches(snapshot, output, entry, node_id, asset_id, source) + }) + }) + }) + .count(); + let asset_nodes = snapshot + .nodes + .values() + .filter(|node| projects_as_asset(snapshot, node)) + .map(|node| node.id.clone()) + .collect::>(); + let parents = source_parents(snapshot); + // Coverage is otherwise measured only over nodes that arrived. Count + // declared holes too, unless an actual asset projection represents them. + // Snapshot completeness still reports every missing operand in either case. + let unrepresented_children = semantic_nodes + .iter() + .filter(|node_id| { + !asset_nodes.contains(**node_id) && !has_asset_ancestor(node_id, &parents, &asset_nodes) + }) + .filter_map(|node_id| snapshot.nodes.get(*node_id)) + .flat_map(|node| node.typed_view().child_ids().collect::>()) + .filter(|child_id| !snapshot.nodes.contains_key(*child_id)) + .collect::>(); + let mut layout = semantic_nodes + .iter() + .filter(|node_id| !has_asset_ancestor(node_id, &parents, &asset_nodes)) + .flat_map(|node_id| { + let is_asset = asset_nodes.contains(*node_id); + LAYOUT_FIELDS.iter().filter_map(move |field| { + snapshot + .nodes + .get(*node_id) + .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::>(); + // Previously claimed implicit or explicit dimensions remain obligations when emitted CSS + // is revalidated. Replacing a Box by an unknown/replaced tag must not make + // its size disappear from coverage merely because it is no longer a Box. + for entry in &output.source_map.entries { + if matches!( + entry.resolution.as_str(), + "accounted-for-implicit-flex-stretch" + | "accounted-for-implicit-flex-grow" + | "accounted-for-content-sizing" + | "verified-explicit-dimension" + ) && let (Some(id), Some(field)) = (entry.node_id.as_deref(), entry.property.as_deref()) + && matches!(field, "width" | "height") + && semantic_nodes.contains(id) + { + layout.insert((id.to_owned(), field.to_owned())); + } + } + // A visible childless painted Box has no intrinsic content, even when it + // was not classified as an asset (for example an empty HUG frame). + for node_id in &semantic_nodes { + let Some(node) = snapshot.nodes.get(*node_id) else { + continue; + }; + if devup_mcp_figma::asset_exclusion_reason(node).is_some() { + continue; + } + let empty_box = empty_layout_leaf(output, node_id); + if empty_box { + for axis in ["width", "height"] { + let view = node.typed_view(); + let sizing = if axis == "width" { + "layoutSizingHorizontal" + } else { + "layoutSizingVertical" + }; + if view + .value("absoluteRenderBounds") + .and_then(|b| b.get(axis)) + .and_then(serde_json::Value::as_f64) + .is_some_and(|v| v > 0.0) + || (view.string(sizing) == Some("HUG") + && (view.number(axis).is_some_and(|v| v > 0.0) + || view + .value("absoluteBoundingBox") + .and_then(|b| b.get(axis)) + .and_then(serde_json::Value::as_f64) + .is_some_and(|v| v > 0.0))) + { + layout.insert(((*node_id).to_owned(), axis.to_owned())); + } + } + } + } + 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 policy_width = property == "width" + && crate::codegen::width::policy_width_proof(snapshot, output, node_id).is_some(); + let represented = policy_width + || 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| { + if entry.resolution == "accounted-for-content-sizing" { + return sizing::content_sizing_accounted( + snapshot, output, node_id, property, + ); + } + if entry.resolution == "verified-explicit-dimension" { + return layout_source_matches(property, source) + && dimension_value_matches( + snapshot, output, node_id, property, source, + ) + && snapshot.nodes.get(node_id).is_some_and(|node| { + !node.field_errors.contains_key(property) + && source + .split_once("=\"") + .and_then(|(_, v)| v.strip_suffix("px\"")) + .and_then(|v| v.parse::().ok()) + .zip(node.typed_view().number(property)) + .is_some_and(|(a, b)| b.is_finite() && a == b) + }); + } + if matches!( + entry.resolution.as_str(), + "accounted-for-implicit-flex-stretch" + | "accounted-for-implicit-flex-grow" + ) { + return implicit_css_verification( + snapshot, output, node_id, property, + )["state"] + == "accounted-for"; + } + if matches!( + property.as_str(), + "layoutSizingHorizontal" | "layoutSizingVertical" | "layoutGrow" + ) { + return sizing::sizing_mapping_matches( + snapshot, output, node_id, property, source, + ); + } + layout_source_matches(property, source) + && dimension_value_matches( + snapshot, output, node_id, property, source, + ) + }) + }); + 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 { + let mut impact = diagnostic.fidelity_impact(); + if impact == FidelityImpact::None && diagnostic.code == "DEVUP_CODEGEN_ABSOLUTE_VERIFIED" { + let components = absolute_component_verification( + snapshot, + output, + diagnostic.node_id.as_deref().unwrap_or(root_id), + ); + if ["height", "width", "horizontal", "vertical"] + .iter() + .any(|name| components[*name]["fidelityImpact"] != "none") + { + impact = FidelityImpact::Approximated; + } + } + if impact == FidelityImpact::None && diagnostic.code == "DEVUP_CODEGEN_NON_RENDERING_ASSET" + { + let id = diagnostic.node_id.as_deref().unwrap_or(root_id); + let hidden = snapshot.nodes.get(id).is_some_and(|node| { + !node.field_errors.contains_key("visible") + && node.typed_view().bool("visible") == Some(false) + }); + if !hidden && !sizing::non_rendering_asset_accounted(snapshot, output, id) { + impact = FidelityImpact::Approximated; + } + } + match impact { + FidelityImpact::None => impacts.none += 1, + FidelityImpact::Approximated => impacts.approximated += 1, + FidelityImpact::Lossy => impacts.lossy += 1, + FidelityImpact::Failed => impacts.failed += 1, + } + } + impacts.lossy += unrepresented_children.len(); + Ok(FidelityReport { + syntax_valid: true, + nodes: FidelityCoverage::new( + expected.len() + unrepresented_children.len(), + observed.len(), + ), + uncovered_node_ids: unrepresented_children + .into_iter() + .take(MAX_REPORTED_UNCOVERED) + .map(str::to_owned) + .collect(), + text: FidelityCoverage::new(text_segments.len(), covered_text), + variables: FidelityCoverage::new(variables.len(), covered_variables), + typography: FidelityCoverage::new(typography.len(), covered_typography), + assets: FidelityCoverage::new(assets.len(), covered_assets), + layout: FidelityCoverage::new(layout.len(), covered_layout), + impacts, + uncovered_layout, + }) +} + +fn has_asset_ancestor( + node_id: &str, + parents: &BTreeMap, + asset_nodes: &BTreeSet, +) -> bool { + let mut parent = parents.get(node_id).map(String::as_str); + while let Some(parent_id) = parent { + if asset_nodes.contains(parent_id) { + return true; + } + parent = parents.get(parent_id).map(String::as_str); + } + 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, + field: &str, +) -> bool { + let view = node.typed_view(); + // Asset internals may be baked into the export, but the CSS element's + // two axes are never internal. Check before HUG/canvas/padding exemptions. + if matches!(field, "width" | "height") + && projects_as_asset(snapshot, node) + && devup_mcp_figma::asset_exclusion_reason(node).is_none() + { + return view.number(field).is_some_and(|v| v > 0.0) + || view + .value("absoluteBoundingBox") + .and_then(|b| b.get(field)) + .and_then(serde_json::Value::as_f64) + .is_some_and(|v| v > 0.0) + || view + .value("absoluteRenderBounds") + .and_then(|b| b.get(field)) + .and_then(serde_json::Value::as_f64) + .is_some_and(|v| v > 0.0); + } + let component_set_parent = view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .is_some_and(|parent| parent.node_type == "COMPONENT_SET") + || snapshot.nodes.values().any(|parent| { + parent.node_type == "COMPONENT_SET" + && parent + .typed_view() + .child_ids() + .any(|child| child == node.id) + }); + // 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. + let placed_out_of_flow = view.string("layoutPositioning") == Some("ABSOLUTE") + || placed_by_a_free_layout( + snapshot, + node, + view.string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)), + canvas_parent, + ); + if field == "height" + && placed_out_of_flow + && view.child_ids().next().is_some() + && !projects_as_asset(snapshot, node) + { + return false; + } + match field { + "layoutSizingHorizontal" => view.string(field) == Some("FILL"), + "layoutSizingVertical" => { + view.string(field) == Some("FILL") + || crate::codegen::vertical_fill_container(snapshot, node) + } + // Growth is shared out by an auto-layout parent, and the code has one + // only where that parent is in it. A root's parent is outside the + // export, and a frame without auto-layout - or a pinned child - takes + // no part in it: the value is inert in Figma and has nothing to say in + // the code. An instance placed on the canvas keeps the 1 its component + // had in a row, and that alone graded a standalone StatusCard lossy. + // Kept in step with `codegen::layout`, which writes `flex` only there. + "layoutGrow" => { + view.number(field).is_some_and(|v| v > 0.0) + && view.string("layoutPositioning") != Some("ABSOLUTE") + && view + .string("parentId") + .and_then(|parent_id| snapshot.nodes.get(parent_id)) + .is_some_and(|parent| { + matches!( + parent.typed_view().string("layoutMode"), + Some("HORIZONTAL" | "VERTICAL") + ) + }) + } + "layoutMode" => matches!(view.string(field), Some("HORIZONTAL" | "VERTICAL" | "GRID")), + "layoutPositioning" => view.string(field) == Some("ABSOLUTE"), + "width" => { + view.value(field).is_some() + && view + .string("layoutSizingHorizontal") + .is_none_or(|value| value == "FIXED") + } + "height" => { + view.value(field).is_some() + && view + .string("layoutSizingVertical") + .is_none_or(|value| value == "FIXED") + } + "itemSpacing" => { + // 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) + } + "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { + view.number(field).is_some_and(|value| value != 0.0) + } + _ => false, + } +} + +fn projects_as_asset(snapshot: &Snapshot, node: &devup_mcp_figma::RawNode) -> bool { + asset_kind(snapshot, node).is_some() +} + +fn semantic_nodes<'a>(snapshot: &'a Snapshot, root_id: &str) -> BTreeSet<&'a str> { + let mut visible = BTreeSet::new(); + let mut hidden = BTreeSet::new(); + for (node_id, parent_id) in active_nodes(snapshot, root_id) { + let is_hidden = parent_id + .as_ref() + .is_some_and(|parent_id| hidden.contains(parent_id.as_str())) + || snapshot + .nodes + .get(&node_id) + .is_some_and(|node| node.typed_view().bool("visible") == Some(false)); + if is_hidden { + hidden.insert(node_id); + } else if let Some((node_id, _)) = snapshot.nodes.get_key_value(&node_id) { + visible.insert(node_id.as_str()); + } + } + visible +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/style_props.rs b/crates/devup-mcp-devup-ui/src/style_props.rs index 57f3c2ab..5535aca7 100644 --- a/crates/devup-mcp-devup-ui/src/style_props.rs +++ b/crates/devup-mcp-devup-ui/src/style_props.rs @@ -21,1071 +21,6 @@ //! 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] = &[ - "MozAppearance", - "MozBinding", - "MozBorderBottomColors", - "MozBorderLeftColors", - "MozBorderRightColors", - "MozBorderTopColors", - "MozBoxAlign", - "MozBoxDirection", - "MozBoxFlex", - "MozBoxFlexGroup", - "MozBoxLines", - "MozBoxOrdinalGroup", - "MozBoxOrient", - "MozBoxPack", - "MozContextProperties", - "MozFloatEdge", - "MozForceBrokenImageIcon", - "MozOrient", - "MozOutlineRadius", - "MozOutlineRadiusBottomleft", - "MozOutlineRadiusBottomright", - "MozOutlineRadiusTopleft", - "MozOutlineRadiusTopright", - "MozOverflowClipBox", - "MozStackSizing", - "MozTextBlink", - "MozUserFocus", - "MozUserInput", - "MozUserModify", - "MozWindowDragging", - "MozWindowShadow", - "WebkitAppearance", - "WebkitBorderAfter", - "WebkitBorderAfterColor", - "WebkitBorderAfterStyle", - "WebkitBorderAfterWidth", - "WebkitBorderBefore", - "WebkitBorderBeforeColor", - "WebkitBorderBeforeStyle", - "WebkitBorderBeforeWidth", - "WebkitBorderEnd", - "WebkitBorderEndColor", - "WebkitBorderEndStyle", - "WebkitBorderEndWidth", - "WebkitBorderStart", - "WebkitBorderStartColor", - "WebkitBorderStartStyle", - "WebkitBorderStartWidth", - "WebkitBoxAlign", - "WebkitBoxDirection", - "WebkitBoxFlex", - "WebkitBoxFlexGroup", - "WebkitBoxLines", - "WebkitBoxOrdinalGroup", - "WebkitBoxOrient", - "WebkitBoxPack", - "WebkitBoxReflect", - "WebkitLineClamp", - "WebkitMask", - "WebkitMaskAttachment", - "WebkitMaskClip", - "WebkitMaskComposite", - "WebkitMaskImage", - "WebkitMaskOrigin", - "WebkitMaskPosition", - "WebkitMaskPositionX", - "WebkitMaskPositionY", - "WebkitMaskRepeat", - "WebkitMaskRepeatX", - "WebkitMaskRepeatY", - "WebkitMaskSize", - "WebkitOverflowScrolling", - "WebkitTapHighlightColor", - "WebkitTextFillColor", - "WebkitTextStroke", - "WebkitTextStrokeColor", - "WebkitTextStrokeWidth", - "WebkitTouchCallout", - "WebkitUserModify", - "WebkitUserSelect", - "accentColor", - "alignContent", - "alignItems", - "alignSelf", - "alignTracks", - "alignmentBaseline", - "all", - "anchorName", - "anchorScope", - "animation", - "animationComposition", - "animationDelay", - "animationDir", - "animationDirection", - "animationDuration", - "animationFillMode", - "animationIterationCount", - "animationName", - "animationPlayState", - "animationRange", - "animationRangeEnd", - "animationRangeStart", - "animationTimeline", - "animationTimingFunction", - "animationTrigger", - "appearance", - "aspectRatio", - "backdropFilter", - "backfaceVisibility", - "background", - "backgroundAttachment", - "backgroundBlendMode", - "backgroundClip", - "backgroundColor", - "backgroundImage", - "backgroundImg", - "backgroundOrigin", - "backgroundPosition", - "backgroundPositionX", - "backgroundPositionY", - "backgroundRepeat", - "backgroundSize", - "baselineShift", - "baselineSource", - "bg", - "bgAttachment", - "bgBlendMode", - "bgClip", - "bgColor", - "bgImage", - "bgImg", - "bgOrigin", - "bgPos", - "bgPosX", - "bgPosY", - "bgPosition", - "bgPositionX", - "bgPositionY", - "bgRepeat", - "bgSize", - "blockSize", - "border", - "borderBlock", - "borderBlockColor", - "borderBlockEnd", - "borderBlockEndColor", - "borderBlockEndStyle", - "borderBlockEndWidth", - "borderBlockStart", - "borderBlockStartColor", - "borderBlockStartStyle", - "borderBlockStartWidth", - "borderBlockStyle", - "borderBlockWidth", - "borderBottom", - "borderBottomColor", - "borderBottomLeftRadius", - "borderBottomRadius", - "borderBottomRightRadius", - "borderBottomStyle", - "borderBottomWidth", - "borderCollapse", - "borderColor", - "borderEndEndRadius", - "borderEndStartRadius", - "borderImage", - "borderImageOutset", - "borderImageRepeat", - "borderImageSlice", - "borderImageSource", - "borderImageWidth", - "borderInline", - "borderInlineColor", - "borderInlineEnd", - "borderInlineEndColor", - "borderInlineEndStyle", - "borderInlineEndWidth", - "borderInlineStart", - "borderInlineStartColor", - "borderInlineStartStyle", - "borderInlineStartWidth", - "borderInlineStyle", - "borderInlineWidth", - "borderLeft", - "borderLeftColor", - "borderLeftRadius", - "borderLeftStyle", - "borderLeftWidth", - "borderRadius", - "borderRight", - "borderRightColor", - "borderRightRadius", - "borderRightStyle", - "borderRightWidth", - "borderShape", - "borderSpacing", - "borderStartEndRadius", - "borderStartStartRadius", - "borderStyle", - "borderTop", - "borderTopColor", - "borderTopLeftRadius", - "borderTopRadius", - "borderTopRightRadius", - "borderTopStyle", - "borderTopWidth", - "borderWidth", - "bottom", - "boxAlign", - "boxDecorationBreak", - "boxDirection", - "boxFlex", - "boxFlexGroup", - "boxLines", - "boxOrdinalGroup", - "boxOrient", - "boxPack", - "boxShadow", - "boxSize", - "boxSizing", - "breakAfter", - "breakBefore", - "breakInside", - "captionSide", - "caret", - "caretAnimation", - "caretColor", - "caretShape", - "clear", - "clip", - "clipPath", - "clipRule", - "color", - "colorInterpolationFilters", - "colorScheme", - "columnCount", - "columnFill", - "columnGap", - "columnHeight", - "columnRule", - "columnRuleColor", - "columnRuleStyle", - "columnRuleWidth", - "columnSpan", - "columnWidth", - "columnWrap", - "columns", - "contain", - "containIntrinsicBlockSize", - "containIntrinsicHeight", - "containIntrinsicInlineSize", - "containIntrinsicSize", - "containIntrinsicWidth", - "container", - "containerName", - "containerType", - "content", - "contentVisibility", - "cornerBlockEndShape", - "cornerBlockStartShape", - "cornerBottomLeftShape", - "cornerBottomRightShape", - "cornerBottomShape", - "cornerEndEndShape", - "cornerEndStartShape", - "cornerInlineEndShape", - "cornerInlineStartShape", - "cornerLeftShape", - "cornerRightShape", - "cornerShape", - "cornerStartEndShape", - "cornerStartStartShape", - "cornerTopLeftShape", - "cornerTopRightShape", - "cornerTopShape", - "counterIncrement", - "counterReset", - "counterSet", - "cursor", - "cx", - "cy", - "d", - "direction", - "display", - "dominantBaseline", - "dynamicRangeLimit", - "emptyCells", - "fieldSizing", - "fill", - "fillOpacity", - "fillRule", - "filter", - "flex", - "flexBasis", - "flexDir", - "flexDirection", - "flexFlow", - "flexGrow", - "flexLineCount", - "flexShrink", - "flexWrap", - "float", - "floodColor", - "floodOpacity", - "font", - "fontFamily", - "fontFeatureSettings", - "fontKerning", - "fontLanguageOverride", - "fontOpticalSizing", - "fontPalette", - "fontSize", - "fontSizeAdjust", - "fontSmooth", - "fontStretch", - "fontStyle", - "fontSynthesis", - "fontSynthesisPosition", - "fontSynthesisSmallCaps", - "fontSynthesisStyle", - "fontSynthesisWeight", - "fontVariant", - "fontVariantAlternates", - "fontVariantCaps", - "fontVariantEastAsian", - "fontVariantEmoji", - "fontVariantLigatures", - "fontVariantNumeric", - "fontVariantPosition", - "fontVariationSettings", - "fontWeight", - "fontWidth", - "forcedColorAdjust", - "frameSizing", - "gap", - "grid", - "gridArea", - "gridAutoColumns", - "gridAutoFlow", - "gridAutoRows", - "gridColumn", - "gridColumnEnd", - "gridColumnGap", - "gridColumnStart", - "gridGap", - "gridRow", - "gridRowEnd", - "gridRowGap", - "gridRowStart", - "gridTemplate", - "gridTemplateAreas", - "gridTemplateColumns", - "gridTemplateRows", - "h", - "hangingPunctuation", - "height", - "hyphenateCharacter", - "hyphenateLimitChars", - "hyphens", - "imageOrientation", - "imageRendering", - "imageResolution", - "imeMode", - "initialLetter", - "initialLetterAlign", - "inlineSize", - "inset", - "insetBlock", - "insetBlockEnd", - "insetBlockStart", - "insetInline", - "insetInlineEnd", - "insetInlineStart", - "interactivity", - "interestDelay", - "interestDelayEnd", - "interestDelayStart", - "interpolateSize", - "isolation", - "justifyContent", - "justifyItems", - "justifySelf", - "justifyTracks", - "left", - "letterSpacing", - "lightingColor", - "lineBreak", - "lineClamp", - "lineHeight", - "lineHeightStep", - "linkParameters", - "listStyle", - "listStyleImage", - "listStylePosition", - "listStyleType", - "m", - "margin", - "marginBlock", - "marginBlockEnd", - "marginBlockStart", - "marginBottom", - "marginInline", - "marginInlineEnd", - "marginInlineStart", - "marginLeft", - "marginRight", - "marginTop", - "marginTrim", - "marker", - "markerEnd", - "markerMid", - "markerStart", - "mask", - "maskBorder", - "maskBorderMode", - "maskBorderOutset", - "maskBorderRepeat", - "maskBorderSlice", - "maskBorderSource", - "maskBorderWidth", - "maskClip", - "maskComposite", - "maskImage", - "maskImg", - "maskMode", - "maskOrigin", - "maskPos", - "maskPosition", - "maskRepeat", - "maskSize", - "maskType", - "masonryAutoFlow", - "mathDepth", - "mathShift", - "mathStyle", - "maxBlockSize", - "maxH", - "maxHeight", - "maxInlineSize", - "maxLines", - "maxW", - "maxWidth", - "mb", - "minBlockSize", - "minH", - "minHeight", - "minInlineSize", - "minW", - "minWidth", - "mixBlendMode", - "ml", - "mr", - "msAccelerator", - "msBlockProgression", - "msContentZoomChaining", - "msContentZoomLimit", - "msContentZoomLimitMax", - "msContentZoomLimitMin", - "msContentZoomSnap", - "msContentZoomSnapPoints", - "msContentZoomSnapType", - "msContentZooming", - "msFilter", - "msFlowFrom", - "msFlowInto", - "msGridColumns", - "msGridRows", - "msHighContrastAdjust", - "msHyphenateLimitChars", - "msHyphenateLimitLines", - "msHyphenateLimitZone", - "msImeAlign", - "msOverflowStyle", - "msScrollChaining", - "msScrollLimit", - "msScrollLimitXMax", - "msScrollLimitXMin", - "msScrollLimitYMax", - "msScrollLimitYMin", - "msScrollRails", - "msScrollSnapPointsX", - "msScrollSnapPointsY", - "msScrollSnapType", - "msScrollSnapX", - "msScrollSnapY", - "msScrollTranslation", - "msScrollbar3dlightColor", - "msScrollbarArrowColor", - "msScrollbarBaseColor", - "msScrollbarDarkshadowColor", - "msScrollbarFaceColor", - "msScrollbarHighlightColor", - "msScrollbarShadowColor", - "msScrollbarTrackColor", - "msTextAutospace", - "msTouchSelect", - "msUserSelect", - "msWrapFlow", - "msWrapMargin", - "msWrapThrough", - "mt", - "mx", - "my", - "objectFit", - "objectPos", - "objectPosition", - "objectViewBox", - "offset", - "offsetAnchor", - "offsetDistance", - "offsetPath", - "offsetPos", - "offsetPosition", - "offsetRotate", - "opacity", - "order", - "orphans", - "outline", - "outlineColor", - "outlineOffset", - "outlineStyle", - "outlineWidth", - "overflow", - "overflowAnchor", - "overflowBlock", - "overflowClipBox", - "overflowClipMargin", - "overflowInline", - "overflowWrap", - "overflowX", - "overflowY", - "overlay", - "overscrollBehavior", - "overscrollBehaviorBlock", - "overscrollBehaviorInline", - "overscrollBehaviorX", - "overscrollBehaviorY", - "p", - "padding", - "paddingBlock", - "paddingBlockEnd", - "paddingBlockStart", - "paddingBottom", - "paddingInline", - "paddingInlineEnd", - "paddingInlineStart", - "paddingLeft", - "paddingRight", - "paddingTop", - "page", - "pageBreakAfter", - "pageBreakBefore", - "pageBreakInside", - "paintOrder", - "pathLength", - "pb", - "perspective", - "perspectiveOrigin", - "pl", - "placeContent", - "placeItems", - "placeSelf", - "pointerEvents", - "pos", - "position", - "positionAnchor", - "positionArea", - "positionTry", - "positionTryFallbacks", - "positionTryOrder", - "positionVisibility", - "positioning", - "pr", - "printColorAdjust", - "pt", - "px", - "py", - "quotes", - "r", - "readingFlow", - "readingOrder", - "resize", - "right", - "rotate", - "rowGap", - "rubyAlign", - "rubyMerge", - "rubyOverhang", - "rubyPosition", - "rx", - "ry", - "scale", - "scrollBehavior", - "scrollInitialTarget", - "scrollMargin", - "scrollMarginBlock", - "scrollMarginBlockEnd", - "scrollMarginBlockStart", - "scrollMarginBottom", - "scrollMarginInline", - "scrollMarginInlineEnd", - "scrollMarginInlineStart", - "scrollMarginLeft", - "scrollMarginRight", - "scrollMarginTop", - "scrollMarkerGroup", - "scrollPadding", - "scrollPaddingBlock", - "scrollPaddingBlockEnd", - "scrollPaddingBlockStart", - "scrollPaddingBottom", - "scrollPaddingInline", - "scrollPaddingInlineEnd", - "scrollPaddingInlineStart", - "scrollPaddingLeft", - "scrollPaddingRight", - "scrollPaddingTop", - "scrollSnapAlign", - "scrollSnapCoordinate", - "scrollSnapDestination", - "scrollSnapPointsX", - "scrollSnapPointsY", - "scrollSnapStop", - "scrollSnapType", - "scrollSnapTypeX", - "scrollSnapTypeY", - "scrollTargetGroup", - "scrollTimeline", - "scrollTimelineAxis", - "scrollTimelineName", - "scrollbarColor", - "scrollbarGutter", - "scrollbarWidth", - "shapeImageThreshold", - "shapeMargin", - "shapeOutside", - "shapeRendering", - "speakAs", - "stopColor", - "stopOpacity", - "stroke", - "strokeColor", - "strokeDasharray", - "strokeDashoffset", - "strokeLinecap", - "strokeLinejoin", - "strokeMiterlimit", - "strokeOpacity", - "strokeWidth", - "tabSize", - "tableLayout", - "textAlign", - "textAlignLast", - "textAnchor", - "textAutospace", - "textBox", - "textBoxEdge", - "textBoxTrim", - "textCombineUpright", - "textDecoration", - "textDecorationColor", - "textDecorationInset", - "textDecorationLine", - "textDecorationSkip", - "textDecorationSkipInk", - "textDecorationStyle", - "textDecorationThickness", - "textEmphasis", - "textEmphasisColor", - "textEmphasisPosition", - "textEmphasisStyle", - "textFit", - "textIndent", - "textJustify", - "textOrientation", - "textOverflow", - "textRendering", - "textShadow", - "textSizeAdjust", - "textSpacingTrim", - "textTransform", - "textUnderlineOffset", - "textUnderlinePosition", - "textWrap", - "textWrapMode", - "textWrapStyle", - "timelineScope", - "timelineTrigger", - "timelineTriggerActiveRange", - "timelineTriggerName", - "timelineTriggerSource", - "top", - "touchAction", - "transform", - "transformBox", - "transformOrigin", - "transformStyle", - "transition", - "transitionBehavior", - "transitionDelay", - "transitionDuration", - "transitionProperty", - "transitionTimingFunction", - "translate", - "triggerScope", - "typography", - "unicodeBidi", - "userSelect", - "vectorEffect", - "verticalAlign", - "viewTimeline", - "viewTimelineAxis", - "viewTimelineInset", - "viewTimelineName", - "viewTransitionClass", - "viewTransitionName", - "viewTransitionScope", - "visibility", - "w", - "whiteSpace", - "whiteSpaceCollapse", - "widows", - "width", - "willChange", - "wordBreak", - "wordSpacing", - "wordWrap", - "writingMode", - "x", - "y", - "zIndex", - "zoom", -]; - -/// Props whose value may be a bare ``, which is what makes a hex -/// literal there worth reporting as `hardcoded-color`. -/// -/// Membership decides whether the rule runs at all, so an omission here is -/// a silent false negative rather than a missed suggestion. `bg` was the -/// expensive one: it is devup-ui's shorthand for `background` and the -/// Figma generator writes it for every solid fill, so `bg="#752E2E"` - -/// the single most common hardcoded color this repository produces - was -/// never examined by anything. That absence was then read as evidence -/// that the rule only fires when a matching token exists, which it does -/// not: `hardcoded-color` reports the literal either way and merely -/// attaches nearby tokens as a suggestion. -/// -/// Deliberately excluded, and why: -/// - `colorScheme`, `colorInterpolationFilters`, `forcedColorAdjust`, -/// `printColorAdjust` take keywords, never a color. -/// - `MozBorderTopColors` and its siblings take a *list* of colors for -/// nested borders; a single hex is not the shape they are written in. -/// - `msScrollbar*Color` are IE-era and unreachable from devup-ui output. -/// - `caret` admits an optional color, but its color half is spelled -/// `caretColor` - which is here - and no generator emits the shorthand. -/// -/// A shorthand earns a place here by being one the generated code -/// actually writes, which is the test `bg` passes and `caret` does not. -pub const DEVUP_COLOR_LIKE_PROPS: &[&str] = &[ - "WebkitTapHighlightColor", - "WebkitTextFillColor", - "WebkitTextStrokeColor", - "accentColor", - "backgroundColor", - "bg", - "bgColor", - "borderBlockColor", - "borderBlockEndColor", - "borderBlockStartColor", - "borderBottomColor", - "borderColor", - "borderInlineColor", - "borderInlineEndColor", - "borderInlineStartColor", - "borderLeftColor", - "borderRightColor", - "borderTopColor", - "caretColor", - "color", - "columnRuleColor", - "fill", - "floodColor", - "lightingColor", - "outlineColor", - "scrollbarColor", - "stopColor", - "stroke", - "strokeColor", - "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"); - } - } - - /// A shorthand and the `*Color` spelling of the same property are the - /// same property, and a caller picks whichever reads better. Covering - /// one and not the other makes the rule depend on spelling: `bg` and - /// `stroke` were unchecked while `bgColor` and `strokeColor`'s - /// counterpart `stroke` were - so the same screen passed or failed on - /// how its author had typed it. - #[test] - fn a_color_prop_and_its_shorthand_are_covered_together() { - for (shorthand, long_form) in [ - ("bg", "backgroundColor"), - ("bgColor", "backgroundColor"), - ("stroke", "strokeColor"), - ] { - assert!( - is_color_like_prop(shorthand), - "{shorthand} is color-like but is not covered" - ); - assert!( - is_color_like_prop(long_form), - "{long_form} is color-like but is not covered" - ); - } - } - - /// The physical border colors were covered and their logical - /// equivalents were not, which is the same property written the way - /// CSS writes it for a right-to-left document. - #[test] - fn logical_border_colors_are_covered_like_the_physical_ones() { - for prop in [ - "borderBlockColor", - "borderBlockStartColor", - "borderBlockEndColor", - "borderInlineColor", - "borderInlineStartColor", - "borderInlineEndColor", - ] { - assert!(is_color_like_prop(prop), "{prop} is not covered"); - } - } - - /// Having "color" in the name is not what makes a prop color-valued. - /// These four take keywords, and reporting a hex in them would be - /// reporting something nobody can write. - #[test] - fn keyword_valued_props_named_color_are_not_color_like() { - for prop in [ - "colorScheme", - "colorInterpolationFilters", - "forcedColorAdjust", - "printColorAdjust", - ] { - assert!( - is_known_style_prop(prop), - "{prop} should still be a known style prop" - ); - assert!(!is_color_like_prop(prop), "{prop} is not color-valued"); - } - } - - #[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")); - // `bg` is devup-ui's shorthand for `background` and what the Figma - // generator writes for a solid fill. Excluding it meant the hardcoded - // color the generator emits most often was the one color nothing - // looked at. - 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")); - } -} +// Source sections share this module to preserve privacy and test names. +include!("style_props/devup_primitive_elements.rs"); +include!("style_props/tests.rs"); diff --git a/crates/devup-mcp-devup-ui/src/style_props/devup_primitive_elements.rs b/crates/devup-mcp-devup-ui/src/style_props/devup_primitive_elements.rs new file mode 100644 index 00000000..af4732b8 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/style_props/devup_primitive_elements.rs @@ -0,0 +1,963 @@ + +/// 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] = &[ + "MozAppearance", + "MozBinding", + "MozBorderBottomColors", + "MozBorderLeftColors", + "MozBorderRightColors", + "MozBorderTopColors", + "MozBoxAlign", + "MozBoxDirection", + "MozBoxFlex", + "MozBoxFlexGroup", + "MozBoxLines", + "MozBoxOrdinalGroup", + "MozBoxOrient", + "MozBoxPack", + "MozContextProperties", + "MozFloatEdge", + "MozForceBrokenImageIcon", + "MozOrient", + "MozOutlineRadius", + "MozOutlineRadiusBottomleft", + "MozOutlineRadiusBottomright", + "MozOutlineRadiusTopleft", + "MozOutlineRadiusTopright", + "MozOverflowClipBox", + "MozStackSizing", + "MozTextBlink", + "MozUserFocus", + "MozUserInput", + "MozUserModify", + "MozWindowDragging", + "MozWindowShadow", + "WebkitAppearance", + "WebkitBorderAfter", + "WebkitBorderAfterColor", + "WebkitBorderAfterStyle", + "WebkitBorderAfterWidth", + "WebkitBorderBefore", + "WebkitBorderBeforeColor", + "WebkitBorderBeforeStyle", + "WebkitBorderBeforeWidth", + "WebkitBorderEnd", + "WebkitBorderEndColor", + "WebkitBorderEndStyle", + "WebkitBorderEndWidth", + "WebkitBorderStart", + "WebkitBorderStartColor", + "WebkitBorderStartStyle", + "WebkitBorderStartWidth", + "WebkitBoxAlign", + "WebkitBoxDirection", + "WebkitBoxFlex", + "WebkitBoxFlexGroup", + "WebkitBoxLines", + "WebkitBoxOrdinalGroup", + "WebkitBoxOrient", + "WebkitBoxPack", + "WebkitBoxReflect", + "WebkitLineClamp", + "WebkitMask", + "WebkitMaskAttachment", + "WebkitMaskClip", + "WebkitMaskComposite", + "WebkitMaskImage", + "WebkitMaskOrigin", + "WebkitMaskPosition", + "WebkitMaskPositionX", + "WebkitMaskPositionY", + "WebkitMaskRepeat", + "WebkitMaskRepeatX", + "WebkitMaskRepeatY", + "WebkitMaskSize", + "WebkitOverflowScrolling", + "WebkitTapHighlightColor", + "WebkitTextFillColor", + "WebkitTextStroke", + "WebkitTextStrokeColor", + "WebkitTextStrokeWidth", + "WebkitTouchCallout", + "WebkitUserModify", + "WebkitUserSelect", + "accentColor", + "alignContent", + "alignItems", + "alignSelf", + "alignTracks", + "alignmentBaseline", + "all", + "anchorName", + "anchorScope", + "animation", + "animationComposition", + "animationDelay", + "animationDir", + "animationDirection", + "animationDuration", + "animationFillMode", + "animationIterationCount", + "animationName", + "animationPlayState", + "animationRange", + "animationRangeEnd", + "animationRangeStart", + "animationTimeline", + "animationTimingFunction", + "animationTrigger", + "appearance", + "aspectRatio", + "backdropFilter", + "backfaceVisibility", + "background", + "backgroundAttachment", + "backgroundBlendMode", + "backgroundClip", + "backgroundColor", + "backgroundImage", + "backgroundImg", + "backgroundOrigin", + "backgroundPosition", + "backgroundPositionX", + "backgroundPositionY", + "backgroundRepeat", + "backgroundSize", + "baselineShift", + "baselineSource", + "bg", + "bgAttachment", + "bgBlendMode", + "bgClip", + "bgColor", + "bgImage", + "bgImg", + "bgOrigin", + "bgPos", + "bgPosX", + "bgPosY", + "bgPosition", + "bgPositionX", + "bgPositionY", + "bgRepeat", + "bgSize", + "blockSize", + "border", + "borderBlock", + "borderBlockColor", + "borderBlockEnd", + "borderBlockEndColor", + "borderBlockEndStyle", + "borderBlockEndWidth", + "borderBlockStart", + "borderBlockStartColor", + "borderBlockStartStyle", + "borderBlockStartWidth", + "borderBlockStyle", + "borderBlockWidth", + "borderBottom", + "borderBottomColor", + "borderBottomLeftRadius", + "borderBottomRadius", + "borderBottomRightRadius", + "borderBottomStyle", + "borderBottomWidth", + "borderCollapse", + "borderColor", + "borderEndEndRadius", + "borderEndStartRadius", + "borderImage", + "borderImageOutset", + "borderImageRepeat", + "borderImageSlice", + "borderImageSource", + "borderImageWidth", + "borderInline", + "borderInlineColor", + "borderInlineEnd", + "borderInlineEndColor", + "borderInlineEndStyle", + "borderInlineEndWidth", + "borderInlineStart", + "borderInlineStartColor", + "borderInlineStartStyle", + "borderInlineStartWidth", + "borderInlineStyle", + "borderInlineWidth", + "borderLeft", + "borderLeftColor", + "borderLeftRadius", + "borderLeftStyle", + "borderLeftWidth", + "borderRadius", + "borderRight", + "borderRightColor", + "borderRightRadius", + "borderRightStyle", + "borderRightWidth", + "borderShape", + "borderSpacing", + "borderStartEndRadius", + "borderStartStartRadius", + "borderStyle", + "borderTop", + "borderTopColor", + "borderTopLeftRadius", + "borderTopRadius", + "borderTopRightRadius", + "borderTopStyle", + "borderTopWidth", + "borderWidth", + "bottom", + "boxAlign", + "boxDecorationBreak", + "boxDirection", + "boxFlex", + "boxFlexGroup", + "boxLines", + "boxOrdinalGroup", + "boxOrient", + "boxPack", + "boxShadow", + "boxSize", + "boxSizing", + "breakAfter", + "breakBefore", + "breakInside", + "captionSide", + "caret", + "caretAnimation", + "caretColor", + "caretShape", + "clear", + "clip", + "clipPath", + "clipRule", + "color", + "colorInterpolationFilters", + "colorScheme", + "columnCount", + "columnFill", + "columnGap", + "columnHeight", + "columnRule", + "columnRuleColor", + "columnRuleStyle", + "columnRuleWidth", + "columnSpan", + "columnWidth", + "columnWrap", + "columns", + "contain", + "containIntrinsicBlockSize", + "containIntrinsicHeight", + "containIntrinsicInlineSize", + "containIntrinsicSize", + "containIntrinsicWidth", + "container", + "containerName", + "containerType", + "content", + "contentVisibility", + "cornerBlockEndShape", + "cornerBlockStartShape", + "cornerBottomLeftShape", + "cornerBottomRightShape", + "cornerBottomShape", + "cornerEndEndShape", + "cornerEndStartShape", + "cornerInlineEndShape", + "cornerInlineStartShape", + "cornerLeftShape", + "cornerRightShape", + "cornerShape", + "cornerStartEndShape", + "cornerStartStartShape", + "cornerTopLeftShape", + "cornerTopRightShape", + "cornerTopShape", + "counterIncrement", + "counterReset", + "counterSet", + "cursor", + "cx", + "cy", + "d", + "direction", + "display", + "dominantBaseline", + "dynamicRangeLimit", + "emptyCells", + "fieldSizing", + "fill", + "fillOpacity", + "fillRule", + "filter", + "flex", + "flexBasis", + "flexDir", + "flexDirection", + "flexFlow", + "flexGrow", + "flexLineCount", + "flexShrink", + "flexWrap", + "float", + "floodColor", + "floodOpacity", + "font", + "fontFamily", + "fontFeatureSettings", + "fontKerning", + "fontLanguageOverride", + "fontOpticalSizing", + "fontPalette", + "fontSize", + "fontSizeAdjust", + "fontSmooth", + "fontStretch", + "fontStyle", + "fontSynthesis", + "fontSynthesisPosition", + "fontSynthesisSmallCaps", + "fontSynthesisStyle", + "fontSynthesisWeight", + "fontVariant", + "fontVariantAlternates", + "fontVariantCaps", + "fontVariantEastAsian", + "fontVariantEmoji", + "fontVariantLigatures", + "fontVariantNumeric", + "fontVariantPosition", + "fontVariationSettings", + "fontWeight", + "fontWidth", + "forcedColorAdjust", + "frameSizing", + "gap", + "grid", + "gridArea", + "gridAutoColumns", + "gridAutoFlow", + "gridAutoRows", + "gridColumn", + "gridColumnEnd", + "gridColumnGap", + "gridColumnStart", + "gridGap", + "gridRow", + "gridRowEnd", + "gridRowGap", + "gridRowStart", + "gridTemplate", + "gridTemplateAreas", + "gridTemplateColumns", + "gridTemplateRows", + "h", + "hangingPunctuation", + "height", + "hyphenateCharacter", + "hyphenateLimitChars", + "hyphens", + "imageOrientation", + "imageRendering", + "imageResolution", + "imeMode", + "initialLetter", + "initialLetterAlign", + "inlineSize", + "inset", + "insetBlock", + "insetBlockEnd", + "insetBlockStart", + "insetInline", + "insetInlineEnd", + "insetInlineStart", + "interactivity", + "interestDelay", + "interestDelayEnd", + "interestDelayStart", + "interpolateSize", + "isolation", + "justifyContent", + "justifyItems", + "justifySelf", + "justifyTracks", + "left", + "letterSpacing", + "lightingColor", + "lineBreak", + "lineClamp", + "lineHeight", + "lineHeightStep", + "linkParameters", + "listStyle", + "listStyleImage", + "listStylePosition", + "listStyleType", + "m", + "margin", + "marginBlock", + "marginBlockEnd", + "marginBlockStart", + "marginBottom", + "marginInline", + "marginInlineEnd", + "marginInlineStart", + "marginLeft", + "marginRight", + "marginTop", + "marginTrim", + "marker", + "markerEnd", + "markerMid", + "markerStart", + "mask", + "maskBorder", + "maskBorderMode", + "maskBorderOutset", + "maskBorderRepeat", + "maskBorderSlice", + "maskBorderSource", + "maskBorderWidth", + "maskClip", + "maskComposite", + "maskImage", + "maskImg", + "maskMode", + "maskOrigin", + "maskPos", + "maskPosition", + "maskRepeat", + "maskSize", + "maskType", + "masonryAutoFlow", + "mathDepth", + "mathShift", + "mathStyle", + "maxBlockSize", + "maxH", + "maxHeight", + "maxInlineSize", + "maxLines", + "maxW", + "maxWidth", + "mb", + "minBlockSize", + "minH", + "minHeight", + "minInlineSize", + "minW", + "minWidth", + "mixBlendMode", + "ml", + "mr", + "msAccelerator", + "msBlockProgression", + "msContentZoomChaining", + "msContentZoomLimit", + "msContentZoomLimitMax", + "msContentZoomLimitMin", + "msContentZoomSnap", + "msContentZoomSnapPoints", + "msContentZoomSnapType", + "msContentZooming", + "msFilter", + "msFlowFrom", + "msFlowInto", + "msGridColumns", + "msGridRows", + "msHighContrastAdjust", + "msHyphenateLimitChars", + "msHyphenateLimitLines", + "msHyphenateLimitZone", + "msImeAlign", + "msOverflowStyle", + "msScrollChaining", + "msScrollLimit", + "msScrollLimitXMax", + "msScrollLimitXMin", + "msScrollLimitYMax", + "msScrollLimitYMin", + "msScrollRails", + "msScrollSnapPointsX", + "msScrollSnapPointsY", + "msScrollSnapType", + "msScrollSnapX", + "msScrollSnapY", + "msScrollTranslation", + "msScrollbar3dlightColor", + "msScrollbarArrowColor", + "msScrollbarBaseColor", + "msScrollbarDarkshadowColor", + "msScrollbarFaceColor", + "msScrollbarHighlightColor", + "msScrollbarShadowColor", + "msScrollbarTrackColor", + "msTextAutospace", + "msTouchSelect", + "msUserSelect", + "msWrapFlow", + "msWrapMargin", + "msWrapThrough", + "mt", + "mx", + "my", + "objectFit", + "objectPos", + "objectPosition", + "objectViewBox", + "offset", + "offsetAnchor", + "offsetDistance", + "offsetPath", + "offsetPos", + "offsetPosition", + "offsetRotate", + "opacity", + "order", + "orphans", + "outline", + "outlineColor", + "outlineOffset", + "outlineStyle", + "outlineWidth", + "overflow", + "overflowAnchor", + "overflowBlock", + "overflowClipBox", + "overflowClipMargin", + "overflowInline", + "overflowWrap", + "overflowX", + "overflowY", + "overlay", + "overscrollBehavior", + "overscrollBehaviorBlock", + "overscrollBehaviorInline", + "overscrollBehaviorX", + "overscrollBehaviorY", + "p", + "padding", + "paddingBlock", + "paddingBlockEnd", + "paddingBlockStart", + "paddingBottom", + "paddingInline", + "paddingInlineEnd", + "paddingInlineStart", + "paddingLeft", + "paddingRight", + "paddingTop", + "page", + "pageBreakAfter", + "pageBreakBefore", + "pageBreakInside", + "paintOrder", + "pathLength", + "pb", + "perspective", + "perspectiveOrigin", + "pl", + "placeContent", + "placeItems", + "placeSelf", + "pointerEvents", + "pos", + "position", + "positionAnchor", + "positionArea", + "positionTry", + "positionTryFallbacks", + "positionTryOrder", + "positionVisibility", + "positioning", + "pr", + "printColorAdjust", + "pt", + "px", + "py", + "quotes", + "r", + "readingFlow", + "readingOrder", + "resize", + "right", + "rotate", + "rowGap", + "rubyAlign", + "rubyMerge", + "rubyOverhang", + "rubyPosition", + "rx", + "ry", + "scale", + "scrollBehavior", + "scrollInitialTarget", + "scrollMargin", + "scrollMarginBlock", + "scrollMarginBlockEnd", + "scrollMarginBlockStart", + "scrollMarginBottom", + "scrollMarginInline", + "scrollMarginInlineEnd", + "scrollMarginInlineStart", + "scrollMarginLeft", + "scrollMarginRight", + "scrollMarginTop", + "scrollMarkerGroup", + "scrollPadding", + "scrollPaddingBlock", + "scrollPaddingBlockEnd", + "scrollPaddingBlockStart", + "scrollPaddingBottom", + "scrollPaddingInline", + "scrollPaddingInlineEnd", + "scrollPaddingInlineStart", + "scrollPaddingLeft", + "scrollPaddingRight", + "scrollPaddingTop", + "scrollSnapAlign", + "scrollSnapCoordinate", + "scrollSnapDestination", + "scrollSnapPointsX", + "scrollSnapPointsY", + "scrollSnapStop", + "scrollSnapType", + "scrollSnapTypeX", + "scrollSnapTypeY", + "scrollTargetGroup", + "scrollTimeline", + "scrollTimelineAxis", + "scrollTimelineName", + "scrollbarColor", + "scrollbarGutter", + "scrollbarWidth", + "shapeImageThreshold", + "shapeMargin", + "shapeOutside", + "shapeRendering", + "speakAs", + "stopColor", + "stopOpacity", + "stroke", + "strokeColor", + "strokeDasharray", + "strokeDashoffset", + "strokeLinecap", + "strokeLinejoin", + "strokeMiterlimit", + "strokeOpacity", + "strokeWidth", + "tabSize", + "tableLayout", + "textAlign", + "textAlignLast", + "textAnchor", + "textAutospace", + "textBox", + "textBoxEdge", + "textBoxTrim", + "textCombineUpright", + "textDecoration", + "textDecorationColor", + "textDecorationInset", + "textDecorationLine", + "textDecorationSkip", + "textDecorationSkipInk", + "textDecorationStyle", + "textDecorationThickness", + "textEmphasis", + "textEmphasisColor", + "textEmphasisPosition", + "textEmphasisStyle", + "textFit", + "textIndent", + "textJustify", + "textOrientation", + "textOverflow", + "textRendering", + "textShadow", + "textSizeAdjust", + "textSpacingTrim", + "textTransform", + "textUnderlineOffset", + "textUnderlinePosition", + "textWrap", + "textWrapMode", + "textWrapStyle", + "timelineScope", + "timelineTrigger", + "timelineTriggerActiveRange", + "timelineTriggerName", + "timelineTriggerSource", + "top", + "touchAction", + "transform", + "transformBox", + "transformOrigin", + "transformStyle", + "transition", + "transitionBehavior", + "transitionDelay", + "transitionDuration", + "transitionProperty", + "transitionTimingFunction", + "translate", + "triggerScope", + "typography", + "unicodeBidi", + "userSelect", + "vectorEffect", + "verticalAlign", + "viewTimeline", + "viewTimelineAxis", + "viewTimelineInset", + "viewTimelineName", + "viewTransitionClass", + "viewTransitionName", + "viewTransitionScope", + "visibility", + "w", + "whiteSpace", + "whiteSpaceCollapse", + "widows", + "width", + "willChange", + "wordBreak", + "wordSpacing", + "wordWrap", + "writingMode", + "x", + "y", + "zIndex", + "zoom", +]; + +/// Props whose value may be a bare ``, which is what makes a hex +/// literal there worth reporting as `hardcoded-color`. +/// +/// Membership decides whether the rule runs at all, so an omission here is +/// a silent false negative rather than a missed suggestion. `bg` was the +/// expensive one: it is devup-ui's shorthand for `background` and the +/// Figma generator writes it for every solid fill, so `bg="#752E2E"` - +/// the single most common hardcoded color this repository produces - was +/// never examined by anything. That absence was then read as evidence +/// that the rule only fires when a matching token exists, which it does +/// not: `hardcoded-color` reports the literal either way and merely +/// attaches nearby tokens as a suggestion. +/// +/// Deliberately excluded, and why: +/// - `colorScheme`, `colorInterpolationFilters`, `forcedColorAdjust`, +/// `printColorAdjust` take keywords, never a color. +/// - `MozBorderTopColors` and its siblings take a *list* of colors for +/// nested borders; a single hex is not the shape they are written in. +/// - `msScrollbar*Color` are IE-era and unreachable from devup-ui output. +/// - `caret` admits an optional color, but its color half is spelled +/// `caretColor` - which is here - and no generator emits the shorthand. +/// +/// A shorthand earns a place here by being one the generated code +/// actually writes, which is the test `bg` passes and `caret` does not. +pub const DEVUP_COLOR_LIKE_PROPS: &[&str] = &[ + "WebkitTapHighlightColor", + "WebkitTextFillColor", + "WebkitTextStrokeColor", + "accentColor", + "backgroundColor", + "bg", + "bgColor", + "borderBlockColor", + "borderBlockEndColor", + "borderBlockStartColor", + "borderBottomColor", + "borderColor", + "borderInlineColor", + "borderInlineEndColor", + "borderInlineStartColor", + "borderLeftColor", + "borderRightColor", + "borderTopColor", + "caretColor", + "color", + "columnRuleColor", + "fill", + "floodColor", + "lightingColor", + "outlineColor", + "scrollbarColor", + "stopColor", + "stroke", + "strokeColor", + "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", +]; \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/style_props/tests.rs b/crates/devup-mcp-devup-ui/src/style_props/tests.rs new file mode 100644 index 00000000..5865e4fc --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/style_props/tests.rs @@ -0,0 +1,107 @@ + + +#[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"); + } + } + + /// A shorthand and the `*Color` spelling of the same property are the + /// same property, and a caller picks whichever reads better. Covering + /// one and not the other makes the rule depend on spelling: `bg` and + /// `stroke` were unchecked while `bgColor` and `strokeColor`'s + /// counterpart `stroke` were - so the same screen passed or failed on + /// how its author had typed it. + #[test] + fn a_color_prop_and_its_shorthand_are_covered_together() { + for (shorthand, long_form) in [ + ("bg", "backgroundColor"), + ("bgColor", "backgroundColor"), + ("stroke", "strokeColor"), + ] { + assert!( + is_color_like_prop(shorthand), + "{shorthand} is color-like but is not covered" + ); + assert!( + is_color_like_prop(long_form), + "{long_form} is color-like but is not covered" + ); + } + } + + /// The physical border colors were covered and their logical + /// equivalents were not, which is the same property written the way + /// CSS writes it for a right-to-left document. + #[test] + fn logical_border_colors_are_covered_like_the_physical_ones() { + for prop in [ + "borderBlockColor", + "borderBlockStartColor", + "borderBlockEndColor", + "borderInlineColor", + "borderInlineStartColor", + "borderInlineEndColor", + ] { + assert!(is_color_like_prop(prop), "{prop} is not covered"); + } + } + + /// Having "color" in the name is not what makes a prop color-valued. + /// These four take keywords, and reporting a hex in them would be + /// reporting something nobody can write. + #[test] + fn keyword_valued_props_named_color_are_not_color_like() { + for prop in [ + "colorScheme", + "colorInterpolationFilters", + "forcedColorAdjust", + "printColorAdjust", + ] { + assert!( + is_known_style_prop(prop), + "{prop} should still be a known style prop" + ); + assert!(!is_color_like_prop(prop), "{prop} is not color-valued"); + } + } + + #[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")); + // `bg` is devup-ui's shorthand for `background` and what the Figma + // generator writes for a solid fill. Excluding it meant the hardcoded + // color the generator emits most often was the one color nothing + // looked at. + 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/text/absolute_bounds-annotate-35afe622.md b/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-35afe622.md new file mode 100644 index 00000000..cacf1b0f --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-35afe622.md @@ -0,0 +1 @@ +Prove the effective constraint belongs to this node, and that render-relative offsets preserve its positioning in the emitted containing block. A successful rounding check alone is insufficient. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-8cf6eb51.md b/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-8cf6eb51.md new file mode 100644 index 00000000..b6b9372d --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-8cf6eb51.md @@ -0,0 +1 @@ +Absolute asset export policy: generated {css} = px(absoluteRenderBounds.{axis}); export origin = render.xy - parent.absoluteBoundingBox.xy. Bounding-box differences are boundary selection, not rounding. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-9c9cb9c4.md b/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-9c9cb9c4.md new file mode 100644 index 00000000..6581689f --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-9c9cb9c4.md @@ -0,0 +1 @@ +Export-boundary arithmetic is reported separately. Inherited/default constraints, SCALE/STRETCH, changed local geometry or an unverified containing block do not prove source placement intent. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-f08cae30.md b/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-f08cae30.md new file mode 100644 index 00000000..1067642f --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-f08cae30.md @@ -0,0 +1 @@ +Use node constraint object; otherwise first child's constraint object; missing axis defaults to MIN. SCALE/STRETCH currently emit the near-edge offset, without a responsive scaling proof. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/blur_layers-check_Violation-c576344a.md b/crates/devup-mcp-devup-ui/src/text/blur_layers-check_Violation-c576344a.md new file mode 100644 index 00000000..11b76b68 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/blur_layers-check_Violation-c576344a.md @@ -0,0 +1 @@ +Static review cannot establish intended decoration, conditional visibility, external CSS, dynamic/custom ancestor effects or browser pixels; this warning does not prove a missing layer. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/component-add_fallback_diagnostics-9333dc00.md b/crates/devup-mcp-devup-ui/src/text/component-add_fallback_diagnostics-9333dc00.md new file mode 100644 index 00000000..ef29d8e7 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/component-add_fallback_diagnostics-9333dc00.md @@ -0,0 +1 @@ +A visible gradient has insufficient stops for CSS. Recapture the missing paint geometry or explicitly flatten the entire subtree; this composition cannot be reconstructed from the collected fields. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/component-add_fallback_diagnostics-b5ee2878.md b/crates/devup-mcp-devup-ui/src/text/component-add_fallback_diagnostics-b5ee2878.md new file mode 100644 index 00000000..0719106a --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/component-add_fallback_diagnostics-b5ee2878.md @@ -0,0 +1 @@ +This live layout background references a native node rendition that includes the node's other fills, effects and subtree. CSS/live children can repeat that composition. Review the exported PNG and flatten the entire subtree, or export original fill bytes separately and implement the paint transforms/compositing before use; automatic fidelity is unsupported here. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/component-render_component_registration_snapshot-dcc3fe0a.md b/crates/devup-mcp-devup-ui/src/text/component-render_component_registration_snapshot-dcc3fe0a.md new file mode 100644 index 00000000..5fd90445 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/component-render_component_registration_snapshot-dcc3fe0a.md @@ -0,0 +1,15 @@ +{{ + "name": {}, + "node": {}, + "tree": {{ + "children": [], + "component": "Box", + "nodeName": {}, + "nodeType": "COMPONENT", + "props": {{ +{} + }}, + }}, + "variantComments": {{}}, + "variants": {}, +}} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/component-unresolved_token_bindings_Diagnostic-08be2867.md b/crates/devup-mcp-devup-ui/src/text/component-unresolved_token_bindings_Diagnostic-08be2867.md new file mode 100644 index 00000000..19b86b1d --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/component-unresolved_token_bindings_Diagnostic-08be2867.md @@ -0,0 +1 @@ +{id} binds {property} to a {kind} the resource catalog did not name, so the resolved value was written instead of its token. Collect the resource, or treat the value in the generated code as a token that still needs a name. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/evidence-image_layout_diagnostics_Diagnostic-79e776df.md b/crates/devup-mcp-devup-ui/src/text/evidence-image_layout_diagnostics_Diagnostic-79e776df.md new file mode 100644 index 00000000..7aa53bb6 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/evidence-image_layout_diagnostics_Diagnostic-79e776df.md @@ -0,0 +1 @@ +Preserve explicit fixed dimensions or source-supported aspect ratio; measure rendered card sizes with the chosen assets. Zero-minimum fractional columns protect track allocation but do not establish image height or crop fidelity. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/evidence-image_layout_diagnostics_Diagnostic-c94c1150.md b/crates/devup-mcp-devup-ui/src/text/evidence-image_layout_diagnostics_Diagnostic-c94c1150.md new file mode 100644 index 00000000..cea136f2 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/evidence-image_layout_diagnostics_Diagnostic-c94c1150.md @@ -0,0 +1 @@ +Preserve valid explicit tracks; use auto for unknown/invalid tracks and a count-only equal-share fallback (auto for HUG). Omit unsupported declarations above the 4096-track codegen budget. No child pixel width is inferred. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-09b061a1.md b/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-09b061a1.md new file mode 100644 index 00000000..14b49a4d --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-09b061a1.md @@ -0,0 +1 @@ +Ensure the generated root's {axis} matches the intended containing block (captured {axis}: {size}); this axis is determined by host CSS or in-flow content, not a generated fixed size. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-5c61c6c3.md b/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-5c61c6c3.md new file mode 100644 index 00000000..b67d714b --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-5c61c6c3.md @@ -0,0 +1 @@ +The surface this root is drawn on in Figma, read from the collected parent. It is reported, not emitted: the parent is not part of this component. Apply it to the host when the screen is meant to sit on it. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-7c9ff723.md b/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-7c9ff723.md new file mode 100644 index 00000000..26a67944 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-7c9ff723.md @@ -0,0 +1 @@ +Provide a definite height on this generated root matching its captured FIXED height so its emitted vertical FILL child can grow into the remaining space. A host height alone is insufficient unless the root also receives that height; preserve the child's emitted flex growth and following action order. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-adba181a.md b/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-adba181a.md new file mode 100644 index 00000000..e4e14083 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-adba181a.md @@ -0,0 +1 @@ +Provide a positioned host matching the original parent dimensions and coordinate origin; the parent is not included in this component. Review the emitted offsets before insertion. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract_Diagnostic-d84874e6.md b/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract_Diagnostic-d84874e6.md new file mode 100644 index 00000000..4da3a661 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/evidence-placement_contract_Diagnostic-d84874e6.md @@ -0,0 +1 @@ +The root is inserted in normal document flow; source canvas x/y are not its placement in the application. Positioned children resolve against the generated root when it establishes a containing block. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-113d7813.md b/crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-113d7813.md new file mode 100644 index 00000000..a11c61bc --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-113d7813.md @@ -0,0 +1 @@ +allowFullScreen allowpopups autosize blinkfeatures disableblinkfeatures disableguestresize disablewebsecurity guestinstance httpreferrer nodeintegration partition plugins preload src useragent webpreferences \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-2afffb93.md b/crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-2afffb93.md new file mode 100644 index 00000000..db30183d --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-2afffb93.md @@ -0,0 +1 @@ +about accessKey autoCapitalize autoCorrect autoFocus autoSave children className color content contentEditable contextMenu dangerouslySetInnerHTML datatype defaultChecked defaultValue dir draggable enterKeyHint exportparts hidden id inert inlist inputMode is itemID itemProp itemRef itemScope itemType key lang nonce part popover popoverTarget popoverTargetAction prefix property radioGroup ref rel resource results rev role security slot spellCheck style suppressContentEditableWarning suppressHydrationWarning tabIndex title translate typeof unselectable vocab \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-9108b590.md b/crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-9108b590.md new file mode 100644 index 00000000..ee7727c3 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-9108b590.md @@ -0,0 +1 @@ +accept alt autoComplete capture checked disabled form formAction formEncType formMethod formNoValidate formTarget height list max maxLength min minLength multiple name pattern placeholder readOnly required size src step type value width \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-2ec1244f.md b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-2ec1244f.md new file mode 100644 index 00000000..9555d96f --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-2ec1244f.md @@ -0,0 +1 @@ +Spaces adjacent to explicit breaks in non-list, unclamped text with fixed inline width use pre-wrap. This preserves source whitespace without changing Korean word-breaking policy; it does not establish glyph or wrapping parity. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-691f8d10.md b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-691f8d10.md new file mode 100644 index 00000000..4bdc748e --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-691f8d10.md @@ -0,0 +1 @@ +Layout/style transform composition from rotation, placement and CENTER constraints; asset exports may already bake rotation. The emitted expression is compared with the responsible stage. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-a0f0555b.md b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-a0f0555b.md new file mode 100644 index 00000000..0a1165bd --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-a0f0555b.md @@ -0,0 +1 @@ +push_auto_layout: explicit FIXED tracks retain pixels, HUG/AUTO tracks retain content sizing, and FLEX columns use minmax(0, Nfr) outside HUG grids to avoid automatic min-content expansion. Rows retain their content minimum. Count-only tracks use a diagnosed equal-share fallback (auto for HUG); unknown/invalid tracks use diagnosed auto sizing. Child widths and placement are independent of track sizing. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-a4bd5441.md b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-a4bd5441.md new file mode 100644 index 00000000..ce0399d6 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-a4bd5441.md @@ -0,0 +1 @@ +push_effects: visible effects in source order; shadow offset.x/y, radius, spread and bound/resolved RGBA color; textShadow omits spread; blur uses radius. Existing effect-loss diagnostics still apply. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-f0db063f.md b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-f0db063f.md new file mode 100644 index 00000000..b582e11f --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-f0db063f.md @@ -0,0 +1 @@ +push_animation_props follows timed Smart Animate destinations in the collected snapshot, computes changed property keyframes and timing. Existing unreachable/unsupported animation diagnostics still apply. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-f4103b4e.md b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-f4103b4e.md new file mode 100644 index 00000000..2292a722 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-f4103b4e.md @@ -0,0 +1 @@ +One solid square-cornered OUTSIDE edge becomes a zero-blur translated box shadow that consumes no layout space; real effects follow it in paint order. This does not claim a source shadow effect or pixel parity. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-16da012c.md b/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-16da012c.md new file mode 100644 index 00000000..3560f6bb --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-16da012c.md @@ -0,0 +1 @@ +Supplied acquisition evidence confirms baked effects. The same CSS effects are suppressed at this asset boundary; layout ancestors retain their effects. Missing baking evidence is unknown; no canvas equality is implied. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-1fe5685d.md b/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-1fe5685d.md new file mode 100644 index 00000000..65c79b10 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-1fe5685d.md @@ -0,0 +1 @@ +Acquired image representation plus supported paint geometry maps to the exact emitted property; assetCssMappings records pixel sizes, normalized coordinates, baking evidence and explicit limits. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-2373f8c7.md b/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-2373f8c7.md new file mode 100644 index 00000000..f5192835 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-2373f8c7.md @@ -0,0 +1 @@ +Horizontal/vertical SPACE_BETWEEN becomes center for exactly one visible non-ABSOLUTE child; child membership, visibility and positioning determine the count. This mapping does not claim pixel parity. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-2c171ecb.md b/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-2c171ecb.md new file mode 100644 index 00000000..f4dae2d6 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-2c171ecb.md @@ -0,0 +1 @@ +Source spaces adjacent to explicit line breaks select pre-wrap for non-list, unclamped text whose inline width is fixed and height is automatic. Characters come from the node or collected styled segments. This preserves source whitespace without changing Korean keep-all; it does not claim intrinsic HUG sizing, glyph parity or identical wrapping. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-6489c0ec.md b/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-6489c0ec.md new file mode 100644 index 00000000..90537443 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-6489c0ec.md @@ -0,0 +1 @@ +One solid, square-cornered OUTSIDE edge on a non-asset auto-layout node paints as a zero-blur translated box shadow without consuming layout space; side weights and stroke paint determine the translation and color. Existing effects are composed after the stroke. This is a stroke projection, not an assertion that Figma supplied a shadow effect, a claim about exported-asset composition, or a claim of pixel parity. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/site-interaction_findings-3f71ed85.md b/crates/devup-mcp-devup-ui/src/text/site-interaction_findings-3f71ed85.md new file mode 100644 index 00000000..c1ed69f9 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/site-interaction_findings-3f71ed85.md @@ -0,0 +1 @@ +Apply the role's recipe from the devup-implementation-plan skill: hover, _focusVisible (2px ring with offset), _active, cursor="pointer" and _disabled where it applies, from existing theme tokens, with the shared transition. On a next/link, render it as so the states are devup-ui props. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/site-metadata_finding-e57c4614.md b/crates/devup-mcp-devup-ui/src/text/site-metadata_finding-e57c4614.md new file mode 100644 index 00000000..39c859c5 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/site-metadata_finding-e57c4614.md @@ -0,0 +1 @@ +Export metadata with title, description and openGraph (the devfive-frontend SEO rule); the root layout also carries the title template and metadataBase. A 'use client' page cannot export it: move the client part into a component. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-13b3029b.md b/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-13b3029b.md new file mode 100644 index 00000000..5f8e6a49 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-13b3029b.md @@ -0,0 +1 @@ +Collect error-free local geometry and constraints; establish the emitted immediate containing block; verify MIN offset, zero CENTER offset with exact translation, or MAX margin with verified sizing, without conflicting props. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-65b6e30c.md b/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-65b6e30c.md new file mode 100644 index 00000000..80b75b43 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-65b6e30c.md @@ -0,0 +1 @@ +Constraint, geometry, read errors, containing block or emitted props do not satisfy the bounded CENTER/MIN/MAX proof; STRETCH/SCALE and MAX with unverified sizing require separate proofs. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-bdf7d908.md b/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-bdf7d908.md new file mode 100644 index 00000000..0c055f4a --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-bdf7d908.md @@ -0,0 +1 @@ +For a proven containing block: resolved width = 100 / 100 * generated parent width in px = source FIXED width. Unknown or unequal parent widths, read errors, non-FIXED sizing and conflicting dimension props do not establish this relation. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-cfd19786.md b/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-cfd19786.md new file mode 100644 index 00000000..34579ffd --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-cfd19786.md @@ -0,0 +1 @@ +Absolute percentage resolves to the collected FIXED width in the emitted immediate positioned parent with equal explicit FIXED pixels, no parent padding/borders or dimension overrides. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/style-description-49ef22f0.md b/crates/devup-mcp-devup-ui/src/text/style-description-49ef22f0.md new file mode 100644 index 00000000..33646197 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/style-description-49ef22f0.md @@ -0,0 +1 @@ +Native raster boundary: use the already rendered pixel box without applying the source IMAGE paint fit a second time; in-flow export offsets override this placement when present. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/style-description-5ad1a929.md b/crates/devup-mcp-devup-ui/src/text/style-description-5ad1a929.md new file mode 100644 index 00000000..5c3e49ce --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/style-description-5ad1a929.md @@ -0,0 +1 @@ +In-flow export boundary: offset=(render.x-bounds.x, render.y-bounds.y); exported size=(render.width, render.height). Image uses objectFit=none and objectPos=offset; mask uses maskSize=exported size and maskPos=offset. Absolute/free-layout placement is handled by layout. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/style-description-b7e8995c.md b/crates/devup-mcp-devup-ui/src/text/style-description-b7e8995c.md new file mode 100644 index 00000000..280a0d63 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/style-description-b7e8995c.md @@ -0,0 +1 @@ +widthPolicy: a vector with a zero-height bounding box and a solid stroke is a straight rule; its box follows the parent's width under a stretch or scale anchor, so the mask is sized to the box (100% 100%) instead of keeping its drawn length. Stroke caps, if any, scale along the line. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/ui_layout-check_held_left-6cb7140a.md b/crates/devup-mcp-devup-ui/src/text/ui_layout-check_held_left-6cb7140a.md new file mode 100644 index 00000000..ca6f7d7c --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/ui_layout-check_held_left-6cb7140a.md @@ -0,0 +1 @@ +{} is drawn with equal gaps on both sides of a container that follows the viewport, but held by its left edge, so it drifts off-centre at any other width. Centre it (left="50%" with transform="translateX(-50%)") or stretch it between the gaps (left and right). \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/ui_layout-check_held_left-d6e0fe40.md b/crates/devup-mcp-devup-ui/src/text/ui_layout-check_held_left-d6e0fe40.md new file mode 100644 index 00000000..85136dc5 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/ui_layout-check_held_left-d6e0fe40.md @@ -0,0 +1 @@ +{} is drawn {}px from the right edge of a container that follows the viewport, but held by its left edge, so at any other width it stays where it was drawn. If it belongs to the right edge, hold it there (right="{}px"). \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/ui_layout-check_line_mask-ab57b482.md b/crates/devup-mcp-devup-ui/src/text/ui_layout-check_line_mask-ab57b482.md new file mode 100644 index 00000000..1e102312 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/ui_layout-check_line_mask-ab57b482.md @@ -0,0 +1 @@ +A rule drawn as a mask keeps its drawn length while this box follows its parent's width, so the line stops short of the box at any other width. Size the mask to the box, or draw the rule as a filled box of the same colour and weight. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/ui_structure-query_findings-60bd4f27.md b/crates/devup-mcp-devup-ui/src/text/ui_structure-query_findings-60bd4f27.md new file mode 100644 index 00000000..2e4d4def --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/ui_structure-query_findings-60bd4f27.md @@ -0,0 +1 @@ +Query data is dereferenced during rendering without local isPending and isError handling; data can be undefined while pending. This does not assert that external boundaries are absent. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/ui_validate-check_static_object_Violation-a031fb84.md b/crates/devup-mcp-devup-ui/src/text/ui_validate-check_static_object_Violation-a031fb84.md new file mode 100644 index 00000000..d95590da --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/ui_validate-check_static_object_Violation-a031fb84.md @@ -0,0 +1 @@ +{call_name}({{ {key}: ... }}) accepts only statically analyzable literal values. css() can extract finite conditional branches as static classes; globalCss()/keyframes() do not select runtime classes. Value branches must be static, not arbitrary runtime values. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/ui_validate-report_hardcoded_value_Violation-63c589c5.md b/crates/devup-mcp-devup-ui/src/text/ui_validate-report_hardcoded_value_Violation-63c589c5.md new file mode 100644 index 00000000..6d06ae77 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/ui_validate-report_hardcoded_value_Violation-63c589c5.md @@ -0,0 +1 @@ +{prop_name} uses hardcoded {kind} {text}, which this project's devup.json already defines as {named}. Check the matching modes before replacement; a partial-mode match may change this value in another mode. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/width-policy_horizontal_proof-6cd374a8.md b/crates/devup-mcp-devup-ui/src/text/width-policy_horizontal_proof-6cd374a8.md new file mode 100644 index 00000000..719672c5 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/width-policy_horizontal_proof-6cd374a8.md @@ -0,0 +1 @@ +The widthPolicy anchor's props reproduce the drawn offset at the drawn parent width. Which edge holds the node at other widths is the caller's policy, not the design's constraint. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/width-policy_width_proof-d2d9e821.md b/crates/devup-mcp-devup-ui/src/text/width-policy_width_proof-d2d9e821.md new file mode 100644 index 00000000..de9db9fb --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/width-policy_width_proof-d2d9e821.md @@ -0,0 +1 @@ +A widthPolicy anchor writes this width as the node's edges or share of its parent; at the drawn parent width it resolves to the drawn width. Other widths follow the policy and are not a claim about the design. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/width-positioned_finding-7a9e6965.md b/crates/devup-mcp-devup-ui/src/text/width-positioned_finding-7a9e6965.md new file mode 100644 index 00000000..3be005dd --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/width-positioned_finding-7a9e6965.md @@ -0,0 +1 @@ +Drawn near the right edge, but its constraint holds it at its left offset, so at a wider width it stays where it was drawn. The design does not say which is meant. Anchoring it right in widthPolicy also carries what is drawn inside it. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/width-width_behavior-8f8e0a0f.md b/crates/devup-mcp-devup-ui/src/text/width-width_behavior-8f8e0a0f.md new file mode 100644 index 00000000..88f9731d --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/width-width_behavior-8f8e0a0f.md @@ -0,0 +1 @@ +The root keeps its drawn width in px, so the screen renders at that width at every viewport. Findings describe what changes once the root takes its container's width - with widthPolicy, or by editing the root. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/width-width_behavior-d9663795.md b/crates/devup-mcp-devup-ui/src/text/width-width_behavior-d9663795.md new file mode 100644 index 00000000..725100da --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/width-width_behavior-d9663795.md @@ -0,0 +1 @@ +Positioned childless shapes are written h="100%" with no width, as the plugin writes them; projectionIssues reports their drawn size. widthPolicy writes each one at its own size. \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/text/width-width_behavior-fdf7cb4e.md b/crates/devup-mcp-devup-ui/src/text/width-width_behavior-fdf7cb4e.md new file mode 100644 index 00000000..07de3b81 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/text/width-width_behavior-fdf7cb4e.md @@ -0,0 +1 @@ +Drawn narrower than any screen (320px): a component its host places and sizes, not a viewport. Findings that ask where a part goes on a wider screen are left out; summary.componentRootSuppressed counts them. \ No newline at end of file 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 0fecc0c6..8937c39b 100644 --- a/crates/devup-mcp-devup-ui/src/theme/devup_json.rs +++ b/crates/devup-mcp-devup-ui/src/theme/devup_json.rs @@ -1,1285 +1,3 @@ -use std::collections::{BTreeMap, BTreeSet, HashSet}; - -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; -use sha2::{Digest, Sha256}; - -use devup_mcp_figma::{DevupError, Diagnostic, DiagnosticSeverity, ErrorCode, UpstreamResult}; - -use super::tokens::{normalize_token, style_token, variable_token}; -use crate::provenance::{ProvenanceEntry, SourceMap, json_pointer_segment}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ThemeScope { - Node, - Page, - File, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum Completeness { - FullLocalPlusUsedRemote, - UsedTokens, - ResolvedValuesOnly, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ThemeCounts { - pub collections: usize, - pub variables: usize, - pub styles: usize, - pub modes: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VariableMode { - pub mode_id: String, - pub name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VariableCollection { - pub id: String, - pub name: String, - pub default_mode_id: String, - pub modes: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VariableDefinition { - pub id: String, - pub name: String, - pub resolved_type: String, - pub variable_collection_id: String, - #[serde(default)] - pub code_syntax: BTreeMap, - #[serde(default)] - pub values_by_mode: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VariableStyle { - pub id: String, - pub name: String, - pub style_type: String, - pub value: Value, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VariableSnapshot { - #[serde(default)] - pub collections: Vec, - #[serde(default)] - pub variables: Vec, - #[serde(default)] - pub styles: Vec, - #[serde(default)] - pub used_remote_variables: Vec, - #[serde(default)] - pub used_variable_ids: Vec, - #[serde(default)] - pub used_style_ids: Vec, - #[serde(default)] - pub local_complete: bool, - #[serde(default)] - pub used_remote_complete: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ThemeOutput { - pub json: String, - pub counts: ThemeCounts, - pub completeness: Completeness, - pub diagnostics: Vec, - pub conflicts: Vec, - pub unresolved_variables: Vec, - #[serde(default)] - pub skipped_styles: Vec, - pub source_map: SourceMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ThemeSkippedStyle { - pub style_id: String, - pub name: String, - pub reason: String, - #[serde(default)] - pub node_ids: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ThemeUnresolvedVariable { - pub variable_id: String, - pub mode_id: Option, - pub reason: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ThemeVariableSource { - Local, - UsedRemote, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ThemeConflictCandidate { - pub variable_id: String, - pub collection_id: String, - pub collection_name: String, - pub raw_name: String, - pub mode_id: String, - pub source: ThemeVariableSource, - pub value_hash: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ThemeConflict { - pub token: String, - pub mode: String, - pub kind: String, - pub winner_variable_id: String, - pub candidates: Vec, -} - -#[derive(Debug, Clone, Copy)] -struct VariableCandidate<'a> { - variable: &'a VariableDefinition, - source: ThemeVariableSource, -} - -#[derive(Debug, Clone)] -struct ProjectedVariable { - variable_id: String, - collection_id: String, - collection_name: String, - raw_name: String, - mode_id: String, - source: ThemeVariableSource, - value: Value, - resolution: String, -} - -pub fn generate_devup_json( - snapshot: &VariableSnapshot, - scope: ThemeScope, -) -> Result { - let mut diagnostics = Vec::new(); - let mut conflicts = Vec::new(); - let mut unresolved_variables = Vec::new(); - let mut skipped_styles = Vec::new(); - let mut source_entries = Vec::new(); - let collections = snapshot - .collections - .iter() - .map(|collection| (collection.id.as_str(), collection)) - .collect::>(); - let mut variable_candidates = snapshot - .variables - .iter() - .map(|variable| VariableCandidate { - variable, - source: ThemeVariableSource::Local, - }) - .chain( - snapshot - .used_remote_variables - .iter() - .map(|variable| VariableCandidate { - variable, - source: ThemeVariableSource::UsedRemote, - }), - ) - .collect::>(); - variable_candidates.sort_by(|left, right| { - let left_collection = collections - .get(left.variable.variable_collection_id.as_str()) - .map(|collection| collection.name.as_str()) - .unwrap_or_default(); - let right_collection = collections - .get(right.variable.variable_collection_id.as_str()) - .map(|collection| collection.name.as_str()) - .unwrap_or_default(); - let left_explicit = has_web_syntax(left.variable); - let right_explicit = has_web_syntax(right.variable); - right_explicit - .cmp(&left_explicit) - .then_with(|| left.source.cmp(&right.source)) - .then_with(|| left_collection.cmp(right_collection)) - .then_with(|| left.variable.name.cmp(&right.variable.name)) - .then_with(|| left.variable.id.cmp(&right.variable.id)) - }); - let mut variables = BTreeMap::new(); - for candidate in &variable_candidates { - variables - .entry(candidate.variable.id.as_str()) - .or_insert(candidate.variable); - } - let selected_variable_ids = selected_variable_ids(snapshot, scope, &variables); - if let Some(selected) = &selected_variable_ids { - variable_candidates.retain(|candidate| selected.contains(&candidate.variable.id)); - } - let variable_count = variable_candidates.len(); - let mut projected = BTreeMap::<(String, String, String), Vec>::new(); - let mut colors: BTreeMap> = BTreeMap::new(); - let mut lengths: BTreeMap> = BTreeMap::new(); - - for candidate in variable_candidates { - let variable = candidate.variable; - let Some(collection) = collections.get(variable.variable_collection_id.as_str()) else { - unresolved_variables.push(ThemeUnresolvedVariable { - variable_id: variable.id.clone(), - mode_id: None, - reason: "collection-missing".to_owned(), - }); - diagnostics.push(Diagnostic { - code: "DEVUP_THEME_COLLECTION_MISSING".to_owned(), - message: format!("Collection for variable '{}' was not found.", variable.name), - node_id: None, - severity: Some(DiagnosticSeverity::Warning), - resource_kind: Some("variable".to_owned()), - resource_id: Some(variable.id.clone()), - recoverable: Some(true), - ..Diagnostic::default() - }); - continue; - }; - let token = variable_token( - &variable.name, - variable.code_syntax.get("WEB").map(String::as_str), - ); - let is_alias = |mode_id: &str| { - variable - .values_by_mode - .get(mode_id) - .and_then(|value| value.get("type")) - .and_then(Value::as_str) - == Some("VARIABLE_ALIAS") - }; - // A length in a collection whose modes are breakpoints - Mobile, - // Tablet, PC - is one value per breakpoint. devup-ui reads a length - // token as a responsive array; under a mode's own name it would be a - // `data-theme` no page sets, and undefined everywhere else. - if variable.resolved_type == "FLOAT" - && let Some(slots) = breakpoint_slots(collection) - { - let mut values: [Option; 6] = Default::default(); - let mut complete = true; - for (mode, slot) in collection.modes.iter().zip(slots) { - match resolve_value(variable, &mode.mode_id, &variables, &mut HashSet::new()) { - Some(value) => match value.as_f64() { - Some(number) => values[slot] = Some(Value::String(format_px(number))), - None => complete = false, - }, - None => { - complete = false; - unresolved_variables.push(unresolved_alias(variable, &mode.mode_id)); - diagnostics.push(alias_diagnostic(variable)); - } - } - } - if complete && let Some(value) = responsive_length(values) { - projected - .entry(("length".to_owned(), BREAKPOINTS.to_owned(), token.clone())) - .or_default() - .push(ProjectedVariable { - variable_id: variable.id.clone(), - collection_id: variable.variable_collection_id.clone(), - collection_name: collection.name.clone(), - raw_name: variable.name.clone(), - mode_id: collection.default_mode_id.clone(), - source: candidate.source, - value, - resolution: if collection.modes.iter().any(|mode| is_alias(&mode.mode_id)) { - "alias".to_owned() - } else { - "variable".to_owned() - }, - }); - } - continue; - } - for mode in &collection.modes { - let mut visiting = HashSet::new(); - let Some(value) = resolve_value(variable, &mode.mode_id, &variables, &mut visiting) - else { - unresolved_variables.push(unresolved_alias(variable, &mode.mode_id)); - diagnostics.push(alias_diagnostic(variable)); - continue; - }; - let mode_name = normalize_token(&mode.name); - let projection = match variable.resolved_type.as_str() { - "COLOR" => color_value(value).map(|color| ("color", Value::String(color))), - "FLOAT" => value - .as_f64() - .map(|number| ("length", Value::String(format_px(number)))), - _ => None, - }; - if let Some((kind, value)) = projection { - projected - .entry((kind.to_owned(), mode_name, token.clone())) - .or_default() - .push(ProjectedVariable { - variable_id: variable.id.clone(), - collection_id: variable.variable_collection_id.clone(), - collection_name: collection.name.clone(), - raw_name: variable.name.clone(), - mode_id: mode.mode_id.clone(), - source: candidate.source, - value, - resolution: if is_alias(&mode.mode_id) { - "alias".to_owned() - } else { - "variable".to_owned() - }, - }); - } - } - } - - let mut breakpoint_lengths: BTreeMap = BTreeMap::new(); - let mut length_winners: BTreeMap<(String, String), ProjectedVariable> = BTreeMap::new(); - for ((kind, mode, token), candidates) in projected { - let Some(winner) = candidates.first() else { - continue; - }; - match kind.as_str() { - "color" => { - colors - .entry(mode.clone()) - .or_default() - .insert(token.clone(), winner.value.clone()); - } - "length" if mode == BREAKPOINTS => { - breakpoint_lengths.insert(token.clone(), winner.clone()); - } - "length" => { - lengths - .entry(mode.clone()) - .or_default() - .insert(token.clone(), winner.value.clone()); - length_winners.insert((mode.clone(), token.clone()), winner.clone()); - } - _ => continue, - } - let category = if kind == "color" { "colors" } else { "length" }; - if mode != BREAKPOINTS { - source_entries.push(ProvenanceEntry { - element_index: None, - calculation: None, - generated_property: None, - generated_range: None, - json_pointer: Some(format!( - "/theme/{}/{}/{}", - category, - json_pointer_segment(&mode), - json_pointer_segment(&token) - )), - node_id: None, - property: None, - variable_id: Some(winner.variable_id.clone()), - style_id: None, - asset_id: None, - resolution: winner.resolution.clone(), - }); - } - if candidates - .iter() - .skip(1) - .all(|candidate| candidate.value == winner.value) - { - continue; - } - conflicts.push(ThemeConflict { - token: token.clone(), - mode: mode.clone(), - kind: kind.clone(), - winner_variable_id: winner.variable_id.clone(), - candidates: candidates.iter().map(conflict_candidate).collect(), - }); - diagnostics.push(conflict_diagnostic( - &kind, - &mode, - &token, - &winner.variable_id, - )); - } - - // A text style is a `typography` entry and an effect style a `shadow` - // entry, each in the shape devup-ui reads and under the name the plugin's - // `styleNameToTypography` gives it. A style named for a breakpoint — - // `desktop/h1`, `3/bodyXlgBold` — is one slot of a responsive entry, and - // the slots of one name are gathered into an array. The raw style object - // used to be written as it came, and devup-ui's own plugin refused the - // file: `Invalid typography property value: Object {"unit": "PERCENT", - // "value": 120}`. - let mut typography_slots: BTreeMap; 6]> = BTreeMap::new(); - let mut shadow_slots: BTreeMap; 6]> = BTreeMap::new(); - let variable_names = variables - .values() - .map(|variable| { - ( - variable.id.as_str(), - variable_token( - &variable.name, - variable.code_syntax.get("WEB").map(String::as_str), - ), - ) - }) - .collect::>(); - let selected_style_ids = (scope != ThemeScope::File && !snapshot.used_style_ids.is_empty()) - .then(|| snapshot.used_style_ids.iter().collect::>()); - let styles = snapshot - .styles - .iter() - .filter(|style| { - selected_style_ids - .as_ref() - .is_none_or(|selected| selected.contains(&style.id)) - }) - .collect::>(); - for style in &styles { - if style.style_type == "TEXT" - && let Some(reason) = typography_problem(&style.value) - { - skipped_styles.push(ThemeSkippedStyle { - style_id: style.id.clone(), - name: style.name.clone(), - reason: reason.clone(), - node_ids: Vec::new(), - }); - diagnostics.push(Diagnostic { - code: "DEVUP_THEME_STYLE_SKIPPED".into(), - message: format!("Text style '{}' ({}) was skipped: {reason}", style.name, style.id), - resource_kind: Some("style".into()), resource_id: Some(style.id.clone()), - severity: Some(DiagnosticSeverity::Warning), recoverable: Some(true), - fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), - details: Some(json!({"styleName":style.name,"styleId":style.id,"reason":reason,"output":"devupJson"})), - ..Diagnostic::default() - }); - continue; - } - let (level, token) = style_token(&style.name); - let level = level.min(5); - match style.style_type.as_str() { - "TEXT" => { - let slots = typography_slots.entry(token.clone()).or_default(); - // A style whose name does not say a breakpoint, with its size - // bound to a variable whose modes do - Mobile, Tablet, PC - is - // one size per breakpoint: a slot for each mode, the size that - // mode resolves to in it. - let named_for_breakpoint = - style_token(&style.name) != (0, normalize_token(&style.name)); - let by_breakpoint = if named_for_breakpoint { - None - } else { - font_size_by_breakpoint(&style.value, &variables, &collections) - }; - if let Some(sizes) = by_breakpoint { - for (slot, size) in sizes { - if slots[slot].is_none() { - let mut value = typography_value(&style.value, &variable_names); - value["fontSize"] = Value::String(format_px(size)); - slots[slot] = Some(value); - } - } - } else if slots[level].is_none() { - // The first style seen for a slot keeps it, as in the plugin. - slots[level] = Some(typography_value(&style.value, &variable_names)); - } - source_entries.push(ProvenanceEntry { - element_index: None, - calculation: None, - generated_property: None, - generated_range: None, - json_pointer: Some(format!( - "/theme/typography/{}", - json_pointer_segment(&token) - )), - node_id: None, - property: None, - variable_id: None, - style_id: Some(style.id.clone()), - asset_id: None, - resolution: "style".to_owned(), - }); - } - "EFFECT" => { - let Some(shadow) = shadow_value(&style.value) else { - continue; - }; - let slots = shadow_slots.entry(token.clone()).or_default(); - if slots[level].is_none() { - slots[level] = Some(shadow); - } - source_entries.push(ProvenanceEntry { - element_index: None, - calculation: None, - generated_property: None, - generated_range: None, - json_pointer: Some(format!( - "/theme/shadow/default/{}", - json_pointer_segment(&token) - )), - node_id: None, - property: None, - variable_id: None, - style_id: Some(style.id.clone()), - asset_id: None, - resolution: "style".to_owned(), - }); - } - _ => {} - } - } - let typography = typography_slots - .into_iter() - .filter_map(|(token, slots)| responsive_entry(slots).map(|entry| (token, entry))) - .collect::>(); - let shadows = shadow_slots - .into_iter() - .filter_map(|(token, slots)| { - responsive_entry(slots.map(|slot| slot.map(Value::String))).map(|entry| (token, entry)) - }) - .collect::>(); - - // devup-ui takes the first theme written under `colors` as the default — - // the one in effect without a `data-theme` — so the order of the modes is - // not cosmetic. Sorted by name, `dark` came before `light`, and a file - // whose default mode is Light was handed to consumers dark. The - // collections' default modes go first, then the rest of their modes in - // the order the collections declare them, as the plugin writes them. - let mode_order = { - let mut order: Vec = Vec::new(); - let mut push = |name: String| { - if !order.contains(&name) { - order.push(name); - } - }; - for collection in collections.values() { - if let Some(default) = collection - .modes - .iter() - .find(|mode| mode.mode_id == collection.default_mode_id) - { - push(normalize_token(&default.name)); - } - } - for collection in collections.values() { - for mode in &collection.modes { - push(normalize_token(&mode.name)); - } - } - order - }; - let in_mode_order = |modes: BTreeMap>| { - let mut ordered = Map::new(); - for name in &mode_order { - if let Some(tokens) = modes.get(name) { - ordered.insert(name.clone(), json!(tokens)); - } - } - for (name, tokens) in modes { - if !ordered.contains_key(&name) { - ordered.insert(name, json!(tokens)); - } - } - Value::Object(ordered) - }; - let colors = in_mode_order(colors); - // Lengths and shadows do not vary by colour theme, but devup-ui keys them - // by one, so each colour theme gets a copy — the plugin replicates them - // the same way. A file with no colour themes keeps `default`. - let theme_names = colors - .as_object() - .map(|themes| themes.keys().cloned().collect::>()) - .filter(|names| !names.is_empty()) - .unwrap_or_else(|| vec!["default".to_owned()]); - let lengths = { - let ordered = in_mode_order(lengths); - let mut by_theme = ordered.as_object().cloned().unwrap_or_default(); - let first = by_theme - .iter() - .next() - .map(|(name, tokens)| (name.clone(), tokens.clone())); - let mut replicated = Map::new(); - let mut copied_from = BTreeMap::new(); - for name in &theme_names { - if let Some(tokens) = by_theme.remove(name) { - replicated.insert(name.clone(), tokens); - copied_from.insert(name.clone(), name.clone()); - } else if let Some((source, first)) = &first { - replicated.insert(name.clone(), first.clone()); - copied_from.insert(name.clone(), source.clone()); - } - } - for (name, tokens) in by_theme { - copied_from.insert(name.clone(), name.clone()); - replicated.insert(name, tokens); - } - if !breakpoint_lengths.is_empty() && replicated.is_empty() { - for name in &theme_names { - replicated.insert(name.clone(), Value::Object(Map::new())); - } - } - for (name, tokens) in &mut replicated { - let Some(tokens) = tokens.as_object_mut() else { - continue; - }; - for (token, breakpoint) in &breakpoint_lengths { - match tokens.get(token) { - None => { - tokens.insert(token.clone(), breakpoint.value.clone()); - source_entries.push(ProvenanceEntry { - element_index: None, - calculation: None, - generated_property: None, - generated_range: None, - json_pointer: Some(format!( - "/theme/length/{}/{}", - json_pointer_segment(name), - json_pointer_segment(token) - )), - node_id: None, - property: None, - variable_id: Some(breakpoint.variable_id.clone()), - style_id: None, - asset_id: None, - resolution: breakpoint.resolution.clone(), - }); - } - // The theme's own token of the same name keeps its place, - // as the first candidate of one name does, and says so. - Some(existing) if *existing != breakpoint.value => { - let own = copied_from.get(name).and_then(|source| { - length_winners.get(&(source.clone(), token.clone())) - }); - let winner_id = own.map_or_else(String::new, |own| own.variable_id.clone()); - conflicts.push(ThemeConflict { - token: token.clone(), - mode: name.clone(), - kind: "length".to_owned(), - winner_variable_id: winner_id.clone(), - candidates: own - .into_iter() - .chain([breakpoint]) - .map(conflict_candidate) - .collect(), - }); - diagnostics.push(conflict_diagnostic("length", name, token, &winner_id)); - } - Some(_) => {} - } - } - } - Value::Object(replicated) - }; - let shadows = theme_names - .iter() - .map(|name| (name.clone(), Value::Object(shadows.clone()))) - .collect::>(); - let mut theme = Map::new(); - theme.insert("colors".to_owned(), colors); - theme.insert("typography".to_owned(), Value::Object(typography)); - theme.insert("length".to_owned(), lengths); - theme.insert("shadow".to_owned(), Value::Object(shadows)); - let mut root = Map::new(); - root.insert("theme".to_owned(), Value::Object(theme)); - let mut output = serde_json::to_string_pretty(&Value::Object(root)).map_err(|_| { - DevupError::new( - ErrorCode::DevupThemeConflict, - "Failed to serialize devup.json.", - false, - ) - })?; - output.push('\n'); - - let completeness = if !skipped_styles.is_empty() || !unresolved_variables.is_empty() { - Completeness::ResolvedValuesOnly - } else if scope == ThemeScope::File && snapshot.local_complete && snapshot.used_remote_complete - { - Completeness::FullLocalPlusUsedRemote - } else if variable_count > 0 || !styles.is_empty() { - Completeness::UsedTokens - } else { - Completeness::ResolvedValuesOnly - }; - unresolved_variables.sort_by(|left, right| { - left.variable_id - .cmp(&right.variable_id) - .then_with(|| left.mode_id.cmp(&right.mode_id)) - .then_with(|| left.reason.cmp(&right.reason)) - }); - source_entries.sort_by(|left, right| left.json_pointer.cmp(&right.json_pointer)); - Ok(ThemeOutput { - json: output, - counts: ThemeCounts { - collections: snapshot.collections.len(), - variables: variable_count, - styles: styles.len() - skipped_styles.len(), - modes: snapshot - .collections - .iter() - .map(|collection| collection.modes.len()) - .sum(), - }, - completeness, - diagnostics, - conflicts, - unresolved_variables, - skipped_styles, - source_map: SourceMap { - version: 1, - entries: source_entries, - ..SourceMap::empty() - }, - }) -} - -// A missing size is representable (including percentage line height). An -// unreadable style or malformed metric is not; isolate it rather than invent -// a default typography entry or reject all the other resources. -fn typography_problem(style: &Value) -> Option { - let Some(object) = style.as_object() else { - return Some("style value is not an object".into()); - }; - fn unreadable(value: &Value) -> bool { - match value { - Value::Object(object) => { - ["$unsupported", "$undefined", "$truncated", "$error"] - .iter() - .any(|marker| object.contains_key(*marker)) - || object.values().any(unreadable) - } - Value::Array(values) => values.iter().any(unreadable), - _ => false, - } - } - if unreadable(style) || object.values().any(unreadable) { - return Some("style contains an unreadable field; recollect the style".into()); - } - for metric in ["lineHeight", "letterSpacing"] { - if let Some(value) = object.get(metric).filter(|v| !v.is_null()) { - let unit = value.get("unit").and_then(Value::as_str); - if unit != Some("AUTO") - && (value.get("value").and_then(Value::as_f64).is_none() - || !matches!(unit, Some("PERCENT" | "PIXELS"))) - { - return Some(format!("{metric} has no representable unit/value")); - } - } - } - None -} - -/// A text style's value as devup-ui reads it — the plugin's -/// `textStyleToTypography`. -/// -/// `fontSize` is in pixels; `lineHeight` from a percentage is the whole-pixel -/// advance Figma draws when the size is fixed, and the percentage itself -/// (`150%`) when the size is bound to a variable or missing - CSS scales it -/// with whatever size applies -, pixels as they are, and `normal` when Figma -/// sets it automatically; `letterSpacing` is `em` from a percentage and pixels as -/// they are; the weight is read off the font style's name; and a field the -/// style binds to a variable is that variable's token. Two departures from -/// the plugin, on purpose: `Bold Italic` is `700` and italic, where the -/// plugin reads the weight off the whole style name and gets `400`; and a -/// text case is the CSS `text-transform` value — `uppercase`, not `upper`. -fn typography_value(style: &Value, variable_names: &BTreeMap<&str, String>) -> Value { - let mut entry = Map::new(); - let font = style.get("fontName"); - let family = font - .and_then(|font| font.get("family")) - .and_then(Value::as_str); - let face = font - .and_then(|font| font.get("style")) - .and_then(Value::as_str) - .unwrap_or("Regular"); - if let Some(family) = family { - entry.insert("fontFamily".to_owned(), Value::String(family.to_owned())); - } - if face.contains("Italic") { - entry.insert("fontStyle".to_owned(), Value::String("italic".to_owned())); - } - entry.insert("fontWeight".to_owned(), Value::from(font_weight(face))); - if let Some(size) = style.get("fontSize").and_then(Value::as_f64) { - entry.insert("fontSize".to_owned(), Value::String(format_px(size))); - } - match style.get("textDecoration").and_then(Value::as_str) { - Some("UNDERLINE") => { - entry.insert( - "textDecoration".to_owned(), - Value::String("underline".to_owned()), - ); - } - Some("STRIKETHROUGH") => { - entry.insert( - "textDecoration".to_owned(), - Value::String("line-through".to_owned()), - ); - } - _ => {} - } - let transform = match style.get("textCase").and_then(Value::as_str) { - Some("UPPER") => Some("uppercase"), - Some("LOWER") => Some("lowercase"), - Some("TITLE") => Some("capitalize"), - _ => None, - }; - if let Some(transform) = transform { - entry.insert( - "textTransform".to_owned(), - Value::String(transform.to_owned()), - ); - } - if let Some(line_height) = style.get("lineHeight") { - let unit = line_height.get("unit").and_then(Value::as_str); - let value = line_height.get("value").and_then(Value::as_f64); - let written = match (unit, value) { - (Some("AUTO"), _) => Some(Value::String("normal".to_owned())), - (Some("PERCENT"), Some(percent)) => Some(Value::String( - match style - .get("fontSize") - .and_then(Value::as_f64) - .filter(|_| style.pointer("/boundVariables/fontSize").is_none()) - { - Some(size) => format_px((size * percent / 100.0).round()), - None => format_percent(percent), - }, - )), - (Some(_), Some(pixels)) => Some(Value::String(format_px(pixels))), - _ => None, - }; - if let Some(written) = written { - entry.insert("lineHeight".to_owned(), written); - } - } - if let Some(spacing) = style.get("letterSpacing") { - let unit = spacing.get("unit").and_then(Value::as_str); - let value = spacing.get("value").and_then(Value::as_f64); - let written = match (unit, value) { - (Some("PERCENT"), Some(percent)) => Some(format!("{}em", percent.round() / 100.0)), - (Some(_), Some(pixels)) => Some(format_px(pixels)), - _ => None, - }; - if let Some(written) = written { - entry.insert("letterSpacing".to_owned(), Value::String(written)); - } - } - if let Some(bound) = style.get("boundVariables").and_then(Value::as_object) { - for field in [ - "fontFamily", - "fontSize", - "fontStyle", - "fontWeight", - "letterSpacing", - "lineHeight", - ] { - if let Some(token) = bound - .get(field) - .and_then(|alias| alias.get("id")) - .and_then(Value::as_str) - .and_then(|id| variable_names.get(id)) - { - entry.insert(field.to_owned(), Value::String(format!("${token}"))); - } - } - } - Value::Object(entry) -} - -/// The weight a font style's name means — the plugin's `getFontWeight`, -/// with `Italic` set aside first so `Bold Italic` is still bold. -fn font_weight(face: &str) -> u32 { - let name = face - .replace("Italic", "") - .replace([' ', '-', '_'], "") - .to_ascii_lowercase(); - match name.as_str() { - "thin" | "hairline" => 100, - "extralight" | "ultralight" => 200, - "light" => 300, - "" | "normal" | "regular" | "book" => 400, - "medium" => 500, - "semibold" | "demibold" => 600, - "bold" => 700, - "extrabold" | "ultrabold" => 800, - "black" | "heavy" => 900, - other => match other.parse::() { - Ok(number) if (1..=9).contains(&number) => number * 100, - Ok(number) => number, - Err(_) => 400, - }, - } -} - -/// An effect style's visible shadows as one CSS `box-shadow` — the plugin's -/// `effectStyleToCssShadow`. `None` when it casts none. -fn shadow_value(effects: &Value) -> Option { - let parts = effects - .as_array()? - .iter() - .filter(|effect| effect.get("visible").and_then(Value::as_bool) != Some(false)) - .filter_map(|effect| { - let kind = effect.get("type").and_then(Value::as_str)?; - let inset = match kind { - "DROP_SHADOW" => "", - "INNER_SHADOW" => "inset ", - _ => return None, - }; - let offset = effect.get("offset"); - let x = offset - .and_then(|offset| offset.get("x")) - .and_then(Value::as_f64) - .unwrap_or(0.0); - let y = offset - .and_then(|offset| offset.get("y")) - .and_then(Value::as_f64) - .unwrap_or(0.0); - let radius = effect.get("radius").and_then(Value::as_f64).unwrap_or(0.0); - let spread = effect.get("spread").and_then(Value::as_f64).unwrap_or(0.0); - let color = color_value(effect.get("color")?)?; - let length = |value: f64| { - if value == 0.0 { - "0".to_owned() - } else { - format_px(value) - } - }; - Some(format!( - "{inset}{} {} {} {} {color}", - length(x), - length(y), - length(radius), - length(spread) - )) - }) - .collect::>(); - (!parts.is_empty()).then(|| parts.join(", ")) -} - -/// One entry of `typography` or `shadow` from its six breakpoint slots — the -/// plugin's reduction: one slot filled is the value alone; the first slot -/// empty is filled with the first value there is, since a mobile-first array -/// has to start somewhere; and the trailing empty slots are dropped, which -/// the plugin does only for an array it had to fill. -fn responsive_entry(slots: [Option; 6]) -> Option { - let filled = slots.iter().filter(|slot| slot.is_some()).count(); - if filled == 0 { - return None; - } - if filled == 1 { - return slots.into_iter().flatten().next(); - } - let mut values = slots.to_vec(); - if values[0].is_none() { - values[0] = slots.iter().flatten().next().cloned(); - } - while values.last().is_some_and(Option::is_none) { - values.pop(); - } - Some(Value::Array( - values - .into_iter() - .map(|slot| slot.unwrap_or(Value::Null)) - .collect(), - )) -} - -fn has_web_syntax(variable: &VariableDefinition) -> bool { - variable - .code_syntax - .get("WEB") - .is_some_and(|syntax| !syntax.trim().is_empty()) -} - -fn selected_variable_ids( - snapshot: &VariableSnapshot, - scope: ThemeScope, - variables: &BTreeMap<&str, &VariableDefinition>, -) -> Option> { - if scope == ThemeScope::File || snapshot.used_variable_ids.is_empty() { - return None; - } - let mut selected = snapshot - .used_variable_ids - .iter() - .cloned() - .collect::>(); - let mut pending = selected.iter().cloned().collect::>(); - while let Some(variable_id) = pending.pop() { - let Some(variable) = variables.get(variable_id.as_str()) else { - continue; - }; - for value in variable.values_by_mode.values() { - if value.get("type").and_then(Value::as_str) == Some("VARIABLE_ALIAS") - && let Some(alias_id) = value.get("id").and_then(Value::as_str) - && selected.insert(alias_id.to_owned()) - { - pending.push(alias_id.to_owned()); - } - } - } - Some(selected) -} - -fn value_hash(value: &Value) -> String { - Sha256::digest(serde_json::to_vec(value).unwrap_or_default()) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - -pub fn variable_snapshot_from_result( - result: &UpstreamResult, -) -> Result { - find_variable_snapshot(&result.raw).ok_or_else(|| { - DevupError::new( - ErrorCode::DevupThemeConflict, - "Variable snapshot was not found in the Figma MCP response.", - false, - ) - }) -} - -/// Whether a value is a variable snapshot by its own shape, rather than by -/// holding anything. A frame that binds no variable and uses no style still -/// answers with the snapshot's fields, all empty - and that is a theme with -/// nothing in it, not a failure to find one. All three fields have to be -/// there: an empty `VariableSnapshot` deserializes from almost any object, -/// and without this the search would seize on the first one it walked into. -fn names_a_variable_snapshot(value: &Value) -> bool { - ["collections", "variables", "styles"] - .into_iter() - .all(|field| value.get(field).is_some_and(Value::is_array)) -} - -fn find_variable_snapshot(value: &Value) -> Option { - if let Ok(snapshot) = serde_json::from_value::(value.clone()) - && (!snapshot.collections.is_empty() - || !snapshot.variables.is_empty() - || !snapshot.styles.is_empty() - || names_a_variable_snapshot(value)) - { - return Some(snapshot); - } - match value { - Value::Object(object) => { - if let Some(Value::String(text)) = object.get("text") - && let Ok(value) = serde_json::from_str::(text) - && let Some(snapshot) = find_variable_snapshot(&value) - { - return Some(snapshot); - } - object.values().find_map(find_variable_snapshot) - } - Value::Array(values) => values.iter().find_map(find_variable_snapshot), - Value::String(text) => serde_json::from_str::(text) - .ok() - .and_then(|value| find_variable_snapshot(&value)), - _ => None, - } -} - -fn resolve_value<'a>( - variable: &'a VariableDefinition, - mode: &str, - variables: &'a BTreeMap<&str, &VariableDefinition>, - visiting: &mut HashSet, -) -> Option<&'a Value> { - if !visiting.insert(variable.id.clone()) { - return None; - } - let value = variable.values_by_mode.get(mode)?; - if value.get("type").and_then(Value::as_str) == Some("VARIABLE_ALIAS") { - let alias = value.get("id").and_then(Value::as_str)?; - let target = variables.get(alias)?; - // An alias into another collection is read in that collection's mode, - // which this mode id is not. A collection with one mode has one value - // to read; with several, which one applies is the consumer's to set. - let mode = if target.values_by_mode.contains_key(mode) { - mode - } else if target.variable_collection_id != variable.variable_collection_id - && target.values_by_mode.len() == 1 - { - target.values_by_mode.keys().next()?.as_str() - } else { - return None; - }; - resolve_value(target, mode, variables, visiting) - } else { - Some(value) - } -} - -fn color_value(value: &Value) -> Option { - let channel = |name: &str| -> Option { - Some((value.get(name)?.as_f64()?.clamp(0.0, 1.0) * 255.0).round() as u8) - }; - let alpha = value.get("a").and_then(Value::as_f64).unwrap_or(1.0); - if alpha < 1.0 { - Some(format!( - "#{:02x}{:02x}{:02x}{:02x}", - channel("r")?, - channel("g")?, - channel("b")?, - (alpha.clamp(0.0, 1.0) * 255.0).round() as u8 - )) - } else { - Some(format!( - "#{:02x}{:02x}{:02x}", - channel("r")?, - channel("g")?, - channel("b")? - )) - } -} - -/// A percentage as CSS writes it, to two decimals: Figma stores `130%` as -/// `129.99999523162842`. -fn format_percent(value: f64) -> String { - let rounded = (value * 100.0).round() / 100.0; - if rounded.fract() == 0.0 { - format!("{rounded:.0}%") - } else { - format!("{}%", format!("{rounded:.2}").trim_end_matches('0')) - } -} - -/// The breakpoint slot a variable mode named for one stands for: `mobile`, -/// `tablet` and `desktop` (or `pc`) at 0, 2 and 4, as in front of a style's -/// name, and a number at its own slot. -fn breakpoint_slot(name: &str) -> Option { - let name = name.trim().to_ascii_lowercase(); - match name.as_str() { - "mobile" => Some(0), - "tablet" => Some(2), - "desktop" | "pc" => Some(4), - _ => name.parse::().ok().filter(|slot| *slot <= 5), - } -} - -/// The slot of each of a collection's modes, in its order, when it has more -/// than one and every one is named for a different breakpoint. -fn breakpoint_slots(collection: &VariableCollection) -> Option> { - let slots = collection - .modes - .iter() - .map(|mode| breakpoint_slot(&mode.name)) - .collect::>>()?; - let distinct = slots.iter().collect::>().len() == slots.len(); - (slots.len() > 1 && distinct).then_some(slots) -} - -/// A length token from its breakpoint slots: the value alone when every -/// breakpoint has the same one, the responsive array otherwise. -fn responsive_length(slots: [Option; 6]) -> Option { - let mut filled = slots.iter().flatten(); - let first = filled.next()?.clone(); - if filled.all(|value| *value == first) { - return Some(first); - } - responsive_entry(slots) -} - -/// The size a text style's bound font size resolves to at each breakpoint: -/// when every mode of the variable's collection is named for a breakpoint, -/// each resolves, no two share a slot and the sizes differ. Otherwise `None`, -/// and the style keeps the variable's token. -fn font_size_by_breakpoint( - style: &Value, - variables: &BTreeMap<&str, &VariableDefinition>, - collections: &BTreeMap<&str, &VariableCollection>, -) -> Option> { - let id = style.pointer("/boundVariables/fontSize/id")?.as_str()?; - let variable = variables.get(id)?; - let collection = collections.get(variable.variable_collection_id.as_str())?; - let sizes = collection - .modes - .iter() - .zip(breakpoint_slots(collection)?) - .map(|(mode, slot)| { - let size = resolve_value(variable, &mode.mode_id, variables, &mut HashSet::new())?; - Some((slot, size.as_f64()?)) - }) - .collect::>>()?; - let differ = sizes.iter().any(|(_, size)| *size != sizes[0].1); - differ.then_some(sizes) -} - -/// The theme mode under which the breakpoint lengths are gathered until the -/// themes are known; `normalize_token` never makes a name like it. -const BREAKPOINTS: &str = "@breakpoints"; - -fn unresolved_alias(variable: &VariableDefinition, mode_id: &str) -> ThemeUnresolvedVariable { - ThemeUnresolvedVariable { - variable_id: variable.id.clone(), - mode_id: Some(mode_id.to_owned()), - reason: "alias-cycle-or-missing-target".to_owned(), - } -} - -fn alias_diagnostic(variable: &VariableDefinition) -> Diagnostic { - Diagnostic { - code: "DEVUP_THEME_ALIAS_CYCLE".to_owned(), - message: format!( - "Alias for variable '{}' could not be resolved safely.", - variable.name - ), - node_id: None, - severity: Some(DiagnosticSeverity::Warning), - resource_kind: Some("variable".to_owned()), - resource_id: Some(variable.id.clone()), - recoverable: Some(true), - ..Diagnostic::default() - } -} - -fn conflict_candidate(candidate: &ProjectedVariable) -> ThemeConflictCandidate { - ThemeConflictCandidate { - variable_id: candidate.variable_id.clone(), - collection_id: candidate.collection_id.clone(), - collection_name: candidate.collection_name.clone(), - raw_name: candidate.raw_name.clone(), - mode_id: candidate.mode_id.clone(), - source: candidate.source, - value_hash: value_hash(&candidate.value), - } -} - -fn conflict_diagnostic(kind: &str, mode: &str, token: &str, winner: &str) -> Diagnostic { - Diagnostic { - code: "DEVUP_THEME_TOKEN_CONFLICT".to_owned(), - message: format!( - "The same theme token had conflicting values; applied deterministic precedence: token={token}, mode={mode}" - ), - node_id: None, - severity: Some(DiagnosticSeverity::Warning), - property: Some(format!("theme.{kind}.{mode}.{token}")), - resource_kind: Some("variable".to_owned()), - resource_id: Some(winner.to_owned()), - fallback: Some("deterministic-winner".to_owned()), - recoverable: Some(true), - ..Diagnostic::default() - } -} - -fn format_px(value: f64) -> String { - if value.fract() == 0.0 { - format!("{value:.0}px") - } else { - format!("{value}px") - } -} +// Source sections share this module to preserve privacy and test names. +include!("devup_json/theme_generation.rs"); +include!("devup_json/responsive_entry.rs"); diff --git a/crates/devup-mcp-devup-ui/src/theme/devup_json/responsive_entry.rs b/crates/devup-mcp-devup-ui/src/theme/devup_json/responsive_entry.rs new file mode 100644 index 00000000..f28300a2 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/theme/devup_json/responsive_entry.rs @@ -0,0 +1,315 @@ + + +/// One entry of `typography` or `shadow` from its six breakpoint slots — the +/// plugin's reduction: one slot filled is the value alone; the first slot +/// empty is filled with the first value there is, since a mobile-first array +/// has to start somewhere; and the trailing empty slots are dropped, which +/// the plugin does only for an array it had to fill. +fn responsive_entry(slots: [Option; 6]) -> Option { + let filled = slots.iter().filter(|slot| slot.is_some()).count(); + if filled == 0 { + return None; + } + if filled == 1 { + return slots.into_iter().flatten().next(); + } + let mut values = slots.to_vec(); + if values[0].is_none() { + values[0] = slots.iter().flatten().next().cloned(); + } + while values.last().is_some_and(Option::is_none) { + values.pop(); + } + Some(Value::Array( + values + .into_iter() + .map(|slot| slot.unwrap_or(Value::Null)) + .collect(), + )) +} + +fn has_web_syntax(variable: &VariableDefinition) -> bool { + variable + .code_syntax + .get("WEB") + .is_some_and(|syntax| !syntax.trim().is_empty()) +} + +fn selected_variable_ids( + snapshot: &VariableSnapshot, + scope: ThemeScope, + variables: &BTreeMap<&str, &VariableDefinition>, +) -> Option> { + if scope == ThemeScope::File || snapshot.used_variable_ids.is_empty() { + return None; + } + let mut selected = snapshot + .used_variable_ids + .iter() + .cloned() + .collect::>(); + let mut pending = selected.iter().cloned().collect::>(); + while let Some(variable_id) = pending.pop() { + let Some(variable) = variables.get(variable_id.as_str()) else { + continue; + }; + for value in variable.values_by_mode.values() { + if value.get("type").and_then(Value::as_str) == Some("VARIABLE_ALIAS") + && let Some(alias_id) = value.get("id").and_then(Value::as_str) + && selected.insert(alias_id.to_owned()) + { + pending.push(alias_id.to_owned()); + } + } + } + Some(selected) +} + +fn value_hash(value: &Value) -> String { + Sha256::digest(serde_json::to_vec(value).unwrap_or_default()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub fn variable_snapshot_from_result( + result: &UpstreamResult, +) -> Result { + find_variable_snapshot(&result.raw).ok_or_else(|| { + DevupError::new( + ErrorCode::DevupThemeConflict, + "Variable snapshot was not found in the Figma MCP response.", + false, + ) + }) +} + +/// Whether a value is a variable snapshot by its own shape, rather than by +/// holding anything. A frame that binds no variable and uses no style still +/// answers with the snapshot's fields, all empty - and that is a theme with +/// nothing in it, not a failure to find one. All three fields have to be +/// there: an empty `VariableSnapshot` deserializes from almost any object, +/// and without this the search would seize on the first one it walked into. +fn names_a_variable_snapshot(value: &Value) -> bool { + ["collections", "variables", "styles"] + .into_iter() + .all(|field| value.get(field).is_some_and(Value::is_array)) +} + +fn find_variable_snapshot(value: &Value) -> Option { + if let Ok(snapshot) = serde_json::from_value::(value.clone()) + && (!snapshot.collections.is_empty() + || !snapshot.variables.is_empty() + || !snapshot.styles.is_empty() + || names_a_variable_snapshot(value)) + { + return Some(snapshot); + } + match value { + Value::Object(object) => { + if let Some(Value::String(text)) = object.get("text") + && let Ok(value) = serde_json::from_str::(text) + && let Some(snapshot) = find_variable_snapshot(&value) + { + return Some(snapshot); + } + object.values().find_map(find_variable_snapshot) + } + Value::Array(values) => values.iter().find_map(find_variable_snapshot), + Value::String(text) => serde_json::from_str::(text) + .ok() + .and_then(|value| find_variable_snapshot(&value)), + _ => None, + } +} + +fn resolve_value<'a>( + variable: &'a VariableDefinition, + mode: &str, + variables: &'a BTreeMap<&str, &VariableDefinition>, + visiting: &mut HashSet, +) -> Option<&'a Value> { + if !visiting.insert(variable.id.clone()) { + return None; + } + let value = variable.values_by_mode.get(mode)?; + if value.get("type").and_then(Value::as_str) == Some("VARIABLE_ALIAS") { + let alias = value.get("id").and_then(Value::as_str)?; + let target = variables.get(alias)?; + // An alias into another collection is read in that collection's mode, + // which this mode id is not. A collection with one mode has one value + // to read; with several, which one applies is the consumer's to set. + let mode = if target.values_by_mode.contains_key(mode) { + mode + } else if target.variable_collection_id != variable.variable_collection_id + && target.values_by_mode.len() == 1 + { + target.values_by_mode.keys().next()?.as_str() + } else { + return None; + }; + resolve_value(target, mode, variables, visiting) + } else { + Some(value) + } +} + +fn color_value(value: &Value) -> Option { + let channel = |name: &str| -> Option { + Some((value.get(name)?.as_f64()?.clamp(0.0, 1.0) * 255.0).round() as u8) + }; + let alpha = value.get("a").and_then(Value::as_f64).unwrap_or(1.0); + if alpha < 1.0 { + Some(format!( + "#{:02x}{:02x}{:02x}{:02x}", + channel("r")?, + channel("g")?, + channel("b")?, + (alpha.clamp(0.0, 1.0) * 255.0).round() as u8 + )) + } else { + Some(format!( + "#{:02x}{:02x}{:02x}", + channel("r")?, + channel("g")?, + channel("b")? + )) + } +} + +/// A percentage as CSS writes it, to two decimals: Figma stores `130%` as +/// `129.99999523162842`. +fn format_percent(value: f64) -> String { + let rounded = (value * 100.0).round() / 100.0; + if rounded.fract() == 0.0 { + format!("{rounded:.0}%") + } else { + format!("{}%", format!("{rounded:.2}").trim_end_matches('0')) + } +} + +/// The breakpoint slot a variable mode named for one stands for: `mobile`, +/// `tablet` and `desktop` (or `pc`) at 0, 2 and 4, as in front of a style's +/// name, and a number at its own slot. +fn breakpoint_slot(name: &str) -> Option { + let name = name.trim().to_ascii_lowercase(); + match name.as_str() { + "mobile" => Some(0), + "tablet" => Some(2), + "desktop" | "pc" => Some(4), + _ => name.parse::().ok().filter(|slot| *slot <= 5), + } +} + +/// The slot of each of a collection's modes, in its order, when it has more +/// than one and every one is named for a different breakpoint. +fn breakpoint_slots(collection: &VariableCollection) -> Option> { + let slots = collection + .modes + .iter() + .map(|mode| breakpoint_slot(&mode.name)) + .collect::>>()?; + let distinct = slots.iter().collect::>().len() == slots.len(); + (slots.len() > 1 && distinct).then_some(slots) +} + +/// A length token from its breakpoint slots: the value alone when every +/// breakpoint has the same one, the responsive array otherwise. +fn responsive_length(slots: [Option; 6]) -> Option { + let mut filled = slots.iter().flatten(); + let first = filled.next()?.clone(); + if filled.all(|value| *value == first) { + return Some(first); + } + responsive_entry(slots) +} + +/// The size a text style's bound font size resolves to at each breakpoint: +/// when every mode of the variable's collection is named for a breakpoint, +/// each resolves, no two share a slot and the sizes differ. Otherwise `None`, +/// and the style keeps the variable's token. +fn font_size_by_breakpoint( + style: &Value, + variables: &BTreeMap<&str, &VariableDefinition>, + collections: &BTreeMap<&str, &VariableCollection>, +) -> Option> { + let id = style.pointer("/boundVariables/fontSize/id")?.as_str()?; + let variable = variables.get(id)?; + let collection = collections.get(variable.variable_collection_id.as_str())?; + let sizes = collection + .modes + .iter() + .zip(breakpoint_slots(collection)?) + .map(|(mode, slot)| { + let size = resolve_value(variable, &mode.mode_id, variables, &mut HashSet::new())?; + Some((slot, size.as_f64()?)) + }) + .collect::>>()?; + let differ = sizes.iter().any(|(_, size)| *size != sizes[0].1); + differ.then_some(sizes) +} + +/// The theme mode under which the breakpoint lengths are gathered until the +/// themes are known; `normalize_token` never makes a name like it. +const BREAKPOINTS: &str = "@breakpoints"; + +fn unresolved_alias(variable: &VariableDefinition, mode_id: &str) -> ThemeUnresolvedVariable { + ThemeUnresolvedVariable { + variable_id: variable.id.clone(), + mode_id: Some(mode_id.to_owned()), + reason: "alias-cycle-or-missing-target".to_owned(), + } +} + +fn alias_diagnostic(variable: &VariableDefinition) -> Diagnostic { + Diagnostic { + code: "DEVUP_THEME_ALIAS_CYCLE".to_owned(), + message: format!( + "Alias for variable '{}' could not be resolved safely.", + variable.name + ), + node_id: None, + severity: Some(DiagnosticSeverity::Warning), + resource_kind: Some("variable".to_owned()), + resource_id: Some(variable.id.clone()), + recoverable: Some(true), + ..Diagnostic::default() + } +} + +fn conflict_candidate(candidate: &ProjectedVariable) -> ThemeConflictCandidate { + ThemeConflictCandidate { + variable_id: candidate.variable_id.clone(), + collection_id: candidate.collection_id.clone(), + collection_name: candidate.collection_name.clone(), + raw_name: candidate.raw_name.clone(), + mode_id: candidate.mode_id.clone(), + source: candidate.source, + value_hash: value_hash(&candidate.value), + } +} + +fn conflict_diagnostic(kind: &str, mode: &str, token: &str, winner: &str) -> Diagnostic { + Diagnostic { + code: "DEVUP_THEME_TOKEN_CONFLICT".to_owned(), + message: format!( + "The same theme token had conflicting values; applied deterministic precedence: token={token}, mode={mode}" + ), + node_id: None, + severity: Some(DiagnosticSeverity::Warning), + property: Some(format!("theme.{kind}.{mode}.{token}")), + resource_kind: Some("variable".to_owned()), + resource_id: Some(winner.to_owned()), + fallback: Some("deterministic-winner".to_owned()), + recoverable: Some(true), + ..Diagnostic::default() + } +} + +fn format_px(value: f64) -> String { + if value.fract() == 0.0 { + format!("{value:.0}px") + } else { + format!("{value}px") + } +} diff --git a/crates/devup-mcp-devup-ui/src/theme/devup_json/theme_generation.rs b/crates/devup-mcp-devup-ui/src/theme/devup_json/theme_generation.rs new file mode 100644 index 00000000..25fcf366 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/theme/devup_json/theme_generation.rs @@ -0,0 +1,971 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; + +use devup_mcp_figma::{DevupError, Diagnostic, DiagnosticSeverity, ErrorCode, UpstreamResult}; + +use super::tokens::{normalize_token, style_token, variable_token}; +use crate::provenance::{ProvenanceEntry, SourceMap, json_pointer_segment}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ThemeScope { + Node, + Page, + File, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Completeness { + FullLocalPlusUsedRemote, + UsedTokens, + ResolvedValuesOnly, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ThemeCounts { + pub collections: usize, + pub variables: usize, + pub styles: usize, + pub modes: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VariableMode { + pub mode_id: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VariableCollection { + pub id: String, + pub name: String, + pub default_mode_id: String, + pub modes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VariableDefinition { + pub id: String, + pub name: String, + pub resolved_type: String, + pub variable_collection_id: String, + #[serde(default)] + pub code_syntax: BTreeMap, + #[serde(default)] + pub values_by_mode: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VariableStyle { + pub id: String, + pub name: String, + pub style_type: String, + pub value: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VariableSnapshot { + #[serde(default)] + pub collections: Vec, + #[serde(default)] + pub variables: Vec, + #[serde(default)] + pub styles: Vec, + #[serde(default)] + pub used_remote_variables: Vec, + #[serde(default)] + pub used_variable_ids: Vec, + #[serde(default)] + pub used_style_ids: Vec, + #[serde(default)] + pub local_complete: bool, + #[serde(default)] + pub used_remote_complete: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ThemeOutput { + pub json: String, + pub counts: ThemeCounts, + pub completeness: Completeness, + pub diagnostics: Vec, + pub conflicts: Vec, + pub unresolved_variables: Vec, + #[serde(default)] + pub skipped_styles: Vec, + pub source_map: SourceMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ThemeSkippedStyle { + pub style_id: String, + pub name: String, + pub reason: String, + #[serde(default)] + pub node_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ThemeUnresolvedVariable { + pub variable_id: String, + pub mode_id: Option, + pub reason: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ThemeVariableSource { + Local, + UsedRemote, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ThemeConflictCandidate { + pub variable_id: String, + pub collection_id: String, + pub collection_name: String, + pub raw_name: String, + pub mode_id: String, + pub source: ThemeVariableSource, + pub value_hash: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ThemeConflict { + pub token: String, + pub mode: String, + pub kind: String, + pub winner_variable_id: String, + pub candidates: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct VariableCandidate<'a> { + variable: &'a VariableDefinition, + source: ThemeVariableSource, +} + +#[derive(Debug, Clone)] +struct ProjectedVariable { + variable_id: String, + collection_id: String, + collection_name: String, + raw_name: String, + mode_id: String, + source: ThemeVariableSource, + value: Value, + resolution: String, +} + +pub fn generate_devup_json( + snapshot: &VariableSnapshot, + scope: ThemeScope, +) -> Result { + let mut diagnostics = Vec::new(); + let mut conflicts = Vec::new(); + let mut unresolved_variables = Vec::new(); + let mut skipped_styles = Vec::new(); + let mut source_entries = Vec::new(); + let collections = snapshot + .collections + .iter() + .map(|collection| (collection.id.as_str(), collection)) + .collect::>(); + let mut variable_candidates = snapshot + .variables + .iter() + .map(|variable| VariableCandidate { + variable, + source: ThemeVariableSource::Local, + }) + .chain( + snapshot + .used_remote_variables + .iter() + .map(|variable| VariableCandidate { + variable, + source: ThemeVariableSource::UsedRemote, + }), + ) + .collect::>(); + variable_candidates.sort_by(|left, right| { + let left_collection = collections + .get(left.variable.variable_collection_id.as_str()) + .map(|collection| collection.name.as_str()) + .unwrap_or_default(); + let right_collection = collections + .get(right.variable.variable_collection_id.as_str()) + .map(|collection| collection.name.as_str()) + .unwrap_or_default(); + let left_explicit = has_web_syntax(left.variable); + let right_explicit = has_web_syntax(right.variable); + right_explicit + .cmp(&left_explicit) + .then_with(|| left.source.cmp(&right.source)) + .then_with(|| left_collection.cmp(right_collection)) + .then_with(|| left.variable.name.cmp(&right.variable.name)) + .then_with(|| left.variable.id.cmp(&right.variable.id)) + }); + let mut variables = BTreeMap::new(); + for candidate in &variable_candidates { + variables + .entry(candidate.variable.id.as_str()) + .or_insert(candidate.variable); + } + let selected_variable_ids = selected_variable_ids(snapshot, scope, &variables); + if let Some(selected) = &selected_variable_ids { + variable_candidates.retain(|candidate| selected.contains(&candidate.variable.id)); + } + let variable_count = variable_candidates.len(); + let mut projected = BTreeMap::<(String, String, String), Vec>::new(); + let mut colors: BTreeMap> = BTreeMap::new(); + let mut lengths: BTreeMap> = BTreeMap::new(); + + for candidate in variable_candidates { + let variable = candidate.variable; + let Some(collection) = collections.get(variable.variable_collection_id.as_str()) else { + unresolved_variables.push(ThemeUnresolvedVariable { + variable_id: variable.id.clone(), + mode_id: None, + reason: "collection-missing".to_owned(), + }); + diagnostics.push(Diagnostic { + code: "DEVUP_THEME_COLLECTION_MISSING".to_owned(), + message: format!("Collection for variable '{}' was not found.", variable.name), + node_id: None, + severity: Some(DiagnosticSeverity::Warning), + resource_kind: Some("variable".to_owned()), + resource_id: Some(variable.id.clone()), + recoverable: Some(true), + ..Diagnostic::default() + }); + continue; + }; + let token = variable_token( + &variable.name, + variable.code_syntax.get("WEB").map(String::as_str), + ); + let is_alias = |mode_id: &str| { + variable + .values_by_mode + .get(mode_id) + .and_then(|value| value.get("type")) + .and_then(Value::as_str) + == Some("VARIABLE_ALIAS") + }; + // A length in a collection whose modes are breakpoints - Mobile, + // Tablet, PC - is one value per breakpoint. devup-ui reads a length + // token as a responsive array; under a mode's own name it would be a + // `data-theme` no page sets, and undefined everywhere else. + if variable.resolved_type == "FLOAT" + && let Some(slots) = breakpoint_slots(collection) + { + let mut values: [Option; 6] = Default::default(); + let mut complete = true; + for (mode, slot) in collection.modes.iter().zip(slots) { + match resolve_value(variable, &mode.mode_id, &variables, &mut HashSet::new()) { + Some(value) => match value.as_f64() { + Some(number) => values[slot] = Some(Value::String(format_px(number))), + None => complete = false, + }, + None => { + complete = false; + unresolved_variables.push(unresolved_alias(variable, &mode.mode_id)); + diagnostics.push(alias_diagnostic(variable)); + } + } + } + if complete && let Some(value) = responsive_length(values) { + projected + .entry(("length".to_owned(), BREAKPOINTS.to_owned(), token.clone())) + .or_default() + .push(ProjectedVariable { + variable_id: variable.id.clone(), + collection_id: variable.variable_collection_id.clone(), + collection_name: collection.name.clone(), + raw_name: variable.name.clone(), + mode_id: collection.default_mode_id.clone(), + source: candidate.source, + value, + resolution: if collection.modes.iter().any(|mode| is_alias(&mode.mode_id)) { + "alias".to_owned() + } else { + "variable".to_owned() + }, + }); + } + continue; + } + for mode in &collection.modes { + let mut visiting = HashSet::new(); + let Some(value) = resolve_value(variable, &mode.mode_id, &variables, &mut visiting) + else { + unresolved_variables.push(unresolved_alias(variable, &mode.mode_id)); + diagnostics.push(alias_diagnostic(variable)); + continue; + }; + let mode_name = normalize_token(&mode.name); + let projection = match variable.resolved_type.as_str() { + "COLOR" => color_value(value).map(|color| ("color", Value::String(color))), + "FLOAT" => value + .as_f64() + .map(|number| ("length", Value::String(format_px(number)))), + _ => None, + }; + if let Some((kind, value)) = projection { + projected + .entry((kind.to_owned(), mode_name, token.clone())) + .or_default() + .push(ProjectedVariable { + variable_id: variable.id.clone(), + collection_id: variable.variable_collection_id.clone(), + collection_name: collection.name.clone(), + raw_name: variable.name.clone(), + mode_id: mode.mode_id.clone(), + source: candidate.source, + value, + resolution: if is_alias(&mode.mode_id) { + "alias".to_owned() + } else { + "variable".to_owned() + }, + }); + } + } + } + + let mut breakpoint_lengths: BTreeMap = BTreeMap::new(); + let mut length_winners: BTreeMap<(String, String), ProjectedVariable> = BTreeMap::new(); + for ((kind, mode, token), candidates) in projected { + let Some(winner) = candidates.first() else { + continue; + }; + match kind.as_str() { + "color" => { + colors + .entry(mode.clone()) + .or_default() + .insert(token.clone(), winner.value.clone()); + } + "length" if mode == BREAKPOINTS => { + breakpoint_lengths.insert(token.clone(), winner.clone()); + } + "length" => { + lengths + .entry(mode.clone()) + .or_default() + .insert(token.clone(), winner.value.clone()); + length_winners.insert((mode.clone(), token.clone()), winner.clone()); + } + _ => continue, + } + let category = if kind == "color" { "colors" } else { "length" }; + if mode != BREAKPOINTS { + source_entries.push(ProvenanceEntry { + element_index: None, + calculation: None, + generated_property: None, + generated_range: None, + json_pointer: Some(format!( + "/theme/{}/{}/{}", + category, + json_pointer_segment(&mode), + json_pointer_segment(&token) + )), + node_id: None, + property: None, + variable_id: Some(winner.variable_id.clone()), + style_id: None, + asset_id: None, + resolution: winner.resolution.clone(), + }); + } + if candidates + .iter() + .skip(1) + .all(|candidate| candidate.value == winner.value) + { + continue; + } + conflicts.push(ThemeConflict { + token: token.clone(), + mode: mode.clone(), + kind: kind.clone(), + winner_variable_id: winner.variable_id.clone(), + candidates: candidates.iter().map(conflict_candidate).collect(), + }); + diagnostics.push(conflict_diagnostic( + &kind, + &mode, + &token, + &winner.variable_id, + )); + } + + // A text style is a `typography` entry and an effect style a `shadow` + // entry, each in the shape devup-ui reads and under the name the plugin's + // `styleNameToTypography` gives it. A style named for a breakpoint — + // `desktop/h1`, `3/bodyXlgBold` — is one slot of a responsive entry, and + // the slots of one name are gathered into an array. The raw style object + // used to be written as it came, and devup-ui's own plugin refused the + // file: `Invalid typography property value: Object {"unit": "PERCENT", + // "value": 120}`. + let mut typography_slots: BTreeMap; 6]> = BTreeMap::new(); + let mut shadow_slots: BTreeMap; 6]> = BTreeMap::new(); + let variable_names = variables + .values() + .map(|variable| { + ( + variable.id.as_str(), + variable_token( + &variable.name, + variable.code_syntax.get("WEB").map(String::as_str), + ), + ) + }) + .collect::>(); + let selected_style_ids = (scope != ThemeScope::File && !snapshot.used_style_ids.is_empty()) + .then(|| snapshot.used_style_ids.iter().collect::>()); + let styles = snapshot + .styles + .iter() + .filter(|style| { + selected_style_ids + .as_ref() + .is_none_or(|selected| selected.contains(&style.id)) + }) + .collect::>(); + for style in &styles { + if style.style_type == "TEXT" + && let Some(reason) = typography_problem(&style.value) + { + skipped_styles.push(ThemeSkippedStyle { + style_id: style.id.clone(), + name: style.name.clone(), + reason: reason.clone(), + node_ids: Vec::new(), + }); + diagnostics.push(Diagnostic { + code: "DEVUP_THEME_STYLE_SKIPPED".into(), + message: format!("Text style '{}' ({}) was skipped: {reason}", style.name, style.id), + resource_kind: Some("style".into()), resource_id: Some(style.id.clone()), + severity: Some(DiagnosticSeverity::Warning), recoverable: Some(true), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), + details: Some(json!({"styleName":style.name,"styleId":style.id,"reason":reason,"output":"devupJson"})), + ..Diagnostic::default() + }); + continue; + } + let (level, token) = style_token(&style.name); + let level = level.min(5); + match style.style_type.as_str() { + "TEXT" => { + let slots = typography_slots.entry(token.clone()).or_default(); + // A style whose name does not say a breakpoint, with its size + // bound to a variable whose modes do - Mobile, Tablet, PC - is + // one size per breakpoint: a slot for each mode, the size that + // mode resolves to in it. + let named_for_breakpoint = + style_token(&style.name) != (0, normalize_token(&style.name)); + let by_breakpoint = if named_for_breakpoint { + None + } else { + font_size_by_breakpoint(&style.value, &variables, &collections) + }; + if let Some(sizes) = by_breakpoint { + for (slot, size) in sizes { + if slots[slot].is_none() { + let mut value = typography_value(&style.value, &variable_names); + value["fontSize"] = Value::String(format_px(size)); + slots[slot] = Some(value); + } + } + } else if slots[level].is_none() { + // The first style seen for a slot keeps it, as in the plugin. + slots[level] = Some(typography_value(&style.value, &variable_names)); + } + source_entries.push(ProvenanceEntry { + element_index: None, + calculation: None, + generated_property: None, + generated_range: None, + json_pointer: Some(format!( + "/theme/typography/{}", + json_pointer_segment(&token) + )), + node_id: None, + property: None, + variable_id: None, + style_id: Some(style.id.clone()), + asset_id: None, + resolution: "style".to_owned(), + }); + } + "EFFECT" => { + let Some(shadow) = shadow_value(&style.value) else { + continue; + }; + let slots = shadow_slots.entry(token.clone()).or_default(); + if slots[level].is_none() { + slots[level] = Some(shadow); + } + source_entries.push(ProvenanceEntry { + element_index: None, + calculation: None, + generated_property: None, + generated_range: None, + json_pointer: Some(format!( + "/theme/shadow/default/{}", + json_pointer_segment(&token) + )), + node_id: None, + property: None, + variable_id: None, + style_id: Some(style.id.clone()), + asset_id: None, + resolution: "style".to_owned(), + }); + } + _ => {} + } + } + let typography = typography_slots + .into_iter() + .filter_map(|(token, slots)| responsive_entry(slots).map(|entry| (token, entry))) + .collect::>(); + let shadows = shadow_slots + .into_iter() + .filter_map(|(token, slots)| { + responsive_entry(slots.map(|slot| slot.map(Value::String))).map(|entry| (token, entry)) + }) + .collect::>(); + + // devup-ui takes the first theme written under `colors` as the default — + // the one in effect without a `data-theme` — so the order of the modes is + // not cosmetic. Sorted by name, `dark` came before `light`, and a file + // whose default mode is Light was handed to consumers dark. The + // collections' default modes go first, then the rest of their modes in + // the order the collections declare them, as the plugin writes them. + let mode_order = { + let mut order: Vec = Vec::new(); + let mut push = |name: String| { + if !order.contains(&name) { + order.push(name); + } + }; + for collection in collections.values() { + if let Some(default) = collection + .modes + .iter() + .find(|mode| mode.mode_id == collection.default_mode_id) + { + push(normalize_token(&default.name)); + } + } + for collection in collections.values() { + for mode in &collection.modes { + push(normalize_token(&mode.name)); + } + } + order + }; + let in_mode_order = |modes: BTreeMap>| { + let mut ordered = Map::new(); + for name in &mode_order { + if let Some(tokens) = modes.get(name) { + ordered.insert(name.clone(), json!(tokens)); + } + } + for (name, tokens) in modes { + if !ordered.contains_key(&name) { + ordered.insert(name, json!(tokens)); + } + } + Value::Object(ordered) + }; + let colors = in_mode_order(colors); + // Lengths and shadows do not vary by colour theme, but devup-ui keys them + // by one, so each colour theme gets a copy — the plugin replicates them + // the same way. A file with no colour themes keeps `default`. + let theme_names = colors + .as_object() + .map(|themes| themes.keys().cloned().collect::>()) + .filter(|names| !names.is_empty()) + .unwrap_or_else(|| vec!["default".to_owned()]); + let lengths = { + let ordered = in_mode_order(lengths); + let mut by_theme = ordered.as_object().cloned().unwrap_or_default(); + let first = by_theme + .iter() + .next() + .map(|(name, tokens)| (name.clone(), tokens.clone())); + let mut replicated = Map::new(); + let mut copied_from = BTreeMap::new(); + for name in &theme_names { + if let Some(tokens) = by_theme.remove(name) { + replicated.insert(name.clone(), tokens); + copied_from.insert(name.clone(), name.clone()); + } else if let Some((source, first)) = &first { + replicated.insert(name.clone(), first.clone()); + copied_from.insert(name.clone(), source.clone()); + } + } + for (name, tokens) in by_theme { + copied_from.insert(name.clone(), name.clone()); + replicated.insert(name, tokens); + } + if !breakpoint_lengths.is_empty() && replicated.is_empty() { + for name in &theme_names { + replicated.insert(name.clone(), Value::Object(Map::new())); + } + } + for (name, tokens) in &mut replicated { + let Some(tokens) = tokens.as_object_mut() else { + continue; + }; + for (token, breakpoint) in &breakpoint_lengths { + match tokens.get(token) { + None => { + tokens.insert(token.clone(), breakpoint.value.clone()); + source_entries.push(ProvenanceEntry { + element_index: None, + calculation: None, + generated_property: None, + generated_range: None, + json_pointer: Some(format!( + "/theme/length/{}/{}", + json_pointer_segment(name), + json_pointer_segment(token) + )), + node_id: None, + property: None, + variable_id: Some(breakpoint.variable_id.clone()), + style_id: None, + asset_id: None, + resolution: breakpoint.resolution.clone(), + }); + } + // The theme's own token of the same name keeps its place, + // as the first candidate of one name does, and says so. + Some(existing) if *existing != breakpoint.value => { + let own = copied_from.get(name).and_then(|source| { + length_winners.get(&(source.clone(), token.clone())) + }); + let winner_id = own.map_or_else(String::new, |own| own.variable_id.clone()); + conflicts.push(ThemeConflict { + token: token.clone(), + mode: name.clone(), + kind: "length".to_owned(), + winner_variable_id: winner_id.clone(), + candidates: own + .into_iter() + .chain([breakpoint]) + .map(conflict_candidate) + .collect(), + }); + diagnostics.push(conflict_diagnostic("length", name, token, &winner_id)); + } + Some(_) => {} + } + } + } + Value::Object(replicated) + }; + let shadows = theme_names + .iter() + .map(|name| (name.clone(), Value::Object(shadows.clone()))) + .collect::>(); + let mut theme = Map::new(); + theme.insert("colors".to_owned(), colors); + theme.insert("typography".to_owned(), Value::Object(typography)); + theme.insert("length".to_owned(), lengths); + theme.insert("shadow".to_owned(), Value::Object(shadows)); + let mut root = Map::new(); + root.insert("theme".to_owned(), Value::Object(theme)); + let mut output = serde_json::to_string_pretty(&Value::Object(root)).map_err(|_| { + DevupError::new( + ErrorCode::DevupThemeConflict, + "Failed to serialize devup.json.", + false, + ) + })?; + output.push('\n'); + + let completeness = if !skipped_styles.is_empty() || !unresolved_variables.is_empty() { + Completeness::ResolvedValuesOnly + } else if scope == ThemeScope::File && snapshot.local_complete && snapshot.used_remote_complete + { + Completeness::FullLocalPlusUsedRemote + } else if variable_count > 0 || !styles.is_empty() { + Completeness::UsedTokens + } else { + Completeness::ResolvedValuesOnly + }; + unresolved_variables.sort_by(|left, right| { + left.variable_id + .cmp(&right.variable_id) + .then_with(|| left.mode_id.cmp(&right.mode_id)) + .then_with(|| left.reason.cmp(&right.reason)) + }); + source_entries.sort_by(|left, right| left.json_pointer.cmp(&right.json_pointer)); + Ok(ThemeOutput { + json: output, + counts: ThemeCounts { + collections: snapshot.collections.len(), + variables: variable_count, + styles: styles.len() - skipped_styles.len(), + modes: snapshot + .collections + .iter() + .map(|collection| collection.modes.len()) + .sum(), + }, + completeness, + diagnostics, + conflicts, + unresolved_variables, + skipped_styles, + source_map: SourceMap { + version: 1, + entries: source_entries, + ..SourceMap::empty() + }, + }) +} + +// A missing size is representable (including percentage line height). An +// unreadable style or malformed metric is not; isolate it rather than invent +// a default typography entry or reject all the other resources. +fn typography_problem(style: &Value) -> Option { + let Some(object) = style.as_object() else { + return Some("style value is not an object".into()); + }; + fn unreadable(value: &Value) -> bool { + match value { + Value::Object(object) => { + ["$unsupported", "$undefined", "$truncated", "$error"] + .iter() + .any(|marker| object.contains_key(*marker)) + || object.values().any(unreadable) + } + Value::Array(values) => values.iter().any(unreadable), + _ => false, + } + } + if unreadable(style) || object.values().any(unreadable) { + return Some("style contains an unreadable field; recollect the style".into()); + } + for metric in ["lineHeight", "letterSpacing"] { + if let Some(value) = object.get(metric).filter(|v| !v.is_null()) { + let unit = value.get("unit").and_then(Value::as_str); + if unit != Some("AUTO") + && (value.get("value").and_then(Value::as_f64).is_none() + || !matches!(unit, Some("PERCENT" | "PIXELS"))) + { + return Some(format!("{metric} has no representable unit/value")); + } + } + } + None +} + +/// A text style's value as devup-ui reads it — the plugin's +/// `textStyleToTypography`. +/// +/// `fontSize` is in pixels; `lineHeight` from a percentage is the whole-pixel +/// advance Figma draws when the size is fixed, and the percentage itself +/// (`150%`) when the size is bound to a variable or missing - CSS scales it +/// with whatever size applies -, pixels as they are, and `normal` when Figma +/// sets it automatically; `letterSpacing` is `em` from a percentage and pixels as +/// they are; the weight is read off the font style's name; and a field the +/// style binds to a variable is that variable's token. Two departures from +/// the plugin, on purpose: `Bold Italic` is `700` and italic, where the +/// plugin reads the weight off the whole style name and gets `400`; and a +/// text case is the CSS `text-transform` value — `uppercase`, not `upper`. +fn typography_value(style: &Value, variable_names: &BTreeMap<&str, String>) -> Value { + let mut entry = Map::new(); + let font = style.get("fontName"); + let family = font + .and_then(|font| font.get("family")) + .and_then(Value::as_str); + let face = font + .and_then(|font| font.get("style")) + .and_then(Value::as_str) + .unwrap_or("Regular"); + if let Some(family) = family { + entry.insert("fontFamily".to_owned(), Value::String(family.to_owned())); + } + if face.contains("Italic") { + entry.insert("fontStyle".to_owned(), Value::String("italic".to_owned())); + } + entry.insert("fontWeight".to_owned(), Value::from(font_weight(face))); + if let Some(size) = style.get("fontSize").and_then(Value::as_f64) { + entry.insert("fontSize".to_owned(), Value::String(format_px(size))); + } + match style.get("textDecoration").and_then(Value::as_str) { + Some("UNDERLINE") => { + entry.insert( + "textDecoration".to_owned(), + Value::String("underline".to_owned()), + ); + } + Some("STRIKETHROUGH") => { + entry.insert( + "textDecoration".to_owned(), + Value::String("line-through".to_owned()), + ); + } + _ => {} + } + let transform = match style.get("textCase").and_then(Value::as_str) { + Some("UPPER") => Some("uppercase"), + Some("LOWER") => Some("lowercase"), + Some("TITLE") => Some("capitalize"), + _ => None, + }; + if let Some(transform) = transform { + entry.insert( + "textTransform".to_owned(), + Value::String(transform.to_owned()), + ); + } + if let Some(line_height) = style.get("lineHeight") { + let unit = line_height.get("unit").and_then(Value::as_str); + let value = line_height.get("value").and_then(Value::as_f64); + let written = match (unit, value) { + (Some("AUTO"), _) => Some(Value::String("normal".to_owned())), + (Some("PERCENT"), Some(percent)) => Some(Value::String( + match style + .get("fontSize") + .and_then(Value::as_f64) + .filter(|_| style.pointer("/boundVariables/fontSize").is_none()) + { + Some(size) => format_px((size * percent / 100.0).round()), + None => format_percent(percent), + }, + )), + (Some(_), Some(pixels)) => Some(Value::String(format_px(pixels))), + _ => None, + }; + if let Some(written) = written { + entry.insert("lineHeight".to_owned(), written); + } + } + if let Some(spacing) = style.get("letterSpacing") { + let unit = spacing.get("unit").and_then(Value::as_str); + let value = spacing.get("value").and_then(Value::as_f64); + let written = match (unit, value) { + (Some("PERCENT"), Some(percent)) => Some(format!("{}em", percent.round() / 100.0)), + (Some(_), Some(pixels)) => Some(format_px(pixels)), + _ => None, + }; + if let Some(written) = written { + entry.insert("letterSpacing".to_owned(), Value::String(written)); + } + } + if let Some(bound) = style.get("boundVariables").and_then(Value::as_object) { + for field in [ + "fontFamily", + "fontSize", + "fontStyle", + "fontWeight", + "letterSpacing", + "lineHeight", + ] { + if let Some(token) = bound + .get(field) + .and_then(|alias| alias.get("id")) + .and_then(Value::as_str) + .and_then(|id| variable_names.get(id)) + { + entry.insert(field.to_owned(), Value::String(format!("${token}"))); + } + } + } + Value::Object(entry) +} + +/// The weight a font style's name means — the plugin's `getFontWeight`, +/// with `Italic` set aside first so `Bold Italic` is still bold. +fn font_weight(face: &str) -> u32 { + let name = face + .replace("Italic", "") + .replace([' ', '-', '_'], "") + .to_ascii_lowercase(); + match name.as_str() { + "thin" | "hairline" => 100, + "extralight" | "ultralight" => 200, + "light" => 300, + "" | "normal" | "regular" | "book" => 400, + "medium" => 500, + "semibold" | "demibold" => 600, + "bold" => 700, + "extrabold" | "ultrabold" => 800, + "black" | "heavy" => 900, + other => match other.parse::() { + Ok(number) if (1..=9).contains(&number) => number * 100, + Ok(number) => number, + Err(_) => 400, + }, + } +} + +/// An effect style's visible shadows as one CSS `box-shadow` — the plugin's +/// `effectStyleToCssShadow`. `None` when it casts none. +fn shadow_value(effects: &Value) -> Option { + let parts = effects + .as_array()? + .iter() + .filter(|effect| effect.get("visible").and_then(Value::as_bool) != Some(false)) + .filter_map(|effect| { + let kind = effect.get("type").and_then(Value::as_str)?; + let inset = match kind { + "DROP_SHADOW" => "", + "INNER_SHADOW" => "inset ", + _ => return None, + }; + let offset = effect.get("offset"); + let x = offset + .and_then(|offset| offset.get("x")) + .and_then(Value::as_f64) + .unwrap_or(0.0); + let y = offset + .and_then(|offset| offset.get("y")) + .and_then(Value::as_f64) + .unwrap_or(0.0); + let radius = effect.get("radius").and_then(Value::as_f64).unwrap_or(0.0); + let spread = effect.get("spread").and_then(Value::as_f64).unwrap_or(0.0); + let color = color_value(effect.get("color")?)?; + let length = |value: f64| { + if value == 0.0 { + "0".to_owned() + } else { + format_px(value) + } + }; + Some(format!( + "{inset}{} {} {} {} {color}", + length(x), + length(y), + length(radius), + length(spread) + )) + }) + .collect::>(); + (!parts.is_empty()).then(|| parts.join(", ")) +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/src/ui_layout.rs b/crates/devup-mcp-devup-ui/src/ui_layout.rs index 1b8a4ca9..6a91afd2 100644 --- a/crates/devup-mcp-devup-ui/src/ui_layout.rs +++ b/crates/devup-mcp-devup-ui/src/ui_layout.rs @@ -308,7 +308,7 @@ impl Collector<'_> { ( "right-aligned-but-held-left", format!( - "{} is drawn {}px from the right edge of a container that follows the viewport, but held by its left edge, so at any other width it stays where it was drawn. If it belongs to the right edge, hold it there (right=\"{}px\").", + include_str!("text/ui_layout-check_held_left-d6e0fe40.md"), describe_short(node), round(right_gap), round(right_gap), @@ -318,7 +318,7 @@ impl Collector<'_> { ( "centered-but-held-left", format!( - "{} is drawn with equal gaps on both sides of a container that follows the viewport, but held by its left edge, so it drifts off-centre at any other width. Centre it (left=\"50%\" with transform=\"translateX(-50%)\") or stretch it between the gaps (left and right).", + include_str!("text/ui_layout-check_held_left-6cb7140a.md"), describe_short(node), ), ) @@ -399,7 +399,7 @@ impl Collector<'_> { "rule": "fixed-length-line-mask", "confidence": "high", "elements": [self.describe(element, placement)], - "message": "A rule drawn as a mask keeps its drawn length while this box follows its parent's width, so the line stops short of the box at any other width. Size the mask to the box, or draw the rule as a filled box of the same colour and weight.", + "message": include_str!("text/ui_layout-check_line_mask-ab57b482.md"), })); } } diff --git a/crates/devup-mcp-devup-ui/src/ui_structure.rs b/crates/devup-mcp-devup-ui/src/ui_structure.rs index f2831004..44920094 100644 --- a/crates/devup-mcp-devup-ui/src/ui_structure.rs +++ b/crates/devup-mcp-devup-ui/src/ui_structure.rs @@ -880,7 +880,7 @@ fn query_findings( } else { None }; - out.push(finding(file,span,"react-query-states",if reason.is_some(){Severity::Info}else{Severity::Error},reason.unwrap_or("Query data is dereferenced during rendering without local isPending and isError handling; data can be undefined while pending. This does not assert that external boundaries are absent."),"Handle isPending and isError before rendering data, or verify the boundary or child responsible for those states.")); + out.push(finding(file,span,"react-query-states",if reason.is_some(){Severity::Info}else{Severity::Error},reason.unwrap_or(include_str!("text/ui_structure-query_findings-60bd4f27.md")),"Handle isPending and isError before rendering data, or verify the boundary or child responsible for those states.")); } } diff --git a/crates/devup-mcp-devup-ui/src/ui_validate.rs b/crates/devup-mcp-devup-ui/src/ui_validate.rs index 13280cda..06f56164 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate.rs @@ -32,1192 +32,6 @@ //! without token suggestions. Empty token categories are summarized once //! in `themeNotes`, avoiding repeated, impossible advice (D11). -use std::collections::BTreeSet; -#[path = "ui_validate/blur_layers.rs"] -mod blur_layers; -#[path = "ui_validate/site.rs"] -pub mod site; - -use oxc_allocator::Allocator; -use oxc_ast::ast::{ - Argument, CallExpression, Expression, JSXAttribute, JSXAttributeItem, 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, TokenCategory, 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 { - Info, - 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], - #[serde(flatten)] - pub context: serde_json::Map, - pub message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub suggestion: Option, -} - -/// The result of one validation, and what each field does and does not -/// claim. The MCP tool publishes these four under the same names -/// (`ok`, `violations`, `checkedTokens`, `availableTokenCount`), and a -/// caller who reads them as anything other than the below will draw the -/// wrong conclusion — `ok: true` beside a non-empty `violations` is -/// correct output, not a contradiction. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct UiValidation { - /// Whether this TSX may be used as it stands. - /// - /// `false` when any violation is [`Severity::Error`] — a syntax - /// failure, a `$token` the theme does not define, a prop the - /// primitive does not take, or a runtime value where devup-ui - /// extracts at build time. Each of those breaks the build or the - /// screen. - /// - /// `true` is therefore compatible with a non-empty `violations`: - /// warnings are values that would be better written as a token, and - /// code that ignores every one of them still compiles and renders. - /// Pass `strict` to fail on warnings too. Info alone never fails `ok`. - pub ok: bool, - /// Every finding, in source order per rule. `severity` is what - /// decides `ok`; the count alone does not. - pub violations: Vec, - /// One note per empty token category encountered by the hardcoded rules. - /// An unavailable theme is handled by the caller's guardrail instead. - pub theme_notes: Vec, - /// How many `$token` references this TSX makes — i.e. how many string - /// attribute values began with `$` and were looked up. It counts the - /// *code's* references, not the theme's tokens, and it counts them - /// whether or not a theme was available to check them against: with - /// no theme, `checked_tokens` is still the number of references while - /// `unknown-token` is skipped entirely (a skipped check must not read - /// as a passed one). - pub checked_tokens: usize, - /// How many distinct tokens the project's `devup.json` defines across - /// all four axes, merged by name — the size of the vocabulary the - /// TSX had to choose from. `0` when no theme was available. - /// - /// Not comparable with `checked_tokens`: one counts references in the - /// code, the other definitions in the theme. - 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 { - validate_source(tsx, theme, strict, SourceType::tsx()) -} - -fn validate_source( - tsx: &str, - theme: Option<&ProjectTheme>, - strict: bool, - source_type: SourceType, -) -> UiValidation { - let allocator = Allocator::default(); - let parsed = Parser::new(&allocator, tsx, source_type).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, - context: Default::default(), - 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(), - render_element_stack: Vec::new(), - }; - visitor.visit_program(&parsed.program); - violations.extend(visitor.violations); - let checked_tokens = visitor.checked_tokens; - let attributes = crate::provenance::attributes::generated_attributes(tsx); - violations.extend(blur_layers::check(tsx, &attributes)); - let elements = crate::provenance::attributes::opening_elements(tsx); - for violation in &mut violations { - let [start, end] = violation.byte_range; - if let Some(attribute) = attributes - .iter() - .find(|attribute| attribute.start <= start && end <= attribute.end) - { - violation - .context - .insert("elementIndex".into(), serde_json::json!(attribute.element)); - violation - .context - .insert("element".into(), serde_json::json!(attribute.tag)); - violation - .context - .insert("attribute".into(), serde_json::json!(attribute.name)); - } else if let Some(element) = elements - .iter() - .filter(|element| element.start <= start && end <= element.end) - .min_by_key(|element| element.end - element.start) - { - violation - .context - .insert("elementIndex".into(), serde_json::json!(element.ordinal)); - } - let prefix = tsx.get(..start).unwrap_or(""); - violation.context.insert( - "line".into(), - serde_json::json!(prefix.bytes().filter(|b| *b == b'\n').count() + 1), - ); - violation.context.insert( - "column".into(), - serde_json::json!(prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1), - ); - violation.context.insert( - "positionEncoding".into(), - serde_json::json!("1-based Unicode scalar columns; byteRange uses UTF-8 bytes"), - ); - violation.context.insert( - "snippet".into(), - serde_json::json!( - tsx.get(start..end) - .unwrap_or("") - .chars() - .take(120) - .collect::() - ), - ); - } - - let ok = violations.iter().all(|violation| { - violation.severity != Severity::Error - && !(strict && violation.severity == Severity::Warning) - }); - let theme_notes = theme - .map(|theme| { - [ - ("hardcoded-color", "color", &theme.colors), - ("hardcoded-length", "length", &theme.length), - ] - .into_iter() - .filter(|(rule, _, modes)| { - modes.values().all(|tokens| tokens.is_empty()) - && violations.iter().any(|finding| finding.rule == *rule) - }) - .map(|(_, kind, _)| format!("The theme defines no {kind} tokens.")) - .collect() - }) - .unwrap_or_default(); - - UiValidation { - ok, - violations, - theme_notes, - checked_tokens, - available_token_count, - } -} - -struct TsxVisitor<'t> { - theme: Option<&'t ProjectTheme>, - checked_tokens: usize, - violations: Vec, - element_stack: Vec>, - render_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 expected = token_category_for_prop(prop_name); - let names = catalog - .iter() - .filter(|(_, entry)| expected.is_none_or(|category| entry.category == category)) - .map(|(name, _)| name) - .collect::>(); - let suggestions = closest_tokens(token, names.into_iter(), 3); - self.violations.push(Violation { - rule: "unknown-token", - severity: Severity::Error, - context: Default::default(), - 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; - } - // devup-ui's `typography` prop names a theme token without a `$`, so - // the check above never sees one. A generated Braillify Studio screen - // carried `typography="mainSubText"`, `"titleSmMed"` and `"bodyMed"`, - // none of them in that project's devup.json, and validation reported - // six unknown tokens — every one a color — while these three passed. - // An unknown typography token is a style that silently does not apply. - if prop_name == "typography" { - let Some(theme) = self.theme else { - return; - }; - // Nothing to check against, and a check that cannot be made is - // skipped rather than guessed, as it is for an absent theme. - if theme.typography.is_empty() { - return; - } - self.checked_tokens += 1; - if theme.typography.contains_key(text) { - return; - } - let suggestions = closest_tokens(text, theme.typography.keys(), 3); - self.violations.push(Violation { - rule: "unknown-token", - severity: Severity::Error, - context: Default::default(), - byte_range: [span.start as usize, span.end as usize], - message: format!("{text} is not a typography token in devup.json."), - suggestion: if suggestions.is_empty() { - None - } else { - Some(format!( - "closest existing typography tokens: {}", - suggestions.join(", ") - )) - }, - }); - return; - } - if is_color_like_prop(prop_name) && is_hex_color(text) { - let tokens = self - .theme - .map(|theme| theme.color_tokens_matching_hex(text)) - .unwrap_or_default(); - self.report_hardcoded_value("hardcoded-color", "color", prop_name, text, &tokens, span); - return; - } - if is_length_like_prop(prop_name) && is_px_length(text) { - let tokens = self - .theme - .map(|theme| theme.length_tokens_matching_px(text)) - .unwrap_or_default(); - self.report_hardcoded_value( - "hardcoded-length", - "length", - prop_name, - text, - &tokens, - span, - ); - } - } - - /// Every literal remains visible. Only exact matches justify a warning - /// and token advice; unmatched values carry factual info with no suggestion. - fn report_hardcoded_value( - &mut self, - rule: &'static str, - kind: &str, - prop_name: &str, - text: &str, - tokens: &[String], - span: Span, - ) { - if tokens.is_empty() { - let context = if self.theme.is_some() { - "the theme has no matching token" - } else { - "no theme is available to check for a matching token" - }; - self.violations.push(Violation { - rule, - severity: Severity::Info, - context: Default::default(), - byte_range: [span.start as usize, span.end as usize], - message: format!("{prop_name} uses hardcoded {kind} {text}; {context}."), - suggestion: None, - }); - return; - } - let named = tokens - .iter() - .map(|name| format!("${name}")) - .collect::>() - .join(", "); - let token_matches: Vec<_> = tokens.iter().map(|name| { - let map = if rule == "hardcoded-color" { &self.theme.unwrap().colors } else { &self.theme.unwrap().length }; - let matching_modes: Vec<_> = map.iter().filter(|(_, values)| values.get(name).and_then(|v| v.as_str()).is_some_and(|v| if rule == "hardcoded-color" { v.trim().eq_ignore_ascii_case(text.trim()) } else { v == text })).map(|(mode,_)|mode.clone()).collect(); - serde_json::json!({"token":format!("${name}"),"matchingModes":matching_modes,"allModesMatch":!map.is_empty() && matching_modes.len() == map.len()}) - }).collect(); - self.violations.push(Violation { - rule, - severity: Severity::Warning, - context: serde_json::Map::from_iter([("tokenMatches".into(), serde_json::json!(token_matches))]), - byte_range: [span.start as usize, span.end as usize], - message: format!( - "{prop_name} uses hardcoded {kind} {text}, which this project's devup.json already defines as {named}. Check the matching modes before replacement; a partial-mode match may change this value in another mode." - ), - suggestion: Some(format!("matching tokens: {named}. Check tokenMatches.matchingModes and allModesMatch before replacing; a partial-mode match can change colors in other modes.")), - }); - } - - 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; - } - if self - .render_element_stack - .last() - .and_then(|tag| tag.as_deref()) - .is_some_and(|tag| crate::html_props::is_html_prop(tag, prop_name)) - { - return; - } - self.violations.push(Violation { - rule: "unknown-prop", - severity: Severity::Error, - context: Default::default(), - 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()); - self.check_object_tokens(object); - } - - /// `$token` references inside `css()`/`globalCss()`/`keyframes()` object - /// literals. - /// - /// The token check used to read JSX attributes only, so - /// `globalCss({ body: { background: "$bg" } })` reported - /// `checkedTokens: 0`: a token that did not exist was accepted in - /// silence, and the response said `clean` about code referencing a token - /// nobody had verified. Only `$`-prefixed strings are inspected here — - /// the hardcoded-value advice stays on JSX props, so no call gains a new - /// warning severity from this walk. - fn check_object_tokens(&mut self, object: &ObjectExpression) { - for property in &object.properties { - let ObjectPropertyKind::ObjectProperty(property) = property else { - continue; - }; - let key = property_key_name(&property.key).unwrap_or_else(|| "?".to_owned()); - self.check_value_tokens(&key, &property.value); - } - } - - /// Nested objects carry the selector/breakpoint nesting devup-ui allows, - /// and arrays carry responsive values; the key that decides a token's - /// expected category is the innermost one, which is the CSS property. - fn check_value_tokens(&mut self, key: &str, value: &Expression) { - match value { - Expression::StringLiteral(text) if text.value.starts_with('$') => { - self.check_attribute_value(key, text.value.as_str(), text.span); - } - Expression::ObjectExpression(nested) => self.check_object_tokens(nested), - Expression::ArrayExpression(array) => { - for element in &array.elements { - if let Some(expression) = element.as_expression() { - self.check_value_tokens(key, expression); - } - } - } - _ => {} - } - } - - 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, call_name == "css") { - self.violations.push(Violation { - rule: "runtime-value", - severity: Severity::Error, - context: Default::default(), - 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. css() can extract finite conditional branches as static classes; globalCss()/keyframes() do not select runtime classes. Value branches must be static, not arbitrary runtime values." - ), - 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); - // Resolve before visiting attributes so maxLength before as works too. - // A later spread/dynamic as can override a literal; do not assume its tag. - let mut rendered = match tag_name.as_deref() { - Some("Text") => Some("span".to_owned()), - Some("Image") => Some("img".to_owned()), - Some("Box" | "Flex" | "Center" | "Grid") => Some("div".to_owned()), - _ => None, - }; - for attribute in &element.attributes { - match attribute { - JSXAttributeItem::SpreadAttribute(_) => rendered = None, - JSXAttributeItem::Attribute(attribute) => { - if matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name == "as") - { - rendered = match &attribute.value { - Some(JSXAttributeValue::StringLiteral(value)) => { - Some(value.value.to_string()) - } - Some(JSXAttributeValue::ExpressionContainer(container)) => container - .expression - .as_expression() - .and_then(|expression| match expression.get_inner_expression() { - Expression::StringLiteral(value) => { - Some(value.value.to_string()) - } - _ => None, - }), - _ => None, - }; - } - } - } - } - self.element_stack.push(tag_name); - self.render_element_stack.push(rendered); - walk::walk_jsx_opening_element(self, element); - self.render_element_stack.pop(); - 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 arbitrary runtime value leaves are not. css() alone can -/// select among recursively static conditional branches; the condition is kept -/// in generated class selection (devup-ui extractor::extract_style_from_expression). -fn is_static_expression(expression: &Expression, conditional_classes: bool) -> bool { - match expression.get_inner_expression() { - Expression::Identifier(id) if conditional_classes && id.name == "undefined" => true, - Expression::ConditionalExpression(conditional) => { - conditional_classes - && is_static_expression(&conditional.consequent, true) - && is_static_expression(&conditional.alternate, true) - } - 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, conditional_classes) - } - Expression::ArrayExpression(array) => array.elements.iter().all(|element| { - element - .as_expression() - .is_some_and(|e| is_static_expression(e, conditional_classes)) - || element.is_elision() - }), - Expression::ObjectExpression(object) => { - object.properties.iter().all(|property| match property { - ObjectPropertyKind::ObjectProperty(property) => { - is_static_expression(&property.value, conditional_classes) - } - ObjectPropertyKind::SpreadProperty(_) => false, - }) - } - _ => false, - } -} - -/// Which category a `$token` written in this prop could belong to. -/// -/// A prop admits one kind of token, so only that kind can replace a name -/// the theme does not have. Ranking the whole catalog by edit distance -/// ignores that and answers a color prop with whatever is spelled closest: -/// `bg="$background"` came back as `$bodyStrong, $button, $caption`, three -/// typography tokens, while `$bg` — the color the project defines for -/// exactly this — was never offered. -/// -/// `None` for a prop this validator does not model. The whole catalog is -/// ranked then, as before: a worse suggestion is still better than none, -/// and narrowing on a guess would be the same mistake in reverse. -fn token_category_for_prop(prop_name: &str) -> Option { - if is_color_like_prop(prop_name) { - Some(TokenCategory::Colors) - } else if is_length_like_prop(prop_name) { - Some(TokenCategory::Length) - } else if matches!(prop_name, "boxShadow" | "textShadow") { - Some(TokenCategory::Shadow) - } else { - None - } -} - -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); - } - - /// A real project's `devup.json` (`braillify-studio` `apps/front`): colors - /// and typography side by side, where the color the generator wanted is - /// one edit away under a different name. - fn theme_with_colors_and_typography() -> ProjectTheme { - parse_project_theme( - r##"{ "theme": { - "colors": { "light": { - "bg": "#FFFFFF", "border": "#E5E5E5", - "text": "#111111", "textSubtle": "#9A9A9A" - } }, - "typography": { - "bodyStrong": { "fontSize": "14px" }, - "button": { "fontSize": "15px" }, - "caption": { "fontSize": "12px" } - }, - "length": {}, "shadow": {} - } }"##, - ) - .unwrap() - } - - /// A token named in a color prop can only be replaced by a color token. - /// Ranking the whole catalog by edit distance answered `bg="$background"` - /// with `$bodyStrong, $button, $caption` — three typography tokens, none - /// of which is usable here — while `$bg`, the one the project actually - /// defines for this, did not appear at all. - #[test] - fn unknown_color_token_is_never_advised_to_use_a_typography_token() { - let tsx = r##"export const X = () => ;"##; - let report = validate_devup_ui_tsx(tsx, Some(&theme_with_colors_and_typography()), false); - let violation = report - .violations - .iter() - .find(|violation| violation.rule == "unknown-token") - .expect("unknown-token violation"); - let suggestion = violation - .suggestion - .as_deref() - .expect("a theme with color tokens can always advise one"); - for typography in ["$bodyStrong", "$button", "$caption"] { - assert!( - !suggestion.contains(typography), - "{typography} cannot be used in a color prop: {suggestion}" - ); - } - assert!( - suggestion.contains("$bg"), - "the color token this project defines must be offered: {suggestion}" - ); - } - - /// The same rule in the other direction: a length prop is advised with - /// length tokens, never with the colors that happen to be spelled alike. - #[test] - fn unknown_length_token_is_advised_only_with_length_tokens() { - let theme = parse_project_theme( - r##"{ "theme": { - "colors": { "default": { "gutterColor": "#FFFFFF" } }, - "typography": {}, - "length": { "default": { "gutter": "16px" } }, - "shadow": {} - } }"##, - ) - .unwrap(); - let tsx = r##"export const X = () => ;"##; - let report = validate_devup_ui_tsx(tsx, Some(&theme), false); - let violation = report - .violations - .iter() - .find(|violation| violation.rule == "unknown-token") - .expect("unknown-token violation"); - let suggestion = violation.suggestion.as_deref().expect("length advice"); - assert!( - !suggestion.contains("$gutterColor"), - "a color token cannot be used in a length prop: {suggestion}" - ); - assert!( - suggestion.contains("$gutter"), - "the length token this project defines must be offered: {suggestion}" - ); - } - - /// A theme that names its typography, as every real one does. - fn theme_with_typography() -> ProjectTheme { - parse_project_theme( - r##"{ "theme": { - "colors": { "default": { "text": "#111111" } }, - "typography": { - "body": { "fontSize": "14px" }, - "bodyL": { "fontSize": "16px" }, - "h1": { "fontSize": "36px" } - }, - "length": {}, "shadow": {} - } }"##, - ) - .unwrap() - } - - /// devup-ui's `typography` prop names a theme token without a `$`, so the - /// `$`-prefixed check never saw one. A generated Braillify Studio screen - /// carried `typography="mainSubText"`, `"titleSmMed"` and `"bodyMed"` — - /// none of them in that project's devup.json — and validation reported - /// six unknown tokens, every one of them a color, and said nothing about - /// these three. An unknown typography token is a style that silently does - /// not apply. - #[test] - fn an_unknown_typography_token_is_reported() { - let tsx = r##"export const X = () => ;"##; - let report = validate_devup_ui_tsx(tsx, Some(&theme_with_typography()), false); - let violation = report - .violations - .iter() - .find(|violation| violation.rule == "unknown-token") - .unwrap_or_else(|| panic!("mainSubText is not in the theme: {:?}", report.violations)); - assert_eq!(violation.severity, Severity::Error); - assert!(violation.message.contains("mainSubText"), "{violation:?}"); - assert!(!report.ok); - } - - /// The advice for one names the others, and only the others. - #[test] - fn an_unknown_typography_token_is_advised_with_typography_tokens() { - let tsx = r##"export const X = () => ;"##; - let report = validate_devup_ui_tsx(tsx, Some(&theme_with_typography()), false); - let violation = report - .violations - .iter() - .find(|violation| violation.rule == "unknown-token") - .expect("bodyMed is not in the theme"); - let suggestion = violation.suggestion.as_deref().expect("typography advice"); - assert!(suggestion.contains("body"), "{suggestion}"); - assert!( - !suggestion.contains("$text"), - "a color cannot be a typography token: {suggestion}" - ); - } - - /// A token the theme does define is not flagged, and is counted as checked. - #[test] - fn a_defined_typography_token_passes() { - let tsx = r##"export const X = () => ;"##; - let report = validate_devup_ui_tsx(tsx, Some(&theme_with_typography()), false); - assert!(report.ok, "{:?}", report.violations); - assert_eq!(report.checked_tokens, 1); - } - - /// With no typography in the theme there is nothing to check against, and - /// a check that cannot be made is skipped rather than guessed — the rule - /// `a_theme_with_no_length_tokens_is_never_told_to_use_one` already holds - /// for lengths. - #[test] - fn a_theme_with_no_typography_tokens_flags_nothing() { - let tsx = r##"export const X = () => ;"##; - let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); - assert!(report.ok, "{:?}", report.violations); - assert!( - report - .violations - .iter() - .all(|violation| violation.rule != "unknown-token"), - "{:?}", - report.violations - ); - } - - #[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")); - } - - /// A real project's `devup.json` (`devup-mcp-defects.md` D11): colors - /// and typography, and no `length` key at all. - fn theme_without_length_tokens() -> ProjectTheme { - parse_project_theme( - r##"{ "theme": { - "colors": { "default": { "primary": "#752D2D" } }, - "typography": { "h4": { "fontSize": "20px" } } - } }"##, - ) - .unwrap() - } - - #[test] - fn a_theme_with_no_length_tokens_is_never_told_to_use_one() { - // Every one of these is a length the generator emitted against a - // theme that defines zero length tokens. Advising a token when the - // project has none to use is advice that cannot be followed. - let tsx = r##"export const X = () => ( - - );"##; - let report = validate_devup_ui_tsx(tsx, Some(&theme_without_length_tokens()), false); - let lengths = report - .violations - .iter() - .filter(|violation| violation.rule == "hardcoded-length") - .collect::>(); - assert_eq!( - lengths.len(), - 8, - "every literal remains visible: {lengths:?}" - ); - for finding in lengths { - assert_eq!(serde_json::to_value(finding).unwrap()["severity"], "info"); - assert!(finding.suggestion.is_none()); - assert!(finding.message.contains("no matching token")); - } - assert_eq!( - serde_json::to_value(report).unwrap()["themeNotes"], - serde_json::json!(["The theme defines no length tokens."]) - ); - } - - #[test] - fn unmatched_lengths_are_info_and_exact_matches_remain_warnings() { - let tsx = r##"export const X = () => ;"##; - let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); - let lengths = report - .violations - .iter() - .filter(|violation| violation.rule == "hardcoded-length") - .collect::>(); - // Both are visible; only 16px has actionable advice ($md). - assert_eq!(lengths.len(), 2, "{lengths:?}"); - assert!(lengths[0].message.contains("40px")); - assert_eq!( - serde_json::to_value(lengths[0]).unwrap()["severity"], - "info" - ); - assert!(lengths[0].suggestion.is_none()); - assert!(lengths[1].message.contains("16px")); - assert_eq!(lengths[1].severity, Severity::Warning); - assert!(lengths[1].suggestion.as_ref().unwrap().contains("$md")); - } - - /// Nonmatching colors remain visible without speculative token advice. - #[test] - fn unmatched_colors_are_info_even_in_strict_mode() { - for strict in [false, true] { - let report = validate_devup_ui_tsx( - r##"export const X = () => ;"##, - Some(&fixture_theme()), - strict, - ); - assert!(report.ok); - assert_eq!(report.violations.len(), 1); - let finding = &report.violations[0]; - assert_eq!(finding.rule, "hardcoded-color"); - assert_eq!(serde_json::to_value(finding).unwrap()["severity"], "info"); - assert!(finding.message.contains("no matching token")); - assert!(finding.suggestion.is_none()); - } - } - - /// An unavailable theme cannot suppress literals or imply an empty theme. - #[test] - fn without_a_theme_both_hardcoded_rules_report_info_without_advice() { - let report = validate_devup_ui_tsx( - r##"export const X = () => ;"##, - None, - true, - ); - assert!(report.ok); - assert_eq!(report.violations.len(), 2); - for finding in &report.violations { - assert_eq!(serde_json::to_value(finding).unwrap()["severity"], "info"); - assert!(finding.suggestion.is_none()); - assert!(finding.message.contains("no theme is available")); - } - assert_eq!( - serde_json::to_value(report).unwrap()["themeNotes"], - serde_json::json!([]) - ); - } - - /// Missing categories are summarized once each, never on every literal. - #[test] - fn empty_color_and_length_categories_are_summarized_once_each() { - let theme = - parse_project_theme(r#"{"theme":{"colors":{"default":{}},"length":{}}}"#).unwrap(); - let report = validate_devup_ui_tsx( - r##"export const X = () => ;"##, - Some(&theme), - true, - ); - assert!(report.ok); - assert_eq!(report.violations.len(), 4); - let value = serde_json::to_value(report).unwrap(); - assert_eq!( - value["themeNotes"], - serde_json::json!([ - "The theme defines no color tokens.", - "The theme defines no length tokens." - ]) - ); - for finding in value["violations"].as_array().unwrap() { - assert_eq!(finding["severity"], "info"); - assert!(!finding["message"].as_str().unwrap().contains("defines no")); - } - } - - #[test] - fn a_reported_hardcoded_value_names_the_token_that_already_holds_it() { - let tsx = r##"export const X = () => ;"##; - let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); - let messages = report - .violations - .iter() - .map(|violation| violation.message.clone()) - .collect::>(); - assert!( - messages - .iter() - .any(|message| message.contains("$captionLight")), - "{messages:?}" - ); - assert!( - messages.iter().any(|message| message.contains("$md")), - "{messages:?}" - ); - } - - #[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); - } -} - -#[path = "ui_structure.rs"] -mod structure; -pub use structure::{ - MAX_FILES, MAX_TOTAL_BYTES, SourceFile, bundle_theme, check_bundle, validate_bundle, -}; +// Source sections share this module to preserve privacy and test names. +include!("ui_validate/types_and_helpers.rs"); +include!("ui_validate/tests.rs"); diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/blur_layers.rs b/crates/devup-mcp-devup-ui/src/ui_validate/blur_layers.rs index 41871f9d..7432b5d5 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate/blur_layers.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate/blur_layers.rs @@ -161,7 +161,7 @@ pub(super) fn check(source_text: &str, attrs: &[GeneratedAttribute]) -> Vec>(), "state":"review-required","evidenceScope":"literal src and statically resolved known-wrapper or same-element CSS filters", - "evidenceLimit":"Static review cannot establish intended decoration, conditional visibility, external CSS, dynamic/custom ancestor effects or browser pixels; this warning does not prove a missing layer."}).as_object().unwrap().clone(), + "evidenceLimit":include_str!("../text/blur_layers-check_Violation-c576344a.md")}).as_object().unwrap().clone(), message:"All statically visible literal uses of this image apply positive CSS blur; review whether an intended sharp copy is missing.".into(), suggestion:Some("If the design includes a sharp image, render that copy alongside the blur layer; keep deliberate decoration explicitly marked.".into())} }).collect() diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/site.rs b/crates/devup-mcp-devup-ui/src/ui_validate/site.rs index 2522185d..828f5fe1 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate/site.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate/site.rs @@ -461,7 +461,7 @@ pub fn interaction_findings(path: &str, source: &str) -> Vec { "<{name}> is interactive ({}) but has neither a hover nor a focus style; it gives no feedback and a keyboard user cannot see focus on it.", element.role.as_str() ), - "Apply the role's recipe from the devup-implementation-plan skill: hover, _focusVisible (2px ring with offset), _active, cursor=\"pointer\" and _disabled where it applies, from existing theme tokens, with the shared transition. On a next/link, render it as so the states are devup-ui props.", + include_str!("../text/site-interaction_findings-3f71ed85.md"), ); } else if !element.focus { push( @@ -600,7 +600,7 @@ pub fn metadata_finding(path: &str, source: &str) -> Option { format!( "{path} exports no metadata, so the page has no title, description or share card of its own." ), - "Export metadata with title, description and openGraph (the devfive-frontend SEO rule); the root layout also carries the title template and metadataBase. A 'use client' page cannot export it: move the client part into a component.", + include_str!("../text/site-metadata_finding-e57c4614.md"), )) } diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/tests.rs b/crates/devup-mcp-devup-ui/src/ui_validate/tests.rs new file mode 100644 index 00000000..6e621e20 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/tests.rs @@ -0,0 +1,510 @@ + + +#[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); + } + + /// A real project's `devup.json` (`braillify-studio` `apps/front`): colors + /// and typography side by side, where the color the generator wanted is + /// one edit away under a different name. + fn theme_with_colors_and_typography() -> ProjectTheme { + parse_project_theme( + r##"{ "theme": { + "colors": { "light": { + "bg": "#FFFFFF", "border": "#E5E5E5", + "text": "#111111", "textSubtle": "#9A9A9A" + } }, + "typography": { + "bodyStrong": { "fontSize": "14px" }, + "button": { "fontSize": "15px" }, + "caption": { "fontSize": "12px" } + }, + "length": {}, "shadow": {} + } }"##, + ) + .unwrap() + } + + /// A token named in a color prop can only be replaced by a color token. + /// Ranking the whole catalog by edit distance answered `bg="$background"` + /// with `$bodyStrong, $button, $caption` — three typography tokens, none + /// of which is usable here — while `$bg`, the one the project actually + /// defines for this, did not appear at all. + #[test] + fn unknown_color_token_is_never_advised_to_use_a_typography_token() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&theme_with_colors_and_typography()), false); + let violation = report + .violations + .iter() + .find(|violation| violation.rule == "unknown-token") + .expect("unknown-token violation"); + let suggestion = violation + .suggestion + .as_deref() + .expect("a theme with color tokens can always advise one"); + for typography in ["$bodyStrong", "$button", "$caption"] { + assert!( + !suggestion.contains(typography), + "{typography} cannot be used in a color prop: {suggestion}" + ); + } + assert!( + suggestion.contains("$bg"), + "the color token this project defines must be offered: {suggestion}" + ); + } + + /// The same rule in the other direction: a length prop is advised with + /// length tokens, never with the colors that happen to be spelled alike. + #[test] + fn unknown_length_token_is_advised_only_with_length_tokens() { + let theme = parse_project_theme( + r##"{ "theme": { + "colors": { "default": { "gutterColor": "#FFFFFF" } }, + "typography": {}, + "length": { "default": { "gutter": "16px" } }, + "shadow": {} + } }"##, + ) + .unwrap(); + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&theme), false); + let violation = report + .violations + .iter() + .find(|violation| violation.rule == "unknown-token") + .expect("unknown-token violation"); + let suggestion = violation.suggestion.as_deref().expect("length advice"); + assert!( + !suggestion.contains("$gutterColor"), + "a color token cannot be used in a length prop: {suggestion}" + ); + assert!( + suggestion.contains("$gutter"), + "the length token this project defines must be offered: {suggestion}" + ); + } + + /// A theme that names its typography, as every real one does. + fn theme_with_typography() -> ProjectTheme { + parse_project_theme( + r##"{ "theme": { + "colors": { "default": { "text": "#111111" } }, + "typography": { + "body": { "fontSize": "14px" }, + "bodyL": { "fontSize": "16px" }, + "h1": { "fontSize": "36px" } + }, + "length": {}, "shadow": {} + } }"##, + ) + .unwrap() + } + + /// devup-ui's `typography` prop names a theme token without a `$`, so the + /// `$`-prefixed check never saw one. A generated Braillify Studio screen + /// carried `typography="mainSubText"`, `"titleSmMed"` and `"bodyMed"` — + /// none of them in that project's devup.json — and validation reported + /// six unknown tokens, every one of them a color, and said nothing about + /// these three. An unknown typography token is a style that silently does + /// not apply. + #[test] + fn an_unknown_typography_token_is_reported() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&theme_with_typography()), false); + let violation = report + .violations + .iter() + .find(|violation| violation.rule == "unknown-token") + .unwrap_or_else(|| panic!("mainSubText is not in the theme: {:?}", report.violations)); + assert_eq!(violation.severity, Severity::Error); + assert!(violation.message.contains("mainSubText"), "{violation:?}"); + assert!(!report.ok); + } + + /// The advice for one names the others, and only the others. + #[test] + fn an_unknown_typography_token_is_advised_with_typography_tokens() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&theme_with_typography()), false); + let violation = report + .violations + .iter() + .find(|violation| violation.rule == "unknown-token") + .expect("bodyMed is not in the theme"); + let suggestion = violation.suggestion.as_deref().expect("typography advice"); + assert!(suggestion.contains("body"), "{suggestion}"); + assert!( + !suggestion.contains("$text"), + "a color cannot be a typography token: {suggestion}" + ); + } + + /// A token the theme does define is not flagged, and is counted as checked. + #[test] + fn a_defined_typography_token_passes() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&theme_with_typography()), false); + assert!(report.ok, "{:?}", report.violations); + assert_eq!(report.checked_tokens, 1); + } + + /// With no typography in the theme there is nothing to check against, and + /// a check that cannot be made is skipped rather than guessed — the rule + /// `a_theme_with_no_length_tokens_is_never_told_to_use_one` already holds + /// for lengths. + #[test] + fn a_theme_with_no_typography_tokens_flags_nothing() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + assert!(report.ok, "{:?}", report.violations); + assert!( + report + .violations + .iter() + .all(|violation| violation.rule != "unknown-token"), + "{:?}", + report.violations + ); + } + + #[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")); + } + + /// A real project's `devup.json` (`devup-mcp-defects.md` D11): colors + /// and typography, and no `length` key at all. + fn theme_without_length_tokens() -> ProjectTheme { + parse_project_theme( + r##"{ "theme": { + "colors": { "default": { "primary": "#752D2D" } }, + "typography": { "h4": { "fontSize": "20px" } } + } }"##, + ) + .unwrap() + } + + #[test] + fn a_theme_with_no_length_tokens_is_never_told_to_use_one() { + // Every one of these is a length the generator emitted against a + // theme that defines zero length tokens. Advising a token when the + // project has none to use is advice that cannot be followed. + let tsx = r##"export const X = () => ( + + );"##; + let report = validate_devup_ui_tsx(tsx, Some(&theme_without_length_tokens()), false); + let lengths = report + .violations + .iter() + .filter(|violation| violation.rule == "hardcoded-length") + .collect::>(); + assert_eq!( + lengths.len(), + 8, + "every literal remains visible: {lengths:?}" + ); + for finding in lengths { + assert_eq!(serde_json::to_value(finding).unwrap()["severity"], "info"); + assert!(finding.suggestion.is_none()); + assert!(finding.message.contains("no matching token")); + } + assert_eq!( + serde_json::to_value(report).unwrap()["themeNotes"], + serde_json::json!(["The theme defines no length tokens."]) + ); + } + + #[test] + fn unmatched_lengths_are_info_and_exact_matches_remain_warnings() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + let lengths = report + .violations + .iter() + .filter(|violation| violation.rule == "hardcoded-length") + .collect::>(); + // Both are visible; only 16px has actionable advice ($md). + assert_eq!(lengths.len(), 2, "{lengths:?}"); + assert!(lengths[0].message.contains("40px")); + assert_eq!( + serde_json::to_value(lengths[0]).unwrap()["severity"], + "info" + ); + assert!(lengths[0].suggestion.is_none()); + assert!(lengths[1].message.contains("16px")); + assert_eq!(lengths[1].severity, Severity::Warning); + assert!(lengths[1].suggestion.as_ref().unwrap().contains("$md")); + } + + /// Nonmatching colors remain visible without speculative token advice. + #[test] + fn unmatched_colors_are_info_even_in_strict_mode() { + for strict in [false, true] { + let report = validate_devup_ui_tsx( + r##"export const X = () => ;"##, + Some(&fixture_theme()), + strict, + ); + assert!(report.ok); + assert_eq!(report.violations.len(), 1); + let finding = &report.violations[0]; + assert_eq!(finding.rule, "hardcoded-color"); + assert_eq!(serde_json::to_value(finding).unwrap()["severity"], "info"); + assert!(finding.message.contains("no matching token")); + assert!(finding.suggestion.is_none()); + } + } + + /// An unavailable theme cannot suppress literals or imply an empty theme. + #[test] + fn without_a_theme_both_hardcoded_rules_report_info_without_advice() { + let report = validate_devup_ui_tsx( + r##"export const X = () => ;"##, + None, + true, + ); + assert!(report.ok); + assert_eq!(report.violations.len(), 2); + for finding in &report.violations { + assert_eq!(serde_json::to_value(finding).unwrap()["severity"], "info"); + assert!(finding.suggestion.is_none()); + assert!(finding.message.contains("no theme is available")); + } + assert_eq!( + serde_json::to_value(report).unwrap()["themeNotes"], + serde_json::json!([]) + ); + } + + /// Missing categories are summarized once each, never on every literal. + #[test] + fn empty_color_and_length_categories_are_summarized_once_each() { + let theme = + parse_project_theme(r#"{"theme":{"colors":{"default":{}},"length":{}}}"#).unwrap(); + let report = validate_devup_ui_tsx( + r##"export const X = () => ;"##, + Some(&theme), + true, + ); + assert!(report.ok); + assert_eq!(report.violations.len(), 4); + let value = serde_json::to_value(report).unwrap(); + assert_eq!( + value["themeNotes"], + serde_json::json!([ + "The theme defines no color tokens.", + "The theme defines no length tokens." + ]) + ); + for finding in value["violations"].as_array().unwrap() { + assert_eq!(finding["severity"], "info"); + assert!(!finding["message"].as_str().unwrap().contains("defines no")); + } + } + + #[test] + fn a_reported_hardcoded_value_names_the_token_that_already_holds_it() { + let tsx = r##"export const X = () => ;"##; + let report = validate_devup_ui_tsx(tsx, Some(&fixture_theme()), false); + let messages = report + .violations + .iter() + .map(|violation| violation.message.clone()) + .collect::>(); + assert!( + messages + .iter() + .any(|message| message.contains("$captionLight")), + "{messages:?}" + ); + assert!( + messages.iter().any(|message| message.contains("$md")), + "{messages:?}" + ); + } + + #[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); + } +} + +#[path = "../ui_structure.rs"] +mod structure; +pub use structure::{ + MAX_FILES, MAX_TOTAL_BYTES, SourceFile, bundle_theme, check_bundle, validate_bundle, +}; diff --git a/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs b/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs new file mode 100644 index 00000000..d7e192a2 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/ui_validate/types_and_helpers.rs @@ -0,0 +1,683 @@ + +use std::collections::BTreeSet; +#[path = "blur_layers.rs"] +mod blur_layers; +#[path = "site.rs"] +pub mod site; + +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + Argument, CallExpression, Expression, JSXAttribute, JSXAttributeItem, 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, TokenCategory, 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 { + Info, + 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], + #[serde(flatten)] + pub context: serde_json::Map, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suggestion: Option, +} + +/// The result of one validation, and what each field does and does not +/// claim. The MCP tool publishes these four under the same names +/// (`ok`, `violations`, `checkedTokens`, `availableTokenCount`), and a +/// caller who reads them as anything other than the below will draw the +/// wrong conclusion — `ok: true` beside a non-empty `violations` is +/// correct output, not a contradiction. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UiValidation { + /// Whether this TSX may be used as it stands. + /// + /// `false` when any violation is [`Severity::Error`] — a syntax + /// failure, a `$token` the theme does not define, a prop the + /// primitive does not take, or a runtime value where devup-ui + /// extracts at build time. Each of those breaks the build or the + /// screen. + /// + /// `true` is therefore compatible with a non-empty `violations`: + /// warnings are values that would be better written as a token, and + /// code that ignores every one of them still compiles and renders. + /// Pass `strict` to fail on warnings too. Info alone never fails `ok`. + pub ok: bool, + /// Every finding, in source order per rule. `severity` is what + /// decides `ok`; the count alone does not. + pub violations: Vec, + /// One note per empty token category encountered by the hardcoded rules. + /// An unavailable theme is handled by the caller's guardrail instead. + pub theme_notes: Vec, + /// How many `$token` references this TSX makes — i.e. how many string + /// attribute values began with `$` and were looked up. It counts the + /// *code's* references, not the theme's tokens, and it counts them + /// whether or not a theme was available to check them against: with + /// no theme, `checked_tokens` is still the number of references while + /// `unknown-token` is skipped entirely (a skipped check must not read + /// as a passed one). + pub checked_tokens: usize, + /// How many distinct tokens the project's `devup.json` defines across + /// all four axes, merged by name — the size of the vocabulary the + /// TSX had to choose from. `0` when no theme was available. + /// + /// Not comparable with `checked_tokens`: one counts references in the + /// code, the other definitions in the theme. + 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 { + validate_source(tsx, theme, strict, SourceType::tsx()) +} + +fn validate_source( + tsx: &str, + theme: Option<&ProjectTheme>, + strict: bool, + source_type: SourceType, +) -> UiValidation { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, tsx, source_type).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, + context: Default::default(), + 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(), + render_element_stack: Vec::new(), + }; + visitor.visit_program(&parsed.program); + violations.extend(visitor.violations); + let checked_tokens = visitor.checked_tokens; + let attributes = crate::provenance::attributes::generated_attributes(tsx); + violations.extend(blur_layers::check(tsx, &attributes)); + let elements = crate::provenance::attributes::opening_elements(tsx); + for violation in &mut violations { + let [start, end] = violation.byte_range; + if let Some(attribute) = attributes + .iter() + .find(|attribute| attribute.start <= start && end <= attribute.end) + { + violation + .context + .insert("elementIndex".into(), serde_json::json!(attribute.element)); + violation + .context + .insert("element".into(), serde_json::json!(attribute.tag)); + violation + .context + .insert("attribute".into(), serde_json::json!(attribute.name)); + } else if let Some(element) = elements + .iter() + .filter(|element| element.start <= start && end <= element.end) + .min_by_key(|element| element.end - element.start) + { + violation + .context + .insert("elementIndex".into(), serde_json::json!(element.ordinal)); + } + let prefix = tsx.get(..start).unwrap_or(""); + violation.context.insert( + "line".into(), + serde_json::json!(prefix.bytes().filter(|b| *b == b'\n').count() + 1), + ); + violation.context.insert( + "column".into(), + serde_json::json!(prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1), + ); + violation.context.insert( + "positionEncoding".into(), + serde_json::json!("1-based Unicode scalar columns; byteRange uses UTF-8 bytes"), + ); + violation.context.insert( + "snippet".into(), + serde_json::json!( + tsx.get(start..end) + .unwrap_or("") + .chars() + .take(120) + .collect::() + ), + ); + } + + let ok = violations.iter().all(|violation| { + violation.severity != Severity::Error + && !(strict && violation.severity == Severity::Warning) + }); + let theme_notes = theme + .map(|theme| { + [ + ("hardcoded-color", "color", &theme.colors), + ("hardcoded-length", "length", &theme.length), + ] + .into_iter() + .filter(|(rule, _, modes)| { + modes.values().all(|tokens| tokens.is_empty()) + && violations.iter().any(|finding| finding.rule == *rule) + }) + .map(|(_, kind, _)| format!("The theme defines no {kind} tokens.")) + .collect() + }) + .unwrap_or_default(); + + UiValidation { + ok, + violations, + theme_notes, + checked_tokens, + available_token_count, + } +} + +struct TsxVisitor<'t> { + theme: Option<&'t ProjectTheme>, + checked_tokens: usize, + violations: Vec, + element_stack: Vec>, + render_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 expected = token_category_for_prop(prop_name); + let names = catalog + .iter() + .filter(|(_, entry)| expected.is_none_or(|category| entry.category == category)) + .map(|(name, _)| name) + .collect::>(); + let suggestions = closest_tokens(token, names.into_iter(), 3); + self.violations.push(Violation { + rule: "unknown-token", + severity: Severity::Error, + context: Default::default(), + 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; + } + // devup-ui's `typography` prop names a theme token without a `$`, so + // the check above never sees one. A generated Braillify Studio screen + // carried `typography="mainSubText"`, `"titleSmMed"` and `"bodyMed"`, + // none of them in that project's devup.json, and validation reported + // six unknown tokens — every one a color — while these three passed. + // An unknown typography token is a style that silently does not apply. + if prop_name == "typography" { + let Some(theme) = self.theme else { + return; + }; + // Nothing to check against, and a check that cannot be made is + // skipped rather than guessed, as it is for an absent theme. + if theme.typography.is_empty() { + return; + } + self.checked_tokens += 1; + if theme.typography.contains_key(text) { + return; + } + let suggestions = closest_tokens(text, theme.typography.keys(), 3); + self.violations.push(Violation { + rule: "unknown-token", + severity: Severity::Error, + context: Default::default(), + byte_range: [span.start as usize, span.end as usize], + message: format!("{text} is not a typography token in devup.json."), + suggestion: if suggestions.is_empty() { + None + } else { + Some(format!( + "closest existing typography tokens: {}", + suggestions.join(", ") + )) + }, + }); + return; + } + if is_color_like_prop(prop_name) && is_hex_color(text) { + let tokens = self + .theme + .map(|theme| theme.color_tokens_matching_hex(text)) + .unwrap_or_default(); + self.report_hardcoded_value("hardcoded-color", "color", prop_name, text, &tokens, span); + return; + } + if is_length_like_prop(prop_name) && is_px_length(text) { + let tokens = self + .theme + .map(|theme| theme.length_tokens_matching_px(text)) + .unwrap_or_default(); + self.report_hardcoded_value( + "hardcoded-length", + "length", + prop_name, + text, + &tokens, + span, + ); + } + } + + /// Every literal remains visible. Only exact matches justify a warning + /// and token advice; unmatched values carry factual info with no suggestion. + fn report_hardcoded_value( + &mut self, + rule: &'static str, + kind: &str, + prop_name: &str, + text: &str, + tokens: &[String], + span: Span, + ) { + if tokens.is_empty() { + let context = if self.theme.is_some() { + "the theme has no matching token" + } else { + "no theme is available to check for a matching token" + }; + self.violations.push(Violation { + rule, + severity: Severity::Info, + context: Default::default(), + byte_range: [span.start as usize, span.end as usize], + message: format!("{prop_name} uses hardcoded {kind} {text}; {context}."), + suggestion: None, + }); + return; + } + let named = tokens + .iter() + .map(|name| format!("${name}")) + .collect::>() + .join(", "); + let token_matches: Vec<_> = tokens.iter().map(|name| { + let map = if rule == "hardcoded-color" { &self.theme.unwrap().colors } else { &self.theme.unwrap().length }; + let matching_modes: Vec<_> = map.iter().filter(|(_, values)| values.get(name).and_then(|v| v.as_str()).is_some_and(|v| if rule == "hardcoded-color" { v.trim().eq_ignore_ascii_case(text.trim()) } else { v == text })).map(|(mode,_)|mode.clone()).collect(); + serde_json::json!({"token":format!("${name}"),"matchingModes":matching_modes,"allModesMatch":!map.is_empty() && matching_modes.len() == map.len()}) + }).collect(); + self.violations.push(Violation { + rule, + severity: Severity::Warning, + context: serde_json::Map::from_iter([("tokenMatches".into(), serde_json::json!(token_matches))]), + byte_range: [span.start as usize, span.end as usize], + message: format!( + include_str!("../text/ui_validate-report_hardcoded_value_Violation-63c589c5.md") + , kind = kind, named = named, prop_name = prop_name, text = text), + suggestion: Some(format!("matching tokens: {named}. Check tokenMatches.matchingModes and allModesMatch before replacing; a partial-mode match can change colors in other modes.")), + }); + } + + 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; + } + if self + .render_element_stack + .last() + .and_then(|tag| tag.as_deref()) + .is_some_and(|tag| crate::html_props::is_html_prop(tag, prop_name)) + { + return; + } + self.violations.push(Violation { + rule: "unknown-prop", + severity: Severity::Error, + context: Default::default(), + 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()); + self.check_object_tokens(object); + } + + /// `$token` references inside `css()`/`globalCss()`/`keyframes()` object + /// literals. + /// + /// The token check used to read JSX attributes only, so + /// `globalCss({ body: { background: "$bg" } })` reported + /// `checkedTokens: 0`: a token that did not exist was accepted in + /// silence, and the response said `clean` about code referencing a token + /// nobody had verified. Only `$`-prefixed strings are inspected here — + /// the hardcoded-value advice stays on JSX props, so no call gains a new + /// warning severity from this walk. + fn check_object_tokens(&mut self, object: &ObjectExpression) { + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + let key = property_key_name(&property.key).unwrap_or_else(|| "?".to_owned()); + self.check_value_tokens(&key, &property.value); + } + } + + /// Nested objects carry the selector/breakpoint nesting devup-ui allows, + /// and arrays carry responsive values; the key that decides a token's + /// expected category is the innermost one, which is the CSS property. + fn check_value_tokens(&mut self, key: &str, value: &Expression) { + match value { + Expression::StringLiteral(text) if text.value.starts_with('$') => { + self.check_attribute_value(key, text.value.as_str(), text.span); + } + Expression::ObjectExpression(nested) => self.check_object_tokens(nested), + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(expression) = element.as_expression() { + self.check_value_tokens(key, expression); + } + } + } + _ => {} + } + } + + 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, call_name == "css") { + self.violations.push(Violation { + rule: "runtime-value", + severity: Severity::Error, + context: Default::default(), + byte_range: [ + property.value.span().start as usize, + property.value.span().end as usize, + ], + message: format!( + include_str!("../text/ui_validate-check_static_object_Violation-a031fb84.md"), + call_name = call_name, + key = key + ), + 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); + // Resolve before visiting attributes so maxLength before as works too. + // A later spread/dynamic as can override a literal; do not assume its tag. + let mut rendered = match tag_name.as_deref() { + Some("Text") => Some("span".to_owned()), + Some("Image") => Some("img".to_owned()), + Some("Box" | "Flex" | "Center" | "Grid") => Some("div".to_owned()), + _ => None, + }; + for attribute in &element.attributes { + match attribute { + JSXAttributeItem::SpreadAttribute(_) => rendered = None, + JSXAttributeItem::Attribute(attribute) => { + if matches!(&attribute.name, JSXAttributeName::Identifier(name) if name.name == "as") + { + rendered = match &attribute.value { + Some(JSXAttributeValue::StringLiteral(value)) => { + Some(value.value.to_string()) + } + Some(JSXAttributeValue::ExpressionContainer(container)) => container + .expression + .as_expression() + .and_then(|expression| match expression.get_inner_expression() { + Expression::StringLiteral(value) => { + Some(value.value.to_string()) + } + _ => None, + }), + _ => None, + }; + } + } + } + } + self.element_stack.push(tag_name); + self.render_element_stack.push(rendered); + walk::walk_jsx_opening_element(self, element); + self.render_element_stack.pop(); + 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 arbitrary runtime value leaves are not. css() alone can +/// select among recursively static conditional branches; the condition is kept +/// in generated class selection (devup-ui extractor::extract_style_from_expression). +fn is_static_expression(expression: &Expression, conditional_classes: bool) -> bool { + match expression.get_inner_expression() { + Expression::Identifier(id) if conditional_classes && id.name == "undefined" => true, + Expression::ConditionalExpression(conditional) => { + conditional_classes + && is_static_expression(&conditional.consequent, true) + && is_static_expression(&conditional.alternate, true) + } + 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, conditional_classes) + } + Expression::ArrayExpression(array) => array.elements.iter().all(|element| { + element + .as_expression() + .is_some_and(|e| is_static_expression(e, conditional_classes)) + || element.is_elision() + }), + Expression::ObjectExpression(object) => { + object.properties.iter().all(|property| match property { + ObjectPropertyKind::ObjectProperty(property) => { + is_static_expression(&property.value, conditional_classes) + } + ObjectPropertyKind::SpreadProperty(_) => false, + }) + } + _ => false, + } +} + +/// Which category a `$token` written in this prop could belong to. +/// +/// A prop admits one kind of token, so only that kind can replace a name +/// the theme does not have. Ranking the whole catalog by edit distance +/// ignores that and answers a color prop with whatever is spelled closest: +/// `bg="$background"` came back as `$bodyStrong, $button, $caption`, three +/// typography tokens, while `$bg` — the color the project defines for +/// exactly this — was never offered. +/// +/// `None` for a prop this validator does not model. The whole catalog is +/// ranked then, as before: a worse suggestion is still better than none, +/// and narrowing on a guess would be the same mistake in reverse. +fn token_category_for_prop(prop_name: &str) -> Option { + if is_color_like_prop(prop_name) { + Some(TokenCategory::Colors) + } else if is_length_like_prop(prop_name) { + Some(TokenCategory::Length) + } else if matches!(prop_name, "boxShadow" | "textShadow") { + Some(TokenCategory::Shadow) + } else { + None + } +} + +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() +} \ No newline at end of file diff --git a/crates/devup-mcp-devup-ui/tests/responsive_screen.rs b/crates/devup-mcp-devup-ui/tests/responsive_screen.rs index 3a115bf5..5a3373d5 100644 --- a/crates/devup-mcp-devup-ui/tests/responsive_screen.rs +++ b/crates/devup-mcp-devup-ui/tests/responsive_screen.rs @@ -5,1011 +5,6 @@ //! `fixtures/local-screens/bp-family.json`. The capture is not committed, so //! these skip when it is absent rather than pretending to have checked. -use std::{fs, path::PathBuf}; - -use devup_mcp_devup_ui::codegen::{ - CodegenOptions, - responsive::{MergedScreen, merge_breakpoints}, -}; -use devup_mcp_figma::{Snapshot, UpstreamResult}; - -fn merged() -> Option { - merged_from("bp-family.json") -} - -/// The merge of a capture in `fixtures/local-screens`, or `None` when that -/// capture is not on this machine. -/// -/// A capture may carry a `payload` beside its `snapshot` — the variables and -/// styles the export collected with it. They are what the converter names -/// tokens from, and without them a `$gray200` fill is written as the tail of -/// its variable ID and a `typography="h4"` as five font props, which is not -/// what the reference wrote and not what the server would write either. A -/// capture without one is converted as before. -fn merged_from(capture: &str) -> Option { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/local-screens") - .join(capture); - let raw = fs::read_to_string(path).ok()?; - let value: serde_json::Value = serde_json::from_str(&raw).ok()?; - let snapshot: Snapshot = serde_json::from_value(value.get("snapshot")?.clone()).ok()?; - let resource = |name: &str| { - value - .get("payload") - .and_then(|payload| payload.get(name)) - .cloned() - .map(|raw| UpstreamResult { raw }) - }; - let variables = resource("variables"); - let styles = resource("styles"); - let options = - CodegenOptions::default().with_resource_results(variables.as_ref(), styles.as_ref()); - merge_breakpoints(&snapshot, &options).ok()? -} - -/// 360 / 992 / 1920, so slots 0 / 2 / 4 — the same three the reference's arrays -/// are written at. -#[test] -fn the_widths_land_where_their_sizes_put_them() { - let Some(merged) = merged() else { - eprintln!("no capture; skipping"); - return; - }; - assert_eq!(merged.slots, vec![0, 2, 4]); -} - -/// The four arrays in `notice/responsive.tsx`, and no fifth. Each is a region -/// one width draws and another does not, so the copies are kept and shown at -/// their own widths. -#[test] -fn the_display_arrays_are_the_reference_s() { - let Some(merged) = merged() else { - eprintln!("no capture; skipping"); - return; - }; - let folded = folded(&merged.tsx); - let written = folded - .lines() - .filter_map(|line| line.trim().split_once("display=").map(|(_, rest)| rest)) - .map(|value| { - value - .split_once("}") - .map_or(value, |(inside, _)| inside) - .trim_start_matches('{') - .to_owned() - }) - .collect::>(); - assert_eq!( - written, - [ - // the mobile banner, on an element that needs no display to show - r#"[null, null, "none"]"#, - // the desktop banner - r#"["none", null, "flex"]"#, - // the desktop content section - r#"["none", null, "flex"]"#, - // the mobile content section - r#"["flex", null, "none"]"#, - ] - ); -} - -/// A component answers for its own widths, so it is referenced once and not -/// descended into. An instance whose component is only a shape is spelled out -/// instead, which is why no `` or `` appears. -#[test] -fn components_are_referenced_and_shapes_are_spelled_out() { - let Some(merged) = merged() else { - eprintln!("no capture; skipping"); - return; - }; - for referenced in ["Header", "Footer", "Tab", "Pagination"] { - assert!( - merged.components.contains(referenced), - "{referenced} should be referenced: {:?}", - merged.components - ); - } - for inlined in ["Logo", "Icons"] { - assert!( - !merged.components.contains(inlined), - "{inlined} is an asset and should have been spelled out" - ); - } - // Spelling it out loses which component it came from, and that is the one - // thing a reader needs to change it in the right place. The name is the one - // the definition declares: `Icons` types its property `Property 1` where - // every other component in this file uses the Korean `속성 1`, and the two - // sanitize differently. - assert!(merged.tsx.contains("{/* */}"), "{}", merged.tsx); - assert!( - merged - .tsx - .contains(r#"{/* */}"#), - "{}", - merged.tsx - ); - assert!(merged.tsx.contains(r#"
"#)); - // `effect` names the interaction state a variant stands for, and the - // definition folds those into `_hover` / `_active`. A call site has no - // state to pass, so `` would be asking for a prop - // the component does not have. The reference writes it bare. - assert!( - merged.tsx.contains(""), - "Tab should take no props: {}", - merged - .tsx - .lines() - .filter(|line| line.contains(">() - .join("\n") - ); - assert!( - !merged.tsx.contains("effect="), - "no reserved variant key should reach a call site" - ); - // The instance is absolutely placed and `
` has nowhere to put - // that, so it is wrapped. - assert!( - merged - .tsx - .contains(r#""#) - ); -} - -/// The screen asks for a different `Header` and `Footer` variant per width. -/// devup-ui reads an array only where it applies CSS, so a component prop -/// cannot carry one; the widest is kept and both places are named. -#[test] -fn a_variant_that_differs_by_width_is_reported_not_faked() { - let Some(merged) = merged() else { - eprintln!("no capture; skipping"); - return; - }; - let said = merged - .unrepresented - .iter() - .map(|note| note.detail.as_str()) - .collect::>(); - assert_eq!(said.len(), 2, "{said:?}"); - assert!( - said.iter() - .any(|note| note.starts_with("Header takes property1")) - ); - assert!( - said.iter() - .any(|note| note.starts_with("Footer takes property1")) - ); - // Whatever is reported must not also be written as an array, which is the - // thing that does nothing. - assert!(!merged.tsx.contains("property1={[")); -} - -/// A responsive array written one slot to a line, folded back onto one, so a -/// test can say what it expects in a line. -fn folded(tsx: &str) -> String { - let mut out = String::with_capacity(tsx.len()); - let mut items: Option> = None; - for line in tsx.lines() { - let trimmed = line.trim(); - match &mut items { - Some(collected) => { - if let Some(rest) = trimmed.strip_prefix("]}") { - out.push_str(&collected.join(", ")); - out.push_str("]}"); - out.push_str(rest); - out.push('\n'); - items = None; - } else { - collected.push(trimmed.trim_end_matches(',').to_owned()); - } - } - None => { - if trimmed.ends_with("={[") { - out.push_str(line); - items = Some(Vec::new()); - } else { - out.push_str(line); - out.push('\n'); - } - } - } - } - out -} - -/// Each element of a file with the nesting it sits at. -/// -/// Each file indents by its own unit, so nesting is counted rather than -/// measured; both bodies begin two levels in. -fn outline(source: &str, unit: usize) -> Vec<(usize, String)> { - source - .lines() - .filter_map(|line| { - let indent = line.len() - line.trim_start().len(); - let name = line - .trim_start() - .strip_prefix('<')? - .chars() - .take_while(char::is_ascii_alphanumeric) - .collect::(); - name.starts_with(char::is_uppercase) - .then(|| (indent / unit, name)) - }) - .collect() -} - -/// How many times each element appears at each nesting, so that two outlines -/// can be compared without depending on the order they were written in. -fn tally(outline: &[(usize, String)]) -> std::collections::BTreeMap<(usize, String), usize> { - let mut counts = std::collections::BTreeMap::new(); - for entry in outline { - *counts.entry(entry.clone()).or_default() += 1; - } - counts -} - -/// Every element of the reference, at the same nesting. -/// -/// The one accepted difference is that a positioned shape is written as a -/// single `Box` carrying both its placement and its mask, where the reference -/// wraps a second `Box` around it. Both draw the same thing; this is two -/// elements fewer. -#[test] -fn the_shape_of_the_output_is_the_reference_s() { - let Some(merged) = merged() else { - eprintln!("no capture; skipping"); - return; - }; - let reference = fs::read_to_string( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/plugin-answers/notice/responsive.tsx"), - ) - .expect("the plugin's answer is committed"); - - let ours = outline(&merged.tsx, 2); - let theirs = outline(&reference, 4); - - // Every element, at the nesting it sits at. Order is compared separately, - // because the two files order one pair differently on purpose. - let mut missing = tally(&theirs); - for entry in tally(&ours) { - let held = missing.remove(&entry.0).unwrap_or_default(); - assert!( - held >= entry.1, - "{:?} appears {} times here and {held} in the reference", - entry.0, - entry.1 - ); - if held > entry.1 { - missing.insert(entry.0, held - entry.1); - } - } - assert_eq!( - missing, - std::collections::BTreeMap::from([((5, "Box".to_owned()), 2)]), - "the only elements the reference has and this does not should be the \ - two shape wrappers" - ); -} - -/// The slots come from the widths; which width stands in for a missing one -/// comes from the order. -/// -/// A width that does not draw a node is given a hidden copy of the node from -/// the first width that does — first in the Section's layer order, which is -/// the order the roots are listed in and the order the plugin walks. Every -/// value of that copy lands in the array, not only its `display`, so the -/// order reaches the output and is meant to: the about hero picture is -/// `w={["770px", null, "778px", null, "770px"]}` because desktop comes first -/// in that Section. What the order must not touch is where each width lands -/// and what is drawn. -#[test] -fn the_order_of_the_widths_decides_which_one_stands_in_for_a_missing_one() { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/local-screens/bp-family.json"); - let Ok(raw) = fs::read_to_string(path) else { - eprintln!("no capture; skipping"); - return; - }; - let mut 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 forwards = merge_breakpoints(&snapshot, &CodegenOptions::default()) - .expect("merge") - .expect("three widths merge"); - - let roots = value["snapshot"]["roots"] - .as_array() - .expect("roots") - .iter() - .rev() - .cloned() - .collect::>(); - value["snapshot"]["roots"] = serde_json::Value::Array(roots); - let reversed_snapshot: Snapshot = - serde_json::from_value(value["snapshot"].clone()).expect("reversed snapshot"); - let backwards = merge_breakpoints(&reversed_snapshot, &CodegenOptions::default()) - .expect("merge") - .expect("three widths merge"); - - assert_eq!( - forwards.slots, backwards.slots, - "the slots come from the widths" - ); - assert_eq!( - outline(&forwards.tsx, 2), - outline(&backwards.tsx, 2), - "and so does what is drawn" - ); - assert_eq!(forwards.components, backwards.components); - // A note names the node it was raised on, which is the first width's; what - // it says does not depend on the order. - let details = |merged: &MergedScreen| { - merged - .unrepresented - .iter() - .map(|note| note.detail.clone()) - .collect::>() - }; - assert_eq!(details(&forwards), details(&backwards)); - assert_ne!( - forwards.tsx, backwards.tsx, - "the width that stands in for a missing one comes from the order" - ); - - // The about capture makes the rule concrete. Its roots are desktop, - // tablet, mobile, so the hidden mobile copy of the hero carries desktop's - // values; read backwards, tablet stands in and its 778px is the base. - let Some(about) = merged_from("about-family.json") else { - return; - }; - assert!( - folded(&about.tsx).contains(r#"w={["770px", null, "778px", null, "770px"]}"#), - "{}", - about.tsx - ); - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/local-screens/about-family.json"); - let mut value: serde_json::Value = - serde_json::from_str(&fs::read_to_string(path).expect("about")).expect("json"); - let roots = value["snapshot"]["roots"] - .as_array() - .expect("roots") - .iter() - .rev() - .cloned() - .collect::>(); - value["snapshot"]["roots"] = serde_json::Value::Array(roots); - let snapshot: Snapshot = serde_json::from_value(value["snapshot"].clone()).expect("snapshot"); - let backwards = merge_breakpoints(&snapshot, &CodegenOptions::default()) - .expect("merge") - .expect("three widths merge"); - assert!( - folded(&backwards.tsx).contains(r#"w={["778px", null, null, null, "770px"]}"#), - "{}", - backwards.tsx - ); -} -/// The `about` screen against the plugin's answer, line for line. -/// -/// It runs on nothing until `about-family.json` sits beside the other captures -/// and `about/responsive.tsx` beside the other answers; a screen that costs -/// more reads than a day's allowance holds is one nobody will want to check -/// twice by hand, so the suite decides it instead. -#[test] -fn the_about_screen_matches_the_answer_when_both_are_present() { - matches_the_answer( - "about-family.json", - "AboutPage", - "about", - &[0, 2, 4], - ABOUT_DIFFERS_ON_PURPOSE, - ); -} - -/// The `popup` screen against the plugin's answer. Drawn at 390 / 768 / 1920, -/// so its tablet takes slot 1, the band `about` skips. -#[test] -fn the_popup_screen_matches_the_answer_when_both_are_present() { - matches_the_answer( - "popup-family.json", - "PopupPage", - "popup", - &[0, 1, 4], - POPUP_DIFFERS_ON_PURPOSE, - ); -} - -/// The `notice` screen against the plugin's answer. -#[test] -fn the_notice_screen_matches_the_answer_when_both_are_present() { - matches_the_answer( - "bp-family.json", - "NoticePage", - "notice", - &[0, 2, 4], - NOTICE_DIFFERS_ON_PURPOSE, - ); -} - -/// Every merged module the suite can build passes the validator the server -/// offers as `devup_ui_validate`: it parses, every prop on a primitive is -/// one devup-ui takes — `as="br"` on a break Box, `display` arrays, -/// `maskImage` and the rest — and nothing in it is a runtime value. Without -/// a theme the validator can only *advise* on a hardcoded length or colour, -/// so those warnings are not counted; an error is. An output the validator -/// would reject is not a deliverable. -#[test] -fn every_merged_module_passes_the_validator() { - use devup_mcp_devup_ui::ui_validate::{Severity, validate_devup_ui_tsx}; - let mut checked = 0; - for (capture, name) in [ - ("about-family.json", "AboutPage"), - ("popup-family.json", "PopupPage"), - ("bp-family.json", "NoticePage"), - ] { - let Some(merged) = merged_from(capture) else { - continue; - }; - let module = merged.module(name); - let report = validate_devup_ui_tsx(&module, None, false); - let errors = report - .violations - .iter() - .filter(|violation| violation.severity == Severity::Error) - .map(|violation| format!("{}: {}", violation.rule, violation.message)) - .collect::>(); - assert!( - report.ok && errors.is_empty(), - "{capture} does not validate:\n {}", - errors.join("\n ") - ); - checked += 1; - } - if checked == 0 { - eprintln!("no captures; skipping"); - } -} - -/// A screen's merged module against the plugin's answer for it. -/// -/// Both files are read the same way — indentation dropped, a responsive array -/// folded onto one line, an image fill's file masked — and then every line -/// the answer has that this does not, and the reverse, must be one written -/// down in `differs_on_purpose` with its reason. Anything else is looked at -/// rather than waved through. Skips when either file is not on this machine. -fn matches_the_answer( - capture: &str, - component_name: &str, - answer: &str, - slots: &[usize], - differs_on_purpose: &[(&str, &str)], -) { - let Some(merged) = merged_from(capture) else { - eprintln!("no {answer} capture; skipping"); - return; - }; - let Ok(reference) = fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( - format!("../../fixtures/plugin-answers/{answer}/responsive.tsx"), - )) else { - eprintln!("no {answer} answer; skipping"); - return; - }; - - // The widths read off the frames themselves. - assert_eq!(merged.slots, slots, "{answer} is drawn at {slots:?}"); - - let ours = comparable_lines(&merged.module(component_name)); - let theirs = comparable_lines(&reference); - let mut only_ours = ours.clone(); - for line in &theirs { - if let Some(index) = only_ours.iter().position(|other| other == line) { - only_ours.remove(index); - } - } - let mut only_theirs = theirs.clone(); - for line in &ours { - if let Some(index) = only_theirs.iter().position(|other| other == line) { - only_theirs.remove(index); - } - } - let unexplained = |lines: &[String]| { - lines - .iter() - .filter(|line| !differs_on_purpose.iter().any(|(known, _)| known == line)) - .cloned() - .collect::>() - }; - let ours_unexplained = unexplained(&only_ours); - let theirs_unexplained = unexplained(&only_theirs); - assert!( - ours_unexplained.is_empty() && theirs_unexplained.is_empty(), - "{answer}: lines not accounted for.\n written here and not in the answer:\n {}\n in the answer and not here:\n {}", - ours_unexplained.join("\n "), - theirs_unexplained.join("\n ") - ); -} - -/// The lines on which `popup` is written differently on purpose. -/// -/// The three frames lay nothing out, and each holds one child the designer -/// centred — constraints `CENTER` / `CENTER`. Here the frame is a `Center` -/// with the card in flow, centred at every width and as tall as the frame -/// was drawn, `h` one value per width; the answer places the card with the -/// padding Figma inferred at the widths it was drawn (`pl="36.5px"` at 390px, -/// an array of them) and at desktop with none, which leaves the popup at the -/// left of a 1920px screen, in a `VStack`/`Flex` the file no longer infers. -/// The plugin as it is today would write the card `pos="absolute"` at -/// `left="50%" top="50%"` in a frame of no height, its dim never drawn. -const POPUP_DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ - ("", "the centred card is centred, not padded"), - ( - "h={[\"800px\", \"1024px\", null, null, \"1080px\"]}", - "a root with nothing in flow keeps its drawn height", - ), - ("", "inferred layout the file no longer has"), - ( - "flexDir={[\"column\", null, null, null, \"row\"]}", - "inferred layout the file no longer has", - ), - ("pl={[\"36.5px\", \"initial\"]}", "centred, not padded"), - ("pr={[\"35.5px\", \"initial\"]}", "centred, not padded"), - ( - "px={[null, \"184px\", null, null, \"initial\"]}", - "centred, not padded", - ), - ( - "py={[\"211.5px\", \"279.5px\", null, null, \"287.5px\"]}", - "centred, not padded", - ), -]; - -/// The lines on which `notice` is written differently on purpose. -/// -/// - The banner logos are `#FFF` at `opacity="0.1"` here and `bg="$text"` -/// with no opacity in the answer. The file decides it: each is a `Logo` -/// instance at opacity 0.1 (`422:6873`, `422:6874` and their siblings at -/// the other widths) whose one vector has no fill and a white stroke bound -/// to nothing, and the plugin's own `analyzeOwnSameColor` reads strokes as -/// fills are read. `$text` is not in that subtree. The answer was written -/// from another state of the file. -/// - The search icon is `$primary` here and `$text` in the answer. The file -/// decides it: the `Union` vector inside every `icons` instance -/// (`I422:6887;13:1876` and the two at the other widths) has its fill -/// bound to `VariableID:422:7203`, whose name in the collected variables -/// is `primary`. The plugin's Pure Code for the same frame writes -/// `$primary` too; only its two component-referencing outputs say `$text`. -/// - A positioned instance that folds to a shape is one `Box` carrying both -/// its place and its mask; the plugin wraps a second `Box` around it. -/// - An instance's asset is named after the instance's own layer; the plugin -/// names it after the variant component, `Property 1=search`, which every -/// component set with a `search` variant would share. -/// - The icon instance's `targetAspectRatio` (24×24) is written; the plugin's -/// component-referencing outputs drop it, its Pure Code keeps it. -const NOTICE_DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ - ( - "bg=\"$text\"", - "the file has a white stroke at 10% and a $primary fill", - ), - ("bg=\"#FFF\"", "the logo's vector has a white stroke"), - ("opacity=\"0.1\"", "the logo instance is at 10%"), - ("bg=\"$primary\"", "the icon's fill is bound to `primary`"), - ( - "aspectRatio=\"1\"", - "the icon instance's targetAspectRatio, which Pure Code keeps", - ), - ( - "", - "one Box carries place and mask", - ), - ( - "", - "one Box carries place and mask", - ), - ("", "one Box carries place and mask"), - ("left=\"44px\"", "one Box carries place and mask"), - ("top=\"169px\"", "one Box carries place and mask"), - ("left=\"-212px\"", "one Box carries place and mask"), - ("top=\"112px\"", "one Box carries place and mask"), - ("pos=\"absolute\"", "one Box carries place and mask"), - ("{/* */}", "one Box carries place and mask"), - ( - "{/* */}", - "a shape spelled out says its component", - ), - ( - "maskImage=\"url('/icons/Property 1=search.svg')\"", - "an asset is named after its instance", - ), - ( - "maskImage=\"url(/icons/icons.svg)\"", - "an asset is named after its instance", - ), -]; -/// The lines on which `about` is written differently here on purpose, each -/// with the reason. A line is listed once whichever side it is on; the test -/// above only asks that every unmatched line is one of these. Four reasons: -/// -/// - A line break at the edge of a segment is drawn (`{" "}
`); the -/// plugin counts it as a space (`{" "}`). A soft return (U+2028) is -/// drawn; the plugin passes the character through, which the answer has as -/// a space. -/// - A break that some widths have and others do not is `` -/// shown at the widths that have it; the plugin sets out to do this and -/// instead rewrites the rendered JSX of the hero's coloured span as text, -/// putting a break either side that no width drew. -/// - The hero's coloured span is kept as an element. -/// - A positioned mask icon keeps its height; the plugin writes the width -/// alone, and a mask with no height draws nothing. -const ABOUT_DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ - // The glow only the tablet draws. The plugin's single `"initial"` after - // the last value leaves mobile's `none` in effect at the tablet, so the - // answer hides it at every width; here it is shown where it is drawn. - ( - "display=\"none\"", - "plugin hides at every width the layer only the tablet draws", - ), - ( - "display={[\"none\", null, \"block\", null, \"none\"]}", - "plugin hides at every width the layer only the tablet draws", - ), - // An export carries the node's own opacity - Figma writes it into an - // SVG as `` and into a PNG's alpha (the landing hero at 0.8 - // exports with its opaque pixels at alpha 204) - so written on the - // element as well it is applied twice. A decoration at 0.2 came out at - // 0.04, which is nothing. The answer writes it on these three. - ( - "opacity=\"0.3\"", - "plugin writes an asset's opacity on the element as well, where the export already carries it, so it is applied twice", - ), - ( - "opacity=\"0.2\"", - "plugin writes an asset's opacity on the element as well, where the export already carries it, so it is applied twice", - ), - // Figma paints children in order; CSS paints a positioned element after - // every in-flow sibling. A background pinned first is under everything - // in Figma and over everything in a browser, so it is sent behind inside - // a stacking context its parent opens. Rendered against Figma's PNGs the - // landing page's hero and badges only sit right once this is written. - ( - "zIndex=\"-1\"", - "plugin writes no stacking order, so a background drawn first paints over the content in a browser", - ), - ( - "zIndex=\"0\"", - "plugin writes no stacking order, so a background drawn first paints over the content in a browser", - ), - // Figma's default counter-axis alignment is MIN, written by leaving the - // field out; CSS's default for the same thing is `stretch`, its - // opposite. A hugging child of such a parent is drawn as wide as the - // parent unless the code says otherwise, and the plugin never does - the - // devup-ui landing page's 247px `Get started` button came out 1360px - // wide. These three centre their own contents, so the wider box moves - // them; the alignment is written for them and for nothing that would - // look the same either way. - ( - "
", - "plugin leaves Figma's MIN cross-axis unsaid, which CSS reads as stretch", - ), - ( - "
", - "plugin leaves Figma's MIN cross-axis unsaid, which CSS reads as stretch", - ), - // Figma keeps a fixed child at its size and lets the line spill past the - // frame, which clips it. CSS shrinks flex children to fit instead, so - // the landing page's seven 240px comparison cards were squeezed into - // 912px, their labels wrapped, and the row came out 58px too tall. - ( - "", - "plugin lets CSS shrink a fixed child that Figma would let overflow", - ), - ( - "", - "plugin lets CSS shrink a fixed child that Figma would let overflow", - ), - ( - "", - "plugin lets CSS shrink a fixed child that Figma would let overflow", - ), - ( - "", - "plugin lets CSS shrink a fixed child that Figma would let overflow", - ), - // The hero column fills its section's height in Figma, and holds its - // picture as a positioned child. A positioned child adds nothing to the - // height of what holds it, so with the fill left unsaid CSS hugs the text - // alone: the column came out 155 tall where Figma has 440, the section - // centred that, and the picture landed 143px down over the heading it is - // meant to sit above. Rendered against Figma's own PNG, it only sits - // right once the fill is written. - ( - "flex=\"1\"", - "plugin leaves a filled height unsaid, which CSS cannot reach past a positioned child", - ), - // Image-fill files are node exports: Figma already baked the crop into - // the pixels. Reapplying imageTransform distorts those pixels. The - // background maps the exported frame once onto its layout box instead. - ( - "bg=\"url(IMAGEFILL) 0 0/100% 100% no-repeat, $gray200\"", - "cropped node export already contains the imageTransform", - ), - ( - "bg=\"url(IMAGEFILL) center/cover no-repeat, $gray200\"", - "plugin applies cover to the already-cropped node export", - ), - ( - "성인 ADHD,{\" \"}", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "
성인 ADHD,{\" \"}
우리는 다르게 봅니다.", - "plugin rewrites rendered JSX as text", - ), - ( - "퍼즐핏은 당사자의 경험에서 출발한, 성인 ADHD 전문 플랫폼입니다.", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "저는 성인 ADHD 당사자이자 정신건강간호사입니다. 진단을 받기까지 수년이 걸렸고, 그 과정에서 수많은 좌절과 시행착오를 겪었습니다. 그 경험을 통해 알게 된 것이 있습니다.", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.{\" \"}", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "{\" \"}우리는 단순한 진단 도구를 만드는 것이 아니라, 당사자가 스스로를 이해하고 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 만들고 있습니다", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "{\" \"}같은 어려움을 겪더라도 각자가 만들어온{\" \"}", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.{\" \"}", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "우리는 단순한 검사 도구를 만드는 것이 아닙니다. 퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서, 코칭과 커뮤니티를 통해
당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고 있습니다.", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "우리는 단순한 검사 도구를 만드는 것이 아닙니다. 퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서,
코칭과 커뮤니티를 통해 당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고
있습니다.", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "퍼즐핏이 바라는 미래는
성인 ADHD가 ‘진단명’으로만 불리는 것이 아니라,{\" \"}", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ("w=\"465px\"", "mask icon height"), - ( - "전문가와 함께 만드는, 신뢰 기반의 성인 ADHD 솔루션{\" \"}", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "{\" \"}퍼즐핏은 단순한 검사 도구 제공이 아닌{\" \"}", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "을 통해 당사자가 사회 속에서 더 잘 기능하도록 돕는 통합 솔루션입니다.{\" \"}", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ( - "전문가와 함께 만드는,
신뢰 기반의 성인 ADHD 솔루션{\" \"}", - "plugin: a break at a segment edge is a space, a soft return passes through", - ), - ("", "the span kept as an element"), - ( - "성인 ADHD,{\" \"}
", - "a break at a segment edge or a soft return is drawn", - ), - ("
", "the span kept as an element"), - ("우리는 다르게 봅니다.", "the span kept as an element"), - ( - "성인 ADHD,{\" \"}", - "a break some widths have, shown at those widths", - ), - ( - "퍼즐핏은 당사자의 경험에서 출발한, 성인 ADHD 전문 플랫폼입니다.", - "a break some widths have, shown at those widths", - ), - ( - "{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.

", - "a break at a segment edge or a soft return is drawn", - ), - ( - "

우리는 단순한 진단 도구를 만드는 것이 아니라,
당사자가 스스로를 이해하고 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 만들고 있습니다", - "a break at a segment edge or a soft return is drawn", - ), - ( - "저는 성인 ADHD 당사자이자 정신건강간호사입니다.
진단을 받기까지 수년이 걸렸고, 그 과정에서 수많은 좌절과 시행착오를 겪었습니다.
그 경험을 통해 알게 된 것이 있습니다.", - "a break at a segment edge or a soft return is drawn", - ), - ( - "
{\" \"}같은 어려움을 겪더라도 각자가 만들어온{\" \"}", - "a break at a segment edge or a soft return is drawn", - ), - ( - "
{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.

", - "a break at a segment edge or a soft return is drawn", - ), - ( - "우리는 단순한 검사 도구를 만드는 것이 아닙니다.
퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서, 코칭과 커뮤니티를 통해
당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고 있습니다.", - "a break at a segment edge or a soft return is drawn", - ), - ( - "우리는 단순한 검사 도구를 만드는 것이 아닙니다.
퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서,
코칭과 커뮤니티를 통해 당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고
있습니다.", - "a break at a segment edge or a soft return is drawn", - ), - ( - "퍼즐핏이 바라는 미래는
성인 ADHD가 ‘진단명’으로만 불리는 것이 아니라,
", - "a break at a segment edge or a soft return is drawn", - ), - ("boxSize=\"465px\"", "mask icon height"), - ( - "전문가와 함께 만드는, 신뢰 기반의 성인 ADHD 솔루션
", - "a break at a segment edge or a soft return is drawn", - ), - ( - "
퍼즐핏은 단순한 검사 도구 제공이 아닌{\" \"}", - "a break at a segment edge or a soft return is drawn", - ), - ( - "을 통해
당사자가 사회 속에서 더 잘 기능하도록 돕는 통합 솔루션입니다.

", - "a break at a segment edge or a soft return is drawn", - ), - ( - "전문가와 함께 만드는,
신뢰 기반의 성인 ADHD 솔루션
", - "a break at a segment edge or a soft return is drawn", - ), - ( - "을 통해 당사자가 사회 속에서 더 잘 기능하도록 돕는 통합 솔루션입니다.

", - "a break at a segment edge or a soft return is drawn", - ), -]; - -/// A file's lines as they are compared: indentation and blank lines dropped, -/// a responsive array folded onto one line, and an image fill's file masked — -/// the plugin writes every one as its fixed `/icons/image.png`, and this -/// writes the file the export actually produces. -fn comparable_lines(source: &str) -> Vec { - folded(source) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(|line| { - let mut line = line.replace("url(/icons/image.png)", "url(IMAGEFILL)"); - while let Some(start) = line - .find("url('/images/") - .or_else(|| line.find("url(/images/")) - { - let Some(end) = line[start..].find(')') else { - break; - }; - line.replace_range(start..start + end + 1, "url(IMAGEFILL)"); - } - line - }) - .collect() -} - -/// Every element the screen draws, as tags, ignoring the ones only mentioned -/// inside a `{/* … */}` comment — those are deliberately not rendered and so -/// deliberately not imported. -fn rendered_tags(tsx: &str) -> std::collections::BTreeSet { - let mut uncommented = String::with_capacity(tsx.len()); - let mut rest = tsx; - while let Some(start) = rest.find("{/*") { - uncommented.push_str(&rest[..start]); - match rest[start..].find("*/}") { - Some(end) => rest = &rest[start + end + 3..], - None => { - rest = ""; - break; - } - } - } - uncommented.push_str(rest); - - let mut tags = std::collections::BTreeSet::new(); - let bytes = uncommented.as_bytes(); - for (index, _) in uncommented.match_indices('<') { - let name = uncommented[index + 1..] - .chars() - .take_while(|character| character.is_alphanumeric() || *character == '_') - .collect::(); - let starts_upper = name.chars().next().is_some_and(char::is_uppercase); - // `
` closes what `>(); - assert_eq!( - declared.len(), - primitives.len() + components.len(), - "a name must be a primitive or a component of this design, not both" - ); - - let drawn = rendered_tags(&merged.tsx); - assert!(!drawn.is_empty(), "the screen draws something"); - let unimported = drawn.difference(&declared).collect::>(); - assert!( - unimported.is_empty(), - "drawn but not imported: {unimported:?}" - ); - - let module = merged.module("AboutPage"); - - // Imports first, then one default export holding the tree. - let export = module - .find("export default function AboutPage()") - .expect("a default export named after the screen"); - for line in module[..export] - .lines() - .filter(|line| !line.trim().is_empty()) - { - assert!( - line.starts_with("import "), - "only imports may precede the export: {line}" - ); - } - assert_eq!( - module.matches("export default function").count(), - 1, - "one screen, one default export" - ); - assert!( - module.contains(&merged.tsx), - "the module must carry the merged tree unchanged" - ); - - // Each component is imported once, from its own file. - let imports = module[..export] - .lines() - .filter(|line| line.starts_with("import ")) - .collect::>(); - let unique = imports.iter().collect::>(); - assert_eq!(unique.len(), imports.len(), "no import written twice"); - for name in &components { - assert!( - module.contains(&format!("import {{ {name} }} from '@/components/{name}'")), - "{name} must be imported from its own file" - ); - } - if !primitives.is_empty() { - assert!( - module.contains("from '@devup-ui/react'"), - "primitives come from devup-ui" - ); - } -} +// Source sections share this module to preserve privacy and test names. +include!("responsive_screen/fixtures_and_cases.rs"); +include!("responsive_screen/every_element_the_module_draws_is_one_it_imports.rs"); diff --git a/crates/devup-mcp-devup-ui/tests/responsive_screen/every_element_the_module_draws_is_one_it_imports.rs b/crates/devup-mcp-devup-ui/tests/responsive_screen/every_element_the_module_draws_is_one_it_imports.rs new file mode 100644 index 00000000..98f95ec8 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_screen/every_element_the_module_draws_is_one_it_imports.rs @@ -0,0 +1,84 @@ + + +/// The module is what `devup_figma_export` hands back as `responsiveTsx`, and +/// it is assembled from the merge rather than emitted by it — imports on one +/// side, the tree on the other. Nothing here checked that the two agree on a +/// real screen: the merge's own tests stop at `tsx`, and the export test that +/// covers this wiring runs against a stub. An element drawn but not imported +/// is a file that does not compile, and it would only be discovered after a +/// capture had been paid for. +#[test] +fn every_element_the_module_draws_is_one_it_imports() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + + let primitives = merged.primitives(); + let components = merged.referenced_components(); + + // The two lists are the one set of components, split and not overlapping. + let declared = primitives + .iter() + .chain(components.iter()) + .map(|name| (*name).to_owned()) + .collect::>(); + assert_eq!( + declared.len(), + primitives.len() + components.len(), + "a name must be a primitive or a component of this design, not both" + ); + + let drawn = rendered_tags(&merged.tsx); + assert!(!drawn.is_empty(), "the screen draws something"); + let unimported = drawn.difference(&declared).collect::>(); + assert!( + unimported.is_empty(), + "drawn but not imported: {unimported:?}" + ); + + let module = merged.module("AboutPage"); + + // Imports first, then one default export holding the tree. + let export = module + .find("export default function AboutPage()") + .expect("a default export named after the screen"); + for line in module[..export] + .lines() + .filter(|line| !line.trim().is_empty()) + { + assert!( + line.starts_with("import "), + "only imports may precede the export: {line}" + ); + } + assert_eq!( + module.matches("export default function").count(), + 1, + "one screen, one default export" + ); + assert!( + module.contains(&merged.tsx), + "the module must carry the merged tree unchanged" + ); + + // Each component is imported once, from its own file. + let imports = module[..export] + .lines() + .filter(|line| line.starts_with("import ")) + .collect::>(); + let unique = imports.iter().collect::>(); + assert_eq!(unique.len(), imports.len(), "no import written twice"); + for name in &components { + assert!( + module.contains(&format!("import {{ {name} }} from '@/components/{name}'")), + "{name} must be imported from its own file" + ); + } + if !primitives.is_empty() { + assert!( + module.contains("from '@devup-ui/react'"), + "primitives come from devup-ui" + ); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_screen/fixtures_and_cases.rs b/crates/devup-mcp-devup-ui/tests/responsive_screen/fixtures_and_cases.rs new file mode 100644 index 00000000..ecb35012 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/responsive_screen/fixtures_and_cases.rs @@ -0,0 +1,926 @@ + +use std::{fs, path::PathBuf}; + +use devup_mcp_devup_ui::codegen::{ + CodegenOptions, + responsive::{MergedScreen, merge_breakpoints}, +}; +use devup_mcp_figma::{Snapshot, UpstreamResult}; + +fn merged() -> Option { + merged_from("bp-family.json") +} + +/// The merge of a capture in `fixtures/local-screens`, or `None` when that +/// capture is not on this machine. +/// +/// A capture may carry a `payload` beside its `snapshot` — the variables and +/// styles the export collected with it. They are what the converter names +/// tokens from, and without them a `$gray200` fill is written as the tail of +/// its variable ID and a `typography="h4"` as five font props, which is not +/// what the reference wrote and not what the server would write either. A +/// capture without one is converted as before. +fn merged_from(capture: &str) -> Option { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/local-screens") + .join(capture); + let raw = fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&raw).ok()?; + let snapshot: Snapshot = serde_json::from_value(value.get("snapshot")?.clone()).ok()?; + let resource = |name: &str| { + value + .get("payload") + .and_then(|payload| payload.get(name)) + .cloned() + .map(|raw| UpstreamResult { raw }) + }; + let variables = resource("variables"); + let styles = resource("styles"); + let options = + CodegenOptions::default().with_resource_results(variables.as_ref(), styles.as_ref()); + merge_breakpoints(&snapshot, &options).ok()? +} + +/// 360 / 992 / 1920, so slots 0 / 2 / 4 — the same three the reference's arrays +/// are written at. +#[test] +fn the_widths_land_where_their_sizes_put_them() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + assert_eq!(merged.slots, vec![0, 2, 4]); +} + +/// The four arrays in `notice/responsive.tsx`, and no fifth. Each is a region +/// one width draws and another does not, so the copies are kept and shown at +/// their own widths. +#[test] +fn the_display_arrays_are_the_reference_s() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + let folded = folded(&merged.tsx); + let written = folded + .lines() + .filter_map(|line| line.trim().split_once("display=").map(|(_, rest)| rest)) + .map(|value| { + value + .split_once("}") + .map_or(value, |(inside, _)| inside) + .trim_start_matches('{') + .to_owned() + }) + .collect::>(); + assert_eq!( + written, + [ + // the mobile banner, on an element that needs no display to show + r#"[null, null, "none"]"#, + // the desktop banner + r#"["none", null, "flex"]"#, + // the desktop content section + r#"["none", null, "flex"]"#, + // the mobile content section + r#"["flex", null, "none"]"#, + ] + ); +} + +/// A component answers for its own widths, so it is referenced once and not +/// descended into. An instance whose component is only a shape is spelled out +/// instead, which is why no `` or `` appears. +#[test] +fn components_are_referenced_and_shapes_are_spelled_out() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + for referenced in ["Header", "Footer", "Tab", "Pagination"] { + assert!( + merged.components.contains(referenced), + "{referenced} should be referenced: {:?}", + merged.components + ); + } + for inlined in ["Logo", "Icons"] { + assert!( + !merged.components.contains(inlined), + "{inlined} is an asset and should have been spelled out" + ); + } + // Spelling it out loses which component it came from, and that is the one + // thing a reader needs to change it in the right place. The name is the one + // the definition declares: `Icons` types its property `Property 1` where + // every other component in this file uses the Korean `속성 1`, and the two + // sanitize differently. + assert!(merged.tsx.contains("{/* */}"), "{}", merged.tsx); + assert!( + merged + .tsx + .contains(r#"{/* */}"#), + "{}", + merged.tsx + ); + assert!(merged.tsx.contains(r#"
"#)); + // `effect` names the interaction state a variant stands for, and the + // definition folds those into `_hover` / `_active`. A call site has no + // state to pass, so `` would be asking for a prop + // the component does not have. The reference writes it bare. + assert!( + merged.tsx.contains(""), + "Tab should take no props: {}", + merged + .tsx + .lines() + .filter(|line| line.contains(">() + .join("\n") + ); + assert!( + !merged.tsx.contains("effect="), + "no reserved variant key should reach a call site" + ); + // The instance is absolutely placed and `
` has nowhere to put + // that, so it is wrapped. + assert!( + merged + .tsx + .contains(r#""#) + ); +} + +/// The screen asks for a different `Header` and `Footer` variant per width. +/// devup-ui reads an array only where it applies CSS, so a component prop +/// cannot carry one; the widest is kept and both places are named. +#[test] +fn a_variant_that_differs_by_width_is_reported_not_faked() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + let said = merged + .unrepresented + .iter() + .map(|note| note.detail.as_str()) + .collect::>(); + assert_eq!(said.len(), 2, "{said:?}"); + assert!( + said.iter() + .any(|note| note.starts_with("Header takes property1")) + ); + assert!( + said.iter() + .any(|note| note.starts_with("Footer takes property1")) + ); + // Whatever is reported must not also be written as an array, which is the + // thing that does nothing. + assert!(!merged.tsx.contains("property1={[")); +} + +/// A responsive array written one slot to a line, folded back onto one, so a +/// test can say what it expects in a line. +fn folded(tsx: &str) -> String { + let mut out = String::with_capacity(tsx.len()); + let mut items: Option> = None; + for line in tsx.lines() { + let trimmed = line.trim(); + match &mut items { + Some(collected) => { + if let Some(rest) = trimmed.strip_prefix("]}") { + out.push_str(&collected.join(", ")); + out.push_str("]}"); + out.push_str(rest); + out.push('\n'); + items = None; + } else { + collected.push(trimmed.trim_end_matches(',').to_owned()); + } + } + None => { + if trimmed.ends_with("={[") { + out.push_str(line); + items = Some(Vec::new()); + } else { + out.push_str(line); + out.push('\n'); + } + } + } + } + out +} + +/// Each element of a file with the nesting it sits at. +/// +/// Each file indents by its own unit, so nesting is counted rather than +/// measured; both bodies begin two levels in. +fn outline(source: &str, unit: usize) -> Vec<(usize, String)> { + source + .lines() + .filter_map(|line| { + let indent = line.len() - line.trim_start().len(); + let name = line + .trim_start() + .strip_prefix('<')? + .chars() + .take_while(char::is_ascii_alphanumeric) + .collect::(); + name.starts_with(char::is_uppercase) + .then(|| (indent / unit, name)) + }) + .collect() +} + +/// How many times each element appears at each nesting, so that two outlines +/// can be compared without depending on the order they were written in. +fn tally(outline: &[(usize, String)]) -> std::collections::BTreeMap<(usize, String), usize> { + let mut counts = std::collections::BTreeMap::new(); + for entry in outline { + *counts.entry(entry.clone()).or_default() += 1; + } + counts +} + +/// Every element of the reference, at the same nesting. +/// +/// The one accepted difference is that a positioned shape is written as a +/// single `Box` carrying both its placement and its mask, where the reference +/// wraps a second `Box` around it. Both draw the same thing; this is two +/// elements fewer. +#[test] +fn the_shape_of_the_output_is_the_reference_s() { + let Some(merged) = merged() else { + eprintln!("no capture; skipping"); + return; + }; + let reference = fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/plugin-answers/notice/responsive.tsx"), + ) + .expect("the plugin's answer is committed"); + + let ours = outline(&merged.tsx, 2); + let theirs = outline(&reference, 4); + + // Every element, at the nesting it sits at. Order is compared separately, + // because the two files order one pair differently on purpose. + let mut missing = tally(&theirs); + for entry in tally(&ours) { + let held = missing.remove(&entry.0).unwrap_or_default(); + assert!( + held >= entry.1, + "{:?} appears {} times here and {held} in the reference", + entry.0, + entry.1 + ); + if held > entry.1 { + missing.insert(entry.0, held - entry.1); + } + } + assert_eq!( + missing, + std::collections::BTreeMap::from([((5, "Box".to_owned()), 2)]), + "the only elements the reference has and this does not should be the \ + two shape wrappers" + ); +} + +/// The slots come from the widths; which width stands in for a missing one +/// comes from the order. +/// +/// A width that does not draw a node is given a hidden copy of the node from +/// the first width that does — first in the Section's layer order, which is +/// the order the roots are listed in and the order the plugin walks. Every +/// value of that copy lands in the array, not only its `display`, so the +/// order reaches the output and is meant to: the about hero picture is +/// `w={["770px", null, "778px", null, "770px"]}` because desktop comes first +/// in that Section. What the order must not touch is where each width lands +/// and what is drawn. +#[test] +fn the_order_of_the_widths_decides_which_one_stands_in_for_a_missing_one() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/local-screens/bp-family.json"); + let Ok(raw) = fs::read_to_string(path) else { + eprintln!("no capture; skipping"); + return; + }; + let mut 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 forwards = merge_breakpoints(&snapshot, &CodegenOptions::default()) + .expect("merge") + .expect("three widths merge"); + + let roots = value["snapshot"]["roots"] + .as_array() + .expect("roots") + .iter() + .rev() + .cloned() + .collect::>(); + value["snapshot"]["roots"] = serde_json::Value::Array(roots); + let reversed_snapshot: Snapshot = + serde_json::from_value(value["snapshot"].clone()).expect("reversed snapshot"); + let backwards = merge_breakpoints(&reversed_snapshot, &CodegenOptions::default()) + .expect("merge") + .expect("three widths merge"); + + assert_eq!( + forwards.slots, backwards.slots, + "the slots come from the widths" + ); + assert_eq!( + outline(&forwards.tsx, 2), + outline(&backwards.tsx, 2), + "and so does what is drawn" + ); + assert_eq!(forwards.components, backwards.components); + // A note names the node it was raised on, which is the first width's; what + // it says does not depend on the order. + let details = |merged: &MergedScreen| { + merged + .unrepresented + .iter() + .map(|note| note.detail.clone()) + .collect::>() + }; + assert_eq!(details(&forwards), details(&backwards)); + assert_ne!( + forwards.tsx, backwards.tsx, + "the width that stands in for a missing one comes from the order" + ); + + // The about capture makes the rule concrete. Its roots are desktop, + // tablet, mobile, so the hidden mobile copy of the hero carries desktop's + // values; read backwards, tablet stands in and its 778px is the base. + let Some(about) = merged_from("about-family.json") else { + return; + }; + assert!( + folded(&about.tsx).contains(r#"w={["770px", null, "778px", null, "770px"]}"#), + "{}", + about.tsx + ); + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/local-screens/about-family.json"); + let mut value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(path).expect("about")).expect("json"); + let roots = value["snapshot"]["roots"] + .as_array() + .expect("roots") + .iter() + .rev() + .cloned() + .collect::>(); + value["snapshot"]["roots"] = serde_json::Value::Array(roots); + let snapshot: Snapshot = serde_json::from_value(value["snapshot"].clone()).expect("snapshot"); + let backwards = merge_breakpoints(&snapshot, &CodegenOptions::default()) + .expect("merge") + .expect("three widths merge"); + assert!( + folded(&backwards.tsx).contains(r#"w={["778px", null, null, null, "770px"]}"#), + "{}", + backwards.tsx + ); +} +/// The `about` screen against the plugin's answer, line for line. +/// +/// It runs on nothing until `about-family.json` sits beside the other captures +/// and `about/responsive.tsx` beside the other answers; a screen that costs +/// more reads than a day's allowance holds is one nobody will want to check +/// twice by hand, so the suite decides it instead. +#[test] +fn the_about_screen_matches_the_answer_when_both_are_present() { + matches_the_answer( + "about-family.json", + "AboutPage", + "about", + &[0, 2, 4], + ABOUT_DIFFERS_ON_PURPOSE, + ); +} + +/// The `popup` screen against the plugin's answer. Drawn at 390 / 768 / 1920, +/// so its tablet takes slot 1, the band `about` skips. +#[test] +fn the_popup_screen_matches_the_answer_when_both_are_present() { + matches_the_answer( + "popup-family.json", + "PopupPage", + "popup", + &[0, 1, 4], + POPUP_DIFFERS_ON_PURPOSE, + ); +} + +/// The `notice` screen against the plugin's answer. +#[test] +fn the_notice_screen_matches_the_answer_when_both_are_present() { + matches_the_answer( + "bp-family.json", + "NoticePage", + "notice", + &[0, 2, 4], + NOTICE_DIFFERS_ON_PURPOSE, + ); +} + +/// Every merged module the suite can build passes the validator the server +/// offers as `devup_ui_validate`: it parses, every prop on a primitive is +/// one devup-ui takes — `as="br"` on a break Box, `display` arrays, +/// `maskImage` and the rest — and nothing in it is a runtime value. Without +/// a theme the validator can only *advise* on a hardcoded length or colour, +/// so those warnings are not counted; an error is. An output the validator +/// would reject is not a deliverable. +#[test] +fn every_merged_module_passes_the_validator() { + use devup_mcp_devup_ui::ui_validate::{Severity, validate_devup_ui_tsx}; + let mut checked = 0; + for (capture, name) in [ + ("about-family.json", "AboutPage"), + ("popup-family.json", "PopupPage"), + ("bp-family.json", "NoticePage"), + ] { + let Some(merged) = merged_from(capture) else { + continue; + }; + let module = merged.module(name); + let report = validate_devup_ui_tsx(&module, None, false); + let errors = report + .violations + .iter() + .filter(|violation| violation.severity == Severity::Error) + .map(|violation| format!("{}: {}", violation.rule, violation.message)) + .collect::>(); + assert!( + report.ok && errors.is_empty(), + "{capture} does not validate:\n {}", + errors.join("\n ") + ); + checked += 1; + } + if checked == 0 { + eprintln!("no captures; skipping"); + } +} + +/// A screen's merged module against the plugin's answer for it. +/// +/// Both files are read the same way — indentation dropped, a responsive array +/// folded onto one line, an image fill's file masked — and then every line +/// the answer has that this does not, and the reverse, must be one written +/// down in `differs_on_purpose` with its reason. Anything else is looked at +/// rather than waved through. Skips when either file is not on this machine. +fn matches_the_answer( + capture: &str, + component_name: &str, + answer: &str, + slots: &[usize], + differs_on_purpose: &[(&str, &str)], +) { + let Some(merged) = merged_from(capture) else { + eprintln!("no {answer} capture; skipping"); + return; + }; + let Ok(reference) = fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join( + format!("../../fixtures/plugin-answers/{answer}/responsive.tsx"), + )) else { + eprintln!("no {answer} answer; skipping"); + return; + }; + + // The widths read off the frames themselves. + assert_eq!(merged.slots, slots, "{answer} is drawn at {slots:?}"); + + let ours = comparable_lines(&merged.module(component_name)); + let theirs = comparable_lines(&reference); + let mut only_ours = ours.clone(); + for line in &theirs { + if let Some(index) = only_ours.iter().position(|other| other == line) { + only_ours.remove(index); + } + } + let mut only_theirs = theirs.clone(); + for line in &ours { + if let Some(index) = only_theirs.iter().position(|other| other == line) { + only_theirs.remove(index); + } + } + let unexplained = |lines: &[String]| { + lines + .iter() + .filter(|line| !differs_on_purpose.iter().any(|(known, _)| known == line)) + .cloned() + .collect::>() + }; + let ours_unexplained = unexplained(&only_ours); + let theirs_unexplained = unexplained(&only_theirs); + assert!( + ours_unexplained.is_empty() && theirs_unexplained.is_empty(), + "{answer}: lines not accounted for.\n written here and not in the answer:\n {}\n in the answer and not here:\n {}", + ours_unexplained.join("\n "), + theirs_unexplained.join("\n ") + ); +} + +/// The lines on which `popup` is written differently on purpose. +/// +/// The three frames lay nothing out, and each holds one child the designer +/// centred — constraints `CENTER` / `CENTER`. Here the frame is a `Center` +/// with the card in flow, centred at every width and as tall as the frame +/// was drawn, `h` one value per width; the answer places the card with the +/// padding Figma inferred at the widths it was drawn (`pl="36.5px"` at 390px, +/// an array of them) and at desktop with none, which leaves the popup at the +/// left of a 1920px screen, in a `VStack`/`Flex` the file no longer infers. +/// The plugin as it is today would write the card `pos="absolute"` at +/// `left="50%" top="50%"` in a frame of no height, its dim never drawn. +const POPUP_DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ + ("", "the centred card is centred, not padded"), + ( + "h={[\"800px\", \"1024px\", null, null, \"1080px\"]}", + "a root with nothing in flow keeps its drawn height", + ), + ("", "inferred layout the file no longer has"), + ( + "flexDir={[\"column\", null, null, null, \"row\"]}", + "inferred layout the file no longer has", + ), + ("pl={[\"36.5px\", \"initial\"]}", "centred, not padded"), + ("pr={[\"35.5px\", \"initial\"]}", "centred, not padded"), + ( + "px={[null, \"184px\", null, null, \"initial\"]}", + "centred, not padded", + ), + ( + "py={[\"211.5px\", \"279.5px\", null, null, \"287.5px\"]}", + "centred, not padded", + ), +]; + +/// The lines on which `notice` is written differently on purpose. +/// +/// - The banner logos are `#FFF` at `opacity="0.1"` here and `bg="$text"` +/// with no opacity in the answer. The file decides it: each is a `Logo` +/// instance at opacity 0.1 (`422:6873`, `422:6874` and their siblings at +/// the other widths) whose one vector has no fill and a white stroke bound +/// to nothing, and the plugin's own `analyzeOwnSameColor` reads strokes as +/// fills are read. `$text` is not in that subtree. The answer was written +/// from another state of the file. +/// - The search icon is `$primary` here and `$text` in the answer. The file +/// decides it: the `Union` vector inside every `icons` instance +/// (`I422:6887;13:1876` and the two at the other widths) has its fill +/// bound to `VariableID:422:7203`, whose name in the collected variables +/// is `primary`. The plugin's Pure Code for the same frame writes +/// `$primary` too; only its two component-referencing outputs say `$text`. +/// - A positioned instance that folds to a shape is one `Box` carrying both +/// its place and its mask; the plugin wraps a second `Box` around it. +/// - An instance's asset is named after the instance's own layer; the plugin +/// names it after the variant component, `Property 1=search`, which every +/// component set with a `search` variant would share. +/// - The icon instance's `targetAspectRatio` (24×24) is written; the plugin's +/// component-referencing outputs drop it, its Pure Code keeps it. +const NOTICE_DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ + ( + "bg=\"$text\"", + "the file has a white stroke at 10% and a $primary fill", + ), + ("bg=\"#FFF\"", "the logo's vector has a white stroke"), + ("opacity=\"0.1\"", "the logo instance is at 10%"), + ("bg=\"$primary\"", "the icon's fill is bound to `primary`"), + ( + "aspectRatio=\"1\"", + "the icon instance's targetAspectRatio, which Pure Code keeps", + ), + ( + "", + "one Box carries place and mask", + ), + ( + "", + "one Box carries place and mask", + ), + ("", "one Box carries place and mask"), + ("left=\"44px\"", "one Box carries place and mask"), + ("top=\"169px\"", "one Box carries place and mask"), + ("left=\"-212px\"", "one Box carries place and mask"), + ("top=\"112px\"", "one Box carries place and mask"), + ("pos=\"absolute\"", "one Box carries place and mask"), + ("{/* */}", "one Box carries place and mask"), + ( + "{/* */}", + "a shape spelled out says its component", + ), + ( + "maskImage=\"url('/icons/Property 1=search.svg')\"", + "an asset is named after its instance", + ), + ( + "maskImage=\"url(/icons/icons.svg)\"", + "an asset is named after its instance", + ), +]; +/// The lines on which `about` is written differently here on purpose, each +/// with the reason. A line is listed once whichever side it is on; the test +/// above only asks that every unmatched line is one of these. Four reasons: +/// +/// - A line break at the edge of a segment is drawn (`{" "}
`); the +/// plugin counts it as a space (`{" "}`). A soft return (U+2028) is +/// drawn; the plugin passes the character through, which the answer has as +/// a space. +/// - A break that some widths have and others do not is `` +/// shown at the widths that have it; the plugin sets out to do this and +/// instead rewrites the rendered JSX of the hero's coloured span as text, +/// putting a break either side that no width drew. +/// - The hero's coloured span is kept as an element. +/// - A positioned mask icon keeps its height; the plugin writes the width +/// alone, and a mask with no height draws nothing. +const ABOUT_DIFFERS_ON_PURPOSE: &[(&str, &str)] = &[ + // The glow only the tablet draws. The plugin's single `"initial"` after + // the last value leaves mobile's `none` in effect at the tablet, so the + // answer hides it at every width; here it is shown where it is drawn. + ( + "display=\"none\"", + "plugin hides at every width the layer only the tablet draws", + ), + ( + "display={[\"none\", null, \"block\", null, \"none\"]}", + "plugin hides at every width the layer only the tablet draws", + ), + // An export carries the node's own opacity - Figma writes it into an + // SVG as `` and into a PNG's alpha (the landing hero at 0.8 + // exports with its opaque pixels at alpha 204) - so written on the + // element as well it is applied twice. A decoration at 0.2 came out at + // 0.04, which is nothing. The answer writes it on these three. + ( + "opacity=\"0.3\"", + "plugin writes an asset's opacity on the element as well, where the export already carries it, so it is applied twice", + ), + ( + "opacity=\"0.2\"", + "plugin writes an asset's opacity on the element as well, where the export already carries it, so it is applied twice", + ), + // Figma paints children in order; CSS paints a positioned element after + // every in-flow sibling. A background pinned first is under everything + // in Figma and over everything in a browser, so it is sent behind inside + // a stacking context its parent opens. Rendered against Figma's PNGs the + // landing page's hero and badges only sit right once this is written. + ( + "zIndex=\"-1\"", + "plugin writes no stacking order, so a background drawn first paints over the content in a browser", + ), + ( + "zIndex=\"0\"", + "plugin writes no stacking order, so a background drawn first paints over the content in a browser", + ), + // Figma's default counter-axis alignment is MIN, written by leaving the + // field out; CSS's default for the same thing is `stretch`, its + // opposite. A hugging child of such a parent is drawn as wide as the + // parent unless the code says otherwise, and the plugin never does - the + // devup-ui landing page's 247px `Get started` button came out 1360px + // wide. These three centre their own contents, so the wider box moves + // them; the alignment is written for them and for nothing that would + // look the same either way. + ( + "
", + "plugin leaves Figma's MIN cross-axis unsaid, which CSS reads as stretch", + ), + ( + "
", + "plugin leaves Figma's MIN cross-axis unsaid, which CSS reads as stretch", + ), + // Figma keeps a fixed child at its size and lets the line spill past the + // frame, which clips it. CSS shrinks flex children to fit instead, so + // the landing page's seven 240px comparison cards were squeezed into + // 912px, their labels wrapped, and the row came out 58px too tall. + ( + "", + "plugin lets CSS shrink a fixed child that Figma would let overflow", + ), + ( + "", + "plugin lets CSS shrink a fixed child that Figma would let overflow", + ), + ( + "", + "plugin lets CSS shrink a fixed child that Figma would let overflow", + ), + ( + "", + "plugin lets CSS shrink a fixed child that Figma would let overflow", + ), + // The hero column fills its section's height in Figma, and holds its + // picture as a positioned child. A positioned child adds nothing to the + // height of what holds it, so with the fill left unsaid CSS hugs the text + // alone: the column came out 155 tall where Figma has 440, the section + // centred that, and the picture landed 143px down over the heading it is + // meant to sit above. Rendered against Figma's own PNG, it only sits + // right once the fill is written. + ( + "flex=\"1\"", + "plugin leaves a filled height unsaid, which CSS cannot reach past a positioned child", + ), + // Image-fill files are node exports: Figma already baked the crop into + // the pixels. Reapplying imageTransform distorts those pixels. The + // background maps the exported frame once onto its layout box instead. + ( + "bg=\"url(IMAGEFILL) 0 0/100% 100% no-repeat, $gray200\"", + "cropped node export already contains the imageTransform", + ), + ( + "bg=\"url(IMAGEFILL) center/cover no-repeat, $gray200\"", + "plugin applies cover to the already-cropped node export", + ), + ( + "성인 ADHD,{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "
성인 ADHD,{\" \"}
우리는 다르게 봅니다.", + "plugin rewrites rendered JSX as text", + ), + ( + "퍼즐핏은 당사자의 경험에서 출발한, 성인 ADHD 전문 플랫폼입니다.", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "저는 성인 ADHD 당사자이자 정신건강간호사입니다. 진단을 받기까지 수년이 걸렸고, 그 과정에서 수많은 좌절과 시행착오를 겪었습니다. 그 경험을 통해 알게 된 것이 있습니다.", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "{\" \"}우리는 단순한 진단 도구를 만드는 것이 아니라, 당사자가 스스로를 이해하고 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 만들고 있습니다", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "{\" \"}같은 어려움을 겪더라도 각자가 만들어온{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "우리는 단순한 검사 도구를 만드는 것이 아닙니다. 퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서, 코칭과 커뮤니티를 통해
당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고 있습니다.", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "우리는 단순한 검사 도구를 만드는 것이 아닙니다. 퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서,
코칭과 커뮤니티를 통해 당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고
있습니다.", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "퍼즐핏이 바라는 미래는
성인 ADHD가 ‘진단명’으로만 불리는 것이 아니라,{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ("w=\"465px\"", "mask icon height"), + ( + "전문가와 함께 만드는, 신뢰 기반의 성인 ADHD 솔루션{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "{\" \"}퍼즐핏은 단순한 검사 도구 제공이 아닌{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "을 통해 당사자가 사회 속에서 더 잘 기능하도록 돕는 통합 솔루션입니다.{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ( + "전문가와 함께 만드는,
신뢰 기반의 성인 ADHD 솔루션{\" \"}", + "plugin: a break at a segment edge is a space, a soft return passes through", + ), + ("", "the span kept as an element"), + ( + "성인 ADHD,{\" \"}
", + "a break at a segment edge or a soft return is drawn", + ), + ("
", "the span kept as an element"), + ("우리는 다르게 봅니다.", "the span kept as an element"), + ( + "성인 ADHD,{\" \"}", + "a break some widths have, shown at those widths", + ), + ( + "퍼즐핏은 당사자의 경험에서 출발한, 성인 ADHD 전문 플랫폼입니다.", + "a break some widths have, shown at those widths", + ), + ( + "{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.

", + "a break at a segment edge or a soft return is drawn", + ), + ( + "

우리는 단순한 진단 도구를 만드는 것이 아니라,
당사자가 스스로를 이해하고 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 만들고 있습니다", + "a break at a segment edge or a soft return is drawn", + ), + ( + "저는 성인 ADHD 당사자이자 정신건강간호사입니다.
진단을 받기까지 수년이 걸렸고, 그 과정에서 수많은 좌절과 시행착오를 겪었습니다.
그 경험을 통해 알게 된 것이 있습니다.", + "a break at a segment edge or a soft return is drawn", + ), + ( + "
{\" \"}같은 어려움을 겪더라도 각자가 만들어온{\" \"}", + "a break at a segment edge or a soft return is drawn", + ), + ( + "
{\" \"}전문가조차 완전히 이해하기 어려운, 오직 당사자만이 체감할 수 있는 영역이 존재합니다.

", + "a break at a segment edge or a soft return is drawn", + ), + ( + "우리는 단순한 검사 도구를 만드는 것이 아닙니다.
퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서, 코칭과 커뮤니티를 통해
당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고 있습니다.", + "a break at a segment edge or a soft return is drawn", + ), + ( + "우리는 단순한 검사 도구를 만드는 것이 아닙니다.
퍼즐핏은 연구 기반의 자가검진, 맞춤형 보고서,
코칭과 커뮤니티를 통해 당사자가 사회 속에서 더 잘 기능할 수 있도록 돕는 시스템을 구축하고
있습니다.", + "a break at a segment edge or a soft return is drawn", + ), + ( + "퍼즐핏이 바라는 미래는
성인 ADHD가 ‘진단명’으로만 불리는 것이 아니라,
", + "a break at a segment edge or a soft return is drawn", + ), + ("boxSize=\"465px\"", "mask icon height"), + ( + "전문가와 함께 만드는, 신뢰 기반의 성인 ADHD 솔루션
", + "a break at a segment edge or a soft return is drawn", + ), + ( + "
퍼즐핏은 단순한 검사 도구 제공이 아닌{\" \"}", + "a break at a segment edge or a soft return is drawn", + ), + ( + "을 통해
당사자가 사회 속에서 더 잘 기능하도록 돕는 통합 솔루션입니다.

", + "a break at a segment edge or a soft return is drawn", + ), + ( + "전문가와 함께 만드는,
신뢰 기반의 성인 ADHD 솔루션
", + "a break at a segment edge or a soft return is drawn", + ), + ( + "을 통해 당사자가 사회 속에서 더 잘 기능하도록 돕는 통합 솔루션입니다.

", + "a break at a segment edge or a soft return is drawn", + ), +]; + +/// A file's lines as they are compared: indentation and blank lines dropped, +/// a responsive array folded onto one line, and an image fill's file masked — +/// the plugin writes every one as its fixed `/icons/image.png`, and this +/// writes the file the export actually produces. +fn comparable_lines(source: &str) -> Vec { + folded(source) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| { + let mut line = line.replace("url(/icons/image.png)", "url(IMAGEFILL)"); + while let Some(start) = line + .find("url('/images/") + .or_else(|| line.find("url(/images/")) + { + let Some(end) = line[start..].find(')') else { + break; + }; + line.replace_range(start..start + end + 1, "url(IMAGEFILL)"); + } + line + }) + .collect() +} + +/// Every element the screen draws, as tags, ignoring the ones only mentioned +/// inside a `{/* … */}` comment — those are deliberately not rendered and so +/// deliberately not imported. +fn rendered_tags(tsx: &str) -> std::collections::BTreeSet { + let mut uncommented = String::with_capacity(tsx.len()); + let mut rest = tsx; + while let Some(start) = rest.find("{/*") { + uncommented.push_str(&rest[..start]); + match rest[start..].find("*/}") { + Some(end) => rest = &rest[start + end + 3..], + None => { + rest = ""; + break; + } + } + } + uncommented.push_str(rest); + + let mut tags = std::collections::BTreeSet::new(); + let bytes = uncommented.as_bytes(); + for (index, _) in uncommented.match_indices('<') { + let name = uncommented[index + 1..] + .chars() + .take_while(|character| character.is_alphanumeric() || *character == '_') + .collect::(); + let starts_upper = name.chars().next().is_some_and(char::is_uppercase); + // `
` closes what ` bool { - matches!(self, Self::Fill | Self::OriginalSource) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssetExportDiagnostics { - pub method: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub render_info: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native_error: Option, - pub stages: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssetExportStage { - pub stage: String, - pub elapsed_ms: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AssetFormat { - Png, - Webp, - Jpg, - Svg, - Pdf, -} - -impl AssetFormat { - pub fn mime_type(self) -> &'static str { - match self { - Self::Png => "image/png", - Self::Webp => "image/webp", - Self::Jpg => "image/jpeg", - Self::Svg => "image/svg+xml", - Self::Pdf => "application/pdf", - } - } - - pub fn extension(self) -> &'static str { - match self { - Self::Png => "png", - Self::Webp => "webp", - Self::Jpg => "jpg", - Self::Svg => "svg", - Self::Pdf => "pdf", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AssetStatus { - Available, - Exported, - Failed, - /// Announced by the export script for an SVG too large for one answer: - /// the bytes follow in fragments, and the collector reports the asset as - /// `Exported` or `Failed` once they have. Never in a manifest. - Chunked, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssetRequest { - /// Optional caller-declared use-site ratio; never measured browser evidence. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub usage_aspect_ratio: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_width: Option, - #[serde(default)] - pub srcset: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub visible_scope_node_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub quality: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lossless: Option, - #[serde(default)] - pub render_mode: AssetRenderMode, - pub asset_id: String, - pub node_id: String, - pub field: String, - pub image_hash: Option, - pub format: AssetFormat, - pub scale: f64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssetSelection { - /// Optional caller-declared use-site ratio; never measured browser evidence. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub usage_aspect_ratio: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_width: Option, - #[serde(default)] - pub srcset: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub visible_scope_node_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub quality: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lossless: Option, - #[serde(default)] - pub render_mode: AssetRenderMode, - pub asset_id: String, - pub format: AssetFormat, - pub scale: f64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssetManifestEntry { - /// Optional caller-declared use-site ratio; never measured browser evidence. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub usage_aspect_ratio: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_width: Option, - #[serde(default)] - pub srcset: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub visible_scope_node_id: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub variants: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub quality: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lossless: Option, - #[serde(default = "legacy_descriptor_mode")] - pub render_mode: AssetRenderMode, - /// Observations and explicit unknowns; dimensions are measured from bytes. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub render_info: Option, - /// A safe hierarchy-based suggestion, never an automatic write destination. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub suggested_filename: Option, - pub asset_id: String, - pub node_id: String, - pub field: String, - pub source_kind: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image_hash: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub format: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scale: Option, - pub status: AssetStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub byte_length: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sha256: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mime_type: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub data_base64: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_code: Option, - /// What failed, in the exporter's words: the step, the time it took and - /// the message Figma or the transport gave. Bounded to 500 characters. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_detail: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub export_diagnostics: Option, - /// Where the generated code refers to this asset, `/icons/x.svg` or - /// `/images/x.png`, filled in by the server from the code generator's - /// naming so the bytes can be written where the code will look. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssetManifest { - pub version: u32, - pub assets: Vec, - pub diagnostics: Vec, -} - -/// A bounded derivative of the acquired source; no hidden upscaling. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AssetVariant { - pub density: f64, - pub width: u32, - pub height: u32, - pub byte_length: usize, - pub sha256: String, - pub mime_type: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub data_base64: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_path: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum AssetNode { - Svg, - Png { - fill_index: usize, - image_hash: Option, - }, - /// A container that draws nothing of its own and holds one picture. It - /// is that picture, but it does not carry the fill, so its bytes come - /// from rendering the node rather than from a fill index it lacks. - PngNode { - image_hash: Option, - }, -} - -pub fn discover_asset_manifest(snapshot: &Snapshot) -> AssetManifest { - let mut assets = Vec::new(); - 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 let Some(asset) = compute_asset_node(snapshot, node, false) { - assets.push(manifest_entry(node, asset)); - continue; - } - - // A container carries pictures of its own: a section drawn over a - // photograph is painted `bg: url(...)` by the code generator, yet the - // node is a layout box rather than an asset, so the walk goes on into - // its children. Listed here, the picture the code points at can be - // exported; unlisted, nothing can deliver it and the screen renders - // with a hole where the photograph belongs. - assets.extend(image_fill_entries(node)); - - 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)); - crate::asset_render_info::annotate_assets(snapshot, &mut assets); - AssetManifest { - version: 1, - assets, - diagnostics: Vec::new(), - } -} - -/// Full in-scope IMAGE census, including paints below flattened asset boundaries. -pub fn discover_image_fill_assets(snapshot: &Snapshot) -> Vec { - let mut pending = snapshot - .roots - .iter() - .rev() - .map(|id| (id.clone(), false)) - .collect::>(); - let mut seen = std::collections::BTreeSet::new(); - let mut assets = Vec::new(); - while let Some((id, ancestor_hidden)) = pending.pop() { - if !seen.insert(id.clone()) { - continue; - } - let Some(node) = snapshot.nodes.get(&id) else { - continue; - }; - let hidden = ancestor_hidden || asset_exclusion_reason(node).is_some(); - for (index, paint) in fills(node) - .into_iter() - .flatten() - .enumerate() - .filter(|(_, p)| fill_type(p) == Some("IMAGE")) - { - let mut asset = manifest_entry( - node, - AssetNode::Png { - fill_index: index, - image_hash: paint - .get("imageHash") - .or_else(|| paint.get("imageRef")) - .and_then(Value::as_str) - .map(str::to_owned), - }, - ); - if hidden - || !is_visible_fill(paint) - || paint["opacity"].as_f64().is_some_and(|n| n <= 0.0) - { - asset.status = AssetStatus::Failed; - asset.error_code = Some("DEVUP_ASSET_NODE_HIDDEN".into()); - asset.error_detail = Some("The node, paint or an acquired ancestor is hidden, transparent or outside rendered bounds; no image was rendered.".into()); - } - assets.push(asset); - } - pending.extend( - node.typed_view() - .child_ids() - .collect::>() - .into_iter() - .rev() - .map(|id| (id.to_owned(), hidden)), - ); - } - assets.sort_by(|a, b| a.asset_id.cmp(&b.asset_id)); - crate::annotate_assets(snapshot, &mut assets); - assets -} - -/// Every acquired IMAGE paint, including hidden/failed dispositions. -pub fn discover_web_ready_assets(snapshot: &Snapshot) -> Vec { - discover_image_fill_assets(snapshot) -} - -fn direct_node_candidate(snapshot: &Snapshot, asset_id: &str) -> Option { - let id = asset_id.strip_suffix(":node")?; - let node = snapshot.nodes.get(id)?; - // Snapshot membership alone is insufficient: context nodes may be acquired - // outside the selected roots. Walk only roots and their actual child edges. - if !in_scope(snapshot, id) { - return None; - } - if matches!(node.typed_view().node_type(), "PAGE" | "DOCUMENT") { - return None; - } - let mut entry = manifest_entry(node, AssetNode::Svg); - entry.source_kind = "node".into(); - Some(entry) -} - -pub(crate) fn in_scope(snapshot: &Snapshot, wanted: &str) -> bool { - let mut pending = snapshot.roots.clone(); - let mut seen = std::collections::BTreeSet::new(); - while let Some(id) = pending.pop() { - if !seen.insert(id.clone()) { - continue; - } - if id == wanted { - return true; - } - if let Some(node) = snapshot.nodes.get(&id) { - pending.extend(node.typed_view().child_ids().map(str::to_owned)); - } - } - false -} - -fn visible_scope( - snapshot: &Snapshot, - node_id: &str, - explicit: Option<&str>, -) -> Result { - let mut current = node_id.to_owned(); - let mut seen = std::collections::BTreeSet::new(); - loop { - if !seen.insert(current.clone()) { - break; - } - if explicit == Some(current.as_str()) && in_scope(snapshot, ¤t) { - return Ok(current); - } - let parent = snapshot - .nodes - .values() - .find(|n| n.typed_view().child_ids().any(|id| id == current)); - let Some(parent) = parent else { - break; - }; - current = parent.id.clone(); - if explicit.is_none() && in_scope(snapshot, ¤t) { - return Ok(current); - } - } - Err(invalid( - "Visible capture requires an acquired ancestor scope. Recollect the parent frame URL, then supply nodeId and visibleScopeNodeId for that node or an in-scope ancestor; outside-scope context is unavailable.", - )) -} - -pub fn validate_asset_options( - format: AssetFormat, - mode: AssetRenderMode, - scale: f64, - max_width: Option, - srcset: bool, -) -> Result<(), DevupError> { - if !scale.is_finite() || scale <= 0.0 || scale > 4.0 { - return Err(invalid( - "Asset scale must be finite, greater than zero and at most 4; rendered bitmaps default to 2 and recommend 1.5 to 3.", - )); - } - if max_width.is_some_and(|w| w == 0 || w > crate::MAX_WEBP_DIMENSION) - || ((max_width.is_some() || srcset) - && (format != AssetFormat::Webp - || !mode.is_original() - || mode == AssetRenderMode::Fill)) - { - return Err(invalid( - "maxWidth (1..8192) and srcset require original-source WebP; legacy fill preserves its no-resize contract.", - )); - } - Ok(()) -} - -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; - } - - // A boolean's rendered shape is authoritative even when its operands - // were not included in the snapshot. Export the node, never its bounds. - if matches!( - view.node_type(), - "VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION" - ) { - 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; - } - - // The container stands in for the child, and the code names the - // file after the container. Asking Figma for `fills/0` of a node - // whose fills are empty is a request it can only refuse, and it did: - // the devup-ui landing page's footer logo sits in such a frame and - // was the one asset of 182 that never arrived. Rendering the node - // gives the same picture, and is a request the node can answer. - return match snapshot - .nodes - .get(child_ids[0]) - .and_then(|child| compute_asset_node(snapshot, child, true)) - { - Some(AssetNode::Svg) => Some(AssetNode::Svg), - Some(AssetNode::Png { image_hash, .. } | AssetNode::PngNode { image_hash }) => { - Some(AssetNode::PngNode { image_hash }) - } - 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); - } - } - - visible_children - .into_iter() - .all(|child| compute_asset_node(snapshot, child, true) == Some(AssetNode::Svg)) - .then_some(AssetNode::Svg) -} - -/// Every picture a node paints from an image fill. The code generator writes -/// a `url(...)` for each visible image fill whatever its scale mode, so each -/// one is an asset a caller has to be able to fetch. A node that is an asset -/// in its own right never reaches this: its bytes come from the node itself. -fn image_fill_entries(node: &RawNode) -> Vec { - fills(node) - .into_iter() - .flatten() - .enumerate() - .filter(|(_, fill)| is_visible_fill(fill) && fill_type(fill) == Some("IMAGE")) - .map(|(fill_index, fill)| { - manifest_entry( - node, - AssetNode::Png { - fill_index, - image_hash: fill - .get("imageHash") - .or_else(|| fill.get("imageRef")) - .and_then(Value::as_str) - .map(str::to_owned), - }, - ) - }) - .collect() -} - -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") - }) - }) - }) - }) -} - -/// Shared discovery/codegen policy for assets that cannot produce visible bytes. -pub fn asset_exclusion_reason(node: &RawNode) -> Option<&'static str> { - let view = node.typed_view(); - if view.bool("visible") == Some(false) { - Some("visible-false") - } else if view.number("opacity").is_some_and(|opacity| opacity <= 0.0) { - Some("zero-opacity") - } else if view.value("absoluteBoundingBox").is_some() - && view - .value("absoluteRenderBounds") - .is_none_or(Value::is_null) - { - Some("no-render-bounds") - } else { - None - } -} - -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, - ), - AssetNode::PngNode { image_hash } => ( - format!("{}:node", node.id), - "node".to_owned(), - "image-node".to_owned(), - image_hash, - ), - }; - - // Figma refuses to export a node that has no visible layers, so a node - // that draws nothing 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. - // - // Drawing nothing is not only being hidden. A node left fully transparent - // is `visible`, and renders no pixel all the same: the about page carries - // two such icons, and they were the only two exports Figma turned down. - // - // Nor is it only about the node itself. One that sits entirely outside an - // ancestor that clips is visible, opaque, and still draws nothing, and - // Figma says so by leaving `absoluteRenderBounds` off it - that field is - // the bounds of what the node actually renders, and there are none. The - // devup-ui landing page's mobile and tablet each carry one such icon, - // pushed past the edge of a clipped panel, and they were the only two - // exports of 215 that Figma turned down. - let hidden = asset_exclusion_reason(node).is_some(); - let (status, error_code) = if hidden { - ( - AssetStatus::Failed, - Some("DEVUP_ASSET_NODE_HIDDEN".to_owned()), - ) - } else { - (AssetStatus::Available, None) - }; - - AssetManifestEntry { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - variants: Vec::new(), - render_mode: AssetRenderMode::Node, - quality: None, - lossless: None, - render_info: None, - suggested_filename: None, - 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, - error_detail: None, - export_diagnostics: None, - path: None, - } -} - -pub fn validate_asset_requests( - snapshot: &Snapshot, - requests: &[AssetRequest], -) -> Result<(), DevupError> { - if requests.len() > 16 { - return Err(invalid("At most 16 assets can be exported at once.")); - } - let mut available = discover_asset_manifest(snapshot); - available - .assets - .extend(discover_image_fill_assets(snapshot)); - let mut seen = std::collections::BTreeSet::new(); - for request in requests { - crate::validate_webp_options(request.format, request.quality, request.lossless)?; - if request - .usage_aspect_ratio - .is_some_and(|r| !r.is_finite() || r <= 0.0 || r > 10000.0) - { - return Err(invalid( - "usageAspectRatio must be finite and positive, at most10000.", - )); - } - validate_asset_options( - request.format, - request.render_mode, - request.scale, - request.max_width, - request.srcset, - )?; - if request.scale == 0.0 || request.scale > 4.0 || !seen.insert(request.asset_id.as_str()) { - return Err(invalid( - "asset request has an invalid scale or a duplicate ID.", - )); - } - let direct = direct_node_candidate(snapshot, &request.asset_id); - let Some(candidate) = available - .assets - .iter() - .find(|asset| asset.asset_id == request.asset_id) - .or(direct.as_ref()) - else { - return Err(invalid("The requested asset is not in the snapshot.")); - }; - if let Some(node) = snapshot.nodes.get(&request.node_id) { - if !request.render_mode.is_original() - && ["width", "height"].into_iter().any(|key| { - node.typed_view() - .number(key) - .is_some_and(|n| !n.is_finite() || n <= 0.0) - }) - { - return Err(invalid( - "Native capture dimensions must be finite and positive.", - )); - } - if request.srcset - && node - .typed_view() - .number("width") - .is_none_or(|w| !w.is_finite() || w <= 0.0) - { - return Err(invalid( - "srcset requires a known positive node display width.", - )); - } - } - if !in_scope(snapshot, &request.node_id) { - return Err(invalid( - "The requested node is outside the acquired snapshot scope.", - )); - } - if candidate.node_id != request.node_id - || candidate.field != request.field - || candidate.image_hash != request.image_hash - { - return Err(invalid("asset request does not match the snapshot source.")); - } - if request.render_mode == AssetRenderMode::Fill - && (!request.field.starts_with("fills/") - || !matches!( - request.format, - AssetFormat::Png | AssetFormat::Jpg | AssetFormat::Webp - ) - || request.scale != 1.0) - { - return Err(invalid( - "renderMode fill requires an image-fill, png or jpg matching the original codec, and scale 1; PNG/JPG preserve original bytes; explicit WebP transcodes PNG/JPEG source without resizing.", - )); - } - // 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 request.render_mode == AssetRenderMode::OriginalSource - && (!request.field.starts_with("fills/") - || request.image_hash.is_none() - || !matches!( - request.format, - AssetFormat::Png | AssetFormat::Jpg | AssetFormat::Webp - )) - { - return Err(invalid( - "original-source requires a resolvable IMAGE fill; request renderMode node explicitly for native raster boundaries, or retain SVG for vectors.", - )); - } - if request.render_mode == AssetRenderMode::Visible { - visible_scope( - snapshot, - &request.node_id, - request.visible_scope_node_id.as_deref(), - )?; - if !matches!( - request.format, - AssetFormat::Png | AssetFormat::Jpg | AssetFormat::Webp - ) { - return Err(invalid("Visible scoped captures require PNG, JPG or WebP.")); - } - } - if request.render_mode != AssetRenderMode::Visible - && request.visible_scope_node_id.is_some() - { - return Err(invalid( - "visibleScopeNodeId applies only to renderMode visible.", - )); - } - if candidate.status == AssetStatus::Failed - && request.render_mode != AssetRenderMode::OriginalSource - { - return Err(invalid( - "The requested asset cannot be exported: the node is hidden in Figma.", - )); - } - } - Ok(()) -} - -pub fn resolve_asset_selections( - snapshot: &Snapshot, - selections: &[AssetSelection], -) -> Result, DevupError> { - if selections.len() > 16 { - return Err(invalid("At most 16 assets can be exported at once.")); - } - let mut manifest = discover_asset_manifest(snapshot); - manifest.assets.extend(discover_image_fill_assets(snapshot)); - let mut seen = std::collections::BTreeSet::new(); - let requests = selections - .iter() - .map(|selection| { - if selection.scale == 0.0 - || selection.scale > 4.0 - || !seen.insert(selection.asset_id.as_str()) - { - return Err(invalid( - "asset selection has an invalid scale or a duplicate ID.", - )); - } - let direct = direct_node_candidate(snapshot, &selection.asset_id); - let asset = manifest - .assets - .iter() - .find(|asset| asset.asset_id == selection.asset_id) - .or(direct.as_ref()) - .ok_or_else(|| { - invalid("The selected asset or nodeId is not in the acquired snapshot scope.") - })?; - let capture_scope = if selection.render_mode == AssetRenderMode::Visible { - Some(visible_scope( - snapshot, - &asset.node_id, - selection.visible_scope_node_id.as_deref(), - )?) - } else { - selection.visible_scope_node_id.clone() - }; - Ok(AssetRequest { - usage_aspect_ratio: selection.usage_aspect_ratio, - max_width: selection.max_width, - srcset: selection.srcset, - visible_scope_node_id: capture_scope, - asset_id: asset.asset_id.clone(), - node_id: asset.node_id.clone(), - field: asset.field.clone(), - image_hash: asset.image_hash.clone(), - format: selection.format, - scale: selection.scale, - render_mode: selection.render_mode, - quality: selection.quality, - lossless: selection.lossless, - }) - }) - .collect::, _>>()?; - validate_asset_requests(snapshot, &requests)?; - Ok(requests) -} - -pub fn asset_export_from_result( - result: &UpstreamResult, - file_key: &str, - version: Option<&str>, - request: &AssetRequest, -) -> Result { - crate::validate_webp_options(request.format, request.quality, request.lossless)?; - let descriptor = find_descriptor(&result.raw) - .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 - || descriptor.node_id != request.node_id - || descriptor.field != request.field - || descriptor.image_hash != request.image_hash - || descriptor.format != request.format - || descriptor.scale != request.scale - || descriptor.render_mode != request.render_mode - || descriptor.max_width != request.max_width - || descriptor.usage_aspect_ratio != request.usage_aspect_ratio - || descriptor.srcset != request.srcset - || descriptor.visible_scope_node_id != request.visible_scope_node_id - { - return Err(invalid( - "asset descriptor target or version does not match the request.", - )); - } - if descriptor.status == AssetStatus::Exported { - let info = descriptor - .export_diagnostics - .as_ref() - .and_then(|d| d.render_info.as_ref()); - if request.render_mode == AssetRenderMode::OriginalSource - && (info.is_none_or(|i| i["representation"] != "original-fill") - || descriptor - .export_diagnostics - .as_ref() - .is_none_or(|d| d.method != "original-fill")) - { - return Err(invalid( - "Original-source export lacks full-source acquisition provenance; native renditions cannot substitute.", - )); - } - if request.render_mode == AssetRenderMode::Visible - && info.is_none_or(|i| { - i["representation"] != "native-visible" - || i["captureNodeId"].as_str() != request.visible_scope_node_id.as_deref() - || i["contextScope"] != "selected-ancestor-subtree" - }) - { - return Err(invalid( - "Visible capture lacks bound selected-ancestor scope provenance.", - )); - } - } - if matches!( - request.render_mode, - AssetRenderMode::Node | AssetRenderMode::Visible - ) && descriptor - .export_diagnostics - .as_ref() - .and_then(|d| d.render_info.as_ref()) - .is_some_and(|i| { - i["representation"] - .as_str() - .is_some_and(|r| r.starts_with("original-fill")) - }) - { - return Err(invalid( - "Native capture cannot substitute original fill bytes; explicit renderMode fill or original-source is required.", - )); - } - if request.format == AssetFormat::Webp - && !request.render_mode.is_original() - && descriptor - .export_diagnostics - .as_ref() - .and_then(|d| d.render_info.as_ref()) - .is_some_and(|info| { - info["representation"] - .as_str() - .is_some_and(|r| r.starts_with("original-fill")) - }) - { - return Err(invalid( - "Node/auto WebP requires a native PNG rendition. Original-fill WebP conversion requires explicit renderMode fill at scale 1; no native provenance was assigned.", - )); - } - if (request.render_mode.is_original() - || descriptor - .export_diagnostics - .as_ref() - .and_then(|d| d.render_info.as_ref()) - .is_some_and(|i| i["representation"] == "original-fill")) - && descriptor.status == AssetStatus::Chunked - { - return Err(invalid( - "original fill bytes cannot use node re-export fragments.", - )); - } - let source_kind = source_kind_of(request); - if descriptor.status == AssetStatus::Chunked { - let (Some(byte_length), Some(sha256), Some(cursor)) = ( - descriptor.byte_length, - descriptor.sha256.clone(), - descriptor.cursor.clone(), - ) else { - return Err(invalid( - "chunked asset export descriptor is missing its length, hash or cursor.", - )); - }; - // The field names what the fragments are cut from. A PNG carries its - // scale on the field, so the re-export behind each fragment is the - // same bytes that were announced. - let field = match request.format { - AssetFormat::Svg => SVG_EXPORT_FIELD.to_owned(), - AssetFormat::Png | AssetFormat::Webp => { - let mut field = format!("{PNG_EXPORT_FIELD}@{}", request.scale); - if request.render_mode == AssetRenderMode::Visible { - field.push_str(&format!( - "@visible@{}", - request - .visible_scope_node_id - .as_deref() - .ok_or_else(|| invalid("Visible capture scope is missing."))? - )); - } - field - } - _ => { - return Err(invalid( - "only an SVG or PNG export is carried in fragments.", - )); - } - }; - let descriptor = LargeValueDescriptor { - node_id: request.node_id.clone(), - field, - byte_length, - sha256, - cursor, - }; - return Ok(AssetExportOutcome::Chunked(descriptor)); - } - if descriptor.status == AssetStatus::Failed { - return Ok(AssetExportOutcome::Entry(consistent_entry( - AssetManifestEntry { - usage_aspect_ratio: request.usage_aspect_ratio, - max_width: request.max_width, - srcset: request.srcset, - visible_scope_node_id: request.visible_scope_node_id.clone(), - variants: Vec::new(), - render_mode: request.render_mode, - quality: request.quality, - lossless: request.lossless, - render_info: Some(crate::asset_render_info::measured_info( - request.render_mode, - request.format, - request.scale, - descriptor - .export_diagnostics - .as_ref() - .and_then(|d| d.render_info.clone()), - None, - )), - suggested_filename: None, - asset_id: request.asset_id.clone(), - node_id: request.node_id.clone(), - field: request.field.clone(), - source_kind: source_kind.to_owned(), - image_hash: request.image_hash.clone(), - format: Some(request.format), - scale: Some(request.scale), - status: AssetStatus::Failed, - byte_length: None, - sha256: None, - mime_type: None, - data_base64: None, - output_path: None, - error_code: descriptor.error_code, - error_detail: bounded(descriptor.error_detail), - export_diagnostics: descriptor.export_diagnostics, - path: None, - }, - ))); - } - let payload = if request.format == AssetFormat::Webp { - find_payload(&result.raw, "image/png").or_else(|| { - (request.render_mode.is_original()) - .then(|| { - ["image/jpeg", "image/gif", "image/webp"] - .into_iter() - .find_map(|mime| find_payload(&result.raw, mime)) - }) - .flatten() - }) - } else if request.render_mode == AssetRenderMode::OriginalSource { - ["image/png", "image/jpeg", "image/webp", "image/gif"] - .into_iter() - .find_map(|mime| find_payload(&result.raw, mime)) - } else { - 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) => { - if data.len() > MAX_ASSET_BYTES.div_ceil(3) * 4 { - return Err(invalid("asset export base64 exceeds the byte budget.")); - } - 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 length or hash does not match.", - )); - } - if request.format != AssetFormat::Webp - && (request.render_mode == AssetRenderMode::Fill - || (request.render_mode == AssetRenderMode::Auto - && descriptor - .export_diagnostics - .as_ref() - .and_then(|d| d.render_info.as_ref()) - .is_some_and(|i| i["representation"] == "original-fill"))) - && image::guess_format(&bytes).ok() - != Some(match request.format { - AssetFormat::Png => image::ImageFormat::Png, - AssetFormat::Jpg => image::ImageFormat::Jpeg, - _ => return Err(invalid("original fill format is unsupported.")), - }) - { - return Err(invalid( - "original fill bytes do not match the requested codec.", - )); - } - if request.render_mode == AssetRenderMode::Auto - && let Some(info) = descriptor - .export_diagnostics - .as_ref() - .and_then(|d| d.render_info.as_ref()) - && info["representation"] == "original-fill" - { - let measured = crate::asset_render_info::measured_info( - request.render_mode, - request.format, - request.scale, - Some(info.clone()), - Some(&bytes), - ); - if request.scale != 1.0 - || info["autoDecision"]["eligible"] != true - || info["autoDecision"]["selected"] != "fill" - || !info["autoDecision"]["reasons"] - .as_array() - .is_some_and(Vec::is_empty) - || measured["outputSize"].is_null() - || measured["outputSize"]["width"].as_f64() != measured["nodeSize"]["width"].as_f64() - || measured["outputSize"]["height"].as_f64() != measured["nodeSize"]["height"].as_f64() - { - return Err(invalid( - "auto raw fill lacks validated safety or intrinsic dimension evidence.", - )); - } - } - Ok(AssetExportOutcome::Entry(consistent_entry( - crate::webp::finish( - request, - AssetManifestEntry { - usage_aspect_ratio: request.usage_aspect_ratio, - max_width: request.max_width, - srcset: request.srcset, - visible_scope_node_id: request.visible_scope_node_id.clone(), - variants: Vec::new(), - render_mode: request.render_mode, - quality: None, - lossless: None, - render_info: Some(crate::asset_render_info::measured_info( - request.render_mode, - request.format, - request.scale, - descriptor - .export_diagnostics - .as_ref() - .and_then(|d| d.render_info.clone()), - Some(&bytes), - )), - suggested_filename: None, - asset_id: request.asset_id.clone(), - node_id: request.node_id.clone(), - field: request.field.clone(), - source_kind: source_kind.to_owned(), - image_hash: request.image_hash.clone(), - format: Some(request.format), - scale: Some(request.scale), - status: AssetStatus::Exported, - byte_length: Some(bytes.len()), - sha256: descriptor.sha256, - mime_type: Some(request.format.mime_type().to_owned()), - data_base64: Some(data), - output_path: None, - error_code: None, - error_detail: None, - export_diagnostics: descriptor.export_diagnostics, - path: None, - }, - )?, - ))) -} - -/// The virtual field a fragmented SVG export is read under: the large-value -/// script re-exports the node as SVG text and slices that, where for any -/// other field it slices the field's JSON. -/// What an export is cut from, from what the request already says: an -/// image fill, a node that is a picture, or a node that is line work. The -/// entry an export produces replaces the one discovery listed, so the two -/// have to agree; deriving both from the same two fields is how they do. -pub fn source_kind_of(request: &AssetRequest) -> &'static str { - if request.field.starts_with("fills/") { - "image-fill" - } else if request.image_hash.is_some() { - "image-node" - } else { - "vector-node" - } -} - -fn consistent_entry(mut entry: AssetManifestEntry) -> Box { - if entry.status == AssetStatus::Failed - && let Some(info) = &mut entry.render_info - { - info["representation"] = json!("not-exported"); - info["renderedAs"] = json!("not-exported"); - info["fidelity"] = json!("unverified"); - info["effectsBakedIntoAsset"] = json!(false); - } - if let Some(info) = &mut entry.render_info { - info["sourceKind"] = json!(entry.source_kind); - info["usageAspectRatio"] = json!(entry.usage_aspect_ratio); - } - if let Some(diagnostics) = &mut entry.export_diagnostics { - diagnostics.render_info = entry.render_info.clone(); - } - Box::new(entry) -} - -pub const SVG_EXPORT_FIELD: &str = "$export:svg"; - -/// The virtual field a PNG too large for one attachment is read back -/// through, with its scale appended: `$export:png@2`. Figma's remote MCP -/// returns a written PNG as an attachment only up to about a megabyte once -/// base64-encoded; a larger one is written, reported exported, and never -/// arrives, so it is carried in fragments instead. -pub const PNG_EXPORT_FIELD: &str = "$export:png"; - -/// What an asset export call answered with: the asset, exported or failed, -/// or the announcement of an SVG too large for one answer, to be read back -/// in fragments. -#[derive(Debug, Clone, PartialEq)] -pub enum AssetExportOutcome { - Entry(Box), - Chunked(LargeValueDescriptor), -} - -/// The entry for an export that arrived in fragments, from the bytes the -/// fragments assembled to. The length and hash were checked against the -/// announcement by the assembler. -pub fn exported_asset_from_bytes( - request: &AssetRequest, - bytes: &[u8], -) -> Result { - if request.render_mode == AssetRenderMode::OriginalSource { - return Err(invalid( - "Original-source requires a full-source acquisition descriptor; node fragments cannot substitute.", - )); - } - if bytes.is_empty() || bytes.len() > MAX_ASSET_BYTES { - return Err(invalid("asset export binary length is out of range.")); - } - crate::webp::finish( - request, - AssetManifestEntry { - usage_aspect_ratio: request.usage_aspect_ratio, - max_width: request.max_width, - srcset: request.srcset, - visible_scope_node_id: request.visible_scope_node_id.clone(), - variants: Vec::new(), - render_mode: request.render_mode, - quality: None, - lossless: None, - render_info: Some(crate::asset_render_info::measured_info( - request.render_mode, - request.format, - request.scale, - None, - Some(bytes), - )), - suggested_filename: None, - asset_id: request.asset_id.clone(), - node_id: request.node_id.clone(), - field: request.field.clone(), - source_kind: source_kind_of(request).to_owned(), - image_hash: request.image_hash.clone(), - format: Some(request.format), - scale: Some(request.scale), - status: AssetStatus::Exported, - byte_length: Some(bytes.len()), - sha256: Some(sha256_hex(bytes)), - mime_type: Some(request.format.mime_type().to_owned()), - data_base64: Some(STANDARD.encode(bytes)), - output_path: None, - error_code: None, - error_detail: None, - export_diagnostics: None, - path: None, - }, - ) -} - -fn legacy_descriptor_mode() -> AssetRenderMode { - AssetRenderMode::Node -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AssetExportDescriptor { - #[serde(default = "legacy_descriptor_mode")] - render_mode: AssetRenderMode, - #[serde(default)] - max_width: Option, - #[serde(default)] - usage_aspect_ratio: Option, - #[serde(default)] - srcset: bool, - #[serde(default)] - visible_scope_node_id: Option, - kind: String, - file_key: String, - version: Option, - asset_id: String, - node_id: String, - field: String, - image_hash: Option, - format: AssetFormat, - scale: f64, - status: AssetStatus, - byte_length: Option, - sha256: Option, - error_code: Option, - #[serde(default)] - error_detail: Option, - #[serde(default)] - export_diagnostics: Option, - #[serde(default)] - cursor: Option, -} - -/// The exporter's own words, kept short: it runs in the plugin, so its length -/// is not ours to trust. -fn bounded(text: Option) -> Option { - text.map(|text| text.chars().take(500).collect()) -} - -fn find_descriptor(value: &Value) -> Option { - if let Ok(mut descriptor) = serde_json::from_value::(value.clone()) - && descriptor.kind == "devupAssetExport" - { - if let Some(diagnostics) = &mut descriptor.export_diagnostics { - if diagnostics - .render_info - .as_ref() - .is_some_and(|v| v.to_string().len() > 16 * 1024) - { - diagnostics.render_info = Some( - json!({"representation":"unknown", "warnings":["render-observations-exceeded-limit"]}), - ); - } - diagnostics.native_error = bounded(diagnostics.native_error.take()); - diagnostics.method = diagnostics.method.chars().take(80).collect(); - diagnostics.stages.truncate(16); - for stage in &mut diagnostics.stages { - stage.stage = stage.stage.chars().take(80).collect(); - } - } - return Some(descriptor); - } - match value { - Value::Object(object) => object.values().find_map(find_descriptor), - Value::Array(values) => values.iter().find_map(find_descriptor), - Value::String(text) => serde_json::from_str::(text) - .ok() - .and_then(|value| find_descriptor(&value)), - _ => None, - } -} - -pub(crate) fn asset_export_diagnostics(result: &UpstreamResult) -> Option { - find_descriptor(&result.raw)?.export_diagnostics -} - -/// 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) => { - 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(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_payload(value, mime_type)) - } - Value::Array(values) => values - .iter() - .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() - .map(|byte| format!("{byte:02x}")) - .collect() -} - -fn invalid(message: &str) -> DevupError { - DevupError::new(ErrorCode::DevupSnapshotUnsupported, message, false) -} +// Source sections share this module to preserve privacy and test names. +include!("assets/asset_manifest.rs"); +include!("assets/asset_export_from_result.rs"); diff --git a/crates/devup-mcp-figma/src/assets/asset_export_from_result.rs b/crates/devup-mcp-figma/src/assets/asset_export_from_result.rs new file mode 100644 index 00000000..3b636e3f --- /dev/null +++ b/crates/devup-mcp-figma/src/assets/asset_export_from_result.rs @@ -0,0 +1,636 @@ + + +pub fn asset_export_from_result( + result: &UpstreamResult, + file_key: &str, + version: Option<&str>, + request: &AssetRequest, +) -> Result { + crate::validate_webp_options(request.format, request.quality, request.lossless)?; + let descriptor = find_descriptor(&result.raw) + .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 + || descriptor.node_id != request.node_id + || descriptor.field != request.field + || descriptor.image_hash != request.image_hash + || descriptor.format != request.format + || descriptor.scale != request.scale + || descriptor.render_mode != request.render_mode + || descriptor.max_width != request.max_width + || descriptor.usage_aspect_ratio != request.usage_aspect_ratio + || descriptor.srcset != request.srcset + || descriptor.visible_scope_node_id != request.visible_scope_node_id + { + return Err(invalid( + "asset descriptor target or version does not match the request.", + )); + } + if descriptor.status == AssetStatus::Exported { + let info = descriptor + .export_diagnostics + .as_ref() + .and_then(|d| d.render_info.as_ref()); + if request.render_mode == AssetRenderMode::OriginalSource + && (info.is_none_or(|i| i["representation"] != "original-fill") + || descriptor + .export_diagnostics + .as_ref() + .is_none_or(|d| d.method != "original-fill")) + { + return Err(invalid( + "Original-source export lacks full-source acquisition provenance; native renditions cannot substitute.", + )); + } + if request.render_mode == AssetRenderMode::Visible + && info.is_none_or(|i| { + i["representation"] != "native-visible" + || i["captureNodeId"].as_str() != request.visible_scope_node_id.as_deref() + || i["contextScope"] != "selected-ancestor-subtree" + }) + { + return Err(invalid( + "Visible capture lacks bound selected-ancestor scope provenance.", + )); + } + } + if matches!( + request.render_mode, + AssetRenderMode::Node | AssetRenderMode::Visible + ) && descriptor + .export_diagnostics + .as_ref() + .and_then(|d| d.render_info.as_ref()) + .is_some_and(|i| { + i["representation"] + .as_str() + .is_some_and(|r| r.starts_with("original-fill")) + }) + { + return Err(invalid( + "Native capture cannot substitute original fill bytes; explicit renderMode fill or original-source is required.", + )); + } + if request.format == AssetFormat::Webp + && !request.render_mode.is_original() + && descriptor + .export_diagnostics + .as_ref() + .and_then(|d| d.render_info.as_ref()) + .is_some_and(|info| { + info["representation"] + .as_str() + .is_some_and(|r| r.starts_with("original-fill")) + }) + { + return Err(invalid( + "Node/auto WebP requires a native PNG rendition. Original-fill WebP conversion requires explicit renderMode fill at scale 1; no native provenance was assigned.", + )); + } + if (request.render_mode.is_original() + || descriptor + .export_diagnostics + .as_ref() + .and_then(|d| d.render_info.as_ref()) + .is_some_and(|i| i["representation"] == "original-fill")) + && descriptor.status == AssetStatus::Chunked + { + return Err(invalid( + "original fill bytes cannot use node re-export fragments.", + )); + } + let source_kind = source_kind_of(request); + if descriptor.status == AssetStatus::Chunked { + let (Some(byte_length), Some(sha256), Some(cursor)) = ( + descriptor.byte_length, + descriptor.sha256.clone(), + descriptor.cursor.clone(), + ) else { + return Err(invalid( + "chunked asset export descriptor is missing its length, hash or cursor.", + )); + }; + // The field names what the fragments are cut from. A PNG carries its + // scale on the field, so the re-export behind each fragment is the + // same bytes that were announced. + let field = match request.format { + AssetFormat::Svg => SVG_EXPORT_FIELD.to_owned(), + AssetFormat::Png | AssetFormat::Webp => { + let mut field = format!("{PNG_EXPORT_FIELD}@{}", request.scale); + if request.render_mode == AssetRenderMode::Visible { + field.push_str(&format!( + "@visible@{}", + request + .visible_scope_node_id + .as_deref() + .ok_or_else(|| invalid("Visible capture scope is missing."))? + )); + } + field + } + _ => { + return Err(invalid( + "only an SVG or PNG export is carried in fragments.", + )); + } + }; + let descriptor = LargeValueDescriptor { + node_id: request.node_id.clone(), + field, + byte_length, + sha256, + cursor, + }; + return Ok(AssetExportOutcome::Chunked(descriptor)); + } + if descriptor.status == AssetStatus::Failed { + return Ok(AssetExportOutcome::Entry(consistent_entry( + AssetManifestEntry { + usage_aspect_ratio: request.usage_aspect_ratio, + max_width: request.max_width, + srcset: request.srcset, + visible_scope_node_id: request.visible_scope_node_id.clone(), + variants: Vec::new(), + render_mode: request.render_mode, + quality: request.quality, + lossless: request.lossless, + render_info: Some(crate::asset_render_info::measured_info( + request.render_mode, + request.format, + request.scale, + descriptor + .export_diagnostics + .as_ref() + .and_then(|d| d.render_info.clone()), + None, + )), + suggested_filename: None, + asset_id: request.asset_id.clone(), + node_id: request.node_id.clone(), + field: request.field.clone(), + source_kind: source_kind.to_owned(), + image_hash: request.image_hash.clone(), + format: Some(request.format), + scale: Some(request.scale), + status: AssetStatus::Failed, + byte_length: None, + sha256: None, + mime_type: None, + data_base64: None, + output_path: None, + error_code: descriptor.error_code, + error_detail: bounded(descriptor.error_detail), + export_diagnostics: descriptor.export_diagnostics, + path: None, + }, + ))); + } + let payload = if request.format == AssetFormat::Webp { + find_payload(&result.raw, "image/png").or_else(|| { + (request.render_mode.is_original()) + .then(|| { + ["image/jpeg", "image/gif", "image/webp"] + .into_iter() + .find_map(|mime| find_payload(&result.raw, mime)) + }) + .flatten() + }) + } else if request.render_mode == AssetRenderMode::OriginalSource { + ["image/png", "image/jpeg", "image/webp", "image/gif"] + .into_iter() + .find_map(|mime| find_payload(&result.raw, mime)) + } else { + 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!( + include_str!("../text/assets-asset_export_from_result-a4ad1a7b.md"), + request.format.extension() + ), + false, + json!({ + "expectedMimeType": request.format.mime_type(), + "observed": observed_payload_shapes(&result.raw), + }), + ) + })?; + let (bytes, data) = match payload { + AssetPayload::Base64(data) => { + if data.len() > MAX_ASSET_BYTES.div_ceil(3) * 4 { + return Err(invalid("asset export base64 exceeds the byte budget.")); + } + 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 length or hash does not match.", + )); + } + if request.format != AssetFormat::Webp + && (request.render_mode == AssetRenderMode::Fill + || (request.render_mode == AssetRenderMode::Auto + && descriptor + .export_diagnostics + .as_ref() + .and_then(|d| d.render_info.as_ref()) + .is_some_and(|i| i["representation"] == "original-fill"))) + && image::guess_format(&bytes).ok() + != Some(match request.format { + AssetFormat::Png => image::ImageFormat::Png, + AssetFormat::Jpg => image::ImageFormat::Jpeg, + _ => return Err(invalid("original fill format is unsupported.")), + }) + { + return Err(invalid( + "original fill bytes do not match the requested codec.", + )); + } + if request.render_mode == AssetRenderMode::Auto + && let Some(info) = descriptor + .export_diagnostics + .as_ref() + .and_then(|d| d.render_info.as_ref()) + && info["representation"] == "original-fill" + { + let measured = crate::asset_render_info::measured_info( + request.render_mode, + request.format, + request.scale, + Some(info.clone()), + Some(&bytes), + ); + if request.scale != 1.0 + || info["autoDecision"]["eligible"] != true + || info["autoDecision"]["selected"] != "fill" + || !info["autoDecision"]["reasons"] + .as_array() + .is_some_and(Vec::is_empty) + || measured["outputSize"].is_null() + || measured["outputSize"]["width"].as_f64() != measured["nodeSize"]["width"].as_f64() + || measured["outputSize"]["height"].as_f64() != measured["nodeSize"]["height"].as_f64() + { + return Err(invalid( + "auto raw fill lacks validated safety or intrinsic dimension evidence.", + )); + } + } + Ok(AssetExportOutcome::Entry(consistent_entry( + crate::webp::finish( + request, + AssetManifestEntry { + usage_aspect_ratio: request.usage_aspect_ratio, + max_width: request.max_width, + srcset: request.srcset, + visible_scope_node_id: request.visible_scope_node_id.clone(), + variants: Vec::new(), + render_mode: request.render_mode, + quality: None, + lossless: None, + render_info: Some(crate::asset_render_info::measured_info( + request.render_mode, + request.format, + request.scale, + descriptor + .export_diagnostics + .as_ref() + .and_then(|d| d.render_info.clone()), + Some(&bytes), + )), + suggested_filename: None, + asset_id: request.asset_id.clone(), + node_id: request.node_id.clone(), + field: request.field.clone(), + source_kind: source_kind.to_owned(), + image_hash: request.image_hash.clone(), + format: Some(request.format), + scale: Some(request.scale), + status: AssetStatus::Exported, + byte_length: Some(bytes.len()), + sha256: descriptor.sha256, + mime_type: Some(request.format.mime_type().to_owned()), + data_base64: Some(data), + output_path: None, + error_code: None, + error_detail: None, + export_diagnostics: descriptor.export_diagnostics, + path: None, + }, + )?, + ))) +} + +/// The virtual field a fragmented SVG export is read under: the large-value +/// script re-exports the node as SVG text and slices that, where for any +/// other field it slices the field's JSON. +/// What an export is cut from, from what the request already says: an +/// image fill, a node that is a picture, or a node that is line work. The +/// entry an export produces replaces the one discovery listed, so the two +/// have to agree; deriving both from the same two fields is how they do. +pub fn source_kind_of(request: &AssetRequest) -> &'static str { + if request.field.starts_with("fills/") { + "image-fill" + } else if request.image_hash.is_some() { + "image-node" + } else { + "vector-node" + } +} + +fn consistent_entry(mut entry: AssetManifestEntry) -> Box { + if entry.status == AssetStatus::Failed + && let Some(info) = &mut entry.render_info + { + info["representation"] = json!("not-exported"); + info["renderedAs"] = json!("not-exported"); + info["fidelity"] = json!("unverified"); + info["effectsBakedIntoAsset"] = json!(false); + } + if let Some(info) = &mut entry.render_info { + info["sourceKind"] = json!(entry.source_kind); + info["usageAspectRatio"] = json!(entry.usage_aspect_ratio); + } + if let Some(diagnostics) = &mut entry.export_diagnostics { + diagnostics.render_info = entry.render_info.clone(); + } + Box::new(entry) +} + +pub const SVG_EXPORT_FIELD: &str = "$export:svg"; + +/// The virtual field a PNG too large for one attachment is read back +/// through, with its scale appended: `$export:png@2`. Figma's remote MCP +/// returns a written PNG as an attachment only up to about a megabyte once +/// base64-encoded; a larger one is written, reported exported, and never +/// arrives, so it is carried in fragments instead. +pub const PNG_EXPORT_FIELD: &str = "$export:png"; + +/// What an asset export call answered with: the asset, exported or failed, +/// or the announcement of an SVG too large for one answer, to be read back +/// in fragments. +#[derive(Debug, Clone, PartialEq)] +pub enum AssetExportOutcome { + Entry(Box), + Chunked(LargeValueDescriptor), +} + +/// The entry for an export that arrived in fragments, from the bytes the +/// fragments assembled to. The length and hash were checked against the +/// announcement by the assembler. +pub fn exported_asset_from_bytes( + request: &AssetRequest, + bytes: &[u8], +) -> Result { + if request.render_mode == AssetRenderMode::OriginalSource { + return Err(invalid( + "Original-source requires a full-source acquisition descriptor; node fragments cannot substitute.", + )); + } + if bytes.is_empty() || bytes.len() > MAX_ASSET_BYTES { + return Err(invalid("asset export binary length is out of range.")); + } + crate::webp::finish( + request, + AssetManifestEntry { + usage_aspect_ratio: request.usage_aspect_ratio, + max_width: request.max_width, + srcset: request.srcset, + visible_scope_node_id: request.visible_scope_node_id.clone(), + variants: Vec::new(), + render_mode: request.render_mode, + quality: None, + lossless: None, + render_info: Some(crate::asset_render_info::measured_info( + request.render_mode, + request.format, + request.scale, + None, + Some(bytes), + )), + suggested_filename: None, + asset_id: request.asset_id.clone(), + node_id: request.node_id.clone(), + field: request.field.clone(), + source_kind: source_kind_of(request).to_owned(), + image_hash: request.image_hash.clone(), + format: Some(request.format), + scale: Some(request.scale), + status: AssetStatus::Exported, + byte_length: Some(bytes.len()), + sha256: Some(sha256_hex(bytes)), + mime_type: Some(request.format.mime_type().to_owned()), + data_base64: Some(STANDARD.encode(bytes)), + output_path: None, + error_code: None, + error_detail: None, + export_diagnostics: None, + path: None, + }, + ) +} + +fn legacy_descriptor_mode() -> AssetRenderMode { + AssetRenderMode::Node +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AssetExportDescriptor { + #[serde(default = "legacy_descriptor_mode")] + render_mode: AssetRenderMode, + #[serde(default)] + max_width: Option, + #[serde(default)] + usage_aspect_ratio: Option, + #[serde(default)] + srcset: bool, + #[serde(default)] + visible_scope_node_id: Option, + kind: String, + file_key: String, + version: Option, + asset_id: String, + node_id: String, + field: String, + image_hash: Option, + format: AssetFormat, + scale: f64, + status: AssetStatus, + byte_length: Option, + sha256: Option, + error_code: Option, + #[serde(default)] + error_detail: Option, + #[serde(default)] + export_diagnostics: Option, + #[serde(default)] + cursor: Option, +} + +/// The exporter's own words, kept short: it runs in the plugin, so its length +/// is not ours to trust. +fn bounded(text: Option) -> Option { + text.map(|text| text.chars().take(500).collect()) +} + +fn find_descriptor(value: &Value) -> Option { + if let Ok(mut descriptor) = serde_json::from_value::(value.clone()) + && descriptor.kind == "devupAssetExport" + { + if let Some(diagnostics) = &mut descriptor.export_diagnostics { + if diagnostics + .render_info + .as_ref() + .is_some_and(|v| v.to_string().len() > 16 * 1024) + { + diagnostics.render_info = Some( + json!({"representation":"unknown", "warnings":["render-observations-exceeded-limit"]}), + ); + } + diagnostics.native_error = bounded(diagnostics.native_error.take()); + diagnostics.method = diagnostics.method.chars().take(80).collect(); + diagnostics.stages.truncate(16); + for stage in &mut diagnostics.stages { + stage.stage = stage.stage.chars().take(80).collect(); + } + } + return Some(descriptor); + } + match value { + Value::Object(object) => object.values().find_map(find_descriptor), + Value::Array(values) => values.iter().find_map(find_descriptor), + Value::String(text) => serde_json::from_str::(text) + .ok() + .and_then(|value| find_descriptor(&value)), + _ => None, + } +} + +pub(crate) fn asset_export_diagnostics(result: &UpstreamResult) -> Option { + find_descriptor(&result.raw)?.export_diagnostics +} + +/// 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) => { + 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(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_payload(value, mime_type)) + } + Value::Array(values) => values + .iter() + .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() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn invalid(message: &str) -> DevupError { + DevupError::new(ErrorCode::DevupSnapshotUnsupported, message, false) +} diff --git a/crates/devup-mcp-figma/src/assets/asset_manifest.rs b/crates/devup-mcp-figma/src/assets/asset_manifest.rs new file mode 100644 index 00000000..e411ee71 --- /dev/null +++ b/crates/devup-mcp-figma/src/assets/asset_manifest.rs @@ -0,0 +1,936 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use crate::{ + DevupError, Diagnostic, ErrorCode, LargeValueCursor, LargeValueDescriptor, RawNode, Snapshot, + UpstreamResult, +}; + +pub const MAX_ASSET_BYTES: usize = 80 * 1024 * 1024; + +/// Full source acquisition is the default; rendered modes are explicit. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AssetRenderMode { + /// Legacy source-byte contract: matching codec and scale 1. + Fill, + Auto, + #[default] + #[serde(rename = "original-source")] + OriginalSource, + Node, + Visible, +} + +impl AssetRenderMode { + pub fn is_original(self) -> bool { + matches!(self, Self::Fill | Self::OriginalSource) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetExportDiagnostics { + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub render_info: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_error: Option, + pub stages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetExportStage { + pub stage: String, + pub elapsed_ms: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AssetFormat { + Png, + Webp, + Jpg, + Svg, + Pdf, +} + +impl AssetFormat { + pub fn mime_type(self) -> &'static str { + match self { + Self::Png => "image/png", + Self::Webp => "image/webp", + Self::Jpg => "image/jpeg", + Self::Svg => "image/svg+xml", + Self::Pdf => "application/pdf", + } + } + + pub fn extension(self) -> &'static str { + match self { + Self::Png => "png", + Self::Webp => "webp", + Self::Jpg => "jpg", + Self::Svg => "svg", + Self::Pdf => "pdf", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AssetStatus { + Available, + Exported, + Failed, + /// Announced by the export script for an SVG too large for one answer: + /// the bytes follow in fragments, and the collector reports the asset as + /// `Exported` or `Failed` once they have. Never in a manifest. + Chunked, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetRequest { + /// Optional caller-declared use-site ratio; never measured browser evidence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_aspect_ratio: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_width: Option, + #[serde(default)] + pub srcset: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visible_scope_node_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lossless: Option, + #[serde(default)] + pub render_mode: AssetRenderMode, + pub asset_id: String, + pub node_id: String, + pub field: String, + pub image_hash: Option, + pub format: AssetFormat, + pub scale: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetSelection { + /// Optional caller-declared use-site ratio; never measured browser evidence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_aspect_ratio: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_width: Option, + #[serde(default)] + pub srcset: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visible_scope_node_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lossless: Option, + #[serde(default)] + pub render_mode: AssetRenderMode, + pub asset_id: String, + pub format: AssetFormat, + pub scale: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetManifestEntry { + /// Optional caller-declared use-site ratio; never measured browser evidence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage_aspect_ratio: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_width: Option, + #[serde(default)] + pub srcset: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visible_scope_node_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub variants: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lossless: Option, + #[serde(default = "legacy_descriptor_mode")] + pub render_mode: AssetRenderMode, + /// Observations and explicit unknowns; dimensions are measured from bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub render_info: Option, + /// A safe hierarchy-based suggestion, never an automatic write destination. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suggested_filename: Option, + pub asset_id: String, + pub node_id: String, + pub field: String, + pub source_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scale: Option, + pub status: AssetStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub byte_length: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha256: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_base64: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_code: Option, + /// What failed, in the exporter's words: the step, the time it took and + /// the message Figma or the transport gave. Bounded to 500 characters. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub export_diagnostics: Option, + /// Where the generated code refers to this asset, `/icons/x.svg` or + /// `/images/x.png`, filled in by the server from the code generator's + /// naming so the bytes can be written where the code will look. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetManifest { + pub version: u32, + pub assets: Vec, + pub diagnostics: Vec, +} + +/// A bounded derivative of the acquired source; no hidden upscaling. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetVariant { + pub density: f64, + pub width: u32, + pub height: u32, + pub byte_length: usize, + pub sha256: String, + pub mime_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_base64: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum AssetNode { + Svg, + Png { + fill_index: usize, + image_hash: Option, + }, + /// A container that draws nothing of its own and holds one picture. It + /// is that picture, but it does not carry the fill, so its bytes come + /// from rendering the node rather than from a fill index it lacks. + PngNode { + image_hash: Option, + }, +} + +pub fn discover_asset_manifest(snapshot: &Snapshot) -> AssetManifest { + let mut assets = Vec::new(); + 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 let Some(asset) = compute_asset_node(snapshot, node, false) { + assets.push(manifest_entry(node, asset)); + continue; + } + + // A container carries pictures of its own: a section drawn over a + // photograph is painted `bg: url(...)` by the code generator, yet the + // node is a layout box rather than an asset, so the walk goes on into + // its children. Listed here, the picture the code points at can be + // exported; unlisted, nothing can deliver it and the screen renders + // with a hole where the photograph belongs. + assets.extend(image_fill_entries(node)); + + 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)); + crate::asset_render_info::annotate_assets(snapshot, &mut assets); + AssetManifest { + version: 1, + assets, + diagnostics: Vec::new(), + } +} + +/// Full in-scope IMAGE census, including paints below flattened asset boundaries. +pub fn discover_image_fill_assets(snapshot: &Snapshot) -> Vec { + let mut pending = snapshot + .roots + .iter() + .rev() + .map(|id| (id.clone(), false)) + .collect::>(); + let mut seen = std::collections::BTreeSet::new(); + let mut assets = Vec::new(); + while let Some((id, ancestor_hidden)) = pending.pop() { + if !seen.insert(id.clone()) { + continue; + } + let Some(node) = snapshot.nodes.get(&id) else { + continue; + }; + let hidden = ancestor_hidden || asset_exclusion_reason(node).is_some(); + for (index, paint) in fills(node) + .into_iter() + .flatten() + .enumerate() + .filter(|(_, p)| fill_type(p) == Some("IMAGE")) + { + let mut asset = manifest_entry( + node, + AssetNode::Png { + fill_index: index, + image_hash: paint + .get("imageHash") + .or_else(|| paint.get("imageRef")) + .and_then(Value::as_str) + .map(str::to_owned), + }, + ); + if hidden + || !is_visible_fill(paint) + || paint["opacity"].as_f64().is_some_and(|n| n <= 0.0) + { + asset.status = AssetStatus::Failed; + asset.error_code = Some("DEVUP_ASSET_NODE_HIDDEN".into()); + asset.error_detail = Some("The node, paint or an acquired ancestor is hidden, transparent or outside rendered bounds; no image was rendered.".into()); + } + assets.push(asset); + } + pending.extend( + node.typed_view() + .child_ids() + .collect::>() + .into_iter() + .rev() + .map(|id| (id.to_owned(), hidden)), + ); + } + assets.sort_by(|a, b| a.asset_id.cmp(&b.asset_id)); + crate::annotate_assets(snapshot, &mut assets); + assets +} + +/// Every acquired IMAGE paint, including hidden/failed dispositions. +pub fn discover_web_ready_assets(snapshot: &Snapshot) -> Vec { + discover_image_fill_assets(snapshot) +} + +fn direct_node_candidate(snapshot: &Snapshot, asset_id: &str) -> Option { + let id = asset_id.strip_suffix(":node")?; + let node = snapshot.nodes.get(id)?; + // Snapshot membership alone is insufficient: context nodes may be acquired + // outside the selected roots. Walk only roots and their actual child edges. + if !in_scope(snapshot, id) { + return None; + } + if matches!(node.typed_view().node_type(), "PAGE" | "DOCUMENT") { + return None; + } + let mut entry = manifest_entry(node, AssetNode::Svg); + entry.source_kind = "node".into(); + Some(entry) +} + +pub(crate) fn in_scope(snapshot: &Snapshot, wanted: &str) -> bool { + let mut pending = snapshot.roots.clone(); + let mut seen = std::collections::BTreeSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id.clone()) { + continue; + } + if id == wanted { + return true; + } + if let Some(node) = snapshot.nodes.get(&id) { + pending.extend(node.typed_view().child_ids().map(str::to_owned)); + } + } + false +} + +fn visible_scope( + snapshot: &Snapshot, + node_id: &str, + explicit: Option<&str>, +) -> Result { + let mut current = node_id.to_owned(); + let mut seen = std::collections::BTreeSet::new(); + loop { + if !seen.insert(current.clone()) { + break; + } + if explicit == Some(current.as_str()) && in_scope(snapshot, ¤t) { + return Ok(current); + } + let parent = snapshot + .nodes + .values() + .find(|n| n.typed_view().child_ids().any(|id| id == current)); + let Some(parent) = parent else { + break; + }; + current = parent.id.clone(); + if explicit.is_none() && in_scope(snapshot, ¤t) { + return Ok(current); + } + } + Err(invalid(include_str!( + "../text/assets-visible_scope-00ec4145.md" + ))) +} + +pub fn validate_asset_options( + format: AssetFormat, + mode: AssetRenderMode, + scale: f64, + max_width: Option, + srcset: bool, +) -> Result<(), DevupError> { + if !scale.is_finite() || scale <= 0.0 || scale > 4.0 { + return Err(invalid( + "Asset scale must be finite, greater than zero and at most 4; rendered bitmaps default to 2 and recommend 1.5 to 3.", + )); + } + if max_width.is_some_and(|w| w == 0 || w > crate::MAX_WEBP_DIMENSION) + || ((max_width.is_some() || srcset) + && (format != AssetFormat::Webp + || !mode.is_original() + || mode == AssetRenderMode::Fill)) + { + return Err(invalid( + "maxWidth (1..8192) and srcset require original-source WebP; legacy fill preserves its no-resize contract.", + )); + } + Ok(()) +} + +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; + } + + // A boolean's rendered shape is authoritative even when its operands + // were not included in the snapshot. Export the node, never its bounds. + if matches!( + view.node_type(), + "VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION" + ) { + 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; + } + + // The container stands in for the child, and the code names the + // file after the container. Asking Figma for `fills/0` of a node + // whose fills are empty is a request it can only refuse, and it did: + // the devup-ui landing page's footer logo sits in such a frame and + // was the one asset of 182 that never arrived. Rendering the node + // gives the same picture, and is a request the node can answer. + return match snapshot + .nodes + .get(child_ids[0]) + .and_then(|child| compute_asset_node(snapshot, child, true)) + { + Some(AssetNode::Svg) => Some(AssetNode::Svg), + Some(AssetNode::Png { image_hash, .. } | AssetNode::PngNode { image_hash }) => { + Some(AssetNode::PngNode { image_hash }) + } + 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); + } + } + + visible_children + .into_iter() + .all(|child| compute_asset_node(snapshot, child, true) == Some(AssetNode::Svg)) + .then_some(AssetNode::Svg) +} + +/// Every picture a node paints from an image fill. The code generator writes +/// a `url(...)` for each visible image fill whatever its scale mode, so each +/// one is an asset a caller has to be able to fetch. A node that is an asset +/// in its own right never reaches this: its bytes come from the node itself. +fn image_fill_entries(node: &RawNode) -> Vec { + fills(node) + .into_iter() + .flatten() + .enumerate() + .filter(|(_, fill)| is_visible_fill(fill) && fill_type(fill) == Some("IMAGE")) + .map(|(fill_index, fill)| { + manifest_entry( + node, + AssetNode::Png { + fill_index, + image_hash: fill + .get("imageHash") + .or_else(|| fill.get("imageRef")) + .and_then(Value::as_str) + .map(str::to_owned), + }, + ) + }) + .collect() +} + +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") + }) + }) + }) + }) +} + +/// Shared discovery/codegen policy for assets that cannot produce visible bytes. +pub fn asset_exclusion_reason(node: &RawNode) -> Option<&'static str> { + let view = node.typed_view(); + if view.bool("visible") == Some(false) { + Some("visible-false") + } else if view.number("opacity").is_some_and(|opacity| opacity <= 0.0) { + Some("zero-opacity") + } else if view.value("absoluteBoundingBox").is_some() + && view + .value("absoluteRenderBounds") + .is_none_or(Value::is_null) + { + Some("no-render-bounds") + } else { + None + } +} + +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, + ), + AssetNode::PngNode { image_hash } => ( + format!("{}:node", node.id), + "node".to_owned(), + "image-node".to_owned(), + image_hash, + ), + }; + + // Figma refuses to export a node that has no visible layers, so a node + // that draws nothing 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. + // + // Drawing nothing is not only being hidden. A node left fully transparent + // is `visible`, and renders no pixel all the same: the about page carries + // two such icons, and they were the only two exports Figma turned down. + // + // Nor is it only about the node itself. One that sits entirely outside an + // ancestor that clips is visible, opaque, and still draws nothing, and + // Figma says so by leaving `absoluteRenderBounds` off it - that field is + // the bounds of what the node actually renders, and there are none. The + // devup-ui landing page's mobile and tablet each carry one such icon, + // pushed past the edge of a clipped panel, and they were the only two + // exports of 215 that Figma turned down. + let hidden = asset_exclusion_reason(node).is_some(); + let (status, error_code) = if hidden { + ( + AssetStatus::Failed, + Some("DEVUP_ASSET_NODE_HIDDEN".to_owned()), + ) + } else { + (AssetStatus::Available, None) + }; + + AssetManifestEntry { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + variants: Vec::new(), + render_mode: AssetRenderMode::Node, + quality: None, + lossless: None, + render_info: None, + suggested_filename: None, + 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, + error_detail: None, + export_diagnostics: None, + path: None, + } +} + +pub fn validate_asset_requests( + snapshot: &Snapshot, + requests: &[AssetRequest], +) -> Result<(), DevupError> { + if requests.len() > 16 { + return Err(invalid("At most 16 assets can be exported at once.")); + } + let mut available = discover_asset_manifest(snapshot); + available + .assets + .extend(discover_image_fill_assets(snapshot)); + let mut seen = std::collections::BTreeSet::new(); + for request in requests { + crate::validate_webp_options(request.format, request.quality, request.lossless)?; + if request + .usage_aspect_ratio + .is_some_and(|r| !r.is_finite() || r <= 0.0 || r > 10000.0) + { + return Err(invalid( + "usageAspectRatio must be finite and positive, at most10000.", + )); + } + validate_asset_options( + request.format, + request.render_mode, + request.scale, + request.max_width, + request.srcset, + )?; + if request.scale == 0.0 || request.scale > 4.0 || !seen.insert(request.asset_id.as_str()) { + return Err(invalid( + "asset request has an invalid scale or a duplicate ID.", + )); + } + let direct = direct_node_candidate(snapshot, &request.asset_id); + let Some(candidate) = available + .assets + .iter() + .find(|asset| asset.asset_id == request.asset_id) + .or(direct.as_ref()) + else { + return Err(invalid("The requested asset is not in the snapshot.")); + }; + if let Some(node) = snapshot.nodes.get(&request.node_id) { + if !request.render_mode.is_original() + && ["width", "height"].into_iter().any(|key| { + node.typed_view() + .number(key) + .is_some_and(|n| !n.is_finite() || n <= 0.0) + }) + { + return Err(invalid( + "Native capture dimensions must be finite and positive.", + )); + } + if request.srcset + && node + .typed_view() + .number("width") + .is_none_or(|w| !w.is_finite() || w <= 0.0) + { + return Err(invalid( + "srcset requires a known positive node display width.", + )); + } + } + if !in_scope(snapshot, &request.node_id) { + return Err(invalid( + "The requested node is outside the acquired snapshot scope.", + )); + } + if candidate.node_id != request.node_id + || candidate.field != request.field + || candidate.image_hash != request.image_hash + { + return Err(invalid("asset request does not match the snapshot source.")); + } + if request.render_mode == AssetRenderMode::Fill + && (!request.field.starts_with("fills/") + || !matches!( + request.format, + AssetFormat::Png | AssetFormat::Jpg | AssetFormat::Webp + ) + || request.scale != 1.0) + { + return Err(invalid(include_str!( + "../text/assets-validate_asset_requests-09bfca92.md" + ))); + } + // 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 request.render_mode == AssetRenderMode::OriginalSource + && (!request.field.starts_with("fills/") + || request.image_hash.is_none() + || !matches!( + request.format, + AssetFormat::Png | AssetFormat::Jpg | AssetFormat::Webp + )) + { + return Err(invalid( + "original-source requires a resolvable IMAGE fill; request renderMode node explicitly for native raster boundaries, or retain SVG for vectors.", + )); + } + if request.render_mode == AssetRenderMode::Visible { + visible_scope( + snapshot, + &request.node_id, + request.visible_scope_node_id.as_deref(), + )?; + if !matches!( + request.format, + AssetFormat::Png | AssetFormat::Jpg | AssetFormat::Webp + ) { + return Err(invalid("Visible scoped captures require PNG, JPG or WebP.")); + } + } + if request.render_mode != AssetRenderMode::Visible + && request.visible_scope_node_id.is_some() + { + return Err(invalid( + "visibleScopeNodeId applies only to renderMode visible.", + )); + } + if candidate.status == AssetStatus::Failed + && request.render_mode != AssetRenderMode::OriginalSource + { + return Err(invalid( + "The requested asset cannot be exported: the node is hidden in Figma.", + )); + } + } + Ok(()) +} + +pub fn resolve_asset_selections( + snapshot: &Snapshot, + selections: &[AssetSelection], +) -> Result, DevupError> { + if selections.len() > 16 { + return Err(invalid("At most 16 assets can be exported at once.")); + } + let mut manifest = discover_asset_manifest(snapshot); + manifest.assets.extend(discover_image_fill_assets(snapshot)); + let mut seen = std::collections::BTreeSet::new(); + let requests = selections + .iter() + .map(|selection| { + if selection.scale == 0.0 + || selection.scale > 4.0 + || !seen.insert(selection.asset_id.as_str()) + { + return Err(invalid( + "asset selection has an invalid scale or a duplicate ID.", + )); + } + let direct = direct_node_candidate(snapshot, &selection.asset_id); + let asset = manifest + .assets + .iter() + .find(|asset| asset.asset_id == selection.asset_id) + .or(direct.as_ref()) + .ok_or_else(|| { + invalid("The selected asset or nodeId is not in the acquired snapshot scope.") + })?; + let capture_scope = if selection.render_mode == AssetRenderMode::Visible { + Some(visible_scope( + snapshot, + &asset.node_id, + selection.visible_scope_node_id.as_deref(), + )?) + } else { + selection.visible_scope_node_id.clone() + }; + Ok(AssetRequest { + usage_aspect_ratio: selection.usage_aspect_ratio, + max_width: selection.max_width, + srcset: selection.srcset, + visible_scope_node_id: capture_scope, + asset_id: asset.asset_id.clone(), + node_id: asset.node_id.clone(), + field: asset.field.clone(), + image_hash: asset.image_hash.clone(), + format: selection.format, + scale: selection.scale, + render_mode: selection.render_mode, + quality: selection.quality, + lossless: selection.lossless, + }) + }) + .collect::, _>>()?; + validate_asset_requests(snapshot, &requests)?; + Ok(requests) +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/bridge.rs b/crates/devup-mcp-figma/src/bridge.rs index d6914c53..fc63bd68 100644 --- a/crates/devup-mcp-figma/src/bridge.rs +++ b/crates/devup-mcp-figma/src/bridge.rs @@ -17,1993 +17,7 @@ //! 을 찾도록 쓰여 있으므로, 여기서 모양을 바꾸면 두 경로가 조용히 갈라진다. 중계를 //! 거친 답도 이 모양이다 — 감싸는 일은 요청한 프로세스가 한다. -mod holder; -mod relay; -mod secret; - -pub use holder::PortOwner; - -use std::{ - collections::HashMap, - hash::Hash, - io, - path::PathBuf, - sync::{ - Arc, Mutex as StdMutex, - atomic::{AtomicU64, AtomicUsize, Ordering}, - }, - time::{Duration, Instant}, -}; - -use async_trait::async_trait; -use axum::{ - Router, - extract::{ - State, - ws::{Message, WebSocket, WebSocketUpgrade}, - }, - http::{HeaderMap, StatusCode, header::ORIGIN}, - response::{IntoResponse, Response}, - routing::any, -}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use tokio::{ - net::TcpListener, - sync::{Mutex, mpsc, oneshot, watch}, - time::timeout, -}; - -use crate::{ - errors::{DevupError, ErrorCode}, - upstream::{BatchBudget, FigmaUpstream, ReadToolCall, UpstreamResult}, - url::{BRIDGE_KEY_PREFIX, is_bridge_only_key}, -}; - -/// 플러그인 manifest 의 `allowedDomains` 와 같은 값이어야 한다. 바꾸려면 양쪽을 -/// 함께 고쳐야 하며, 플러그인은 재빌드·재설치가 필요하다. -pub const DEFAULT_BRIDGE_PORT: u16 = 1993; - -/// 한 번의 읽기에 허용하는 시간. 스크립트는 문서를 한 번 훑는 정도라 원격보다 -/// 훨씬 짧아도 되지만, 아주 큰 페이지의 첫 스냅샷은 몇 초가 걸린다. -const JOB_TIMEOUT: Duration = Duration::from_secs(90); - -/// 브리지가 답한 결과의 `_meta` 에서 어느 플러그인이 답했는지를 싣는 자리. -const SERVED_META_KEY: &str = "devup/bridge"; - -/// 참조 PNG 결과의 `_meta` 에서 플러그인이 그 그림에 단 말을 싣는 자리. -pub(crate) const REFERENCE_PNG_NOTE_META_KEY: &str = "devup/referencePngNote"; - -/// 플러그인이 끊긴 뒤 다시 붙기를 시도하는 간격(`plugin/src/ui.ts` 의 `RETRY_MS`). -const PLUGIN_RETRY: Duration = Duration::from_secs(2); - -/// 호스트가 바뀐 직후, 붙어 있던 플러그인이 새 호스트에 다시 붙기를 기다리는 시간. -/// 플러그인의 재시도 간격에 연결·인사에 걸리는 시간을 얹었다. 이 사이의 읽기는 -/// 플러그인이 없다고 단정해 요금이 드는 경로로 넘기지 않고, 붙기를 기다린다. -const REATTACH_GRACE: Duration = Duration::from_secs(PLUGIN_RETRY.as_secs() + 3); - -/// 누가 포트를 쥐었는지 아직 모를 때 status 와 읽기가 답을 기다리는 한도. 확인은 -/// 몇 밀리초면 끝나고, 대답하지 않는 상대도 [`relay`] 의 제한 시간 안에 판정된다. -const SETTLE_TIMEOUT: Duration = Duration::from_secs(5); - -/// 플러그인이 가리키는 노드 하나 — 보고 있는 페이지, 또는 거기서 선택한 것. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct NodeRef { - pub id: String, - #[serde(default)] - pub name: String, - #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] - pub node_type: Option, -} - -/// Figma 를 쓰는 사람이 지금 있는 곳: 보고 있는 페이지와 거기서 선택한 것. -/// -/// 모두 선택 사항이다. 이것을 보고하기 전에 빌드된 플러그인은 아무것도 보내지 -/// 않고, "이 플러그인은 말하지 않는다"와 "아무것도 선택하지 않았다"는 계속 -/// 구분되어야 한다 — 앞의 것은 플러그인을 다시 띄울 일이고, 뒤의 것은 Figma 에서 -/// 고를 일이다. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PluginContext { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub zoom: Option, - /// Pixel ratio of the plugin UI iframe, not a verified canvas capture scale. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ui_device_pixel_ratio: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub observed_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_known_selection: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_known_page: Option, - #[serde(default)] - pub current_page: Option, - /// 선택한 노드의 앞부분. 플러그인이 보내는 만큼만 있고, 전체 수는 - /// `selection_count` 다. - #[serde(default)] - pub selection: Option>, - #[serde(default)] - pub selection_count: Option, - /// 플러그인 창이 화면에 보이는지. Figma 는 보이지 않는 파일이 아직 불러오지 않은 - /// 데이터를 읽지 못하게 하므로, 수집을 시작하기 전에 알 수 있어야 한다. 이것을 - /// 보고하기 전에 빌드된 플러그인은 `None`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub in_view: Option, -} - -impl PluginContext { - /// 정확히 하나가 선택돼 있으면 그 노드. - pub fn single_selection(&self) -> Option<&NodeRef> { - match self.selection.as_deref()? { - [node] if self.selection_count.is_none_or(|count| count == 1) => Some(node), - _ => None, - } - } -} - -/// 붙어 있는 플러그인 하나. `status` 가 보고하는 것이자, url 없는 요청이 -/// 가리키게 되는 것이다. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AttachedFile { - /// 읽기가 이 플러그인에 닿으려면 부를 이름. 플러그인이 보고한 파일 키이고, - /// 보고하지 못했으면 이 연결만 가리키는 브리지 전용 키다. 호출자에게는 - /// 보이지 않는다 — 플러그인이 보고하지 않은 키는 그 파일의 키가 아니다. - pub target_key: String, - /// 플러그인이 보고한 파일 키. 보고하지 못했으면 `None`. - pub file_key: Option, - pub file_name: Option, - pub context: PluginContext, -} - -/// 수집의 읽기를 어느 플러그인이 답했는지. -/// -/// 수집 결과와 함께 보관되어, 새로 투영하든 재사용하든 같은 출처를 말한다. -/// 파일 키는 플러그인이 보고한 것뿐이다 — 보고하지 못했으면 키를 말하지 않으며, -/// 요청에 실려 온 키를 그 파일의 키로 내세우지 않는다. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BridgeServed { - /// 수집의 읽기 가운데 플러그인이 답한 수. - pub reads: usize, - pub port: Option, - pub file_key: Option, - pub file_name: Option, - pub page_name: Option, -} - -impl BridgeServed { - /// 브리지가 답한 결과면 그 출처, 아니면 `None`. - pub fn from_result(raw: &Value) -> Option { - serde_json::from_value(raw.get("_meta")?.get(SERVED_META_KEY)?.clone()).ok() - } -} - -/// 플러그인이 작업을 마치고 보내는 메시지. -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PluginResult { - request_id: String, - #[serde(default)] - data: Option, - #[serde(default)] - error: Option, -} - -/// 서버가 플러그인에 보내는 작업. -/// -/// `requestId` 는 늘 호스트가 매긴다. 중계로 온 읽기도 마찬가지라, 여러 프로세스의 -/// 읽기가 한 플러그인에 모여도 번호가 겹치지 않고, 답은 번호로 물은 쪽에만 간다. -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct Job<'a> { - kind: &'static str, - request_id: String, - script: &'a str, - params: Value, -} - -/// 브리지가 맡을 수 있는 읽기 하나. `ReadToolCall::bridge_job` 이 만든다. -#[derive(Debug, Clone, PartialEq)] -pub struct BridgeJob { - /// 플러그인이 아는 이름. 코드젠이 파일명에서 만든 것과 같아야 한다. - pub script: &'static str, - /// 스크립트가 읽는 값. Rust 의 치환 자리와 1:1 이다. - pub params: Value, -} - -struct Connected { - outbox: mpsc::UnboundedSender, - /// 플러그인이 보고한 파일 키. 보고하지 못했으면 빈 문자열이다. - file_key: String, - /// 읽기가 이 플러그인에 닿으려면 부를 이름. [`routing_key`] 가 정한다. - routing_key: String, - file_name: Option, - context: PluginContext, -} - -impl Connected { - fn reported_key(&self) -> Option { - (!self.file_key.is_empty()).then(|| self.file_key.clone()) - } - - fn attached(&self) -> AttachedFile { - AttachedFile { - target_key: self.routing_key.clone(), - file_key: self.reported_key(), - file_name: self.file_name.clone(), - context: self.context.clone(), - } - } - - fn served(&self) -> BridgeServed { - BridgeServed { - reads: 1, - port: None, - file_key: self.reported_key(), - file_name: self.file_name.clone(), - page_name: self - .context - .current_page - .as_ref() - .map(|page| page.name.clone()), - } - } -} - -/// 읽기가 이 플러그인에 닿으려면 부를 이름. -/// -/// 파일 키를 보고했으면 그 키다. 보고하지 못했으면(Dev Mode) 브리지 전용 키인데, -/// 플러그인이 창마다 한 번 정해 `hello` 에 싣는 `sessionId` 가 있으면 그것에서 -/// 만든다 — 소켓이 끊겨 다시 붙어도, 포트를 쥔 devup-mcp 가 바뀌어도 같은 창이면 -/// 같은 이름이라, 수집 도중에 연결이 바뀌어도 남은 읽기가 그 창을 다시 찾는다. -/// `sessionId` 를 보내지 않는 예전 빌드는 연결마다 매긴 번호로 부른다. -fn routing_key(file_key: &str, session: Option<&str>, connection: u64) -> String { - if !file_key.is_empty() { - return file_key.to_owned(); - } - match session { - Some(session) => format!("{BRIDGE_KEY_PREFIX}{session}"), - None => connection_key(connection), - } -} - -/// 플러그인이 보낸 `sessionId` 를 이름에 쓸 수 있을 때만 받는다. 브리지 전용 키의 -/// 모양을 지키고(`bridge:current` 같은 예약된 이름과 겹치지 않게) 길이를 묶는다. -fn session_of(hello: &Value) -> Option { - hello - .get("sessionId") - .and_then(Value::as_str) - .filter(|session| { - (16..=64).contains(&session.len()) - && session - .bytes() - .all(|byte| byte.is_ascii_hexdigit() || byte == b'-') - }) - .map(|session| format!("s-{session}")) -} - -/// 플러그인이 답하기를 기다리는 읽기 하나. -struct Pending { - /// 작업을 받은 플러그인의 연결 번호. 그 연결이 끊기면 이 읽기도 끝난다. - plugin: u64, - /// 그 플러그인에 보낼 곳. 기다리는 쪽이 떠나면 여기로 거둬 달라고 알린다. - outbox: mpsc::UnboundedSender, - waiter: oneshot::Sender, -} - -type PendingTable = StdMutex>; - -/// 플러그인의 답을 기다리는 동안 쥐고 있는 대기표. -/// -/// 답이 오면 받는 쪽이 대기표를 이미 걷었으므로 아무 일도 하지 않는다. 답을 받지 -/// 못한 채 버려지면 — 시간이 다 됐거나, 읽기를 맡긴 프로세스가 떠나 호스트가 그 -/// 읽기를 취소했거나 — 대기표를 걷고 플러그인에 `devup-cancel` 을 보낸다. 아직 -/// 차례를 기다리던 작업이면 플러그인은 돌리지 않는다. -struct Awaiting<'a> { - pending: &'a PendingTable, - request_id: String, -} - -impl Drop for Awaiting<'_> { - fn drop(&mut self) { - let Ok(mut pending) = self.pending.lock() else { - return; - }; - if let Some(abandoned) = pending.remove(&self.request_id) { - let cancel = json!({ "kind": "devup-cancel", "requestId": self.request_id }); - let _ = abandoned.outbox.send(cancel.to_string()); - } - } -} - -#[derive(Default)] -struct Inner { - /// 연결 번호 → 플러그인. - /// - /// 파일 키로 묶지 않는다. 키를 보고하지 못한 플러그인들은 모두 빈 키라서, - /// 그렇게 묶으면 둘째가 첫째를 덮어쓰고 첫째가 끊길 때 둘째의 등록까지 - /// 지운다. 그러면 "플러그인이 정확히 하나"를 셀 수도 없다. - plugins: HashMap, - /// 연결 번호 → 이 호스트를 통해 읽는 다른 devup-mcp. 플러그인이 붙거나 떠나거나 - /// 선택이 바뀔 때마다 모두에게 새 목록을 보낸다. - relays: HashMap>, -} - -/// 결과를 기다리는 쪽이 사라지면 그 대기표를 걷는다. -/// -/// 기다리던 future 가 버려지는 일은 흔하다 — 중계를 요청한 프로세스가 끊기면 호스트는 -/// 그 읽기들을 취소한다. 걷지 않으면 플러그인이 끝내 답하지 않는 한 대기표가 남는다. -struct Waiting<'a, K: Eq + Hash, V> { - pending: &'a StdMutex>, - key: K, -} - -impl Drop for Waiting<'_, K, V> { - fn drop(&mut self) { - if let Ok(mut pending) = self.pending.lock() { - pending.remove(&self.key); - } - } -} - -/// 이 프로세스가 브리지에서 맡은 역할. -#[derive(Clone)] -enum LinkState { - /// 누가 포트를 쥐었는지 아직 모른다 — 시작할 때, 또는 쥐고 있던 호스트가 떠난 뒤. - Connecting { after: Option }, - /// 이 프로세스가 포트를 쥐었다. - /// - /// `expecting` 은 방금 떠난 호스트다. 그때 플러그인이 붙어 있었으면 곧 이리로 - /// 다시 붙는다. - Host { - expecting: Option, - since: Instant, - }, - /// 다른 devup-mcp 가 포트를 쥐었고, 이 프로세스는 그를 통해 읽는다. `files` 는 - /// 그 호스트가 보낸 플러그인 목록이다. - Relay { - connection: Arc, - files: Vec<(u64, AttachedFile)>, - expecting: Option, - since: Instant, - }, - /// 지금은 브리지를 쓸 수 없다. `holder` 는 포트를 쥔 쪽이 스스로 밝힌 것이다. - Unavailable { - issue: BridgeIssue, - holder: Option, - }, -} - -struct Link { - state: watch::Sender, - /// 중계 핸드셰이크에서 상대에게 밝히는 이 프로세스. - me: BridgePeer, - secret_path: Option, - shutdown: watch::Sender, -} - -impl Link { - fn current(&self) -> LinkState { - self.state.borrow().clone() - } - - fn set(&self, state: LinkState) { - self.state.send_replace(state); - } - - /// 역할은 그대로지만 보이는 것이 바뀌었다(플러그인이 붙거나 떠났다). - fn touch(&self) { - self.state.send_modify(|_| {}); - } - - fn shutdown_signal(&self) -> watch::Receiver { - self.shutdown.subscribe() - } - - fn is_shut_down(&self) -> bool { - *self.shutdown.borrow() - } -} - -/// 닫으라는 신호가 올 때까지 기다린다. -async fn shut_down(signal: &mut watch::Receiver) { - let _ = signal.wait_for(|down| *down).await; -} - -/// 플러그인 연결과 대기 중인 요청, 이 프로세스의 역할을 함께 들고 있는 공유 상태. -#[derive(Clone)] -pub struct BridgeState { - inner: Arc>, - /// requestId → 결과를 기다리는 쪽. 잠금을 쥔 채 기다리지 않으므로 동기 잠금이면 - /// 되고, 그래야 기다리던 쪽이 사라질 때 [`Awaiting`] 이 곧바로 걷을 수 있다. - pending: Arc, - /// 요청 번호와 연결 번호를 함께 매긴다. 둘 다 유일하기만 하면 된다. - counter: Arc, - /// 이 프로세스에서 보이는 플러그인 수. 배치 크기를 정할 때는 잠금을 기다릴 수 없어 - /// (그 자리가 async 가 아니다) 따로 센다. - connected: Arc, - link: Arc, -} - -fn unavailable(message: impl Into) -> DevupError { - DevupError::new(ErrorCode::DevupFigmaDirectUnavailable, message, false) -} - -/// 플러그인이 답한 실패 — 스크립트가 던진 `DEVUP_*` 코드나 Figma 런타임의 문구. -/// 전송이 끊긴 것과 달리 읽기는 플러그인에 닿았으므로, [`BridgeFigmaClient`] 가 -/// 원격 `use_figma` 와 같은 `isError` 결과로 바꿔 올린다. 그러지 않으면 Figma 가 -/// 페이지를 불러오지 못했다는 말까지 "direct 경로를 쓸 수 없다"로 읽혔다. -fn script_failure(message: impl Into) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaDirectUnavailable, - message, - false, - json!({ "source": "bridge", "stage": "plugin-execution" }), - ) -} - -pub(crate) fn is_script_failure(error: &DevupError) -> bool { - error.details["source"] == "bridge" && error.details["stage"] == "plugin-execution" -} - -/// 플러그인이 답한 실패를 원격 `use_figma` 가 주는 모양으로 쓴다. 위쪽은 두 경로의 -/// 실패를 한 가지로 분류한다 — 없는 노드는 호출자 오류로, 나머지는 플러그인 실행 -/// 오류로. -fn script_failure_result(message: &str) -> UpstreamResult { - UpstreamResult { - raw: json!({ - "content": [{ "type": "text", "text": format!("Error: {message}") }], - "isError": true, - }), - } -} - -/// 이 연결 하나만 가리키는 브리지 전용 키. -fn connection_key(id: u64) -> String { - format!("{BRIDGE_KEY_PREFIX}{id}") -} - -/// 오류 문구에 쓸 파일 이름. 브리지 전용 키는 파일의 키가 아니므로 드러내지 않는다. -fn describe_file(file_key: &str) -> String { - if is_bridge_only_key(file_key) { - "the file this request was addressed to".to_owned() - } else { - format!("file {file_key}") - } -} - -/// 어느 연결이 이 파일을 맡을지 고른다. -/// -/// 보통은 파일 키가 그대로 맞는다. 같은 파일을 두 창에서 열었으면 나중에 붙은 -/// 쪽이 맡는다 — 둘 다 같은 문서를 보므로 어느 쪽이든 답은 같다. -/// -/// 다만 `figma.fileKey` 는 늘 있는 값이 아니어서 (Dev Mode 에서 비어 오는 것을 -/// 실제로 봤다) 키 없이 붙는 플러그인이 생긴다. 그때 등록을 건너뛰면 창은 -/// "연결됨"이라고 하는데 어떤 읽기도 오지 않는 — 원인을 찾기 가장 어려운 — 상태가 -/// 된다. 그래서 키 없는 플러그인은 **혼자 붙어 있을 때만** 아무 키나 맡는다. -/// 여럿이면 어느 파일을 보고 있는지 알 수 없고, 엉뚱한 파일을 읽어 주는 것보다 -/// 원격으로 넘기는 편이 낫다. -/// -/// 브리지 전용 키는 그 창 하나만 가리킨다. 몇 개가 붙어 있든 모호하지 않고, 그 -/// 창이 끊기면 아무도 맡지 않는다 — 다른 파일의 플러그인이 대신 답하면 안 된다. -/// 같은 창이 다시 붙으면(같은 `sessionId`) 나중 연결이 맡는다. -/// -/// `plugins` 는 (연결 번호, 부를 이름, 보고한 파일 키 — 없으면 빈 문자열) 이다. -/// 호스트는 제 연결로, 중계는 호스트가 보낸 목록으로 같은 규칙을 쓴다. -fn resolve<'a>( - plugins: impl IntoIterator, - file_key: &str, -) -> Option { - let plugins: Vec<(u64, &str, &str)> = plugins.into_iter().collect(); - if is_bridge_only_key(file_key) { - return plugins - .iter() - .filter(|(_, routing, _)| *routing == file_key) - .map(|(id, ..)| *id) - .max(); - } - let holding = plugins - .iter() - .filter(|(_, _, reported)| *reported == file_key) - .map(|(id, ..)| *id) - .max(); - if holding.is_some() { - return holding; - } - match plugins.as_slice() { - [(id, _, "")] => Some(*id), - _ => None, - } -} - -fn keys_of(plugins: &HashMap) -> impl Iterator { - plugins - .iter() - .map(|(id, plugin)| (*id, plugin.routing_key.as_str(), plugin.file_key.as_str())) -} - -fn keys_in(files: &[(u64, AttachedFile)]) -> impl Iterator { - files.iter().map(|(id, file)| { - ( - *id, - file.target_key.as_str(), - file.file_key.as_deref().unwrap_or_default(), - ) - }) -} - -/// 읽기 한 번이 끝난 모양. 중간에 끊긴 것은 다시 보낼 수 있다 — 읽기는 문서를 -/// 바꾸지 않는다. -pub(crate) enum Failure { - /// 플러그인에 닿는 길이 도중에 끊겼다: 플러그인이 떠났거나, 포트를 쥔 호스트가 - /// 떠났다. 플러그인이 돌아오면 다시 보낸다. - Interrupted(DevupError), - /// 답이 왔거나(스크립트 오류 포함) 더 기다릴 수 없다. 그대로 올린다. - Final(DevupError), -} - -/// 끊긴 읽기를 다시 보내는 횟수의 한도. -const INTERRUPTIONS: usize = 3; - -/// 끊긴 읽기가 플러그인이 돌아오기를 기다리는 한도. 호스트가 바뀌어도 이 안에 -/// 포트가 넘어가고, 플러그인은 2초마다 다시 붙는다. -const RETURN_WAIT: Duration = Duration::from_secs(10); - -/// 중계가 다룰 수 없는 상태에서 읽기가 왔을 때의 거절. -fn not_reachable(link: &LinkState) -> DevupError { - match link { - LinkState::Unavailable { issue, .. } => unavailable(format!( - "this devup-mcp cannot use the Devup Bridge right now ({}); devup_figma_auth \ - {{ action: \"status\" }} says why and what to do", - issue.code() - )), - _ => unavailable( - "the Devup Bridge port is changing hands: the devup-mcp that held it went away and \ - this process is taking it over or reconnecting. Repeat the call in a few seconds.", - ), - } -} - -impl BridgeState { - fn new(options: BridgeOptions) -> Self { - Self { - inner: Arc::default(), - pending: Arc::default(), - // 연결 번호가 키 없는 플러그인의 라우팅 키(`bridge:<번호>`)가 된다. 호스트가 - // 바뀐 뒤 옛 호스트가 매긴 번호가 새 호스트의 다른 플러그인을 가리키면, 읽기가 - // 엉뚱한 파일로 조용히 간다. 프로세스마다 멀리 떨어진 자리에서 센다. - counter: Arc::new(AtomicU64::new(rand::random::() >> 16)), - connected: Arc::default(), - link: Arc::new(Link { - state: watch::channel(LinkState::Connecting { after: None }).0, - me: BridgePeer { - pid: std::process::id(), - version: env!("CARGO_PKG_VERSION").to_owned(), - build_id: options.build_id, - }, - secret_path: options.secret_path.or_else(secret::default_path), - shutdown: watch::channel(false).0, - }), - } - } - - fn next_request_id(&self) -> String { - format!("job-{}", self.counter.fetch_add(1, Ordering::Relaxed)) - } - - /// 포트를 쥔 이 프로세스가 플러그인과 중계를 받기 시작한다. - fn serve( - &self, - listener: std::net::TcpListener, - expecting: Option, - ) -> io::Result<()> { - listener.set_nonblocking(true)?; - let listener = TcpListener::from_std(listener)?; - let app = Router::new() - .route("/plugin", any(plugin_socket)) - .route("/relay", any(relay::relay_socket)) - .with_state(self.clone()); - let mut shutdown = self.link.shutdown_signal(); - self.connected.store(0, Ordering::Relaxed); - self.link.set(LinkState::Host { - expecting, - since: Instant::now(), - }); - tokio::spawn(async move { - // 닫으라는 신호가 오면 듣기를 멈추고 포트를 놓는다. 이어받을 프로세스가 곧장 - // bind 할 수 있어야 한다. - let _ = axum::serve(listener, app) - .with_graceful_shutdown(async move { shut_down(&mut shutdown).await }) - .await; - }); - Ok(()) - } - - /// 플러그인 목록이 바뀌었다. 세고, 중계들에게 알리고, 기다리는 쪽을 깨운다. - fn plugins_changed(&self, inner: &mut Inner) { - self.connected.store(inner.plugins.len(), Ordering::Relaxed); - if !inner.relays.is_empty() { - let message = relay::files_message(&inner.plugins); - inner - .relays - .retain(|_, outbox| outbox.send(message.clone()).is_ok()); - } - self.link.touch(); - } - - /// 역할이 정해질 때까지, 그리고 방금 호스트가 바뀌었다면 플러그인이 다시 붙을 - /// 때까지 기다린다. 둘 다 짧고, 한도가 있다. - /// - /// 그 사이에 판정하면 "플러그인이 없다"가 된다. 그러면 읽기는 요금이 드는 경로로 - /// 넘어가고, status 는 로그인하라고 한다 — 플러그인이 2초 뒤면 다시 붙는데도. - async fn settle(&self) { - let deadline = tokio::time::Instant::now() + SETTLE_TIMEOUT; - let mut changes = self.link.state.subscribe(); - loop { - let until = match &*changes.borrow_and_update() { - LinkState::Connecting { .. } => Some(deadline), - LinkState::Host { - expecting: Some(_), - since, - } - | LinkState::Relay { - expecting: Some(_), - since, - .. - } if self.connected.load(Ordering::Relaxed) == 0 => { - Some(deadline.min(tokio::time::Instant::from_std(*since + REATTACH_GRACE))) - } - _ => None, - }; - let Some(until) = until else { return }; - if !matches!( - tokio::time::timeout_at(until, changes.changed()).await, - Ok(Ok(())) - ) { - return; - } - } - } - - /// 이 프로세스에서 보이는 플러그인, 연결 번호와 함께 붙은 순서대로. - async fn view(&self, link: &LinkState) -> Vec<(u64, AttachedFile)> { - match link { - LinkState::Host { .. } => { - let inner = self.inner.lock().await; - let mut plugins: Vec<_> = inner.plugins.iter().collect(); - plugins.sort_unstable_by_key(|(id, _)| **id); - plugins - .into_iter() - .map(|(id, plugin)| (*id, plugin.attached())) - .collect() - } - LinkState::Relay { files, .. } => files.clone(), - LinkState::Connecting { .. } | LinkState::Unavailable { .. } => Vec::new(), - } - } - - /// 지금 이 파일을 맡을 플러그인이 보이는지. 기다리지 않는다. - async fn sees(&self, file_key: &str) -> bool { - let link = self.link.current(); - let view = self.view(&link).await; - resolve(keys_in(&view), file_key).is_some() - } - - /// 이 파일을 열어 둔 플러그인이 있는지. 포트를 쥔 프로세스를 통해 보이는 것도 센다. - pub async fn has_plugin(&self, file_key: &str) -> bool { - self.settle().await; - self.sees(file_key).await - } - - /// 끊긴 읽기의 플러그인이 돌아오기를 기다린다. 돌아왔으면 `true`. - async fn plugin_returns(&self, file_key: &str) -> bool { - let until = tokio::time::Instant::now() + RETURN_WAIT; - let mut changes = self.link.state.subscribe(); - loop { - changes.borrow_and_update(); - if self.sees(file_key).await { - return true; - } - if !matches!( - tokio::time::timeout_at(until, changes.changed()).await, - Ok(Ok(())) - ) { - return self.sees(file_key).await; - } - } - } - - /// 붙어 있는 플러그인이 보고한 파일 키. 보고하지 못한 플러그인은 빈 문자열이다. - pub async fn connected_files(&self) -> Vec { - let mut keys: Vec = self - .attached_files() - .await - .into_iter() - .map(|file| file.file_key.unwrap_or_default()) - .collect(); - keys.sort(); - keys - } - - /// 붙어 있는 플러그인 전부를 붙은 순서대로. 어느 프로세스에서 물어도 같다 — - /// 포트를 쥐지 못한 프로세스는 쥔 프로세스가 보낸 목록을 그대로 보인다. - pub async fn attached_files(&self) -> Vec { - self.settle().await; - let link = self.link.current(); - self.view(&link) - .await - .into_iter() - .map(|(_, file)| file) - .collect() - } - - /// 아직 플러그인의 답을 기다리는 읽기 수. 요청한 쪽이 사라지면 그 읽기가 걷히는지 - /// 테스트가 확인한다. - #[doc(hidden)] - pub fn pending_reads(&self) -> usize { - self.pending.lock().map_or(0, |pending| pending.len()) - } - - async fn path_snapshot(&self, port: Option) -> BridgePathSnapshot { - self.settle().await; - let link = self.link.current(); - let attached_files: Vec = self - .view(&link) - .await - .into_iter() - .map(|(_, file)| file) - .collect(); - let recently = |expecting: &Option, since: &Instant| { - expecting - .clone() - .filter(|_| attached_files.is_empty() && since.elapsed() < REATTACH_GRACE) - }; - let (role, host, issue, handover_from) = match &link { - LinkState::Host { expecting, since } => ( - BridgeRole::Host, - Some(self.link.me.clone()), - None, - recently(expecting, since), - ), - LinkState::Relay { - connection, - expecting, - since, - .. - } => ( - BridgeRole::Relay, - Some(connection.host.clone()), - None, - recently(expecting, since), - ), - LinkState::Connecting { after } => (BridgeRole::Connecting, None, None, after.clone()), - LinkState::Unavailable { issue, holder } => ( - BridgeRole::Unavailable, - holder.clone(), - Some(issue.clone()), - None, - ), - }; - BridgePathSnapshot { - port, - attached_files, - role, - host, - issue, - handover_from, - } - } - - /// 읽기 하나를 플러그인에 보낸다. 포트를 쥐었으면 곧장, 아니면 쥔 프로세스를 통해. - /// - /// 도중에 길이 끊기면 — 플러그인 창이 다시 붙었거나, 포트를 쥔 호스트가 떠나 - /// 다른 프로세스가 이어받았으면 — 플러그인이 돌아오기를 잠깐 기다렸다가 다시 - /// 보낸다. 읽기는 문서를 바꾸지 않으므로 두 번 돌아도 해가 없고, 호출자는 인계가 - /// 있었는지 모른 채 답을 받는다. 수집 한가운데서 호스트 세션이 끝나도 그 수집은 - /// 이어진다. - async fn dispatch( - &self, - file_key: &str, - script: &str, - params: Value, - ) -> Result<(Value, BridgeServed), DevupError> { - let mut interruptions = 0; - loop { - self.settle().await; - let attempt = match self.link.current() { - LinkState::Host { .. } => { - self.dispatch_local(file_key, script, params.clone()).await - } - LinkState::Relay { connection, .. } => { - connection.dispatch(file_key, script, params.clone()).await - } - other => return Err(not_reachable(&other)), - }; - match attempt { - Ok(done) => return Ok(done), - Err(Failure::Final(error)) => return Err(error), - Err(Failure::Interrupted(error)) => { - interruptions += 1; - if interruptions > INTERRUPTIONS || !self.plugin_returns(file_key).await { - return Err(error); - } - } - } - } - } - - /// 이 프로세스에 붙은 플러그인으로 한 번 보낸다. - async fn dispatch_local( - &self, - file_key: &str, - script: &str, - params: Value, - ) -> Result<(Value, BridgeServed), Failure> { - let request_id = self.next_request_id(); - let budget = job_timeout(script, ¶ms); - let is_asset = job_is_asset_read(script, ¶ms); - let requested_node = params["nodeId"].as_str().unwrap_or("unknown").to_owned(); - let (tx, rx) = oneshot::channel(); - - let served = { - let mut inner = self.inner.lock().await; - let Some((id, plugin)) = resolve(keys_of(&inner.plugins), file_key) - .and_then(|id| inner.plugins.get(&id).map(|plugin| (id, plugin))) - else { - return Err(Failure::Final(unavailable(format!( - "no Devup Bridge plugin is open for {}", - describe_file(file_key) - )))); - }; - let job = Job { - kind: "devup-job", - request_id: request_id.clone(), - script, - params, - }; - let encoded = serde_json::to_string(&job).map_err(|error| { - Failure::Final(unavailable(format!("bridge job encode failed: {error}"))) - })?; - // 답이 보내기보다 먼저 올 수는 없지만, 대기표는 보내기 전에 둔다. - self.pending - .lock() - .expect("the pending table is never poisoned") - .insert( - request_id.clone(), - Pending { - plugin: id, - outbox: plugin.outbox.clone(), - waiter: tx, - }, - ); - let sent = plugin.outbox.send(encoded).is_ok(); - let served = plugin.served(); - if !sent { - // 소켓이 막 닫혔다. 등록을 지워 다음 호출이 곧장 폴백하도록 한다. - inner.plugins.remove(&id); - self.plugin_left(&mut inner, id); - return Err(Failure::Interrupted(unavailable(format!( - "the Devup Bridge plugin for {} disconnected", - describe_file(file_key) - )))); - } - served - }; - - // 성공이든 실패든, 기다리던 쪽이 도중에 사라지든 대기표는 반드시 걷는다. - // 답을 받지 못한 채 걷으면 플러그인에도 그 작업을 거두라고 알린다. - let _awaiting = Awaiting { - pending: &self.pending, - request_id, - }; - match timeout(budget, rx).await { - Ok(Ok(result)) => match (result.data, result.error) { - // 스크립트가 던진 DEVUP_* 코드는 그대로 올린다. 원격 경로와 같은 - // 문자열이어야 위쪽 분기가 동일하게 동작한다. - (_, Some(message)) => Err(Failure::Final(script_failure(message))), - (Some(data), None) => Ok((data, served)), - (None, None) => Err(Failure::Final(unavailable( - "bridge returned neither data nor error", - ))), - }, - // 작업을 받은 플러그인 창이 끊겼다. - Ok(Err(_)) => Err(Failure::Interrupted(unavailable( - "the Devup Bridge plugin disconnected mid-read", - ))), - Err(_) => Err(Failure::Final(unavailable(format!( - "{}Devup Bridge {script} did not answer after {} ms (node: {requested_node})", - if is_asset { - "DEVUP_ASSET_TIMEOUT: " - } else { - "" - }, - budget.as_millis() - )))), - } - } - - /// 플러그인 하나가 떠났다(등록은 이미 지웠다). 그 플러그인이 받아 둔 읽기는 더 - /// 답이 오지 않으니 곧장 끝낸다 — 90초를 기다리게 하지 않는다. - fn plugin_left(&self, inner: &mut Inner, id: u64) { - if let Ok(mut pending) = self.pending.lock() { - pending.retain(|_, waiting| waiting.plugin != id); - } - self.plugins_changed(inner); - } -} - -pub(super) fn job_timeout(script: &str, params: &Value) -> Duration { - if job_is_asset_read(script, params) { - let options = if script == "assets" { - ¶ms["asset"] - } else { - ¶ms["largeValue"] - }; - let millis = options["timeoutMs"] - .as_u64() - .filter(|v| (1000..=600000).contains(v)) - .unwrap_or(90000); - Duration::from_millis(millis) + Duration::from_secs(5) - } else { - JOB_TIMEOUT - } -} - -pub(super) fn job_is_asset_read(script: &str, params: &Value) -> bool { - script == "assets" - || (script == "largeValue" - && params["largeValue"]["field"] - .as_str() - .is_some_and(|field| field.starts_with("$export:"))) -} - -/// 한 번에 담을 수 있는 양. 로컬 소켓이라 공식 MCP 의 자름을 따를 이유가 없다. -/// -/// 기본값(봉투 19KB · 필드 4KB)은 공식 MCP 가 텍스트 결과를 20,480 바이트에서 -/// 자르기 때문에 생긴 것이다. 그 탓에 화면 하나가 30번 넘는 왕복으로 쪼개지고, -/// 창이 뒤로 가 있으면 왕복 하나가 60초까지 걸린다 — 실측한 값이다. -pub(crate) const BRIDGE_ENVELOPE_BYTES: usize = 8 * 1024 * 1024; -const BRIDGE_FIELD_BYTES: usize = 4 * 1024 * 1024; - -/// 스냅샷 읽기의 한도를 브리지 전송에 맞게 올린다. -/// -/// 값만 바꿀 뿐 무엇을 읽을지는 그대로다. 스크립트는 한 페이지에 다 담기면 -/// `complete` 로 표시하고, 큰 필드도 잘리지 않으므로 수집기가 그 필드를 따로 -/// 받으러 가지 않는다. 두 왕복이 한 왕복이 되는 것이 아니라 서른이 하나가 된다. -fn widen_budgets(params: &mut Value) { - let Some(object) = params.as_object_mut() else { - return; - }; - let Some(snapshot) = object.get_mut("snapshot").and_then(Value::as_object_mut) else { - return; - }; - snapshot.insert("maxEnvelopeBytes".to_owned(), BRIDGE_ENVELOPE_BYTES.into()); - snapshot.insert("maxPayloadBytes".to_owned(), BRIDGE_ENVELOPE_BYTES.into()); - snapshot.insert("maxFieldBytes".to_owned(), BRIDGE_FIELD_BYTES.into()); -} - -/// 봉투의 `fileKey` 가 비어 있으면 요청한 값으로 채운다. -/// -/// 스크립트는 `figma.fileKey` 를 그대로 싣는데, 그 값이 늘 오지는 않는다 — -/// 비어 오는 것을 실기기에서 확인했다. 디코더는 비어 있지 않은 `fileKey` 를 요구하므로 -/// 그대로 두면 노드를 다 받고도 "스냅샷을 찾지 못했다"며 버린다. -/// -/// 지어내는 값이 아니다. 이 읽기가 어느 파일을 향했는지는 호출자가 알고 있고, -/// 그 파일을 이 플러그인이 맡는다는 판단은 이미 `resolve` 가 내렸다. 이 값은 -/// 디코더의 대조에만 쓰이고, 파일 키로 보고되는 것은 [`BridgeServed`] 가 싣는 -/// 플러그인 자신의 보고뿐이다. -/// -/// 빠른 스냅샷·테마 봉투는 이 키를 `source` 와 `snapshot` 안에 싣는다. 거기가 빈 -/// 채로 가면 디코더가 대상 불일치로 보고 빠른 경로를 통째로 버린다 — 실기기에서 -/// 화면마다 그렇게 노드마다 읽는 예전 경로로 떨어졌고, breakpoint 형제 폭도 잃었다. -fn stamp_file_key(data: &mut Value, file_key: &str) { - let Some(object) = data.as_object_mut() else { - return; - }; - let empty = object - .get("fileKey") - .and_then(Value::as_str) - .is_none_or(str::is_empty); - if empty { - object.insert("fileKey".to_owned(), Value::String(file_key.to_owned())); - } - for nested in ["source", "snapshot"] { - if let Some(inner) = object.get_mut(nested).and_then(Value::as_object_mut) - && inner - .get("fileKey") - .is_some_and(|key| key.as_str().is_none_or(str::is_empty)) - { - inner.insert("fileKey".to_owned(), Value::String(file_key.to_owned())); - } - } -} - -/// 플러그인이 돌려준 값을 원격 `use_figma` 가 주는 모양으로 감싼다. -/// -/// 디코더들은 `content[].text` 안의 JSON 문자열을 찾도록 쓰여 있다. 값을 그대로 -/// 올리면 일부 디코더는 통과하고 일부는 실패해, 두 경로가 화면 단위로 갈라진다. -/// -/// 어느 플러그인이 답했는지는 `_meta` 에 싣는다. 디코더는 그 자리를 읽지 않으므로 -/// 두 경로의 모양은 그대로이고, 수집기는 이것으로 응답의 출처를 브리지로 적는다. -fn wrap_as_tool_result(data: &Value, served: &BridgeServed) -> Result { - let encode = - |error: serde_json::Error| unavailable(format!("bridge result encode failed: {error}")); - let text = serde_json::to_string(data).map_err(encode)?; - let mut result = json!({ - "content": [{ "type": "text", "text": text }], - "isError": false, - "_meta": {}, - }); - result["_meta"][SERVED_META_KEY] = serde_json::to_value(served).map_err(encode)?; - Ok(result) -} - -/// 노드 없는 metadata 읽기를 원격 `get_metadata` 가 주는 모양 — 최상위 페이지 목록 — -/// 으로 쓴다. 수집기는 두 경로의 답을 같은 파서(`parse_top_level_pages`)로 받는다. -pub(crate) fn wrap_as_top_level_pages( - data: &Value, - served: &BridgeServed, -) -> Result { - let pages = data["nodes"] - .as_array() - .ok_or_else(|| unavailable("the plugin's page catalog has no nodes"))?; - let mut text = String::from("Top-level pages of the document:\n"); - for page in pages { - let name = page["fields"]["name"].as_str().unwrap_or_default(); - text.push_str(&format!( - "- {}: {}\n", - page["id"].as_str().unwrap_or_default(), - name.replace(['\r', '\n'], " ") - )); - } - let mut result = json!({ - "content": [{ "type": "text", "text": text }], - "isError": false, - "_meta": {}, - }); - result["_meta"][SERVED_META_KEY] = serde_json::to_value(served) - .map_err(|error| unavailable(format!("bridge result encode failed: {error}")))?; - Ok(result) -} - -/// 참조 PNG 의 답이 브리지 답 하나에 들어가지 않으면 플러그인은 그것을 붙들고 조각으로 -/// 내준다. 이어 받아 하나로 되돌린다 — 원격 `get_screenshot` 이 16 MiB 까지 받는 것을 -/// 브리지도 받는다. -async fn whole_reference_answer( - state: &BridgeState, - file_key: &str, - data: Value, -) -> Result { - if data["kind"] != "devupReferencePngChunked" { - return Ok(data); - } - let id = data["id"].as_str().unwrap_or_default().to_owned(); - let length = data["length"].as_u64().unwrap_or(0) as usize; - if id.is_empty() || length == 0 || length > REFERENCE_ANSWER_LIMIT { - return Err(unavailable( - "the plugin's reference PNG answer is too large or unnamed", - )); - } - let mut text = String::with_capacity(length); - while text.len() < length { - let (piece, _) = state - .dispatch( - file_key, - "referencePngChunk", - json!({ "largeValue": { "id": id, "offset": text.len() } }), - ) - .await?; - let part = piece["text"].as_str().unwrap_or_default(); - // 붙든 글은 ASCII 라 오프셋이 두 쪽에서 같은 자리를 가리킨다. 아니면 어긋난 것이다. - if piece["kind"] != "devupReferencePngChunk" - || piece["id"] != id.as_str() - || piece["offset"].as_u64() != Some(text.len() as u64) - || part.is_empty() - || !part.is_ascii() - || text.len() + part.len() > length - { - return Err(unavailable( - "the plugin's reference PNG pieces do not line up", - )); - } - text.push_str(part); - } - serde_json::from_str(&text) - .map_err(|error| unavailable(format!("the plugin's reference PNG answer: {error}"))) -} - -/// 참조 PNG 의 답 전체. -/// -/// 플러그인은 큰 답을 읽혀 갈 때까지 붙들어 두고, 그것들이 상한만큼 쌓여 있으면 새 그림을 -/// 그리지 않고 바쁘다고 답한다(`DEVUP_REFERENCE_PNG_BUSY`). 붙든 답을 밀어내면 그 답을 읽던 -/// 요청이 실패하기 때문이다. 그동안에도 다른 요청의 조각 읽기는 처리되므로 잠시 뒤 다시 -/// 묻는다. 붙들어 둔 답이 읽기 전에 사라졌으면(플러그인이 다시 실행되는 등) 한 번 다시 받는다. -async fn reference_answer( - state: &BridgeState, - file_key: &str, - script: &'static str, - params: Value, -) -> Result<(Value, BridgeServed), DevupError> { - let deadline = tokio::time::Instant::now() + REFERENCE_BUSY_WAIT; - let mut fetched_again = false; - loop { - let (data, served) = match state.dispatch(file_key, script, params.clone()).await { - Err(error) - if plugin_says(&error, "DEVUP_REFERENCE_PNG_BUSY") - && tokio::time::Instant::now() < deadline => - { - tokio::time::sleep(REFERENCE_BUSY_RETRY).await; - continue; - } - answered => answered?, - }; - match whole_reference_answer(state, file_key, data).await { - Err(error) - if plugin_says(&error, "DEVUP_REFERENCE_PNG_ANSWER_GONE") && !fetched_again => - { - fetched_again = true; - } - whole => return whole.map(|data| (data, served)), - } - } -} - -fn plugin_says(error: &DevupError, code: &str) -> bool { - is_script_failure(error) && error.message.contains(code) -} - -/// 바쁜 플러그인에게 참조 PNG 를 다시 묻는 간격과 그 상한. 상한은 export 작업이 읽기 하나에 -/// 주는 90초 안에 그림과 조각 읽기까지 마치도록 잡았다. -const REFERENCE_BUSY_RETRY: std::time::Duration = std::time::Duration::from_millis(500); -const REFERENCE_BUSY_WAIT: std::time::Duration = std::time::Duration::from_secs(45); - -/// 조각으로 받는 참조 PNG 답의 상한. 이미지 채움의 원본까지 실으므로 PNG 상한(16 MiB) -/// 보다 크다. -const REFERENCE_ANSWER_LIMIT: usize = 96 * 1024 * 1024; - -/// 참조 PNG 결과의 `_meta` 에서, 플러그인이 `get_screenshot` 과 같은 그림을 다 그리지 -/// 못했다는 이유를 싣는 자리. 원격 경로가 열려 있으면 그쪽이 대신 답한다. -const REFERENCE_PNG_APPROXIMATE_META_KEY: &str = "devup/referencePngApproximate"; - -/// 원격 경로가 브리지 대신 답했을 때 그 이유를 결과의 `_meta` 에 싣는 자리. -pub(crate) const DIRECT_FALLBACK_META_KEY: &str = "devup/directFallback"; - -/// 플러그인이 export 한 참조 PNG 를 공식 `get_screenshot` 이 주는 모양 — 이미지 -/// 블록 하나 — 으로 쓴다. 수집기는 두 경로의 답을 같은 디코더로 받는다. -/// -/// 그림도 같게 맞춘다. 다른 레이어가 닿지 않으면 플러그인은 캔버스가 보여 주는 그 -/// 자리를 export 하고 페이지 배경을 알려 주며, 여기서 그 위로 투명한 자리를 덮는다. -/// 닿으면 노드만 export 하고 조상들을 설명하며, 여기서 나머지를 그린다 -/// (`reference_compose`). 다 그리지 못한 것이 있으면 그 이유를 `_meta` 에 실어 -/// 원격 경로가 대신 답하게 한다. -fn wrap_as_image_result(data: &Value, served: &BridgeServed) -> Result { - if data["kind"] != "devupReferencePng" || data["mimeType"] != "image/png" { - return Err(unavailable( - "the plugin's reference PNG answer is not a devupReferencePng", - )); - } - // 그리다 멈추는 일이 생겨도 함께 온 캔버스 그림이 답이 된다. - let composed = data["compose"].is_object().then(|| { - std::panic::catch_unwind(|| crate::reference_compose::compose(&data["compose"])) - .unwrap_or_else(|_| Err(unavailable("composing it stopped unexpectedly"))) - }); - let mut failure = None; - let png = match composed { - Some(Ok(png)) => png, - failed => { - failure = failed.and_then(Result::err); - let png = data["data"].as_str().ok_or_else(|| { - unavailable("the plugin's reference PNG answer carries no picture") - })?; - match named_backdrop(data) { - Some(backdrop) => crate::collector::flatten_reference_png(png, backdrop)?, - None => png.to_owned(), - } - } - }; - let mut result = json!({ - "content": [{ "type": "image", "data": png, "mimeType": "image/png" }], - "isError": false, - "_meta": {}, - }); - result["_meta"][SERVED_META_KEY] = serde_json::to_value(served) - .map_err(|error| unavailable(format!("bridge result encode failed: {error}")))?; - if let Some(note) = data.get("note").and_then(Value::as_str) { - result["_meta"][REFERENCE_PNG_NOTE_META_KEY] = json!(note); - } - if let Some(reason) = data.get("approximate").and_then(Value::as_str) { - result["_meta"][REFERENCE_PNG_APPROXIMATE_META_KEY] = json!(reason); - } - if let Some(error) = failure { - result["_meta"][REFERENCE_PNG_APPROXIMATE_META_KEY] = - json!(format!("devup-mcp could not compose it: {}", error.message)); - result["_meta"][REFERENCE_PNG_NOTE_META_KEY] = json!(format!( - "Drawn as the canvas shows this area: devup-mcp could not compose what get_screenshot draws here ({}). Layers reaching into it may show here: {}; get_screenshot leaves other layers out.", - error.message, - data["reaching"].as_str().unwrap_or("other layers") - )); - } - Ok(result) -} - -/// 플러그인이 export 한 노드 아래의 색, `[r, g, b]`. 이 값을 보내기 전에 빌드된 -/// 플러그인은 보내지 않으며, 그 export 는 받은 그대로 넘긴다. -fn named_backdrop(data: &Value) -> Option<[u8; 3]> { - let channels = data - .get("backdrop")? - .as_array()? - .iter() - .map(|value| value.as_u64().and_then(|value| u8::try_from(value).ok())) - .collect::>>()?; - <[u8; 3]>::try_from(channels).ok() -} - -/// 플러그인이 붙는 문에 들이는 `Origin`. -/// -/// 브라우저 페이지도 `ws://localhost` 에 붙을 수 있다. 그 페이지가 여기서 `hello` -/// 를 보내 플러그인인 척하면 읽기 요청을 받고 지어낸 디자인을 돌려줄 수 있다 — 같은 -/// 파일 키로 나중에 붙은 연결이 그 파일의 읽기를 맡으므로 진짜 플러그인을 가로챌 -/// 수도 있다. 브라우저는 WebSocket 핸드셰이크에 늘 `Origin` 을 싣고 페이지는 그것을 -/// 바꾸지 못한다. -/// -/// Figma 플러그인의 UI 는 origin 이 `null` 인 iframe 에서 돈다(Figma 개발자 문서 -/// "Making Network Requests": "Plugin iframes have a null origin"). 이 플러그인은 -/// iframe 을 다른 주소로 옮기지 않는다. 혹시 Figma 가 제 origin 을 싣는 판이 있어도 -/// 끊기지 않도록 figma.com 도 들인다 — 다른 페이지는 그 값을 지어낼 수 없다. -/// `Origin` 이 없는 요청은 브라우저가 아닌 프로그램(테스트, 프로브)이다. -/// -/// 한계가 있다: 샌드박스 iframe 이나 `file://` 페이지도 `null` 을 싣는다. 흔한 웹 -/// 페이지는 막지만 그런 페이지까지 막지는 못한다. -fn a_plugin_origin(headers: &HeaderMap) -> bool { - headers.get(ORIGIN).is_none_or(|origin| { - matches!( - origin.as_bytes(), - b"null" | b"https://www.figma.com" | b"https://figma.com" - ) - }) -} - -/// 플러그인이 붙는 문. -async fn plugin_socket( - State(state): State, - headers: HeaderMap, - upgrade: WebSocketUpgrade, -) -> Response { - if !a_plugin_origin(&headers) { - return StatusCode::FORBIDDEN.into_response(); - } - upgrade - .max_message_size(128 * 1024 * 1024) - .max_frame_size(128 * 1024 * 1024) - .on_upgrade(move |socket| handle_plugin(state, socket)) -} - -async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { - let (outbox, mut outbox_rx) = mpsc::unbounded_channel::(); - let id = state.counter.fetch_add(1, Ordering::Relaxed); - let mut shutdown = state.link.shutdown_signal(); - - // 보내기와 받기를 한 루프에서 번갈아 본다. 소켓을 쪼개려면 Stream/Sink 트레이트 - // 의존성이 필요한데, 그것을 들이는 것보다 select 가 싸다. - loop { - tokio::select! { - // 이 프로세스가 브리지를 내려놓는다. 소켓을 닫아야 플러그인이 다음 호스트를 찾는다. - () = shut_down(&mut shutdown) => break, - outgoing = outbox_rx.recv() => { - let Some(text) = outgoing else { break }; - if socket.send(Message::Text(text.into())).await.is_err() { - break; - } - } - incoming = socket.recv() => { - let Some(Ok(message)) = incoming else { break }; - let Message::Text(text) = message else { continue }; - let Ok(value) = serde_json::from_str::(&text) else { continue }; - - match value.get("kind").and_then(Value::as_str) { - Some("hello") => { - // 키가 없어도 등록한다. 건너뛰면 창은 "연결됨"이라고 하는데 - // 어떤 읽기도 오지 않아 원인을 찾을 수 없다. 키 없는 연결을 - // 어디까지 믿을지는 resolve 가 정한다. - // - // 필드마다 따로 읽는다. 페이지나 선택이 어긋난 모양으로 와도 - // 파일 키까지 잃으면 안 된다. - let file_key = value - .get("fileKey") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(); - let plugin = Connected { - outbox: outbox.clone(), - routing_key: routing_key(&file_key, session_of(&value).as_deref(), id), - file_key, - file_name: value - .get("fileName") - .and_then(Value::as_str) - .filter(|name| !name.is_empty()) - .map(str::to_owned), - context: serde_json::from_value(value).unwrap_or_default(), - }; - let mut inner = state.inner.lock().await; - inner.plugins.insert(id, plugin); - state.plugins_changed(&mut inner); - } - // 페이지를 옮기거나 선택을 바꿀 때마다 온다. url 없는 요청은 - // 이 값으로 대상을 고르므로, 늦게라도 최신이어야 한다. 중계들도 - // 같은 선택을 보도록 함께 알린다. - Some("context") => { - let Ok(context) = serde_json::from_value::(value) else { - continue; - }; - let mut inner = state.inner.lock().await; - if let Some(plugin) = inner.plugins.get_mut(&id) { - plugin.context = context; - state.plugins_changed(&mut inner); - } - } - Some("devup-result") => { - let Ok(result) = serde_json::from_value::(value) else { - continue; - }; - let waiting = state - .pending - .lock() - .expect("the pending table is never poisoned") - .remove(&result.request_id); - // 받는 쪽이 이미 타임아웃했거나 떠났으면 버린다. - if let Some(waiting) = waiting { - let _ = waiting.waiter.send(result); - } - } - _ => {} - } - } - } - } - - let mut inner = state.inner.lock().await; - if inner.plugins.remove(&id).is_some() { - state.plugin_left(&mut inner, id); - } -} - -/// 브리지를 여는 데 쓰는 것. -#[derive(Debug, Clone, Default)] -pub struct BridgeOptions { - /// 이 빌드의 식별자(`devup-mcp --version` 의 괄호 안). 중계 핸드셰이크가 상대에게 - /// 알리고, status 가 포트를 쥔 프로세스를 밝힐 때 쓴다. - pub build_id: Option, - /// 중계 비밀값 파일. 비우면 그 사용자만 쓰는 기본 자리다. - pub secret_path: Option, -} - -/// 중계 핸드셰이크에서 밝히는 devup-mcp 하나. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BridgePeer { - pub pid: u32, - pub version: String, - #[serde(default)] - pub build_id: Option, -} - -/// 이 프로세스가 브리지에서 맡은 역할. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BridgeRole { - /// 포트를 쥐었다. 플러그인은 이 프로세스에 붙는다. - Host, - /// 다른 devup-mcp 가 쥔 포트를 통해 읽는다. - Relay, - /// 누가 포트를 쥐었는지 확인하는 중이다 — 시작할 때, 또는 호스트가 떠난 뒤. - Connecting, - /// 지금은 브리지를 쓸 수 없다. 이유는 [`BridgeIssue`] 다. - Unavailable, -} - -/// 브리지를 쓸 수 없는 이유. 고치는 방법이 서로 다르다. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum BridgeIssue { - /// 중계를 모르는 예전 devup-mcp 가 포트를 쥐었다. `/plugin` 만 있고 `/relay` 가 없다. - /// 자신을 밝히지 않으므로 `owner` 는 OS 에 물어 안 것이다. - LegacyHost { owner: Option }, - /// devup-mcp 가 아닌 프로그램이 포트를 쥐었다. `owner` 는 OS 에 물어 안 것이다. - ForeignProgram { - detail: String, - owner: Option, - }, - /// 다른 판의 중계 규약을 쓰는 devup-mcp 가 쥐었다. 틀린 답을 내느니 잇지 않는다. - IncompatibleProtocol { theirs: Option, ours: u64 }, - /// 서로 같은 사용자의 devup-mcp 임을 증명하지 못했다. - AuthenticationFailed { detail: String }, - /// 중계 비밀값 파일을 읽거나 만들 수 없다. - SecretUnavailable { detail: String }, - /// 포트를 잡을 수 없는데 그 포트에서 듣는 쪽도 없다. - BindFailed { detail: String }, -} - -impl BridgeIssue { - /// status 가 싣는 짧은 이름. - pub fn code(&self) -> &'static str { - match self { - Self::LegacyHost { .. } => "legacy-host", - Self::ForeignProgram { .. } => "foreign-program", - Self::IncompatibleProtocol { .. } => "incompatible-protocol", - Self::AuthenticationFailed { .. } => "authentication-failed", - Self::SecretUnavailable { .. } => "secret-unavailable", - Self::BindFailed { .. } => "bind-failed", - } - } -} - -/// 브리지 서버. -/// -/// 포트를 잡으면 호스트가 되고, 잡지 못하면 그 포트를 쥔 devup-mcp 를 통해 읽는다. -/// 어느 쪽이든 [`BridgeFigmaClient`] 는 같은 방식으로 쓴다. -pub struct BridgeServer { - state: BridgeState, - port: u16, -} - -impl BridgeServer { - /// 포트를 잡아 서버를 띄운다. - /// - /// 이미 다른 프로세스가 같은 포트를 쓰고 있으면 그쪽을 통해 읽는다. 한 기기에서 - /// MCP 클라이언트를 여러 개 띄우는 것은 정상이고, 플러그인이 붙을 수 있는 포트는 - /// 하나뿐이다. 그 포트를 쥔 쪽이 떠나면 이 프로세스가 이어받을 수 있다. - /// - /// 서버가 만들어지는 자리가 동기 함수라 bind 도 동기로 한다 — 듣기 소켓을 - /// 여는 것은 어차피 기다리지 않는 일이고, 기다리는 부분만 따로 띄운다. - pub fn start(port: u16) -> Option { - Self::start_with(port, BridgeOptions::default()) - } - - pub fn start_with(port: u16, options: BridgeOptions) -> Option { - // 런타임 밖에서 만들어졌다면 붙일 곳이 없다. 그때도 원격 경로는 멀쩡하다. - // bind 보다 먼저 본다 — 쓰지도 못할 포트를 잠깐이라도 쥐면 안 된다. - let runtime = tokio::runtime::Handle::try_current().ok()?; - let state = BridgeState::new(options); - match std::net::TcpListener::bind(("127.0.0.1", port)) { - Ok(listener) => { - // 0 을 주면 커널이 빈 포트를 고른다. 실제로 잡힌 번호를 알아야 붙을 수 있다. - let bound = listener.local_addr().ok()?.port(); - state.serve(listener, None).ok()?; - Some(Self { state, port: bound }) - } - // 0 은 빈 포트를 달라는 뜻이라, 누가 쥐고 있을 포트가 없다. - Err(_) if port == 0 => None, - Err(_) => { - runtime.spawn(relay::supervise(state.clone(), port)); - Some(Self { state, port }) - } - } - } - - /// 환경 설정을 읽어 띄운다. - /// - /// `DEVUP_FIGMA_BRIDGE_PORT` 가 포트를 정하며, `0` 이나 `off` 는 끈다. 값이 - /// 없으면 기본 포트로 켠다 — 플러그인이 안 떠 있으면 어차피 원격으로 가므로 - /// 켜 두는 쪽이 손해가 없다. - pub fn from_env() -> Option { - Self::from_env_with(BridgeOptions::default()) - } - - pub fn from_env_with(options: BridgeOptions) -> Option { - let setting = std::env::var("DEVUP_FIGMA_BRIDGE_PORT").ok(); - let port = match setting.as_deref().map(str::trim) { - None | Some("") => DEFAULT_BRIDGE_PORT, - Some("off") | Some("0") => return None, - Some(value) => value.parse().ok()?, - }; - Self::start_with(port, options) - } - - pub fn state(&self) -> BridgeState { - self.state.clone() - } - - /// 실제로 잡은 포트, 또는 이 프로세스가 통해 읽는 포트. `start(0)` 으로 띄웠을 때 - /// 어디에 붙어야 하는지 알려 준다. - pub fn port(&self) -> u16 { - self.port - } - - /// 브리지를 내려놓는다 — 프로세스가 끝날 때처럼 포트와 모든 연결을 닫는다. - /// - /// 서버를 버리는 것만으로는 닫지 않는다. 운영에서는 프로세스가 끝날 때까지 - /// 살아 있어야 하고, 이것은 한 프로세스 안에서 호스트가 떠나는 일을 재현한다. - pub fn shutdown(self) { - self.state.link.shutdown.send_replace(true); - } -} - -/// 이 요청을 맡을 수 있는지 **부르기 전에** 답하는 상류. -/// -/// 실패한 뒤에 폴백하면 안 되기 때문에 따로 둔다. `CapabilityUnavailable` 과 -/// `Transport` 는 같은 `ErrorCode` 로 접히므로, 에러만 보고는 "브리지가 못 하는 -/// 일"과 "브리지가 하다가 실패한 일"을 구분할 수 없다. 뒤엣것까지 원격으로 넘기면 -/// 아끼려던 요금을 그대로 쓰게 된다. -#[async_trait] -pub trait PreferredUpstream: FigmaUpstream { - async fn can_serve(&self, call: &ReadToolCall) -> bool; - - /// 지금 이 상류가 쓸 수 있는 상태인지. 배치 크기를 정하는 자리는 async 가 - /// 아니어서 `can_serve` 를 부를 수 없다. - fn is_live(&self) -> bool; -} - -/// 브리지 경로의 실측 상태. `devup_figma_auth doctor` 의 `paths.bridge` 가 된다. -/// -/// 이 값이 있다는 것 자체가 "이 프로세스가 브리지를 쓰려 한다"는 뜻이다. -/// `DEVUP_FIGMA_BRIDGE_PORT=off` 면 브리지 상류가 아예 만들어지지 않으므로 `None` -/// 이 된다. 포트를 잡지 못한 것은 이제 `None` 이 아니다 — 그 포트를 쥔 쪽을 통해 -/// 읽거나([`BridgeRole::Relay`]), 왜 그럴 수 없는지를 말한다. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BridgePathSnapshot { - /// 플러그인이 붙는 포트 — 이 프로세스가 잡았든 다른 프로세스가 잡았든. - /// 플러그인 manifest 의 `allowedDomains` 와 같아야 붙는다. - pub port: Option, - /// 지금 붙어 있는 플러그인, 붙은 순서대로. 포트를 쥔 프로세스 기준이라 어느 - /// 프로세스에서 보든 같다. 파일 키를 보고하지 못한 플러그인은 혼자 붙어 있을 - /// 때만 다른 키의 읽기를 받는다. - pub attached_files: Vec, - pub role: BridgeRole, - /// 포트를 쥔 devup-mcp. 호스트면 이 프로세스 자신이다. 모르면 `None` 이다 — - /// 예전 devup-mcp 는 자신을 밝히지 않는다. - pub host: Option, - /// [`BridgeRole::Unavailable`] 의 이유. - pub issue: Option, - /// 방금 떠난 호스트. 포트가 넘어가는 중이거나, 넘어갔는데 그때 붙어 있던 - /// 플러그인이 아직 다시 붙지 않았다. - pub handover_from: Option, -} - -impl BridgePathSnapshot { - /// 지금 이 경로로 읽기를 보낼 수 있는지 — 플러그인이 보이고, 그것에 닿는 길이 있다. - pub fn available(&self) -> bool { - matches!(self.role, BridgeRole::Host | BridgeRole::Relay) && !self.attached_files.is_empty() - } -} - -/// 플러그인을 통해 Figma 를 읽는 `FigmaUpstream`. -#[derive(Clone)] -pub struct BridgeFigmaClient { - state: BridgeState, - /// 진단에만 쓴다. 읽기 경로는 포트를 알 필요가 없다. - port: Option, -} - -impl BridgeFigmaClient { - pub fn new(state: BridgeState) -> Self { - Self { state, port: None } - } - - /// 잡은 포트를 함께 들고 있게 한다. "문이 어디에 열려 있는가"는 붙지 않는 - /// 플러그인을 진단할 때 가장 먼저 확인할 값이다. - #[must_use] - pub fn with_port(mut self, port: u16) -> Self { - self.port = Some(port); - self - } -} - -#[async_trait] -impl FigmaUpstream for BridgeFigmaClient { - async fn list_tools(&self) -> Result, DevupError> { - // 스크립트 경로만 대신하므로 광고하는 것도 그 하나다. - Ok(vec!["use_figma".to_owned()]) - } - - async fn call_read_tool(&self, call: ReadToolCall) -> Result { - let Some(job) = call.bridge_job() else { - return Err(unavailable("the bridge serves script reads only")); - }; - let mut params = job.params; - widen_budgets(&mut params); - let answered = if matches!(call, ReadToolCall::Screenshot { .. }) { - reference_answer(&self.state, call.file_key(), job.script, params).await - } else { - self.state - .dispatch(call.file_key(), job.script, params) - .await - }; - let (mut data, served) = match answered { - Ok(answered) => answered, - Err(error) if is_script_failure(&error) => { - return Ok(script_failure_result(&error.message)); - } - Err(error) => return Err(error), - }; - let served = BridgeServed { - port: self.port, - ..served - }; - if matches!(call, ReadToolCall::Screenshot { .. }) { - // 그리는 일은 픽셀마다 도는 계산이라 비동기 작업자를 붙들지 않게 넘긴다. - let raw = tokio::task::spawn_blocking(move || wrap_as_image_result(&data, &served)) - .await - .map_err(|error| { - unavailable(format!("composing the reference PNG stopped: {error}")) - })??; - return Ok(UpstreamResult { raw }); - } - if matches!(call, ReadToolCall::Metadata { node_id: None, .. }) { - return Ok(UpstreamResult { - raw: wrap_as_top_level_pages(&data, &served)?, - }); - } - stamp_file_key(&mut data, call.file_key()); - Ok(UpstreamResult { - raw: wrap_as_tool_result(&data, &served)?, - }) - } - - fn batch_budget(&self) -> BatchBudget { - bridge_batch_budget() - } - - async fn serves_without_credentials(&self, file_key: &str) -> bool { - self.state.has_plugin(file_key).await - } - - async fn bridge_path_snapshot(&self) -> Option { - Some(self.state.path_snapshot(self.port).await) - } - - async fn refresh_bridge_context(&self, file_key: &str) -> Result { - let (data, _) = self - .state - .dispatch(file_key, "bridgeContext", json!({})) - .await?; - if data.to_string().len() > 16_384 - || data["kind"] != "devup-bridge-context" - || !data["selection"].is_array() - || !data["selectionCount"].is_u64() - || !data["currentPage"]["id"].is_string() - { - return Err(unavailable( - "The plugin did not return a current selection context.", - )); - } - let context: PluginContext = serde_json::from_value(data) - .map_err(|_| unavailable("Invalid plugin selection context."))?; - if context.selection.as_ref().is_some_and(|s| { - s.len() > 20 || context.selection_count.is_some_and(|count| count < s.len()) - }) { - return Err(unavailable("Invalid plugin selection count.")); - } - Ok(context) - } - - async fn is_metered(&self, _call: &ReadToolCall) -> bool { - false - } -} - -#[async_trait] -impl PreferredUpstream for BridgeFigmaClient { - async fn can_serve(&self, call: &ReadToolCall) -> bool { - call.bridge_job().is_some() && self.state.has_plugin(call.file_key()).await - } - - fn is_live(&self) -> bool { - self.state.connected.load(Ordering::Relaxed) > 0 - } -} - -/// 자르지 않는 전송이므로 쪼갤 이유가 없다. 변수와 스타일을 한 번에 다 묻는다. -const BRIDGE_BATCH: BatchBudget = BatchBudget { - variable_items: 4096, - style_items: 4096, - used_resource_items: 4096, - used_resource_bytes: BRIDGE_ENVELOPE_BYTES, -}; - -/// 한 번에 물을 리소스 수를 밖에서 낮춰 보기 위한 손잡이. -/// -/// 리소스가 하나도 해석되지 않는 것을 실기기에서 봤고, 원인이 이 크기인지 -/// (플러그인이 그만한 동시 조회를 감당하지 못하는지) 아니면 애초에 그 변수를 -/// 읽을 권한이 없는지 가려야 했다. 둘은 고치는 곳이 다르다. -const BRIDGE_USED_RESOURCE_ITEMS_ENV: &str = "DEVUP_FIGMA_BRIDGE_USED_RESOURCE_ITEMS"; - -fn bridge_batch_budget() -> BatchBudget { - let mut budget = BRIDGE_BATCH; - if let Some(items) = std::env::var(BRIDGE_USED_RESOURCE_ITEMS_ENV) - .ok() - .and_then(|raw| raw.trim().parse::().ok()) - .filter(|items| *items > 0) - { - budget.used_resource_items = items; - } - budget -} - -/// 브리지가 맡을 수 있으면 브리지로, 아니면 원격으로 보낸다. -/// -/// 판정은 부르기 전에 끝난다. 브리지가 맡은 요청이 실패하면 그 실패를 그대로 -/// 올린다 — 노드가 없거나 봉투가 너무 큰 것은 원격에서도 같은 결과이고, 다시 -/// 물으면 아낀 요금을 도로 쓰게 된다. 예외는 원격이라면 해낼 수 있는 거절 -/// (`remote_may_answer`)이다. 원격이 읽을 수 있는 키면 원격에 묻되, 원격도 다시 해 볼 -/// 여지 없이 거절하면(로그인이 없다 등) 플러그인의 거절을 올린다 — 한도를 쓰지 않는 -/// 처방(플러그인 갱신)을 말하는 쪽이 그것이다. -pub struct FallbackUpstream { - preferred: P, - secondary: S, - meter: Option>, -} - -/// 한도를 쓰는 경로로 보낼 읽기를 한도 안에 들 때까지 붙든다. -/// -/// 원격으로 가는 읽기는 부르는 쪽이 스스로 늦춘다. 플러그인이 답한 읽기를 원격으로 다시 -/// 읽을지는 그 판단 뒤 여기서 정해지므로 여기서 이것을 기다린다 — 다만 정해진 시간 안에 -/// 자리가 나지 않으면 기다리지 않는다. 기다리는 시간도 읽기 하나에 주어진 시간 안에 들어 -/// 있어서, 오래 기다리면 이미 받은 플러그인의 답까지 시간 초과로 잃는다. -#[async_trait] -pub trait Meter: Send + Sync { - /// `budget` 안에 한도 자리를 얻었으면 `true`. 얻지 못했으면 자리를 쓰지 않는다. - async fn acquire_within(&self, budget: std::time::Duration) -> bool; -} - -/// 읽기 하나에 주어진 시간. export 작업은 이 시간이 지난 읽기를 멈추고 다시 하게 한다. -pub const READ_TIME_LIMIT: std::time::Duration = std::time::Duration::from_secs(90); - -/// 원격으로 다시 읽는 일 — 한도 자리를 기다리는 것과 원격의 답까지 — 에 주는 시간의 상한. -const DIRECT_FALLBACK_BUDGET: std::time::Duration = std::time::Duration::from_secs(40); - -/// 다시 읽는 일은 플러그인의 읽기와 합쳐 `READ_TIME_LIMIT` 보다 이만큼 일찍 끝낸다. 그래야 -/// 시간이 다 됐을 때 이미 받은 플러그인의 답을 돌려줄 수 있다. -const FALLBACK_MARGIN: std::time::Duration = std::time::Duration::from_secs(5); - -impl FallbackUpstream { - pub fn new(preferred: P, secondary: S) -> Self { - Self { - preferred, - secondary, - meter: None, - } - } - - pub fn with_meter(mut self, meter: Arc) -> Self { - self.meter = Some(meter); - self - } - - /// 원격 읽기를 한도 자리를 얻어 `deadline` 안에 끝낸다. 자리를 얻지 못했거나 시간 안에 - /// 끝나지 않았으면 `None`. - async fn metered( - &self, - deadline: tokio::time::Instant, - read: impl std::future::Future, - ) -> Option { - let budget = deadline.saturating_duration_since(tokio::time::Instant::now()); - if budget.is_zero() { - return None; - } - if let Some(meter) = &self.meter - && !tokio::time::timeout_at(deadline, meter.acquire_within(budget)) - .await - .unwrap_or(false) - { - return None; - } - tokio::time::timeout_at(deadline, read).await.ok() - } -} - -#[async_trait] -impl FigmaUpstream for FallbackUpstream -where - P: PreferredUpstream, - S: FigmaUpstream, -{ - async fn list_tools(&self) -> Result, DevupError> { - // 능력 목록은 원격이 정본이다. 브리지는 그중 하나를 대신할 뿐이다. - self.secondary.list_tools().await - } - - async fn call_read_tool(&self, call: ReadToolCall) -> Result { - let started = tokio::time::Instant::now(); - if self.preferred.can_serve(&call).await { - let answered = self.preferred.call_read_tool(call.clone()).await?; - if is_bridge_only_key(call.file_key()) { - return Ok(answered); - } - let better = remote_does_better(&answered.raw); - let Some(reason) = better.or_else(|| remote_may_answer(&answered.raw)) else { - return Ok(answered); - }; - // 로그인 확인, 한도 자리, 원격의 답이 모두 이 시간 안이다 — `DIRECT_FALLBACK_BUDGET` - // 안에, 그리고 이 읽기가 시작된 때부터 `READ_TIME_LIMIT - FALLBACK_MARGIN` 안에. - let deadline = (tokio::time::Instant::now() + DIRECT_FALLBACK_BUDGET) - .min(started + READ_TIME_LIMIT - FALLBACK_MARGIN); - let direct = - match tokio::time::timeout_at(deadline, self.secondary.has_credentials()).await { - Ok(false) => return Ok(answered), - Ok(true) => { - self.metered(deadline, self.secondary.call_read_tool(call)) - .await - } - Err(_) => None, - }; - return match direct { - // 플러그인의 답을 쓸 수 있으면 그것을 쓴다. 쓸 수 없는 답(거절)이면 한도가 - // 비는 대로 다시 해 볼 일이다. - None if better.is_some() => Ok(answered), - None => Err(no_room_on_the_metered_path()), - Some(Ok(direct)) if better.is_some() && direct_unusable(&direct.raw) => { - Ok(answered) - } - Some(Ok(mut direct)) => { - direct.raw["_meta"][DIRECT_FALLBACK_META_KEY] = json!(reason); - Ok(direct) - } - Some(Err(error)) if better.is_none() && error.retryable => Err(error), - Some(Err(_)) => Ok(answered), - }; - } - // 브리지만 읽을 수 있는 키다. 원격으로 넘기면 Figma 에 없는 키를 묻게 되고, - // 돌아오는 것은 원인과 무관한 거절 — 로그인하라거나 파일이 없다는 — 뿐이다. - if is_bridge_only_key(call.file_key()) { - return Err(bridge_only_refusal(&call)); - } - self.secondary.call_read_tool(call).await - } - - /// 붙어 있는 플러그인이 있으면 그쪽 크기로 묶는다. - /// - /// 배치는 부르기 한참 전에 나뉘므로 어느 전송이 받을지 그때는 알 수 없다. - /// 다만 크게 묶어도 되는 읽기는 전부 스크립트 경로이고, 플러그인이 붙어 있는 - /// 한 그것은 브리지가 받는다. 수집 도중 플러그인이 닫히면 남은 배치가 원격에 - /// 너무 커서 거절되는데, 그때는 수집 자체가 이어질 수 없으므로 실패가 드러나는 - /// 편이 맞다. - fn batch_budget(&self) -> BatchBudget { - if self.preferred.is_live() { - self.preferred.batch_budget() - } else { - self.secondary.batch_budget() - } - } - - /// 이 파일을 맡은 플러그인이 있으면 원격 자격증명 없이도 수집이 성립한다. - /// 뒤엣것은 원격이므로 물을 것이 없다. - /// - /// 브리지만 읽을 수 있는 키는 로그인을 기다리지 않는다. direct 경로는 그 키로는 - /// 무엇을 치러도 읽을 수 없으므로, 로그인을 요구하는 것은 엉뚱한 처방이다. - /// 플러그인이 그새 끊겼다면 그 거절은 첫 읽기가 제 이유와 함께 한다. - async fn serves_without_credentials(&self, file_key: &str) -> bool { - is_bridge_only_key(file_key) || self.preferred.serves_without_credentials(file_key).await - } - - async fn bridge_path_snapshot(&self) -> Option { - self.preferred.bridge_path_snapshot().await - } - - async fn refresh_bridge_context(&self, file_key: &str) -> Result { - self.preferred.refresh_bridge_context(file_key).await - } - - /// 브리지가 맡는 읽기는 한도를 쓰지 않는다. 브리지만 읽을 수 있는 키는 원격으로 - /// 가지 않으므로(거절된다) 역시 세지 않는다. - async fn is_metered(&self, call: &ReadToolCall) -> bool { - !(is_bridge_only_key(call.file_key()) || self.preferred.can_serve(call).await) - } -} - -/// 플러그인이 답은 했지만 원격 경로가 더 낫게 답할 수 있는 경우와 그 이유. 원격이 -/// 어떤 이유로든 답하지 못하면 플러그인의 답을 그대로 쓴다 — 쓸 수 있는 답을 버리고 -/// 실패를 올리지 않는다. -/// -/// - Figma 가 화면에 보이지 않는 파일의 데이터를 가져오지 않아 읽기의 일부나 전부가 -/// "Unable to establish connection to Figma" 로 빠졌다(`figma_unreachable_hint`). -/// 원격 경로는 그 상태와 무관하다. -/// - 참조 PNG 에서 플러그인이 `get_screenshot` 과 같은 그림을 다 그리지 못했다. -fn remote_does_better(raw: &Value) -> Option<&'static str> { - if raw["_meta"][REFERENCE_PNG_APPROXIMATE_META_KEY].is_string() { - return Some("reference-png-approximate"); - } - texts(raw) - .any(|text| { - crate::figma_unreachable_hint(text).is_some() - && (raw["isError"] == true || reports_unreachable(text)) - }) - .then_some("file-out-of-sight") -} - -/// 플러그인 답의 JSON 에서 오류를 싣는 자리 — 값을 읽지 못한 `$error`, 필드 읽기 실패 -/// `fieldErrors`, 미해석 리소스의 `error`, 자산 export 의 `errorDetail` — 에 그 말이 있는지. -/// 디자인의 글(`characters`, 이름, 설명)에 같은 문장이 있어도 한도를 써서 다시 읽지 않는다. -fn reports_unreachable(text: &str) -> bool { - fn within(value: &Value, in_error: bool) -> bool { - match value { - Value::String(text) => in_error && crate::figma_unreachable_hint(text).is_some(), - Value::Array(items) => items.iter().any(|item| within(item, in_error)), - Value::Object(fields) => fields.iter().any(|(key, value)| { - let error_field = matches!( - key.as_str(), - "$error" | "error" | "errors" | "fieldErrors" | "errorDetail" | "lookupError" - ); - within(value, in_error || error_field) - }), - _ => false, - } - } - serde_json::from_str::(text).is_ok_and(|value| within(&value, false)) -} - -/// 플러그인이 답하지 못한 읽기를 원격으로 넘기려 했지만 정해진 시간 안에 한도 자리가 나지 -/// 않았다. 한도가 찬 것과 같으므로 그렇게 알려, 수집이 한도가 비기를 기다렸다 다시 하게 한다. -fn no_room_on_the_metered_path() -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaRateLimited, - "The Devup Bridge plugin could not answer this read, and the metered direct path had no \ - room in its allowance to read it in time. It is retried once the allowance frees up.", - true, - json!({ "source": "direct", "stage": "bridge-fallback" }), - ) -} - -/// 원격 경로라면 해낼 수 있는 플러그인의 거절과 그 이유: -/// -/// - 이 devup-mcp 보다 먼저 빌드돼 스크립트를 모른다 — 읽기는 시도되지 않았다. -/// - 참조 PNG 가 브리지 답 하나(8 MiB)를 넘는다 — 조각으로 내주기 전의 플러그인이다. -/// - 큰 값의 바이트가 읽기가 가리키는 해시와 다르다 — 그 값은 원격 경로의 렌더러가 -/// 만든 것이라(플러그인이 답하지 못해 원격이 읽은 자산의 이어 읽기), 거기서만 같은 -/// 바이트가 나온다. -fn remote_may_answer(raw: &Value) -> Option<&'static str> { - if raw["isError"] != true { - return None; - } - let text = raw["content"][0]["text"].as_str()?; - [ - ("DEVUP_BRIDGE_UNKNOWN_SCRIPT", "plugin-older-than-server"), - ("DEVUP_REFERENCE_PNG_TOO_LARGE", "plugin-answer-too-large"), - ("DEVUP_REFERENCE_PNG_BUSY", "plugin-busy"), - ("DEVUP_LARGE_VALUE_CHANGED", "value-from-direct-path"), - ] - .into_iter() - .find_map(|(code, reason)| text.contains(code).then_some(reason)) -} - -/// 원격의 답을 쓸 수 없는지. 그때는 플러그인의 답이 낫다. -/// -/// - 오류로 표시됐다. -/// - 한도에 걸렸다는 문장 하나다 — `isError` 없이 오기도 한다(`is_quota_refusal`). -/// - 공식 MCP 의 텍스트 상한(20 KB)에서 잘렸다. 플러그인의 크기로 묶은 읽기를 원격이 다시 -/// 읽으면 생기고, 그 답은 디코딩되지 않는다. -fn direct_unusable(raw: &Value) -> bool { - raw["isError"] == true - || texts(raw).any(|text| { - crate::is_quota_refusal(text, false) - || text.trim_end().ends_with("// truncated to 20kb") - }) -} - -fn texts(raw: &Value) -> impl Iterator { - raw["content"] - .as_array() - .into_iter() - .flatten() - .filter_map(|block| block["text"].as_str()) -} - -/// 브리지만 읽을 수 있는 키의 읽기를 브리지가 맡지 못할 때의 거절. -/// -/// 두 경우는 고치는 방법이 다르다. 스크립트 읽기면 플러그인이 끊긴 것이니 다시 -/// 띄우면 되고, 그 밖의 읽기(수집이 쓰지 않는 공식 도구 — 변수 정의, 디자인 컨텍스트, -/// Code Connect)는 브리지가 애초에 못 하는 일이라 그 파일의 진짜 Figma 링크가 있어야 -/// direct 경로로 읽을 수 있다. -fn bridge_only_refusal(call: &ReadToolCall) -> DevupError { - let message = if call.bridge_job().is_some() { - "The Devup Bridge plugin this request was addressed to is no longer attached. Run it \ - again on the file in the Figma desktop app, then repeat the call." - } else { - "This read cannot go through the Devup Bridge plugin, and the plugin could not report \ - the file's Figma key, so the direct path cannot make it either. Repeat the call with the \ - file's own Figma link (Share -> Copy link) to read it through the direct path." - }; - DevupError::with_details( - ErrorCode::DevupFigmaDirectUnavailable, - message, - false, - json!({ "source": "bridge", "stage": "bridge-routing", "tool": call.tool_name() }), - ) -} +// Source sections share this module to preserve privacy and test names. +include!("bridge/transport_and_state.rs"); +include!("bridge/stamp_file_key.rs"); +include!("bridge/texts.rs"); diff --git a/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs b/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs new file mode 100644 index 00000000..56da2547 --- /dev/null +++ b/crates/devup-mcp-figma/src/bridge/stamp_file_key.rs @@ -0,0 +1,995 @@ + + +/// 봉투의 `fileKey` 가 비어 있으면 요청한 값으로 채운다. +/// +/// 스크립트는 `figma.fileKey` 를 그대로 싣는데, 그 값이 늘 오지는 않는다 — +/// 비어 오는 것을 실기기에서 확인했다. 디코더는 비어 있지 않은 `fileKey` 를 요구하므로 +/// 그대로 두면 노드를 다 받고도 "스냅샷을 찾지 못했다"며 버린다. +/// +/// 지어내는 값이 아니다. 이 읽기가 어느 파일을 향했는지는 호출자가 알고 있고, +/// 그 파일을 이 플러그인이 맡는다는 판단은 이미 `resolve` 가 내렸다. 이 값은 +/// 디코더의 대조에만 쓰이고, 파일 키로 보고되는 것은 [`BridgeServed`] 가 싣는 +/// 플러그인 자신의 보고뿐이다. +/// +/// 빠른 스냅샷·테마 봉투는 이 키를 `source` 와 `snapshot` 안에 싣는다. 거기가 빈 +/// 채로 가면 디코더가 대상 불일치로 보고 빠른 경로를 통째로 버린다 — 실기기에서 +/// 화면마다 그렇게 노드마다 읽는 예전 경로로 떨어졌고, breakpoint 형제 폭도 잃었다. +fn stamp_file_key(data: &mut Value, file_key: &str) { + let Some(object) = data.as_object_mut() else { + return; + }; + let empty = object + .get("fileKey") + .and_then(Value::as_str) + .is_none_or(str::is_empty); + if empty { + object.insert("fileKey".to_owned(), Value::String(file_key.to_owned())); + } + for nested in ["source", "snapshot"] { + if let Some(inner) = object.get_mut(nested).and_then(Value::as_object_mut) + && inner + .get("fileKey") + .is_some_and(|key| key.as_str().is_none_or(str::is_empty)) + { + inner.insert("fileKey".to_owned(), Value::String(file_key.to_owned())); + } + } +} + +/// 플러그인이 돌려준 값을 원격 `use_figma` 가 주는 모양으로 감싼다. +/// +/// 디코더들은 `content[].text` 안의 JSON 문자열을 찾도록 쓰여 있다. 값을 그대로 +/// 올리면 일부 디코더는 통과하고 일부는 실패해, 두 경로가 화면 단위로 갈라진다. +/// +/// 어느 플러그인이 답했는지는 `_meta` 에 싣는다. 디코더는 그 자리를 읽지 않으므로 +/// 두 경로의 모양은 그대로이고, 수집기는 이것으로 응답의 출처를 브리지로 적는다. +fn wrap_as_tool_result(data: &Value, served: &BridgeServed) -> Result { + let encode = + |error: serde_json::Error| unavailable(format!("bridge result encode failed: {error}")); + let text = serde_json::to_string(data).map_err(encode)?; + let mut result = json!({ + "content": [{ "type": "text", "text": text }], + "isError": false, + "_meta": {}, + }); + result["_meta"][SERVED_META_KEY] = serde_json::to_value(served).map_err(encode)?; + Ok(result) +} + +/// 노드 없는 metadata 읽기를 원격 `get_metadata` 가 주는 모양 — 최상위 페이지 목록 — +/// 으로 쓴다. 수집기는 두 경로의 답을 같은 파서(`parse_top_level_pages`)로 받는다. +pub(crate) fn wrap_as_top_level_pages( + data: &Value, + served: &BridgeServed, +) -> Result { + let pages = data["nodes"] + .as_array() + .ok_or_else(|| unavailable("the plugin's page catalog has no nodes"))?; + let mut text = String::from("Top-level pages of the document:\n"); + for page in pages { + let name = page["fields"]["name"].as_str().unwrap_or_default(); + text.push_str(&format!( + "- {}: {}\n", + page["id"].as_str().unwrap_or_default(), + name.replace(['\r', '\n'], " ") + )); + } + let mut result = json!({ + "content": [{ "type": "text", "text": text }], + "isError": false, + "_meta": {}, + }); + result["_meta"][SERVED_META_KEY] = serde_json::to_value(served) + .map_err(|error| unavailable(format!("bridge result encode failed: {error}")))?; + Ok(result) +} + +/// 참조 PNG 의 답이 브리지 답 하나에 들어가지 않으면 플러그인은 그것을 붙들고 조각으로 +/// 내준다. 이어 받아 하나로 되돌린다 — 원격 `get_screenshot` 이 16 MiB 까지 받는 것을 +/// 브리지도 받는다. +async fn whole_reference_answer( + state: &BridgeState, + file_key: &str, + data: Value, +) -> Result { + if data["kind"] != "devupReferencePngChunked" { + return Ok(data); + } + let id = data["id"].as_str().unwrap_or_default().to_owned(); + let length = data["length"].as_u64().unwrap_or(0) as usize; + if id.is_empty() || length == 0 || length > REFERENCE_ANSWER_LIMIT { + return Err(unavailable( + "the plugin's reference PNG answer is too large or unnamed", + )); + } + let mut text = String::with_capacity(length); + while text.len() < length { + let (piece, _) = state + .dispatch( + file_key, + "referencePngChunk", + json!({ "largeValue": { "id": id, "offset": text.len() } }), + ) + .await?; + let part = piece["text"].as_str().unwrap_or_default(); + // 붙든 글은 ASCII 라 오프셋이 두 쪽에서 같은 자리를 가리킨다. 아니면 어긋난 것이다. + if piece["kind"] != "devupReferencePngChunk" + || piece["id"] != id.as_str() + || piece["offset"].as_u64() != Some(text.len() as u64) + || part.is_empty() + || !part.is_ascii() + || text.len() + part.len() > length + { + return Err(unavailable( + "the plugin's reference PNG pieces do not line up", + )); + } + text.push_str(part); + } + serde_json::from_str(&text) + .map_err(|error| unavailable(format!("the plugin's reference PNG answer: {error}"))) +} + +/// 참조 PNG 의 답 전체. +/// +/// 플러그인은 큰 답을 읽혀 갈 때까지 붙들어 두고, 그것들이 상한만큼 쌓여 있으면 새 그림을 +/// 그리지 않고 바쁘다고 답한다(`DEVUP_REFERENCE_PNG_BUSY`). 붙든 답을 밀어내면 그 답을 읽던 +/// 요청이 실패하기 때문이다. 그동안에도 다른 요청의 조각 읽기는 처리되므로 잠시 뒤 다시 +/// 묻는다. 붙들어 둔 답이 읽기 전에 사라졌으면(플러그인이 다시 실행되는 등) 한 번 다시 받는다. +async fn reference_answer( + state: &BridgeState, + file_key: &str, + script: &'static str, + params: Value, +) -> Result<(Value, BridgeServed), DevupError> { + let deadline = tokio::time::Instant::now() + REFERENCE_BUSY_WAIT; + let mut fetched_again = false; + loop { + let (data, served) = match state.dispatch(file_key, script, params.clone()).await { + Err(error) + if plugin_says(&error, "DEVUP_REFERENCE_PNG_BUSY") + && tokio::time::Instant::now() < deadline => + { + tokio::time::sleep(REFERENCE_BUSY_RETRY).await; + continue; + } + answered => answered?, + }; + match whole_reference_answer(state, file_key, data).await { + Err(error) + if plugin_says(&error, "DEVUP_REFERENCE_PNG_ANSWER_GONE") && !fetched_again => + { + fetched_again = true; + } + whole => return whole.map(|data| (data, served)), + } + } +} + +fn plugin_says(error: &DevupError, code: &str) -> bool { + is_script_failure(error) && error.message.contains(code) +} + +/// 바쁜 플러그인에게 참조 PNG 를 다시 묻는 간격과 그 상한. 상한은 export 작업이 읽기 하나에 +/// 주는 90초 안에 그림과 조각 읽기까지 마치도록 잡았다. +const REFERENCE_BUSY_RETRY: std::time::Duration = std::time::Duration::from_millis(500); +const REFERENCE_BUSY_WAIT: std::time::Duration = std::time::Duration::from_secs(45); + +/// 조각으로 받는 참조 PNG 답의 상한. 이미지 채움의 원본까지 실으므로 PNG 상한(16 MiB) +/// 보다 크다. +const REFERENCE_ANSWER_LIMIT: usize = 96 * 1024 * 1024; + +/// 참조 PNG 결과의 `_meta` 에서, 플러그인이 `get_screenshot` 과 같은 그림을 다 그리지 +/// 못했다는 이유를 싣는 자리. 원격 경로가 열려 있으면 그쪽이 대신 답한다. +const REFERENCE_PNG_APPROXIMATE_META_KEY: &str = "devup/referencePngApproximate"; + +/// 원격 경로가 브리지 대신 답했을 때 그 이유를 결과의 `_meta` 에 싣는 자리. +pub(crate) const DIRECT_FALLBACK_META_KEY: &str = "devup/directFallback"; + +/// 플러그인이 export 한 참조 PNG 를 공식 `get_screenshot` 이 주는 모양 — 이미지 +/// 블록 하나 — 으로 쓴다. 수집기는 두 경로의 답을 같은 디코더로 받는다. +/// +/// 그림도 같게 맞춘다. 다른 레이어가 닿지 않으면 플러그인은 캔버스가 보여 주는 그 +/// 자리를 export 하고 페이지 배경을 알려 주며, 여기서 그 위로 투명한 자리를 덮는다. +/// 닿으면 노드만 export 하고 조상들을 설명하며, 여기서 나머지를 그린다 +/// (`reference_compose`). 다 그리지 못한 것이 있으면 그 이유를 `_meta` 에 실어 +/// 원격 경로가 대신 답하게 한다. +fn wrap_as_image_result(data: &Value, served: &BridgeServed) -> Result { + if data["kind"] != "devupReferencePng" || data["mimeType"] != "image/png" { + return Err(unavailable( + "the plugin's reference PNG answer is not a devupReferencePng", + )); + } + // 그리다 멈추는 일이 생겨도 함께 온 캔버스 그림이 답이 된다. + let composed = data["compose"].is_object().then(|| { + std::panic::catch_unwind(|| crate::reference_compose::compose(&data["compose"])) + .unwrap_or_else(|_| Err(unavailable("composing it stopped unexpectedly"))) + }); + let mut failure = None; + let png = match composed { + Some(Ok(png)) => png, + failed => { + failure = failed.and_then(Result::err); + let png = data["data"].as_str().ok_or_else(|| { + unavailable("the plugin's reference PNG answer carries no picture") + })?; + match named_backdrop(data) { + Some(backdrop) => crate::collector::flatten_reference_png(png, backdrop)?, + None => png.to_owned(), + } + } + }; + let mut result = json!({ + "content": [{ "type": "image", "data": png, "mimeType": "image/png" }], + "isError": false, + "_meta": {}, + }); + result["_meta"][SERVED_META_KEY] = serde_json::to_value(served) + .map_err(|error| unavailable(format!("bridge result encode failed: {error}")))?; + if let Some(note) = data.get("note").and_then(Value::as_str) { + result["_meta"][REFERENCE_PNG_NOTE_META_KEY] = json!(note); + } + if let Some(reason) = data.get("approximate").and_then(Value::as_str) { + result["_meta"][REFERENCE_PNG_APPROXIMATE_META_KEY] = json!(reason); + } + if let Some(error) = failure { + result["_meta"][REFERENCE_PNG_APPROXIMATE_META_KEY] = + json!(format!("devup-mcp could not compose it: {}", error.message)); + result["_meta"][REFERENCE_PNG_NOTE_META_KEY] = json!(format!( + include_str!("../text/bridge-wrap_as_image_result-467d2f14.md"), + error.message, + data["reaching"].as_str().unwrap_or("other layers") + )); + } + Ok(result) +} + +/// 플러그인이 export 한 노드 아래의 색, `[r, g, b]`. 이 값을 보내기 전에 빌드된 +/// 플러그인은 보내지 않으며, 그 export 는 받은 그대로 넘긴다. +fn named_backdrop(data: &Value) -> Option<[u8; 3]> { + let channels = data + .get("backdrop")? + .as_array()? + .iter() + .map(|value| value.as_u64().and_then(|value| u8::try_from(value).ok())) + .collect::>>()?; + <[u8; 3]>::try_from(channels).ok() +} + +/// 플러그인이 붙는 문에 들이는 `Origin`. +/// +/// 브라우저 페이지도 `ws://localhost` 에 붙을 수 있다. 그 페이지가 여기서 `hello` +/// 를 보내 플러그인인 척하면 읽기 요청을 받고 지어낸 디자인을 돌려줄 수 있다 — 같은 +/// 파일 키로 나중에 붙은 연결이 그 파일의 읽기를 맡으므로 진짜 플러그인을 가로챌 +/// 수도 있다. 브라우저는 WebSocket 핸드셰이크에 늘 `Origin` 을 싣고 페이지는 그것을 +/// 바꾸지 못한다. +/// +/// Figma 플러그인의 UI 는 origin 이 `null` 인 iframe 에서 돈다(Figma 개발자 문서 +/// "Making Network Requests": "Plugin iframes have a null origin"). 이 플러그인은 +/// iframe 을 다른 주소로 옮기지 않는다. 혹시 Figma 가 제 origin 을 싣는 판이 있어도 +/// 끊기지 않도록 figma.com 도 들인다 — 다른 페이지는 그 값을 지어낼 수 없다. +/// `Origin` 이 없는 요청은 브라우저가 아닌 프로그램(테스트, 프로브)이다. +/// +/// 한계가 있다: 샌드박스 iframe 이나 `file://` 페이지도 `null` 을 싣는다. 흔한 웹 +/// 페이지는 막지만 그런 페이지까지 막지는 못한다. +fn a_plugin_origin(headers: &HeaderMap) -> bool { + headers.get(ORIGIN).is_none_or(|origin| { + matches!( + origin.as_bytes(), + b"null" | b"https://www.figma.com" | b"https://figma.com" + ) + }) +} + +/// 플러그인이 붙는 문. +async fn plugin_socket( + State(state): State, + headers: HeaderMap, + upgrade: WebSocketUpgrade, +) -> Response { + if !a_plugin_origin(&headers) { + return StatusCode::FORBIDDEN.into_response(); + } + upgrade + .max_message_size(128 * 1024 * 1024) + .max_frame_size(128 * 1024 * 1024) + .on_upgrade(move |socket| handle_plugin(state, socket)) +} + +async fn handle_plugin(state: BridgeState, mut socket: WebSocket) { + let (outbox, mut outbox_rx) = mpsc::unbounded_channel::(); + let id = state.counter.fetch_add(1, Ordering::Relaxed); + let mut shutdown = state.link.shutdown_signal(); + + // 보내기와 받기를 한 루프에서 번갈아 본다. 소켓을 쪼개려면 Stream/Sink 트레이트 + // 의존성이 필요한데, 그것을 들이는 것보다 select 가 싸다. + loop { + tokio::select! { + // 이 프로세스가 브리지를 내려놓는다. 소켓을 닫아야 플러그인이 다음 호스트를 찾는다. + () = shut_down(&mut shutdown) => break, + outgoing = outbox_rx.recv() => { + let Some(text) = outgoing else { break }; + if socket.send(Message::Text(text.into())).await.is_err() { + break; + } + } + incoming = socket.recv() => { + let Some(Ok(message)) = incoming else { break }; + let Message::Text(text) = message else { continue }; + let Ok(value) = serde_json::from_str::(&text) else { continue }; + + match value.get("kind").and_then(Value::as_str) { + Some("hello") => { + // 키가 없어도 등록한다. 건너뛰면 창은 "연결됨"이라고 하는데 + // 어떤 읽기도 오지 않아 원인을 찾을 수 없다. 키 없는 연결을 + // 어디까지 믿을지는 resolve 가 정한다. + // + // 필드마다 따로 읽는다. 페이지나 선택이 어긋난 모양으로 와도 + // 파일 키까지 잃으면 안 된다. + let file_key = value + .get("fileKey") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let plugin = Connected { + outbox: outbox.clone(), + routing_key: routing_key(&file_key, session_of(&value).as_deref(), id), + file_key, + file_name: value + .get("fileName") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + .map(str::to_owned), + context: serde_json::from_value(value).unwrap_or_default(), + }; + let mut inner = state.inner.lock().await; + inner.plugins.insert(id, plugin); + state.plugins_changed(&mut inner); + } + // 페이지를 옮기거나 선택을 바꿀 때마다 온다. url 없는 요청은 + // 이 값으로 대상을 고르므로, 늦게라도 최신이어야 한다. 중계들도 + // 같은 선택을 보도록 함께 알린다. + Some("context") => { + let Ok(context) = serde_json::from_value::(value) else { + continue; + }; + let mut inner = state.inner.lock().await; + if let Some(plugin) = inner.plugins.get_mut(&id) { + plugin.context = context; + state.plugins_changed(&mut inner); + } + } + Some("devup-result") => { + let Ok(result) = serde_json::from_value::(value) else { + continue; + }; + let waiting = state + .pending + .lock() + .expect("the pending table is never poisoned") + .remove(&result.request_id); + // 받는 쪽이 이미 타임아웃했거나 떠났으면 버린다. + if let Some(waiting) = waiting { + let _ = waiting.waiter.send(result); + } + } + _ => {} + } + } + } + } + + let mut inner = state.inner.lock().await; + if inner.plugins.remove(&id).is_some() { + state.plugin_left(&mut inner, id); + } +} + +/// 브리지를 여는 데 쓰는 것. +#[derive(Debug, Clone, Default)] +pub struct BridgeOptions { + /// 이 빌드의 식별자(`devup-mcp --version` 의 괄호 안). 중계 핸드셰이크가 상대에게 + /// 알리고, status 가 포트를 쥔 프로세스를 밝힐 때 쓴다. + pub build_id: Option, + /// 중계 비밀값 파일. 비우면 그 사용자만 쓰는 기본 자리다. + pub secret_path: Option, +} + +/// 중계 핸드셰이크에서 밝히는 devup-mcp 하나. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgePeer { + pub pid: u32, + pub version: String, + #[serde(default)] + pub build_id: Option, +} + +/// 이 프로세스가 브리지에서 맡은 역할. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BridgeRole { + /// 포트를 쥐었다. 플러그인은 이 프로세스에 붙는다. + Host, + /// 다른 devup-mcp 가 쥔 포트를 통해 읽는다. + Relay, + /// 누가 포트를 쥐었는지 확인하는 중이다 — 시작할 때, 또는 호스트가 떠난 뒤. + Connecting, + /// 지금은 브리지를 쓸 수 없다. 이유는 [`BridgeIssue`] 다. + Unavailable, +} + +/// 브리지를 쓸 수 없는 이유. 고치는 방법이 서로 다르다. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BridgeIssue { + /// 중계를 모르는 예전 devup-mcp 가 포트를 쥐었다. `/plugin` 만 있고 `/relay` 가 없다. + /// 자신을 밝히지 않으므로 `owner` 는 OS 에 물어 안 것이다. + LegacyHost { owner: Option }, + /// devup-mcp 가 아닌 프로그램이 포트를 쥐었다. `owner` 는 OS 에 물어 안 것이다. + ForeignProgram { + detail: String, + owner: Option, + }, + /// 다른 판의 중계 규약을 쓰는 devup-mcp 가 쥐었다. 틀린 답을 내느니 잇지 않는다. + IncompatibleProtocol { theirs: Option, ours: u64 }, + /// 서로 같은 사용자의 devup-mcp 임을 증명하지 못했다. + AuthenticationFailed { detail: String }, + /// 중계 비밀값 파일을 읽거나 만들 수 없다. + SecretUnavailable { detail: String }, + /// 포트를 잡을 수 없는데 그 포트에서 듣는 쪽도 없다. + BindFailed { detail: String }, +} + +impl BridgeIssue { + /// status 가 싣는 짧은 이름. + pub fn code(&self) -> &'static str { + match self { + Self::LegacyHost { .. } => "legacy-host", + Self::ForeignProgram { .. } => "foreign-program", + Self::IncompatibleProtocol { .. } => "incompatible-protocol", + Self::AuthenticationFailed { .. } => "authentication-failed", + Self::SecretUnavailable { .. } => "secret-unavailable", + Self::BindFailed { .. } => "bind-failed", + } + } +} + +/// 브리지 서버. +/// +/// 포트를 잡으면 호스트가 되고, 잡지 못하면 그 포트를 쥔 devup-mcp 를 통해 읽는다. +/// 어느 쪽이든 [`BridgeFigmaClient`] 는 같은 방식으로 쓴다. +pub struct BridgeServer { + state: BridgeState, + port: u16, +} + +impl BridgeServer { + /// 포트를 잡아 서버를 띄운다. + /// + /// 이미 다른 프로세스가 같은 포트를 쓰고 있으면 그쪽을 통해 읽는다. 한 기기에서 + /// MCP 클라이언트를 여러 개 띄우는 것은 정상이고, 플러그인이 붙을 수 있는 포트는 + /// 하나뿐이다. 그 포트를 쥔 쪽이 떠나면 이 프로세스가 이어받을 수 있다. + /// + /// 서버가 만들어지는 자리가 동기 함수라 bind 도 동기로 한다 — 듣기 소켓을 + /// 여는 것은 어차피 기다리지 않는 일이고, 기다리는 부분만 따로 띄운다. + pub fn start(port: u16) -> Option { + Self::start_with(port, BridgeOptions::default()) + } + + pub fn start_with(port: u16, options: BridgeOptions) -> Option { + // 런타임 밖에서 만들어졌다면 붙일 곳이 없다. 그때도 원격 경로는 멀쩡하다. + // bind 보다 먼저 본다 — 쓰지도 못할 포트를 잠깐이라도 쥐면 안 된다. + let runtime = tokio::runtime::Handle::try_current().ok()?; + let state = BridgeState::new(options); + match std::net::TcpListener::bind(("127.0.0.1", port)) { + Ok(listener) => { + // 0 을 주면 커널이 빈 포트를 고른다. 실제로 잡힌 번호를 알아야 붙을 수 있다. + let bound = listener.local_addr().ok()?.port(); + state.serve(listener, None).ok()?; + Some(Self { state, port: bound }) + } + // 0 은 빈 포트를 달라는 뜻이라, 누가 쥐고 있을 포트가 없다. + Err(_) if port == 0 => None, + Err(_) => { + runtime.spawn(relay::supervise(state.clone(), port)); + Some(Self { state, port }) + } + } + } + + /// 환경 설정을 읽어 띄운다. + /// + /// `DEVUP_FIGMA_BRIDGE_PORT` 가 포트를 정하며, `0` 이나 `off` 는 끈다. 값이 + /// 없으면 기본 포트로 켠다 — 플러그인이 안 떠 있으면 어차피 원격으로 가므로 + /// 켜 두는 쪽이 손해가 없다. + pub fn from_env() -> Option { + Self::from_env_with(BridgeOptions::default()) + } + + pub fn from_env_with(options: BridgeOptions) -> Option { + let setting = std::env::var("DEVUP_FIGMA_BRIDGE_PORT").ok(); + let port = match setting.as_deref().map(str::trim) { + None | Some("") => DEFAULT_BRIDGE_PORT, + Some("off") | Some("0") => return None, + Some(value) => value.parse().ok()?, + }; + Self::start_with(port, options) + } + + pub fn state(&self) -> BridgeState { + self.state.clone() + } + + /// 실제로 잡은 포트, 또는 이 프로세스가 통해 읽는 포트. `start(0)` 으로 띄웠을 때 + /// 어디에 붙어야 하는지 알려 준다. + pub fn port(&self) -> u16 { + self.port + } + + /// 브리지를 내려놓는다 — 프로세스가 끝날 때처럼 포트와 모든 연결을 닫는다. + /// + /// 서버를 버리는 것만으로는 닫지 않는다. 운영에서는 프로세스가 끝날 때까지 + /// 살아 있어야 하고, 이것은 한 프로세스 안에서 호스트가 떠나는 일을 재현한다. + pub fn shutdown(self) { + self.state.link.shutdown.send_replace(true); + } +} + +/// 이 요청을 맡을 수 있는지 **부르기 전에** 답하는 상류. +/// +/// 실패한 뒤에 폴백하면 안 되기 때문에 따로 둔다. `CapabilityUnavailable` 과 +/// `Transport` 는 같은 `ErrorCode` 로 접히므로, 에러만 보고는 "브리지가 못 하는 +/// 일"과 "브리지가 하다가 실패한 일"을 구분할 수 없다. 뒤엣것까지 원격으로 넘기면 +/// 아끼려던 요금을 그대로 쓰게 된다. +#[async_trait] +pub trait PreferredUpstream: FigmaUpstream { + async fn can_serve(&self, call: &ReadToolCall) -> bool; + + /// 지금 이 상류가 쓸 수 있는 상태인지. 배치 크기를 정하는 자리는 async 가 + /// 아니어서 `can_serve` 를 부를 수 없다. + fn is_live(&self) -> bool; +} + +/// 브리지 경로의 실측 상태. `devup_figma_auth doctor` 의 `paths.bridge` 가 된다. +/// +/// 이 값이 있다는 것 자체가 "이 프로세스가 브리지를 쓰려 한다"는 뜻이다. +/// `DEVUP_FIGMA_BRIDGE_PORT=off` 면 브리지 상류가 아예 만들어지지 않으므로 `None` +/// 이 된다. 포트를 잡지 못한 것은 이제 `None` 이 아니다 — 그 포트를 쥔 쪽을 통해 +/// 읽거나([`BridgeRole::Relay`]), 왜 그럴 수 없는지를 말한다. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgePathSnapshot { + /// 플러그인이 붙는 포트 — 이 프로세스가 잡았든 다른 프로세스가 잡았든. + /// 플러그인 manifest 의 `allowedDomains` 와 같아야 붙는다. + pub port: Option, + /// 지금 붙어 있는 플러그인, 붙은 순서대로. 포트를 쥔 프로세스 기준이라 어느 + /// 프로세스에서 보든 같다. 파일 키를 보고하지 못한 플러그인은 혼자 붙어 있을 + /// 때만 다른 키의 읽기를 받는다. + pub attached_files: Vec, + pub role: BridgeRole, + /// 포트를 쥔 devup-mcp. 호스트면 이 프로세스 자신이다. 모르면 `None` 이다 — + /// 예전 devup-mcp 는 자신을 밝히지 않는다. + pub host: Option, + /// [`BridgeRole::Unavailable`] 의 이유. + pub issue: Option, + /// 방금 떠난 호스트. 포트가 넘어가는 중이거나, 넘어갔는데 그때 붙어 있던 + /// 플러그인이 아직 다시 붙지 않았다. + pub handover_from: Option, +} + +impl BridgePathSnapshot { + /// 지금 이 경로로 읽기를 보낼 수 있는지 — 플러그인이 보이고, 그것에 닿는 길이 있다. + pub fn available(&self) -> bool { + matches!(self.role, BridgeRole::Host | BridgeRole::Relay) && !self.attached_files.is_empty() + } +} + +/// 플러그인을 통해 Figma 를 읽는 `FigmaUpstream`. +#[derive(Clone)] +pub struct BridgeFigmaClient { + state: BridgeState, + /// 진단에만 쓴다. 읽기 경로는 포트를 알 필요가 없다. + port: Option, +} + +impl BridgeFigmaClient { + pub fn new(state: BridgeState) -> Self { + Self { state, port: None } + } + + /// 잡은 포트를 함께 들고 있게 한다. "문이 어디에 열려 있는가"는 붙지 않는 + /// 플러그인을 진단할 때 가장 먼저 확인할 값이다. + #[must_use] + pub fn with_port(mut self, port: u16) -> Self { + self.port = Some(port); + self + } +} + +#[async_trait] +impl FigmaUpstream for BridgeFigmaClient { + async fn list_tools(&self) -> Result, DevupError> { + // 스크립트 경로만 대신하므로 광고하는 것도 그 하나다. + Ok(vec!["use_figma".to_owned()]) + } + + async fn call_read_tool(&self, call: ReadToolCall) -> Result { + let Some(job) = call.bridge_job() else { + return Err(unavailable("the bridge serves script reads only")); + }; + let mut params = job.params; + widen_budgets(&mut params); + let answered = if matches!(call, ReadToolCall::Screenshot { .. }) { + reference_answer(&self.state, call.file_key(), job.script, params).await + } else { + self.state + .dispatch(call.file_key(), job.script, params) + .await + }; + let (mut data, served) = match answered { + Ok(answered) => answered, + Err(error) if is_script_failure(&error) => { + return Ok(script_failure_result(&error.message)); + } + Err(error) => return Err(error), + }; + let served = BridgeServed { + port: self.port, + ..served + }; + if matches!(call, ReadToolCall::Screenshot { .. }) { + // 그리는 일은 픽셀마다 도는 계산이라 비동기 작업자를 붙들지 않게 넘긴다. + let raw = tokio::task::spawn_blocking(move || wrap_as_image_result(&data, &served)) + .await + .map_err(|error| { + unavailable(format!("composing the reference PNG stopped: {error}")) + })??; + return Ok(UpstreamResult { raw }); + } + if matches!(call, ReadToolCall::Metadata { node_id: None, .. }) { + return Ok(UpstreamResult { + raw: wrap_as_top_level_pages(&data, &served)?, + }); + } + stamp_file_key(&mut data, call.file_key()); + Ok(UpstreamResult { + raw: wrap_as_tool_result(&data, &served)?, + }) + } + + fn batch_budget(&self) -> BatchBudget { + bridge_batch_budget() + } + + async fn serves_without_credentials(&self, file_key: &str) -> bool { + self.state.has_plugin(file_key).await + } + + async fn bridge_path_snapshot(&self) -> Option { + Some(self.state.path_snapshot(self.port).await) + } + + async fn refresh_bridge_context(&self, file_key: &str) -> Result { + let (data, _) = self + .state + .dispatch(file_key, "bridgeContext", json!({})) + .await?; + if data.to_string().len() > 16_384 + || data["kind"] != "devup-bridge-context" + || !data["selection"].is_array() + || !data["selectionCount"].is_u64() + || !data["currentPage"]["id"].is_string() + { + return Err(unavailable( + "The plugin did not return a current selection context.", + )); + } + let context: PluginContext = serde_json::from_value(data) + .map_err(|_| unavailable("Invalid plugin selection context."))?; + if context.selection.as_ref().is_some_and(|s| { + s.len() > 20 || context.selection_count.is_some_and(|count| count < s.len()) + }) { + return Err(unavailable("Invalid plugin selection count.")); + } + Ok(context) + } + + async fn is_metered(&self, _call: &ReadToolCall) -> bool { + false + } +} + +#[async_trait] +impl PreferredUpstream for BridgeFigmaClient { + async fn can_serve(&self, call: &ReadToolCall) -> bool { + call.bridge_job().is_some() && self.state.has_plugin(call.file_key()).await + } + + fn is_live(&self) -> bool { + self.state.connected.load(Ordering::Relaxed) > 0 + } +} + +/// 자르지 않는 전송이므로 쪼갤 이유가 없다. 변수와 스타일을 한 번에 다 묻는다. +const BRIDGE_BATCH: BatchBudget = BatchBudget { + variable_items: 4096, + style_items: 4096, + used_resource_items: 4096, + used_resource_bytes: BRIDGE_ENVELOPE_BYTES, +}; + +/// 한 번에 물을 리소스 수를 밖에서 낮춰 보기 위한 손잡이. +/// +/// 리소스가 하나도 해석되지 않는 것을 실기기에서 봤고, 원인이 이 크기인지 +/// (플러그인이 그만한 동시 조회를 감당하지 못하는지) 아니면 애초에 그 변수를 +/// 읽을 권한이 없는지 가려야 했다. 둘은 고치는 곳이 다르다. +const BRIDGE_USED_RESOURCE_ITEMS_ENV: &str = "DEVUP_FIGMA_BRIDGE_USED_RESOURCE_ITEMS"; + +fn bridge_batch_budget() -> BatchBudget { + let mut budget = BRIDGE_BATCH; + if let Some(items) = std::env::var(BRIDGE_USED_RESOURCE_ITEMS_ENV) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|items| *items > 0) + { + budget.used_resource_items = items; + } + budget +} + +/// 브리지가 맡을 수 있으면 브리지로, 아니면 원격으로 보낸다. +/// +/// 판정은 부르기 전에 끝난다. 브리지가 맡은 요청이 실패하면 그 실패를 그대로 +/// 올린다 — 노드가 없거나 봉투가 너무 큰 것은 원격에서도 같은 결과이고, 다시 +/// 물으면 아낀 요금을 도로 쓰게 된다. 예외는 원격이라면 해낼 수 있는 거절 +/// (`remote_may_answer`)이다. 원격이 읽을 수 있는 키면 원격에 묻되, 원격도 다시 해 볼 +/// 여지 없이 거절하면(로그인이 없다 등) 플러그인의 거절을 올린다 — 한도를 쓰지 않는 +/// 처방(플러그인 갱신)을 말하는 쪽이 그것이다. +pub struct FallbackUpstream { + preferred: P, + secondary: S, + meter: Option>, +} + +/// 한도를 쓰는 경로로 보낼 읽기를 한도 안에 들 때까지 붙든다. +/// +/// 원격으로 가는 읽기는 부르는 쪽이 스스로 늦춘다. 플러그인이 답한 읽기를 원격으로 다시 +/// 읽을지는 그 판단 뒤 여기서 정해지므로 여기서 이것을 기다린다 — 다만 정해진 시간 안에 +/// 자리가 나지 않으면 기다리지 않는다. 기다리는 시간도 읽기 하나에 주어진 시간 안에 들어 +/// 있어서, 오래 기다리면 이미 받은 플러그인의 답까지 시간 초과로 잃는다. +#[async_trait] +pub trait Meter: Send + Sync { + /// `budget` 안에 한도 자리를 얻었으면 `true`. 얻지 못했으면 자리를 쓰지 않는다. + async fn acquire_within(&self, budget: std::time::Duration) -> bool; +} + +/// 읽기 하나에 주어진 시간. export 작업은 이 시간이 지난 읽기를 멈추고 다시 하게 한다. +pub const READ_TIME_LIMIT: std::time::Duration = std::time::Duration::from_secs(90); + +/// 원격으로 다시 읽는 일 — 한도 자리를 기다리는 것과 원격의 답까지 — 에 주는 시간의 상한. +const DIRECT_FALLBACK_BUDGET: std::time::Duration = std::time::Duration::from_secs(40); + +/// 다시 읽는 일은 플러그인의 읽기와 합쳐 `READ_TIME_LIMIT` 보다 이만큼 일찍 끝낸다. 그래야 +/// 시간이 다 됐을 때 이미 받은 플러그인의 답을 돌려줄 수 있다. +const FALLBACK_MARGIN: std::time::Duration = std::time::Duration::from_secs(5); + +impl FallbackUpstream { + pub fn new(preferred: P, secondary: S) -> Self { + Self { + preferred, + secondary, + meter: None, + } + } + + pub fn with_meter(mut self, meter: Arc) -> Self { + self.meter = Some(meter); + self + } + + /// 원격 읽기를 한도 자리를 얻어 `deadline` 안에 끝낸다. 자리를 얻지 못했거나 시간 안에 + /// 끝나지 않았으면 `None`. + async fn metered( + &self, + deadline: tokio::time::Instant, + read: impl std::future::Future, + ) -> Option { + let budget = deadline.saturating_duration_since(tokio::time::Instant::now()); + if budget.is_zero() { + return None; + } + if let Some(meter) = &self.meter + && !tokio::time::timeout_at(deadline, meter.acquire_within(budget)) + .await + .unwrap_or(false) + { + return None; + } + tokio::time::timeout_at(deadline, read).await.ok() + } +} + +#[async_trait] +impl FigmaUpstream for FallbackUpstream +where + P: PreferredUpstream, + S: FigmaUpstream, +{ + async fn list_tools(&self) -> Result, DevupError> { + // 능력 목록은 원격이 정본이다. 브리지는 그중 하나를 대신할 뿐이다. + self.secondary.list_tools().await + } + + async fn call_read_tool(&self, call: ReadToolCall) -> Result { + let started = tokio::time::Instant::now(); + if self.preferred.can_serve(&call).await { + let answered = self.preferred.call_read_tool(call.clone()).await?; + if is_bridge_only_key(call.file_key()) { + return Ok(answered); + } + let better = remote_does_better(&answered.raw); + let Some(reason) = better.or_else(|| remote_may_answer(&answered.raw)) else { + return Ok(answered); + }; + // 로그인 확인, 한도 자리, 원격의 답이 모두 이 시간 안이다 — `DIRECT_FALLBACK_BUDGET` + // 안에, 그리고 이 읽기가 시작된 때부터 `READ_TIME_LIMIT - FALLBACK_MARGIN` 안에. + let deadline = (tokio::time::Instant::now() + DIRECT_FALLBACK_BUDGET) + .min(started + READ_TIME_LIMIT - FALLBACK_MARGIN); + let direct = + match tokio::time::timeout_at(deadline, self.secondary.has_credentials()).await { + Ok(false) => return Ok(answered), + Ok(true) => { + self.metered(deadline, self.secondary.call_read_tool(call)) + .await + } + Err(_) => None, + }; + return match direct { + // 플러그인의 답을 쓸 수 있으면 그것을 쓴다. 쓸 수 없는 답(거절)이면 한도가 + // 비는 대로 다시 해 볼 일이다. + None if better.is_some() => Ok(answered), + None => Err(no_room_on_the_metered_path()), + Some(Ok(direct)) if better.is_some() && direct_unusable(&direct.raw) => { + Ok(answered) + } + Some(Ok(mut direct)) => { + direct.raw["_meta"][DIRECT_FALLBACK_META_KEY] = json!(reason); + Ok(direct) + } + Some(Err(error)) if better.is_none() && error.retryable => Err(error), + Some(Err(_)) => Ok(answered), + }; + } + // 브리지만 읽을 수 있는 키다. 원격으로 넘기면 Figma 에 없는 키를 묻게 되고, + // 돌아오는 것은 원인과 무관한 거절 — 로그인하라거나 파일이 없다는 — 뿐이다. + if is_bridge_only_key(call.file_key()) { + return Err(bridge_only_refusal(&call)); + } + self.secondary.call_read_tool(call).await + } + + /// 붙어 있는 플러그인이 있으면 그쪽 크기로 묶는다. + /// + /// 배치는 부르기 한참 전에 나뉘므로 어느 전송이 받을지 그때는 알 수 없다. + /// 다만 크게 묶어도 되는 읽기는 전부 스크립트 경로이고, 플러그인이 붙어 있는 + /// 한 그것은 브리지가 받는다. 수집 도중 플러그인이 닫히면 남은 배치가 원격에 + /// 너무 커서 거절되는데, 그때는 수집 자체가 이어질 수 없으므로 실패가 드러나는 + /// 편이 맞다. + fn batch_budget(&self) -> BatchBudget { + if self.preferred.is_live() { + self.preferred.batch_budget() + } else { + self.secondary.batch_budget() + } + } + + /// 이 파일을 맡은 플러그인이 있으면 원격 자격증명 없이도 수집이 성립한다. + /// 뒤엣것은 원격이므로 물을 것이 없다. + /// + /// 브리지만 읽을 수 있는 키는 로그인을 기다리지 않는다. direct 경로는 그 키로는 + /// 무엇을 치러도 읽을 수 없으므로, 로그인을 요구하는 것은 엉뚱한 처방이다. + /// 플러그인이 그새 끊겼다면 그 거절은 첫 읽기가 제 이유와 함께 한다. + async fn serves_without_credentials(&self, file_key: &str) -> bool { + is_bridge_only_key(file_key) || self.preferred.serves_without_credentials(file_key).await + } + + async fn bridge_path_snapshot(&self) -> Option { + self.preferred.bridge_path_snapshot().await + } + + async fn refresh_bridge_context(&self, file_key: &str) -> Result { + self.preferred.refresh_bridge_context(file_key).await + } + + /// 브리지가 맡는 읽기는 한도를 쓰지 않는다. 브리지만 읽을 수 있는 키는 원격으로 + /// 가지 않으므로(거절된다) 역시 세지 않는다. + async fn is_metered(&self, call: &ReadToolCall) -> bool { + !(is_bridge_only_key(call.file_key()) || self.preferred.can_serve(call).await) + } +} + +/// 플러그인이 답은 했지만 원격 경로가 더 낫게 답할 수 있는 경우와 그 이유. 원격이 +/// 어떤 이유로든 답하지 못하면 플러그인의 답을 그대로 쓴다 — 쓸 수 있는 답을 버리고 +/// 실패를 올리지 않는다. +/// +/// - Figma 가 화면에 보이지 않는 파일의 데이터를 가져오지 않아 읽기의 일부나 전부가 +/// "Unable to establish connection to Figma" 로 빠졌다(`figma_unreachable_hint`). +/// 원격 경로는 그 상태와 무관하다. +/// - 참조 PNG 에서 플러그인이 `get_screenshot` 과 같은 그림을 다 그리지 못했다. +fn remote_does_better(raw: &Value) -> Option<&'static str> { + if raw["_meta"][REFERENCE_PNG_APPROXIMATE_META_KEY].is_string() { + return Some("reference-png-approximate"); + } + texts(raw) + .any(|text| { + crate::figma_unreachable_hint(text).is_some() + && (raw["isError"] == true || reports_unreachable(text)) + }) + .then_some("file-out-of-sight") +} + +/// 플러그인 답의 JSON 에서 오류를 싣는 자리 — 값을 읽지 못한 `$error`, 필드 읽기 실패 +/// `fieldErrors`, 미해석 리소스의 `error`, 자산 export 의 `errorDetail` — 에 그 말이 있는지. +/// 디자인의 글(`characters`, 이름, 설명)에 같은 문장이 있어도 한도를 써서 다시 읽지 않는다. +fn reports_unreachable(text: &str) -> bool { + fn within(value: &Value, in_error: bool) -> bool { + match value { + Value::String(text) => in_error && crate::figma_unreachable_hint(text).is_some(), + Value::Array(items) => items.iter().any(|item| within(item, in_error)), + Value::Object(fields) => fields.iter().any(|(key, value)| { + let error_field = matches!( + key.as_str(), + "$error" | "error" | "errors" | "fieldErrors" | "errorDetail" | "lookupError" + ); + within(value, in_error || error_field) + }), + _ => false, + } + } + serde_json::from_str::(text).is_ok_and(|value| within(&value, false)) +} + +/// 플러그인이 답하지 못한 읽기를 원격으로 넘기려 했지만 정해진 시간 안에 한도 자리가 나지 +/// 않았다. 한도가 찬 것과 같으므로 그렇게 알려, 수집이 한도가 비기를 기다렸다 다시 하게 한다. +fn no_room_on_the_metered_path() -> DevupError { + DevupError::with_details( + ErrorCode::DevupFigmaRateLimited, + include_str!("../text/bridge-no_room_on_the_metered_path-41a0802f.md"), + true, + json!({ "source": "direct", "stage": "bridge-fallback" }), + ) +} + +/// 원격 경로라면 해낼 수 있는 플러그인의 거절과 그 이유: +/// +/// - 이 devup-mcp 보다 먼저 빌드돼 스크립트를 모른다 — 읽기는 시도되지 않았다. +/// - 참조 PNG 가 브리지 답 하나(8 MiB)를 넘는다 — 조각으로 내주기 전의 플러그인이다. +/// - 큰 값의 바이트가 읽기가 가리키는 해시와 다르다 — 그 값은 원격 경로의 렌더러가 +/// 만든 것이라(플러그인이 답하지 못해 원격이 읽은 자산의 이어 읽기), 거기서만 같은 +/// 바이트가 나온다. +fn remote_may_answer(raw: &Value) -> Option<&'static str> { + if raw["isError"] != true { + return None; + } + let text = raw["content"][0]["text"].as_str()?; + [ + ("DEVUP_BRIDGE_UNKNOWN_SCRIPT", "plugin-older-than-server"), + ("DEVUP_REFERENCE_PNG_TOO_LARGE", "plugin-answer-too-large"), + ("DEVUP_REFERENCE_PNG_BUSY", "plugin-busy"), + ("DEVUP_LARGE_VALUE_CHANGED", "value-from-direct-path"), + ] + .into_iter() + .find_map(|(code, reason)| text.contains(code).then_some(reason)) +} + +/// 원격의 답을 쓸 수 없는지. 그때는 플러그인의 답이 낫다. +/// +/// - 오류로 표시됐다. +/// - 한도에 걸렸다는 문장 하나다 — `isError` 없이 오기도 한다(`is_quota_refusal`). +/// - 공식 MCP 의 텍스트 상한(20 KB)에서 잘렸다. 플러그인의 크기로 묶은 읽기를 원격이 다시 +/// 읽으면 생기고, 그 답은 디코딩되지 않는다. +fn direct_unusable(raw: &Value) -> bool { + raw["isError"] == true + || texts(raw).any(|text| { + crate::is_quota_refusal(text, false) + || text.trim_end().ends_with("// truncated to 20kb") + }) +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/bridge/texts.rs b/crates/devup-mcp-figma/src/bridge/texts.rs new file mode 100644 index 00000000..3ac19d2e --- /dev/null +++ b/crates/devup-mcp-figma/src/bridge/texts.rs @@ -0,0 +1,30 @@ + + +fn texts(raw: &Value) -> impl Iterator { + raw["content"] + .as_array() + .into_iter() + .flatten() + .filter_map(|block| block["text"].as_str()) +} + +/// 브리지만 읽을 수 있는 키의 읽기를 브리지가 맡지 못할 때의 거절. +/// +/// 두 경우는 고치는 방법이 다르다. 스크립트 읽기면 플러그인이 끊긴 것이니 다시 +/// 띄우면 되고, 그 밖의 읽기(수집이 쓰지 않는 공식 도구 — 변수 정의, 디자인 컨텍스트, +/// Code Connect)는 브리지가 애초에 못 하는 일이라 그 파일의 진짜 Figma 링크가 있어야 +/// direct 경로로 읽을 수 있다. +fn bridge_only_refusal(call: &ReadToolCall) -> DevupError { + let message = if call.bridge_job().is_some() { + "The Devup Bridge plugin this request was addressed to is no longer attached. Run it \ + again on the file in the Figma desktop app, then repeat the call." + } else { + include_str!("../text/bridge-bridge_only_refusal-4e141d6f.md") + }; + DevupError::with_details( + ErrorCode::DevupFigmaDirectUnavailable, + message, + false, + json!({ "source": "bridge", "stage": "bridge-routing", "tool": call.tool_name() }), + ) +} diff --git a/crates/devup-mcp-figma/src/bridge/transport_and_state.rs b/crates/devup-mcp-figma/src/bridge/transport_and_state.rs new file mode 100644 index 00000000..0cd73223 --- /dev/null +++ b/crates/devup-mcp-figma/src/bridge/transport_and_state.rs @@ -0,0 +1,965 @@ + +#[path = "holder.rs"] +mod holder; +#[path = "relay.rs"] +mod relay; +#[path = "secret.rs"] +mod secret; + +pub use holder::PortOwner; + +use std::{ + collections::HashMap, + hash::Hash, + io, + path::PathBuf, + sync::{ + Arc, Mutex as StdMutex, + atomic::{AtomicU64, AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +use async_trait::async_trait; +use axum::{ + Router, + extract::{ + State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + http::{HeaderMap, StatusCode, header::ORIGIN}, + response::{IntoResponse, Response}, + routing::any, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::{ + net::TcpListener, + sync::{Mutex, mpsc, oneshot, watch}, + time::timeout, +}; + +use crate::{ + errors::{DevupError, ErrorCode}, + upstream::{BatchBudget, FigmaUpstream, ReadToolCall, UpstreamResult}, + url::{BRIDGE_KEY_PREFIX, is_bridge_only_key}, +}; + +/// 플러그인 manifest 의 `allowedDomains` 와 같은 값이어야 한다. 바꾸려면 양쪽을 +/// 함께 고쳐야 하며, 플러그인은 재빌드·재설치가 필요하다. +pub const DEFAULT_BRIDGE_PORT: u16 = 1993; + +/// 한 번의 읽기에 허용하는 시간. 스크립트는 문서를 한 번 훑는 정도라 원격보다 +/// 훨씬 짧아도 되지만, 아주 큰 페이지의 첫 스냅샷은 몇 초가 걸린다. +const JOB_TIMEOUT: Duration = Duration::from_secs(90); + +/// 브리지가 답한 결과의 `_meta` 에서 어느 플러그인이 답했는지를 싣는 자리. +const SERVED_META_KEY: &str = "devup/bridge"; + +/// 참조 PNG 결과의 `_meta` 에서 플러그인이 그 그림에 단 말을 싣는 자리. +pub(crate) const REFERENCE_PNG_NOTE_META_KEY: &str = "devup/referencePngNote"; + +/// 플러그인이 끊긴 뒤 다시 붙기를 시도하는 간격(`plugin/src/ui.ts` 의 `RETRY_MS`). +const PLUGIN_RETRY: Duration = Duration::from_secs(2); + +/// 호스트가 바뀐 직후, 붙어 있던 플러그인이 새 호스트에 다시 붙기를 기다리는 시간. +/// 플러그인의 재시도 간격에 연결·인사에 걸리는 시간을 얹었다. 이 사이의 읽기는 +/// 플러그인이 없다고 단정해 요금이 드는 경로로 넘기지 않고, 붙기를 기다린다. +const REATTACH_GRACE: Duration = Duration::from_secs(PLUGIN_RETRY.as_secs() + 3); + +/// 누가 포트를 쥐었는지 아직 모를 때 status 와 읽기가 답을 기다리는 한도. 확인은 +/// 몇 밀리초면 끝나고, 대답하지 않는 상대도 [`relay`] 의 제한 시간 안에 판정된다. +const SETTLE_TIMEOUT: Duration = Duration::from_secs(5); + +/// 플러그인이 가리키는 노드 하나 — 보고 있는 페이지, 또는 거기서 선택한 것. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeRef { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub node_type: Option, +} + +/// Figma 를 쓰는 사람이 지금 있는 곳: 보고 있는 페이지와 거기서 선택한 것. +/// +/// 모두 선택 사항이다. 이것을 보고하기 전에 빌드된 플러그인은 아무것도 보내지 +/// 않고, "이 플러그인은 말하지 않는다"와 "아무것도 선택하지 않았다"는 계속 +/// 구분되어야 한다 — 앞의 것은 플러그인을 다시 띄울 일이고, 뒤의 것은 Figma 에서 +/// 고를 일이다. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginContext { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub zoom: Option, + /// Pixel ratio of the plugin UI iframe, not a verified canvas capture scale. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_device_pixel_ratio: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_known_selection: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_known_page: Option, + #[serde(default)] + pub current_page: Option, + /// 선택한 노드의 앞부분. 플러그인이 보내는 만큼만 있고, 전체 수는 + /// `selection_count` 다. + #[serde(default)] + pub selection: Option>, + #[serde(default)] + pub selection_count: Option, + /// 플러그인 창이 화면에 보이는지. Figma 는 보이지 않는 파일이 아직 불러오지 않은 + /// 데이터를 읽지 못하게 하므로, 수집을 시작하기 전에 알 수 있어야 한다. 이것을 + /// 보고하기 전에 빌드된 플러그인은 `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub in_view: Option, +} + +impl PluginContext { + /// 정확히 하나가 선택돼 있으면 그 노드. + pub fn single_selection(&self) -> Option<&NodeRef> { + match self.selection.as_deref()? { + [node] if self.selection_count.is_none_or(|count| count == 1) => Some(node), + _ => None, + } + } +} + +/// 붙어 있는 플러그인 하나. `status` 가 보고하는 것이자, url 없는 요청이 +/// 가리키게 되는 것이다. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttachedFile { + /// 읽기가 이 플러그인에 닿으려면 부를 이름. 플러그인이 보고한 파일 키이고, + /// 보고하지 못했으면 이 연결만 가리키는 브리지 전용 키다. 호출자에게는 + /// 보이지 않는다 — 플러그인이 보고하지 않은 키는 그 파일의 키가 아니다. + pub target_key: String, + /// 플러그인이 보고한 파일 키. 보고하지 못했으면 `None`. + pub file_key: Option, + pub file_name: Option, + pub context: PluginContext, +} + +/// 수집의 읽기를 어느 플러그인이 답했는지. +/// +/// 수집 결과와 함께 보관되어, 새로 투영하든 재사용하든 같은 출처를 말한다. +/// 파일 키는 플러그인이 보고한 것뿐이다 — 보고하지 못했으면 키를 말하지 않으며, +/// 요청에 실려 온 키를 그 파일의 키로 내세우지 않는다. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeServed { + /// 수집의 읽기 가운데 플러그인이 답한 수. + pub reads: usize, + pub port: Option, + pub file_key: Option, + pub file_name: Option, + pub page_name: Option, +} + +impl BridgeServed { + /// 브리지가 답한 결과면 그 출처, 아니면 `None`. + pub fn from_result(raw: &Value) -> Option { + serde_json::from_value(raw.get("_meta")?.get(SERVED_META_KEY)?.clone()).ok() + } +} + +/// 플러그인이 작업을 마치고 보내는 메시지. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PluginResult { + request_id: String, + #[serde(default)] + data: Option, + #[serde(default)] + error: Option, +} + +/// 서버가 플러그인에 보내는 작업. +/// +/// `requestId` 는 늘 호스트가 매긴다. 중계로 온 읽기도 마찬가지라, 여러 프로세스의 +/// 읽기가 한 플러그인에 모여도 번호가 겹치지 않고, 답은 번호로 물은 쪽에만 간다. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct Job<'a> { + kind: &'static str, + request_id: String, + script: &'a str, + params: Value, +} + +/// 브리지가 맡을 수 있는 읽기 하나. `ReadToolCall::bridge_job` 이 만든다. +#[derive(Debug, Clone, PartialEq)] +pub struct BridgeJob { + /// 플러그인이 아는 이름. 코드젠이 파일명에서 만든 것과 같아야 한다. + pub script: &'static str, + /// 스크립트가 읽는 값. Rust 의 치환 자리와 1:1 이다. + pub params: Value, +} + +struct Connected { + outbox: mpsc::UnboundedSender, + /// 플러그인이 보고한 파일 키. 보고하지 못했으면 빈 문자열이다. + file_key: String, + /// 읽기가 이 플러그인에 닿으려면 부를 이름. [`routing_key`] 가 정한다. + routing_key: String, + file_name: Option, + context: PluginContext, +} + +impl Connected { + fn reported_key(&self) -> Option { + (!self.file_key.is_empty()).then(|| self.file_key.clone()) + } + + fn attached(&self) -> AttachedFile { + AttachedFile { + target_key: self.routing_key.clone(), + file_key: self.reported_key(), + file_name: self.file_name.clone(), + context: self.context.clone(), + } + } + + fn served(&self) -> BridgeServed { + BridgeServed { + reads: 1, + port: None, + file_key: self.reported_key(), + file_name: self.file_name.clone(), + page_name: self + .context + .current_page + .as_ref() + .map(|page| page.name.clone()), + } + } +} + +/// 읽기가 이 플러그인에 닿으려면 부를 이름. +/// +/// 파일 키를 보고했으면 그 키다. 보고하지 못했으면(Dev Mode) 브리지 전용 키인데, +/// 플러그인이 창마다 한 번 정해 `hello` 에 싣는 `sessionId` 가 있으면 그것에서 +/// 만든다 — 소켓이 끊겨 다시 붙어도, 포트를 쥔 devup-mcp 가 바뀌어도 같은 창이면 +/// 같은 이름이라, 수집 도중에 연결이 바뀌어도 남은 읽기가 그 창을 다시 찾는다. +/// `sessionId` 를 보내지 않는 예전 빌드는 연결마다 매긴 번호로 부른다. +fn routing_key(file_key: &str, session: Option<&str>, connection: u64) -> String { + if !file_key.is_empty() { + return file_key.to_owned(); + } + match session { + Some(session) => format!("{BRIDGE_KEY_PREFIX}{session}"), + None => connection_key(connection), + } +} + +/// 플러그인이 보낸 `sessionId` 를 이름에 쓸 수 있을 때만 받는다. 브리지 전용 키의 +/// 모양을 지키고(`bridge:current` 같은 예약된 이름과 겹치지 않게) 길이를 묶는다. +fn session_of(hello: &Value) -> Option { + hello + .get("sessionId") + .and_then(Value::as_str) + .filter(|session| { + (16..=64).contains(&session.len()) + && session + .bytes() + .all(|byte| byte.is_ascii_hexdigit() || byte == b'-') + }) + .map(|session| format!("s-{session}")) +} + +/// 플러그인이 답하기를 기다리는 읽기 하나. +struct Pending { + /// 작업을 받은 플러그인의 연결 번호. 그 연결이 끊기면 이 읽기도 끝난다. + plugin: u64, + /// 그 플러그인에 보낼 곳. 기다리는 쪽이 떠나면 여기로 거둬 달라고 알린다. + outbox: mpsc::UnboundedSender, + waiter: oneshot::Sender, +} + +type PendingTable = StdMutex>; + +/// 플러그인의 답을 기다리는 동안 쥐고 있는 대기표. +/// +/// 답이 오면 받는 쪽이 대기표를 이미 걷었으므로 아무 일도 하지 않는다. 답을 받지 +/// 못한 채 버려지면 — 시간이 다 됐거나, 읽기를 맡긴 프로세스가 떠나 호스트가 그 +/// 읽기를 취소했거나 — 대기표를 걷고 플러그인에 `devup-cancel` 을 보낸다. 아직 +/// 차례를 기다리던 작업이면 플러그인은 돌리지 않는다. +struct Awaiting<'a> { + pending: &'a PendingTable, + request_id: String, +} + +impl Drop for Awaiting<'_> { + fn drop(&mut self) { + let Ok(mut pending) = self.pending.lock() else { + return; + }; + if let Some(abandoned) = pending.remove(&self.request_id) { + let cancel = json!({ "kind": "devup-cancel", "requestId": self.request_id }); + let _ = abandoned.outbox.send(cancel.to_string()); + } + } +} + +#[derive(Default)] +struct Inner { + /// 연결 번호 → 플러그인. + /// + /// 파일 키로 묶지 않는다. 키를 보고하지 못한 플러그인들은 모두 빈 키라서, + /// 그렇게 묶으면 둘째가 첫째를 덮어쓰고 첫째가 끊길 때 둘째의 등록까지 + /// 지운다. 그러면 "플러그인이 정확히 하나"를 셀 수도 없다. + plugins: HashMap, + /// 연결 번호 → 이 호스트를 통해 읽는 다른 devup-mcp. 플러그인이 붙거나 떠나거나 + /// 선택이 바뀔 때마다 모두에게 새 목록을 보낸다. + relays: HashMap>, +} + +/// 결과를 기다리는 쪽이 사라지면 그 대기표를 걷는다. +/// +/// 기다리던 future 가 버려지는 일은 흔하다 — 중계를 요청한 프로세스가 끊기면 호스트는 +/// 그 읽기들을 취소한다. 걷지 않으면 플러그인이 끝내 답하지 않는 한 대기표가 남는다. +struct Waiting<'a, K: Eq + Hash, V> { + pending: &'a StdMutex>, + key: K, +} + +impl Drop for Waiting<'_, K, V> { + fn drop(&mut self) { + if let Ok(mut pending) = self.pending.lock() { + pending.remove(&self.key); + } + } +} + +/// 이 프로세스가 브리지에서 맡은 역할. +#[derive(Clone)] +enum LinkState { + /// 누가 포트를 쥐었는지 아직 모른다 — 시작할 때, 또는 쥐고 있던 호스트가 떠난 뒤. + Connecting { after: Option }, + /// 이 프로세스가 포트를 쥐었다. + /// + /// `expecting` 은 방금 떠난 호스트다. 그때 플러그인이 붙어 있었으면 곧 이리로 + /// 다시 붙는다. + Host { + expecting: Option, + since: Instant, + }, + /// 다른 devup-mcp 가 포트를 쥐었고, 이 프로세스는 그를 통해 읽는다. `files` 는 + /// 그 호스트가 보낸 플러그인 목록이다. + Relay { + connection: Arc, + files: Vec<(u64, AttachedFile)>, + expecting: Option, + since: Instant, + }, + /// 지금은 브리지를 쓸 수 없다. `holder` 는 포트를 쥔 쪽이 스스로 밝힌 것이다. + Unavailable { + issue: BridgeIssue, + holder: Option, + }, +} + +struct Link { + state: watch::Sender, + /// 중계 핸드셰이크에서 상대에게 밝히는 이 프로세스. + me: BridgePeer, + secret_path: Option, + shutdown: watch::Sender, +} + +impl Link { + fn current(&self) -> LinkState { + self.state.borrow().clone() + } + + fn set(&self, state: LinkState) { + self.state.send_replace(state); + } + + /// 역할은 그대로지만 보이는 것이 바뀌었다(플러그인이 붙거나 떠났다). + fn touch(&self) { + self.state.send_modify(|_| {}); + } + + fn shutdown_signal(&self) -> watch::Receiver { + self.shutdown.subscribe() + } + + fn is_shut_down(&self) -> bool { + *self.shutdown.borrow() + } +} + +/// 닫으라는 신호가 올 때까지 기다린다. +async fn shut_down(signal: &mut watch::Receiver) { + let _ = signal.wait_for(|down| *down).await; +} + +/// 플러그인 연결과 대기 중인 요청, 이 프로세스의 역할을 함께 들고 있는 공유 상태. +#[derive(Clone)] +pub struct BridgeState { + inner: Arc>, + /// requestId → 결과를 기다리는 쪽. 잠금을 쥔 채 기다리지 않으므로 동기 잠금이면 + /// 되고, 그래야 기다리던 쪽이 사라질 때 [`Awaiting`] 이 곧바로 걷을 수 있다. + pending: Arc, + /// 요청 번호와 연결 번호를 함께 매긴다. 둘 다 유일하기만 하면 된다. + counter: Arc, + /// 이 프로세스에서 보이는 플러그인 수. 배치 크기를 정할 때는 잠금을 기다릴 수 없어 + /// (그 자리가 async 가 아니다) 따로 센다. + connected: Arc, + link: Arc, +} + +fn unavailable(message: impl Into) -> DevupError { + DevupError::new(ErrorCode::DevupFigmaDirectUnavailable, message, false) +} + +/// 플러그인이 답한 실패 — 스크립트가 던진 `DEVUP_*` 코드나 Figma 런타임의 문구. +/// 전송이 끊긴 것과 달리 읽기는 플러그인에 닿았으므로, [`BridgeFigmaClient`] 가 +/// 원격 `use_figma` 와 같은 `isError` 결과로 바꿔 올린다. 그러지 않으면 Figma 가 +/// 페이지를 불러오지 못했다는 말까지 "direct 경로를 쓸 수 없다"로 읽혔다. +fn script_failure(message: impl Into) -> DevupError { + DevupError::with_details( + ErrorCode::DevupFigmaDirectUnavailable, + message, + false, + json!({ "source": "bridge", "stage": "plugin-execution" }), + ) +} + +pub(crate) fn is_script_failure(error: &DevupError) -> bool { + error.details["source"] == "bridge" && error.details["stage"] == "plugin-execution" +} + +/// 플러그인이 답한 실패를 원격 `use_figma` 가 주는 모양으로 쓴다. 위쪽은 두 경로의 +/// 실패를 한 가지로 분류한다 — 없는 노드는 호출자 오류로, 나머지는 플러그인 실행 +/// 오류로. +fn script_failure_result(message: &str) -> UpstreamResult { + UpstreamResult { + raw: json!({ + "content": [{ "type": "text", "text": format!("Error: {message}") }], + "isError": true, + }), + } +} + +/// 이 연결 하나만 가리키는 브리지 전용 키. +fn connection_key(id: u64) -> String { + format!("{BRIDGE_KEY_PREFIX}{id}") +} + +/// 오류 문구에 쓸 파일 이름. 브리지 전용 키는 파일의 키가 아니므로 드러내지 않는다. +fn describe_file(file_key: &str) -> String { + if is_bridge_only_key(file_key) { + "the file this request was addressed to".to_owned() + } else { + format!("file {file_key}") + } +} + +/// 어느 연결이 이 파일을 맡을지 고른다. +/// +/// 보통은 파일 키가 그대로 맞는다. 같은 파일을 두 창에서 열었으면 나중에 붙은 +/// 쪽이 맡는다 — 둘 다 같은 문서를 보므로 어느 쪽이든 답은 같다. +/// +/// 다만 `figma.fileKey` 는 늘 있는 값이 아니어서 (Dev Mode 에서 비어 오는 것을 +/// 실제로 봤다) 키 없이 붙는 플러그인이 생긴다. 그때 등록을 건너뛰면 창은 +/// "연결됨"이라고 하는데 어떤 읽기도 오지 않는 — 원인을 찾기 가장 어려운 — 상태가 +/// 된다. 그래서 키 없는 플러그인은 **혼자 붙어 있을 때만** 아무 키나 맡는다. +/// 여럿이면 어느 파일을 보고 있는지 알 수 없고, 엉뚱한 파일을 읽어 주는 것보다 +/// 원격으로 넘기는 편이 낫다. +/// +/// 브리지 전용 키는 그 창 하나만 가리킨다. 몇 개가 붙어 있든 모호하지 않고, 그 +/// 창이 끊기면 아무도 맡지 않는다 — 다른 파일의 플러그인이 대신 답하면 안 된다. +/// 같은 창이 다시 붙으면(같은 `sessionId`) 나중 연결이 맡는다. +/// +/// `plugins` 는 (연결 번호, 부를 이름, 보고한 파일 키 — 없으면 빈 문자열) 이다. +/// 호스트는 제 연결로, 중계는 호스트가 보낸 목록으로 같은 규칙을 쓴다. +fn resolve<'a>( + plugins: impl IntoIterator, + file_key: &str, +) -> Option { + let plugins: Vec<(u64, &str, &str)> = plugins.into_iter().collect(); + if is_bridge_only_key(file_key) { + return plugins + .iter() + .filter(|(_, routing, _)| *routing == file_key) + .map(|(id, ..)| *id) + .max(); + } + let holding = plugins + .iter() + .filter(|(_, _, reported)| *reported == file_key) + .map(|(id, ..)| *id) + .max(); + if holding.is_some() { + return holding; + } + match plugins.as_slice() { + [(id, _, "")] => Some(*id), + _ => None, + } +} + +fn keys_of(plugins: &HashMap) -> impl Iterator { + plugins + .iter() + .map(|(id, plugin)| (*id, plugin.routing_key.as_str(), plugin.file_key.as_str())) +} + +fn keys_in(files: &[(u64, AttachedFile)]) -> impl Iterator { + files.iter().map(|(id, file)| { + ( + *id, + file.target_key.as_str(), + file.file_key.as_deref().unwrap_or_default(), + ) + }) +} + +/// 읽기 한 번이 끝난 모양. 중간에 끊긴 것은 다시 보낼 수 있다 — 읽기는 문서를 +/// 바꾸지 않는다. +pub(crate) enum Failure { + /// 플러그인에 닿는 길이 도중에 끊겼다: 플러그인이 떠났거나, 포트를 쥔 호스트가 + /// 떠났다. 플러그인이 돌아오면 다시 보낸다. + Interrupted(DevupError), + /// 답이 왔거나(스크립트 오류 포함) 더 기다릴 수 없다. 그대로 올린다. + Final(DevupError), +} + +/// 끊긴 읽기를 다시 보내는 횟수의 한도. +const INTERRUPTIONS: usize = 3; + +/// 끊긴 읽기가 플러그인이 돌아오기를 기다리는 한도. 호스트가 바뀌어도 이 안에 +/// 포트가 넘어가고, 플러그인은 2초마다 다시 붙는다. +const RETURN_WAIT: Duration = Duration::from_secs(10); + +/// 중계가 다룰 수 없는 상태에서 읽기가 왔을 때의 거절. +fn not_reachable(link: &LinkState) -> DevupError { + match link { + LinkState::Unavailable { issue, .. } => unavailable(format!( + "this devup-mcp cannot use the Devup Bridge right now ({}); devup_figma_auth \ + {{ action: \"status\" }} says why and what to do", + issue.code() + )), + _ => unavailable(include_str!("../text/bridge-not_reachable-fa7318ce.md")), + } +} + +impl BridgeState { + fn new(options: BridgeOptions) -> Self { + Self { + inner: Arc::default(), + pending: Arc::default(), + // 연결 번호가 키 없는 플러그인의 라우팅 키(`bridge:<번호>`)가 된다. 호스트가 + // 바뀐 뒤 옛 호스트가 매긴 번호가 새 호스트의 다른 플러그인을 가리키면, 읽기가 + // 엉뚱한 파일로 조용히 간다. 프로세스마다 멀리 떨어진 자리에서 센다. + counter: Arc::new(AtomicU64::new(rand::random::() >> 16)), + connected: Arc::default(), + link: Arc::new(Link { + state: watch::channel(LinkState::Connecting { after: None }).0, + me: BridgePeer { + pid: std::process::id(), + version: env!("CARGO_PKG_VERSION").to_owned(), + build_id: options.build_id, + }, + secret_path: options.secret_path.or_else(secret::default_path), + shutdown: watch::channel(false).0, + }), + } + } + + fn next_request_id(&self) -> String { + format!("job-{}", self.counter.fetch_add(1, Ordering::Relaxed)) + } + + /// 포트를 쥔 이 프로세스가 플러그인과 중계를 받기 시작한다. + fn serve( + &self, + listener: std::net::TcpListener, + expecting: Option, + ) -> io::Result<()> { + listener.set_nonblocking(true)?; + let listener = TcpListener::from_std(listener)?; + let app = Router::new() + .route("/plugin", any(plugin_socket)) + .route("/relay", any(relay::relay_socket)) + .with_state(self.clone()); + let mut shutdown = self.link.shutdown_signal(); + self.connected.store(0, Ordering::Relaxed); + self.link.set(LinkState::Host { + expecting, + since: Instant::now(), + }); + tokio::spawn(async move { + // 닫으라는 신호가 오면 듣기를 멈추고 포트를 놓는다. 이어받을 프로세스가 곧장 + // bind 할 수 있어야 한다. + let _ = axum::serve(listener, app) + .with_graceful_shutdown(async move { shut_down(&mut shutdown).await }) + .await; + }); + Ok(()) + } + + /// 플러그인 목록이 바뀌었다. 세고, 중계들에게 알리고, 기다리는 쪽을 깨운다. + fn plugins_changed(&self, inner: &mut Inner) { + self.connected.store(inner.plugins.len(), Ordering::Relaxed); + if !inner.relays.is_empty() { + let message = relay::files_message(&inner.plugins); + inner + .relays + .retain(|_, outbox| outbox.send(message.clone()).is_ok()); + } + self.link.touch(); + } + + /// 역할이 정해질 때까지, 그리고 방금 호스트가 바뀌었다면 플러그인이 다시 붙을 + /// 때까지 기다린다. 둘 다 짧고, 한도가 있다. + /// + /// 그 사이에 판정하면 "플러그인이 없다"가 된다. 그러면 읽기는 요금이 드는 경로로 + /// 넘어가고, status 는 로그인하라고 한다 — 플러그인이 2초 뒤면 다시 붙는데도. + async fn settle(&self) { + let deadline = tokio::time::Instant::now() + SETTLE_TIMEOUT; + let mut changes = self.link.state.subscribe(); + loop { + let until = match &*changes.borrow_and_update() { + LinkState::Connecting { .. } => Some(deadline), + LinkState::Host { + expecting: Some(_), + since, + } + | LinkState::Relay { + expecting: Some(_), + since, + .. + } if self.connected.load(Ordering::Relaxed) == 0 => { + Some(deadline.min(tokio::time::Instant::from_std(*since + REATTACH_GRACE))) + } + _ => None, + }; + let Some(until) = until else { return }; + if !matches!( + tokio::time::timeout_at(until, changes.changed()).await, + Ok(Ok(())) + ) { + return; + } + } + } + + /// 이 프로세스에서 보이는 플러그인, 연결 번호와 함께 붙은 순서대로. + async fn view(&self, link: &LinkState) -> Vec<(u64, AttachedFile)> { + match link { + LinkState::Host { .. } => { + let inner = self.inner.lock().await; + let mut plugins: Vec<_> = inner.plugins.iter().collect(); + plugins.sort_unstable_by_key(|(id, _)| **id); + plugins + .into_iter() + .map(|(id, plugin)| (*id, plugin.attached())) + .collect() + } + LinkState::Relay { files, .. } => files.clone(), + LinkState::Connecting { .. } | LinkState::Unavailable { .. } => Vec::new(), + } + } + + /// 지금 이 파일을 맡을 플러그인이 보이는지. 기다리지 않는다. + async fn sees(&self, file_key: &str) -> bool { + let link = self.link.current(); + let view = self.view(&link).await; + resolve(keys_in(&view), file_key).is_some() + } + + /// 이 파일을 열어 둔 플러그인이 있는지. 포트를 쥔 프로세스를 통해 보이는 것도 센다. + pub async fn has_plugin(&self, file_key: &str) -> bool { + self.settle().await; + self.sees(file_key).await + } + + /// 끊긴 읽기의 플러그인이 돌아오기를 기다린다. 돌아왔으면 `true`. + async fn plugin_returns(&self, file_key: &str) -> bool { + let until = tokio::time::Instant::now() + RETURN_WAIT; + let mut changes = self.link.state.subscribe(); + loop { + changes.borrow_and_update(); + if self.sees(file_key).await { + return true; + } + if !matches!( + tokio::time::timeout_at(until, changes.changed()).await, + Ok(Ok(())) + ) { + return self.sees(file_key).await; + } + } + } + + /// 붙어 있는 플러그인이 보고한 파일 키. 보고하지 못한 플러그인은 빈 문자열이다. + pub async fn connected_files(&self) -> Vec { + let mut keys: Vec = self + .attached_files() + .await + .into_iter() + .map(|file| file.file_key.unwrap_or_default()) + .collect(); + keys.sort(); + keys + } + + /// 붙어 있는 플러그인 전부를 붙은 순서대로. 어느 프로세스에서 물어도 같다 — + /// 포트를 쥐지 못한 프로세스는 쥔 프로세스가 보낸 목록을 그대로 보인다. + pub async fn attached_files(&self) -> Vec { + self.settle().await; + let link = self.link.current(); + self.view(&link) + .await + .into_iter() + .map(|(_, file)| file) + .collect() + } + + /// 아직 플러그인의 답을 기다리는 읽기 수. 요청한 쪽이 사라지면 그 읽기가 걷히는지 + /// 테스트가 확인한다. + #[doc(hidden)] + pub fn pending_reads(&self) -> usize { + self.pending.lock().map_or(0, |pending| pending.len()) + } + + async fn path_snapshot(&self, port: Option) -> BridgePathSnapshot { + self.settle().await; + let link = self.link.current(); + let attached_files: Vec = self + .view(&link) + .await + .into_iter() + .map(|(_, file)| file) + .collect(); + let recently = |expecting: &Option, since: &Instant| { + expecting + .clone() + .filter(|_| attached_files.is_empty() && since.elapsed() < REATTACH_GRACE) + }; + let (role, host, issue, handover_from) = match &link { + LinkState::Host { expecting, since } => ( + BridgeRole::Host, + Some(self.link.me.clone()), + None, + recently(expecting, since), + ), + LinkState::Relay { + connection, + expecting, + since, + .. + } => ( + BridgeRole::Relay, + Some(connection.host.clone()), + None, + recently(expecting, since), + ), + LinkState::Connecting { after } => (BridgeRole::Connecting, None, None, after.clone()), + LinkState::Unavailable { issue, holder } => ( + BridgeRole::Unavailable, + holder.clone(), + Some(issue.clone()), + None, + ), + }; + BridgePathSnapshot { + port, + attached_files, + role, + host, + issue, + handover_from, + } + } + + /// 읽기 하나를 플러그인에 보낸다. 포트를 쥐었으면 곧장, 아니면 쥔 프로세스를 통해. + /// + /// 도중에 길이 끊기면 — 플러그인 창이 다시 붙었거나, 포트를 쥔 호스트가 떠나 + /// 다른 프로세스가 이어받았으면 — 플러그인이 돌아오기를 잠깐 기다렸다가 다시 + /// 보낸다. 읽기는 문서를 바꾸지 않으므로 두 번 돌아도 해가 없고, 호출자는 인계가 + /// 있었는지 모른 채 답을 받는다. 수집 한가운데서 호스트 세션이 끝나도 그 수집은 + /// 이어진다. + async fn dispatch( + &self, + file_key: &str, + script: &str, + params: Value, + ) -> Result<(Value, BridgeServed), DevupError> { + let mut interruptions = 0; + loop { + self.settle().await; + let attempt = match self.link.current() { + LinkState::Host { .. } => { + self.dispatch_local(file_key, script, params.clone()).await + } + LinkState::Relay { connection, .. } => { + connection.dispatch(file_key, script, params.clone()).await + } + other => return Err(not_reachable(&other)), + }; + match attempt { + Ok(done) => return Ok(done), + Err(Failure::Final(error)) => return Err(error), + Err(Failure::Interrupted(error)) => { + interruptions += 1; + if interruptions > INTERRUPTIONS || !self.plugin_returns(file_key).await { + return Err(error); + } + } + } + } + } + + /// 이 프로세스에 붙은 플러그인으로 한 번 보낸다. + async fn dispatch_local( + &self, + file_key: &str, + script: &str, + params: Value, + ) -> Result<(Value, BridgeServed), Failure> { + let request_id = self.next_request_id(); + let budget = job_timeout(script, ¶ms); + let is_asset = job_is_asset_read(script, ¶ms); + let requested_node = params["nodeId"].as_str().unwrap_or("unknown").to_owned(); + let (tx, rx) = oneshot::channel(); + + let served = { + let mut inner = self.inner.lock().await; + let Some((id, plugin)) = resolve(keys_of(&inner.plugins), file_key) + .and_then(|id| inner.plugins.get(&id).map(|plugin| (id, plugin))) + else { + return Err(Failure::Final(unavailable(format!( + "no Devup Bridge plugin is open for {}", + describe_file(file_key) + )))); + }; + let job = Job { + kind: "devup-job", + request_id: request_id.clone(), + script, + params, + }; + let encoded = serde_json::to_string(&job).map_err(|error| { + Failure::Final(unavailable(format!("bridge job encode failed: {error}"))) + })?; + // 답이 보내기보다 먼저 올 수는 없지만, 대기표는 보내기 전에 둔다. + self.pending + .lock() + .expect("the pending table is never poisoned") + .insert( + request_id.clone(), + Pending { + plugin: id, + outbox: plugin.outbox.clone(), + waiter: tx, + }, + ); + let sent = plugin.outbox.send(encoded).is_ok(); + let served = plugin.served(); + if !sent { + // 소켓이 막 닫혔다. 등록을 지워 다음 호출이 곧장 폴백하도록 한다. + inner.plugins.remove(&id); + self.plugin_left(&mut inner, id); + return Err(Failure::Interrupted(unavailable(format!( + "the Devup Bridge plugin for {} disconnected", + describe_file(file_key) + )))); + } + served + }; + + // 성공이든 실패든, 기다리던 쪽이 도중에 사라지든 대기표는 반드시 걷는다. + // 답을 받지 못한 채 걷으면 플러그인에도 그 작업을 거두라고 알린다. + let _awaiting = Awaiting { + pending: &self.pending, + request_id, + }; + match timeout(budget, rx).await { + Ok(Ok(result)) => match (result.data, result.error) { + // 스크립트가 던진 DEVUP_* 코드는 그대로 올린다. 원격 경로와 같은 + // 문자열이어야 위쪽 분기가 동일하게 동작한다. + (_, Some(message)) => Err(Failure::Final(script_failure(message))), + (Some(data), None) => Ok((data, served)), + (None, None) => Err(Failure::Final(unavailable( + "bridge returned neither data nor error", + ))), + }, + // 작업을 받은 플러그인 창이 끊겼다. + Ok(Err(_)) => Err(Failure::Interrupted(unavailable( + "the Devup Bridge plugin disconnected mid-read", + ))), + Err(_) => Err(Failure::Final(unavailable(format!( + "{}Devup Bridge {script} did not answer after {} ms (node: {requested_node})", + if is_asset { + "DEVUP_ASSET_TIMEOUT: " + } else { + "" + }, + budget.as_millis() + )))), + } + } + + /// 플러그인 하나가 떠났다(등록은 이미 지웠다). 그 플러그인이 받아 둔 읽기는 더 + /// 답이 오지 않으니 곧장 끝낸다 — 90초를 기다리게 하지 않는다. + fn plugin_left(&self, inner: &mut Inner, id: u64) { + if let Ok(mut pending) = self.pending.lock() { + pending.retain(|_, waiting| waiting.plugin != id); + } + self.plugins_changed(inner); + } +} + +pub(super) fn job_timeout(script: &str, params: &Value) -> Duration { + if job_is_asset_read(script, params) { + let options = if script == "assets" { + ¶ms["asset"] + } else { + ¶ms["largeValue"] + }; + let millis = options["timeoutMs"] + .as_u64() + .filter(|v| (1000..=600000).contains(v)) + .unwrap_or(90000); + Duration::from_millis(millis) + Duration::from_secs(5) + } else { + JOB_TIMEOUT + } +} + +pub(super) fn job_is_asset_read(script: &str, params: &Value) -> bool { + script == "assets" + || (script == "largeValue" + && params["largeValue"]["field"] + .as_str() + .is_some_and(|field| field.starts_with("$export:"))) +} + +/// 한 번에 담을 수 있는 양. 로컬 소켓이라 공식 MCP 의 자름을 따를 이유가 없다. +/// +/// 기본값(봉투 19KB · 필드 4KB)은 공식 MCP 가 텍스트 결과를 20,480 바이트에서 +/// 자르기 때문에 생긴 것이다. 그 탓에 화면 하나가 30번 넘는 왕복으로 쪼개지고, +/// 창이 뒤로 가 있으면 왕복 하나가 60초까지 걸린다 — 실측한 값이다. +pub(crate) const BRIDGE_ENVELOPE_BYTES: usize = 8 * 1024 * 1024; +const BRIDGE_FIELD_BYTES: usize = 4 * 1024 * 1024; + +/// 스냅샷 읽기의 한도를 브리지 전송에 맞게 올린다. +/// +/// 값만 바꿀 뿐 무엇을 읽을지는 그대로다. 스크립트는 한 페이지에 다 담기면 +/// `complete` 로 표시하고, 큰 필드도 잘리지 않으므로 수집기가 그 필드를 따로 +/// 받으러 가지 않는다. 두 왕복이 한 왕복이 되는 것이 아니라 서른이 하나가 된다. +fn widen_budgets(params: &mut Value) { + let Some(object) = params.as_object_mut() else { + return; + }; + let Some(snapshot) = object.get_mut("snapshot").and_then(Value::as_object_mut) else { + return; + }; + snapshot.insert("maxEnvelopeBytes".to_owned(), BRIDGE_ENVELOPE_BYTES.into()); + snapshot.insert("maxPayloadBytes".to_owned(), BRIDGE_ENVELOPE_BYTES.into()); + snapshot.insert("maxFieldBytes".to_owned(), BRIDGE_FIELD_BYTES.into()); +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index b6605288..4861fa33 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -1,3233 +1,3 @@ -use std::{ - collections::{BTreeMap, BTreeSet, VecDeque}, - io::Cursor, -}; - -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use image::{ImageDecoder, ImageError, Limits, codecs::png::PngDecoder}; -use rmcp::model::{CallToolResult, ContentBlock}; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; -use sha2::{Digest, Sha256}; - -use crate::large_values::{ - LargeValueResult, descriptors_in_chunk, large_value_from_result, replace_descriptor, -}; -use crate::{ - AssetExportOutcome, AssetManifestEntry, AssetRequest, AssetSelection, AssetStatus, BatchBudget, - BatchLimits, BuiltinScript, DevupError, ErrorCode, ExploreReadOptions, FigmaTarget, - LargeValueAssembler, LargeValueReadOptions, RawNode, ReadToolCall, ResourceBatch, - ResourceScope, ResourceStyleRef, 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, exported_asset_from_bytes, - merge_chunks, - metadata::{MetadataResult, metadata_from_result_for_target}, - plan_batches, read_snapshot_cursor, resolve_asset_selections, snapshot_chunk_from_result, - variables::{ - VariableBatchResult, VariableCatalog, batch_from_result, catalog_from_result, - join_used_resources, library_refs, merge_used_resource_results, merge_variable_results, - }, -}; - -const LARGE_SUBTREE_THRESHOLD: usize = 200; - -const MAX_PENDING_CALLS: usize = 4; -pub const MAX_COLLECTED_ASSET_BYTES: usize = 128 * 1024 * 1024; -// The byte budget below measures the *request* — the IDs — and the ceiling -// that matters is on the *response*: the official MCP cuts a text result at -// 20,480 UTF-8 bytes (measured; see `scripts/fast_snapshot.js`) and appends -// "// truncated to 20kb", which the batch decoder then cannot parse. A variable serialised with its valuesByMode and -// codeSyntax is five hundred to a thousand bytes on the way back, so the -// sixty-four this allowed could not fit; thirty-eight were measured filling -// the cap exactly. Twelve leaves the response near a third of it. -// 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 MAX_REFERENCE_PNG_BYTES: usize = 16 * 1024 * 1024; -const MAX_REFERENCE_PNG_BASE64_BYTES: usize = MAX_REFERENCE_PNG_BYTES.div_ceil(3) * 4; -pub(crate) const MAX_REFERENCE_PNG_DIMENSION: u32 = 8_192; -const MAX_REFERENCE_PNG_DECODED_BYTES: usize = 64 * 1024 * 1024; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum CollectionScope { - Node, - Page, - File, -} - -#[derive(Debug, Clone)] -pub struct CollectionRequest { - /// 이 수집을 실어 나를 전송이 한 번에 받을 수 있는 양. - pub batch_budget: BatchBudget, - pub target: FigmaTarget, - pub scope: CollectionScope, - pub resource_scope: ResourceScope, - pub include_context: bool, - pub metadata_only: bool, - pub variables_only: bool, - pub search: Option, - pub explore: Option, - pub section: Option, - pub cached_section_index: Option, - pub asset_selections: Vec, - pub web_ready_images: bool, - pub reference_png: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SectionReadOptions { - #[serde(default)] - pub frame_ids: Vec, - #[serde(default)] - pub all_screens: bool, -} - -impl CollectionRequest { - pub fn new(target: FigmaTarget, scope: CollectionScope) -> Self { - Self { - target, - scope, - // 자르는 전송을 전제로 한 값. 브리지로 수집할 때는 호출자가 - // `FigmaUpstream::batch_budget` 에서 받아 덮어쓴다. - batch_budget: BatchBudget::default(), - resource_scope: ResourceScope::None, - include_context: false, - metadata_only: false, - variables_only: false, - search: None, - explore: None, - section: None, - cached_section_index: None, - asset_selections: Vec::new(), - web_ready_images: false, - reference_png: false, - } - } -} - -#[derive(Debug, Clone)] -pub struct PlannedCall { - pub id: String, - pub call: ReadToolCall, - pub expected_file_key: String, - pub expected_node_id: Option, -} - -#[derive(Debug, Clone)] -pub struct CollectedParts { - pub target: FigmaTarget, - pub scope: CollectionScope, - pub metadata: Value, - pub snapshot_chunks: Vec, - pub variables: Option, - pub styles: Option, - pub source_version: Option, - 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)] -#[serde(rename_all = "camelCase")] -pub struct ReferencePng { - pub mime_type: String, - pub data_base64: String, - pub byte_length: usize, - pub sha256: String, - /// The Devup Bridge plugin's word when its picture may hold layers - /// `get_screenshot`'s leaves out. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub note: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CollectionStats { - pub figma_tool_calls: usize, - pub transport: String, - pub fallback_used: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fallback_reason: Option, - pub node_count: usize, - pub variable_count: usize, - pub style_count: usize, - pub raw_bytes: usize, - pub wire_bytes: usize, - pub envelope_chunks: usize, - /// Set when the Devup Bridge plugin answered any of `figma_tool_calls`, - /// naming the plugin; the rest went to Figma's metered MCP. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bridge: Option, - /// Why reads the Devup Bridge plugin answered were read again on the - /// metered path, one entry per read: `file-out-of-sight`, - /// `reference-png-approximate`, `plugin-older-than-server`, - /// `plugin-answer-too-large`, `value-from-direct-path`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub direct_fallbacks: Vec, -} - -impl Default for CollectionStats { - fn default() -> Self { - Self { - figma_tool_calls: 0, - // 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, - variable_count: 0, - style_count: 0, - raw_bytes: 0, - wire_bytes: 0, - envelope_chunks: 0, - bridge: None, - direct_fallbacks: Vec::new(), - } - } -} - -#[derive(Debug, Clone)] -pub enum CollectorStep { - Call(Box), - AwaitingResults, - Complete(Box), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CallKind { - FastSnapshot, - FastTheme, - Metadata, - PageCatalog, - Snapshot, - VariableCatalog, - VariableBatch, - UsedResourceBatch, - Explore, - ScopedSearch, - SectionIndex, - FastMultiRoot, - LargeValue, - Asset, - Screenshot, -} - -#[derive(Debug, Clone)] -struct PendingCall { - planned: PlannedCall, - kind: CallKind, - order: usize, -} - -#[derive(Debug, Clone)] -pub struct CollectorSession { - request: CollectionRequest, - queued: VecDeque, - pending: BTreeMap, - consumed: BTreeSet, - metadata: Option, - metadata_nodes: BTreeMap, - metadata_root_ids: Vec, - root_node_id: Option, - source_version: Option, - snapshot_chunks: BTreeMap, - variable_catalog: Option, - /// A file's own variables and styles, read from its catalog, while the - /// library ones its nodes use are read. - file_local_resources: Option, - used_resource_refs: Option, - variable_batches: BTreeMap, - variables: Option, - large_values: BTreeMap<(String, String), LargeValueAssembler>, - /// An SVG or PNG export announced as too large for one answer, by the - /// key its fragments arrive under, until the last of them has. - chunked_assets: - BTreeMap<(String, String), (AssetRequest, Option)>, - /// The pages of a paginated theme so far, merged, until the last one. - fast_theme_pages: Option, - fast_theme_page_count: usize, - asset_results: Vec, - retained_asset_bytes: usize, - asset_byte_budget_exhausted: bool, - assets_scheduled: bool, - asset_plan_initialized: bool, - asset_plan: VecDeque, - reference_png: Option, - reference_png_scheduled: bool, - stats: CollectionStats, - fast_attempted: bool, - section_index: Option, - 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, -} - -impl CollectorSession { - pub fn new(request: CollectionRequest) -> Self { - let assets_scheduled = request.asset_selections.is_empty() && !request.web_ready_images; - let reference_png_scheduled = !request.reference_png; - Self { - request, - queued: VecDeque::new(), - pending: BTreeMap::new(), - consumed: BTreeSet::new(), - metadata: None, - metadata_nodes: BTreeMap::new(), - metadata_root_ids: Vec::new(), - root_node_id: None, - source_version: None, - snapshot_chunks: BTreeMap::new(), - variable_catalog: None, - file_local_resources: None, - used_resource_refs: None, - variable_batches: BTreeMap::new(), - variables: None, - large_values: BTreeMap::new(), - chunked_assets: BTreeMap::new(), - fast_theme_pages: None, - fast_theme_page_count: 0, - asset_results: Vec::new(), - retained_asset_bytes: 0, - asset_byte_budget_exhausted: false, - assets_scheduled, - asset_plan_initialized: false, - asset_plan: VecDeque::new(), - reference_png: None, - reference_png_scheduled, - stats: CollectionStats::default(), - fast_attempted: false, - section_index: None, - 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, - } - } - - /// Validated per-asset acquisition results retained before final payload assembly. - /// Binary data is deliberately not part of progress responses. - pub fn asset_progress(&self) -> Vec { - self.request.asset_selections.iter().map(|selection| { - match self.asset_results.iter().find(|asset| asset.asset_id == selection.asset_id) { - Some(asset) => serde_json::json!({"assetId":asset.asset_id,"status":asset.status,"format":asset.format,"scale":asset.scale,"quality":asset.quality,"lossless":asset.lossless,"usageAspectRatio":asset.usage_aspect_ratio,"maxWidth":asset.max_width,"srcset":asset.srcset,"visibleScopeNodeId":asset.visible_scope_node_id, - "byteLength":asset.byte_length,"sha256":asset.sha256,"errorCode":asset.error_code,"errorDetail":asset.error_detail,"exportDiagnostics":asset.export_diagnostics,"renderMode":asset.render_mode,"renderInfo":asset.render_info}), - None => serde_json::json!({"assetId":selection.asset_id,"renderMode":selection.render_mode,"format":selection.format,"scale":selection.scale,"quality":selection.quality,"lossless":selection.lossless,"usageAspectRatio":selection.usage_aspect_ratio,"maxWidth":selection.max_width,"srcset":selection.srcset,"visibleScopeNodeId":selection.visible_scope_node_id,"status":"pending"}), - } - }).collect() - } - - pub fn stats(&self) -> &CollectionStats { - &self.stats - } - - pub fn advance(&mut self) -> Result { - if self.completed { - 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() - { - self.prepare_cached_section_index(index)?; - return self.advance(); - } - 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 requires a node ID."))?; - let mut call = ReadToolCall::section_index(&self.request.target.file_key, &node_id); - if let ReadToolCall::Snapshot { root_ids, .. } = &mut call { - *root_ids = self.request.section.as_ref().map(|s| s.frame_ids.clone()); - } - self.enqueue(call, Some(node_id), CallKind::SectionIndex); - return self.advance(); - } - if self.fast_theme_eligible() && !self.fast_attempted { - self.fast_attempted = true; - self.enqueue( - ReadToolCall::fast_theme(&self.request.target.file_key), - None, - CallKind::FastTheme, - ); - return self.advance(); - } - if let Some(options) = self.request.explore.clone() { - let node_id = self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Figma nearby-screen exploration requires a node ID.") - })?; - self.enqueue( - ReadToolCall::explore_snapshot( - &self.request.target.file_key, - &node_id, - options, - ), - Some(node_id), - CallKind::Explore, - ); - return self.advance(); - } - if let Some(options) = self.request.search.clone() { - if self.request.scope == CollectionScope::Node { - let node_id = self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Node-scoped Figma search requires a node ID.") - })?; - self.enqueue( - ReadToolCall::search_snapshot( - &self.request.target.file_key, - &node_id, - options, - ), - Some(node_id), - CallKind::ScopedSearch, - ); - return self.advance(); - } - self.enqueue( - ReadToolCall::page_catalog(&self.request.target.file_key), - None, - CallKind::PageCatalog, - ); - 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 requires a node ID."))?; - self.fast_attempted = true; - self.enqueue( - ReadToolCall::fast_snapshot(&self.request.target.file_key, &node_id), - Some(node_id), - CallKind::FastSnapshot, - ); - return self.advance(); - } - let node_id = match self.request.scope { - CollectionScope::File => None, - CollectionScope::Node | CollectionScope::Page => { - self.request.target.node_id.as_deref() - } - }; - self.enqueue( - ReadToolCall::metadata(&self.request.target.file_key, node_id), - node_id.map(str::to_owned), - CallKind::Metadata, - ); - } - let pending_limit = if self.request.section.is_some() { - 2 - } else { - MAX_PENDING_CALLS - }; - if self.pending.len() < pending_limit - && let Some(call) = self.queued.pop_front() - { - self.pending.insert(call.planned.id.clone(), call.clone()); - self.stats.figma_tool_calls = self.stats.figma_tool_calls.saturating_add(1); - return Ok(CollectorStep::Call(Box::new(call.planned))); - } - if !self.pending.is_empty() { - return Ok(CollectorStep::AwaitingResults); - } - if self.request.section.is_some() - && !self.section_selected_roots.is_empty() - && self.variables.is_none() - && self.section_fallback_roots.is_empty() - && !self.fast_multi_has_large_values - && let Some(resources) = self.fast_multi_resources.take() - { - self.variables = Some(resources); - } - if self.metadata.is_some() - && !self.assets_scheduled - && !self.snapshot_chunks.is_empty() - && self.queued.is_empty() - { - let snapshot = merge_chunks(self.snapshot_chunks.values().cloned().collect())?; - if !self.asset_plan_initialized { - self.asset_plan_initialized = true; - if self.request.web_ready_images { - let census = crate::discover_web_ready_assets(&snapshot); - self.request.asset_selections = census - .iter() - .map(|a| AssetSelection { - usage_aspect_ratio: None, - max_width: snapshot - .nodes - .get(&a.node_id) - .and_then(|n| n.typed_view().number("width")) - .filter(|w| w.is_finite() && *w > 0.0) - .map(|w| (w * 2.0).ceil().clamp(1.0, 8192.0) as u32), - srcset: false, - visible_scope_node_id: None, - asset_id: a.asset_id.clone(), - format: crate::AssetFormat::Webp, - scale: 2.0, - render_mode: crate::AssetRenderMode::OriginalSource, - quality: Some(85), - lossless: Some(false), - }) - .collect(); - for (index, asset) in census.iter().enumerate() { - if asset.image_hash.is_some() && index < 4096 { - continue; - } - let mut failure = asset.clone(); - failure.status = AssetStatus::Failed; - if index >= 4096 { - failure.error_code = Some("DEVUP_ASSET_PLAN_LIMIT".into()); - failure.error_detail=Some("Preset scheduling is bounded to4096 IMAGE sources; recollect failed IDs in a smaller scope.".into()); - } else if asset.image_hash.is_none() { - failure.error_code = Some("DEVUP_ORIGINAL_SOURCE_UNAVAILABLE".into()); - failure.error_detail = Some( - "The acquired IMAGE paint has no resolvable source hash.".into(), - ); - } - failure.render_mode = crate::AssetRenderMode::OriginalSource; - failure.format = Some(crate::AssetFormat::Webp); - failure.scale = Some(2.0); - failure.max_width = self.request.asset_selections[index].max_width; - failure.quality = Some(85); - failure.lossless = Some(false); - self.asset_results.push(failure); - } - } - let selections = self - .request - .asset_selections - .iter() - .filter(|s| !self.asset_results.iter().any(|a| a.asset_id == s.asset_id)) - .cloned() - .collect::>(); - for batch in selections.chunks(6) { - self.asset_plan - .extend(resolve_asset_selections(&snapshot, batch)?); - } - } - // A later batch is scheduled only after every call/fragment in this - // batch has settled. The ordinary pending-call bound still applies. - if self.asset_byte_budget_exhausted { - while let Some(request) = self.asset_plan.pop_front() { - self.record_asset_failure(request, "DEVUP_ASSET_TOTAL_BYTES_EXCEEDED", Some("The 128 MiB retained asset budget was reached. Export failed IDs in smaller custom batches or select a smaller frame.".into())); - } - } - let mut nodes = BTreeSet::new(); - let mut batch = Vec::new(); - let remaining = self.asset_plan.len(); - for _ in 0..remaining { - let request = self.asset_plan.pop_front().unwrap(); - if batch.len() < 6 && nodes.insert(request.node_id.clone()) { - batch.push(request); - } else { - self.asset_plan.push_back(request); - } - } - self.assets_scheduled = self.asset_plan.is_empty(); - for request in batch { - if self.asset_byte_budget_exhausted { - self.record_asset_failure(request, "DEVUP_ASSET_TOTAL_BYTES_EXCEEDED", Some("The 128 MiB retained asset budget was reached. Export the failed asset IDs in smaller custom batches or select a smaller frame.".into())); - continue; - } - self.enqueue( - ReadToolCall::asset_export( - &self.request.target.file_key, - self.source_version.clone(), - request.clone(), - ), - Some(request.node_id.clone()), - CallKind::Asset, - ); - } - if !self.queued.is_empty() { - return self.advance(); - } - } - if self.metadata.is_some() - && !self.reference_png_scheduled - && !self.snapshot_chunks.is_empty() - && self.queued.is_empty() - { - let node_id = self.request.target.node_id.clone().ok_or_else(|| { - invalid_call("Figma reference PNG collection requires a node ID.") - })?; - self.reference_png_scheduled = true; - self.enqueue( - ReadToolCall::screenshot(&self.request.target.file_key, &node_id), - Some(node_id), - CallKind::Screenshot, - ); - return self.advance(); - } - if self.metadata.is_some() - && self.request.resource_scope == ResourceScope::File - && self.variable_catalog.is_none() - && self.file_local_resources.is_none() - && self.variables.is_none() - && self.queued.is_empty() - { - 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, - &node_id, - BuiltinScript::VariableCatalog, - ), - Some(node_id), - CallKind::VariableCatalog, - ); - return self.advance(); - } - if self.metadata.is_some() - && self.request.resource_scope == ResourceScope::File - && self.variable_catalog.is_some() - && self.variables.is_none() - && self.queued.is_empty() - { - let catalog = self - .variable_catalog - .take() - .ok_or_else(|| invalid_call("Figma variable catalog is missing."))?; - let local = merge_variable_results( - catalog, - std::mem::take(&mut self.variable_batches).into_values(), - )?; - // The catalog is the file's own variables and styles. The library - // ones its nodes use, and those its variables alias, were left - // out, so a theme of the whole file knew only the local half: a - // local spacing aliasing a library primitive had no value. They - // are read as a used-resource read reads them and joined to it. - let chunks = self.snapshot_chunks.values().cloned().collect::>(); - let refs = library_refs(&local, collect_used_resource_refs(&chunks)); - if refs.variable_ids.is_empty() && refs.styles.is_empty() { - self.variables = Some(join_used_resources(local, None)); - } else { - let node_id = self.root_node_id.clone().ok_or_else(|| { - invalid_call("No root node ID available for the used Figma resource batch.") - })?; - for batch in used_resource_batches(&refs, self.request.batch_budget)? { - self.enqueue( - ReadToolCall::used_resources( - &self.request.target.file_key, - &node_id, - batch, - ), - Some(node_id.clone()), - CallKind::UsedResourceBatch, - ); - } - self.used_resource_refs = Some(refs); - self.file_local_resources = Some(local); - return self.advance(); - } - } - if self.metadata.is_some() - && self.request.resource_scope == ResourceScope::File - && self.file_local_resources.is_some() - && self.variables.is_none() - && self.queued.is_empty() - { - let refs = self - .used_resource_refs - .take() - .ok_or_else(|| invalid_call("Used Figma resource references are missing."))?; - let local = self - .file_local_resources - .take() - .ok_or_else(|| invalid_call("The file's own Figma resources are missing."))?; - let merged = merge_used_resource_results( - &refs, - std::mem::take(&mut self.variable_batches).into_values(), - )?; - self.record_unresolved_diagnostics(&refs, &merged.unresolved); - self.variables = Some(join_used_resources(local, Some(merged.result))); - } - if self.metadata.is_some() - && self.request.resource_scope == ResourceScope::Used - && self.used_resource_refs.is_none() - && self.variables.is_none() - && self.queued.is_empty() - { - self.enqueue_used_resource_batches()?; - if !self.queued.is_empty() { - return self.advance(); - } - } - if self.metadata.is_some() - && self.request.resource_scope == ResourceScope::Used - && self.used_resource_refs.is_some() - && self.variables.is_none() - && self.queued.is_empty() - { - let refs = self - .used_resource_refs - .take() - .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(), - )?; - self.record_unresolved_diagnostics(&refs, &merged.unresolved); - let mut result = Some(merged.result); - if !self.section_fallback_roots.is_empty() - && !self.fast_multi_has_large_values - && let Some(fast_resources) = self.fast_multi_resources.take() - { - let mut combined = Some(fast_resources); - merge_fast_resources( - &mut combined, - result - .take() - .ok_or_else(|| invalid_call("fallback resource result is missing."))?, - )?; - result = combined; - } - self.variables = result; - } - if self.metadata.is_some() && self.queued.is_empty() { - self.completed = true; - let snapshot_chunks = if self.request.metadata_only || self.request.variables_only { - vec![SnapshotChunk { - file_key: self.request.target.file_key.clone(), - version: self.source_version.clone(), - root_ids: std::mem::take(&mut self.metadata_root_ids), - nodes: std::mem::take(&mut self.metadata_nodes) - .into_values() - .collect(), - diagnostics: Vec::new(), - }] - } else { - let chunks = std::mem::take(&mut self.snapshot_chunks) - .into_values() - .collect(); - self.restore_section_visual_order(chunks)? - }; - self.finish_stats(&snapshot_chunks); - return Ok(CollectorStep::Complete(Box::new(CollectedParts { - target: self.request.target.clone(), - scope: self.request.scope, - metadata: self.metadata.take().unwrap_or(Value::Null), - snapshot_chunks, - variables: self.variables.clone(), - styles: self.variables.clone(), - source_version: self.source_version.clone(), - 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) - } - - pub fn accept(&mut self, call_id: &str, result: UpstreamResult) -> Result<(), DevupError> { - let pending = self - .pending - .remove(call_id) - .ok_or_else(|| invalid_call("Unknown or already-handled Figma call ID."))?; - self.consumed.insert(call_id.to_owned()); - if let Some(served) = crate::BridgeServed::from_result(&result.raw) { - let reads = self.stats.bridge.as_ref().map_or(0, |bridge| bridge.reads) + served.reads; - self.stats.bridge = Some(crate::BridgeServed { reads, ..served }); - } - if let Some(reason) = result.raw["_meta"][crate::bridge::DIRECT_FALLBACK_META_KEY].as_str() - { - self.stats.direct_fallbacks.push(reason.to_owned()); - } - match pending.kind { - CallKind::FastSnapshot => { - self.accept_fast_snapshot(&pending.planned, pending.order, result) - } - CallKind::FastTheme => self.accept_fast_theme(result), - CallKind::Metadata => self.accept_metadata(&pending.planned, result), - CallKind::PageCatalog => self.accept_page_catalog(&pending.planned, result), - CallKind::Snapshot => self.accept_snapshot(&pending.planned, pending.order, result), - CallKind::VariableCatalog => self.accept_variable_catalog(result), - CallKind::VariableBatch => { - let batch = batch_from_result(&result)?; - self.enqueue_style_consumer_batches(&batch)?; - self.variable_batches.insert(pending.order, batch); - Ok(()) - } - CallKind::UsedResourceBatch => { - let batch = batch_from_result(&result)?; - self.variable_batches.insert(pending.order, batch); - Ok(()) - } - CallKind::Explore | CallKind::ScopedSearch => { - self.accept_projection(&pending.planned, pending.order, result) - } - CallKind::SectionIndex => { - self.accept_section_index(&pending.planned, pending.order, result) - } - CallKind::FastMultiRoot => { - self.accept_fast_multi_root(&pending.planned, pending.order, result) - } - CallKind::LargeValue => self.accept_large_value(&pending.planned, result), - CallKind::Asset => self.accept_asset(&pending.planned, result), - CallKind::Screenshot => self.accept_reference_png(&pending.planned, result), - } - } - - pub fn reject(&mut self, call_id: &str, error: &DevupError) -> Result { - let Some(pending) = self.pending.get(call_id) else { - 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 is missing."))?; - self.consumed.insert(call_id.to_owned()); - let ReadToolCall::AssetExport { request, .. } = pending.planned.call else { - return Err(invalid_call("asset call format is invalid.")); - }; - self.record_asset_failure( - *request, - if error.message.contains("DEVUP_ASSET_TIMEOUT:") - || error.details["reason"] == "timeout" - { - "DEVUP_ASSET_TIMEOUT" - } else { - "DEVUP_ASSET_EXPORT_FAILED" - }, - Some(error.message.clone()), - ); - return Ok(true); - } - if pending.kind == CallKind::LargeValue { - let pending = self - .pending - .remove(call_id) - .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 format is invalid.")); - }; - self.record_large_value_unsupported( - &options, - "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM", - Some(&error.message), - )?; - 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 - ) { - return Ok(false); - } - if !fast_call_fallback_allowed(error) { - return Ok(false); - } - // A continuation already has accepted pages and possibly field reads - // in flight. Restarting at zero would mix them with a second capture. - if pending.kind == CallKind::FastMultiRoot - && matches!(&pending.planned.call, ReadToolCall::Snapshot { - snapshot: Some(options), .. - } if options.offset > 0) - { - return Ok(false); - } - let pending = self - .pending - .remove(call_id) - .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))?; - return Ok(true); - } - self.restart_legacy(fallback_category(error)); - Ok(true) - } - - fn fast_path_eligible(&self) -> bool { - self.request.scope == CollectionScope::Node - && self.request.resource_scope == ResourceScope::Used - && self.request.target.node_id.is_some() - && !self.request.include_context - && !self.request.metadata_only - && !self.request.variables_only - && self.request.search.is_none() - && self.request.explore.is_none() - && self.request.section.is_none() - } - - fn fast_theme_eligible(&self) -> bool { - self.request.scope == CollectionScope::File - && self.request.resource_scope == ResourceScope::File - && self.request.variables_only - && !self.request.metadata_only - && !self.request.include_context - && self.request.search.is_none() - && self.request.explore.is_none() - && self.request.section.is_none() - } - - fn accept_fast_theme(&mut self, result: UpstreamResult) -> Result<(), DevupError> { - let payload = match decode_fast_theme(&result, &self.request.target.file_key) { - Ok(payload) => payload, - Err(error) => { - self.restart_legacy(fallback_category(&error)); - return Ok(()); - } - }; - self.fast_theme_page_count += 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); - // A page is merged onto the pages before it: the resources are - // concatenated in the one order the script lists them, and the scan - // every page repeats - which ids are used, what could not be - // resolved - is taken from the page at hand. - let merged = match self.fast_theme_pages.take() { - None => payload.resources.raw, - Some(mut merged) => { - merge_theme_page(&mut merged, payload.resources.raw)?; - merged - } - }; - if let Some(page) = payload.page - && !page.complete - { - if page.next_offset <= page.offset { - return Err(invalid_call("Figma theme page cursor did not advance.")); - } - self.fast_theme_pages = Some(merged); - self.enqueue( - ReadToolCall::fast_theme_page(&self.request.target.file_key, page.next_offset), - None, - CallKind::FastTheme, - ); - return Ok(()); - } - // The remote variables among those collected, which a page does not - // list twice. - let mut merged = merged; - let remote = merged["variables"] - .as_array() - .map(|variables| { - variables - .iter() - .filter(|variable| { - variable.get("remote").and_then(Value::as_bool) == Some(true) - }) - .cloned() - .collect::>() - }) - .unwrap_or_default(); - if merged["usedRemoteVariables"] - .as_array() - .is_none_or(Vec::is_empty) - { - merged["usedRemoteVariables"] = Value::Array(remote); - } - self.metadata = Some(json!({ - "transport": payload.stats.transport, - "pageCount": self.fast_theme_page_count, - "collectionCount": merged["collections"].as_array().map_or(0, Vec::len), - "variableCount": merged["variables"].as_array().map_or(0, Vec::len), - "styleCount": merged["styles"].as_array().map_or(0, Vec::len) - })); - self.source_version = payload.source_version; - self.stats.transport = payload.stats.transport.to_owned(); - self.stats.envelope_chunks = self.fast_theme_page_count; - self.variables = Some(UpstreamResult { raw: merged }); - Ok(()) - } - - fn accept_fast_snapshot( - &mut self, - planned: &PlannedCall, - order: usize, - result: UpstreamResult, - ) -> Result<(), DevupError> { - let payload = match decode_fast_snapshot(&result, &self.request.target) { - Ok(payload) => payload, - Err(error) => { - if let Ok(chunk) = snapshot_chunk_from_result(&result) - && chunk.root_ids.as_slice() - == [self.request.target.node_id.as_deref().unwrap_or_default()] - && chunk.nodes.iter().any(|node| { - node.id == self.request.target.node_id.as_deref().unwrap_or_default() - && node.node_type == "SECTION" - && node.typed_view().value("projectionTruncated").is_some() - }) - { - self.request.section = Some(SectionReadOptions { - frame_ids: Vec::new(), - all_screens: false, - }); - return self.accept_section_index(planned, order, result); - } - self.restart_legacy(fallback_category(&error)); - return Ok(()); - } - }; - let root_id = self - .request - .target - .node_id - .clone() - .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.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(); - 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(()) - } - - fn restart_legacy(&mut self, reason: String) { - self.queued.clear(); - self.pending.clear(); - self.metadata = None; - self.metadata_nodes.clear(); - self.metadata_root_ids.clear(); - self.root_node_id = None; - self.source_version = None; - self.snapshot_chunks.clear(); - self.variable_catalog = None; - self.used_resource_refs = None; - 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.retained_asset_bytes = 0; - self.asset_byte_budget_exhausted = false; - self.assets_scheduled = - self.request.asset_selections.is_empty() && !self.request.web_ready_images; - self.asset_plan_initialized = false; - self.asset_plan.clear(); - self.reference_png = None; - self.reference_png_scheduled = !self.request.reference_png; - self.stats.transport = "legacy-cursor".to_owned(); - self.stats.fallback_used = true; - self.stats.fallback_reason = Some(reason); - self.stats.node_count = 0; - self.stats.variable_count = 0; - self.stats.style_count = 0; - self.stats.raw_bytes = 0; - self.stats.wire_bytes = 0; - self.stats.envelope_chunks = 0; - } - - fn finish_stats(&mut self, chunks: &[SnapshotChunk]) { - self.stats.node_count = chunks - .iter() - .flat_map(|chunk| chunk.nodes.iter().map(|node| node.id.as_str())) - .collect::>() - .len(); - if let Some(resources) = &self.variables { - self.stats.variable_count = resource_count(&resources.raw, "variables"); - self.stats.style_count = resource_count(&resources.raw, "styles"); - } - } - - fn accept_page_catalog( - &mut self, - planned: &PlannedCall, - result: UpstreamResult, - ) -> Result<(), DevupError> { - let catalog = snapshot_chunk_from_result(&result)?; - if catalog.file_key != planned.expected_file_key { - return Err(invalid_call( - "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 is empty.", - false, - )); - } - let options = self - .request - .search - .clone() - .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(); - self.metadata_root_ids = catalog.root_ids.clone(); - for page in catalog.nodes { - self.metadata_nodes.insert(page.id.clone(), page); - } - for page_id in catalog.root_ids { - self.enqueue( - ReadToolCall::search_snapshot( - &self.request.target.file_key, - &page_id, - options.clone(), - ), - Some(page_id), - CallKind::Snapshot, - ); - } - Ok(()) - } - - fn accept_projection( - &mut self, - planned: &PlannedCall, - order: usize, - result: UpstreamResult, - ) -> Result<(), DevupError> { - let chunk = snapshot_chunk_from_result(&result)?; - if chunk.file_key != planned.expected_file_key { - return Err(invalid_call( - "Figma 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 is missing."))?; - if !chunk.nodes.iter().any(|node| node.id == expected_node_id) { - return Err(DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "anchor node not found in the Figma projection.", - false, - )); - } - self.source_version = chunk.version.clone(); - self.root_node_id = Some(expected_node_id.to_owned()); - self.metadata_root_ids = chunk.root_ids.clone(); - self.metadata = Some(result.raw); - self.record_snapshot_chunk(order, chunk)?; - Ok(()) - } - - fn accept_section_index( - &mut self, - planned: &PlannedCall, - order: usize, - result: UpstreamResult, - ) -> Result<(), DevupError> { - let chunk = snapshot_chunk_from_result(&result)?; - if chunk.file_key != planned.expected_file_key { - return Err(invalid_call( - "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 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 does not match the requested Section.", - )); - } - let snapshot = merge_chunks(vec![chunk.clone()])?; - let index = build_section_index(&snapshot, &self.request.target)?; - let options = self - .request - .section - .clone() - .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(); - self.metadata = Some(json!({ - "transport": "section-index-v1", - "sectionIndex": &index - })); - self.section_index = Some(index.clone()); - - if options.frame_ids.is_empty() && !options.all_screens { - self.request.resource_scope = ResourceScope::None; - self.variables = Some(empty_used_resources()); - self.record_snapshot_chunk(order, chunk)?; - return Ok(()); - } - - let selected = index.select(&options.frame_ids, options.all_screens)?; - let batches = plan_batches(&index, &selected, BatchLimits::default())?; - self.section_selected_roots = selected.clone(); - self.root_node_id = selected.first().cloned(); - self.metadata_root_ids = selected.clone(); - self.metadata = Some(json!({ - "transport": "section-multi-root-v1", - "sectionIndex": &index, - "selectedRootIds": &selected, - "batchCount": batches.len() - })); - for batch in batches { - if batch.oversized { - self.mark_section_legacy("oversized-section-root".to_owned()); - for root_id in batch.root_ids { - self.enqueue_section_legacy_root(root_id); - } - } else { - 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(()) - } - - fn prepare_cached_section_index(&mut self, index: SectionIndex) -> Result<(), DevupError> { - if index.file_key != self.request.target.file_key - || self.request.target.node_id.as_deref() != Some(index.section.node_id.as_str()) - { - return Err(invalid_call( - "cached Section index does not match the requested Section.", - )); - } - let options = self - .request - .section - .clone() - .ok_or_else(|| invalid_call("cached Section index has no selection options."))?; - // Compact indexes only retain ownership for IDs queried during that - // request. A newly requested descendant needs a fresh index query. - if options.frame_ids.iter().any(|id| { - !index - .candidates - .iter() - .any(|candidate| candidate.node_id == *id) - && !index.node_screen_ids.contains_key(id) - }) { - return Ok(()); - } - 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(); - self.section_selected_roots = selected.clone(); - self.root_node_id = selected.first().cloned(); - self.metadata_root_ids = selected.clone(); - self.metadata = Some(json!({ - "transport": "section-multi-root-v1", - "sectionIndex": &index, - "selectedRootIds": &selected, - "batchCount": batches.len(), - "indexCacheHit": true - })); - self.section_index = Some(index); - let section_id = self - .request - .target - .node_id - .clone() - .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()); - for root_id in batch.root_ids { - self.enqueue_section_legacy_root(root_id); - } - } else { - 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(()) - } - - fn accept_fast_multi_root( - &mut self, - planned: &PlannedCall, - order: usize, - result: UpstreamResult, - ) -> Result<(), DevupError> { - let ReadToolCall::Snapshot { - script: BuiltinScript::MultiRootSnapshotEnvelope, - root_ids: Some(root_ids), - snapshot: options, - .. - } = &planned.call - else { - return Err(invalid_call("multi-root snapshot call format is invalid.")); - }; - let requested_offset = options.as_ref().map_or(0, |options| options.offset); - let payload = match decode_fast_multi_snapshot(&result, &self.request.target, root_ids) { - Ok(payload) => payload, - Err(error) if requested_offset == 0 && fast_call_fallback_allowed(&error) => { - self.fallback_multi_root_batch(planned, fallback_category(&error))?; - return Ok(()); - } - Err(error) => return Err(error), - }; - let mut chunk = payload.snapshot; - let cursor = take_snapshot_cursor(&mut chunk)?; - if let Some(cursor) = &cursor { - if cursor.offset != requested_offset - || requested_offset.checked_add(chunk.nodes.len()) != Some(cursor.next_offset) - || cursor.next_offset > cursor.total_nodes - || cursor.complete != (cursor.next_offset == cursor.total_nodes) - || (!cursor.complete && cursor.next_offset <= requested_offset) - { - return Err(invalid_call( - "Figma multi-root snapshot cursor does not match the requested node range.", - )); - } - } else if requested_offset != 0 { - return Err(invalid_call( - "Figma multi-root continuation is missing its snapshot cursor.", - )); - } - if let (Some(existing), Some(incoming)) = (&self.source_version, &chunk.version) - && existing != incoming - { - return Err(DevupError::new( - ErrorCode::DevupFigmaVersionChanged, - "The Figma file version changed during multi-root collection.", - true, - )); - } - if chunk.version.is_some() { - self.source_version = chunk.version.clone(); - } - self.fast_multi_has_large_values |= !descriptors_in_chunk(&chunk)?.is_empty(); - merge_fast_resources(&mut self.fast_multi_resources, payload.resources)?; - self.stats.transport = if self.section_fallback_roots.is_empty() { - if requested_offset > 0 - || self.stats.transport == "text-paginated" - || cursor.as_ref().is_some_and(|cursor| !cursor.complete) - { - "text-paginated" - } else { - payload.stats.transport - } - } else { - "hybrid-multi-root-cursor" - } - .to_owned(); - 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); - self.stats.envelope_chunks = self - .stats - .envelope_chunks - .saturating_add(payload.stats.chunk_count); - self.record_snapshot_chunk(order, chunk)?; - if let Some(cursor) = cursor.filter(|cursor| !cursor.complete) { - // Keep this Section and this exact root set: another sibling may - // have a different cursor in flight. Never cache a first page as - // a complete design while its vector operands remain unread. - let mut continuation = planned.call.clone(); - if let ReadToolCall::Snapshot { snapshot, .. } = &mut continuation { - let mut options = options.clone().unwrap_or_default(); - options.offset = cursor.next_offset; - *snapshot = Some(options); - } - self.enqueue( - continuation, - planned.expected_node_id.clone(), - CallKind::FastMultiRoot, - ); - } - Ok(()) - } - - fn fallback_multi_root_batch( - &mut self, - planned: &PlannedCall, - reason: String, - ) -> Result<(), DevupError> { - let ReadToolCall::Snapshot { - script: BuiltinScript::MultiRootSnapshotEnvelope, - root_ids: Some(root_ids), - .. - } = &planned.call - else { - return Err(invalid_call("multi-root fallback call format is invalid.")); - }; - if root_ids.is_empty() { - return Err(invalid_call("multi-root fallback has no selected roots.")); - } - self.mark_section_legacy(reason); - for root_id in root_ids { - self.enqueue_section_legacy_root(root_id.clone()); - } - Ok(()) - } - - fn mark_section_legacy(&mut self, reason: String) { - self.stats.transport = if self.fast_multi_resources.is_some() { - "hybrid-multi-root-cursor" - } else { - "legacy-multi-root-cursor" - } - .to_owned(); - self.stats.fallback_used = true; - self.stats.fallback_reason.get_or_insert(reason); - } - - fn enqueue_section_legacy_root(&mut self, root_id: String) { - if !self.section_fallback_roots.insert(root_id.clone()) { - return; - } - self.enqueue( - ReadToolCall::snapshot( - &self.request.target.file_key, - &root_id, - BuiltinScript::NodeSnapshot, - ), - Some(root_id), - CallKind::Snapshot, - ); - } - - fn restore_section_visual_order( - &self, - chunks: Vec, - ) -> Result, DevupError> { - if self.section_selected_roots.is_empty() || chunks.is_empty() { - return Ok(chunks); - } - 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) && !failed.contains(root_id.as_str())) - { - return Err(invalid_call( - "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 - .iter() - .filter(|root_id| observed.contains(*root_id)) - .cloned() - .collect(), - nodes: snapshot.nodes.into_values().collect(), - diagnostics: snapshot.diagnostics, - }]) - } - - fn record_snapshot_chunk( - &mut self, - order: usize, - chunk: SnapshotChunk, - ) -> Result<(), DevupError> { - let descriptors = descriptors_in_chunk(&chunk)?; - let version = chunk.version.clone(); - self.snapshot_chunks.insert(order, chunk); - for descriptor in descriptors { - let key = (descriptor.node_id.clone(), descriptor.field.clone()); - if self.large_values.contains_key(&key) { - return Err(invalid_call( - "Duplicate Figma large value descriptor for the same field.", - )); - } - let options = LargeValueReadOptions::from_descriptor( - &descriptor, - version.clone(), - descriptor.cursor.next_offset, - ); - self.large_values.insert( - key, - LargeValueAssembler::new( - self.request.target.file_key.clone(), - version.clone(), - descriptor, - )?, - ); - self.enqueue( - ReadToolCall::large_value(&self.request.target.file_key, options.clone()), - Some(options.node_id), - CallKind::LargeValue, - ); - } - Ok(()) - } - - fn accept_large_value( - &mut self, - planned: &PlannedCall, - result: UpstreamResult, - ) -> Result<(), DevupError> { - let ReadToolCall::LargeValue { options, .. } = &planned.call else { - return Err(invalid_call("large value call format is invalid.")); - }; - let result = large_value_from_result(&result)?; - if let LargeValueResult::Unsupported(unsupported) = result { - if unsupported.file_key != planned.expected_file_key - || unsupported.version != options.version - || unsupported.node_id != options.node_id - || unsupported.field != options.field - || unsupported.byte_length != options.byte_length - || unsupported.sha256 != options.sha256 - || unsupported.error_code != "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM" - { - return Err(invalid_call( - "large value unsupported response does not match the request.", - )); - } - return self.record_large_value_unsupported( - options, - &unsupported.error_code, - unsupported.error_detail.as_deref(), - ); - } - let LargeValueResult::Fragment(fragment) = result else { - unreachable!() - }; - if fragment.offset != options.offset { - return Err(invalid_call( - "large value fragment offset does not match the request.", - )); - } - let key = (options.node_id.clone(), options.field.clone()); - let next_offset = fragment.next_offset; - let complete = fragment.complete; - let assembler = self - .large_values - .get_mut(&key) - .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 is missing."))?; - if let Some((request, diagnostics)) = self.chunked_assets.remove(&key) { - // The fragments were an SVG or PNG export, not a field: the - // bytes are the asset, and go where a one-answer export would. - let bytes = match assembler.finish_bytes() { - Ok(bytes) => bytes, - Err(error) if request.format == crate::AssetFormat::Webp => { - self.record_asset_failure( - request, - "DEVUP_ASSET_WEBP_POSTPROCESS_FAILED", - Some(error.message), - ); - return Ok(()); - } - Err(error) => return Err(error), - }; - let mut asset = match exported_asset_from_bytes(&request, &bytes) { - Ok(asset) => asset, - Err(error) if request.format == crate::AssetFormat::Webp => { - self.record_asset_failure( - request, - "DEVUP_ASSET_WEBP_POSTPROCESS_FAILED", - Some(error.message), - ); - return Ok(()); - } - Err(error) => return Err(error), - }; - if let Some(mut observed) = diagnostics.as_ref().and_then(|d| d.render_info.clone()) - { - if request.format == crate::AssetFormat::Webp { - // Preserve postprocessing evidence and measure the final bytes, - // not the PNG fragments from which they were encoded. - if let Some(info) = &asset.render_info { - for key in ["representation", "compression", "transcoded"] { - observed[key] = info[key].clone(); - } - } - } - let output = asset - .data_base64 - .as_deref() - .and_then(|s| STANDARD.decode(s).ok()); - asset.render_info = Some(crate::asset_render_info::measured_info( - request.render_mode, - request.format, - request.scale, - Some(observed), - output.as_deref(), - )); - } - asset.export_diagnostics = diagnostics; - if let Some(diagnostics) = &mut asset.export_diagnostics { - diagnostics.render_info = asset.render_info.clone(); - } - self.retain_asset(asset); - return Ok(()); - } - 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 did not advance.")); - } - let descriptor = assembler.descriptor().clone(); - let next = LargeValueReadOptions::from_descriptor( - &descriptor, - options.version.clone(), - next_offset, - ); - self.enqueue( - ReadToolCall::large_value(&self.request.target.file_key, next.clone()), - Some(next.node_id), - CallKind::LargeValue, - ); - } - Ok(()) - } - - fn record_large_value_unsupported( - &mut self, - options: &LargeValueReadOptions, - error_code: &str, - error_detail: Option<&str>, - ) -> Result<(), DevupError> { - let key = (options.node_id.clone(), options.field.clone()); - let assembler = self - .large_values - .remove(&key) - .ok_or_else(|| invalid_call("large value assembler is missing."))?; - if let Some((request, _)) = self.chunked_assets.remove(&key) { - self.record_asset_failure( - request, - if error_detail.is_some_and(|detail| detail.contains("DEVUP_ASSET_TIMEOUT:")) { - "DEVUP_ASSET_TIMEOUT" - } else { - "DEVUP_ASSET_EXPORT_FAILED" - }, - Some(format!( - "reading the export back in fragments failed: {}", - error_detail.unwrap_or(error_code) - )), - ); - return Ok(()); - } - let descriptor = assembler.descriptor().clone(); - replace_descriptor( - &mut self.snapshot_chunks, - &descriptor, - json!({ - "$truncated": "unsupported-by-upstream", - "byteLength": descriptor.byte_length - }), - )?; - if let Some(chunk) = self - .snapshot_chunks - .values_mut() - .find(|chunk| chunk.nodes.iter().any(|node| node.id == descriptor.node_id)) - && let Some(node) = chunk - .nodes - .iter_mut() - .find(|node| node.id == descriptor.node_id) - { - node.field_errors - .insert(descriptor.field.clone(), error_code.to_owned()); - chunk.diagnostics.push(crate::Diagnostic { - code: error_code.to_owned(), - message: - "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), - property: Some(descriptor.field), - fallback: Some("unsupported-by-upstream".to_owned()), - recoverable: Some(false), - ..crate::Diagnostic::default() - }); - } - Ok(()) - } - - fn accept_asset( - &mut self, - planned: &PlannedCall, - result: UpstreamResult, - ) -> Result<(), DevupError> { - let ReadToolCall::AssetExport { - version, request, .. - } = &planned.call - else { - return Err(invalid_call("asset call format is invalid.")); - }; - if self.asset_byte_budget_exhausted { - self.record_asset_failure(request.as_ref().clone(), "DEVUP_ASSET_TOTAL_BYTES_EXCEEDED", Some("The retained asset byte budget was reached; export this ID in a smaller batch.".into())); - return Ok(()); - } - let outcome = asset_export_from_result( - &result, - &planned.expected_file_key, - version.as_deref(), - request, - ); - let outcome = match outcome { - Ok(outcome) => outcome, - Err(error) if request.format == crate::AssetFormat::Webp => { - self.record_asset_failure( - request.as_ref().clone(), - "DEVUP_ASSET_WEBP_POSTPROCESS_FAILED", - Some(error.message), - ); - return Ok(()); - } - Err(error) if request.render_mode == crate::AssetRenderMode::OriginalSource => { - self.record_asset_failure( - request.as_ref().clone(), - "DEVUP_ASSET_SOURCE_POSTPROCESS_FAILED", - Some(error.message), - ); - return Ok(()); - } - Err(error) => return Err(error), - }; - let exported = match outcome { - AssetExportOutcome::Entry(entry) => *entry, - AssetExportOutcome::Chunked(descriptor) => { - let key = (descriptor.node_id.clone(), descriptor.field.clone()); - if self.large_values.contains_key(&key) { - return Err(invalid_call( - "Duplicate Figma large value descriptor for the same field.", - )); - } - let options = LargeValueReadOptions::from_descriptor( - &descriptor, - version.clone(), - descriptor.cursor.next_offset, - ); - self.large_values.insert( - key.clone(), - LargeValueAssembler::new( - self.request.target.file_key.clone(), - version.clone(), - descriptor, - )?, - ); - self.chunked_assets.insert( - key, - ( - request.as_ref().clone(), - crate::assets::asset_export_diagnostics(&result), - ), - ); - self.enqueue( - ReadToolCall::large_value(&self.request.target.file_key, options.clone()), - Some(options.node_id), - CallKind::LargeValue, - ); - return Ok(()); - } - }; - if exported.status == AssetStatus::Failed { - let error_code = exported - .error_code - .clone() - .unwrap_or_else(|| "DEVUP_ASSET_EXPORT_FAILED".to_owned()); - self.record_asset_diagnostic(request, &error_code, exported.error_detail.as_deref()); - } - self.retain_asset(exported); - Ok(()) - } - - fn accept_reference_png( - &mut self, - planned: &PlannedCall, - result: UpstreamResult, - ) -> Result<(), DevupError> { - let ReadToolCall::Screenshot { file_key, node_id } = &planned.call else { - 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 target does not match the request.", - )); - } - let note = result.raw["_meta"][crate::bridge::REFERENCE_PNG_NOTE_META_KEY] - .as_str() - .map(str::to_owned); - 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 exceeds the allowed size.", - false, - )); - } - let bytes = STANDARD - .decode(data_base64.as_bytes()) - .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 format or size is invalid.", - )); - } - validate_reference_png(&bytes)?; - self.reference_png = Some(ReferencePng { - mime_type: "image/png".to_owned(), - data_base64, - byte_length: bytes.len(), - sha256: Sha256::digest(&bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(), - note, - }); - Ok(()) - } - - fn retain_asset(&mut self, mut asset: AssetManifestEntry) { - if asset.status == AssetStatus::Exported { - let bytes = asset - .byte_length - .unwrap_or_default() - .saturating_add(asset.variants.iter().map(|v| v.byte_length).sum::()); - if self.retained_asset_bytes.saturating_add(bytes) > MAX_COLLECTED_ASSET_BYTES { - self.asset_byte_budget_exhausted = true; - asset.status = AssetStatus::Failed; - asset.error_code = Some("DEVUP_ASSET_TOTAL_BYTES_EXCEEDED".into()); - asset.error_detail = Some("The 128 MiB retained asset budget was reached. Export failed IDs in smaller custom batches or select a smaller frame.".into()); - asset.data_base64 = None; - asset.variants.clear(); - asset.byte_length = None; - asset.sha256 = None; - asset.mime_type = None; - if let Some(info) = &mut asset.render_info { - info["representation"] = json!("not-exported"); - info["renderedAs"] = json!("not-exported"); - info["byteEvidence"] = Value::Null; - info["fidelity"] = json!("unverified"); - info["effectsBakedIntoAsset"] = json!(false); - } - if let Some(diagnostics) = &mut asset.export_diagnostics { - diagnostics.render_info = asset.render_info.clone(); - } - } else { - self.retained_asset_bytes += bytes; - } - } - self.asset_results.push(asset); - } - - fn record_asset_failure( - &mut self, - request: AssetRequest, - error_code: &str, - error_detail: Option, - ) { - let error_detail = error_detail.map(|detail| detail.chars().take(500).collect::()); - self.record_asset_diagnostic(&request, error_code, error_detail.as_deref()); - let source_kind = crate::assets::source_kind_of(&request).to_owned(); - self.asset_results.push(AssetManifestEntry { - usage_aspect_ratio: request.usage_aspect_ratio, - max_width: request.max_width, - srcset: request.srcset, - visible_scope_node_id: request.visible_scope_node_id.clone(), - variants: Vec::new(), - render_mode: request.render_mode, - quality: request.quality, - lossless: request.lossless, - render_info: None, - suggested_filename: None, - asset_id: request.asset_id, - node_id: request.node_id, - field: request.field, - source_kind, - image_hash: request.image_hash, - format: Some(request.format), - scale: Some(request.scale), - status: AssetStatus::Failed, - byte_length: None, - sha256: None, - mime_type: None, - data_base64: None, - output_path: None, - error_code: Some(error_code.to_owned()), - error_detail, - export_diagnostics: None, - path: None, - }); - } - - fn record_asset_diagnostic( - &mut self, - request: &AssetRequest, - error_code: &str, - error_detail: Option<&str>, - ) { - if let Some(chunk) = self - .snapshot_chunks - .values_mut() - .find(|chunk| chunk.nodes.iter().any(|node| node.id == request.node_id)) - { - chunk.diagnostics.push(crate::Diagnostic { - code: error_code.to_owned(), - message: match error_detail { - Some(detail) => format!( - "Failed to export the requested Figma asset; layout output was kept. {detail}" - ), - None => "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), - property: Some(request.field.clone()), - resource_kind: Some("asset".to_owned()), - resource_id: Some(request.asset_id.clone()), - fallback: Some("layout-only".to_owned()), - recoverable: Some(true), - ..crate::Diagnostic::default() - }); - } - } - - fn accept_metadata( - &mut self, - planned: &PlannedCall, - result: UpstreamResult, - ) -> Result<(), DevupError> { - let metadata = metadata_from_result_for_target( - &result, - &self.request.target.file_key, - planned.expected_node_id.as_deref(), - )?; - if let MetadataResult::TopLevelPages(pages) = metadata { - if planned.expected_node_id.is_some() { - return Err(invalid_call( - "A page metadata request returned the top-level page list.", - )); - } - self.record_metadata(result.raw); - self.root_node_id = pages.first().map(|page| page.id.clone()); - for page in &pages { - self.record_metadata_node(page); - if !self.metadata_root_ids.contains(&page.id) { - self.metadata_root_ids.push(page.id.clone()); - } - } - if let Some(options) = self.request.search.clone() { - for page in pages { - self.enqueue( - ReadToolCall::search_snapshot( - &self.request.target.file_key, - &page.id, - options.clone(), - ), - Some(page.id), - CallKind::Snapshot, - ); - } - return Ok(()); - } - if self.request.variables_only { - return Ok(()); - } - for page in pages { - self.enqueue( - ReadToolCall::metadata(&self.request.target.file_key, Some(&page.id)), - Some(page.id), - CallKind::Metadata, - ); - } - return Ok(()); - } - let MetadataResult::Document(document) = metadata else { - 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 does not match the request.", - )); - } - if let (Some(existing), Some(incoming)) = (&self.source_version, &document.version) - && existing != incoming - { - return Err(DevupError::new( - ErrorCode::DevupFigmaVersionChanged, - "The Figma file version changed during metadata collection.", - true, - )); - } - if document.version.is_some() { - self.source_version = document.version.clone(); - } - if self.root_node_id.is_none() { - self.root_node_id = Some(document.root_id.clone()); - } - if !self.metadata_root_ids.contains(&document.root_id) { - self.metadata_root_ids.push(document.root_id.clone()); - } - for node in &document.nodes { - self.record_metadata_node(node); - } - self.record_metadata(result.raw); - let root = document.root().ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Target node not found in the Figma metadata.", - false, - ) - })?; - if let Some(options) = self.request.search.clone() { - self.enqueue( - ReadToolCall::search_snapshot(&self.request.target.file_key, &root.id, options), - Some(root.id.clone()), - CallKind::Snapshot, - ); - return Ok(()); - } - if self.request.metadata_only || self.request.variables_only { - return Ok(()); - } - let split = !root.children_ids.is_empty() - && (root.descendant_count > LARGE_SUBTREE_THRESHOLD - || matches!( - self.request.scope, - CollectionScope::Page | CollectionScope::File - )); - let mut targets = if split { - root.children_ids.clone() - } else { - vec![root.id.clone()] - }; - // 노드 스코프에서 변환 대상은 루트 자신이다. 자식으로 쪼갠 뒤 루트를 빼면 - // 투영 단계가 대상을 찾지 못한다. - if split && self.request.scope == CollectionScope::Node && !targets.contains(&root.id) { - targets.push(root.id.clone()); - } - for node_id in targets { - self.enqueue( - ReadToolCall::snapshot( - &self.request.target.file_key, - &node_id, - BuiltinScript::NodeSnapshot, - ), - Some(node_id), - CallKind::Snapshot, - ); - } - Ok(()) - } - - fn record_metadata(&mut self, value: Value) { - self.metadata = Some(match self.metadata.take() { - None => value, - Some(Value::Array(mut values)) => { - values.push(value); - Value::Array(values) - } - Some(existing) => Value::Array(vec![existing, value]), - }); - } - - fn record_metadata_node(&mut self, node: &crate::metadata::MetadataNode) { - let mut fields = Map::new(); - if let Some(name) = &node.name { - fields.insert("name".to_owned(), Value::String(name.clone())); - } - fields.insert( - "childrenIds".to_owned(), - Value::Array( - node.children_ids - .iter() - .cloned() - .map(Value::String) - .collect(), - ), - ); - fields.insert( - "descendantCount".to_owned(), - Value::from(node.descendant_count), - ); - self.metadata_nodes.insert( - node.id.clone(), - RawNode { - id: node.id.clone(), - node_type: node.node_type.clone(), - fields, - extra: Map::new(), - field_errors: BTreeMap::new(), - }, - ); - } - - fn accept_snapshot( - &mut self, - planned: &PlannedCall, - order: usize, - result: UpstreamResult, - ) -> 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 does not match the request.", - )); - } - if chunk.version != self.source_version { - return Err(DevupError::new( - ErrorCode::DevupFigmaVersionChanged, - "The Figma file version changed during collection.", - true, - )); - } - let pagination = match &planned.call { - ReadToolCall::Snapshot { - script: BuiltinScript::NodeSnapshot, - snapshot: Some(options), - .. - } => Some(options.clone()), - _ => None, - }; - if let Some(options) = pagination - && let Some(cursor) = take_snapshot_cursor(&mut chunk)? - { - let expected_next = options - .offset - .checked_add(chunk.nodes.len()) - .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 does not match the collected node range.", - )); - } - if cursor.complete != (cursor.next_offset >= cursor.total_nodes) { - return Err(invalid_call( - "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 did not advance.")); - } - let node_id = planned.expected_node_id.clone().ok_or_else(|| { - invalid_call("Figma snapshot cursor root node ID is missing.") - })?; - self.enqueue( - ReadToolCall::snapshot_chunk( - &self.request.target.file_key, - &node_id, - SnapshotReadOptions { - offset: cursor.next_offset, - ..options - }, - ), - Some(node_id), - CallKind::Snapshot, - ); - } - } - self.record_snapshot_chunk(order, chunk)?; - Ok(()) - } - - 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("No root node ID available for the Figma variable batch.") - })?; - for variable_ids in catalog - .variable_ids - .chunks(self.request.batch_budget.variable_items.max(1)) - { - self.enqueue( - ReadToolCall::resource_batch( - &self.request.target.file_key, - &node_id, - ResourceBatch { - variable_ids: variable_ids.to_vec(), - styles: Vec::new(), - }, - ), - Some(node_id.clone()), - CallKind::VariableBatch, - ); - } - for styles in catalog - .styles - .chunks(self.request.batch_budget.style_items.max(1)) - { - self.enqueue( - ReadToolCall::resource_batch( - &self.request.target.file_key, - &node_id, - ResourceBatch { - variable_ids: Vec::new(), - styles: styles.to_vec(), - }, - ), - Some(node_id.clone()), - CallKind::VariableBatch, - ); - } - self.variable_catalog = Some(catalog); - Ok(()) - } - - fn enqueue_used_resource_batches(&mut self) -> Result<(), DevupError> { - let chunks = self - .snapshot_chunks - .values() - .filter(|chunk| { - self.section_fallback_roots.is_empty() - || self.fast_multi_resources.is_none() - || self.fast_multi_has_large_values - || chunk - .root_ids - .iter() - .any(|root_id| self.section_fallback_roots.contains(root_id)) - }) - .cloned() - .collect::>(); - let refs = collect_used_resource_refs(&chunks); - let node_id = self.root_node_id.clone().ok_or_else(|| { - invalid_call("No root node ID available for the used Figma resource batch.") - })?; - for batch in used_resource_batches(&refs, self.request.batch_budget)? { - self.enqueue( - ReadToolCall::used_resources(&self.request.target.file_key, &node_id, batch), - Some(node_id.clone()), - CallKind::UsedResourceBatch, - ); - } - self.used_resource_refs = Some(refs); - Ok(()) - } - - fn record_unresolved_diagnostics( - &mut self, - refs: &UsedResourceRefs, - unresolved: &[UnresolvedResource], - ) { - let unresolved = unresolved - .iter() - .map(|resource| (resource.kind, resource.id.as_str())) - .collect::>(); - for occurrence in &refs.occurrences { - if !unresolved.contains(&(occurrence.resource_kind, occurrence.resource_id.as_str())) { - continue; - } - let diagnostic = crate::Diagnostic { - code: "DEVUP_RESOURCE_UNRESOLVED".to_owned(), - message: format!( - "Could not resolve the Figma resource; substituted the raw value: field={}, resourceId={}", - occurrence.field, occurrence.resource_id - ), - node_id: Some(occurrence.node_id.clone()), - severity: Some(crate::DiagnosticSeverity::Warning), - property: Some(occurrence.field.clone()), - resource_kind: Some( - match occurrence.resource_kind { - crate::ResourceKind::Variable => "variable", - crate::ResourceKind::Style => "style", - } - .to_owned(), - ), - resource_id: Some(occurrence.resource_id.clone()), - fallback: Some("raw-value".to_owned()), - recoverable: Some(true), - ..crate::Diagnostic::default() - }; - if let Some(chunk) = self - .snapshot_chunks - .values_mut() - .find(|chunk| chunk.nodes.iter().any(|node| node.id == occurrence.node_id)) - { - chunk.diagnostics.push(diagnostic); - } - } - } - - fn enqueue_style_consumer_batches( - &mut self, - batch: &VariableBatchResult, - ) -> Result<(), DevupError> { - let node_id = self.root_node_id.clone().ok_or_else(|| { - 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 format is invalid.")); - }; - let Some(consumer_count) = object.get("$consumerCount").and_then(Value::as_u64) else { - continue; - }; - let id = object - .get("id") - .and_then(Value::as_str) - .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 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( - ReadToolCall::resource_batch( - &self.request.target.file_key, - &node_id, - ResourceBatch { - variable_ids: Vec::new(), - styles: vec![ResourceStyleRef { - id: id.to_owned(), - style_type: style_type.to_owned(), - consumer_start: Some(start), - consumer_end: Some(end), - }], - }, - ), - Some(node_id.clone()), - CallKind::VariableBatch, - ); - } - } - Ok(()) - } - - fn enqueue(&mut self, call: ReadToolCall, expected_node_id: Option, kind: CallKind) { - let order = self.next_id; - let id = format!("call-{order}"); - self.next_id += 1; - self.queued.push_back(PendingCall { - planned: PlannedCall { - id, - call, - expected_file_key: self.request.target.file_key.clone(), - expected_node_id, - }, - kind, - order, - }); - } -} - -/// The one image the screenshot answered with. -/// -/// The official `get_screenshot` writes the PNG's URL and how to fetch it as -/// text blocks, and inlines the PNG itself as an image block beside them when -/// asked to (`enableBase64Response`). The text is not read; the image must be -/// exactly one, and a PNG. A response with no image, or two, or one hidden in -/// text or JSON, is refused as before. -fn take_single_png_data(value: Value) -> Result { - let result = serde_json::from_value::(value) - .map_err(|_| invalid_call("Figma screenshot response format is invalid."))?; - if result.is_error == Some(true) { - return Err(invalid_call( - "Figma screenshot response must contain exactly one image/png content block.", - )); - } - let mut images = result - .content - .into_iter() - .filter_map(|content| match content { - ContentBlock::Image(image) => Some(image), - _ => None, - }); - let (Some(image), None) = (images.next(), images.next()) else { - return Err(invalid_call( - "Figma screenshot response must contain exactly one image/png content block.", - )); - }; - if image.mime_type != "image/png" { - return Err(invalid_call( - "Figma screenshot response MIME type is not image/png.", - )); - } - Ok(image.data) -} - -fn validate_reference_png(bytes: &[u8]) -> Result<(), DevupError> { - let mut limits = Limits::default(); - limits.max_image_width = Some(MAX_REFERENCE_PNG_DIMENSION); - limits.max_image_height = Some(MAX_REFERENCE_PNG_DIMENSION); - limits.max_alloc = Some(MAX_REFERENCE_PNG_DECODED_BYTES as u64); - let decoder = - PngDecoder::with_limits(Cursor::new(bytes), limits).map_err(reference_png_decode_error)?; - let (width, height) = decoder.dimensions(); - let decoded_bytes = usize::try_from(decoder.total_bytes()).map_err(|_| { - DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG decoded size exceeds the allowed range.", - false, - ) - })?; - if width == 0 - || height == 0 - || width > MAX_REFERENCE_PNG_DIMENSION - || height > MAX_REFERENCE_PNG_DIMENSION - || decoded_bytes > MAX_REFERENCE_PNG_DECODED_BYTES - { - return Err(DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG dimensions or decoded size exceed the allowed range.", - false, - )); - } - let mut pixels = vec![0_u8; decoded_bytes]; - decoder - .read_image(&mut pixels) - .map_err(reference_png_decode_error) -} - -/// The plugin's export flattened onto the colour the plugin names under the -/// node - its ancestors' fills over the page background - which is what -/// `get_screenshot` hands back: measured on the same frames, its PNG was -/// opaque where the export kept a translucent backdrop or transparent gaps, -/// showing that colour there (the page background (30, 30, 30) on a frame -/// straight on the page). An opaque export is returned as it came. -pub(crate) fn flatten_reference_png( - data_base64: &str, - backdrop: [u8; 3], -) -> Result { - let bytes = STANDARD - .decode(data_base64.as_bytes()) - .map_err(|_| invalid_call("Figma reference PNG base64 is invalid."))?; - let mut limits = Limits::default(); - limits.max_image_width = Some(MAX_REFERENCE_PNG_DIMENSION); - limits.max_image_height = Some(MAX_REFERENCE_PNG_DIMENSION); - limits.max_alloc = Some(MAX_REFERENCE_PNG_DECODED_BYTES as u64); - let decoder = - PngDecoder::with_limits(Cursor::new(bytes), limits).map_err(reference_png_decode_error)?; - let mut pixels = image::DynamicImage::from_decoder(decoder) - .map_err(reference_png_decode_error)? - .into_rgba8(); - if pixels.pixels().all(|pixel| pixel[3] == u8::MAX) { - return Ok(data_base64.to_owned()); - } - for pixel in pixels.pixels_mut() { - let alpha = u32::from(pixel[3]); - for (value, under) in pixel.0.iter_mut().zip(backdrop) { - let mixed = u32::from(*value) * alpha + u32::from(under) * (255 - alpha); - *value = u8::try_from((mixed + 127) / 255).unwrap_or(u8::MAX); - } - pixel[3] = u8::MAX; - } - let mut encoded = Vec::new(); - pixels - .write_with_encoder(image::codecs::png::PngEncoder::new(&mut encoded)) - .map_err(|_| invalid_call("Figma reference PNG could not be re-encoded."))?; - Ok(STANDARD.encode(encoded)) -} - -fn reference_png_decode_error(error: ImageError) -> DevupError { - if matches!(error, ImageError::Limits(_)) { - DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "Figma reference PNG dimensions or decoded size exceed the allowed range.", - false, - ) - } else { - invalid_call("Figma reference PNG data is corrupted.") - } -} - -fn take_snapshot_cursor(chunk: &mut SnapshotChunk) -> Result, DevupError> { - let Some(cursor) = read_snapshot_cursor(&chunk.nodes) - .map_err(|message| invalid_call(message.korean_message()))? - else { - return Ok(None); - }; - chunk.nodes.retain(|node| node.id != SNAPSHOT_CURSOR_ID); - Ok(Some(cursor)) -} - -fn invalid_call(message: &str) -> DevupError { - DevupError::new(ErrorCode::DevupFigmaHandoffInvalid, message, false) -} - -/// The next page of a theme onto the pages before it. Every list - the -/// resources and the scan alike - is appended in order, with an entry the -/// pages both carry kept once; the scalars are the page's. -fn merge_theme_page(merged: &mut Value, page: Value) -> Result<(), DevupError> { - let Some(page) = page.as_object() else { - return Err(invalid_call("Figma theme page format is invalid.")); - }; - for list in [ - "collections", - "variables", - "styles", - "usedRemoteVariables", - "usedVariableIds", - "usedStyleIds", - "unresolved", - ] { - let incoming = page - .get(list) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let target = merged - .get_mut(list) - .and_then(Value::as_array_mut) - .ok_or_else(|| invalid_call("Figma theme page format is invalid."))?; - for item in incoming { - // A resource is one by its id, an id in the scan by itself. - let same = |existing: &Value| match item.get("id") { - Some(id) => existing.get("id") == Some(id), - None => *existing == item, - }; - if target.iter().any(same) { - continue; - } - target.push(item); - } - } - for field in ["localComplete", "usedRemoteComplete"] { - if let Some(value) = page.get(field) { - merged[field] = value.clone(); - } - } - Ok(()) -} - -fn fallback_category(error: &DevupError) -> String { - error - .details - .get("category") - .and_then(Value::as_str) - .map(str::to_owned) - .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, - ErrorCode::DevupFigmaDirectUnavailable - | ErrorCode::DevupFigmaCatalogRejected - | ErrorCode::DevupFigmaResponseTooLarge - | ErrorCode::DevupSnapshotUnsupported - | ErrorCode::DevupFigmaHandoffInvalid - ) -} - -fn resource_count(value: &Value, field: &str) -> usize { - value - .get(field) - .and_then(Value::as_array) - .map_or(0, Vec::len) -} - -fn empty_used_resources() -> UpstreamResult { - UpstreamResult { - raw: json!({ - "collections": [], - "variables": [], - "styles": [], - "usedRemoteVariables": [], - "usedVariableIds": [], - "usedStyleIds": [], - "localComplete": false, - "usedRemoteComplete": true, - "unresolved": [] - }), - } -} - -fn merge_fast_resources( - existing: &mut Option, - incoming: UpstreamResult, -) -> Result<(), DevupError> { - let Some(current) = existing else { - *existing = Some(incoming); - return Ok(()); - }; - let current = current - .raw - .as_object_mut() - .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 format is invalid."))?; - for field in ["collections", "variables", "styles", "usedRemoteVariables"] { - let mut values = BTreeMap::::new(); - for value in current - .get(field) - .and_then(Value::as_array) - .into_iter() - .flatten() - .chain( - incoming - .get(field) - .and_then(Value::as_array) - .into_iter() - .flatten(), - ) - { - let id = value - .get("id") - .and_then(Value::as_str) - .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 contents differ between batches.", - true, - )); - } - values.insert(id.to_owned(), value.clone()); - } - current.insert( - field.to_owned(), - Value::Array(values.into_values().collect()), - ); - } - for field in ["usedVariableIds", "usedStyleIds"] { - let values = current - .get(field) - .and_then(Value::as_array) - .into_iter() - .flatten() - .chain( - incoming - .get(field) - .and_then(Value::as_array) - .into_iter() - .flatten(), - ) - .map(|value| { - value - .as_str() - .map(str::to_owned) - .ok_or_else(|| invalid_call("multi-root resource ID format is invalid.")) - }) - .collect::, _>>()?; - current.insert( - field.to_owned(), - Value::Array(values.into_iter().map(Value::String).collect()), - ); - } - let unresolved = current - .get("unresolved") - .and_then(Value::as_array) - .into_iter() - .flatten() - .chain( - incoming - .get("unresolved") - .and_then(Value::as_array) - .into_iter() - .flatten(), - ) - .map(|value| { - ( - (value["id"].to_string(), value["kind"].to_string()), - value.clone(), - ) - }) - .collect::>(); - current.insert( - "unresolved".to_owned(), - Value::Array(unresolved.into_values().collect()), - ); - for field in ["localComplete", "usedRemoteComplete"] { - let merged = current.get(field).and_then(Value::as_bool).unwrap_or(false) - && incoming - .get(field) - .and_then(Value::as_bool) - .unwrap_or(false); - current.insert(field.to_owned(), Value::Bool(merged)); - } - Ok(()) -} - -#[derive(Debug, Clone)] -enum UsedResourceItem { - Variable(String), - Style(ResourceStyleRef), -} - -fn used_resource_batches( - refs: &UsedResourceRefs, - budget: BatchBudget, -) -> Result, DevupError> { - let items = refs - .variable_ids - .iter() - .cloned() - .map(UsedResourceItem::Variable) - .chain(refs.styles.iter().cloned().map(UsedResourceItem::Style)); - let mut batches = Vec::new(); - let mut current = ResourceBatch { - variable_ids: Vec::new(), - styles: Vec::new(), - }; - - for item in items { - let mut candidate = current.clone(); - add_used_resource(&mut candidate, item.clone()); - if used_resource_batch_fits(&candidate, budget) { - current = candidate; - continue; - } - if current.variable_ids.is_empty() && current.styles.is_empty() { - return Err(DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "A single Figma resource ID exceeds the safe batch size.", - false, - )); - } - batches.push(current); - current = ResourceBatch { - variable_ids: Vec::new(), - styles: Vec::new(), - }; - add_used_resource(&mut current, item); - if !used_resource_batch_fits(¤t, budget) { - return Err(DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "A single Figma resource ID exceeds the safe batch size.", - false, - )); - } - } - if !current.variable_ids.is_empty() || !current.styles.is_empty() { - batches.push(current); - } - Ok(batches) -} - -fn add_used_resource(batch: &mut ResourceBatch, item: UsedResourceItem) { - match item { - UsedResourceItem::Variable(id) => batch.variable_ids.push(id), - UsedResourceItem::Style(style) => batch.styles.push(style), - } -} - -fn used_resource_batch_fits(batch: &ResourceBatch, budget: BatchBudget) -> bool { - batch.variable_ids.len() + batch.styles.len() <= budget.used_resource_items - && serde_json::to_vec(batch).is_ok_and(|bytes| bytes.len() <= budget.used_resource_bytes) -} - -#[cfg(test)] -mod w5_tests { - use super::*; - #[test] - fn aggregate_budget_keeps_successes_and_exact_failure_options_and_stops_remaining_reads() { - let target = - FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=1-2") - .unwrap(); - let mut collector = - CollectorSession::new(CollectionRequest::new(target, CollectionScope::Node)); - let request = AssetRequest { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - asset_id: "synthetic:fills:0".into(), - node_id: "synthetic".into(), - field: "fills/0".into(), - image_hash: Some("source".into()), - format: crate::AssetFormat::Png, - scale: 1.0, - render_mode: crate::AssetRenderMode::Node, - quality: None, - lossless: None, - }; - let mut asset = - crate::exported_asset_from_bytes(&request, b"synthetic byte-budget boundary").unwrap(); - collector.retained_asset_bytes = MAX_COLLECTED_ASSET_BYTES - asset.byte_length.unwrap(); - collector.retain_asset(asset.clone()); - assert_eq!(collector.asset_results[0].status, AssetStatus::Exported); - asset.asset_id = "second:fills:0".into(); - asset.quality = Some(41); - asset.lossless = Some(true); - asset.format = Some(crate::AssetFormat::Webp); - collector.retain_asset(asset); - let failed = &collector.asset_results[1]; - assert_eq!(failed.status, AssetStatus::Failed); - assert_eq!(failed.quality, Some(41)); - assert_eq!(failed.lossless, Some(true)); - assert!(failed.data_base64.is_none()); - assert!(failed.sha256.is_none()); - assert!(collector.asset_byte_budget_exhausted); - assert_eq!(collector.retained_asset_bytes, MAX_COLLECTED_ASSET_BYTES); - } - use std::{ - io::Write, - process::{Command, Stdio}, - }; - - fn run_script(script: &str, setup: &str, key: &str, options: Value) -> Value { - let script = script - .replace("__DEVUP_NODE_ID__", "2:1") - .replace(&format!("\"{key}\""), &options.to_string()); - let program = format!( - "{setup}\n(async () => {{ {script} }})().then(v => process.stdout.write(JSON.stringify(v))).catch(e => {{ console.error(e); process.exit(1); }});" - ); - let mut child = Command::new("node") - .arg("-") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("Node.js required for script tests"); - child - .stdin - .take() - .unwrap() - .write_all(program.as_bytes()) - .unwrap(); - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - serde_json::from_slice(&output.stdout).unwrap() - } - - const TREE: &str = r#" -const doc = {id:'0:0', type:'DOCUMENT'}; -const page = {id:'1:1', type:'PAGE', name:'Page', parent:doc, children:[]}; -function node(id, type, name, parent) { - const n = {id, type, name, parent, children:[], x:0, y:0, width:390, height:844, visible:true}; - n.findAll = predicate => n.children.flatMap(c => [c, ...c.findAll(predicate)]).filter(predicate); - parent.children.push(n); return n; -} -const section = node('2:1', 'SECTION', 'Loading section', page); -const frame = node('3:1', 'FRAME', 'Loading', section); -node('3:2', 'FRAME', 'Loading', page); -page.findAll = () => { throw new Error('must not traverse whole page'); }; -const figma = {fileKey:'file', getNodeByIdAsync: async () => section, - setCurrentPageAsync: async p => { if (p !== page) throw new Error('wrong page'); }}; -"#; - - fn search_output(setup: &str) -> Value { - run_script( - include_str!("scripts/search.js"), - setup, - "__DEVUP_SEARCH__", - json!({"query":"Loading", "nodeTypes":["FRAME"], "matchKind":"exact", "limit":5}), - ) - } - - #[test] - fn p4_section_keeps_long_page_and_nested_automatic_screens() { - let setup = format!( - r#"{TREE} -frame.height = 7240; -const wrapper = node('4:1', 'GROUP', 'Wrapper', frame); -node('5:1', 'FRAME', 'Screen', wrapper); -node('5:2', 'FRAME', 'Screen contents', wrapper.children[0]); -const small = node('6:1', 'FRAME', 'Case', section); small.width = small.height = 150; -node('6:2', 'TEXT', 'Text case', section); -"# - ); - let output = run_script( - include_str!("scripts/section_index.js"), - &setup, - "unused", - Value::Null, - ); - let nodes = output["nodes"].as_array().unwrap(); - assert!( - nodes.iter().any(|n| n["id"] == "5:1"), - "nested screen was discarded" - ); - assert!(!nodes.iter().any(|n| n["id"] == "5:2")); - let screen = nodes.iter().find(|n| n["id"] == "5:1").unwrap(); - assert_eq!(screen["fields"]["parentId"], "3:1"); - let snapshot = merge_chunks(vec![ - snapshot_chunk_from_result(&UpstreamResult { raw: output }).unwrap(), - ]) - .unwrap(); - let target = FigmaTarget { - file_key: "file".into(), - node_id: Some("2:1".into()), - branch_key: None, - }; - let index = build_section_index(&snapshot, &target).unwrap(); - assert_eq!(index.select(&[], true).unwrap(), ["5:1"]); - assert_eq!(index.select(&["3:1".into()], false).unwrap(), ["3:1"]); - for id in ["3:1", "6:1", "6:2"] { - let candidate = index.candidates.iter().find(|c| c.node_id == id).unwrap(); - assert!(!candidate.is_screen_candidate()); - assert!( - candidate - .selection_reasons - .contains(&"explicit-selection-only".into()) - ); - } - } - - #[test] - fn w5_search_accepts_section_and_frame_roots_without_scanning_siblings() { - for setup in [ - TREE.to_owned(), - format!("{TREE}\nfigma.getNodeByIdAsync = async () => frame;"), - ] { - let output = search_output(&setup); - let nodes = output["nodes"].as_array().unwrap(); - assert!(nodes.iter().any(|n| n["id"] == "3:1")); - assert!(!nodes.iter().any(|n| n["id"] == "3:2")); - assert!(nodes.iter().any(|n| n["id"] == "2:1")); - assert_eq!(output["rootIds"], json!(["1:1"])); - } - } - - #[test] - fn w5_text_preview_does_not_change_candidates_or_projection_truncation() { - let setup = format!( - r#"{TREE} -for (let i=0; i<60; i++) {{ - const f = node('4:'+i, 'FRAME', 'Screen '+i, section); - const t = node('5:'+i, 'TEXT', 'Copy', f); t.characters = '한글 " preview '.repeat(40); -}} -"# - ); - let mut baseline = None; - for limit in [0, 160, 500] { - let output = run_script( - include_str!("scripts/explore.js"), - &setup, - "__DEVUP_EXPLORE__", - json!({"projectionLimit":200,"textPreviewLimit":limit}), - ); - let nodes = output["nodes"].as_array().unwrap(); - let ids: Vec<_> = nodes - .iter() - .filter(|n| n["type"] == "FRAME") - .map(|n| n["id"].clone()) - .collect(); - assert!(!ids.is_empty()); - let snapshot = merge_chunks(vec![ - snapshot_chunk_from_result(&UpstreamResult { - raw: output.clone(), - }) - .unwrap(), - ]) - .unwrap(); - let target = FigmaTarget { - file_key: "file".into(), - node_id: Some("2:1".into()), - branch_key: None, - }; - let explored = - crate::explore_snapshot(&snapshot, &target, &crate::ExploreOptions { limit: 100 }) - .unwrap(); - let candidate_ids: Vec<_> = explored - .candidates - .iter() - .map(|candidate| candidate.node.node_id.clone()) - .collect(); - assert_eq!(candidate_ids.len(), ids.len()); - let state = ( - ids, - candidate_ids, - nodes[0]["fields"]["projectionTruncated"].clone(), - ); - if let Some(expected) = &baseline { - assert_eq!(&state, expected); - } else { - baseline = Some(state); - } - assert!(output.to_string().chars().count() <= 14_000); - } - } - - #[test] - fn w5_previews_are_retained_when_the_envelope_has_room() { - let setup = - format!("{TREE}\nnode('4:1', 'TEXT', 'Copy', frame).characters = 'Preview text';"); - let output = run_script( - include_str!("scripts/explore.js"), - &setup, - "__DEVUP_EXPLORE__", - json!({"projectionLimit":200,"textPreviewLimit":160}), - ); - let nodes = output["nodes"].as_array().unwrap(); - let frame = nodes.iter().find(|node| node["id"] == "3:1").unwrap(); - assert_eq!(frame["fields"]["textPreview"], "Preview text"); - assert_eq!(nodes[0]["fields"]["projectionTruncated"], false); - } - - #[test] - fn w5_empty_search_preserves_anchor_and_ancestry() { - let output = search_output(&format!("{TREE}\nframe.name = 'Unrelated';")); - let ids: BTreeSet<_> = output["nodes"] - .as_array() - .unwrap() - .iter() - .map(|node| node["id"].as_str().unwrap()) - .collect(); - assert_eq!(ids, BTreeSet::from(["1:1", "2:1"])); - } - - fn page_output(ids: &[String]) -> UpstreamResult { - UpstreamResult { - raw: json!({ - "fileKey": "file", - "version": null, - "rootIds": ids, - "nodes": ids.iter().map(|id| json!({ - "id": id, "type": "PAGE", - "fields": {"name": "Page", "parentId": null, "childrenIds": []}, - "extra": {}, "fieldErrors": {} - })).collect::>(), - "diagnostics": [] - }), - } - } - - #[test] - fn w5_file_search_still_schedules_all_thirteen_pages() { - let target = FigmaTarget { - file_key: "file".into(), - node_id: Some("2:1".into()), - branch_key: None, - }; - let mut request = CollectionRequest::new(target, CollectionScope::File); - request.search = Some(SearchReadOptions { - query: "Loading".into(), - match_kind: "exact".into(), - limit: 5, - ..Default::default() - }); - let mut session = CollectorSession::new(request); - let CollectorStep::Call(call) = session.advance().unwrap() else { - panic!("expected catalog") - }; - assert!(matches!(call.call, ReadToolCall::PageCatalog { .. })); - let pages: Vec<_> = (1..=13).map(|i| format!("1:{i}")).collect(); - session.accept(&call.id, page_output(&pages)).unwrap(); - let mut searched = BTreeSet::new(); - loop { - match session.advance().unwrap() { - CollectorStep::Call(call) => { - let ReadToolCall::SearchSnapshot { node_id, .. } = &call.call else { - panic!("expected page search") - }; - searched.insert(node_id.clone()); - session - .accept(&call.id, page_output(std::slice::from_ref(node_id))) - .unwrap(); - } - CollectorStep::Complete(parts) => { - assert_eq!(parts.stats.figma_tool_calls, 14); - assert_eq!(parts.scope, CollectionScope::File); - break; - } - CollectorStep::AwaitingResults => panic!("all calls were accepted"), - } - } - assert_eq!(searched, pages.into_iter().collect()); - } - - #[test] - fn w5_node_search_finishes_in_one_call_without_page_catalog() { - let target = FigmaTarget { - file_key: "file".into(), - node_id: Some("2:1".into()), - branch_key: None, - }; - let mut request = CollectionRequest::new(target, CollectionScope::Node); - request.search = Some(SearchReadOptions { - query: "Loading".into(), - match_kind: "exact".into(), - limit: 5, - ..Default::default() - }); - let mut session = CollectorSession::new(request); - let CollectorStep::Call(call) = session.advance().unwrap() else { - panic!("expected search call") - }; - assert!( - matches!(&call.call, ReadToolCall::SearchSnapshot {node_id, ..} if node_id == "2:1") - ); - session - .accept( - &call.id, - UpstreamResult { - raw: search_output(TREE), - }, - ) - .unwrap(); - let CollectorStep::Complete(parts) = session.advance().unwrap() else { - panic!("extra Figma call") - }; - assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.scope, CollectionScope::Node); - } -} +// Source sections share this module to preserve privacy and test names. +include!("collector/collection_state.rs"); +include!("collector/w5_tests.rs"); diff --git a/crates/devup-mcp-figma/src/collector/accept.rs b/crates/devup-mcp-figma/src/collector/accept.rs new file mode 100644 index 00000000..6f0e9cdd --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept.rs @@ -0,0 +1,51 @@ +impl CollectorSession { + + pub fn accept(&mut self, call_id: &str, result: UpstreamResult) -> Result<(), DevupError> { + let pending = self + .pending + .remove(call_id) + .ok_or_else(|| invalid_call("Unknown or already-handled Figma call ID."))?; + self.consumed.insert(call_id.to_owned()); + if let Some(served) = crate::BridgeServed::from_result(&result.raw) { + let reads = self.stats.bridge.as_ref().map_or(0, |bridge| bridge.reads) + served.reads; + self.stats.bridge = Some(crate::BridgeServed { reads, ..served }); + } + if let Some(reason) = result.raw["_meta"][crate::bridge::DIRECT_FALLBACK_META_KEY].as_str() + { + self.stats.direct_fallbacks.push(reason.to_owned()); + } + match pending.kind { + CallKind::FastSnapshot => { + self.accept_fast_snapshot(&pending.planned, pending.order, result) + } + CallKind::FastTheme => self.accept_fast_theme(result), + CallKind::Metadata => self.accept_metadata(&pending.planned, result), + CallKind::PageCatalog => self.accept_page_catalog(&pending.planned, result), + CallKind::Snapshot => self.accept_snapshot(&pending.planned, pending.order, result), + CallKind::VariableCatalog => self.accept_variable_catalog(result), + CallKind::VariableBatch => { + let batch = batch_from_result(&result)?; + self.enqueue_style_consumer_batches(&batch)?; + self.variable_batches.insert(pending.order, batch); + Ok(()) + } + CallKind::UsedResourceBatch => { + let batch = batch_from_result(&result)?; + self.variable_batches.insert(pending.order, batch); + Ok(()) + } + CallKind::Explore | CallKind::ScopedSearch => { + self.accept_projection(&pending.planned, pending.order, result) + } + CallKind::SectionIndex => { + self.accept_section_index(&pending.planned, pending.order, result) + } + CallKind::FastMultiRoot => { + self.accept_fast_multi_root(&pending.planned, pending.order, result) + } + CallKind::LargeValue => self.accept_large_value(&pending.planned, result), + CallKind::Asset => self.accept_asset(&pending.planned, result), + CallKind::Screenshot => self.accept_reference_png(&pending.planned, result), + } + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_asset.rs b/crates/devup-mcp-figma/src/collector/accept_asset.rs new file mode 100644 index 00000000..de0ccdeb --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_asset.rs @@ -0,0 +1,91 @@ +impl CollectorSession { + + fn accept_asset( + &mut self, + planned: &PlannedCall, + result: UpstreamResult, + ) -> Result<(), DevupError> { + let ReadToolCall::AssetExport { + version, request, .. + } = &planned.call + else { + return Err(invalid_call("asset call format is invalid.")); + }; + if self.asset_byte_budget_exhausted { + self.record_asset_failure(request.as_ref().clone(), "DEVUP_ASSET_TOTAL_BYTES_EXCEEDED", Some("The retained asset byte budget was reached; export this ID in a smaller batch.".into())); + return Ok(()); + } + let outcome = asset_export_from_result( + &result, + &planned.expected_file_key, + version.as_deref(), + request, + ); + let outcome = match outcome { + Ok(outcome) => outcome, + Err(error) if request.format == crate::AssetFormat::Webp => { + self.record_asset_failure( + request.as_ref().clone(), + "DEVUP_ASSET_WEBP_POSTPROCESS_FAILED", + Some(error.message), + ); + return Ok(()); + } + Err(error) if request.render_mode == crate::AssetRenderMode::OriginalSource => { + self.record_asset_failure( + request.as_ref().clone(), + "DEVUP_ASSET_SOURCE_POSTPROCESS_FAILED", + Some(error.message), + ); + return Ok(()); + } + Err(error) => return Err(error), + }; + let exported = match outcome { + AssetExportOutcome::Entry(entry) => *entry, + AssetExportOutcome::Chunked(descriptor) => { + let key = (descriptor.node_id.clone(), descriptor.field.clone()); + if self.large_values.contains_key(&key) { + return Err(invalid_call( + "Duplicate Figma large value descriptor for the same field.", + )); + } + let options = LargeValueReadOptions::from_descriptor( + &descriptor, + version.clone(), + descriptor.cursor.next_offset, + ); + self.large_values.insert( + key.clone(), + LargeValueAssembler::new( + self.request.target.file_key.clone(), + version.clone(), + descriptor, + )?, + ); + self.chunked_assets.insert( + key, + ( + request.as_ref().clone(), + crate::assets::asset_export_diagnostics(&result), + ), + ); + self.enqueue( + ReadToolCall::large_value(&self.request.target.file_key, options.clone()), + Some(options.node_id), + CallKind::LargeValue, + ); + return Ok(()); + } + }; + if exported.status == AssetStatus::Failed { + let error_code = exported + .error_code + .clone() + .unwrap_or_else(|| "DEVUP_ASSET_EXPORT_FAILED".to_owned()); + self.record_asset_diagnostic(request, &error_code, exported.error_detail.as_deref()); + } + self.retain_asset(exported); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_fast_multi_root.rs b/crates/devup-mcp-figma/src/collector/accept_fast_multi_root.rs new file mode 100644 index 00000000..843c67d8 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_fast_multi_root.rs @@ -0,0 +1,100 @@ +impl CollectorSession { + + fn accept_fast_multi_root( + &mut self, + planned: &PlannedCall, + order: usize, + result: UpstreamResult, + ) -> Result<(), DevupError> { + let ReadToolCall::Snapshot { + script: BuiltinScript::MultiRootSnapshotEnvelope, + root_ids: Some(root_ids), + snapshot: options, + .. + } = &planned.call + else { + return Err(invalid_call("multi-root snapshot call format is invalid.")); + }; + let requested_offset = options.as_ref().map_or(0, |options| options.offset); + let payload = match decode_fast_multi_snapshot(&result, &self.request.target, root_ids) { + Ok(payload) => payload, + Err(error) if requested_offset == 0 && fast_call_fallback_allowed(&error) => { + self.fallback_multi_root_batch(planned, fallback_category(&error))?; + return Ok(()); + } + Err(error) => return Err(error), + }; + let mut chunk = payload.snapshot; + let cursor = take_snapshot_cursor(&mut chunk)?; + if let Some(cursor) = &cursor { + if cursor.offset != requested_offset + || requested_offset.checked_add(chunk.nodes.len()) != Some(cursor.next_offset) + || cursor.next_offset > cursor.total_nodes + || cursor.complete != (cursor.next_offset == cursor.total_nodes) + || (!cursor.complete && cursor.next_offset <= requested_offset) + { + return Err(invalid_call( + "Figma multi-root snapshot cursor does not match the requested node range.", + )); + } + } else if requested_offset != 0 { + return Err(invalid_call( + "Figma multi-root continuation is missing its snapshot cursor.", + )); + } + if let (Some(existing), Some(incoming)) = (&self.source_version, &chunk.version) + && existing != incoming + { + return Err(DevupError::new( + ErrorCode::DevupFigmaVersionChanged, + "The Figma file version changed during multi-root collection.", + true, + )); + } + if chunk.version.is_some() { + self.source_version = chunk.version.clone(); + } + self.fast_multi_has_large_values |= !descriptors_in_chunk(&chunk)?.is_empty(); + merge_fast_resources(&mut self.fast_multi_resources, payload.resources)?; + self.stats.transport = if self.section_fallback_roots.is_empty() { + if requested_offset > 0 + || self.stats.transport == "text-paginated" + || cursor.as_ref().is_some_and(|cursor| !cursor.complete) + { + "text-paginated" + } else { + payload.stats.transport + } + } else { + "hybrid-multi-root-cursor" + } + .to_owned(); + 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); + self.stats.envelope_chunks = self + .stats + .envelope_chunks + .saturating_add(payload.stats.chunk_count); + self.record_snapshot_chunk(order, chunk)?; + if let Some(cursor) = cursor.filter(|cursor| !cursor.complete) { + // Keep this Section and this exact root set: another sibling may + // have a different cursor in flight. Never cache a first page as + // a complete design while its vector operands remain unread. + let mut continuation = planned.call.clone(); + if let ReadToolCall::Snapshot { snapshot, .. } = &mut continuation { + let mut options = options.clone().unwrap_or_default(); + options.offset = cursor.next_offset; + *snapshot = Some(options); + } + self.enqueue( + continuation, + planned.expected_node_id.clone(), + CallKind::FastMultiRoot, + ); + } + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_fast_snapshot.rs b/crates/devup-mcp-figma/src/collector/accept_fast_snapshot.rs new file mode 100644 index 00000000..5c57b77a --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_fast_snapshot.rs @@ -0,0 +1,103 @@ +impl CollectorSession { + + fn accept_fast_snapshot( + &mut self, + planned: &PlannedCall, + order: usize, + result: UpstreamResult, + ) -> Result<(), DevupError> { + let payload = match decode_fast_snapshot(&result, &self.request.target) { + Ok(payload) => payload, + Err(error) => { + if let Ok(chunk) = snapshot_chunk_from_result(&result) + && chunk.root_ids.as_slice() + == [self.request.target.node_id.as_deref().unwrap_or_default()] + && chunk.nodes.iter().any(|node| { + node.id == self.request.target.node_id.as_deref().unwrap_or_default() + && node.node_type == "SECTION" + && node.typed_view().value("projectionTruncated").is_some() + }) + { + self.request.section = Some(SectionReadOptions { + frame_ids: Vec::new(), + all_screens: false, + }); + return self.accept_section_index(planned, order, result); + } + self.restart_legacy(fallback_category(&error)); + return Ok(()); + } + }; + let root_id = self + .request + .target + .node_id + .clone() + .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.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(); + 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(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_fast_theme.rs b/crates/devup-mcp-figma/src/collector/accept_fast_theme.rs new file mode 100644 index 00000000..1b994028 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_fast_theme.rs @@ -0,0 +1,76 @@ +impl CollectorSession { + + fn accept_fast_theme(&mut self, result: UpstreamResult) -> Result<(), DevupError> { + let payload = match decode_fast_theme(&result, &self.request.target.file_key) { + Ok(payload) => payload, + Err(error) => { + self.restart_legacy(fallback_category(&error)); + return Ok(()); + } + }; + self.fast_theme_page_count += 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); + // A page is merged onto the pages before it: the resources are + // concatenated in the one order the script lists them, and the scan + // every page repeats - which ids are used, what could not be + // resolved - is taken from the page at hand. + let merged = match self.fast_theme_pages.take() { + None => payload.resources.raw, + Some(mut merged) => { + merge_theme_page(&mut merged, payload.resources.raw)?; + merged + } + }; + if let Some(page) = payload.page + && !page.complete + { + if page.next_offset <= page.offset { + return Err(invalid_call("Figma theme page cursor did not advance.")); + } + self.fast_theme_pages = Some(merged); + self.enqueue( + ReadToolCall::fast_theme_page(&self.request.target.file_key, page.next_offset), + None, + CallKind::FastTheme, + ); + return Ok(()); + } + // The remote variables among those collected, which a page does not + // list twice. + let mut merged = merged; + let remote = merged["variables"] + .as_array() + .map(|variables| { + variables + .iter() + .filter(|variable| { + variable.get("remote").and_then(Value::as_bool) == Some(true) + }) + .cloned() + .collect::>() + }) + .unwrap_or_default(); + if merged["usedRemoteVariables"] + .as_array() + .is_none_or(Vec::is_empty) + { + merged["usedRemoteVariables"] = Value::Array(remote); + } + self.metadata = Some(json!({ + "transport": payload.stats.transport, + "pageCount": self.fast_theme_page_count, + "collectionCount": merged["collections"].as_array().map_or(0, Vec::len), + "variableCount": merged["variables"].as_array().map_or(0, Vec::len), + "styleCount": merged["styles"].as_array().map_or(0, Vec::len) + })); + self.source_version = payload.source_version; + self.stats.transport = payload.stats.transport.to_owned(); + self.stats.envelope_chunks = self.fast_theme_page_count; + self.variables = Some(UpstreamResult { raw: merged }); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_large_value.rs b/crates/devup-mcp-figma/src/collector/accept_large_value.rs new file mode 100644 index 00000000..6b982278 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_large_value.rs @@ -0,0 +1,130 @@ +impl CollectorSession { + + fn accept_large_value( + &mut self, + planned: &PlannedCall, + result: UpstreamResult, + ) -> Result<(), DevupError> { + let ReadToolCall::LargeValue { options, .. } = &planned.call else { + return Err(invalid_call("large value call format is invalid.")); + }; + let result = large_value_from_result(&result)?; + if let LargeValueResult::Unsupported(unsupported) = result { + if unsupported.file_key != planned.expected_file_key + || unsupported.version != options.version + || unsupported.node_id != options.node_id + || unsupported.field != options.field + || unsupported.byte_length != options.byte_length + || unsupported.sha256 != options.sha256 + || unsupported.error_code != "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM" + { + return Err(invalid_call( + "large value unsupported response does not match the request.", + )); + } + return self.record_large_value_unsupported( + options, + &unsupported.error_code, + unsupported.error_detail.as_deref(), + ); + } + let LargeValueResult::Fragment(fragment) = result else { + unreachable!() + }; + if fragment.offset != options.offset { + return Err(invalid_call( + "large value fragment offset does not match the request.", + )); + } + let key = (options.node_id.clone(), options.field.clone()); + let next_offset = fragment.next_offset; + let complete = fragment.complete; + let assembler = self + .large_values + .get_mut(&key) + .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 is missing."))?; + if let Some((request, diagnostics)) = self.chunked_assets.remove(&key) { + // The fragments were an SVG or PNG export, not a field: the + // bytes are the asset, and go where a one-answer export would. + let bytes = match assembler.finish_bytes() { + Ok(bytes) => bytes, + Err(error) if request.format == crate::AssetFormat::Webp => { + self.record_asset_failure( + request, + "DEVUP_ASSET_WEBP_POSTPROCESS_FAILED", + Some(error.message), + ); + return Ok(()); + } + Err(error) => return Err(error), + }; + let mut asset = match exported_asset_from_bytes(&request, &bytes) { + Ok(asset) => asset, + Err(error) if request.format == crate::AssetFormat::Webp => { + self.record_asset_failure( + request, + "DEVUP_ASSET_WEBP_POSTPROCESS_FAILED", + Some(error.message), + ); + return Ok(()); + } + Err(error) => return Err(error), + }; + if let Some(mut observed) = diagnostics.as_ref().and_then(|d| d.render_info.clone()) + { + if request.format == crate::AssetFormat::Webp { + // Preserve postprocessing evidence and measure the final bytes, + // not the PNG fragments from which they were encoded. + if let Some(info) = &asset.render_info { + for key in ["representation", "compression", "transcoded"] { + observed[key] = info[key].clone(); + } + } + } + let output = asset + .data_base64 + .as_deref() + .and_then(|s| STANDARD.decode(s).ok()); + asset.render_info = Some(crate::asset_render_info::measured_info( + request.render_mode, + request.format, + request.scale, + Some(observed), + output.as_deref(), + )); + } + asset.export_diagnostics = diagnostics; + if let Some(diagnostics) = &mut asset.export_diagnostics { + diagnostics.render_info = asset.render_info.clone(); + } + self.retain_asset(asset); + return Ok(()); + } + 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 did not advance.")); + } + let descriptor = assembler.descriptor().clone(); + let next = LargeValueReadOptions::from_descriptor( + &descriptor, + options.version.clone(), + next_offset, + ); + self.enqueue( + ReadToolCall::large_value(&self.request.target.file_key, next.clone()), + Some(next.node_id), + CallKind::LargeValue, + ); + } + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_metadata.rs b/crates/devup-mcp-figma/src/collector/accept_metadata.rs new file mode 100644 index 00000000..64770681 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_metadata.rs @@ -0,0 +1,130 @@ +impl CollectorSession { + + fn accept_metadata( + &mut self, + planned: &PlannedCall, + result: UpstreamResult, + ) -> Result<(), DevupError> { + let metadata = metadata_from_result_for_target( + &result, + &self.request.target.file_key, + planned.expected_node_id.as_deref(), + )?; + if let MetadataResult::TopLevelPages(pages) = metadata { + if planned.expected_node_id.is_some() { + return Err(invalid_call( + "A page metadata request returned the top-level page list.", + )); + } + self.record_metadata(result.raw); + self.root_node_id = pages.first().map(|page| page.id.clone()); + for page in &pages { + self.record_metadata_node(page); + if !self.metadata_root_ids.contains(&page.id) { + self.metadata_root_ids.push(page.id.clone()); + } + } + if let Some(options) = self.request.search.clone() { + for page in pages { + self.enqueue( + ReadToolCall::search_snapshot( + &self.request.target.file_key, + &page.id, + options.clone(), + ), + Some(page.id), + CallKind::Snapshot, + ); + } + return Ok(()); + } + if self.request.variables_only { + return Ok(()); + } + for page in pages { + self.enqueue( + ReadToolCall::metadata(&self.request.target.file_key, Some(&page.id)), + Some(page.id), + CallKind::Metadata, + ); + } + return Ok(()); + } + let MetadataResult::Document(document) = metadata else { + 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 does not match the request.", + )); + } + if let (Some(existing), Some(incoming)) = (&self.source_version, &document.version) + && existing != incoming + { + return Err(DevupError::new( + ErrorCode::DevupFigmaVersionChanged, + "The Figma file version changed during metadata collection.", + true, + )); + } + if document.version.is_some() { + self.source_version = document.version.clone(); + } + if self.root_node_id.is_none() { + self.root_node_id = Some(document.root_id.clone()); + } + if !self.metadata_root_ids.contains(&document.root_id) { + self.metadata_root_ids.push(document.root_id.clone()); + } + for node in &document.nodes { + self.record_metadata_node(node); + } + self.record_metadata(result.raw); + let root = document.root().ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Target node not found in the Figma metadata.", + false, + ) + })?; + if let Some(options) = self.request.search.clone() { + self.enqueue( + ReadToolCall::search_snapshot(&self.request.target.file_key, &root.id, options), + Some(root.id.clone()), + CallKind::Snapshot, + ); + return Ok(()); + } + if self.request.metadata_only || self.request.variables_only { + return Ok(()); + } + let split = !root.children_ids.is_empty() + && (root.descendant_count > LARGE_SUBTREE_THRESHOLD + || matches!( + self.request.scope, + CollectionScope::Page | CollectionScope::File + )); + let mut targets = if split { + root.children_ids.clone() + } else { + vec![root.id.clone()] + }; + // 노드 스코프에서 변환 대상은 루트 자신이다. 자식으로 쪼갠 뒤 루트를 빼면 + // 투영 단계가 대상을 찾지 못한다. + if split && self.request.scope == CollectionScope::Node && !targets.contains(&root.id) { + targets.push(root.id.clone()); + } + for node_id in targets { + self.enqueue( + ReadToolCall::snapshot( + &self.request.target.file_key, + &node_id, + BuiltinScript::NodeSnapshot, + ), + Some(node_id), + CallKind::Snapshot, + ); + } + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_page_catalog.rs b/crates/devup-mcp-figma/src/collector/accept_page_catalog.rs new file mode 100644 index 00000000..9c990c84 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_page_catalog.rs @@ -0,0 +1,46 @@ +impl CollectorSession { + + fn accept_page_catalog( + &mut self, + planned: &PlannedCall, + result: UpstreamResult, + ) -> Result<(), DevupError> { + let catalog = snapshot_chunk_from_result(&result)?; + if catalog.file_key != planned.expected_file_key { + return Err(invalid_call( + "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 is empty.", + false, + )); + } + let options = self + .request + .search + .clone() + .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(); + self.metadata_root_ids = catalog.root_ids.clone(); + for page in catalog.nodes { + self.metadata_nodes.insert(page.id.clone(), page); + } + for page_id in catalog.root_ids { + self.enqueue( + ReadToolCall::search_snapshot( + &self.request.target.file_key, + &page_id, + options.clone(), + ), + Some(page_id), + CallKind::Snapshot, + ); + } + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_projection.rs b/crates/devup-mcp-figma/src/collector/accept_projection.rs new file mode 100644 index 00000000..6c3a2ee9 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_projection.rs @@ -0,0 +1,33 @@ +impl CollectorSession { + + fn accept_projection( + &mut self, + planned: &PlannedCall, + order: usize, + result: UpstreamResult, + ) -> Result<(), DevupError> { + let chunk = snapshot_chunk_from_result(&result)?; + if chunk.file_key != planned.expected_file_key { + return Err(invalid_call( + "Figma 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 is missing."))?; + if !chunk.nodes.iter().any(|node| node.id == expected_node_id) { + return Err(DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "anchor node not found in the Figma projection.", + false, + )); + } + self.source_version = chunk.version.clone(); + self.root_node_id = Some(expected_node_id.to_owned()); + self.metadata_root_ids = chunk.root_ids.clone(); + self.metadata = Some(result.raw); + self.record_snapshot_chunk(order, chunk)?; + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_reference_png.rs b/crates/devup-mcp-figma/src/collector/accept_reference_png.rs new file mode 100644 index 00000000..4c73b020 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_reference_png.rs @@ -0,0 +1,50 @@ +impl CollectorSession { + + fn accept_reference_png( + &mut self, + planned: &PlannedCall, + result: UpstreamResult, + ) -> Result<(), DevupError> { + let ReadToolCall::Screenshot { file_key, node_id } = &planned.call else { + 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 target does not match the request.", + )); + } + let note = result.raw["_meta"][crate::bridge::REFERENCE_PNG_NOTE_META_KEY] + .as_str() + .map(str::to_owned); + 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 exceeds the allowed size.", + false, + )); + } + let bytes = STANDARD + .decode(data_base64.as_bytes()) + .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 format or size is invalid.", + )); + } + validate_reference_png(&bytes)?; + self.reference_png = Some(ReferencePng { + mime_type: "image/png".to_owned(), + data_base64, + byte_length: bytes.len(), + sha256: Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + note, + }); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_section_index.rs b/crates/devup-mcp-figma/src/collector/accept_section_index.rs new file mode 100644 index 00000000..d90ab399 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_section_index.rs @@ -0,0 +1,82 @@ +impl CollectorSession { + + fn accept_section_index( + &mut self, + planned: &PlannedCall, + order: usize, + result: UpstreamResult, + ) -> Result<(), DevupError> { + let chunk = snapshot_chunk_from_result(&result)?; + if chunk.file_key != planned.expected_file_key { + return Err(invalid_call( + "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 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 does not match the requested Section.", + )); + } + let snapshot = merge_chunks(vec![chunk.clone()])?; + let index = build_section_index(&snapshot, &self.request.target)?; + let options = self + .request + .section + .clone() + .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(); + self.metadata = Some(json!({ + "transport": "section-index-v1", + "sectionIndex": &index + })); + self.section_index = Some(index.clone()); + + if options.frame_ids.is_empty() && !options.all_screens { + self.request.resource_scope = ResourceScope::None; + self.variables = Some(empty_used_resources()); + self.record_snapshot_chunk(order, chunk)?; + return Ok(()); + } + + let selected = index.select(&options.frame_ids, options.all_screens)?; + let batches = plan_batches(&index, &selected, BatchLimits::default())?; + self.section_selected_roots = selected.clone(); + self.root_node_id = selected.first().cloned(); + self.metadata_root_ids = selected.clone(); + self.metadata = Some(json!({ + "transport": "section-multi-root-v1", + "sectionIndex": &index, + "selectedRootIds": &selected, + "batchCount": batches.len() + })); + for batch in batches { + if batch.oversized { + self.mark_section_legacy("oversized-section-root".to_owned()); + for root_id in batch.root_ids { + self.enqueue_section_legacy_root(root_id); + } + } else { + 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(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_snapshot.rs b/crates/devup-mcp-figma/src/collector/accept_snapshot.rs new file mode 100644 index 00000000..87ab3c78 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_snapshot.rs @@ -0,0 +1,71 @@ +impl CollectorSession { + + fn accept_snapshot( + &mut self, + planned: &PlannedCall, + order: usize, + result: UpstreamResult, + ) -> 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 does not match the request.", + )); + } + if chunk.version != self.source_version { + return Err(DevupError::new( + ErrorCode::DevupFigmaVersionChanged, + "The Figma file version changed during collection.", + true, + )); + } + let pagination = match &planned.call { + ReadToolCall::Snapshot { + script: BuiltinScript::NodeSnapshot, + snapshot: Some(options), + .. + } => Some(options.clone()), + _ => None, + }; + if let Some(options) = pagination + && let Some(cursor) = take_snapshot_cursor(&mut chunk)? + { + let expected_next = options + .offset + .checked_add(chunk.nodes.len()) + .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 does not match the collected node range.", + )); + } + if cursor.complete != (cursor.next_offset >= cursor.total_nodes) { + return Err(invalid_call( + "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 did not advance.")); + } + let node_id = planned.expected_node_id.clone().ok_or_else(|| { + invalid_call("Figma snapshot cursor root node ID is missing.") + })?; + self.enqueue( + ReadToolCall::snapshot_chunk( + &self.request.target.file_key, + &node_id, + SnapshotReadOptions { + offset: cursor.next_offset, + ..options + }, + ), + Some(node_id), + CallKind::Snapshot, + ); + } + } + self.record_snapshot_chunk(order, chunk)?; + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/accept_variable_catalog.rs b/crates/devup-mcp-figma/src/collector/accept_variable_catalog.rs new file mode 100644 index 00000000..78f0f465 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/accept_variable_catalog.rs @@ -0,0 +1,45 @@ +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("No root node ID available for the Figma variable batch.") + })?; + for variable_ids in catalog + .variable_ids + .chunks(self.request.batch_budget.variable_items.max(1)) + { + self.enqueue( + ReadToolCall::resource_batch( + &self.request.target.file_key, + &node_id, + ResourceBatch { + variable_ids: variable_ids.to_vec(), + styles: Vec::new(), + }, + ), + Some(node_id.clone()), + CallKind::VariableBatch, + ); + } + for styles in catalog + .styles + .chunks(self.request.batch_budget.style_items.max(1)) + { + self.enqueue( + ReadToolCall::resource_batch( + &self.request.target.file_key, + &node_id, + ResourceBatch { + variable_ids: Vec::new(), + styles: styles.to_vec(), + }, + ), + Some(node_id.clone()), + CallKind::VariableBatch, + ); + } + self.variable_catalog = Some(catalog); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/advance.rs b/crates/devup-mcp-figma/src/collector/advance.rs new file mode 100644 index 00000000..97a2b3c5 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/advance.rs @@ -0,0 +1,410 @@ +impl CollectorSession { + + pub fn advance(&mut self) -> Result { + if self.completed { + 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() + { + self.prepare_cached_section_index(index)?; + return self.advance(); + } + 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 requires a node ID."))?; + let mut call = ReadToolCall::section_index(&self.request.target.file_key, &node_id); + if let ReadToolCall::Snapshot { root_ids, .. } = &mut call { + *root_ids = self.request.section.as_ref().map(|s| s.frame_ids.clone()); + } + self.enqueue(call, Some(node_id), CallKind::SectionIndex); + return self.advance(); + } + if self.fast_theme_eligible() && !self.fast_attempted { + self.fast_attempted = true; + self.enqueue( + ReadToolCall::fast_theme(&self.request.target.file_key), + None, + CallKind::FastTheme, + ); + return self.advance(); + } + if let Some(options) = self.request.explore.clone() { + let node_id = self.request.target.node_id.clone().ok_or_else(|| { + invalid_call("Figma nearby-screen exploration requires a node ID.") + })?; + self.enqueue( + ReadToolCall::explore_snapshot( + &self.request.target.file_key, + &node_id, + options, + ), + Some(node_id), + CallKind::Explore, + ); + return self.advance(); + } + if let Some(options) = self.request.search.clone() { + if self.request.scope == CollectionScope::Node { + let node_id = self.request.target.node_id.clone().ok_or_else(|| { + invalid_call("Node-scoped Figma search requires a node ID.") + })?; + self.enqueue( + ReadToolCall::search_snapshot( + &self.request.target.file_key, + &node_id, + options, + ), + Some(node_id), + CallKind::ScopedSearch, + ); + return self.advance(); + } + self.enqueue( + ReadToolCall::page_catalog(&self.request.target.file_key), + None, + CallKind::PageCatalog, + ); + 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 requires a node ID."))?; + self.fast_attempted = true; + self.enqueue( + ReadToolCall::fast_snapshot(&self.request.target.file_key, &node_id), + Some(node_id), + CallKind::FastSnapshot, + ); + return self.advance(); + } + let node_id = match self.request.scope { + CollectionScope::File => None, + CollectionScope::Node | CollectionScope::Page => { + self.request.target.node_id.as_deref() + } + }; + self.enqueue( + ReadToolCall::metadata(&self.request.target.file_key, node_id), + node_id.map(str::to_owned), + CallKind::Metadata, + ); + } + let pending_limit = if self.request.section.is_some() { + 2 + } else { + MAX_PENDING_CALLS + }; + if self.pending.len() < pending_limit + && let Some(call) = self.queued.pop_front() + { + self.pending.insert(call.planned.id.clone(), call.clone()); + self.stats.figma_tool_calls = self.stats.figma_tool_calls.saturating_add(1); + return Ok(CollectorStep::Call(Box::new(call.planned))); + } + if !self.pending.is_empty() { + return Ok(CollectorStep::AwaitingResults); + } + if self.request.section.is_some() + && !self.section_selected_roots.is_empty() + && self.variables.is_none() + && self.section_fallback_roots.is_empty() + && !self.fast_multi_has_large_values + && let Some(resources) = self.fast_multi_resources.take() + { + self.variables = Some(resources); + } + if self.metadata.is_some() + && !self.assets_scheduled + && !self.snapshot_chunks.is_empty() + && self.queued.is_empty() + { + let snapshot = merge_chunks(self.snapshot_chunks.values().cloned().collect())?; + if !self.asset_plan_initialized { + self.asset_plan_initialized = true; + if self.request.web_ready_images { + let census = crate::discover_web_ready_assets(&snapshot); + self.request.asset_selections = census + .iter() + .map(|a| AssetSelection { + usage_aspect_ratio: None, + max_width: snapshot + .nodes + .get(&a.node_id) + .and_then(|n| n.typed_view().number("width")) + .filter(|w| w.is_finite() && *w > 0.0) + .map(|w| (w * 2.0).ceil().clamp(1.0, 8192.0) as u32), + srcset: false, + visible_scope_node_id: None, + asset_id: a.asset_id.clone(), + format: crate::AssetFormat::Webp, + scale: 2.0, + render_mode: crate::AssetRenderMode::OriginalSource, + quality: Some(85), + lossless: Some(false), + }) + .collect(); + for (index, asset) in census.iter().enumerate() { + if asset.image_hash.is_some() && index < 4096 { + continue; + } + let mut failure = asset.clone(); + failure.status = AssetStatus::Failed; + if index >= 4096 { + failure.error_code = Some("DEVUP_ASSET_PLAN_LIMIT".into()); + failure.error_detail=Some("Preset scheduling is bounded to4096 IMAGE sources; recollect failed IDs in a smaller scope.".into()); + } else if asset.image_hash.is_none() { + failure.error_code = Some("DEVUP_ORIGINAL_SOURCE_UNAVAILABLE".into()); + failure.error_detail = Some( + "The acquired IMAGE paint has no resolvable source hash.".into(), + ); + } + failure.render_mode = crate::AssetRenderMode::OriginalSource; + failure.format = Some(crate::AssetFormat::Webp); + failure.scale = Some(2.0); + failure.max_width = self.request.asset_selections[index].max_width; + failure.quality = Some(85); + failure.lossless = Some(false); + self.asset_results.push(failure); + } + } + let selections = self + .request + .asset_selections + .iter() + .filter(|s| !self.asset_results.iter().any(|a| a.asset_id == s.asset_id)) + .cloned() + .collect::>(); + for batch in selections.chunks(6) { + self.asset_plan + .extend(resolve_asset_selections(&snapshot, batch)?); + } + } + // A later batch is scheduled only after every call/fragment in this + // batch has settled. The ordinary pending-call bound still applies. + if self.asset_byte_budget_exhausted { + while let Some(request) = self.asset_plan.pop_front() { + self.record_asset_failure(request, "DEVUP_ASSET_TOTAL_BYTES_EXCEEDED", Some("The 128 MiB retained asset budget was reached. Export failed IDs in smaller custom batches or select a smaller frame.".into())); + } + } + let mut nodes = BTreeSet::new(); + let mut batch = Vec::new(); + let remaining = self.asset_plan.len(); + for _ in 0..remaining { + let request = self.asset_plan.pop_front().unwrap(); + if batch.len() < 6 && nodes.insert(request.node_id.clone()) { + batch.push(request); + } else { + self.asset_plan.push_back(request); + } + } + self.assets_scheduled = self.asset_plan.is_empty(); + for request in batch { + if self.asset_byte_budget_exhausted { + self.record_asset_failure(request, "DEVUP_ASSET_TOTAL_BYTES_EXCEEDED", Some("The 128 MiB retained asset budget was reached. Export the failed asset IDs in smaller custom batches or select a smaller frame.".into())); + continue; + } + self.enqueue( + ReadToolCall::asset_export( + &self.request.target.file_key, + self.source_version.clone(), + request.clone(), + ), + Some(request.node_id.clone()), + CallKind::Asset, + ); + } + if !self.queued.is_empty() { + return self.advance(); + } + } + if self.metadata.is_some() + && !self.reference_png_scheduled + && !self.snapshot_chunks.is_empty() + && self.queued.is_empty() + { + let node_id = self.request.target.node_id.clone().ok_or_else(|| { + invalid_call("Figma reference PNG collection requires a node ID.") + })?; + self.reference_png_scheduled = true; + self.enqueue( + ReadToolCall::screenshot(&self.request.target.file_key, &node_id), + Some(node_id), + CallKind::Screenshot, + ); + return self.advance(); + } + if self.metadata.is_some() + && self.request.resource_scope == ResourceScope::File + && self.variable_catalog.is_none() + && self.file_local_resources.is_none() + && self.variables.is_none() + && self.queued.is_empty() + { + 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, + &node_id, + BuiltinScript::VariableCatalog, + ), + Some(node_id), + CallKind::VariableCatalog, + ); + return self.advance(); + } + if self.metadata.is_some() + && self.request.resource_scope == ResourceScope::File + && self.variable_catalog.is_some() + && self.variables.is_none() + && self.queued.is_empty() + { + let catalog = self + .variable_catalog + .take() + .ok_or_else(|| invalid_call("Figma variable catalog is missing."))?; + let local = merge_variable_results( + catalog, + std::mem::take(&mut self.variable_batches).into_values(), + )?; + // The catalog is the file's own variables and styles. The library + // ones its nodes use, and those its variables alias, were left + // out, so a theme of the whole file knew only the local half: a + // local spacing aliasing a library primitive had no value. They + // are read as a used-resource read reads them and joined to it. + let chunks = self.snapshot_chunks.values().cloned().collect::>(); + let refs = library_refs(&local, collect_used_resource_refs(&chunks)); + if refs.variable_ids.is_empty() && refs.styles.is_empty() { + self.variables = Some(join_used_resources(local, None)); + } else { + let node_id = self.root_node_id.clone().ok_or_else(|| { + invalid_call("No root node ID available for the used Figma resource batch.") + })?; + for batch in used_resource_batches(&refs, self.request.batch_budget)? { + self.enqueue( + ReadToolCall::used_resources( + &self.request.target.file_key, + &node_id, + batch, + ), + Some(node_id.clone()), + CallKind::UsedResourceBatch, + ); + } + self.used_resource_refs = Some(refs); + self.file_local_resources = Some(local); + return self.advance(); + } + } + if self.metadata.is_some() + && self.request.resource_scope == ResourceScope::File + && self.file_local_resources.is_some() + && self.variables.is_none() + && self.queued.is_empty() + { + let refs = self + .used_resource_refs + .take() + .ok_or_else(|| invalid_call("Used Figma resource references are missing."))?; + let local = self + .file_local_resources + .take() + .ok_or_else(|| invalid_call("The file's own Figma resources are missing."))?; + let merged = merge_used_resource_results( + &refs, + std::mem::take(&mut self.variable_batches).into_values(), + )?; + self.record_unresolved_diagnostics(&refs, &merged.unresolved); + self.variables = Some(join_used_resources(local, Some(merged.result))); + } + if self.metadata.is_some() + && self.request.resource_scope == ResourceScope::Used + && self.used_resource_refs.is_none() + && self.variables.is_none() + && self.queued.is_empty() + { + self.enqueue_used_resource_batches()?; + if !self.queued.is_empty() { + return self.advance(); + } + } + if self.metadata.is_some() + && self.request.resource_scope == ResourceScope::Used + && self.used_resource_refs.is_some() + && self.variables.is_none() + && self.queued.is_empty() + { + let refs = self + .used_resource_refs + .take() + .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(), + )?; + self.record_unresolved_diagnostics(&refs, &merged.unresolved); + let mut result = Some(merged.result); + if !self.section_fallback_roots.is_empty() + && !self.fast_multi_has_large_values + && let Some(fast_resources) = self.fast_multi_resources.take() + { + let mut combined = Some(fast_resources); + merge_fast_resources( + &mut combined, + result + .take() + .ok_or_else(|| invalid_call("fallback resource result is missing."))?, + )?; + result = combined; + } + self.variables = result; + } + if self.metadata.is_some() && self.queued.is_empty() { + self.completed = true; + let snapshot_chunks = if self.request.metadata_only || self.request.variables_only { + vec![SnapshotChunk { + file_key: self.request.target.file_key.clone(), + version: self.source_version.clone(), + root_ids: std::mem::take(&mut self.metadata_root_ids), + nodes: std::mem::take(&mut self.metadata_nodes) + .into_values() + .collect(), + diagnostics: Vec::new(), + }] + } else { + let chunks = std::mem::take(&mut self.snapshot_chunks) + .into_values() + .collect(); + self.restore_section_visual_order(chunks)? + }; + self.finish_stats(&snapshot_chunks); + return Ok(CollectorStep::Complete(Box::new(CollectedParts { + target: self.request.target.clone(), + scope: self.request.scope, + metadata: self.metadata.take().unwrap_or(Value::Null), + snapshot_chunks, + variables: self.variables.clone(), + styles: self.variables.clone(), + source_version: self.source_version.clone(), + 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) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/asset_progress.rs b/crates/devup-mcp-figma/src/collector/asset_progress.rs new file mode 100644 index 00000000..2c47c10d --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/asset_progress.rs @@ -0,0 +1,14 @@ +impl CollectorSession { + + /// Validated per-asset acquisition results retained before final payload assembly. + /// Binary data is deliberately not part of progress responses. + pub fn asset_progress(&self) -> Vec { + self.request.asset_selections.iter().map(|selection| { + match self.asset_results.iter().find(|asset| asset.asset_id == selection.asset_id) { + Some(asset) => serde_json::json!({"assetId":asset.asset_id,"status":asset.status,"format":asset.format,"scale":asset.scale,"quality":asset.quality,"lossless":asset.lossless,"usageAspectRatio":asset.usage_aspect_ratio,"maxWidth":asset.max_width,"srcset":asset.srcset,"visibleScopeNodeId":asset.visible_scope_node_id, + "byteLength":asset.byte_length,"sha256":asset.sha256,"errorCode":asset.error_code,"errorDetail":asset.error_detail,"exportDiagnostics":asset.export_diagnostics,"renderMode":asset.render_mode,"renderInfo":asset.render_info}), + None => serde_json::json!({"assetId":selection.asset_id,"renderMode":selection.render_mode,"format":selection.format,"scale":selection.scale,"quality":selection.quality,"lossless":selection.lossless,"usageAspectRatio":selection.usage_aspect_ratio,"maxWidth":selection.max_width,"srcset":selection.srcset,"visibleScopeNodeId":selection.visible_scope_node_id,"status":"pending"}), + } + }).collect() + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/collection_state.rs b/crates/devup-mcp-figma/src/collector/collection_state.rs new file mode 100644 index 00000000..ff771f73 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/collection_state.rs @@ -0,0 +1,737 @@ +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + io::Cursor, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use image::{ImageDecoder, ImageError, Limits, codecs::png::PngDecoder}; +use rmcp::model::{CallToolResult, ContentBlock}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; + +use crate::large_values::{ + LargeValueResult, descriptors_in_chunk, large_value_from_result, replace_descriptor, +}; +use crate::{ + AssetExportOutcome, AssetManifestEntry, AssetRequest, AssetSelection, AssetStatus, BatchBudget, + BatchLimits, BuiltinScript, DevupError, ErrorCode, ExploreReadOptions, FigmaTarget, + LargeValueAssembler, LargeValueReadOptions, RawNode, ReadToolCall, ResourceBatch, + ResourceScope, ResourceStyleRef, 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, exported_asset_from_bytes, + merge_chunks, + metadata::{MetadataResult, metadata_from_result_for_target}, + plan_batches, read_snapshot_cursor, resolve_asset_selections, snapshot_chunk_from_result, + variables::{ + VariableBatchResult, VariableCatalog, batch_from_result, catalog_from_result, + join_used_resources, library_refs, merge_used_resource_results, merge_variable_results, + }, +}; + +const LARGE_SUBTREE_THRESHOLD: usize = 200; + +const MAX_PENDING_CALLS: usize = 4; +pub const MAX_COLLECTED_ASSET_BYTES: usize = 128 * 1024 * 1024; +// The byte budget below measures the *request* — the IDs — and the ceiling +// that matters is on the *response*: the official MCP cuts a text result at +// 20,480 UTF-8 bytes (measured; see `scripts/fast_snapshot.js`) and appends +// "// truncated to 20kb", which the batch decoder then cannot parse. A variable serialised with its valuesByMode and +// codeSyntax is five hundred to a thousand bytes on the way back, so the +// sixty-four this allowed could not fit; thirty-eight were measured filling +// the cap exactly. Twelve leaves the response near a third of it. +// 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 MAX_REFERENCE_PNG_BYTES: usize = 16 * 1024 * 1024; +const MAX_REFERENCE_PNG_BASE64_BYTES: usize = MAX_REFERENCE_PNG_BYTES.div_ceil(3) * 4; +pub(crate) const MAX_REFERENCE_PNG_DIMENSION: u32 = 8_192; +const MAX_REFERENCE_PNG_DECODED_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CollectionScope { + Node, + Page, + File, +} + +#[derive(Debug, Clone)] +pub struct CollectionRequest { + /// 이 수집을 실어 나를 전송이 한 번에 받을 수 있는 양. + pub batch_budget: BatchBudget, + pub target: FigmaTarget, + pub scope: CollectionScope, + pub resource_scope: ResourceScope, + pub include_context: bool, + pub metadata_only: bool, + pub variables_only: bool, + pub search: Option, + pub explore: Option, + pub section: Option, + pub cached_section_index: Option, + pub asset_selections: Vec, + pub web_ready_images: bool, + pub reference_png: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SectionReadOptions { + #[serde(default)] + pub frame_ids: Vec, + #[serde(default)] + pub all_screens: bool, +} + +impl CollectionRequest { + pub fn new(target: FigmaTarget, scope: CollectionScope) -> Self { + Self { + target, + scope, + // 자르는 전송을 전제로 한 값. 브리지로 수집할 때는 호출자가 + // `FigmaUpstream::batch_budget` 에서 받아 덮어쓴다. + batch_budget: BatchBudget::default(), + resource_scope: ResourceScope::None, + include_context: false, + metadata_only: false, + variables_only: false, + search: None, + explore: None, + section: None, + cached_section_index: None, + asset_selections: Vec::new(), + web_ready_images: false, + reference_png: false, + } + } +} + +#[derive(Debug, Clone)] +pub struct PlannedCall { + pub id: String, + pub call: ReadToolCall, + pub expected_file_key: String, + pub expected_node_id: Option, +} + +#[derive(Debug, Clone)] +pub struct CollectedParts { + pub target: FigmaTarget, + pub scope: CollectionScope, + pub metadata: Value, + pub snapshot_chunks: Vec, + pub variables: Option, + pub styles: Option, + pub source_version: Option, + 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)] +#[serde(rename_all = "camelCase")] +pub struct ReferencePng { + pub mime_type: String, + pub data_base64: String, + pub byte_length: usize, + pub sha256: String, + /// The Devup Bridge plugin's word when its picture may hold layers + /// `get_screenshot`'s leaves out. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CollectionStats { + pub figma_tool_calls: usize, + pub transport: String, + pub fallback_used: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fallback_reason: Option, + pub node_count: usize, + pub variable_count: usize, + pub style_count: usize, + pub raw_bytes: usize, + pub wire_bytes: usize, + pub envelope_chunks: usize, + /// Set when the Devup Bridge plugin answered any of `figma_tool_calls`, + /// naming the plugin; the rest went to Figma's metered MCP. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bridge: Option, + /// Why reads the Devup Bridge plugin answered were read again on the + /// metered path, one entry per read: `file-out-of-sight`, + /// `reference-png-approximate`, `plugin-older-than-server`, + /// `plugin-answer-too-large`, `value-from-direct-path`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub direct_fallbacks: Vec, +} + +impl Default for CollectionStats { + fn default() -> Self { + Self { + figma_tool_calls: 0, + // 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, + variable_count: 0, + style_count: 0, + raw_bytes: 0, + wire_bytes: 0, + envelope_chunks: 0, + bridge: None, + direct_fallbacks: Vec::new(), + } + } +} + +#[derive(Debug, Clone)] +pub enum CollectorStep { + Call(Box), + AwaitingResults, + Complete(Box), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CallKind { + FastSnapshot, + FastTheme, + Metadata, + PageCatalog, + Snapshot, + VariableCatalog, + VariableBatch, + UsedResourceBatch, + Explore, + ScopedSearch, + SectionIndex, + FastMultiRoot, + LargeValue, + Asset, + Screenshot, +} + +#[derive(Debug, Clone)] +struct PendingCall { + planned: PlannedCall, + kind: CallKind, + order: usize, +} + +#[derive(Debug, Clone)] +pub struct CollectorSession { + request: CollectionRequest, + queued: VecDeque, + pending: BTreeMap, + consumed: BTreeSet, + metadata: Option, + metadata_nodes: BTreeMap, + metadata_root_ids: Vec, + root_node_id: Option, + source_version: Option, + snapshot_chunks: BTreeMap, + variable_catalog: Option, + /// A file's own variables and styles, read from its catalog, while the + /// library ones its nodes use are read. + file_local_resources: Option, + used_resource_refs: Option, + variable_batches: BTreeMap, + variables: Option, + large_values: BTreeMap<(String, String), LargeValueAssembler>, + /// An SVG or PNG export announced as too large for one answer, by the + /// key its fragments arrive under, until the last of them has. + chunked_assets: + BTreeMap<(String, String), (AssetRequest, Option)>, + /// The pages of a paginated theme so far, merged, until the last one. + fast_theme_pages: Option, + fast_theme_page_count: usize, + asset_results: Vec, + retained_asset_bytes: usize, + asset_byte_budget_exhausted: bool, + assets_scheduled: bool, + asset_plan_initialized: bool, + asset_plan: VecDeque, + reference_png: Option, + reference_png_scheduled: bool, + stats: CollectionStats, + fast_attempted: bool, + section_index: Option, + 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, +} + +include!("new.rs"); +include!("asset_progress.rs"); +include!("stats.rs"); +include!("advance.rs"); +include!("accept.rs"); +include!("reject.rs"); +include!("fast_path_eligible.rs"); +include!("fast_theme_eligible.rs"); +include!("accept_fast_theme.rs"); +include!("accept_fast_snapshot.rs"); +include!("restart_legacy.rs"); +include!("finish_stats.rs"); +include!("accept_page_catalog.rs"); +include!("accept_projection.rs"); +include!("accept_section_index.rs"); +include!("prepare_cached_section_index.rs"); +include!("accept_fast_multi_root.rs"); +include!("fallback_multi_root_batch.rs"); +include!("mark_section_legacy.rs"); +include!("enqueue_section_legacy_root.rs"); +include!("restore_section_visual_order.rs"); +include!("record_snapshot_chunk.rs"); +include!("accept_large_value.rs"); +include!("record_large_value_unsupported.rs"); +include!("accept_asset.rs"); +include!("accept_reference_png.rs"); +include!("retain_asset.rs"); +include!("record_asset_failure.rs"); +include!("record_asset_diagnostic.rs"); +include!("accept_metadata.rs"); +include!("record_metadata.rs"); +include!("record_metadata_node.rs"); +include!("accept_snapshot.rs"); +include!("accept_variable_catalog.rs"); +include!("enqueue_used_resource_batches.rs"); +include!("record_unresolved_diagnostics.rs"); +include!("enqueue_style_consumer_batches.rs"); +include!("enqueue.rs"); + +/// The one image the screenshot answered with. +/// +/// The official `get_screenshot` writes the PNG's URL and how to fetch it as +/// text blocks, and inlines the PNG itself as an image block beside them when +/// asked to (`enableBase64Response`). The text is not read; the image must be +/// exactly one, and a PNG. A response with no image, or two, or one hidden in +/// text or JSON, is refused as before. +fn take_single_png_data(value: Value) -> Result { + let result = serde_json::from_value::(value) + .map_err(|_| invalid_call("Figma screenshot response format is invalid."))?; + if result.is_error == Some(true) { + return Err(invalid_call( + "Figma screenshot response must contain exactly one image/png content block.", + )); + } + let mut images = result + .content + .into_iter() + .filter_map(|content| match content { + ContentBlock::Image(image) => Some(image), + _ => None, + }); + let (Some(image), None) = (images.next(), images.next()) else { + return Err(invalid_call( + "Figma screenshot response must contain exactly one image/png content block.", + )); + }; + if image.mime_type != "image/png" { + return Err(invalid_call( + "Figma screenshot response MIME type is not image/png.", + )); + } + Ok(image.data) +} + +fn validate_reference_png(bytes: &[u8]) -> Result<(), DevupError> { + let mut limits = Limits::default(); + limits.max_image_width = Some(MAX_REFERENCE_PNG_DIMENSION); + limits.max_image_height = Some(MAX_REFERENCE_PNG_DIMENSION); + limits.max_alloc = Some(MAX_REFERENCE_PNG_DECODED_BYTES as u64); + let decoder = + PngDecoder::with_limits(Cursor::new(bytes), limits).map_err(reference_png_decode_error)?; + let (width, height) = decoder.dimensions(); + let decoded_bytes = usize::try_from(decoder.total_bytes()).map_err(|_| { + DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "Figma reference PNG decoded size exceeds the allowed range.", + false, + ) + })?; + if width == 0 + || height == 0 + || width > MAX_REFERENCE_PNG_DIMENSION + || height > MAX_REFERENCE_PNG_DIMENSION + || decoded_bytes > MAX_REFERENCE_PNG_DECODED_BYTES + { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "Figma reference PNG dimensions or decoded size exceed the allowed range.", + false, + )); + } + let mut pixels = vec![0_u8; decoded_bytes]; + decoder + .read_image(&mut pixels) + .map_err(reference_png_decode_error) +} + +/// The plugin's export flattened onto the colour the plugin names under the +/// node - its ancestors' fills over the page background - which is what +/// `get_screenshot` hands back: measured on the same frames, its PNG was +/// opaque where the export kept a translucent backdrop or transparent gaps, +/// showing that colour there (the page background (30, 30, 30) on a frame +/// straight on the page). An opaque export is returned as it came. +pub(crate) fn flatten_reference_png( + data_base64: &str, + backdrop: [u8; 3], +) -> Result { + let bytes = STANDARD + .decode(data_base64.as_bytes()) + .map_err(|_| invalid_call("Figma reference PNG base64 is invalid."))?; + let mut limits = Limits::default(); + limits.max_image_width = Some(MAX_REFERENCE_PNG_DIMENSION); + limits.max_image_height = Some(MAX_REFERENCE_PNG_DIMENSION); + limits.max_alloc = Some(MAX_REFERENCE_PNG_DECODED_BYTES as u64); + let decoder = + PngDecoder::with_limits(Cursor::new(bytes), limits).map_err(reference_png_decode_error)?; + let mut pixels = image::DynamicImage::from_decoder(decoder) + .map_err(reference_png_decode_error)? + .into_rgba8(); + if pixels.pixels().all(|pixel| pixel[3] == u8::MAX) { + return Ok(data_base64.to_owned()); + } + for pixel in pixels.pixels_mut() { + let alpha = u32::from(pixel[3]); + for (value, under) in pixel.0.iter_mut().zip(backdrop) { + let mixed = u32::from(*value) * alpha + u32::from(under) * (255 - alpha); + *value = u8::try_from((mixed + 127) / 255).unwrap_or(u8::MAX); + } + pixel[3] = u8::MAX; + } + let mut encoded = Vec::new(); + pixels + .write_with_encoder(image::codecs::png::PngEncoder::new(&mut encoded)) + .map_err(|_| invalid_call("Figma reference PNG could not be re-encoded."))?; + Ok(STANDARD.encode(encoded)) +} + +fn reference_png_decode_error(error: ImageError) -> DevupError { + if matches!(error, ImageError::Limits(_)) { + DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "Figma reference PNG dimensions or decoded size exceed the allowed range.", + false, + ) + } else { + invalid_call("Figma reference PNG data is corrupted.") + } +} + +fn take_snapshot_cursor(chunk: &mut SnapshotChunk) -> Result, DevupError> { + let Some(cursor) = read_snapshot_cursor(&chunk.nodes) + .map_err(|message| invalid_call(message.korean_message()))? + else { + return Ok(None); + }; + chunk.nodes.retain(|node| node.id != SNAPSHOT_CURSOR_ID); + Ok(Some(cursor)) +} + +fn invalid_call(message: &str) -> DevupError { + DevupError::new(ErrorCode::DevupFigmaHandoffInvalid, message, false) +} + +/// The next page of a theme onto the pages before it. Every list - the +/// resources and the scan alike - is appended in order, with an entry the +/// pages both carry kept once; the scalars are the page's. +fn merge_theme_page(merged: &mut Value, page: Value) -> Result<(), DevupError> { + let Some(page) = page.as_object() else { + return Err(invalid_call("Figma theme page format is invalid.")); + }; + for list in [ + "collections", + "variables", + "styles", + "usedRemoteVariables", + "usedVariableIds", + "usedStyleIds", + "unresolved", + ] { + let incoming = page + .get(list) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let target = merged + .get_mut(list) + .and_then(Value::as_array_mut) + .ok_or_else(|| invalid_call("Figma theme page format is invalid."))?; + for item in incoming { + // A resource is one by its id, an id in the scan by itself. + let same = |existing: &Value| match item.get("id") { + Some(id) => existing.get("id") == Some(id), + None => *existing == item, + }; + if target.iter().any(same) { + continue; + } + target.push(item); + } + } + for field in ["localComplete", "usedRemoteComplete"] { + if let Some(value) = page.get(field) { + merged[field] = value.clone(); + } + } + Ok(()) +} + +fn fallback_category(error: &DevupError) -> String { + error + .details + .get("category") + .and_then(Value::as_str) + .map(str::to_owned) + .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, + ErrorCode::DevupFigmaDirectUnavailable + | ErrorCode::DevupFigmaCatalogRejected + | ErrorCode::DevupFigmaResponseTooLarge + | ErrorCode::DevupSnapshotUnsupported + | ErrorCode::DevupFigmaHandoffInvalid + ) +} + +fn resource_count(value: &Value, field: &str) -> usize { + value + .get(field) + .and_then(Value::as_array) + .map_or(0, Vec::len) +} + +fn empty_used_resources() -> UpstreamResult { + UpstreamResult { + raw: json!({ + "collections": [], + "variables": [], + "styles": [], + "usedRemoteVariables": [], + "usedVariableIds": [], + "usedStyleIds": [], + "localComplete": false, + "usedRemoteComplete": true, + "unresolved": [] + }), + } +} + +fn merge_fast_resources( + existing: &mut Option, + incoming: UpstreamResult, +) -> Result<(), DevupError> { + let Some(current) = existing else { + *existing = Some(incoming); + return Ok(()); + }; + let current = current + .raw + .as_object_mut() + .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 format is invalid."))?; + for field in ["collections", "variables", "styles", "usedRemoteVariables"] { + let mut values = BTreeMap::::new(); + for value in current + .get(field) + .and_then(Value::as_array) + .into_iter() + .flatten() + .chain( + incoming + .get(field) + .and_then(Value::as_array) + .into_iter() + .flatten(), + ) + { + let id = value + .get("id") + .and_then(Value::as_str) + .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 contents differ between batches.", + true, + )); + } + values.insert(id.to_owned(), value.clone()); + } + current.insert( + field.to_owned(), + Value::Array(values.into_values().collect()), + ); + } + for field in ["usedVariableIds", "usedStyleIds"] { + let values = current + .get(field) + .and_then(Value::as_array) + .into_iter() + .flatten() + .chain( + incoming + .get(field) + .and_then(Value::as_array) + .into_iter() + .flatten(), + ) + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_call("multi-root resource ID format is invalid.")) + }) + .collect::, _>>()?; + current.insert( + field.to_owned(), + Value::Array(values.into_iter().map(Value::String).collect()), + ); + } + let unresolved = current + .get("unresolved") + .and_then(Value::as_array) + .into_iter() + .flatten() + .chain( + incoming + .get("unresolved") + .and_then(Value::as_array) + .into_iter() + .flatten(), + ) + .map(|value| { + ( + (value["id"].to_string(), value["kind"].to_string()), + value.clone(), + ) + }) + .collect::>(); + current.insert( + "unresolved".to_owned(), + Value::Array(unresolved.into_values().collect()), + ); + for field in ["localComplete", "usedRemoteComplete"] { + let merged = current.get(field).and_then(Value::as_bool).unwrap_or(false) + && incoming + .get(field) + .and_then(Value::as_bool) + .unwrap_or(false); + current.insert(field.to_owned(), Value::Bool(merged)); + } + Ok(()) +} + +#[derive(Debug, Clone)] +enum UsedResourceItem { + Variable(String), + Style(ResourceStyleRef), +} + +fn used_resource_batches( + refs: &UsedResourceRefs, + budget: BatchBudget, +) -> Result, DevupError> { + let items = refs + .variable_ids + .iter() + .cloned() + .map(UsedResourceItem::Variable) + .chain(refs.styles.iter().cloned().map(UsedResourceItem::Style)); + let mut batches = Vec::new(); + let mut current = ResourceBatch { + variable_ids: Vec::new(), + styles: Vec::new(), + }; + + for item in items { + let mut candidate = current.clone(); + add_used_resource(&mut candidate, item.clone()); + if used_resource_batch_fits(&candidate, budget) { + current = candidate; + continue; + } + if current.variable_ids.is_empty() && current.styles.is_empty() { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "A single Figma resource ID exceeds the safe batch size.", + false, + )); + } + batches.push(current); + current = ResourceBatch { + variable_ids: Vec::new(), + styles: Vec::new(), + }; + add_used_resource(&mut current, item); + if !used_resource_batch_fits(¤t, budget) { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "A single Figma resource ID exceeds the safe batch size.", + false, + )); + } + } + if !current.variable_ids.is_empty() || !current.styles.is_empty() { + batches.push(current); + } + Ok(batches) +} + +fn add_used_resource(batch: &mut ResourceBatch, item: UsedResourceItem) { + match item { + UsedResourceItem::Variable(id) => batch.variable_ids.push(id), + UsedResourceItem::Style(style) => batch.styles.push(style), + } +} + +fn used_resource_batch_fits(batch: &ResourceBatch, budget: BatchBudget) -> bool { + batch.variable_ids.len() + batch.styles.len() <= budget.used_resource_items + && serde_json::to_vec(batch).is_ok_and(|bytes| bytes.len() <= budget.used_resource_bytes) +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/enqueue.rs b/crates/devup-mcp-figma/src/collector/enqueue.rs new file mode 100644 index 00000000..082a1df9 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/enqueue.rs @@ -0,0 +1,18 @@ +impl CollectorSession { + + fn enqueue(&mut self, call: ReadToolCall, expected_node_id: Option, kind: CallKind) { + let order = self.next_id; + let id = format!("call-{order}"); + self.next_id += 1; + self.queued.push_back(PendingCall { + planned: PlannedCall { + id, + call, + expected_file_key: self.request.target.file_key.clone(), + expected_node_id, + }, + kind, + order, + }); + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/enqueue_section_legacy_root.rs b/crates/devup-mcp-figma/src/collector/enqueue_section_legacy_root.rs new file mode 100644 index 00000000..e26aa4b9 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/enqueue_section_legacy_root.rs @@ -0,0 +1,17 @@ +impl CollectorSession { + + fn enqueue_section_legacy_root(&mut self, root_id: String) { + if !self.section_fallback_roots.insert(root_id.clone()) { + return; + } + self.enqueue( + ReadToolCall::snapshot( + &self.request.target.file_key, + &root_id, + BuiltinScript::NodeSnapshot, + ), + Some(root_id), + CallKind::Snapshot, + ); + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/enqueue_style_consumer_batches.rs b/crates/devup-mcp-figma/src/collector/enqueue_style_consumer_batches.rs new file mode 100644 index 00000000..237bcca8 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/enqueue_style_consumer_batches.rs @@ -0,0 +1,48 @@ +impl CollectorSession { + + fn enqueue_style_consumer_batches( + &mut self, + batch: &VariableBatchResult, + ) -> Result<(), DevupError> { + let node_id = self.root_node_id.clone().ok_or_else(|| { + 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 format is invalid.")); + }; + let Some(consumer_count) = object.get("$consumerCount").and_then(Value::as_u64) else { + continue; + }; + let id = object + .get("id") + .and_then(Value::as_str) + .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 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( + ReadToolCall::resource_batch( + &self.request.target.file_key, + &node_id, + ResourceBatch { + variable_ids: Vec::new(), + styles: vec![ResourceStyleRef { + id: id.to_owned(), + style_type: style_type.to_owned(), + consumer_start: Some(start), + consumer_end: Some(end), + }], + }, + ), + Some(node_id.clone()), + CallKind::VariableBatch, + ); + } + } + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/enqueue_used_resource_batches.rs b/crates/devup-mcp-figma/src/collector/enqueue_used_resource_batches.rs new file mode 100644 index 00000000..e1cee9c8 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/enqueue_used_resource_batches.rs @@ -0,0 +1,32 @@ +impl CollectorSession { + + fn enqueue_used_resource_batches(&mut self) -> Result<(), DevupError> { + let chunks = self + .snapshot_chunks + .values() + .filter(|chunk| { + self.section_fallback_roots.is_empty() + || self.fast_multi_resources.is_none() + || self.fast_multi_has_large_values + || chunk + .root_ids + .iter() + .any(|root_id| self.section_fallback_roots.contains(root_id)) + }) + .cloned() + .collect::>(); + let refs = collect_used_resource_refs(&chunks); + let node_id = self.root_node_id.clone().ok_or_else(|| { + invalid_call("No root node ID available for the used Figma resource batch.") + })?; + for batch in used_resource_batches(&refs, self.request.batch_budget)? { + self.enqueue( + ReadToolCall::used_resources(&self.request.target.file_key, &node_id, batch), + Some(node_id.clone()), + CallKind::UsedResourceBatch, + ); + } + self.used_resource_refs = Some(refs); + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/fallback_multi_root_batch.rs b/crates/devup-mcp-figma/src/collector/fallback_multi_root_batch.rs new file mode 100644 index 00000000..dc0c762d --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/fallback_multi_root_batch.rs @@ -0,0 +1,25 @@ +impl CollectorSession { + + fn fallback_multi_root_batch( + &mut self, + planned: &PlannedCall, + reason: String, + ) -> Result<(), DevupError> { + let ReadToolCall::Snapshot { + script: BuiltinScript::MultiRootSnapshotEnvelope, + root_ids: Some(root_ids), + .. + } = &planned.call + else { + return Err(invalid_call("multi-root fallback call format is invalid.")); + }; + if root_ids.is_empty() { + return Err(invalid_call("multi-root fallback has no selected roots.")); + } + self.mark_section_legacy(reason); + for root_id in root_ids { + self.enqueue_section_legacy_root(root_id.clone()); + } + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/fast_path_eligible.rs b/crates/devup-mcp-figma/src/collector/fast_path_eligible.rs new file mode 100644 index 00000000..211d521c --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/fast_path_eligible.rs @@ -0,0 +1,14 @@ +impl CollectorSession { + + fn fast_path_eligible(&self) -> bool { + self.request.scope == CollectionScope::Node + && self.request.resource_scope == ResourceScope::Used + && self.request.target.node_id.is_some() + && !self.request.include_context + && !self.request.metadata_only + && !self.request.variables_only + && self.request.search.is_none() + && self.request.explore.is_none() + && self.request.section.is_none() + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/fast_theme_eligible.rs b/crates/devup-mcp-figma/src/collector/fast_theme_eligible.rs new file mode 100644 index 00000000..a2c3aba8 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/fast_theme_eligible.rs @@ -0,0 +1,13 @@ +impl CollectorSession { + + fn fast_theme_eligible(&self) -> bool { + self.request.scope == CollectionScope::File + && self.request.resource_scope == ResourceScope::File + && self.request.variables_only + && !self.request.metadata_only + && !self.request.include_context + && self.request.search.is_none() + && self.request.explore.is_none() + && self.request.section.is_none() + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/finish_stats.rs b/crates/devup-mcp-figma/src/collector/finish_stats.rs new file mode 100644 index 00000000..540d55d0 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/finish_stats.rs @@ -0,0 +1,14 @@ +impl CollectorSession { + + fn finish_stats(&mut self, chunks: &[SnapshotChunk]) { + self.stats.node_count = chunks + .iter() + .flat_map(|chunk| chunk.nodes.iter().map(|node| node.id.as_str())) + .collect::>() + .len(); + if let Some(resources) = &self.variables { + self.stats.variable_count = resource_count(&resources.raw, "variables"); + self.stats.style_count = resource_count(&resources.raw, "styles"); + } + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/mark_section_legacy.rs b/crates/devup-mcp-figma/src/collector/mark_section_legacy.rs new file mode 100644 index 00000000..2669e020 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/mark_section_legacy.rs @@ -0,0 +1,13 @@ +impl CollectorSession { + + fn mark_section_legacy(&mut self, reason: String) { + self.stats.transport = if self.fast_multi_resources.is_some() { + "hybrid-multi-root-cursor" + } else { + "legacy-multi-root-cursor" + } + .to_owned(); + self.stats.fallback_used = true; + self.stats.fallback_reason.get_or_insert(reason); + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/new.rs b/crates/devup-mcp-figma/src/collector/new.rs new file mode 100644 index 00000000..e22f6bcc --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/new.rs @@ -0,0 +1,48 @@ +impl CollectorSession { + pub fn new(request: CollectionRequest) -> Self { + let assets_scheduled = request.asset_selections.is_empty() && !request.web_ready_images; + let reference_png_scheduled = !request.reference_png; + Self { + request, + queued: VecDeque::new(), + pending: BTreeMap::new(), + consumed: BTreeSet::new(), + metadata: None, + metadata_nodes: BTreeMap::new(), + metadata_root_ids: Vec::new(), + root_node_id: None, + source_version: None, + snapshot_chunks: BTreeMap::new(), + variable_catalog: None, + file_local_resources: None, + used_resource_refs: None, + variable_batches: BTreeMap::new(), + variables: None, + large_values: BTreeMap::new(), + chunked_assets: BTreeMap::new(), + fast_theme_pages: None, + fast_theme_page_count: 0, + asset_results: Vec::new(), + retained_asset_bytes: 0, + asset_byte_budget_exhausted: false, + assets_scheduled, + asset_plan_initialized: false, + asset_plan: VecDeque::new(), + reference_png: None, + reference_png_scheduled, + stats: CollectionStats::default(), + fast_attempted: false, + section_index: None, + 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, + } + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/prepare_cached_section_index.rs b/crates/devup-mcp-figma/src/collector/prepare_cached_section_index.rs new file mode 100644 index 00000000..0bf6ba22 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/prepare_cached_section_index.rs @@ -0,0 +1,69 @@ +impl CollectorSession { + + fn prepare_cached_section_index(&mut self, index: SectionIndex) -> Result<(), DevupError> { + if index.file_key != self.request.target.file_key + || self.request.target.node_id.as_deref() != Some(index.section.node_id.as_str()) + { + return Err(invalid_call( + "cached Section index does not match the requested Section.", + )); + } + let options = self + .request + .section + .clone() + .ok_or_else(|| invalid_call("cached Section index has no selection options."))?; + // Compact indexes only retain ownership for IDs queried during that + // request. A newly requested descendant needs a fresh index query. + if options.frame_ids.iter().any(|id| { + !index + .candidates + .iter() + .any(|candidate| candidate.node_id == *id) + && !index.node_screen_ids.contains_key(id) + }) { + return Ok(()); + } + 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(); + self.section_selected_roots = selected.clone(); + self.root_node_id = selected.first().cloned(); + self.metadata_root_ids = selected.clone(); + self.metadata = Some(json!({ + "transport": "section-multi-root-v1", + "sectionIndex": &index, + "selectedRootIds": &selected, + "batchCount": batches.len(), + "indexCacheHit": true + })); + self.section_index = Some(index); + let section_id = self + .request + .target + .node_id + .clone() + .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()); + for root_id in batch.root_ids { + self.enqueue_section_legacy_root(root_id); + } + } else { + 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(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/record_asset_diagnostic.rs b/crates/devup-mcp-figma/src/collector/record_asset_diagnostic.rs new file mode 100644 index 00000000..acc0c072 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/record_asset_diagnostic.rs @@ -0,0 +1,34 @@ +impl CollectorSession { + + fn record_asset_diagnostic( + &mut self, + request: &AssetRequest, + error_code: &str, + error_detail: Option<&str>, + ) { + if let Some(chunk) = self + .snapshot_chunks + .values_mut() + .find(|chunk| chunk.nodes.iter().any(|node| node.id == request.node_id)) + { + chunk.diagnostics.push(crate::Diagnostic { + code: error_code.to_owned(), + message: match error_detail { + Some(detail) => format!( + "Failed to export the requested Figma asset; layout output was kept. {detail}" + ), + None => "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), + property: Some(request.field.clone()), + resource_kind: Some("asset".to_owned()), + resource_id: Some(request.asset_id.clone()), + fallback: Some("layout-only".to_owned()), + recoverable: Some(true), + ..crate::Diagnostic::default() + }); + } + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/record_asset_failure.rs b/crates/devup-mcp-figma/src/collector/record_asset_failure.rs new file mode 100644 index 00000000..81989c64 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/record_asset_failure.rs @@ -0,0 +1,42 @@ +impl CollectorSession { + + fn record_asset_failure( + &mut self, + request: AssetRequest, + error_code: &str, + error_detail: Option, + ) { + let error_detail = error_detail.map(|detail| detail.chars().take(500).collect::()); + self.record_asset_diagnostic(&request, error_code, error_detail.as_deref()); + let source_kind = crate::assets::source_kind_of(&request).to_owned(); + self.asset_results.push(AssetManifestEntry { + usage_aspect_ratio: request.usage_aspect_ratio, + max_width: request.max_width, + srcset: request.srcset, + visible_scope_node_id: request.visible_scope_node_id.clone(), + variants: Vec::new(), + render_mode: request.render_mode, + quality: request.quality, + lossless: request.lossless, + render_info: None, + suggested_filename: None, + asset_id: request.asset_id, + node_id: request.node_id, + field: request.field, + source_kind, + image_hash: request.image_hash, + format: Some(request.format), + scale: Some(request.scale), + status: AssetStatus::Failed, + byte_length: None, + sha256: None, + mime_type: None, + data_base64: None, + output_path: None, + error_code: Some(error_code.to_owned()), + error_detail, + export_diagnostics: None, + path: None, + }); + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/record_large_value_unsupported.rs b/crates/devup-mcp-figma/src/collector/record_large_value_unsupported.rs new file mode 100644 index 00000000..4f3d9d08 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/record_large_value_unsupported.rs @@ -0,0 +1,64 @@ +impl CollectorSession { + + fn record_large_value_unsupported( + &mut self, + options: &LargeValueReadOptions, + error_code: &str, + error_detail: Option<&str>, + ) -> Result<(), DevupError> { + let key = (options.node_id.clone(), options.field.clone()); + let assembler = self + .large_values + .remove(&key) + .ok_or_else(|| invalid_call("large value assembler is missing."))?; + if let Some((request, _)) = self.chunked_assets.remove(&key) { + self.record_asset_failure( + request, + if error_detail.is_some_and(|detail| detail.contains("DEVUP_ASSET_TIMEOUT:")) { + "DEVUP_ASSET_TIMEOUT" + } else { + "DEVUP_ASSET_EXPORT_FAILED" + }, + Some(format!( + "reading the export back in fragments failed: {}", + error_detail.unwrap_or(error_code) + )), + ); + return Ok(()); + } + let descriptor = assembler.descriptor().clone(); + replace_descriptor( + &mut self.snapshot_chunks, + &descriptor, + json!({ + "$truncated": "unsupported-by-upstream", + "byteLength": descriptor.byte_length + }), + )?; + if let Some(chunk) = self + .snapshot_chunks + .values_mut() + .find(|chunk| chunk.nodes.iter().any(|node| node.id == descriptor.node_id)) + && let Some(node) = chunk + .nodes + .iter_mut() + .find(|node| node.id == descriptor.node_id) + { + node.field_errors + .insert(descriptor.field.clone(), error_code.to_owned()); + chunk.diagnostics.push(crate::Diagnostic { + code: error_code.to_owned(), + message: + "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), + property: Some(descriptor.field), + fallback: Some("unsupported-by-upstream".to_owned()), + recoverable: Some(false), + ..crate::Diagnostic::default() + }); + } + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/record_metadata.rs b/crates/devup-mcp-figma/src/collector/record_metadata.rs new file mode 100644 index 00000000..4c23f071 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/record_metadata.rs @@ -0,0 +1,13 @@ +impl CollectorSession { + + fn record_metadata(&mut self, value: Value) { + self.metadata = Some(match self.metadata.take() { + None => value, + Some(Value::Array(mut values)) => { + values.push(value); + Value::Array(values) + } + Some(existing) => Value::Array(vec![existing, value]), + }); + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/record_metadata_node.rs b/crates/devup-mcp-figma/src/collector/record_metadata_node.rs new file mode 100644 index 00000000..79a34f15 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/record_metadata_node.rs @@ -0,0 +1,33 @@ +impl CollectorSession { + + fn record_metadata_node(&mut self, node: &crate::metadata::MetadataNode) { + let mut fields = Map::new(); + if let Some(name) = &node.name { + fields.insert("name".to_owned(), Value::String(name.clone())); + } + fields.insert( + "childrenIds".to_owned(), + Value::Array( + node.children_ids + .iter() + .cloned() + .map(Value::String) + .collect(), + ), + ); + fields.insert( + "descendantCount".to_owned(), + Value::from(node.descendant_count), + ); + self.metadata_nodes.insert( + node.id.clone(), + RawNode { + id: node.id.clone(), + node_type: node.node_type.clone(), + fields, + extra: Map::new(), + field_errors: BTreeMap::new(), + }, + ); + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/record_snapshot_chunk.rs b/crates/devup-mcp-figma/src/collector/record_snapshot_chunk.rs new file mode 100644 index 00000000..a9252ded --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/record_snapshot_chunk.rs @@ -0,0 +1,39 @@ +impl CollectorSession { + + fn record_snapshot_chunk( + &mut self, + order: usize, + chunk: SnapshotChunk, + ) -> Result<(), DevupError> { + let descriptors = descriptors_in_chunk(&chunk)?; + let version = chunk.version.clone(); + self.snapshot_chunks.insert(order, chunk); + for descriptor in descriptors { + let key = (descriptor.node_id.clone(), descriptor.field.clone()); + if self.large_values.contains_key(&key) { + return Err(invalid_call( + "Duplicate Figma large value descriptor for the same field.", + )); + } + let options = LargeValueReadOptions::from_descriptor( + &descriptor, + version.clone(), + descriptor.cursor.next_offset, + ); + self.large_values.insert( + key, + LargeValueAssembler::new( + self.request.target.file_key.clone(), + version.clone(), + descriptor, + )?, + ); + self.enqueue( + ReadToolCall::large_value(&self.request.target.file_key, options.clone()), + Some(options.node_id), + CallKind::LargeValue, + ); + } + Ok(()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/record_unresolved_diagnostics.rs b/crates/devup-mcp-figma/src/collector/record_unresolved_diagnostics.rs new file mode 100644 index 00000000..89928211 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/record_unresolved_diagnostics.rs @@ -0,0 +1,46 @@ +impl CollectorSession { + + fn record_unresolved_diagnostics( + &mut self, + refs: &UsedResourceRefs, + unresolved: &[UnresolvedResource], + ) { + let unresolved = unresolved + .iter() + .map(|resource| (resource.kind, resource.id.as_str())) + .collect::>(); + for occurrence in &refs.occurrences { + if !unresolved.contains(&(occurrence.resource_kind, occurrence.resource_id.as_str())) { + continue; + } + let diagnostic = crate::Diagnostic { + code: "DEVUP_RESOURCE_UNRESOLVED".to_owned(), + message: format!( + "Could not resolve the Figma resource; substituted the raw value: field={}, resourceId={}", + occurrence.field, occurrence.resource_id + ), + node_id: Some(occurrence.node_id.clone()), + severity: Some(crate::DiagnosticSeverity::Warning), + property: Some(occurrence.field.clone()), + resource_kind: Some( + match occurrence.resource_kind { + crate::ResourceKind::Variable => "variable", + crate::ResourceKind::Style => "style", + } + .to_owned(), + ), + resource_id: Some(occurrence.resource_id.clone()), + fallback: Some("raw-value".to_owned()), + recoverable: Some(true), + ..crate::Diagnostic::default() + }; + if let Some(chunk) = self + .snapshot_chunks + .values_mut() + .find(|chunk| chunk.nodes.iter().any(|node| node.id == occurrence.node_id)) + { + chunk.diagnostics.push(diagnostic); + } + } + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/reject.rs b/crates/devup-mcp-figma/src/collector/reject.rs new file mode 100644 index 00000000..98c03b73 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/reject.rs @@ -0,0 +1,121 @@ +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("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 is missing."))?; + self.consumed.insert(call_id.to_owned()); + let ReadToolCall::AssetExport { request, .. } = pending.planned.call else { + return Err(invalid_call("asset call format is invalid.")); + }; + self.record_asset_failure( + *request, + if error.message.contains("DEVUP_ASSET_TIMEOUT:") + || error.details["reason"] == "timeout" + { + "DEVUP_ASSET_TIMEOUT" + } else { + "DEVUP_ASSET_EXPORT_FAILED" + }, + Some(error.message.clone()), + ); + return Ok(true); + } + if pending.kind == CallKind::LargeValue { + let pending = self + .pending + .remove(call_id) + .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 format is invalid.")); + }; + self.record_large_value_unsupported( + &options, + "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM", + Some(&error.message), + )?; + 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 + ) { + return Ok(false); + } + if !fast_call_fallback_allowed(error) { + return Ok(false); + } + // A continuation already has accepted pages and possibly field reads + // in flight. Restarting at zero would mix them with a second capture. + if pending.kind == CallKind::FastMultiRoot + && matches!(&pending.planned.call, ReadToolCall::Snapshot { + snapshot: Some(options), .. + } if options.offset > 0) + { + return Ok(false); + } + let pending = self + .pending + .remove(call_id) + .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))?; + return Ok(true); + } + self.restart_legacy(fallback_category(error)); + Ok(true) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/restart_legacy.rs b/crates/devup-mcp-figma/src/collector/restart_legacy.rs new file mode 100644 index 00000000..32375f0c --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/restart_legacy.rs @@ -0,0 +1,41 @@ +impl CollectorSession { + + fn restart_legacy(&mut self, reason: String) { + self.queued.clear(); + self.pending.clear(); + self.metadata = None; + self.metadata_nodes.clear(); + self.metadata_root_ids.clear(); + self.root_node_id = None; + self.source_version = None; + self.snapshot_chunks.clear(); + self.variable_catalog = None; + self.used_resource_refs = None; + 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.retained_asset_bytes = 0; + self.asset_byte_budget_exhausted = false; + self.assets_scheduled = + self.request.asset_selections.is_empty() && !self.request.web_ready_images; + self.asset_plan_initialized = false; + self.asset_plan.clear(); + self.reference_png = None; + self.reference_png_scheduled = !self.request.reference_png; + self.stats.transport = "legacy-cursor".to_owned(); + self.stats.fallback_used = true; + self.stats.fallback_reason = Some(reason); + self.stats.node_count = 0; + self.stats.variable_count = 0; + self.stats.style_count = 0; + self.stats.raw_bytes = 0; + self.stats.wire_bytes = 0; + self.stats.envelope_chunks = 0; + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/restore_section_visual_order.rs b/crates/devup-mcp-figma/src/collector/restore_section_visual_order.rs new file mode 100644 index 00000000..26dd1318 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/restore_section_visual_order.rs @@ -0,0 +1,39 @@ +impl CollectorSession { + + fn restore_section_visual_order( + &self, + chunks: Vec, + ) -> Result, DevupError> { + if self.section_selected_roots.is_empty() || chunks.is_empty() { + return Ok(chunks); + } + 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) && !failed.contains(root_id.as_str())) + { + return Err(invalid_call( + "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 + .iter() + .filter(|root_id| observed.contains(*root_id)) + .cloned() + .collect(), + nodes: snapshot.nodes.into_values().collect(), + diagnostics: snapshot.diagnostics, + }]) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/retain_asset.rs b/crates/devup-mcp-figma/src/collector/retain_asset.rs new file mode 100644 index 00000000..a0b1a9e1 --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/retain_asset.rs @@ -0,0 +1,35 @@ +impl CollectorSession { + + fn retain_asset(&mut self, mut asset: AssetManifestEntry) { + if asset.status == AssetStatus::Exported { + let bytes = asset + .byte_length + .unwrap_or_default() + .saturating_add(asset.variants.iter().map(|v| v.byte_length).sum::()); + if self.retained_asset_bytes.saturating_add(bytes) > MAX_COLLECTED_ASSET_BYTES { + self.asset_byte_budget_exhausted = true; + asset.status = AssetStatus::Failed; + asset.error_code = Some("DEVUP_ASSET_TOTAL_BYTES_EXCEEDED".into()); + asset.error_detail = Some("The 128 MiB retained asset budget was reached. Export failed IDs in smaller custom batches or select a smaller frame.".into()); + asset.data_base64 = None; + asset.variants.clear(); + asset.byte_length = None; + asset.sha256 = None; + asset.mime_type = None; + if let Some(info) = &mut asset.render_info { + info["representation"] = json!("not-exported"); + info["renderedAs"] = json!("not-exported"); + info["byteEvidence"] = Value::Null; + info["fidelity"] = json!("unverified"); + info["effectsBakedIntoAsset"] = json!(false); + } + if let Some(diagnostics) = &mut asset.export_diagnostics { + diagnostics.render_info = asset.render_info.clone(); + } + } else { + self.retained_asset_bytes += bytes; + } + } + self.asset_results.push(asset); + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/stats.rs b/crates/devup-mcp-figma/src/collector/stats.rs new file mode 100644 index 00000000..5ee8856d --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/stats.rs @@ -0,0 +1,6 @@ +impl CollectorSession { + + pub fn stats(&self) -> &CollectionStats { + &self.stats + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/collector/w5_tests.rs b/crates/devup-mcp-figma/src/collector/w5_tests.rs new file mode 100644 index 00000000..f4326eea --- /dev/null +++ b/crates/devup-mcp-figma/src/collector/w5_tests.rs @@ -0,0 +1,333 @@ + + +#[cfg(test)] +mod w5_tests { + use super::*; + #[test] + fn aggregate_budget_keeps_successes_and_exact_failure_options_and_stops_remaining_reads() { + let target = + FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture?node-id=1-2") + .unwrap(); + let mut collector = + CollectorSession::new(CollectionRequest::new(target, CollectionScope::Node)); + let request = AssetRequest { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + asset_id: "synthetic:fills:0".into(), + node_id: "synthetic".into(), + field: "fills/0".into(), + image_hash: Some("source".into()), + format: crate::AssetFormat::Png, + scale: 1.0, + render_mode: crate::AssetRenderMode::Node, + quality: None, + lossless: None, + }; + let mut asset = + crate::exported_asset_from_bytes(&request, b"synthetic byte-budget boundary").unwrap(); + collector.retained_asset_bytes = MAX_COLLECTED_ASSET_BYTES - asset.byte_length.unwrap(); + collector.retain_asset(asset.clone()); + assert_eq!(collector.asset_results[0].status, AssetStatus::Exported); + asset.asset_id = "second:fills:0".into(); + asset.quality = Some(41); + asset.lossless = Some(true); + asset.format = Some(crate::AssetFormat::Webp); + collector.retain_asset(asset); + let failed = &collector.asset_results[1]; + assert_eq!(failed.status, AssetStatus::Failed); + assert_eq!(failed.quality, Some(41)); + assert_eq!(failed.lossless, Some(true)); + assert!(failed.data_base64.is_none()); + assert!(failed.sha256.is_none()); + assert!(collector.asset_byte_budget_exhausted); + assert_eq!(collector.retained_asset_bytes, MAX_COLLECTED_ASSET_BYTES); + } + use std::{ + io::Write, + process::{Command, Stdio}, + }; + + fn run_script(script: &str, setup: &str, key: &str, options: Value) -> Value { + let script = script + .replace("__DEVUP_NODE_ID__", "2:1") + .replace(&format!("\"{key}\""), &options.to_string()); + let program = format!( + "{setup}\n(async () => {{ {script} }})().then(v => process.stdout.write(JSON.stringify(v))).catch(e => {{ console.error(e); process.exit(1); }});" + ); + let mut child = Command::new("node") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Node.js required for script tests"); + child + .stdin + .take() + .unwrap() + .write_all(program.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() + } + + const TREE: &str = include_str!("../text/collector-w5_tests_TREE-ae8d8ae7.md"); + + fn search_output(setup: &str) -> Value { + run_script( + include_str!("../scripts/search.js"), + setup, + "__DEVUP_SEARCH__", + json!({"query":"Loading", "nodeTypes":["FRAME"], "matchKind":"exact", "limit":5}), + ) + } + + #[test] + fn p4_section_keeps_long_page_and_nested_automatic_screens() { + let setup = format!( + include_str!( + "../text/collector-w5_tests_p4_section_keeps_long_page_and_nested_automatic_screens-261e1062.md" + ), + TREE = TREE + ); + let output = run_script( + include_str!("../scripts/section_index.js"), + &setup, + "unused", + Value::Null, + ); + let nodes = output["nodes"].as_array().unwrap(); + assert!( + nodes.iter().any(|n| n["id"] == "5:1"), + "nested screen was discarded" + ); + assert!(!nodes.iter().any(|n| n["id"] == "5:2")); + let screen = nodes.iter().find(|n| n["id"] == "5:1").unwrap(); + assert_eq!(screen["fields"]["parentId"], "3:1"); + let snapshot = merge_chunks(vec![ + snapshot_chunk_from_result(&UpstreamResult { raw: output }).unwrap(), + ]) + .unwrap(); + let target = FigmaTarget { + file_key: "file".into(), + node_id: Some("2:1".into()), + branch_key: None, + }; + let index = build_section_index(&snapshot, &target).unwrap(); + assert_eq!(index.select(&[], true).unwrap(), ["5:1"]); + assert_eq!(index.select(&["3:1".into()], false).unwrap(), ["3:1"]); + for id in ["3:1", "6:1", "6:2"] { + let candidate = index.candidates.iter().find(|c| c.node_id == id).unwrap(); + assert!(!candidate.is_screen_candidate()); + assert!( + candidate + .selection_reasons + .contains(&"explicit-selection-only".into()) + ); + } + } + + #[test] + fn w5_search_accepts_section_and_frame_roots_without_scanning_siblings() { + for setup in [ + TREE.to_owned(), + format!("{TREE}\nfigma.getNodeByIdAsync = async () => frame;"), + ] { + let output = search_output(&setup); + let nodes = output["nodes"].as_array().unwrap(); + assert!(nodes.iter().any(|n| n["id"] == "3:1")); + assert!(!nodes.iter().any(|n| n["id"] == "3:2")); + assert!(nodes.iter().any(|n| n["id"] == "2:1")); + assert_eq!(output["rootIds"], json!(["1:1"])); + } + } + + #[test] + fn w5_text_preview_does_not_change_candidates_or_projection_truncation() { + let setup = format!( + include_str!( + "../text/collector-w5_tests_w5_text_preview_does_not_change_candidates_or_projection_trun-90cc510e.md" + ), + TREE = TREE + ); + let mut baseline = None; + for limit in [0, 160, 500] { + let output = run_script( + include_str!("../scripts/explore.js"), + &setup, + "__DEVUP_EXPLORE__", + json!({"projectionLimit":200,"textPreviewLimit":limit}), + ); + let nodes = output["nodes"].as_array().unwrap(); + let ids: Vec<_> = nodes + .iter() + .filter(|n| n["type"] == "FRAME") + .map(|n| n["id"].clone()) + .collect(); + assert!(!ids.is_empty()); + let snapshot = merge_chunks(vec![ + snapshot_chunk_from_result(&UpstreamResult { + raw: output.clone(), + }) + .unwrap(), + ]) + .unwrap(); + let target = FigmaTarget { + file_key: "file".into(), + node_id: Some("2:1".into()), + branch_key: None, + }; + let explored = + crate::explore_snapshot(&snapshot, &target, &crate::ExploreOptions { limit: 100 }) + .unwrap(); + let candidate_ids: Vec<_> = explored + .candidates + .iter() + .map(|candidate| candidate.node.node_id.clone()) + .collect(); + assert_eq!(candidate_ids.len(), ids.len()); + let state = ( + ids, + candidate_ids, + nodes[0]["fields"]["projectionTruncated"].clone(), + ); + if let Some(expected) = &baseline { + assert_eq!(&state, expected); + } else { + baseline = Some(state); + } + assert!(output.to_string().chars().count() <= 14_000); + } + } + + #[test] + fn w5_previews_are_retained_when_the_envelope_has_room() { + let setup = + format!("{TREE}\nnode('4:1', 'TEXT', 'Copy', frame).characters = 'Preview text';"); + let output = run_script( + include_str!("../scripts/explore.js"), + &setup, + "__DEVUP_EXPLORE__", + json!({"projectionLimit":200,"textPreviewLimit":160}), + ); + let nodes = output["nodes"].as_array().unwrap(); + let frame = nodes.iter().find(|node| node["id"] == "3:1").unwrap(); + assert_eq!(frame["fields"]["textPreview"], "Preview text"); + assert_eq!(nodes[0]["fields"]["projectionTruncated"], false); + } + + #[test] + fn w5_empty_search_preserves_anchor_and_ancestry() { + let output = search_output(&format!("{TREE}\nframe.name = 'Unrelated';")); + let ids: BTreeSet<_> = output["nodes"] + .as_array() + .unwrap() + .iter() + .map(|node| node["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, BTreeSet::from(["1:1", "2:1"])); + } + + fn page_output(ids: &[String]) -> UpstreamResult { + UpstreamResult { + raw: json!({ + "fileKey": "file", + "version": null, + "rootIds": ids, + "nodes": ids.iter().map(|id| json!({ + "id": id, "type": "PAGE", + "fields": {"name": "Page", "parentId": null, "childrenIds": []}, + "extra": {}, "fieldErrors": {} + })).collect::>(), + "diagnostics": [] + }), + } + } + + #[test] + fn w5_file_search_still_schedules_all_thirteen_pages() { + let target = FigmaTarget { + file_key: "file".into(), + node_id: Some("2:1".into()), + branch_key: None, + }; + let mut request = CollectionRequest::new(target, CollectionScope::File); + request.search = Some(SearchReadOptions { + query: "Loading".into(), + match_kind: "exact".into(), + limit: 5, + ..Default::default() + }); + let mut session = CollectorSession::new(request); + let CollectorStep::Call(call) = session.advance().unwrap() else { + panic!("expected catalog") + }; + assert!(matches!(call.call, ReadToolCall::PageCatalog { .. })); + let pages: Vec<_> = (1..=13).map(|i| format!("1:{i}")).collect(); + session.accept(&call.id, page_output(&pages)).unwrap(); + let mut searched = BTreeSet::new(); + loop { + match session.advance().unwrap() { + CollectorStep::Call(call) => { + let ReadToolCall::SearchSnapshot { node_id, .. } = &call.call else { + panic!("expected page search") + }; + searched.insert(node_id.clone()); + session + .accept(&call.id, page_output(std::slice::from_ref(node_id))) + .unwrap(); + } + CollectorStep::Complete(parts) => { + assert_eq!(parts.stats.figma_tool_calls, 14); + assert_eq!(parts.scope, CollectionScope::File); + break; + } + CollectorStep::AwaitingResults => panic!("all calls were accepted"), + } + } + assert_eq!(searched, pages.into_iter().collect()); + } + + #[test] + fn w5_node_search_finishes_in_one_call_without_page_catalog() { + let target = FigmaTarget { + file_key: "file".into(), + node_id: Some("2:1".into()), + branch_key: None, + }; + let mut request = CollectionRequest::new(target, CollectionScope::Node); + request.search = Some(SearchReadOptions { + query: "Loading".into(), + match_kind: "exact".into(), + limit: 5, + ..Default::default() + }); + let mut session = CollectorSession::new(request); + let CollectorStep::Call(call) = session.advance().unwrap() else { + panic!("expected search call") + }; + assert!( + matches!(&call.call, ReadToolCall::SearchSnapshot {node_id, ..} if node_id == "2:1") + ); + session + .accept( + &call.id, + UpstreamResult { + raw: search_output(TREE), + }, + ) + .unwrap(); + let CollectorStep::Complete(parts) = session.advance().unwrap() else { + panic!("extra Figma call") + }; + assert_eq!(parts.stats.figma_tool_calls, 1); + assert_eq!(parts.scope, CollectionScope::Node); + } +} diff --git a/crates/devup-mcp-figma/src/errors.rs b/crates/devup-mcp-figma/src/errors.rs index bcbca7df..73766a31 100644 --- a/crates/devup-mcp-figma/src/errors.rs +++ b/crates/devup-mcp-figma/src/errors.rs @@ -130,12 +130,7 @@ pub fn is_quota_refusal(message: &str, flagged: bool) -> bool { pub fn figma_unreachable_hint(message: &str) -> Option<&'static str> { message .contains("Unable to establish connection to Figma") - .then_some( - "Figma's plugin runtime gave up after 10 seconds fetching data it had not loaded - \ - another page, a main component, a variable or a style. It answers this once the \ - file has been out of sight in the Figma desktop app for two or three minutes - its \ - tab behind another file's tab, or the window minimized or covered by another \ - window - and showing the file again brought the same reads back at once. Bring the \ - Figma window to the front and click the file's tab, then retry.", - ) + .then_some(include_str!( + "text/errors-figma_unreachable_hint-347725ba.md" + )) } diff --git a/crates/devup-mcp-figma/src/oauth.rs b/crates/devup-mcp-figma/src/oauth.rs index 09f2626b..2a3c93ea 100644 --- a/crates/devup-mcp-figma/src/oauth.rs +++ b/crates/devup-mcp-figma/src/oauth.rs @@ -686,11 +686,8 @@ 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." + include_str!("text/oauth-callback_port_in_use_error-049191b7.md"), + port = port ), false, serde_json::json!({ "port": port }), diff --git a/crates/devup-mcp-figma/src/reference_compose.rs b/crates/devup-mcp-figma/src/reference_compose.rs index 7818c251..4192cec3 100644 --- a/crates/devup-mcp-figma/src/reference_compose.rs +++ b/crates/devup-mcp-figma/src/reference_compose.rs @@ -11,2110 +11,7 @@ //! 99.6% of pixels, the rest on text edges and the clipped rim of a blurred //! ancestor. -use std::collections::HashMap; - -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use serde::Deserialize; -use serde_json::Value; - -use crate::{DevupError, ErrorCode}; - -/// Straight-alpha RGBA in 0..1. -type Px = [f32; 4]; - -/// Larger areas are not composed: a buffer is 16 bytes a pixel and a deep -/// chain keeps several. The plugin falls back to the canvas export above it. -pub(crate) const MAX_COMPOSE_PIXELS: u64 = 8_000_000; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Scene { - area: Rect, - #[serde(default = "one")] - scale: f64, - width: u32, - height: u32, - /// The page background, 0..255. - page: [f64; 3], - /// The node's ancestors below the page, outermost first. - #[serde(default)] - layers: Vec, - node: Content, - /// Image fill bytes by image hash, base64. - #[serde(default)] - images: HashMap, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum ImageSource { - /// Its own bytes, laid out at its own size. - Bytes(String), - /// A PNG the plugin's UI decoded it into (the original was not a PNG, JPEG - /// or GIF, or too large), laid out at the original's size. - Decoded { - data: String, - width: f64, - height: f64, - }, -} - -/// Where an export starts on the canvas; its size is the PNG's. -#[derive(Deserialize, Clone, Copy)] -struct Rect { - x: f64, - y: f64, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Layer { - /// `absoluteTransform`: local to absolute. - transform: [[f64; 3]; 2], - width: f64, - height: f64, - /// Top-left, top-right, bottom-right, bottom-left. - #[serde(default)] - radii: [f64; 4], - #[serde(default)] - fills: Vec, - #[serde(default = "one")] - opacity: f64, - #[serde(default)] - blend_mode: Option, - #[serde(default)] - clips: bool, - #[serde(default)] - effects: Vec, - /// The mask that clips this layer among its siblings. - #[serde(default)] - mask: Option, - /// Drawn over all the layer holds: its paints, inside, outside or across - /// its edge by `stroke_align`, `stroke_weights` wide (top, right, bottom, - /// left). The plugin sends only a rectangle's undashed, mitred strokes. - #[serde(default)] - strokes: Vec, - #[serde(default)] - stroke_align: Option, - #[serde(default)] - stroke_weights: [f64; 4], -} - -/// A plugin export placed at the absolute bounds it covers. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Picture { - data: String, - area: Rect, - #[serde(default)] - blend_mode: Option, - /// A LUMINANCE mask clips by brightness, the others by alpha. - #[serde(default)] - luminance: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Content { - /// The node alone. - #[serde(default)] - picture: Option, - /// The node drawn from its own paints, when its export cannot be used - /// alone (a background blur that sampled a layer behind it)... - #[serde(default)] - own: Option, - /// ...with its children's exports on top. - #[serde(default)] - children: Vec, - /// The mask that clips the node among its siblings. - #[serde(default)] - mask: Option, -} - -fn one() -> f64 { - 1.0 -} - -fn invalid(message: impl Into) -> DevupError { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - format!( - "the plugin's reference PNG scene cannot be composed: {}", - message.into() - ), - false, - ) -} - -/// The composed picture as a base64 PNG, opaque, `width` x `height`. -pub(crate) fn compose(scene: &Value) -> Result { - let scene = Scene::deserialize(scene).map_err(|error| invalid(error.to_string()))?; - let (width, height) = (scene.width as usize, scene.height as usize); - if width == 0 || height == 0 || (width as u64) * (height as u64) > MAX_COMPOSE_PIXELS { - return Err(invalid(format!( - "{width}x{height} is outside the composable range" - ))); - } - if !(scene.scale.is_finite() && scene.scale > 0.0 && scene.scale <= 64.0) { - return Err(invalid(format!( - "the scale {} is outside the composable range", - scene.scale - ))); - } - // A blur at the area's rim reads what lies past it - the ancestors' - // paints go on there - so the stack is drawn wider and cut back. - let mut margin = blur_reach(&scene); - while margin > 0 - && ((width + 2 * margin) * (height + 2 * margin)) as u64 > 2 * MAX_COMPOSE_PIXELS - { - margin /= 2; - } - // A drop shadow is cast from what lies its offset and spread away, and - // past the margin nothing is drawn to cast it: a shadow reaching farther is - // not composed rather than drawn smaller. - if let Some(reach) = shadow_reach(&scene).filter(|&reach| reach > margin) { - return Err(invalid(format!( - "a shadow is cast from {reach} pixels away, past the {margin} drawn around the area" - ))); - } - let wide = width + 2 * margin; - let mut images = Images::new(&scene.images)?; - within_budget(&scene, wide, height + 2 * margin, &images)?; - let pixels = draw(&scene, wide, height + 2 * margin, margin, &mut images)?; - let mut rgba = Vec::with_capacity(width * height * 4); - for row in pixels.chunks_exact(wide).skip(margin).take(height) { - for pixel in &row[margin..margin + width] { - for channel in &pixel[..3] { - rgba.push((channel.clamp(0.0, 1.0) * 255.0).round() as u8); - } - rgba.push(255); - } - } - let image = image::RgbaImage::from_raw(scene.width, scene.height, rgba) - .ok_or_else(|| invalid("the pixel buffer does not fill the image"))?; - let mut encoded = Vec::new(); - image - .write_with_encoder(image::codecs::png::PngEncoder::new(&mut encoded)) - .map_err(|error| invalid(error.to_string()))?; - Ok(STANDARD.encode(encoded)) -} - -struct Grid { - width: usize, - height: usize, - origin: (f64, f64), - scale: f64, -} - -impl Grid { - /// The absolute point at a pixel's centre. Pixels sit on whole canvas - /// units from the floor of the bounds' start; an export is the ceiling of - /// the bounds' size wide, as `get_screenshot`'s is. Starting at the exact, - /// fractional start instead matched `get_screenshot` worse on two of the - /// three composed nodes that start between pixels. - fn point(&self, index: usize) -> (f64, f64) { - let (column, row) = (index % self.width, index / self.width); - ( - self.origin.0 + (column as f64 + 0.5) / self.scale, - self.origin.1 + (row as f64 + 0.5) / self.scale, - ) - } - - fn origin_of(rect: Rect, scale: f64) -> (f64, f64) { - ( - (rect.x * scale).floor() / scale, - (rect.y * scale).floor() / scale, - ) - } -} - -struct Entry<'a> { - pixels: Vec, - clip: Vec, - layer: Option<&'a Layer>, - /// The mask that cuts this layer where it is laid back. - mask: Option>, -} - -/// Working memory one composition may take: the stack's buffers and masks, -/// those a layer, blur or picture holds while it is drawn, and the decoded -/// pictures and images. Past it the area is drawn as the canvas shows it -/// instead. The answer the scene came in is read before this is counted. -const MAX_COMPOSE_BYTES: u64 = 1536 * 1024 * 1024; - -/// What a decoder may hold besides its per-pixel buffers: row buffers and -/// metadata chunks. Counted once: pictures and images decode one at a time. -const DECODE_SLACK: u64 = 16 * 1024 * 1024; - -/// Kernel taps across every blur and shadow, so a wide blur over a large area -/// is drawn as the canvas shows it rather than computed for minutes. -const MAX_BLUR_TAPS: u64 = 4_000_000_000; - -/// Refuses, before drawing, what would take more memory or blur work than one -/// reference PNG is worth. -fn within_budget( - scene: &Scene, - width: usize, - height: usize, - images: &Images, -) -> Result<(), DevupError> { - let pixels = (width as u64) * (height as u64); - let layers = || scene.layers.iter().chain(scene.node.own.as_ref()); - let isolated = layers().filter(|layer| isolating(layer)).count() as u64 - + u64::from(scene.node.own.is_some() && scene.node.mask.is_some()); - let masks = layers().filter(|layer| layer.mask.is_some()).count() as u64 - + u64::from(scene.node.mask.is_some()); - let stroked = layers().filter(|layer| !layer.strokes.is_empty()).count() as u64; - // Pictures and images decode one at a time; a picture is dropped once laid - // on the grid, an image is kept. - let mut peak = images.peak; - for picture in layers() - .filter_map(|layer| layer.mask.as_ref()) - .chain(&scene.node.mask) - .chain(&scene.node.picture) - .chain(&scene.node.children) - { - let encoded = base64_bytes(&picture.data)?; - peak = peak.max(Header::read(&encoded)?.peak(encoded.len())); - } - // 20 bytes a pixel for each stack entry (colour and clip), 16 for each - // mask kept to cut its layer, 4 for the clip a stroke waits with, and about - // 120 for what one layer, blur or picture holds while it is drawn. - let bytes = pixels - .saturating_mul(20 * (1 + isolated) + 16 * masks + 4 * stroked + 120) - .saturating_add(peak) - .saturating_add(images.kept); - if bytes > MAX_COMPOSE_BYTES { - return Err(invalid(format!( - "it would take {} MiB to draw", - bytes >> 20 - ))); - } - let taps = layers() - .flat_map(|layer| layer.effects.iter().filter(|effect| visible(effect))) - .filter(|effect| { - matches!( - effect["type"].as_str(), - Some("LAYER_BLUR" | "BACKGROUND_BLUR" | "DROP_SHADOW" | "INNER_SHADOW") - ) - }) - .map(|effect| { - let reach = (number(&effect["radius"]) * scene.scale * 1.5) - .ceil() - .max(0.0) as u64; - // A shadow's spread takes the widest (or narrowest) alpha across a - // window twice the spread wide, once along each axis. - let spread = if effect["type"] == "DROP_SHADOW" { - (number(&effect["spread"]) * scene.scale).round().abs() as u64 - } else { - 0 - }; - pixels - .saturating_mul(reach.saturating_mul(4).saturating_add(2)) - .saturating_add(if spread == 0 { - 0 - } else { - pixels.saturating_mul(spread.saturating_mul(4).saturating_add(2)) - }) - }) - .fold(0_u64, u64::saturating_add); - if taps > MAX_BLUR_TAPS { - return Err(invalid(format!( - "its blurs and shadow spreads would take {taps} kernel taps" - ))); - } - Ok(()) -} - -/// How far away, in pixels, the farthest drop shadow is cast from. -fn shadow_reach(scene: &Scene) -> Option { - scene - .layers - .iter() - .chain(scene.node.own.as_ref()) - .flat_map(|layer| layer.effects.iter().filter(|effect| visible(effect))) - .filter(|effect| effect["type"] == "DROP_SHADOW") - .map(|effect| shadow_cast(effect, scene.scale)) - .max() -} - -/// How far away a drop shadow is cast from as `drop_shadow` draws it: its -/// larger offset and its spread, each rounded to whole pixels on its own. -fn shadow_cast(effect: &Value, scale: f64) -> usize { - let rounded = |value: &Value| (number(value) * scale).round().abs() as usize; - rounded(&effect["offset"]["x"]) - .max(rounded(&effect["offset"]["y"])) - .saturating_add(rounded(&effect["spread"])) -} - -/// How many pixels past the area the widest blur or shadow in the scene reads. -fn blur_reach(scene: &Scene) -> usize { - scene - .layers - .iter() - .chain(scene.node.own.as_ref()) - .flat_map(|layer| layer.effects.iter().filter(|effect| visible(effect))) - .map(|effect| { - let sigma = number(&effect["radius"]) * scene.scale / 2.0; - let shift = if matches!( - effect["type"].as_str(), - Some("DROP_SHADOW" | "INNER_SHADOW") - ) { - (number(&effect["offset"]["x"]) - .abs() - .max(number(&effect["offset"]["y"]).abs()) - + number(&effect["spread"]).abs()) - * scene.scale - } else { - 0.0 - }; - let reach = (sigma * 3.0 + shift).ceil() as usize; - // Rounded apart, a drop shadow's offset and spread can be drawn a - // pixel farther than their sum rounded up. - if effect["type"] == "DROP_SHADOW" { - reach.max( - shadow_cast(effect, scene.scale).saturating_add((sigma * 3.0).ceil() as usize), - ) - } else { - reach - } - }) - .max() - .unwrap_or(0) - .min(512) -} - -fn draw( - scene: &Scene, - width: usize, - height: usize, - margin: usize, - images: &mut Images, -) -> Result, DevupError> { - let origin = Grid::origin_of(scene.area, scene.scale); - let grid = Grid { - width, - height, - origin: ( - origin.0 - margin as f64 / scene.scale, - origin.1 - margin as f64 / scene.scale, - ), - scale: scene.scale, - }; - let count = width * height; - let page = [ - (scene.page[0] / 255.0) as f32, - (scene.page[1] / 255.0) as f32, - (scene.page[2] / 255.0) as f32, - 1.0, - ]; - let mut stack = vec![Entry { - pixels: vec![page; count], - clip: vec![1.0; count], - layer: None, - mask: None, - }]; - let own = scene.node.own.as_ref(); - // A layer's stroke goes over everything it holds, so it waits until that - // is drawn, with the clip that cuts the layer itself. - let mut strokes: Vec<(usize, &Layer, Vec)> = Vec::new(); - for layer in scene.layers.iter().chain(own) { - // Drawn from its parts, the node takes its own mask: it cuts the - // node's paints, background blur and children alike. - let is_own = own.is_some_and(|own| std::ptr::eq(own, layer)); - let mask = match layer - .mask - .as_ref() - .or(scene.node.mask.as_ref().filter(|_| is_own)) - { - Some(mask) => Some(place(mask, &grid, true)?), - None => None, - }; - let geometry = Geometry::new(layer)?; - let shape: Vec = (0..count) - .map(|index| geometry.coverage(grid.point(index), grid.scale)) - .collect(); - let isolated = isolating(layer) || mask.is_some(); - if isolated { - // The ancestors' clip cuts this layer's result - after its blur - // and shadow - where it is laid back, not its content before them. - stack.push(Entry { - pixels: vec![[0.0; 4]; count], - clip: vec![1.0; count], - layer: Some(layer), - mask, - }); - } - if let Some(radius) = effect_radius(layer, "BACKGROUND_BLUR") { - let below = if isolated { - stack.len() - 2 - } else { - stack.len() - 1 - }; - let blurred = gaussian( - &stack[below].pixels, - width, - height, - radius * scene.scale / 2.0, - ); - let (lower, upper) = stack.split_at_mut(below + 1); - let entry = &mut lower[below]; - let layer_mask = upper.last().and_then(|top| top.mask.as_deref()); - for index in 0..count { - let cut = layer_mask.map_or(1.0, |mask| mask[index][3]); - let weight = shape[index] * entry.clip[index] * cut; - entry.pixels[index] = mix(entry.pixels[index], blurred[index], weight); - } - } - let depth = stack.len() - 1; - let top = stack.last_mut().expect("the page entry stays"); - for paint in layer.fills.iter().filter(|paint| visible(paint)) { - let blend = Blend::parse(paint["blendMode"].as_str())?; - let cells = top.pixels.iter_mut().zip(shape.iter().zip(&top.clip)); - for (index, (pixel, (&cover, &clip))) in cells.enumerate() { - let coverage = cover * clip; - if coverage <= 0.0 { - continue; - } - let local = geometry.local(grid.point(index)); - let source = paint_at(paint, layer, local, images)?; - composite(pixel, source, blend, coverage); - } - } - for effect in layer - .effects - .iter() - .filter(|effect| visible(effect) && effect["type"] == "INNER_SHADOW") - { - inner_shadow(&mut top.pixels, &top.clip, &shape, &geometry, &grid, effect)?; - } - if !layer.strokes.is_empty() { - strokes.push((depth, layer, top.clip.clone())); - } - if layer.clips { - for (clip, &cover) in top.clip.iter_mut().zip(&shape) { - *clip *= cover; - } - } - } - let top = stack.last_mut().expect("the page entry stays"); - // Drawn from its parts, the node's mask already went with its paints. - let node_mask = match (&scene.node.mask, own) { - (Some(mask), None) => Some(place(mask, &grid, true)?), - _ => None, - }; - if let Some(picture) = &scene.node.picture { - let blend = Blend::parse(picture.blend_mode.as_deref())?; - let placed = place(picture, &grid, false)?; - for index in 0..count { - let coverage = top.clip[index] * node_mask.as_ref().map_or(1.0, |mask| mask[index][3]); - composite(&mut top.pixels[index], placed[index], blend, coverage); - } - } - for child in &scene.node.children { - let blend = Blend::parse(child.blend_mode.as_deref())?; - let placed = place(child, &grid, false)?; - for ((pixel, &source), &clip) in top.pixels.iter_mut().zip(&placed).zip(&top.clip) { - composite(pixel, source, blend, clip); - } - } - loop { - // The entry on top is complete: lay the strokes of the layers drawn - // into it, innermost first. - let depth = stack.len() - 1; - let top = stack.last_mut().expect("the page entry stays"); - for (_, layer, clip) in strokes.iter().rev().filter(|(at, ..)| *at == depth) { - stroke(&mut top.pixels, layer, clip, &grid, images)?; - } - if stack.len() == 1 { - break; - } - let entry = stack.pop().expect("more than the page entry"); - let layer = entry.layer.expect("an isolated entry names its layer"); - let mut pixels = entry.pixels; - let shadows: Vec<&Value> = layer - .effects - .iter() - .filter(|effect| visible(effect) && effect["type"] == "DROP_SHADOW") - .collect(); - if !shadows.is_empty() { - // Each shadow is cast by the layer alone, and all lie under it. - // Where it is not shown behind the layer it is cut away under the - // layer: under its shape when it is painted, and under what it - // holds wherever that reaches. - let geometry = Geometry::new(layer)?; - let painted = layer.fills.iter().any(visible); - let shape: Vec = (0..count) - .map(|index| { - let held = pixels[index][3]; - if painted { - geometry.coverage(grid.point(index), grid.scale).max(held) - } else { - held - } - }) - .collect(); - let mut under = vec![[0.0; 4]; count]; - for effect in shadows { - let shadow = drop_shadow(&pixels, &grid, effect, &shape); - for (px, cast) in under.iter_mut().zip(shadow) { - composite(px, cast, Blend::Normal, 1.0); - } - } - for (px, &own) in under.iter_mut().zip(&pixels) { - composite(px, own, Blend::Normal, 1.0); - } - pixels = under; - } - if let Some(radius) = effect_radius(layer, "LAYER_BLUR") { - pixels = gaussian(&pixels, width, height, radius * scene.scale / 2.0); - } - let mask = entry.mask; - let blend = Blend::parse(layer.blend_mode.as_deref())?; - let below = stack.last_mut().expect("the page entry stays"); - for index in 0..count { - let mut source = pixels[index]; - source[3] *= layer.opacity as f32 * mask.as_ref().map_or(1.0, |mask| mask[index][3]); - composite(&mut below.pixels[index], source, blend, below.clip[index]); - } - } - Ok(stack.pop().expect("the page entry stays").pixels) -} - -/// A rectangle's stroke laid over `pixels`, cut by `clip`. -fn stroke( - pixels: &mut [Px], - layer: &Layer, - clip: &[f32], - grid: &Grid, - images: &mut Images, -) -> Result<(), DevupError> { - let geometry = Geometry::new(layer)?; - let [top, right, bottom, left] = layer.stroke_weights; - let half = [top / 2.0, right / 2.0, bottom / 2.0, left / 2.0]; - let (outer, inner) = match layer.stroke_align.as_deref() { - Some("INSIDE") => ([0.0; 4], [-top, -right, -bottom, -left]), - Some("OUTSIDE") => (layer.stroke_weights, [0.0; 4]), - _ => (half, half.map(|weight| -weight)), - }; - for paint in layer.strokes.iter().filter(|paint| visible(paint)) { - let blend = Blend::parse(paint["blendMode"].as_str())?; - for (index, pixel) in pixels.iter_mut().enumerate() { - let point = grid.point(index); - let ring = (geometry.grown(point, grid.scale, outer) - - geometry.grown(point, grid.scale, inner)) - .max(0.0); - let coverage = ring * clip[index]; - if coverage <= 0.0 { - continue; - } - let source = paint_at(paint, layer, geometry.local(point), images)?; - composite(pixel, source, blend, coverage); - } - } - Ok(()) -} - -fn visible(item: &Value) -> bool { - item.is_object() && item["visible"] != false -} - -fn isolating(layer: &Layer) -> bool { - layer.opacity < 1.0 - || !matches!( - layer.blend_mode.as_deref(), - None | Some("PASS_THROUGH" | "NORMAL") - ) - || layer.mask.is_some() - || layer.effects.iter().any(|effect| { - visible(effect) && matches!(effect["type"].as_str(), Some("LAYER_BLUR" | "DROP_SHADOW")) - }) -} - -fn effect_radius(layer: &Layer, kind: &str) -> Option { - layer - .effects - .iter() - .filter(|effect| visible(effect) && effect["type"] == kind) - .filter_map(|effect| effect["radius"].as_f64()) - .fold(None, |largest: Option, radius| { - Some(largest.map_or(radius, |r| r.max(radius))) - }) -} - -/// A layer's transform inverted, its size and corner radii. -struct Geometry { - inverse: [[f64; 2]; 2], - translate: (f64, f64), - width: f64, - height: f64, - radii: [f64; 4], -} - -impl Geometry { - fn new(layer: &Layer) -> Result { - let [[a, b, tx], [c, d, ty]] = layer.transform; - let determinant = a * d - b * c; - if determinant.abs() < 1e-12 || layer.width <= 0.0 || layer.height <= 0.0 { - return Err(invalid("a layer has a degenerate transform or size")); - } - let limit = layer.width.min(layer.height) / 2.0; - Ok(Self { - inverse: [ - [d / determinant, -b / determinant], - [-c / determinant, a / determinant], - ], - translate: (tx, ty), - width: layer.width, - height: layer.height, - radii: layer.radii.map(|radius| radius.clamp(0.0, limit)), - }) - } - - /// Anti-aliased coverage of the rectangle grown by `grow` on each side (top, - /// right, bottom, left; negative shrinks). A rounded corner's arc grows - /// about the same centre; a square corner stays square, as a mitre does. - fn grown(&self, point: (f64, f64), scale: f64, [top, right, bottom, left]: [f64; 4]) -> f32 { - let (x, y) = self.local(point); - let half = 0.5 / scale; - let (x0, y0, x1, y1) = (-left, -top, self.width + right, self.height + bottom); - if x1 <= x0 || y1 <= y0 { - return 0.0; - } - let edge = (x - x0).min(x1 - x).min(y - y0).min(y1 - y); - let mut coverage = ((edge + half) / (2.0 * half)).clamp(0.0, 1.0); - let [tl, tr, br, bl] = self.radii; - for (radius, grow, cx, cy, sx, sy) in [ - (tl, top.max(left), tl, tl, -1.0, -1.0), - (tr, top.max(right), self.width - tr, tr, 1.0, -1.0), - ( - br, - bottom.max(right), - self.width - br, - self.height - br, - 1.0, - 1.0, - ), - (bl, bottom.max(left), bl, self.height - bl, -1.0, 1.0), - ] { - let arc = radius + grow; - if radius > 0.0 && arc > 0.0 && (x - cx) * sx > 0.0 && (y - cy) * sy > 0.0 { - let distance = (x - cx).hypot(y - cy) - arc; - coverage = coverage.min(((-distance + half) / (2.0 * half)).clamp(0.0, 1.0)); - } - } - coverage as f32 - } - - fn local(&self, (x, y): (f64, f64)) -> (f64, f64) { - let (dx, dy) = (x - self.translate.0, y - self.translate.1); - ( - self.inverse[0][0] * dx + self.inverse[0][1] * dy, - self.inverse[1][0] * dx + self.inverse[1][1] * dy, - ) - } - - /// Anti-aliased coverage of the (rounded) rectangle at an absolute point. - fn coverage(&self, point: (f64, f64), scale: f64) -> f32 { - let (x, y) = self.local(point); - let half = 0.5 / scale; - let edge = x.min(self.width - x).min(y).min(self.height - y); - let mut coverage = ((edge + half) / (2.0 * half)).clamp(0.0, 1.0); - let [tl, tr, br, bl] = self.radii; - for (radius, cx, cy, sx, sy) in [ - (tl, tl, tl, -1.0, -1.0), - (tr, self.width - tr, tr, 1.0, -1.0), - (br, self.width - br, self.height - br, 1.0, 1.0), - (bl, bl, self.height - bl, -1.0, 1.0), - ] { - if radius > 0.0 && (x - cx) * sx > 0.0 && (y - cy) * sy > 0.0 { - let distance = (x - cx).hypot(y - cy) - radius; - coverage = coverage.min(((-distance + half) / (2.0 * half)).clamp(0.0, 1.0)); - } - } - coverage as f32 - } -} - -fn number(value: &Value) -> f64 { - value.as_f64().unwrap_or(0.0) -} - -fn colour(value: &Value, alpha: f64) -> Px { - [ - number(&value["r"]) as f32, - number(&value["g"]) as f32, - number(&value["b"]) as f32, - (value["a"].as_f64().unwrap_or(1.0) * alpha) as f32, - ] -} - -fn paint_at( - paint: &Value, - layer: &Layer, - (x, y): (f64, f64), - images: &mut Images, -) -> Result { - let opacity = paint["opacity"].as_f64().unwrap_or(1.0); - let (nx, ny) = (x / layer.width, y / layer.height); - let kind = paint["type"].as_str().unwrap_or_default(); - match kind { - "SOLID" => { - let mut px = colour(&paint["color"], 1.0); - px[3] = opacity as f32; - Ok(px) - } - "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" => { - let m = &paint["gradientTransform"]; - let row = |r: usize, c: usize| number(&m[r][c]); - let qx = row(0, 0) * nx + row(0, 1) * ny + row(0, 2); - let qy = row(1, 0) * nx + row(1, 1) * ny + row(1, 2); - let t = match kind { - "GRADIENT_LINEAR" => qx, - "GRADIENT_RADIAL" => (qx - 0.5).hypot(qy - 0.5) * 2.0, - "GRADIENT_DIAMOND" => ((qx - 0.5).abs() + (qy - 0.5).abs()) * 2.0, - _ => ((qy - 0.5).atan2(qx - 0.5) / std::f64::consts::TAU).rem_euclid(1.0), - } - .clamp(0.0, 1.0); - let mut px = gradient_colour(&paint["gradientStops"], t)?; - px[3] *= opacity as f32; - Ok(px) - } - "IMAGE" => { - let hash = paint["imageHash"].as_str().unwrap_or_default(); - let image = images.get(hash)?; - let (iw, ih) = image.layout; - let (lw, lh) = (layer.width, layer.height); - let (u, v, inside) = match paint["scaleMode"].as_str().unwrap_or("FILL") { - "FILL" | "FIT" => { - let s = if paint["scaleMode"] == "FIT" { - (lw / iw).min(lh / ih) - } else { - (lw / iw).max(lh / ih) - }; - let u = (x - (lw - iw * s) / 2.0) / s; - let v = (y - (lh - ih * s) / 2.0) / s; - (u, v, (0.0..iw).contains(&u) && (0.0..ih).contains(&v)) - } - "TILE" => { - let s = paint["scalingFactor"] - .as_f64() - .filter(|s| *s > 0.0) - .unwrap_or(1.0); - ((x / s).rem_euclid(iw), (y / s).rem_euclid(ih), true) - } - _ => { - let m = &paint["imageTransform"]; - let row = |r: usize, c: usize| number(&m[r][c]); - let u = (row(0, 0) * nx + row(0, 1) * ny + row(0, 2)) * iw; - let v = (row(1, 0) * nx + row(1, 1) * ny + row(1, 2)) * ih; - (u, v, (0.0..iw).contains(&u) && (0.0..ih).contains(&v)) - } - }; - // Exactly 1 unless the plugin sent the image smaller than laid out. - let (sx, sy) = (image.width as f64 / iw, image.height as f64 / ih); - let mut px = image.bilinear(u * sx - 0.5, v * sy - 0.5); - px[3] *= (opacity * f64::from(u8::from(inside))) as f32; - Ok(px) - } - other => Err(invalid(format!("a {other} paint"))), - } -} - -fn gradient_colour(stops: &Value, t: f64) -> Result { - let mut stops: Vec<(f64, Px)> = stops - .as_array() - .ok_or_else(|| invalid("a gradient without stops"))? - .iter() - .map(|stop| (number(&stop["position"]), colour(&stop["color"], 1.0))) - .collect(); - if stops.is_empty() { - return Err(invalid("a gradient without stops")); - } - stops.sort_by(|a, b| a.0.total_cmp(&b.0)); - let first = stops[0]; - let last = stops[stops.len() - 1]; - if t <= first.0 { - return Ok(first.1); - } - if t >= last.0 { - return Ok(last.1); - } - let pair = stops - .windows(2) - .find(|pair| t >= pair[0].0 && t <= pair[1].0) - .ok_or_else(|| invalid("gradient stops out of order"))?; - let span = (pair[1].0 - pair[0].0).max(1e-12); - let k = ((t - pair[0].0) / span) as f32; - let mut px = [0.0; 4]; - for (channel, value) in px.iter_mut().enumerate() { - *value = pair[0].1[channel] + (pair[1].1[channel] - pair[0].1[channel]) * k; - } - Ok(px) -} - -/// A decoded picture, RGBA bytes: a quarter of what straight floats take. -struct Decoded { - width: usize, - height: usize, - rgba: Vec, - /// The size its fill is laid out at: its own, or the original's when the - /// plugin sent it decoded and smaller. - layout: (f64, f64), -} - -fn base64_bytes(data: &str) -> Result, DevupError> { - STANDARD - .decode(data.as_bytes()) - .map_err(|_| invalid("an image is not base64")) -} - -impl Decoded { - /// Decoded as the format its header names, within its allowance. - fn from_bytes(bytes: &[u8]) -> Result { - let header = Header::read(bytes)?; - let mut limits = image::Limits::default(); - limits.max_alloc = Some(header.allowance); - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)); - reader.set_format(header.format); - reader.limits(limits); - let image = reader - .decode() - .map_err(|error| invalid(format!("an image does not decode: {error}")))? - .into_rgba8(); - Ok(Self { - width: image.width() as usize, - height: image.height() as usize, - layout: (f64::from(image.width()), f64::from(image.height())), - rgba: image.into_raw(), - }) - } - - fn at(&self, x: usize, y: usize) -> Px { - let start = (y * self.width + x) * 4; - let mut px = [0.0; 4]; - for (value, &byte) in px.iter_mut().zip(&self.rgba[start..start + 4]) { - *value = f32::from(byte) / 255.0; - } - px - } - - fn bilinear(&self, u: f64, v: f64) -> Px { - let u = u.clamp(0.0, (self.width - 1) as f64); - let v = v.clamp(0.0, (self.height - 1) as f64); - let (x0, y0) = (u.floor() as usize, v.floor() as usize); - let (x1, y1) = ((x0 + 1).min(self.width - 1), (y0 + 1).min(self.height - 1)); - let (fx, fy) = ((u - x0 as f64) as f32, (v - y0 as f64) as f32); - let (a, b, c, d) = ( - self.at(x0, y0), - self.at(x1, y0), - self.at(x0, y1), - self.at(x1, y1), - ); - let mut px = [0.0; 4]; - for (channel, value) in px.iter_mut().enumerate() { - let top = a[channel] * (1.0 - fx) + b[channel] * fx; - let bottom = c[channel] * (1.0 - fx) + d[channel] * fx; - *value = top * (1.0 - fy) + bottom * fy; - } - px - } -} - -/// The images the ancestors' fills use, read once: their bytes up front, so -/// their size is known before anything is drawn, and their pixels when first -/// sampled. -struct Images { - sources: HashMap>, - /// The original's size, for an image the plugin sent decoded. - layouts: HashMap, - /// What stays once they are decoded, and the most one decode takes. - kept: u64, - peak: u64, - decoded: HashMap, -} - -/// A picture or image read from its first bytes without a decoder, so nothing -/// is allocated for one the budget then refuses. -struct Header { - format: image::ImageFormat, - pixels: u64, - /// What its decoder may allocate. - allowance: u64, - /// What its decoder keeps for each PNG text chunk, which no allowance holds. - texts: u64, -} - -/// What a PNG decoder keeps for one text chunk beside its bytes: the entry and -/// its strings. -const PNG_TEXT_CHUNK_BYTES: u64 = 512; - -/// PNGs wider than this are not decoded. A PNG decoder unfilters rows outside -/// its allowance, and for a wide interlaced 16-bit image those buffers outgrow -/// it; up to this width (the plugin's exports' longest edge) they stay within -/// `DECODE_SLACK`. -const MAX_PNG_WIDTH: u64 = 8192; - -impl Header { - /// Its format, size and allowance: - /// - PNG and GIF: 8 bytes a pixel (a 16-bit PNG decodes to 8), which - /// `Decoded::from_bytes` holds their decoders to; a GIF frame larger than - /// its screen decodes only within it. A PNG's text chunks are counted - /// one by one. - /// - JPEG: two bytes for each sample of each component's coefficients at - /// the sampling factors its frame header names, over rows and columns - /// padded to whole MCUs, and a 4-byte output. Its decoder takes no - /// allocation limit; this is what it is counted at. - /// - Anything else, WebP among them, is refused: its decoder takes no limit - /// and sizes buffers from data no header bounds, its entropy tables and - /// partitions among them. - fn read(encoded: &[u8]) -> Result { - let field = - |at: usize, length: usize| at.checked_add(length).and_then(|end| encoded.get(at..end)); - let (format, width, height, allowance, texts) = if encoded.starts_with(b"\x89PNG\r\n\x1a\n") - { - let size = - |at| field(at, 4).map(|b| u64::from(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))); - let (Some(b"IHDR"), Some(width), Some(height)) = (field(12, 4), size(16), size(20)) - else { - return Err(invalid("a PNG's header cannot be read")); - }; - if width > MAX_PNG_WIDTH { - return Err(invalid(format!( - "a PNG wider than {MAX_PNG_WIDTH} pixels is not composed" - ))); - } - let mut texts = 0_u64; - let mut at = 8_usize; - while at < encoded.len() { - let (Some(length), Some(kind)) = (size(at), field(at + 4, 4)) else { - return Err(invalid("a PNG's chunks cannot be read")); - }; - let Some(end) = usize::try_from(length) - .ok() - .and_then(|length| at.checked_add(12)?.checked_add(length)) - .filter(|&end| end <= encoded.len()) - else { - return Err(invalid("a PNG's chunk runs past its end")); - }; - if matches!(kind, b"tEXt" | b"zTXt" | b"iTXt") { - texts += 1; - } - if kind == b"IEND" { - break; - } - at = end; - } - ( - image::ImageFormat::Png, - width, - height, - width.saturating_mul(height).saturating_mul(8), - texts, - ) - } else if encoded.starts_with(b"GIF87a") || encoded.starts_with(b"GIF89a") { - let size = |at| field(at, 2).map(|b| u64::from(u16::from_le_bytes([b[0], b[1]]))); - let (Some(width), Some(height)) = (size(6), size(8)) else { - return Err(invalid("a GIF's header cannot be read")); - }; - ( - image::ImageFormat::Gif, - width, - height, - width * height * 8, - 0, - ) - } else if encoded.starts_with(&[0xFF, 0xD8]) { - let (width, height, coefficients) = jpeg_frame(encoded) - .ok_or_else(|| invalid("a JPEG's frame header cannot be read"))?; - let padded = (width + 32).saturating_mul(height + 32); - ( - image::ImageFormat::Jpeg, - width, - height, - padded.saturating_mul(coefficients + 4), - 0, - ) - } else { - return Err(invalid("an image is not a PNG, JPEG or GIF")); - }; - if width == 0 || height == 0 { - return Err(invalid("an image has no size")); - } - Ok(Self { - format, - pixels: width.saturating_mul(height), - allowance: allowance.saturating_add(DECODE_SLACK), - texts: texts.saturating_mul(PNG_TEXT_CHUNK_BYTES), - }) - } - - /// The most decoding it takes: its allowance three times (the output it is - /// held to, what a PNG decoder keeps within its own copy of the allowance, - /// and the rows it unfilters outside it), the RGBA8 it becomes, eight - /// copies of its bytes (the source, a JPEG decoder's own copy, the metadata - /// it gathers and reassembles, and the lists holding it), and its text - /// chunks. - fn peak(&self, encoded: usize) -> u64 { - self.allowance - .saturating_mul(3) - .saturating_add(self.pixels.saturating_mul(4)) - .saturating_add((encoded as u64).saturating_mul(8)) - .saturating_add(self.texts) - } -} - -/// A JPEG's size and two bytes a pixel for each sample of each component, from -/// its frame header: the first, before its first scan (its decoder refuses a -/// second). A marker standing alone before it (another SOI, a restart, a -/// stuffed zero) or a segment too short for its length is refused: its decoder -/// reads those differently, and could skip the frame header counted here. -fn jpeg_frame(encoded: &[u8]) -> Option<(u64, u64, u64)> { - let mut at = 2; - loop { - if *encoded.get(at)? != 0xFF { - return None; - } - let marker = *encoded.get(at + 1)?; - let word = |offset: usize| { - Some(u16::from_be_bytes([ - *encoded.get(at + offset)?, - *encoded.get(at + offset + 1)?, - ])) - }; - match marker { - 0xFF => at += 1, - 0x00 | 0x01 | 0xD0..=0xDA => return None, - 0xC0..=0xCF if !matches!(marker, 0xC4 | 0xC8 | 0xCC) => { - let (height, width) = (u64::from(word(5)?), u64::from(word(7)?)); - let components = usize::from(*encoded.get(at + 9)?); - if usize::from(word(2)?) != 8 + 3 * components { - return None; - } - let samples = (0..components) - .map(|index| { - let factors = *encoded.get(at + 11 + 3 * index)?; - Some(2 * u64::from(factors >> 4) * u64::from(factors & 15)) - }) - .sum::>()?; - return Some((width, height, samples)); - } - _ => { - let length = usize::from(word(2)?); - if length < 2 { - return None; - } - at += 2 + length; - } - } - } -} - -impl Images { - fn new(sources: &HashMap) -> Result { - let (mut bytes, mut layouts) = (HashMap::new(), HashMap::new()); - let (mut kept, mut peak) = (0_u64, 0_u64); - for (hash, source) in sources { - let (data, layout) = match source { - ImageSource::Bytes(data) => (data, None), - ImageSource::Decoded { - data, - width, - height, - } if *width > 0.0 && *height > 0.0 => (data, Some((*width, *height))), - ImageSource::Decoded { .. } => { - return Err(invalid(format!("the image {hash} has no size"))); - } - }; - let encoded = base64_bytes(data)?; - let header = Header::read(&encoded)?; - kept = kept - .saturating_add(header.pixels.saturating_mul(4)) - .saturating_add(encoded.len() as u64); - peak = peak.max(header.peak(encoded.len())); - bytes.insert(hash.clone(), encoded); - if let Some(layout) = layout { - layouts.insert(hash.clone(), layout); - } - } - Ok(Self { - sources: bytes, - layouts, - kept, - peak, - decoded: HashMap::new(), - }) - } - - fn get(&mut self, hash: &str) -> Result<&Decoded, DevupError> { - if !self.decoded.contains_key(hash) { - let data = self - .sources - .get(hash) - .ok_or_else(|| invalid(format!("the image {hash} was not sent")))?; - let mut decoded = Decoded::from_bytes(data)?; - if let Some(layout) = self.layouts.get(hash) { - decoded.layout = *layout; - } - self.decoded.insert(hash.to_owned(), decoded); - } - Ok(&self.decoded[hash]) - } -} - -/// A plugin export laid on the grid; outside it, transparent. A mask reads as -/// its coverage in the alpha channel. -fn place(picture: &Picture, grid: &Grid, as_mask: bool) -> Result, DevupError> { - let image = Decoded::from_bytes(&base64_bytes(&picture.data)?)?; - let origin = Grid::origin_of(picture.area, grid.scale); - let offset_x = ((origin.0 - grid.origin.0) * grid.scale).round() as isize; - let offset_y = ((origin.1 - grid.origin.1) * grid.scale).round() as isize; - let mut out = vec![[0.0; 4]; grid.width * grid.height]; - for (index, px) in out.iter_mut().enumerate() { - let column = (index % grid.width) as isize - offset_x; - let row = (index / grid.width) as isize - offset_y; - if column < 0 || row < 0 || column as usize >= image.width || row as usize >= image.height { - continue; - } - let source = image.at(column as usize, row as usize); - *px = if as_mask && picture.luminance { - let luma = 0.2126 * source[0] + 0.7152 * source[1] + 0.0722 * source[2]; - [0.0, 0.0, 0.0, luma * source[3]] - } else { - source - }; - } - Ok(out) -} - -fn mix(a: Px, b: Px, weight: f32) -> Px { - let mut out = [0.0; 4]; - for channel in 0..4 { - out[channel] = a[channel] + (b[channel] - a[channel]) * weight; - } - out -} - -/// A drop shadow the layer casts: its alpha, moved, spread, blurred and -/// coloured; unless shown behind it, cut away by `shape`. -fn drop_shadow(pixels: &[Px], grid: &Grid, effect: &Value, shape: &[f32]) -> Vec { - let colour = colour(&effect["color"], 1.0); - let shift = |value: &Value| (number(value) * grid.scale).round().clamp(-1e9, 1e9) as isize; - let (dx, dy) = (shift(&effect["offset"]["x"]), shift(&effect["offset"]["y"])); - let (width, height) = (grid.width, grid.height); - let mut shadow = vec![[0.0; 4]; width * height]; - for (index, px) in shadow.iter_mut().enumerate() { - let column = (index % width) as isize - dx; - let row = (index / width) as isize - dy; - if column >= 0 && row >= 0 && (column as usize) < width && (row as usize) < height { - let alpha = pixels[row as usize * width + column as usize][3]; - *px = [colour[0], colour[1], colour[2], alpha * colour[3]]; - } - } - let spread = (number(&effect["spread"]) * grid.scale).round() as isize; - if spread != 0 { - spread_alpha(&mut shadow, width, height, spread); - } - let mut shadow = gaussian( - &shadow, - width, - height, - number(&effect["radius"]) * grid.scale / 2.0, - ); - if effect["showShadowBehindNode"] == false { - for (px, &cover) in shadow.iter_mut().zip(shape) { - px[3] *= 1.0 - cover; - } - } - shadow -} - -/// An inner shadow over a layer's paints and under what it holds: what lies -/// outside the layer, moved by the offset, grown inward by the spread, blurred -/// and coloured, inside the layer. -fn inner_shadow( - pixels: &mut [Px], - clip: &[f32], - shape: &[f32], - geometry: &Geometry, - grid: &Grid, - effect: &Value, -) -> Result<(), DevupError> { - let colour = colour(&effect["color"], 1.0); - let blend = Blend::parse(effect["blendMode"].as_str())?; - let (dx, dy) = ( - number(&effect["offset"]["x"]), - number(&effect["offset"]["y"]), - ); - let spread = number(&effect["spread"]); - let outside: Vec = (0..pixels.len()) - .map(|index| { - let (x, y) = grid.point(index); - let inside = geometry.grown((x - dx, y - dy), grid.scale, [-spread; 4]); - [0.0, 0.0, 0.0, 1.0 - inside] - }) - .collect(); - let blurred = gaussian( - &outside, - grid.width, - grid.height, - number(&effect["radius"]) * grid.scale / 2.0, - ); - for (index, pixel) in pixels.iter_mut().enumerate() { - let coverage = blurred[index][3] * shape[index] * clip[index]; - composite(pixel, colour, blend, coverage); - } - Ok(()) -} - -/// Grows a shadow's alpha by `by` pixels (shrinks it when negative), as a -/// shadow's spread does. Past the grid's edge the shadow is taken to go on. -fn spread_alpha(shadow: &mut [Px], width: usize, height: usize, by: isize) { - let reach = by.unsigned_abs(); - let pick = |a: f32, b: f32| if by > 0 { a.max(b) } else { a.min(b) }; - for horizontal in [true, false] { - let (lines, length) = if horizontal { - (height, width) - } else { - (width, height) - }; - let at = |line: usize, step: usize| { - if horizontal { - line * width + step - } else { - step * width + line - } - }; - let mut alphas = vec![0.0_f32; length]; - for line in 0..lines { - for (step, alpha) in alphas.iter_mut().enumerate() { - *alpha = shadow[at(line, step)][3]; - } - for step in 0..length { - let (from, to) = (step.saturating_sub(reach), (step + reach).min(length - 1)); - shadow[at(line, step)][3] = - alphas[from..=to].iter().copied().fold(alphas[step], pick); - } - } - } -} - -/// Separable Gaussian on premultiplied colour; nothing outside the grid. -fn gaussian(pixels: &[Px], width: usize, height: usize, sigma: f64) -> Vec { - if sigma < 0.05 { - return pixels.to_vec(); - } - let reach = (sigma * 3.0).ceil() as isize; - let weights: Vec = (-reach..=reach) - .map(|offset| (-((offset * offset) as f64) / (2.0 * sigma * sigma)).exp() as f32) - .collect(); - let total: f32 = weights.iter().sum(); - let weights: Vec = weights.iter().map(|weight| weight / total).collect(); - let premultiplied: Vec = pixels - .iter() - .map(|p| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]) - .collect(); - let pass = |source: &[Px], horizontal: bool| -> Vec { - let mut out = vec![[0.0; 4]; width * height]; - for (index, px) in out.iter_mut().enumerate() { - let (column, row) = ((index % width) as isize, (index / width) as isize); - for (k, weight) in weights.iter().enumerate() { - let offset = k as isize - reach; - let (c, r) = if horizontal { - (column + offset, row) - } else { - (column, row + offset) - }; - if c < 0 || r < 0 || c as usize >= width || r as usize >= height { - continue; - } - let sample = source[r as usize * width + c as usize]; - for channel in 0..4 { - px[channel] += sample[channel] * weight; - } - } - } - out - }; - pass(&pass(&premultiplied, true), false) - .into_iter() - .map(|p| { - if p[3] > 1e-6 { - [p[0] / p[3], p[1] / p[3], p[2] / p[3], p[3]] - } else { - [0.0; 4] - } - }) - .collect() -} - -#[derive(Clone, Copy, PartialEq)] -enum Blend { - Normal, - Multiply, - Screen, - Overlay, - Darken, - Lighten, - ColorDodge, - ColorBurn, - HardLight, - SoftLight, - Difference, - Exclusion, - LinearDodge, - LinearBurn, - Hue, - Saturation, - Color, - Luminosity, -} - -impl Blend { - fn parse(mode: Option<&str>) -> Result { - Ok(match mode.unwrap_or("NORMAL") { - "NORMAL" | "PASS_THROUGH" => Self::Normal, - "MULTIPLY" => Self::Multiply, - "SCREEN" => Self::Screen, - "OVERLAY" => Self::Overlay, - "DARKEN" => Self::Darken, - "LIGHTEN" => Self::Lighten, - "COLOR_DODGE" => Self::ColorDodge, - "COLOR_BURN" => Self::ColorBurn, - "HARD_LIGHT" => Self::HardLight, - "SOFT_LIGHT" => Self::SoftLight, - "DIFFERENCE" => Self::Difference, - "EXCLUSION" => Self::Exclusion, - "LINEAR_DODGE" => Self::LinearDodge, - "LINEAR_BURN" => Self::LinearBurn, - "HUE" => Self::Hue, - "SATURATION" => Self::Saturation, - "COLOR" => Self::Color, - "LUMINOSITY" => Self::Luminosity, - other => return Err(invalid(format!("the blend mode {other}"))), - }) - } - - fn channel(self, b: f32, s: f32) -> f32 { - match self { - Self::Multiply => b * s, - Self::Screen => b + s - b * s, - Self::Overlay => Self::HardLight.channel(s, b), - Self::Darken => b.min(s), - Self::Lighten => b.max(s), - Self::ColorDodge if b <= 0.0 => 0.0, - Self::ColorDodge if s >= 1.0 => 1.0, - Self::ColorDodge => (b / (1.0 - s)).min(1.0), - Self::ColorBurn if b >= 1.0 => 1.0, - Self::ColorBurn if s <= 0.0 => 0.0, - Self::ColorBurn => 1.0 - ((1.0 - b) / s).min(1.0), - Self::HardLight if s <= 0.5 => b * 2.0 * s, - Self::HardLight => Self::Screen.channel(b, 2.0 * s - 1.0), - Self::SoftLight => { - let d = if b <= 0.25 { - ((16.0 * b - 12.0) * b + 4.0) * b - } else { - b.sqrt() - }; - if s <= 0.5 { - b - (1.0 - 2.0 * s) * b * (1.0 - b) - } else { - b + (2.0 * s - 1.0) * (d - b) - } - } - Self::Difference => (b - s).abs(), - Self::Exclusion => b + s - 2.0 * b * s, - Self::LinearDodge => (b + s).min(1.0), - Self::LinearBurn => (b + s - 1.0).max(0.0), - _ => s, - } - } - - /// W3C compositing: separable modes per channel, the four others on the - /// whole colour. - fn apply(self, b: [f32; 3], s: [f32; 3]) -> [f32; 3] { - match self { - Self::Normal => s, - Self::Hue => set_lum(set_sat(s, sat(b)), lum(b)), - Self::Saturation => set_lum(set_sat(b, sat(s)), lum(b)), - Self::Color => set_lum(s, lum(b)), - Self::Luminosity => set_lum(b, lum(s)), - _ => [0, 1, 2].map(|channel| self.channel(b[channel], s[channel])), - } - } -} - -fn lum(c: [f32; 3]) -> f32 { - 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] -} - -fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] { - let d = l - lum(c); - let c = c.map(|channel| channel + d); - let l = lum(c); - let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); - c.map(|channel| { - let mut channel = channel; - if n < 0.0 { - channel = l + (channel - l) * l / (l - n).max(1e-9); - } - if x > 1.0 { - channel = l + (channel - l) * (1.0 - l) / (x - l).max(1e-9); - } - channel - }) -} - -fn sat(c: [f32; 3]) -> f32 { - c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) -} - -fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { - let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); - if x <= n { - return [0.0; 3]; - } - c.map(|channel| (channel - n) / (x - n) * s) -} - -/// Source over destination with a blend mode, straight alpha in and out. -fn composite(dst: &mut Px, src: Px, blend: Blend, coverage: f32) { - let sa = src[3] * coverage; - if sa <= 0.0 { - return; - } - let da = dst[3]; - let (cs, cb) = ([src[0], src[1], src[2]], [dst[0], dst[1], dst[2]]); - let blended = blend.apply(cb, cs); - let out_a = sa + da * (1.0 - sa); - for channel in 0..3 { - let mixed = (1.0 - da) * cs[channel] + da * blended[channel]; - dst[channel] = (sa * mixed + (1.0 - sa) * da * cb[channel]) / out_a.max(1e-9); - } - dst[3] = out_a; -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - fn png(width: u32, height: u32, rgba: Vec) -> String { - let image = image::RgbaImage::from_raw(width, height, rgba).expect("pixels fill the image"); - let mut encoded = Vec::new(); - image - .write_with_encoder(image::codecs::png::PngEncoder::new(&mut encoded)) - .expect("a PNG encodes"); - STANDARD.encode(encoded) - } - - fn decode(data: &str) -> Vec { - image::load_from_memory(&STANDARD.decode(data).expect("base64")) - .expect("a PNG") - .to_rgba8() - .into_raw() - } - - fn frame(fills: Value) -> Value { - json!({"transform": [[1, 0, 0], [0, 1, 0]], "width": 4, "height": 1, "fills": fills, "clips": true}) - } - - /// The node over a horizontal gradient its parent is painted with: the - /// gradient runs left to right across the parent, sampled at pixel centres. - #[test] - fn a_node_alone_lands_on_its_parents_gradient() { - let scene = json!({ - "area": {"x": 0, "y": 0, "width": 4, "height": 1}, "width": 4, "height": 1, "page": [30, 30, 30], - "layers": [frame(json!([{"type": "GRADIENT_LINEAR", "gradientTransform": [[1, 0, 0], [0, 1, 0]], - "gradientStops": [{"position": 0, "color": {"r": 0, "g": 0, "b": 0, "a": 1}}, - {"position": 1, "color": {"r": 1, "g": 1, "b": 1, "a": 1}}]}]))], - "node": {"picture": {"data": png(4, 1, vec![255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), - "area": {"x": 0, "y": 0, "width": 4, "height": 1}}} - }); - let rgba = decode(&compose(&scene).expect("composes")); - assert_eq!(&rgba[0..4], &[255, 0, 0, 255]); - assert_eq!(&rgba[4..8], &[96, 96, 96, 255]); - assert_eq!(&rgba[12..16], &[223, 223, 223, 255]); - } - - /// A translucent ancestor fades the node with it, and a MULTIPLY node - /// multiplies what lies under it. - #[test] - fn group_opacity_and_blend_modes_apply_to_the_node() { - let node = png(4, 1, [128, 128, 128, 255].repeat(4)); - let scene = |opacity: f64, blend: &str| { - json!({ - "area": {"x": 0, "y": 0, "width": 4, "height": 1}, "width": 4, "height": 1, "page": [255, 255, 255], - "layers": [{"transform": [[1, 0, 0], [0, 1, 0]], "width": 4, "height": 1, "opacity": opacity, - "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 1}}]}], - "node": {"picture": {"data": node, "area": {"x": 0, "y": 0, "width": 4, "height": 1}, "blendMode": blend}} - }) - }; - let faded = decode(&compose(&scene(0.25, "NORMAL")).expect("composes")); - assert_eq!(&faded[0..4], &[223, 223, 223, 255]); - let multiplied = decode(&compose(&scene(1.0, "MULTIPLY")).expect("composes")); - assert_eq!(&multiplied[0..4], &[0, 0, 128, 255]); - } - - /// A mask clips the node to its own alpha; outside the mask nothing of the - /// node shows. - #[test] - fn a_mask_clips_the_node() { - let scene = json!({ - "area": {"x": 0, "y": 0, "width": 2, "height": 1}, "width": 2, "height": 1, "page": [0, 0, 0], - "node": {"picture": {"data": png(2, 1, [255, 255, 255, 255].repeat(2)), - "area": {"x": 0, "y": 0, "width": 2, "height": 1}}, - "mask": {"data": png(1, 1, vec![0, 0, 0, 255]), "area": {"x": 1, "y": 0, "width": 1, "height": 1}}} - }); - let rgba = decode(&compose(&scene).expect("composes")); - assert_eq!(rgba, vec![0, 0, 0, 255, 255, 255, 255, 255]); - } - - /// A background blur over a backdrop that goes on past the area keeps the - /// rim the same as the middle: the blur reads that backdrop, not nothing. - #[test] - fn a_background_blur_reads_the_backdrop_past_the_area() { - let scene = json!({ - "area": {"x": 0, "y": 0}, "width": 2, "height": 2, "page": [0, 0, 0], - "layers": [{"transform": [[1, 0, -20], [0, 1, -20]], "width": 42, "height": 42, - "fills": [{"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0}}]}], - "node": {"own": {"transform": [[1, 0, 0], [0, 1, 0]], "width": 2, "height": 2, - "fills": [{"type": "SOLID", "opacity": 0.2, "color": {"r": 1, "g": 1, "b": 1}}], - "effects": [{"type": "BACKGROUND_BLUR", "radius": 4}]}} - }); - let rgba = decode(&compose(&scene).expect("composes")); - assert_eq!(rgba, [255, 51, 51, 255].repeat(4)); - } - - /// A clipping ancestor cuts a translucent layer once, where the layer is - /// laid back: half covered at the clip's edge and at half opacity is a - /// quarter, not an eighth. - #[test] - fn a_clip_cuts_an_isolated_layer_once() { - let scene = json!({ - "area": {"x": 0, "y": 0}, "width": 2, "height": 1, "page": [0, 0, 0], - "layers": [ - {"transform": [[1, 0, 0], [0, 1, 0]], "width": 1.5, "height": 1, "clips": true}, - {"transform": [[1, 0, 0], [0, 1, 0]], "width": 2, "height": 1, "opacity": 0.5, - "fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}]} - ], - "node": {"picture": {"data": png(2, 1, vec![0; 8]), "area": {"x": 0, "y": 0}}} - }); - let rgba = decode(&compose(&scene).expect("composes")); - assert_eq!(rgba, vec![128, 128, 128, 255, 64, 64, 64, 255]); - } - - /// Drawn from its parts (its own paints, then its children), the node is - /// still cut by its mask: outside it neither shows, inside both do. - #[test] - fn a_mask_cuts_a_node_drawn_from_its_parts() { - let scene = json!({ - "area": {"x": 0, "y": 0}, "width": 2, "height": 1, "page": [0, 0, 0], - "node": { - "own": {"transform": [[1, 0, 0], [0, 1, 0]], "width": 2, "height": 1, - "fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}], - "effects": [{"type": "BACKGROUND_BLUR", "radius": 2}]}, - "children": [{"data": png(1, 1, vec![255, 0, 0, 255]), "area": {"x": 1, "y": 0}}], - "mask": {"data": png(1, 1, vec![0, 0, 0, 255]), "area": {"x": 1, "y": 0}} - } - }); - let rgba = decode(&compose(&scene).expect("composes")); - assert_eq!(rgba, vec![0, 0, 0, 255, 255, 0, 0, 255]); - } - - /// A PNG that is only its header, claiming `width` x `height`. - fn png_header(width: u32, height: u32) -> String { - fn crc(bytes: &[u8]) -> u32 { - let mut crc = !0_u32; - for &byte in bytes { - crc ^= u32::from(byte); - for _ in 0..8 { - crc = if crc & 1 == 1 { - (crc >> 1) ^ 0xEDB8_8320 - } else { - crc >> 1 - }; - } - } - !crc - } - let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); - let mut chunk = |kind: &[u8; 4], data: &[u8]| { - let typed = [kind.as_slice(), data].concat(); - png.extend((data.len() as u32).to_be_bytes()); - png.extend(&typed); - png.extend(crc(&typed).to_be_bytes()); - }; - chunk( - b"IHDR", - &[width.to_be_bytes(), height.to_be_bytes()] - .concat() - .iter() - .copied() - .chain([8, 6, 0, 0, 0]) - .collect::>(), - ); - chunk(b"IDAT", &[]); - chunk(b"IEND", &[]); - STANDARD.encode(png) - } - - /// Every mask a layer keeps to cut it counts: eleven masked ancestors over - /// 2000x2000 take over 1.5 GiB though their stack alone was counted under it. - #[test] - fn masks_kept_on_the_stack_count_toward_the_budget() { - let masked = |_| { - json!({"transform": [[1, 0, 0], [0, 1, 0]], "width": 2000, "height": 2000, - "mask": {"data": png(1, 1, vec![0, 0, 0, 255]), "area": {"x": 0, "y": 0}}}) - }; - let scene = json!({ - "area": {"x": 0, "y": 0}, "width": 2000, "height": 2000, "page": [0, 0, 0], - "layers": (0..11).map(masked).collect::>(), - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} - }); - let error = compose(&scene).expect_err("too much memory"); - assert!(error.message.contains("MiB to draw"), "{}", error.message); - } - - /// A picture's size is read from its header before it is decoded: one that - /// would take too much to decode is refused, not decoded. - #[test] - fn a_picture_too_large_to_decode_is_refused_from_its_header() { - let scene = json!({ - "area": {"x": 0, "y": 0}, "width": 2000, "height": 2000, "page": [0, 0, 0], - "node": {"picture": {"data": png_header(8_192, 12_000), "area": {"x": 0, "y": 0}}} - }); - let error = compose(&scene).expect_err("too much memory"); - assert!(error.message.contains("MiB to draw"), "{}", error.message); - } - - /// The mask of a node drawn from its parts is sized from its header too. - #[test] - fn the_mask_of_a_node_drawn_from_its_parts_is_sized_up_front() { - let scene = json!({ - "area": {"x": 0, "y": 0}, "width": 2000, "height": 2000, "page": [0, 0, 0], - "node": {"own": {"transform": [[1, 0, 0], [0, 1, 0]], "width": 2000, "height": 2000, - "mask": {"data": png_header(8_192, 12_000), "area": {"x": 0, "y": 0}}}} - }); - let error = compose(&scene).expect_err("too much memory"); - assert!(error.message.contains("MiB to draw"), "{}", error.message); - } - - /// A GIF whose first frame is far larger than the 1x1 screen its header - /// names is refused by the decoder before that frame is allocated. - #[test] - fn a_gif_frame_larger_than_its_header_is_not_decoded() { - let mut gif = b"GIF89a".to_vec(); - gif.extend([1, 0, 1, 0, 0, 0, 0]); - gif.push(0x2C); - gif.extend([0, 0, 0, 0]); - gif.extend(10_000_u16.to_le_bytes()); - gif.extend(12_000_u16.to_le_bytes()); - gif.extend([0x80, 0, 0, 0, 255, 255, 255, 2, 0, 0x3B]); - let scene = json!({ - "area": {"x": 0, "y": 0}, "width": 1, "height": 1, "page": [0, 0, 0], - "node": {"picture": {"data": STANDARD.encode(gif), "area": {"x": 0, "y": 0}}} - }); - let error = compose(&scene).expect_err("the frame is refused"); - assert!( - error.message.contains("Memory limit exceeded"), - "{}", - error.message - ); - } - - /// A WebP is not decoded: its decoder takes no allocation limit and sizes - /// buffers from data no header bounds. This one hides a VP8 frame claiming - /// 16383x16383 past the RIFF size its 1x1 canvas declares. - #[test] - fn a_webp_is_not_decoded() { - let mut webp = b"RIFF".to_vec(); - webp.extend(22_u32.to_le_bytes()); - webp.extend(b"WEBPVP8X"); - webp.extend(10_u32.to_le_bytes()); - webp.extend([0; 10]); - webp.extend(b"VP8 "); - webp.extend(10_u32.to_le_bytes()); - webp.extend([0, 0, 0, 0x9d, 0x01, 0x2a, 0xff, 0x3f, 0xff, 0x3f]); - let scene = json!({ - "area": {"x": 0, "y": 0}, "width": 1, "height": 1, "page": [0, 0, 0], - "node": {"picture": {"data": STANDARD.encode(webp), "area": {"x": 0, "y": 0}}} - }); - let error = compose(&scene).expect_err("the WebP is refused"); - assert!( - error.message.contains("not a PNG, JPEG or GIF"), - "{}", - error.message - ); - } - - /// A JPEG is read from its frame header: its size, and its coefficients at - /// the sampling factors it names. - #[test] - fn a_jpeg_is_counted_at_its_sampling_factors() { - let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00]; - jpeg.extend([0xFF, 0xC0, 0x00, 0x14, 8, 0x0F, 0xA0, 0x0F, 0xA0, 4]); - for id in 1..=4 { - jpeg.extend([id, 0x44, 0]); - } - jpeg.extend([0xFF, 0xDA]); - assert_eq!(jpeg_frame(&jpeg), Some((4000, 4000, 2 * 16 * 4))); - } - - /// A PNG's text chunks are counted one by one: its decoder keeps an entry - /// for each that its allowance does not hold. - #[test] - fn a_pngs_text_chunks_are_counted() { - let mut bytes = STANDARD - .decode(png(1, 1, vec![0, 0, 0, 0])) - .expect("base64"); - let text = [0, 0, 0, 2, b't', b'E', b'X', b't', b'a', 0, 0, 0, 0, 0]; - let tail = bytes.split_off(33); - for _ in 0..1000 { - bytes.extend(text); - } - bytes.extend(tail); - let header = Header::read(&bytes).expect("a PNG header"); - assert_eq!(header.texts, 1000 * PNG_TEXT_CHUNK_BYTES); - assert!(header.peak(bytes.len()) > 1000 * PNG_TEXT_CHUNK_BYTES); - } - - /// A PNG wider than the plugin's exports, or whose chunk runs past its end, - /// is not decoded. - #[test] - fn a_png_too_wide_or_cut_short_is_refused() { - let error = Header::read(&STANDARD.decode(png_header(8_193, 1)).expect("base64")) - .err() - .expect("too wide"); - assert!( - error.message.contains("wider than 8192"), - "{}", - error.message - ); - let mut cut = STANDARD - .decode(png(1, 1, vec![0, 0, 0, 0])) - .expect("base64"); - cut.truncate(33); - cut.extend([0xFF, 0xFF, 0xFF, 0xFF, b'I', b'D', b'A', b'T']); - let error = Header::read(&cut).err().expect("cut short"); - assert!( - error.message.contains("runs past its end"), - "{}", - error.message - ); - } - - /// A second SOI, or a stuffed zero, before the frame header is refused: its - /// decoder reads those differently and could skip the frame header counted. - #[test] - fn a_marker_standing_alone_before_the_jpeg_frame_is_refused() { - let jpeg = [ - 0xFF, 0xD8, 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x0B, 8, 0, 1, 0, 1, 1, 1, 0x11, 0, 0xFF, - 0xDA, - ]; - assert_eq!(jpeg_frame(&jpeg), None); - assert_eq!(jpeg_frame(&[0xFF, 0xD8, 0xFF, 0x00, 0x00, 0x13]), None); - assert_eq!( - jpeg_frame(&[&jpeg[..2], &jpeg[4..]].concat()), - Some((1, 1, 2)) - ); - } - - /// A JPEG fill composes, counted with eight copies of its bytes - here - /// mostly a large APP0 segment its decoder copies with the rest. A WebP - /// fill is refused, so the canvas picture is used. - #[test] - fn a_jpeg_fill_composes_and_a_webp_fill_is_refused() { - let mut encoded = Vec::new(); - image::codecs::jpeg::JpegEncoder::new(&mut encoded) - .encode( - &[255, 0, 0].repeat(16), - 4, - 4, - image::ExtendedColorType::Rgb8, - ) - .expect("a JPEG encodes"); - let mut jpeg = encoded[..2].to_vec(); - for _ in 0..16 { - jpeg.extend([0xFF, 0xE0, 0xFF, 0xFF]); - jpeg.extend([0; 0xFFFD]); - } - jpeg.extend(&encoded[2..]); - let header = Header::read(&jpeg).expect("a JPEG header"); - assert!(header.peak(jpeg.len()) >= 8 * jpeg.len() as u64); - let mut webp = Vec::new(); - image::codecs::webp::WebPEncoder::new_lossless(&mut webp) - .encode( - &[0, 0, 255, 255].repeat(16), - 4, - 4, - image::ExtendedColorType::Rgba8, - ) - .expect("a WebP encodes"); - let scene = |bytes: &[u8]| { - json!({ - "area": {"x": 0, "y": 0}, "width": 4, "height": 4, "page": [0, 0, 0], - "layers": [{"transform": [[1, 0, 0], [0, 1, 0]], "width": 4, "height": 4, - "fills": [{"type": "IMAGE", "imageHash": "fill", "scaleMode": "FILL"}]}], - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}}, - "images": {"fill": STANDARD.encode(bytes)} - }) - }; - let rgba = decode(&compose(&scene(&jpeg)).expect("composes")); - assert!(rgba[0] > 200, "{rgba:?}"); - let error = compose(&scene(&webp)).expect_err("the WebP is refused"); - assert!( - error.message.contains("not a PNG, JPEG or GIF"), - "{}", - error.message - ); - } - - /// An image the plugin sent decoded and smaller is laid out at the original's - /// size: a 4-wide tile of red and blue sent 2 wide still repeats every 4. - #[test] - fn a_decoded_image_is_laid_out_at_the_originals_size() { - let tile = |images: Value| { - json!({ - "area": {"x": 0, "y": 0}, "width": 8, "height": 2, "page": [0, 0, 0], - "layers": [{"transform": [[1, 0, 0], [0, 1, 0]], "width": 8, "height": 2, - "fills": [{"type": "IMAGE", "imageHash": "t", "scaleMode": "TILE", "scalingFactor": 1}]}], - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}}, - "images": {"t": images} - }) - }; - let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 255]); - let original = png(4, 2, [red, red, blue, blue].concat().repeat(2)); - let smaller = png(2, 1, [red, blue].concat()); - let full = decode(&compose(&tile(json!(original))).expect("composes")); - let laid_out = decode( - &compose(&tile(json!({"data": smaller, "width": 4, "height": 2}))).expect("composes"), - ); - let columns = |rgba: &[u8]| { - rgba[..32] - .chunks(4) - .map(|px| px[0] > 128) - .collect::>() - }; - assert_eq!( - columns(&full), - [true, true, false, false, true, true, false, false] - ); - assert_eq!(columns(&laid_out), columns(&full)); - } - - /// An ancestor's stroke goes over everything it holds: one pixel outside a - /// white 2x2 square is black all round, mitred at the corners, and the - /// node inside does not cover it. - #[test] - fn an_ancestors_stroke_is_drawn_round_it() { - let scene = |align: &str, weight: f64, at: f64, size: f64| { - json!({ - "area": {"x": 0, "y": 0}, "width": 4, "height": 4, "page": [128, 128, 128], - "layers": [{"transform": [[1, 0, at], [0, 1, at]], "width": size, "height": size, - "fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}], - "strokes": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0}}], - "strokeAlign": align, "strokeWeights": [weight, weight, weight, weight]}], - "node": {"picture": {"data": png(4, 4, [0, 255, 0, 255].repeat(16)), "area": {"x": 0, "y": 0}}} - }) - }; - let pixel = - |rgba: &[u8], x: usize, y: usize| rgba[(y * 4 + x) * 4..(y * 4 + x) * 4 + 3].to_vec(); - let outside = decode(&compose(&scene("OUTSIDE", 1.0, 1.0, 2.0)).expect("composes")); - for (x, y) in [(0, 0), (1, 0), (3, 0), (0, 2), (3, 3)] { - assert_eq!(pixel(&outside, x, y), [0, 0, 0], "({x}, {y})"); - } - assert_eq!(pixel(&outside, 1, 1), [0, 255, 0]); - let inside = decode(&compose(&scene("INSIDE", 1.0, 0.0, 4.0)).expect("composes")); - assert_eq!(pixel(&inside, 0, 0), [0, 0, 0]); - assert_eq!(pixel(&inside, 3, 2), [0, 0, 0]); - assert_eq!(pixel(&inside, 2, 2), [0, 255, 0]); - } - - /// A shadow's spread grows or shrinks it before it is blurred: a black - /// square two pixels wide, its shadow moved four to the right and not - /// blurred, casts four red pixels; with a spread of one, sixteen; with a - /// spread of minus one, none. - #[test] - fn a_shadows_spread_grows_or_shrinks_it() { - let scene = |spread: f64| { - json!({ - "area": {"x": 0, "y": 0}, "width": 10, "height": 6, "page": [255, 255, 255], - "layers": [{"transform": [[1, 0, 2], [0, 1, 2]], "width": 2, "height": 2, - "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0}}], - "effects": [{"type": "DROP_SHADOW", "radius": 0, "spread": spread, - "offset": {"x": 4, "y": 0}, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}]}], - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} - }) - }; - let red = |rgba: &[u8]| { - rgba.chunks(4) - .filter(|px| px[0] > 200 && px[1] < 60) - .count() - }; - assert_eq!(red(&decode(&compose(&scene(0.0)).expect("composes"))), 4); - assert_eq!(red(&decode(&compose(&scene(1.0)).expect("composes"))), 16); - assert_eq!(red(&decode(&compose(&scene(-1.0)).expect("composes"))), 0); - } - - /// A spread is drawn as asked or not at all. The work it takes is counted - /// before drawing: unblurred, a spread of 500 over a thousand-pixel square - /// would take eight billion comparisons. A shadow cast from farther than - /// the margin drawn around the area - here a square 550 pixels to the left - /// with a spread of 600, whose shadow reaches the area while one spread - /// 512 would not - is not composed rather than drawn with less spread. - #[test] - fn a_large_spread_is_counted_and_never_drawn_smaller() { - let scene = |width: u32, layer: Value| { - json!({ - "area": {"x": 0, "y": 0}, "width": width, "height": width, "page": [255, 255, 255], - "layers": [layer], - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} - }) - }; - let shadowed = |x: f64, size: f64, spread: f64| { - json!({"transform": [[1, 0, x], [0, 1, 0]], "width": size, "height": size, - "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0}}], - "effects": [{"type": "DROP_SHADOW", "radius": 0, "spread": spread, - "offset": {"x": 0, "y": 0}, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}]}) - }; - let error = compose(&scene(1000, shadowed(0.0, 1000.0, 500.0))).expect_err("too much work"); - assert!(error.message.contains("kernel taps"), "{}", error.message); - let error = - compose(&scene(4, shadowed(-552.0, 2.0, 600.0))).expect_err("cast from too far"); - assert!(error.message.contains("pixels away"), "{}", error.message); - } - - /// A shadow's reach is counted as `drop_shadow` draws it, its offset and - /// spread each rounded: offset 255.5 and spread 256.5 come to 512 together - /// but are drawn 256 and 257 pixels away, one past the 512 drawn around - /// the area, so they are not composed. Offset 0.5 and spread 0.5 are drawn - /// as offset 1 and spread 1 are, and the margin is drawn wide enough for - /// them. - #[test] - fn a_shadows_reach_is_counted_as_it_is_drawn() { - let scene = |offset: f64, spread: f64| { - json!({ - "area": {"x": 0, "y": 0}, "width": 4, "height": 4, "page": [255, 255, 255], - "layers": [{"transform": [[1, 0, -2], [0, 1, 1]], "width": 2, "height": 2, - "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0}}], - "effects": [{"type": "DROP_SHADOW", "radius": 0, "spread": spread, - "offset": {"x": offset, "y": 0}, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}]}], - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} - }) - }; - let error = compose(&scene(255.5, 256.5)).expect_err("cast from past the margin"); - assert!( - error.message.contains("513 pixels away"), - "{}", - error.message - ); - assert_eq!( - compose(&scene(0.5, 0.5)).expect("composes"), - compose(&scene(1.0, 1.0)).expect("composes") - ); - } - - /// An inner shadow falls inside the layer from the side its offset points - /// away from: moved two down and not blurred, it darkens the top two rows of - /// a white square, and nothing below them or outside the square. - #[test] - fn an_inner_shadow_falls_inside_the_layer() { - let scene = json!({ - "area": {"x": 0, "y": 0}, "width": 6, "height": 6, "page": [128, 128, 128], - "layers": [{"transform": [[1, 0, 1], [0, 1, 1]], "width": 4, "height": 4, - "fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}], - "effects": [{"type": "INNER_SHADOW", "radius": 0, "spread": 0, - "offset": {"x": 0, "y": 2}, "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}], - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} - }); - let rgba = decode(&compose(&scene).expect("composes")); - let pixel = |x: usize, y: usize| rgba[(y * 6 + x) * 4]; - assert_eq!( - [pixel(2, 1), pixel(2, 2), pixel(2, 3), pixel(2, 4)], - [0, 0, 255, 255] - ); - assert_eq!(pixel(0, 1), 128); - } - - /// A shadow not shown behind its layer is cut away under a painted layer's - /// whole shape, but under an unpainted one (a group) only where it holds - /// something: the shadow of a translucent square stays out of its box, - /// while a group's shadow shows inside its box beside what it holds. - #[test] - fn a_shadow_is_cut_away_under_its_layer_only() { - let scene = |fills: Value| { - json!({ - "area": {"x": 0, "y": 0}, "width": 6, "height": 4, "page": [255, 255, 255], - "layers": [{"transform": [[1, 0, 1], [0, 1, 1]], "width": 4, "height": 2, "fills": fills, - "effects": [{"type": "DROP_SHADOW", "radius": 0, "spread": 0, "showShadowBehindNode": false, - "offset": {"x": 1, "y": 0}, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}]}], - "node": {"picture": {"data": png(1, 2, vec![0, 0, 255, 255, 0, 0, 255, 255]), "area": {"x": 1, "y": 1}}} - }) - }; - let pixel = - |rgba: &[u8], x: usize, y: usize| rgba[(y * 6 + x) * 4..(y * 6 + x) * 4 + 3].to_vec(); - let painted = decode( - &compose(&scene( - json!([{"type": "SOLID", "opacity": 0.5, "color": {"r": 0, "g": 1, "b": 0}}]), - )) - .expect("composes"), - ); - // Cast by a half-transparent fill, the shadow is half as strong. - assert_eq!( - pixel(&painted, 5, 1), - [255, 128, 128], - "the shadow past the square shows" - ); - assert!( - pixel(&painted, 3, 1)[0] < 200, - "no shadow inside the painted square" - ); - let group = decode(&compose(&scene(json!([]))).expect("composes")); - assert_eq!( - pixel(&group, 3, 1), - [255, 255, 255], - "nothing is cast where the group holds nothing" - ); - } - - /// What would take more memory or blur work than a reference PNG is worth - /// is refused before it is drawn, so the canvas picture is used. - #[test] - fn an_oversized_composition_is_refused_up_front() { - let translucent = |_| json!({"transform": [[1, 0, 0], [0, 1, 0]], "width": 2000, "height": 2000, "opacity": 0.5}); - let deep = json!({ - "area": {"x": 0, "y": 0}, "width": 2000, "height": 2000, "page": [0, 0, 0], - "layers": (0..60).map(translucent).collect::>(), - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} - }); - let error = compose(&deep).expect_err("too much memory"); - assert!(error.message.contains("MiB to draw"), "{}", error.message); - - let blurred = json!({ - "area": {"x": 0, "y": 0}, "width": 2000, "height": 2000, "page": [0, 0, 0], - "layers": [{"transform": [[1, 0, 0], [0, 1, 0]], "width": 2000, "height": 2000, - "effects": [{"type": "LAYER_BLUR", "radius": 1e12}]}], - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} - }); - let error = compose(&blurred).expect_err("too much blur work"); - assert!(error.message.contains("kernel taps"), "{}", error.message); - - let unscaled = json!({ - "area": {"x": 0, "y": 0}, "scale": 0, "width": 1, "height": 1, "page": [0, 0, 0], - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} - }); - assert!(compose(&unscaled).is_err()); - } - - #[test] - fn an_unknown_paint_is_refused_not_guessed() { - let scene = json!({ - "area": {"x": 0, "y": 0, "width": 1, "height": 1}, "width": 1, "height": 1, "page": [0, 0, 0], - "layers": [{"transform": [[1, 0, 0], [0, 1, 0]], "width": 1, "height": 1, "fills": [{"type": "VIDEO"}]}], - "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0, "width": 1, "height": 1}}} - }); - assert!(compose(&scene).is_err()); - } -} +// Source sections share this module to preserve privacy and test names. +include!("reference_compose/types_and_helpers.rs"); +include!("reference_compose/impl.rs"); +include!("reference_compose/tests.rs"); diff --git a/crates/devup-mcp-figma/src/reference_compose/impl.rs b/crates/devup-mcp-figma/src/reference_compose/impl.rs new file mode 100644 index 00000000..477d88bf --- /dev/null +++ b/crates/devup-mcp-figma/src/reference_compose/impl.rs @@ -0,0 +1,540 @@ + + +impl Header { + /// Its format, size and allowance: + /// - PNG and GIF: 8 bytes a pixel (a 16-bit PNG decodes to 8), which + /// `Decoded::from_bytes` holds their decoders to; a GIF frame larger than + /// its screen decodes only within it. A PNG's text chunks are counted + /// one by one. + /// - JPEG: two bytes for each sample of each component's coefficients at + /// the sampling factors its frame header names, over rows and columns + /// padded to whole MCUs, and a 4-byte output. Its decoder takes no + /// allocation limit; this is what it is counted at. + /// - Anything else, WebP among them, is refused: its decoder takes no limit + /// and sizes buffers from data no header bounds, its entropy tables and + /// partitions among them. + fn read(encoded: &[u8]) -> Result { + let field = + |at: usize, length: usize| at.checked_add(length).and_then(|end| encoded.get(at..end)); + let (format, width, height, allowance, texts) = if encoded.starts_with(b"\x89PNG\r\n\x1a\n") + { + let size = + |at| field(at, 4).map(|b| u64::from(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))); + let (Some(b"IHDR"), Some(width), Some(height)) = (field(12, 4), size(16), size(20)) + else { + return Err(invalid("a PNG's header cannot be read")); + }; + if width > MAX_PNG_WIDTH { + return Err(invalid(format!( + "a PNG wider than {MAX_PNG_WIDTH} pixels is not composed" + ))); + } + let mut texts = 0_u64; + let mut at = 8_usize; + while at < encoded.len() { + let (Some(length), Some(kind)) = (size(at), field(at + 4, 4)) else { + return Err(invalid("a PNG's chunks cannot be read")); + }; + let Some(end) = usize::try_from(length) + .ok() + .and_then(|length| at.checked_add(12)?.checked_add(length)) + .filter(|&end| end <= encoded.len()) + else { + return Err(invalid("a PNG's chunk runs past its end")); + }; + if matches!(kind, b"tEXt" | b"zTXt" | b"iTXt") { + texts += 1; + } + if kind == b"IEND" { + break; + } + at = end; + } + ( + image::ImageFormat::Png, + width, + height, + width.saturating_mul(height).saturating_mul(8), + texts, + ) + } else if encoded.starts_with(b"GIF87a") || encoded.starts_with(b"GIF89a") { + let size = |at| field(at, 2).map(|b| u64::from(u16::from_le_bytes([b[0], b[1]]))); + let (Some(width), Some(height)) = (size(6), size(8)) else { + return Err(invalid("a GIF's header cannot be read")); + }; + ( + image::ImageFormat::Gif, + width, + height, + width * height * 8, + 0, + ) + } else if encoded.starts_with(&[0xFF, 0xD8]) { + let (width, height, coefficients) = jpeg_frame(encoded) + .ok_or_else(|| invalid("a JPEG's frame header cannot be read"))?; + let padded = (width + 32).saturating_mul(height + 32); + ( + image::ImageFormat::Jpeg, + width, + height, + padded.saturating_mul(coefficients + 4), + 0, + ) + } else { + return Err(invalid("an image is not a PNG, JPEG or GIF")); + }; + if width == 0 || height == 0 { + return Err(invalid("an image has no size")); + } + Ok(Self { + format, + pixels: width.saturating_mul(height), + allowance: allowance.saturating_add(DECODE_SLACK), + texts: texts.saturating_mul(PNG_TEXT_CHUNK_BYTES), + }) + } + + /// The most decoding it takes: its allowance three times (the output it is + /// held to, what a PNG decoder keeps within its own copy of the allowance, + /// and the rows it unfilters outside it), the RGBA8 it becomes, eight + /// copies of its bytes (the source, a JPEG decoder's own copy, the metadata + /// it gathers and reassembles, and the lists holding it), and its text + /// chunks. + fn peak(&self, encoded: usize) -> u64 { + self.allowance + .saturating_mul(3) + .saturating_add(self.pixels.saturating_mul(4)) + .saturating_add((encoded as u64).saturating_mul(8)) + .saturating_add(self.texts) + } +} + +/// A JPEG's size and two bytes a pixel for each sample of each component, from +/// its frame header: the first, before its first scan (its decoder refuses a +/// second). A marker standing alone before it (another SOI, a restart, a +/// stuffed zero) or a segment too short for its length is refused: its decoder +/// reads those differently, and could skip the frame header counted here. +fn jpeg_frame(encoded: &[u8]) -> Option<(u64, u64, u64)> { + let mut at = 2; + loop { + if *encoded.get(at)? != 0xFF { + return None; + } + let marker = *encoded.get(at + 1)?; + let word = |offset: usize| { + Some(u16::from_be_bytes([ + *encoded.get(at + offset)?, + *encoded.get(at + offset + 1)?, + ])) + }; + match marker { + 0xFF => at += 1, + 0x00 | 0x01 | 0xD0..=0xDA => return None, + 0xC0..=0xCF if !matches!(marker, 0xC4 | 0xC8 | 0xCC) => { + let (height, width) = (u64::from(word(5)?), u64::from(word(7)?)); + let components = usize::from(*encoded.get(at + 9)?); + if usize::from(word(2)?) != 8 + 3 * components { + return None; + } + let samples = (0..components) + .map(|index| { + let factors = *encoded.get(at + 11 + 3 * index)?; + Some(2 * u64::from(factors >> 4) * u64::from(factors & 15)) + }) + .sum::>()?; + return Some((width, height, samples)); + } + _ => { + let length = usize::from(word(2)?); + if length < 2 { + return None; + } + at += 2 + length; + } + } + } +} + +impl Images { + fn new(sources: &HashMap) -> Result { + let (mut bytes, mut layouts) = (HashMap::new(), HashMap::new()); + let (mut kept, mut peak) = (0_u64, 0_u64); + for (hash, source) in sources { + let (data, layout) = match source { + ImageSource::Bytes(data) => (data, None), + ImageSource::Decoded { + data, + width, + height, + } if *width > 0.0 && *height > 0.0 => (data, Some((*width, *height))), + ImageSource::Decoded { .. } => { + return Err(invalid(format!("the image {hash} has no size"))); + } + }; + let encoded = base64_bytes(data)?; + let header = Header::read(&encoded)?; + kept = kept + .saturating_add(header.pixels.saturating_mul(4)) + .saturating_add(encoded.len() as u64); + peak = peak.max(header.peak(encoded.len())); + bytes.insert(hash.clone(), encoded); + if let Some(layout) = layout { + layouts.insert(hash.clone(), layout); + } + } + Ok(Self { + sources: bytes, + layouts, + kept, + peak, + decoded: HashMap::new(), + }) + } + + fn get(&mut self, hash: &str) -> Result<&Decoded, DevupError> { + if !self.decoded.contains_key(hash) { + let data = self + .sources + .get(hash) + .ok_or_else(|| invalid(format!("the image {hash} was not sent")))?; + let mut decoded = Decoded::from_bytes(data)?; + if let Some(layout) = self.layouts.get(hash) { + decoded.layout = *layout; + } + self.decoded.insert(hash.to_owned(), decoded); + } + Ok(&self.decoded[hash]) + } +} + +/// A plugin export laid on the grid; outside it, transparent. A mask reads as +/// its coverage in the alpha channel. +fn place(picture: &Picture, grid: &Grid, as_mask: bool) -> Result, DevupError> { + let image = Decoded::from_bytes(&base64_bytes(&picture.data)?)?; + let origin = Grid::origin_of(picture.area, grid.scale); + let offset_x = ((origin.0 - grid.origin.0) * grid.scale).round() as isize; + let offset_y = ((origin.1 - grid.origin.1) * grid.scale).round() as isize; + let mut out = vec![[0.0; 4]; grid.width * grid.height]; + for (index, px) in out.iter_mut().enumerate() { + let column = (index % grid.width) as isize - offset_x; + let row = (index / grid.width) as isize - offset_y; + if column < 0 || row < 0 || column as usize >= image.width || row as usize >= image.height { + continue; + } + let source = image.at(column as usize, row as usize); + *px = if as_mask && picture.luminance { + let luma = 0.2126 * source[0] + 0.7152 * source[1] + 0.0722 * source[2]; + [0.0, 0.0, 0.0, luma * source[3]] + } else { + source + }; + } + Ok(out) +} + +fn mix(a: Px, b: Px, weight: f32) -> Px { + let mut out = [0.0; 4]; + for channel in 0..4 { + out[channel] = a[channel] + (b[channel] - a[channel]) * weight; + } + out +} + +/// A drop shadow the layer casts: its alpha, moved, spread, blurred and +/// coloured; unless shown behind it, cut away by `shape`. +fn drop_shadow(pixels: &[Px], grid: &Grid, effect: &Value, shape: &[f32]) -> Vec { + let colour = colour(&effect["color"], 1.0); + let shift = |value: &Value| (number(value) * grid.scale).round().clamp(-1e9, 1e9) as isize; + let (dx, dy) = (shift(&effect["offset"]["x"]), shift(&effect["offset"]["y"])); + let (width, height) = (grid.width, grid.height); + let mut shadow = vec![[0.0; 4]; width * height]; + for (index, px) in shadow.iter_mut().enumerate() { + let column = (index % width) as isize - dx; + let row = (index / width) as isize - dy; + if column >= 0 && row >= 0 && (column as usize) < width && (row as usize) < height { + let alpha = pixels[row as usize * width + column as usize][3]; + *px = [colour[0], colour[1], colour[2], alpha * colour[3]]; + } + } + let spread = (number(&effect["spread"]) * grid.scale).round() as isize; + if spread != 0 { + spread_alpha(&mut shadow, width, height, spread); + } + let mut shadow = gaussian( + &shadow, + width, + height, + number(&effect["radius"]) * grid.scale / 2.0, + ); + if effect["showShadowBehindNode"] == false { + for (px, &cover) in shadow.iter_mut().zip(shape) { + px[3] *= 1.0 - cover; + } + } + shadow +} + +/// An inner shadow over a layer's paints and under what it holds: what lies +/// outside the layer, moved by the offset, grown inward by the spread, blurred +/// and coloured, inside the layer. +fn inner_shadow( + pixels: &mut [Px], + clip: &[f32], + shape: &[f32], + geometry: &Geometry, + grid: &Grid, + effect: &Value, +) -> Result<(), DevupError> { + let colour = colour(&effect["color"], 1.0); + let blend = Blend::parse(effect["blendMode"].as_str())?; + let (dx, dy) = ( + number(&effect["offset"]["x"]), + number(&effect["offset"]["y"]), + ); + let spread = number(&effect["spread"]); + let outside: Vec = (0..pixels.len()) + .map(|index| { + let (x, y) = grid.point(index); + let inside = geometry.grown((x - dx, y - dy), grid.scale, [-spread; 4]); + [0.0, 0.0, 0.0, 1.0 - inside] + }) + .collect(); + let blurred = gaussian( + &outside, + grid.width, + grid.height, + number(&effect["radius"]) * grid.scale / 2.0, + ); + for (index, pixel) in pixels.iter_mut().enumerate() { + let coverage = blurred[index][3] * shape[index] * clip[index]; + composite(pixel, colour, blend, coverage); + } + Ok(()) +} + +/// Grows a shadow's alpha by `by` pixels (shrinks it when negative), as a +/// shadow's spread does. Past the grid's edge the shadow is taken to go on. +fn spread_alpha(shadow: &mut [Px], width: usize, height: usize, by: isize) { + let reach = by.unsigned_abs(); + let pick = |a: f32, b: f32| if by > 0 { a.max(b) } else { a.min(b) }; + for horizontal in [true, false] { + let (lines, length) = if horizontal { + (height, width) + } else { + (width, height) + }; + let at = |line: usize, step: usize| { + if horizontal { + line * width + step + } else { + step * width + line + } + }; + let mut alphas = vec![0.0_f32; length]; + for line in 0..lines { + for (step, alpha) in alphas.iter_mut().enumerate() { + *alpha = shadow[at(line, step)][3]; + } + for step in 0..length { + let (from, to) = (step.saturating_sub(reach), (step + reach).min(length - 1)); + shadow[at(line, step)][3] = + alphas[from..=to].iter().copied().fold(alphas[step], pick); + } + } + } +} + +/// Separable Gaussian on premultiplied colour; nothing outside the grid. +fn gaussian(pixels: &[Px], width: usize, height: usize, sigma: f64) -> Vec { + if sigma < 0.05 { + return pixels.to_vec(); + } + let reach = (sigma * 3.0).ceil() as isize; + let weights: Vec = (-reach..=reach) + .map(|offset| (-((offset * offset) as f64) / (2.0 * sigma * sigma)).exp() as f32) + .collect(); + let total: f32 = weights.iter().sum(); + let weights: Vec = weights.iter().map(|weight| weight / total).collect(); + let premultiplied: Vec = pixels + .iter() + .map(|p| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]) + .collect(); + let pass = |source: &[Px], horizontal: bool| -> Vec { + let mut out = vec![[0.0; 4]; width * height]; + for (index, px) in out.iter_mut().enumerate() { + let (column, row) = ((index % width) as isize, (index / width) as isize); + for (k, weight) in weights.iter().enumerate() { + let offset = k as isize - reach; + let (c, r) = if horizontal { + (column + offset, row) + } else { + (column, row + offset) + }; + if c < 0 || r < 0 || c as usize >= width || r as usize >= height { + continue; + } + let sample = source[r as usize * width + c as usize]; + for channel in 0..4 { + px[channel] += sample[channel] * weight; + } + } + } + out + }; + pass(&pass(&premultiplied, true), false) + .into_iter() + .map(|p| { + if p[3] > 1e-6 { + [p[0] / p[3], p[1] / p[3], p[2] / p[3], p[3]] + } else { + [0.0; 4] + } + }) + .collect() +} + +#[derive(Clone, Copy, PartialEq)] +enum Blend { + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + LinearDodge, + LinearBurn, + Hue, + Saturation, + Color, + Luminosity, +} + +impl Blend { + fn parse(mode: Option<&str>) -> Result { + Ok(match mode.unwrap_or("NORMAL") { + "NORMAL" | "PASS_THROUGH" => Self::Normal, + "MULTIPLY" => Self::Multiply, + "SCREEN" => Self::Screen, + "OVERLAY" => Self::Overlay, + "DARKEN" => Self::Darken, + "LIGHTEN" => Self::Lighten, + "COLOR_DODGE" => Self::ColorDodge, + "COLOR_BURN" => Self::ColorBurn, + "HARD_LIGHT" => Self::HardLight, + "SOFT_LIGHT" => Self::SoftLight, + "DIFFERENCE" => Self::Difference, + "EXCLUSION" => Self::Exclusion, + "LINEAR_DODGE" => Self::LinearDodge, + "LINEAR_BURN" => Self::LinearBurn, + "HUE" => Self::Hue, + "SATURATION" => Self::Saturation, + "COLOR" => Self::Color, + "LUMINOSITY" => Self::Luminosity, + other => return Err(invalid(format!("the blend mode {other}"))), + }) + } + + fn channel(self, b: f32, s: f32) -> f32 { + match self { + Self::Multiply => b * s, + Self::Screen => b + s - b * s, + Self::Overlay => Self::HardLight.channel(s, b), + Self::Darken => b.min(s), + Self::Lighten => b.max(s), + Self::ColorDodge if b <= 0.0 => 0.0, + Self::ColorDodge if s >= 1.0 => 1.0, + Self::ColorDodge => (b / (1.0 - s)).min(1.0), + Self::ColorBurn if b >= 1.0 => 1.0, + Self::ColorBurn if s <= 0.0 => 0.0, + Self::ColorBurn => 1.0 - ((1.0 - b) / s).min(1.0), + Self::HardLight if s <= 0.5 => b * 2.0 * s, + Self::HardLight => Self::Screen.channel(b, 2.0 * s - 1.0), + Self::SoftLight => { + let d = if b <= 0.25 { + ((16.0 * b - 12.0) * b + 4.0) * b + } else { + b.sqrt() + }; + if s <= 0.5 { + b - (1.0 - 2.0 * s) * b * (1.0 - b) + } else { + b + (2.0 * s - 1.0) * (d - b) + } + } + Self::Difference => (b - s).abs(), + Self::Exclusion => b + s - 2.0 * b * s, + Self::LinearDodge => (b + s).min(1.0), + Self::LinearBurn => (b + s - 1.0).max(0.0), + _ => s, + } + } + + /// W3C compositing: separable modes per channel, the four others on the + /// whole colour. + fn apply(self, b: [f32; 3], s: [f32; 3]) -> [f32; 3] { + match self { + Self::Normal => s, + Self::Hue => set_lum(set_sat(s, sat(b)), lum(b)), + Self::Saturation => set_lum(set_sat(b, sat(s)), lum(b)), + Self::Color => set_lum(s, lum(b)), + Self::Luminosity => set_lum(b, lum(s)), + _ => [0, 1, 2].map(|channel| self.channel(b[channel], s[channel])), + } + } +} + +fn lum(c: [f32; 3]) -> f32 { + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] +} + +fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] { + let d = l - lum(c); + let c = c.map(|channel| channel + d); + let l = lum(c); + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + c.map(|channel| { + let mut channel = channel; + if n < 0.0 { + channel = l + (channel - l) * l / (l - n).max(1e-9); + } + if x > 1.0 { + channel = l + (channel - l) * (1.0 - l) / (x - l).max(1e-9); + } + channel + }) +} + +fn sat(c: [f32; 3]) -> f32 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) +} + +fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] { + let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2])); + if x <= n { + return [0.0; 3]; + } + c.map(|channel| (channel - n) / (x - n) * s) +} + +/// Source over destination with a blend mode, straight alpha in and out. +fn composite(dst: &mut Px, src: Px, blend: Blend, coverage: f32) { + let sa = src[3] * coverage; + if sa <= 0.0 { + return; + } + let da = dst[3]; + let (cs, cb) = ([src[0], src[1], src[2]], [dst[0], dst[1], dst[2]]); + let blended = blend.apply(cb, cs); + let out_a = sa + da * (1.0 - sa); + for channel in 0..3 { + let mixed = (1.0 - da) * cs[channel] + da * blended[channel]; + dst[channel] = (sa * mixed + (1.0 - sa) * da * cb[channel]) / out_a.max(1e-9); + } + dst[3] = out_a; +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/reference_compose/tests.rs b/crates/devup-mcp-figma/src/reference_compose/tests.rs new file mode 100644 index 00000000..b01c032d --- /dev/null +++ b/crates/devup-mcp-figma/src/reference_compose/tests.rs @@ -0,0 +1,626 @@ + + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn png(width: u32, height: u32, rgba: Vec) -> String { + let image = image::RgbaImage::from_raw(width, height, rgba).expect("pixels fill the image"); + let mut encoded = Vec::new(); + image + .write_with_encoder(image::codecs::png::PngEncoder::new(&mut encoded)) + .expect("a PNG encodes"); + STANDARD.encode(encoded) + } + + fn decode(data: &str) -> Vec { + image::load_from_memory(&STANDARD.decode(data).expect("base64")) + .expect("a PNG") + .to_rgba8() + .into_raw() + } + + fn frame(fills: Value) -> Value { + json!({"transform": [[1, 0, 0], [0, 1, 0]], "width": 4, "height": 1, "fills": fills, "clips": true}) + } + + /// The node over a horizontal gradient its parent is painted with: the + /// gradient runs left to right across the parent, sampled at pixel centres. + #[test] + fn a_node_alone_lands_on_its_parents_gradient() { + let scene = json!({ + "area": {"x": 0, "y": 0, "width": 4, "height": 1}, "width": 4, "height": 1, "page": [30, 30, 30], + "layers": [frame(json!([{"type": "GRADIENT_LINEAR", "gradientTransform": [[1, 0, 0], [0, 1, 0]], + "gradientStops": [{"position": 0, "color": {"r": 0, "g": 0, "b": 0, "a": 1}}, + {"position": 1, "color": {"r": 1, "g": 1, "b": 1, "a": 1}}]}]))], + "node": {"picture": {"data": png(4, 1, vec![255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + "area": {"x": 0, "y": 0, "width": 4, "height": 1}}} + }); + let rgba = decode(&compose(&scene).expect("composes")); + assert_eq!(&rgba[0..4], &[255, 0, 0, 255]); + assert_eq!(&rgba[4..8], &[96, 96, 96, 255]); + assert_eq!(&rgba[12..16], &[223, 223, 223, 255]); + } + + /// A translucent ancestor fades the node with it, and a MULTIPLY node + /// multiplies what lies under it. + #[test] + fn group_opacity_and_blend_modes_apply_to_the_node() { + let node = png(4, 1, [128, 128, 128, 255].repeat(4)); + let scene = |opacity: f64, blend: &str| { + json!({ + "area": {"x": 0, "y": 0, "width": 4, "height": 1}, "width": 4, "height": 1, "page": [255, 255, 255], + "layers": [{"transform": [[1, 0, 0], [0, 1, 0]], "width": 4, "height": 1, "opacity": opacity, + "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 1}}]}], + "node": {"picture": {"data": node, "area": {"x": 0, "y": 0, "width": 4, "height": 1}, "blendMode": blend}} + }) + }; + let faded = decode(&compose(&scene(0.25, "NORMAL")).expect("composes")); + assert_eq!(&faded[0..4], &[223, 223, 223, 255]); + let multiplied = decode(&compose(&scene(1.0, "MULTIPLY")).expect("composes")); + assert_eq!(&multiplied[0..4], &[0, 0, 128, 255]); + } + + /// A mask clips the node to its own alpha; outside the mask nothing of the + /// node shows. + #[test] + fn a_mask_clips_the_node() { + let scene = json!({ + "area": {"x": 0, "y": 0, "width": 2, "height": 1}, "width": 2, "height": 1, "page": [0, 0, 0], + "node": {"picture": {"data": png(2, 1, [255, 255, 255, 255].repeat(2)), + "area": {"x": 0, "y": 0, "width": 2, "height": 1}}, + "mask": {"data": png(1, 1, vec![0, 0, 0, 255]), "area": {"x": 1, "y": 0, "width": 1, "height": 1}}} + }); + let rgba = decode(&compose(&scene).expect("composes")); + assert_eq!(rgba, vec![0, 0, 0, 255, 255, 255, 255, 255]); + } + + /// A background blur over a backdrop that goes on past the area keeps the + /// rim the same as the middle: the blur reads that backdrop, not nothing. + #[test] + fn a_background_blur_reads_the_backdrop_past_the_area() { + let scene = json!({ + "area": {"x": 0, "y": 0}, "width": 2, "height": 2, "page": [0, 0, 0], + "layers": [{"transform": [[1, 0, -20], [0, 1, -20]], "width": 42, "height": 42, + "fills": [{"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0}}]}], + "node": {"own": {"transform": [[1, 0, 0], [0, 1, 0]], "width": 2, "height": 2, + "fills": [{"type": "SOLID", "opacity": 0.2, "color": {"r": 1, "g": 1, "b": 1}}], + "effects": [{"type": "BACKGROUND_BLUR", "radius": 4}]}} + }); + let rgba = decode(&compose(&scene).expect("composes")); + assert_eq!(rgba, [255, 51, 51, 255].repeat(4)); + } + + /// A clipping ancestor cuts a translucent layer once, where the layer is + /// laid back: half covered at the clip's edge and at half opacity is a + /// quarter, not an eighth. + #[test] + fn a_clip_cuts_an_isolated_layer_once() { + let scene = json!({ + "area": {"x": 0, "y": 0}, "width": 2, "height": 1, "page": [0, 0, 0], + "layers": [ + {"transform": [[1, 0, 0], [0, 1, 0]], "width": 1.5, "height": 1, "clips": true}, + {"transform": [[1, 0, 0], [0, 1, 0]], "width": 2, "height": 1, "opacity": 0.5, + "fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}]} + ], + "node": {"picture": {"data": png(2, 1, vec![0; 8]), "area": {"x": 0, "y": 0}}} + }); + let rgba = decode(&compose(&scene).expect("composes")); + assert_eq!(rgba, vec![128, 128, 128, 255, 64, 64, 64, 255]); + } + + /// Drawn from its parts (its own paints, then its children), the node is + /// still cut by its mask: outside it neither shows, inside both do. + #[test] + fn a_mask_cuts_a_node_drawn_from_its_parts() { + let scene = json!({ + "area": {"x": 0, "y": 0}, "width": 2, "height": 1, "page": [0, 0, 0], + "node": { + "own": {"transform": [[1, 0, 0], [0, 1, 0]], "width": 2, "height": 1, + "fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}], + "effects": [{"type": "BACKGROUND_BLUR", "radius": 2}]}, + "children": [{"data": png(1, 1, vec![255, 0, 0, 255]), "area": {"x": 1, "y": 0}}], + "mask": {"data": png(1, 1, vec![0, 0, 0, 255]), "area": {"x": 1, "y": 0}} + } + }); + let rgba = decode(&compose(&scene).expect("composes")); + assert_eq!(rgba, vec![0, 0, 0, 255, 255, 0, 0, 255]); + } + + /// A PNG that is only its header, claiming `width` x `height`. + fn png_header(width: u32, height: u32) -> String { + fn crc(bytes: &[u8]) -> u32 { + let mut crc = !0_u32; + for &byte in bytes { + crc ^= u32::from(byte); + for _ in 0..8 { + crc = if crc & 1 == 1 { + (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >> 1 + }; + } + } + !crc + } + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut chunk = |kind: &[u8; 4], data: &[u8]| { + let typed = [kind.as_slice(), data].concat(); + png.extend((data.len() as u32).to_be_bytes()); + png.extend(&typed); + png.extend(crc(&typed).to_be_bytes()); + }; + chunk( + b"IHDR", + &[width.to_be_bytes(), height.to_be_bytes()] + .concat() + .iter() + .copied() + .chain([8, 6, 0, 0, 0]) + .collect::>(), + ); + chunk(b"IDAT", &[]); + chunk(b"IEND", &[]); + STANDARD.encode(png) + } + + /// Every mask a layer keeps to cut it counts: eleven masked ancestors over + /// 2000x2000 take over 1.5 GiB though their stack alone was counted under it. + #[test] + fn masks_kept_on_the_stack_count_toward_the_budget() { + let masked = |_| { + json!({"transform": [[1, 0, 0], [0, 1, 0]], "width": 2000, "height": 2000, + "mask": {"data": png(1, 1, vec![0, 0, 0, 255]), "area": {"x": 0, "y": 0}}}) + }; + let scene = json!({ + "area": {"x": 0, "y": 0}, "width": 2000, "height": 2000, "page": [0, 0, 0], + "layers": (0..11).map(masked).collect::>(), + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} + }); + let error = compose(&scene).expect_err("too much memory"); + assert!(error.message.contains("MiB to draw"), "{}", error.message); + } + + /// A picture's size is read from its header before it is decoded: one that + /// would take too much to decode is refused, not decoded. + #[test] + fn a_picture_too_large_to_decode_is_refused_from_its_header() { + let scene = json!({ + "area": {"x": 0, "y": 0}, "width": 2000, "height": 2000, "page": [0, 0, 0], + "node": {"picture": {"data": png_header(8_192, 12_000), "area": {"x": 0, "y": 0}}} + }); + let error = compose(&scene).expect_err("too much memory"); + assert!(error.message.contains("MiB to draw"), "{}", error.message); + } + + /// The mask of a node drawn from its parts is sized from its header too. + #[test] + fn the_mask_of_a_node_drawn_from_its_parts_is_sized_up_front() { + let scene = json!({ + "area": {"x": 0, "y": 0}, "width": 2000, "height": 2000, "page": [0, 0, 0], + "node": {"own": {"transform": [[1, 0, 0], [0, 1, 0]], "width": 2000, "height": 2000, + "mask": {"data": png_header(8_192, 12_000), "area": {"x": 0, "y": 0}}}} + }); + let error = compose(&scene).expect_err("too much memory"); + assert!(error.message.contains("MiB to draw"), "{}", error.message); + } + + /// A GIF whose first frame is far larger than the 1x1 screen its header + /// names is refused by the decoder before that frame is allocated. + #[test] + fn a_gif_frame_larger_than_its_header_is_not_decoded() { + let mut gif = b"GIF89a".to_vec(); + gif.extend([1, 0, 1, 0, 0, 0, 0]); + gif.push(0x2C); + gif.extend([0, 0, 0, 0]); + gif.extend(10_000_u16.to_le_bytes()); + gif.extend(12_000_u16.to_le_bytes()); + gif.extend([0x80, 0, 0, 0, 255, 255, 255, 2, 0, 0x3B]); + let scene = json!({ + "area": {"x": 0, "y": 0}, "width": 1, "height": 1, "page": [0, 0, 0], + "node": {"picture": {"data": STANDARD.encode(gif), "area": {"x": 0, "y": 0}}} + }); + let error = compose(&scene).expect_err("the frame is refused"); + assert!( + error.message.contains("Memory limit exceeded"), + "{}", + error.message + ); + } + + /// A WebP is not decoded: its decoder takes no allocation limit and sizes + /// buffers from data no header bounds. This one hides a VP8 frame claiming + /// 16383x16383 past the RIFF size its 1x1 canvas declares. + #[test] + fn a_webp_is_not_decoded() { + let mut webp = b"RIFF".to_vec(); + webp.extend(22_u32.to_le_bytes()); + webp.extend(b"WEBPVP8X"); + webp.extend(10_u32.to_le_bytes()); + webp.extend([0; 10]); + webp.extend(b"VP8 "); + webp.extend(10_u32.to_le_bytes()); + webp.extend([0, 0, 0, 0x9d, 0x01, 0x2a, 0xff, 0x3f, 0xff, 0x3f]); + let scene = json!({ + "area": {"x": 0, "y": 0}, "width": 1, "height": 1, "page": [0, 0, 0], + "node": {"picture": {"data": STANDARD.encode(webp), "area": {"x": 0, "y": 0}}} + }); + let error = compose(&scene).expect_err("the WebP is refused"); + assert!( + error.message.contains("not a PNG, JPEG or GIF"), + "{}", + error.message + ); + } + + /// A JPEG is read from its frame header: its size, and its coefficients at + /// the sampling factors it names. + #[test] + fn a_jpeg_is_counted_at_its_sampling_factors() { + let mut jpeg = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x04, 0x00, 0x00]; + jpeg.extend([0xFF, 0xC0, 0x00, 0x14, 8, 0x0F, 0xA0, 0x0F, 0xA0, 4]); + for id in 1..=4 { + jpeg.extend([id, 0x44, 0]); + } + jpeg.extend([0xFF, 0xDA]); + assert_eq!(jpeg_frame(&jpeg), Some((4000, 4000, 2 * 16 * 4))); + } + + /// A PNG's text chunks are counted one by one: its decoder keeps an entry + /// for each that its allowance does not hold. + #[test] + fn a_pngs_text_chunks_are_counted() { + let mut bytes = STANDARD + .decode(png(1, 1, vec![0, 0, 0, 0])) + .expect("base64"); + let text = [0, 0, 0, 2, b't', b'E', b'X', b't', b'a', 0, 0, 0, 0, 0]; + let tail = bytes.split_off(33); + for _ in 0..1000 { + bytes.extend(text); + } + bytes.extend(tail); + let header = Header::read(&bytes).expect("a PNG header"); + assert_eq!(header.texts, 1000 * PNG_TEXT_CHUNK_BYTES); + assert!(header.peak(bytes.len()) > 1000 * PNG_TEXT_CHUNK_BYTES); + } + + /// A PNG wider than the plugin's exports, or whose chunk runs past its end, + /// is not decoded. + #[test] + fn a_png_too_wide_or_cut_short_is_refused() { + let error = Header::read(&STANDARD.decode(png_header(8_193, 1)).expect("base64")) + .err() + .expect("too wide"); + assert!( + error.message.contains("wider than 8192"), + "{}", + error.message + ); + let mut cut = STANDARD + .decode(png(1, 1, vec![0, 0, 0, 0])) + .expect("base64"); + cut.truncate(33); + cut.extend([0xFF, 0xFF, 0xFF, 0xFF, b'I', b'D', b'A', b'T']); + let error = Header::read(&cut).err().expect("cut short"); + assert!( + error.message.contains("runs past its end"), + "{}", + error.message + ); + } + + /// A second SOI, or a stuffed zero, before the frame header is refused: its + /// decoder reads those differently and could skip the frame header counted. + #[test] + fn a_marker_standing_alone_before_the_jpeg_frame_is_refused() { + let jpeg = [ + 0xFF, 0xD8, 0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x0B, 8, 0, 1, 0, 1, 1, 1, 0x11, 0, 0xFF, + 0xDA, + ]; + assert_eq!(jpeg_frame(&jpeg), None); + assert_eq!(jpeg_frame(&[0xFF, 0xD8, 0xFF, 0x00, 0x00, 0x13]), None); + assert_eq!( + jpeg_frame(&[&jpeg[..2], &jpeg[4..]].concat()), + Some((1, 1, 2)) + ); + } + + /// A JPEG fill composes, counted with eight copies of its bytes - here + /// mostly a large APP0 segment its decoder copies with the rest. A WebP + /// fill is refused, so the canvas picture is used. + #[test] + fn a_jpeg_fill_composes_and_a_webp_fill_is_refused() { + let mut encoded = Vec::new(); + image::codecs::jpeg::JpegEncoder::new(&mut encoded) + .encode( + &[255, 0, 0].repeat(16), + 4, + 4, + image::ExtendedColorType::Rgb8, + ) + .expect("a JPEG encodes"); + let mut jpeg = encoded[..2].to_vec(); + for _ in 0..16 { + jpeg.extend([0xFF, 0xE0, 0xFF, 0xFF]); + jpeg.extend([0; 0xFFFD]); + } + jpeg.extend(&encoded[2..]); + let header = Header::read(&jpeg).expect("a JPEG header"); + assert!(header.peak(jpeg.len()) >= 8 * jpeg.len() as u64); + let mut webp = Vec::new(); + image::codecs::webp::WebPEncoder::new_lossless(&mut webp) + .encode( + &[0, 0, 255, 255].repeat(16), + 4, + 4, + image::ExtendedColorType::Rgba8, + ) + .expect("a WebP encodes"); + let scene = |bytes: &[u8]| { + json!({ + "area": {"x": 0, "y": 0}, "width": 4, "height": 4, "page": [0, 0, 0], + "layers": [{"transform": [[1, 0, 0], [0, 1, 0]], "width": 4, "height": 4, + "fills": [{"type": "IMAGE", "imageHash": "fill", "scaleMode": "FILL"}]}], + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}}, + "images": {"fill": STANDARD.encode(bytes)} + }) + }; + let rgba = decode(&compose(&scene(&jpeg)).expect("composes")); + assert!(rgba[0] > 200, "{rgba:?}"); + let error = compose(&scene(&webp)).expect_err("the WebP is refused"); + assert!( + error.message.contains("not a PNG, JPEG or GIF"), + "{}", + error.message + ); + } + + /// An image the plugin sent decoded and smaller is laid out at the original's + /// size: a 4-wide tile of red and blue sent 2 wide still repeats every 4. + #[test] + fn a_decoded_image_is_laid_out_at_the_originals_size() { + let tile = |images: Value| { + json!({ + "area": {"x": 0, "y": 0}, "width": 8, "height": 2, "page": [0, 0, 0], + "layers": [{"transform": [[1, 0, 0], [0, 1, 0]], "width": 8, "height": 2, + "fills": [{"type": "IMAGE", "imageHash": "t", "scaleMode": "TILE", "scalingFactor": 1}]}], + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}}, + "images": {"t": images} + }) + }; + let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 255]); + let original = png(4, 2, [red, red, blue, blue].concat().repeat(2)); + let smaller = png(2, 1, [red, blue].concat()); + let full = decode(&compose(&tile(json!(original))).expect("composes")); + let laid_out = decode( + &compose(&tile(json!({"data": smaller, "width": 4, "height": 2}))).expect("composes"), + ); + let columns = |rgba: &[u8]| { + rgba[..32] + .chunks(4) + .map(|px| px[0] > 128) + .collect::>() + }; + assert_eq!( + columns(&full), + [true, true, false, false, true, true, false, false] + ); + assert_eq!(columns(&laid_out), columns(&full)); + } + + /// An ancestor's stroke goes over everything it holds: one pixel outside a + /// white 2x2 square is black all round, mitred at the corners, and the + /// node inside does not cover it. + #[test] + fn an_ancestors_stroke_is_drawn_round_it() { + let scene = |align: &str, weight: f64, at: f64, size: f64| { + json!({ + "area": {"x": 0, "y": 0}, "width": 4, "height": 4, "page": [128, 128, 128], + "layers": [{"transform": [[1, 0, at], [0, 1, at]], "width": size, "height": size, + "fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}], + "strokes": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0}}], + "strokeAlign": align, "strokeWeights": [weight, weight, weight, weight]}], + "node": {"picture": {"data": png(4, 4, [0, 255, 0, 255].repeat(16)), "area": {"x": 0, "y": 0}}} + }) + }; + let pixel = + |rgba: &[u8], x: usize, y: usize| rgba[(y * 4 + x) * 4..(y * 4 + x) * 4 + 3].to_vec(); + let outside = decode(&compose(&scene("OUTSIDE", 1.0, 1.0, 2.0)).expect("composes")); + for (x, y) in [(0, 0), (1, 0), (3, 0), (0, 2), (3, 3)] { + assert_eq!(pixel(&outside, x, y), [0, 0, 0], "({x}, {y})"); + } + assert_eq!(pixel(&outside, 1, 1), [0, 255, 0]); + let inside = decode(&compose(&scene("INSIDE", 1.0, 0.0, 4.0)).expect("composes")); + assert_eq!(pixel(&inside, 0, 0), [0, 0, 0]); + assert_eq!(pixel(&inside, 3, 2), [0, 0, 0]); + assert_eq!(pixel(&inside, 2, 2), [0, 255, 0]); + } + + /// A shadow's spread grows or shrinks it before it is blurred: a black + /// square two pixels wide, its shadow moved four to the right and not + /// blurred, casts four red pixels; with a spread of one, sixteen; with a + /// spread of minus one, none. + #[test] + fn a_shadows_spread_grows_or_shrinks_it() { + let scene = |spread: f64| { + json!({ + "area": {"x": 0, "y": 0}, "width": 10, "height": 6, "page": [255, 255, 255], + "layers": [{"transform": [[1, 0, 2], [0, 1, 2]], "width": 2, "height": 2, + "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0}}], + "effects": [{"type": "DROP_SHADOW", "radius": 0, "spread": spread, + "offset": {"x": 4, "y": 0}, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}]}], + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} + }) + }; + let red = |rgba: &[u8]| { + rgba.chunks(4) + .filter(|px| px[0] > 200 && px[1] < 60) + .count() + }; + assert_eq!(red(&decode(&compose(&scene(0.0)).expect("composes"))), 4); + assert_eq!(red(&decode(&compose(&scene(1.0)).expect("composes"))), 16); + assert_eq!(red(&decode(&compose(&scene(-1.0)).expect("composes"))), 0); + } + + /// A spread is drawn as asked or not at all. The work it takes is counted + /// before drawing: unblurred, a spread of 500 over a thousand-pixel square + /// would take eight billion comparisons. A shadow cast from farther than + /// the margin drawn around the area - here a square 550 pixels to the left + /// with a spread of 600, whose shadow reaches the area while one spread + /// 512 would not - is not composed rather than drawn with less spread. + #[test] + fn a_large_spread_is_counted_and_never_drawn_smaller() { + let scene = |width: u32, layer: Value| { + json!({ + "area": {"x": 0, "y": 0}, "width": width, "height": width, "page": [255, 255, 255], + "layers": [layer], + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} + }) + }; + let shadowed = |x: f64, size: f64, spread: f64| { + json!({"transform": [[1, 0, x], [0, 1, 0]], "width": size, "height": size, + "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0}}], + "effects": [{"type": "DROP_SHADOW", "radius": 0, "spread": spread, + "offset": {"x": 0, "y": 0}, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}]}) + }; + let error = compose(&scene(1000, shadowed(0.0, 1000.0, 500.0))).expect_err("too much work"); + assert!(error.message.contains("kernel taps"), "{}", error.message); + let error = + compose(&scene(4, shadowed(-552.0, 2.0, 600.0))).expect_err("cast from too far"); + assert!(error.message.contains("pixels away"), "{}", error.message); + } + + /// A shadow's reach is counted as `drop_shadow` draws it, its offset and + /// spread each rounded: offset 255.5 and spread 256.5 come to 512 together + /// but are drawn 256 and 257 pixels away, one past the 512 drawn around + /// the area, so they are not composed. Offset 0.5 and spread 0.5 are drawn + /// as offset 1 and spread 1 are, and the margin is drawn wide enough for + /// them. + #[test] + fn a_shadows_reach_is_counted_as_it_is_drawn() { + let scene = |offset: f64, spread: f64| { + json!({ + "area": {"x": 0, "y": 0}, "width": 4, "height": 4, "page": [255, 255, 255], + "layers": [{"transform": [[1, 0, -2], [0, 1, 1]], "width": 2, "height": 2, + "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0}}], + "effects": [{"type": "DROP_SHADOW", "radius": 0, "spread": spread, + "offset": {"x": offset, "y": 0}, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}]}], + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} + }) + }; + let error = compose(&scene(255.5, 256.5)).expect_err("cast from past the margin"); + assert!( + error.message.contains("513 pixels away"), + "{}", + error.message + ); + assert_eq!( + compose(&scene(0.5, 0.5)).expect("composes"), + compose(&scene(1.0, 1.0)).expect("composes") + ); + } + + /// An inner shadow falls inside the layer from the side its offset points + /// away from: moved two down and not blurred, it darkens the top two rows of + /// a white square, and nothing below them or outside the square. + #[test] + fn an_inner_shadow_falls_inside_the_layer() { + let scene = json!({ + "area": {"x": 0, "y": 0}, "width": 6, "height": 6, "page": [128, 128, 128], + "layers": [{"transform": [[1, 0, 1], [0, 1, 1]], "width": 4, "height": 4, + "fills": [{"type": "SOLID", "color": {"r": 1, "g": 1, "b": 1}}], + "effects": [{"type": "INNER_SHADOW", "radius": 0, "spread": 0, + "offset": {"x": 0, "y": 2}, "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}], + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} + }); + let rgba = decode(&compose(&scene).expect("composes")); + let pixel = |x: usize, y: usize| rgba[(y * 6 + x) * 4]; + assert_eq!( + [pixel(2, 1), pixel(2, 2), pixel(2, 3), pixel(2, 4)], + [0, 0, 255, 255] + ); + assert_eq!(pixel(0, 1), 128); + } + + /// A shadow not shown behind its layer is cut away under a painted layer's + /// whole shape, but under an unpainted one (a group) only where it holds + /// something: the shadow of a translucent square stays out of its box, + /// while a group's shadow shows inside its box beside what it holds. + #[test] + fn a_shadow_is_cut_away_under_its_layer_only() { + let scene = |fills: Value| { + json!({ + "area": {"x": 0, "y": 0}, "width": 6, "height": 4, "page": [255, 255, 255], + "layers": [{"transform": [[1, 0, 1], [0, 1, 1]], "width": 4, "height": 2, "fills": fills, + "effects": [{"type": "DROP_SHADOW", "radius": 0, "spread": 0, "showShadowBehindNode": false, + "offset": {"x": 1, "y": 0}, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}]}], + "node": {"picture": {"data": png(1, 2, vec![0, 0, 255, 255, 0, 0, 255, 255]), "area": {"x": 1, "y": 1}}} + }) + }; + let pixel = + |rgba: &[u8], x: usize, y: usize| rgba[(y * 6 + x) * 4..(y * 6 + x) * 4 + 3].to_vec(); + let painted = decode( + &compose(&scene( + json!([{"type": "SOLID", "opacity": 0.5, "color": {"r": 0, "g": 1, "b": 0}}]), + )) + .expect("composes"), + ); + // Cast by a half-transparent fill, the shadow is half as strong. + assert_eq!( + pixel(&painted, 5, 1), + [255, 128, 128], + "the shadow past the square shows" + ); + assert!( + pixel(&painted, 3, 1)[0] < 200, + "no shadow inside the painted square" + ); + let group = decode(&compose(&scene(json!([]))).expect("composes")); + assert_eq!( + pixel(&group, 3, 1), + [255, 255, 255], + "nothing is cast where the group holds nothing" + ); + } + + /// What would take more memory or blur work than a reference PNG is worth + /// is refused before it is drawn, so the canvas picture is used. + #[test] + fn an_oversized_composition_is_refused_up_front() { + let translucent = |_| json!({"transform": [[1, 0, 0], [0, 1, 0]], "width": 2000, "height": 2000, "opacity": 0.5}); + let deep = json!({ + "area": {"x": 0, "y": 0}, "width": 2000, "height": 2000, "page": [0, 0, 0], + "layers": (0..60).map(translucent).collect::>(), + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} + }); + let error = compose(&deep).expect_err("too much memory"); + assert!(error.message.contains("MiB to draw"), "{}", error.message); + + let blurred = json!({ + "area": {"x": 0, "y": 0}, "width": 2000, "height": 2000, "page": [0, 0, 0], + "layers": [{"transform": [[1, 0, 0], [0, 1, 0]], "width": 2000, "height": 2000, + "effects": [{"type": "LAYER_BLUR", "radius": 1e12}]}], + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} + }); + let error = compose(&blurred).expect_err("too much blur work"); + assert!(error.message.contains("kernel taps"), "{}", error.message); + + let unscaled = json!({ + "area": {"x": 0, "y": 0}, "scale": 0, "width": 1, "height": 1, "page": [0, 0, 0], + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0}}} + }); + assert!(compose(&unscaled).is_err()); + } + + #[test] + fn an_unknown_paint_is_refused_not_guessed() { + let scene = json!({ + "area": {"x": 0, "y": 0, "width": 1, "height": 1}, "width": 1, "height": 1, "page": [0, 0, 0], + "layers": [{"transform": [[1, 0, 0], [0, 1, 0]], "width": 1, "height": 1, "fills": [{"type": "VIDEO"}]}], + "node": {"picture": {"data": png(1, 1, vec![0, 0, 0, 0]), "area": {"x": 0, "y": 0, "width": 1, "height": 1}}} + }); + assert!(compose(&scene).is_err()); + } +} diff --git a/crates/devup-mcp-figma/src/reference_compose/types_and_helpers.rs b/crates/devup-mcp-figma/src/reference_compose/types_and_helpers.rs new file mode 100644 index 00000000..5dd0c765 --- /dev/null +++ b/crates/devup-mcp-figma/src/reference_compose/types_and_helpers.rs @@ -0,0 +1,944 @@ + +use std::collections::HashMap; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::Deserialize; +use serde_json::Value; + +use crate::{DevupError, ErrorCode}; + +/// Straight-alpha RGBA in 0..1. +type Px = [f32; 4]; + +/// Larger areas are not composed: a buffer is 16 bytes a pixel and a deep +/// chain keeps several. The plugin falls back to the canvas export above it. +pub(crate) const MAX_COMPOSE_PIXELS: u64 = 8_000_000; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Scene { + area: Rect, + #[serde(default = "one")] + scale: f64, + width: u32, + height: u32, + /// The page background, 0..255. + page: [f64; 3], + /// The node's ancestors below the page, outermost first. + #[serde(default)] + layers: Vec, + node: Content, + /// Image fill bytes by image hash, base64. + #[serde(default)] + images: HashMap, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum ImageSource { + /// Its own bytes, laid out at its own size. + Bytes(String), + /// A PNG the plugin's UI decoded it into (the original was not a PNG, JPEG + /// or GIF, or too large), laid out at the original's size. + Decoded { + data: String, + width: f64, + height: f64, + }, +} + +/// Where an export starts on the canvas; its size is the PNG's. +#[derive(Deserialize, Clone, Copy)] +struct Rect { + x: f64, + y: f64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Layer { + /// `absoluteTransform`: local to absolute. + transform: [[f64; 3]; 2], + width: f64, + height: f64, + /// Top-left, top-right, bottom-right, bottom-left. + #[serde(default)] + radii: [f64; 4], + #[serde(default)] + fills: Vec, + #[serde(default = "one")] + opacity: f64, + #[serde(default)] + blend_mode: Option, + #[serde(default)] + clips: bool, + #[serde(default)] + effects: Vec, + /// The mask that clips this layer among its siblings. + #[serde(default)] + mask: Option, + /// Drawn over all the layer holds: its paints, inside, outside or across + /// its edge by `stroke_align`, `stroke_weights` wide (top, right, bottom, + /// left). The plugin sends only a rectangle's undashed, mitred strokes. + #[serde(default)] + strokes: Vec, + #[serde(default)] + stroke_align: Option, + #[serde(default)] + stroke_weights: [f64; 4], +} + +/// A plugin export placed at the absolute bounds it covers. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Picture { + data: String, + area: Rect, + #[serde(default)] + blend_mode: Option, + /// A LUMINANCE mask clips by brightness, the others by alpha. + #[serde(default)] + luminance: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Content { + /// The node alone. + #[serde(default)] + picture: Option, + /// The node drawn from its own paints, when its export cannot be used + /// alone (a background blur that sampled a layer behind it)... + #[serde(default)] + own: Option, + /// ...with its children's exports on top. + #[serde(default)] + children: Vec, + /// The mask that clips the node among its siblings. + #[serde(default)] + mask: Option, +} + +fn one() -> f64 { + 1.0 +} + +fn invalid(message: impl Into) -> DevupError { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + format!( + "the plugin's reference PNG scene cannot be composed: {}", + message.into() + ), + false, + ) +} + +/// The composed picture as a base64 PNG, opaque, `width` x `height`. +pub(crate) fn compose(scene: &Value) -> Result { + let scene = Scene::deserialize(scene).map_err(|error| invalid(error.to_string()))?; + let (width, height) = (scene.width as usize, scene.height as usize); + if width == 0 || height == 0 || (width as u64) * (height as u64) > MAX_COMPOSE_PIXELS { + return Err(invalid(format!( + "{width}x{height} is outside the composable range" + ))); + } + if !(scene.scale.is_finite() && scene.scale > 0.0 && scene.scale <= 64.0) { + return Err(invalid(format!( + "the scale {} is outside the composable range", + scene.scale + ))); + } + // A blur at the area's rim reads what lies past it - the ancestors' + // paints go on there - so the stack is drawn wider and cut back. + let mut margin = blur_reach(&scene); + while margin > 0 + && ((width + 2 * margin) * (height + 2 * margin)) as u64 > 2 * MAX_COMPOSE_PIXELS + { + margin /= 2; + } + // A drop shadow is cast from what lies its offset and spread away, and + // past the margin nothing is drawn to cast it: a shadow reaching farther is + // not composed rather than drawn smaller. + if let Some(reach) = shadow_reach(&scene).filter(|&reach| reach > margin) { + return Err(invalid(format!( + "a shadow is cast from {reach} pixels away, past the {margin} drawn around the area" + ))); + } + let wide = width + 2 * margin; + let mut images = Images::new(&scene.images)?; + within_budget(&scene, wide, height + 2 * margin, &images)?; + let pixels = draw(&scene, wide, height + 2 * margin, margin, &mut images)?; + let mut rgba = Vec::with_capacity(width * height * 4); + for row in pixels.chunks_exact(wide).skip(margin).take(height) { + for pixel in &row[margin..margin + width] { + for channel in &pixel[..3] { + rgba.push((channel.clamp(0.0, 1.0) * 255.0).round() as u8); + } + rgba.push(255); + } + } + let image = image::RgbaImage::from_raw(scene.width, scene.height, rgba) + .ok_or_else(|| invalid("the pixel buffer does not fill the image"))?; + let mut encoded = Vec::new(); + image + .write_with_encoder(image::codecs::png::PngEncoder::new(&mut encoded)) + .map_err(|error| invalid(error.to_string()))?; + Ok(STANDARD.encode(encoded)) +} + +struct Grid { + width: usize, + height: usize, + origin: (f64, f64), + scale: f64, +} + +impl Grid { + /// The absolute point at a pixel's centre. Pixels sit on whole canvas + /// units from the floor of the bounds' start; an export is the ceiling of + /// the bounds' size wide, as `get_screenshot`'s is. Starting at the exact, + /// fractional start instead matched `get_screenshot` worse on two of the + /// three composed nodes that start between pixels. + fn point(&self, index: usize) -> (f64, f64) { + let (column, row) = (index % self.width, index / self.width); + ( + self.origin.0 + (column as f64 + 0.5) / self.scale, + self.origin.1 + (row as f64 + 0.5) / self.scale, + ) + } + + fn origin_of(rect: Rect, scale: f64) -> (f64, f64) { + ( + (rect.x * scale).floor() / scale, + (rect.y * scale).floor() / scale, + ) + } +} + +struct Entry<'a> { + pixels: Vec, + clip: Vec, + layer: Option<&'a Layer>, + /// The mask that cuts this layer where it is laid back. + mask: Option>, +} + +/// Working memory one composition may take: the stack's buffers and masks, +/// those a layer, blur or picture holds while it is drawn, and the decoded +/// pictures and images. Past it the area is drawn as the canvas shows it +/// instead. The answer the scene came in is read before this is counted. +const MAX_COMPOSE_BYTES: u64 = 1536 * 1024 * 1024; + +/// What a decoder may hold besides its per-pixel buffers: row buffers and +/// metadata chunks. Counted once: pictures and images decode one at a time. +const DECODE_SLACK: u64 = 16 * 1024 * 1024; + +/// Kernel taps across every blur and shadow, so a wide blur over a large area +/// is drawn as the canvas shows it rather than computed for minutes. +const MAX_BLUR_TAPS: u64 = 4_000_000_000; + +/// Refuses, before drawing, what would take more memory or blur work than one +/// reference PNG is worth. +fn within_budget( + scene: &Scene, + width: usize, + height: usize, + images: &Images, +) -> Result<(), DevupError> { + let pixels = (width as u64) * (height as u64); + let layers = || scene.layers.iter().chain(scene.node.own.as_ref()); + let isolated = layers().filter(|layer| isolating(layer)).count() as u64 + + u64::from(scene.node.own.is_some() && scene.node.mask.is_some()); + let masks = layers().filter(|layer| layer.mask.is_some()).count() as u64 + + u64::from(scene.node.mask.is_some()); + let stroked = layers().filter(|layer| !layer.strokes.is_empty()).count() as u64; + // Pictures and images decode one at a time; a picture is dropped once laid + // on the grid, an image is kept. + let mut peak = images.peak; + for picture in layers() + .filter_map(|layer| layer.mask.as_ref()) + .chain(&scene.node.mask) + .chain(&scene.node.picture) + .chain(&scene.node.children) + { + let encoded = base64_bytes(&picture.data)?; + peak = peak.max(Header::read(&encoded)?.peak(encoded.len())); + } + // 20 bytes a pixel for each stack entry (colour and clip), 16 for each + // mask kept to cut its layer, 4 for the clip a stroke waits with, and about + // 120 for what one layer, blur or picture holds while it is drawn. + let bytes = pixels + .saturating_mul(20 * (1 + isolated) + 16 * masks + 4 * stroked + 120) + .saturating_add(peak) + .saturating_add(images.kept); + if bytes > MAX_COMPOSE_BYTES { + return Err(invalid(format!( + "it would take {} MiB to draw", + bytes >> 20 + ))); + } + let taps = layers() + .flat_map(|layer| layer.effects.iter().filter(|effect| visible(effect))) + .filter(|effect| { + matches!( + effect["type"].as_str(), + Some("LAYER_BLUR" | "BACKGROUND_BLUR" | "DROP_SHADOW" | "INNER_SHADOW") + ) + }) + .map(|effect| { + let reach = (number(&effect["radius"]) * scene.scale * 1.5) + .ceil() + .max(0.0) as u64; + // A shadow's spread takes the widest (or narrowest) alpha across a + // window twice the spread wide, once along each axis. + let spread = if effect["type"] == "DROP_SHADOW" { + (number(&effect["spread"]) * scene.scale).round().abs() as u64 + } else { + 0 + }; + pixels + .saturating_mul(reach.saturating_mul(4).saturating_add(2)) + .saturating_add(if spread == 0 { + 0 + } else { + pixels.saturating_mul(spread.saturating_mul(4).saturating_add(2)) + }) + }) + .fold(0_u64, u64::saturating_add); + if taps > MAX_BLUR_TAPS { + return Err(invalid(format!( + "its blurs and shadow spreads would take {taps} kernel taps" + ))); + } + Ok(()) +} + +/// How far away, in pixels, the farthest drop shadow is cast from. +fn shadow_reach(scene: &Scene) -> Option { + scene + .layers + .iter() + .chain(scene.node.own.as_ref()) + .flat_map(|layer| layer.effects.iter().filter(|effect| visible(effect))) + .filter(|effect| effect["type"] == "DROP_SHADOW") + .map(|effect| shadow_cast(effect, scene.scale)) + .max() +} + +/// How far away a drop shadow is cast from as `drop_shadow` draws it: its +/// larger offset and its spread, each rounded to whole pixels on its own. +fn shadow_cast(effect: &Value, scale: f64) -> usize { + let rounded = |value: &Value| (number(value) * scale).round().abs() as usize; + rounded(&effect["offset"]["x"]) + .max(rounded(&effect["offset"]["y"])) + .saturating_add(rounded(&effect["spread"])) +} + +/// How many pixels past the area the widest blur or shadow in the scene reads. +fn blur_reach(scene: &Scene) -> usize { + scene + .layers + .iter() + .chain(scene.node.own.as_ref()) + .flat_map(|layer| layer.effects.iter().filter(|effect| visible(effect))) + .map(|effect| { + let sigma = number(&effect["radius"]) * scene.scale / 2.0; + let shift = if matches!( + effect["type"].as_str(), + Some("DROP_SHADOW" | "INNER_SHADOW") + ) { + (number(&effect["offset"]["x"]) + .abs() + .max(number(&effect["offset"]["y"]).abs()) + + number(&effect["spread"]).abs()) + * scene.scale + } else { + 0.0 + }; + let reach = (sigma * 3.0 + shift).ceil() as usize; + // Rounded apart, a drop shadow's offset and spread can be drawn a + // pixel farther than their sum rounded up. + if effect["type"] == "DROP_SHADOW" { + reach.max( + shadow_cast(effect, scene.scale).saturating_add((sigma * 3.0).ceil() as usize), + ) + } else { + reach + } + }) + .max() + .unwrap_or(0) + .min(512) +} + +fn draw( + scene: &Scene, + width: usize, + height: usize, + margin: usize, + images: &mut Images, +) -> Result, DevupError> { + let origin = Grid::origin_of(scene.area, scene.scale); + let grid = Grid { + width, + height, + origin: ( + origin.0 - margin as f64 / scene.scale, + origin.1 - margin as f64 / scene.scale, + ), + scale: scene.scale, + }; + let count = width * height; + let page = [ + (scene.page[0] / 255.0) as f32, + (scene.page[1] / 255.0) as f32, + (scene.page[2] / 255.0) as f32, + 1.0, + ]; + let mut stack = vec![Entry { + pixels: vec![page; count], + clip: vec![1.0; count], + layer: None, + mask: None, + }]; + let own = scene.node.own.as_ref(); + // A layer's stroke goes over everything it holds, so it waits until that + // is drawn, with the clip that cuts the layer itself. + let mut strokes: Vec<(usize, &Layer, Vec)> = Vec::new(); + for layer in scene.layers.iter().chain(own) { + // Drawn from its parts, the node takes its own mask: it cuts the + // node's paints, background blur and children alike. + let is_own = own.is_some_and(|own| std::ptr::eq(own, layer)); + let mask = match layer + .mask + .as_ref() + .or(scene.node.mask.as_ref().filter(|_| is_own)) + { + Some(mask) => Some(place(mask, &grid, true)?), + None => None, + }; + let geometry = Geometry::new(layer)?; + let shape: Vec = (0..count) + .map(|index| geometry.coverage(grid.point(index), grid.scale)) + .collect(); + let isolated = isolating(layer) || mask.is_some(); + if isolated { + // The ancestors' clip cuts this layer's result - after its blur + // and shadow - where it is laid back, not its content before them. + stack.push(Entry { + pixels: vec![[0.0; 4]; count], + clip: vec![1.0; count], + layer: Some(layer), + mask, + }); + } + if let Some(radius) = effect_radius(layer, "BACKGROUND_BLUR") { + let below = if isolated { + stack.len() - 2 + } else { + stack.len() - 1 + }; + let blurred = gaussian( + &stack[below].pixels, + width, + height, + radius * scene.scale / 2.0, + ); + let (lower, upper) = stack.split_at_mut(below + 1); + let entry = &mut lower[below]; + let layer_mask = upper.last().and_then(|top| top.mask.as_deref()); + for index in 0..count { + let cut = layer_mask.map_or(1.0, |mask| mask[index][3]); + let weight = shape[index] * entry.clip[index] * cut; + entry.pixels[index] = mix(entry.pixels[index], blurred[index], weight); + } + } + let depth = stack.len() - 1; + let top = stack.last_mut().expect("the page entry stays"); + for paint in layer.fills.iter().filter(|paint| visible(paint)) { + let blend = Blend::parse(paint["blendMode"].as_str())?; + let cells = top.pixels.iter_mut().zip(shape.iter().zip(&top.clip)); + for (index, (pixel, (&cover, &clip))) in cells.enumerate() { + let coverage = cover * clip; + if coverage <= 0.0 { + continue; + } + let local = geometry.local(grid.point(index)); + let source = paint_at(paint, layer, local, images)?; + composite(pixel, source, blend, coverage); + } + } + for effect in layer + .effects + .iter() + .filter(|effect| visible(effect) && effect["type"] == "INNER_SHADOW") + { + inner_shadow(&mut top.pixels, &top.clip, &shape, &geometry, &grid, effect)?; + } + if !layer.strokes.is_empty() { + strokes.push((depth, layer, top.clip.clone())); + } + if layer.clips { + for (clip, &cover) in top.clip.iter_mut().zip(&shape) { + *clip *= cover; + } + } + } + let top = stack.last_mut().expect("the page entry stays"); + // Drawn from its parts, the node's mask already went with its paints. + let node_mask = match (&scene.node.mask, own) { + (Some(mask), None) => Some(place(mask, &grid, true)?), + _ => None, + }; + if let Some(picture) = &scene.node.picture { + let blend = Blend::parse(picture.blend_mode.as_deref())?; + let placed = place(picture, &grid, false)?; + for index in 0..count { + let coverage = top.clip[index] * node_mask.as_ref().map_or(1.0, |mask| mask[index][3]); + composite(&mut top.pixels[index], placed[index], blend, coverage); + } + } + for child in &scene.node.children { + let blend = Blend::parse(child.blend_mode.as_deref())?; + let placed = place(child, &grid, false)?; + for ((pixel, &source), &clip) in top.pixels.iter_mut().zip(&placed).zip(&top.clip) { + composite(pixel, source, blend, clip); + } + } + loop { + // The entry on top is complete: lay the strokes of the layers drawn + // into it, innermost first. + let depth = stack.len() - 1; + let top = stack.last_mut().expect("the page entry stays"); + for (_, layer, clip) in strokes.iter().rev().filter(|(at, ..)| *at == depth) { + stroke(&mut top.pixels, layer, clip, &grid, images)?; + } + if stack.len() == 1 { + break; + } + let entry = stack.pop().expect("more than the page entry"); + let layer = entry.layer.expect("an isolated entry names its layer"); + let mut pixels = entry.pixels; + let shadows: Vec<&Value> = layer + .effects + .iter() + .filter(|effect| visible(effect) && effect["type"] == "DROP_SHADOW") + .collect(); + if !shadows.is_empty() { + // Each shadow is cast by the layer alone, and all lie under it. + // Where it is not shown behind the layer it is cut away under the + // layer: under its shape when it is painted, and under what it + // holds wherever that reaches. + let geometry = Geometry::new(layer)?; + let painted = layer.fills.iter().any(visible); + let shape: Vec = (0..count) + .map(|index| { + let held = pixels[index][3]; + if painted { + geometry.coverage(grid.point(index), grid.scale).max(held) + } else { + held + } + }) + .collect(); + let mut under = vec![[0.0; 4]; count]; + for effect in shadows { + let shadow = drop_shadow(&pixels, &grid, effect, &shape); + for (px, cast) in under.iter_mut().zip(shadow) { + composite(px, cast, Blend::Normal, 1.0); + } + } + for (px, &own) in under.iter_mut().zip(&pixels) { + composite(px, own, Blend::Normal, 1.0); + } + pixels = under; + } + if let Some(radius) = effect_radius(layer, "LAYER_BLUR") { + pixels = gaussian(&pixels, width, height, radius * scene.scale / 2.0); + } + let mask = entry.mask; + let blend = Blend::parse(layer.blend_mode.as_deref())?; + let below = stack.last_mut().expect("the page entry stays"); + for index in 0..count { + let mut source = pixels[index]; + source[3] *= layer.opacity as f32 * mask.as_ref().map_or(1.0, |mask| mask[index][3]); + composite(&mut below.pixels[index], source, blend, below.clip[index]); + } + } + Ok(stack.pop().expect("the page entry stays").pixels) +} + +/// A rectangle's stroke laid over `pixels`, cut by `clip`. +fn stroke( + pixels: &mut [Px], + layer: &Layer, + clip: &[f32], + grid: &Grid, + images: &mut Images, +) -> Result<(), DevupError> { + let geometry = Geometry::new(layer)?; + let [top, right, bottom, left] = layer.stroke_weights; + let half = [top / 2.0, right / 2.0, bottom / 2.0, left / 2.0]; + let (outer, inner) = match layer.stroke_align.as_deref() { + Some("INSIDE") => ([0.0; 4], [-top, -right, -bottom, -left]), + Some("OUTSIDE") => (layer.stroke_weights, [0.0; 4]), + _ => (half, half.map(|weight| -weight)), + }; + for paint in layer.strokes.iter().filter(|paint| visible(paint)) { + let blend = Blend::parse(paint["blendMode"].as_str())?; + for (index, pixel) in pixels.iter_mut().enumerate() { + let point = grid.point(index); + let ring = (geometry.grown(point, grid.scale, outer) + - geometry.grown(point, grid.scale, inner)) + .max(0.0); + let coverage = ring * clip[index]; + if coverage <= 0.0 { + continue; + } + let source = paint_at(paint, layer, geometry.local(point), images)?; + composite(pixel, source, blend, coverage); + } + } + Ok(()) +} + +fn visible(item: &Value) -> bool { + item.is_object() && item["visible"] != false +} + +fn isolating(layer: &Layer) -> bool { + layer.opacity < 1.0 + || !matches!( + layer.blend_mode.as_deref(), + None | Some("PASS_THROUGH" | "NORMAL") + ) + || layer.mask.is_some() + || layer.effects.iter().any(|effect| { + visible(effect) && matches!(effect["type"].as_str(), Some("LAYER_BLUR" | "DROP_SHADOW")) + }) +} + +fn effect_radius(layer: &Layer, kind: &str) -> Option { + layer + .effects + .iter() + .filter(|effect| visible(effect) && effect["type"] == kind) + .filter_map(|effect| effect["radius"].as_f64()) + .fold(None, |largest: Option, radius| { + Some(largest.map_or(radius, |r| r.max(radius))) + }) +} + +/// A layer's transform inverted, its size and corner radii. +struct Geometry { + inverse: [[f64; 2]; 2], + translate: (f64, f64), + width: f64, + height: f64, + radii: [f64; 4], +} + +impl Geometry { + fn new(layer: &Layer) -> Result { + let [[a, b, tx], [c, d, ty]] = layer.transform; + let determinant = a * d - b * c; + if determinant.abs() < 1e-12 || layer.width <= 0.0 || layer.height <= 0.0 { + return Err(invalid("a layer has a degenerate transform or size")); + } + let limit = layer.width.min(layer.height) / 2.0; + Ok(Self { + inverse: [ + [d / determinant, -b / determinant], + [-c / determinant, a / determinant], + ], + translate: (tx, ty), + width: layer.width, + height: layer.height, + radii: layer.radii.map(|radius| radius.clamp(0.0, limit)), + }) + } + + /// Anti-aliased coverage of the rectangle grown by `grow` on each side (top, + /// right, bottom, left; negative shrinks). A rounded corner's arc grows + /// about the same centre; a square corner stays square, as a mitre does. + fn grown(&self, point: (f64, f64), scale: f64, [top, right, bottom, left]: [f64; 4]) -> f32 { + let (x, y) = self.local(point); + let half = 0.5 / scale; + let (x0, y0, x1, y1) = (-left, -top, self.width + right, self.height + bottom); + if x1 <= x0 || y1 <= y0 { + return 0.0; + } + let edge = (x - x0).min(x1 - x).min(y - y0).min(y1 - y); + let mut coverage = ((edge + half) / (2.0 * half)).clamp(0.0, 1.0); + let [tl, tr, br, bl] = self.radii; + for (radius, grow, cx, cy, sx, sy) in [ + (tl, top.max(left), tl, tl, -1.0, -1.0), + (tr, top.max(right), self.width - tr, tr, 1.0, -1.0), + ( + br, + bottom.max(right), + self.width - br, + self.height - br, + 1.0, + 1.0, + ), + (bl, bottom.max(left), bl, self.height - bl, -1.0, 1.0), + ] { + let arc = radius + grow; + if radius > 0.0 && arc > 0.0 && (x - cx) * sx > 0.0 && (y - cy) * sy > 0.0 { + let distance = (x - cx).hypot(y - cy) - arc; + coverage = coverage.min(((-distance + half) / (2.0 * half)).clamp(0.0, 1.0)); + } + } + coverage as f32 + } + + fn local(&self, (x, y): (f64, f64)) -> (f64, f64) { + let (dx, dy) = (x - self.translate.0, y - self.translate.1); + ( + self.inverse[0][0] * dx + self.inverse[0][1] * dy, + self.inverse[1][0] * dx + self.inverse[1][1] * dy, + ) + } + + /// Anti-aliased coverage of the (rounded) rectangle at an absolute point. + fn coverage(&self, point: (f64, f64), scale: f64) -> f32 { + let (x, y) = self.local(point); + let half = 0.5 / scale; + let edge = x.min(self.width - x).min(y).min(self.height - y); + let mut coverage = ((edge + half) / (2.0 * half)).clamp(0.0, 1.0); + let [tl, tr, br, bl] = self.radii; + for (radius, cx, cy, sx, sy) in [ + (tl, tl, tl, -1.0, -1.0), + (tr, self.width - tr, tr, 1.0, -1.0), + (br, self.width - br, self.height - br, 1.0, 1.0), + (bl, bl, self.height - bl, -1.0, 1.0), + ] { + if radius > 0.0 && (x - cx) * sx > 0.0 && (y - cy) * sy > 0.0 { + let distance = (x - cx).hypot(y - cy) - radius; + coverage = coverage.min(((-distance + half) / (2.0 * half)).clamp(0.0, 1.0)); + } + } + coverage as f32 + } +} + +fn number(value: &Value) -> f64 { + value.as_f64().unwrap_or(0.0) +} + +fn colour(value: &Value, alpha: f64) -> Px { + [ + number(&value["r"]) as f32, + number(&value["g"]) as f32, + number(&value["b"]) as f32, + (value["a"].as_f64().unwrap_or(1.0) * alpha) as f32, + ] +} + +fn paint_at( + paint: &Value, + layer: &Layer, + (x, y): (f64, f64), + images: &mut Images, +) -> Result { + let opacity = paint["opacity"].as_f64().unwrap_or(1.0); + let (nx, ny) = (x / layer.width, y / layer.height); + let kind = paint["type"].as_str().unwrap_or_default(); + match kind { + "SOLID" => { + let mut px = colour(&paint["color"], 1.0); + px[3] = opacity as f32; + Ok(px) + } + "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" => { + let m = &paint["gradientTransform"]; + let row = |r: usize, c: usize| number(&m[r][c]); + let qx = row(0, 0) * nx + row(0, 1) * ny + row(0, 2); + let qy = row(1, 0) * nx + row(1, 1) * ny + row(1, 2); + let t = match kind { + "GRADIENT_LINEAR" => qx, + "GRADIENT_RADIAL" => (qx - 0.5).hypot(qy - 0.5) * 2.0, + "GRADIENT_DIAMOND" => ((qx - 0.5).abs() + (qy - 0.5).abs()) * 2.0, + _ => ((qy - 0.5).atan2(qx - 0.5) / std::f64::consts::TAU).rem_euclid(1.0), + } + .clamp(0.0, 1.0); + let mut px = gradient_colour(&paint["gradientStops"], t)?; + px[3] *= opacity as f32; + Ok(px) + } + "IMAGE" => { + let hash = paint["imageHash"].as_str().unwrap_or_default(); + let image = images.get(hash)?; + let (iw, ih) = image.layout; + let (lw, lh) = (layer.width, layer.height); + let (u, v, inside) = match paint["scaleMode"].as_str().unwrap_or("FILL") { + "FILL" | "FIT" => { + let s = if paint["scaleMode"] == "FIT" { + (lw / iw).min(lh / ih) + } else { + (lw / iw).max(lh / ih) + }; + let u = (x - (lw - iw * s) / 2.0) / s; + let v = (y - (lh - ih * s) / 2.0) / s; + (u, v, (0.0..iw).contains(&u) && (0.0..ih).contains(&v)) + } + "TILE" => { + let s = paint["scalingFactor"] + .as_f64() + .filter(|s| *s > 0.0) + .unwrap_or(1.0); + ((x / s).rem_euclid(iw), (y / s).rem_euclid(ih), true) + } + _ => { + let m = &paint["imageTransform"]; + let row = |r: usize, c: usize| number(&m[r][c]); + let u = (row(0, 0) * nx + row(0, 1) * ny + row(0, 2)) * iw; + let v = (row(1, 0) * nx + row(1, 1) * ny + row(1, 2)) * ih; + (u, v, (0.0..iw).contains(&u) && (0.0..ih).contains(&v)) + } + }; + // Exactly 1 unless the plugin sent the image smaller than laid out. + let (sx, sy) = (image.width as f64 / iw, image.height as f64 / ih); + let mut px = image.bilinear(u * sx - 0.5, v * sy - 0.5); + px[3] *= (opacity * f64::from(u8::from(inside))) as f32; + Ok(px) + } + other => Err(invalid(format!("a {other} paint"))), + } +} + +fn gradient_colour(stops: &Value, t: f64) -> Result { + let mut stops: Vec<(f64, Px)> = stops + .as_array() + .ok_or_else(|| invalid("a gradient without stops"))? + .iter() + .map(|stop| (number(&stop["position"]), colour(&stop["color"], 1.0))) + .collect(); + if stops.is_empty() { + return Err(invalid("a gradient without stops")); + } + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + let first = stops[0]; + let last = stops[stops.len() - 1]; + if t <= first.0 { + return Ok(first.1); + } + if t >= last.0 { + return Ok(last.1); + } + let pair = stops + .windows(2) + .find(|pair| t >= pair[0].0 && t <= pair[1].0) + .ok_or_else(|| invalid("gradient stops out of order"))?; + let span = (pair[1].0 - pair[0].0).max(1e-12); + let k = ((t - pair[0].0) / span) as f32; + let mut px = [0.0; 4]; + for (channel, value) in px.iter_mut().enumerate() { + *value = pair[0].1[channel] + (pair[1].1[channel] - pair[0].1[channel]) * k; + } + Ok(px) +} + +/// A decoded picture, RGBA bytes: a quarter of what straight floats take. +struct Decoded { + width: usize, + height: usize, + rgba: Vec, + /// The size its fill is laid out at: its own, or the original's when the + /// plugin sent it decoded and smaller. + layout: (f64, f64), +} + +fn base64_bytes(data: &str) -> Result, DevupError> { + STANDARD + .decode(data.as_bytes()) + .map_err(|_| invalid("an image is not base64")) +} + +impl Decoded { + /// Decoded as the format its header names, within its allowance. + fn from_bytes(bytes: &[u8]) -> Result { + let header = Header::read(bytes)?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(header.allowance); + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)); + reader.set_format(header.format); + reader.limits(limits); + let image = reader + .decode() + .map_err(|error| invalid(format!("an image does not decode: {error}")))? + .into_rgba8(); + Ok(Self { + width: image.width() as usize, + height: image.height() as usize, + layout: (f64::from(image.width()), f64::from(image.height())), + rgba: image.into_raw(), + }) + } + + fn at(&self, x: usize, y: usize) -> Px { + let start = (y * self.width + x) * 4; + let mut px = [0.0; 4]; + for (value, &byte) in px.iter_mut().zip(&self.rgba[start..start + 4]) { + *value = f32::from(byte) / 255.0; + } + px + } + + fn bilinear(&self, u: f64, v: f64) -> Px { + let u = u.clamp(0.0, (self.width - 1) as f64); + let v = v.clamp(0.0, (self.height - 1) as f64); + let (x0, y0) = (u.floor() as usize, v.floor() as usize); + let (x1, y1) = ((x0 + 1).min(self.width - 1), (y0 + 1).min(self.height - 1)); + let (fx, fy) = ((u - x0 as f64) as f32, (v - y0 as f64) as f32); + let (a, b, c, d) = ( + self.at(x0, y0), + self.at(x1, y0), + self.at(x0, y1), + self.at(x1, y1), + ); + let mut px = [0.0; 4]; + for (channel, value) in px.iter_mut().enumerate() { + let top = a[channel] * (1.0 - fx) + b[channel] * fx; + let bottom = c[channel] * (1.0 - fx) + d[channel] * fx; + *value = top * (1.0 - fy) + bottom * fy; + } + px + } +} + +/// The images the ancestors' fills use, read once: their bytes up front, so +/// their size is known before anything is drawn, and their pixels when first +/// sampled. +struct Images { + sources: HashMap>, + /// The original's size, for an image the plugin sent decoded. + layouts: HashMap, + /// What stays once they are decoded, and the most one decode takes. + kept: u64, + peak: u64, + decoded: HashMap, +} + +/// A picture or image read from its first bytes without a decoder, so nothing +/// is allocated for one the budget then refuses. +struct Header { + format: image::ImageFormat, + pixels: u64, + /// What its decoder may allocate. + allowance: u64, + /// What its decoder keeps for each PNG text chunk, which no allowance holds. + texts: u64, +} + +/// What a PNG decoder keeps for one text chunk beside its bytes: the entry and +/// its strings. +const PNG_TEXT_CHUNK_BYTES: u64 = 512; + +/// PNGs wider than this are not decoded. A PNG decoder unfilters rows outside +/// its allowance, and for a wide interlaced 16-bit image those buffers outgrow +/// it; up to this width (the plugin's exports' longest edge) they stay within +/// `DECODE_SLACK`. +const MAX_PNG_WIDTH: u64 = 8192; \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/text/asset_render_info-annotate_assets-012d4cd1.md b/crates/devup-mcp-figma/src/text/asset_render_info-annotate_assets-012d4cd1.md new file mode 100644 index 00000000..745bc76a --- /dev/null +++ b/crates/devup-mcp-figma/src/text/asset_render_info-annotate_assets-012d4cd1.md @@ -0,0 +1 @@ +Raster/image paint: WebP is a candidate for photos and raster content; content classification is unknown. Alpha is preserved; use lossless for text, certificates and crisp edges, and inspect lossy RGB quality. \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/text/assets-asset_export_from_result-a4ad1a7b.md b/crates/devup-mcp-figma/src/text/assets-asset_export_from_result-a4ad1a7b.md new file mode 100644 index 00000000..fb9824bf --- /dev/null +++ b/crates/devup-mcp-figma/src/text/assets-asset_export_from_result-a4ad1a7b.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/text/assets-validate_asset_requests-09bfca92.md b/crates/devup-mcp-figma/src/text/assets-validate_asset_requests-09bfca92.md new file mode 100644 index 00000000..2a808b77 --- /dev/null +++ b/crates/devup-mcp-figma/src/text/assets-validate_asset_requests-09bfca92.md @@ -0,0 +1 @@ +renderMode fill requires an image-fill, png or jpg matching the original codec, and scale 1; PNG/JPG preserve original bytes; explicit WebP transcodes PNG/JPEG source without resizing. \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/text/assets-visible_scope-00ec4145.md b/crates/devup-mcp-figma/src/text/assets-visible_scope-00ec4145.md new file mode 100644 index 00000000..28f4718f --- /dev/null +++ b/crates/devup-mcp-figma/src/text/assets-visible_scope-00ec4145.md @@ -0,0 +1 @@ +Visible capture requires an acquired ancestor scope. Recollect the parent frame URL, then supply nodeId and visibleScopeNodeId for that node or an in-scope ancestor; outside-scope context is unavailable. \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/text/bridge-bridge_only_refusal-4e141d6f.md b/crates/devup-mcp-figma/src/text/bridge-bridge_only_refusal-4e141d6f.md new file mode 100644 index 00000000..64c16d5f --- /dev/null +++ b/crates/devup-mcp-figma/src/text/bridge-bridge_only_refusal-4e141d6f.md @@ -0,0 +1 @@ +This read cannot go through the Devup Bridge plugin, and the plugin could not report the file's Figma key, so the direct path cannot make it either. Repeat the call with the file's own Figma link (Share -> Copy link) to read it through the direct path. \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/text/bridge-no_room_on_the_metered_path-41a0802f.md b/crates/devup-mcp-figma/src/text/bridge-no_room_on_the_metered_path-41a0802f.md new file mode 100644 index 00000000..7befa98c --- /dev/null +++ b/crates/devup-mcp-figma/src/text/bridge-no_room_on_the_metered_path-41a0802f.md @@ -0,0 +1 @@ +The Devup Bridge plugin could not answer this read, and the metered direct path had no room in its allowance to read it in time. It is retried once the allowance frees up. \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/text/bridge-not_reachable-fa7318ce.md b/crates/devup-mcp-figma/src/text/bridge-not_reachable-fa7318ce.md new file mode 100644 index 00000000..d4570a43 --- /dev/null +++ b/crates/devup-mcp-figma/src/text/bridge-not_reachable-fa7318ce.md @@ -0,0 +1 @@ +the Devup Bridge port is changing hands: the devup-mcp that held it went away and this process is taking it over or reconnecting. Repeat the call in a few seconds. \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/text/bridge-wrap_as_image_result-467d2f14.md b/crates/devup-mcp-figma/src/text/bridge-wrap_as_image_result-467d2f14.md new file mode 100644 index 00000000..7d937b3a --- /dev/null +++ b/crates/devup-mcp-figma/src/text/bridge-wrap_as_image_result-467d2f14.md @@ -0,0 +1 @@ +Drawn as the canvas shows this area: devup-mcp could not compose what get_screenshot draws here ({}). Layers reaching into it may show here: {}; get_screenshot leaves other layers out. \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/text/collector-w5_tests_TREE-ae8d8ae7.md b/crates/devup-mcp-figma/src/text/collector-w5_tests_TREE-ae8d8ae7.md new file mode 100644 index 00000000..398bd04b --- /dev/null +++ b/crates/devup-mcp-figma/src/text/collector-w5_tests_TREE-ae8d8ae7.md @@ -0,0 +1,14 @@ + +const doc = {id:'0:0', type:'DOCUMENT'}; +const page = {id:'1:1', type:'PAGE', name:'Page', parent:doc, children:[]}; +function node(id, type, name, parent) { + const n = {id, type, name, parent, children:[], x:0, y:0, width:390, height:844, visible:true}; + n.findAll = predicate => n.children.flatMap(c => [c, ...c.findAll(predicate)]).filter(predicate); + parent.children.push(n); return n; +} +const section = node('2:1', 'SECTION', 'Loading section', page); +const frame = node('3:1', 'FRAME', 'Loading', section); +node('3:2', 'FRAME', 'Loading', page); +page.findAll = () => { throw new Error('must not traverse whole page'); }; +const figma = {fileKey:'file', getNodeByIdAsync: async () => section, + setCurrentPageAsync: async p => { if (p !== page) throw new Error('wrong page'); }}; diff --git a/crates/devup-mcp-figma/src/text/collector-w5_tests_p4_section_keeps_long_page_and_nested_automatic_screens-261e1062.md b/crates/devup-mcp-figma/src/text/collector-w5_tests_p4_section_keeps_long_page_and_nested_automatic_screens-261e1062.md new file mode 100644 index 00000000..ff6a638a --- /dev/null +++ b/crates/devup-mcp-figma/src/text/collector-w5_tests_p4_section_keeps_long_page_and_nested_automatic_screens-261e1062.md @@ -0,0 +1,7 @@ +{TREE} +frame.height = 7240; +const wrapper = node('4:1', 'GROUP', 'Wrapper', frame); +node('5:1', 'FRAME', 'Screen', wrapper); +node('5:2', 'FRAME', 'Screen contents', wrapper.children[0]); +const small = node('6:1', 'FRAME', 'Case', section); small.width = small.height = 150; +node('6:2', 'TEXT', 'Text case', section); diff --git a/crates/devup-mcp-figma/src/text/collector-w5_tests_w5_text_preview_does_not_change_candidates_or_projection_trun-90cc510e.md b/crates/devup-mcp-figma/src/text/collector-w5_tests_w5_text_preview_does_not_change_candidates_or_projection_trun-90cc510e.md new file mode 100644 index 00000000..f4faf85f --- /dev/null +++ b/crates/devup-mcp-figma/src/text/collector-w5_tests_w5_text_preview_does_not_change_candidates_or_projection_trun-90cc510e.md @@ -0,0 +1,5 @@ +{TREE} +for (let i=0; i<60; i++) {{ + const f = node('4:'+i, 'FRAME', 'Screen '+i, section); + const t = node('5:'+i, 'TEXT', 'Copy', f); t.characters = '한글 " preview '.repeat(40); +}} diff --git a/crates/devup-mcp-figma/src/text/errors-figma_unreachable_hint-347725ba.md b/crates/devup-mcp-figma/src/text/errors-figma_unreachable_hint-347725ba.md new file mode 100644 index 00000000..7f362a0c --- /dev/null +++ b/crates/devup-mcp-figma/src/text/errors-figma_unreachable_hint-347725ba.md @@ -0,0 +1 @@ +Figma's plugin runtime gave up after 10 seconds fetching data it had not loaded - another page, a main component, a variable or a style. It answers this once the file has been out of sight in the Figma desktop app for two or three minutes - its tab behind another file's tab, or the window minimized or covered by another window - and showing the file again brought the same reads back at once. Bring the Figma window to the front and click the file's tab, then retry. \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/text/oauth-callback_port_in_use_error-049191b7.md b/crates/devup-mcp-figma/src/text/oauth-callback_port_in_use_error-049191b7.md new file mode 100644 index 00000000..b3669536 --- /dev/null +++ b/crates/devup-mcp-figma/src/text/oauth-callback_port_in_use_error-049191b7.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/upstream.rs b/crates/devup-mcp-figma/src/upstream.rs index a093e2b2..ddd40338 100644 --- a/crates/devup-mcp-figma/src/upstream.rs +++ b/crates/devup-mcp-figma/src/upstream.rs @@ -1,1518 +1,3 @@ -use std::{ - collections::BTreeSet, - future::Future, - sync::Arc, - time::{Duration, Instant}, -}; - -use async_trait::async_trait; -use rmcp::{ - ServiceExt, - model::CallToolRequestParams, - transport::{ - StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig, - }, -}; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value, json}; -use tokio::sync::Mutex; - -use super::{ - AssetRequest, AuthStatus, CredentialStore, DevupError, LargeValueReadOptions, OAuthManager, - ResourceBatch, UpstreamFailureContext, UpstreamFailureKind, - collector::MAX_REFERENCE_PNG_DIMENSION, upstream_failure_error, -}; - -const DEFAULT_FIGMA_MCP_ENDPOINT: &str = "https://mcp.figma.com/mcp"; -const MAX_SSE_EVENT_SIZE: usize = 16 * 1024 * 1024; -const REMOTE_SESSION_TTL: Duration = Duration::from_secs(30); -const REMOTE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); -const REMOTE_LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(30); -const REMOTE_CALL_TOOL_TIMEOUT: Duration = Duration::from_secs(5 * 60); -const READ_ONLY_TOOL_NAMES: [&str; 6] = [ - "get_metadata", - "get_variable_defs", - "get_design_context", - "get_code_connect_map", - "get_screenshot", - "use_figma", -]; - -type RunningFigmaClient = rmcp::service::RunningService; - -// Keep at most one initialized MCP session for 30 seconds. Its capability catalog is fetched -// once, intersected with the locally compiled ReadToolCall allowlist, and reused concurrently. -// A closed session, TTL expiry, or tools/call transport failure evicts it; the next request then -// reconnects with a freshly resolved OAuth token and re-verifies the catalog. No remote-only tool -// name can cross this boundary, even when the server advertises it. -#[derive(Clone)] -struct RemoteSessionCache { - ttl: Duration, - connect_timeout: Duration, - list_tools_timeout: Duration, - state: Arc>>, -} - -#[derive(Clone)] -struct RemoteSession { - client: Arc, - capabilities: Arc>, -} - -struct CachedRemoteSession { - created_at: Instant, - session: RemoteSession, -} - -impl RemoteSessionCache { - fn new(ttl: Duration) -> Self { - Self::with_timeouts(ttl, REMOTE_CONNECT_TIMEOUT, REMOTE_LIST_TOOLS_TIMEOUT) - } - - fn with_timeouts( - ttl: Duration, - connect_timeout: Duration, - list_tools_timeout: Duration, - ) -> Self { - Self { - ttl, - connect_timeout, - list_tools_timeout, - state: Arc::new(Mutex::new(None)), - } - } - - async fn get_or_connect(&self, connect: F) -> Result - where - F: FnOnce() -> Fut, - Fut: Future>, - { - let mut cached = self.state.lock().await; - if let Some(current) = cached.as_ref() - && current.created_at.elapsed() < self.ttl - && !current.session.client.is_closed() - { - return Ok(current.session.clone()); - } - let client = Arc::new( - tokio::time::timeout(self.connect_timeout, connect()) - .await - .map_err(|_| upstream_timeout_error(UpstreamFailureContext::Connect))??, - ); - let capabilities = tokio::time::timeout(self.list_tools_timeout, client.list_all_tools()) - .await - .map_err(|_| upstream_timeout_error(UpstreamFailureContext::ListTools))? - .map_err(|error| map_upstream_error(UpstreamFailureContext::ListTools, error))? - .into_iter() - .map(|tool| tool.name.to_string()) - .filter(|name| is_read_only_tool_name(name)) - .collect::>(); - let session = RemoteSession { - client, - capabilities: Arc::new(capabilities), - }; - *cached = Some(CachedRemoteSession { - created_at: Instant::now(), - session: session.clone(), - }); - Ok(session) - } - - async fn invalidate(&self, session: &RemoteSession) { - let mut cached = self.state.lock().await; - if cached - .as_ref() - .is_some_and(|current| Arc::ptr_eq(¤t.session.client, &session.client)) - { - cached.take(); - } - } - - async fn call_tool( - &self, - session: &RemoteSession, - call: &ReadToolCall, - timeout: Duration, - ) -> Result { - let result = tokio::time::timeout( - timeout, - session.client.call_tool( - CallToolRequestParams::new(call.tool_name()).with_arguments(call.arguments()), - ), - ) - .await; - match result { - Ok(Ok(result)) => Ok(result), - Ok(Err(error)) => { - self.invalidate(session).await; - Err(map_upstream_error(UpstreamFailureContext::CallTool, error)) - } - Err(_) => { - self.invalidate(session).await; - Err(upstream_timeout_error(UpstreamFailureContext::CallTool)) - } - } - } -} - -fn is_read_only_tool_name(name: &str) -> bool { - READ_ONLY_TOOL_NAMES.contains(&name) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BuiltinScript { - MeasureNodes, - BridgeContext, - NodeSnapshot, - FastSnapshotEnvelope, - FastThemeEnvelope, - PageCatalog, - SearchSnapshot, - VariableCatalog, - LocalVariables, - UsedResources, - ExploreSnapshot, - SectionIndex, - MultiRootSnapshotEnvelope, - LargeValue, - AssetExport, -} - -impl BuiltinScript { - fn source(self, node_id: &str, inputs: ScriptInputs<'_>) -> String { - let default_root_ids = [node_id.to_owned()]; - let node_id = serde_json::to_string(node_id).expect("node id serializes"); - let source = match self { - Self::MeasureNodes => include_str!("scripts/measure_nodes.js"), - Self::BridgeContext => include_str!("scripts/bridge_context.js"), - Self::NodeSnapshot => include_str!("scripts/snapshot.js"), - Self::FastSnapshotEnvelope => include_str!("scripts/fast_snapshot.js"), - Self::FastThemeEnvelope => include_str!("scripts/fast_theme.js"), - Self::PageCatalog => include_str!("scripts/page_catalog.js"), - Self::SearchSnapshot => include_str!("scripts/search.js"), - Self::VariableCatalog => include_str!("scripts/variable_catalog.js"), - Self::LocalVariables => include_str!("scripts/variables.js"), - Self::UsedResources => include_str!("scripts/used_resources.js"), - Self::ExploreSnapshot => include_str!("scripts/explore.js"), - Self::SectionIndex => include_str!("scripts/section_index.js"), - Self::MultiRootSnapshotEnvelope => include_str!("scripts/fast_snapshot.js"), - Self::LargeValue => include_str!("scripts/large_value.js"), - Self::AssetExport => include_str!("scripts/assets.js"), - }; - let empty_resources = ResourceBatch { - variable_ids: Vec::new(), - styles: Vec::new(), - }; - let resources = serde_json::to_string(inputs.resources.unwrap_or(&empty_resources)) - .expect("resource batch serializes"); - let search = serde_json::to_string(inputs.search.unwrap_or(&SearchReadOptions::default())) - .expect("search options serialize"); - let explore = - serde_json::to_string(inputs.explore.unwrap_or(&ExploreReadOptions::default())) - .expect("explore options serialize"); - let snapshot = - serde_json::to_string(inputs.snapshot.unwrap_or(&SnapshotReadOptions::default())) - .expect("snapshot options serialize"); - let root_ids = serde_json::to_string(inputs.root_ids.unwrap_or(&default_root_ids)) - .expect("root ids serialize"); - let mut large_value = - serde_json::to_value(inputs.large_value.unwrap_or(&LargeValueReadOptions { - node_id: String::new(), - field: String::new(), - offset: 0, - max_chunk_bytes: 1, - byte_length: 0, - sha256: String::new(), - version: None, - })) - .expect("large value options serialize"); - if large_value["field"] - .as_str() - .is_some_and(|field| field.starts_with("$export:")) - { - large_value["timeoutMs"] = json!(crate::asset_timeout().as_millis() as u64); - } - let large_value = large_value.to_string(); - let asset = inputs - .asset - .map(|(request, version)| { - json!({ - "assetId": request.asset_id, - "renderMode": request.render_mode, - "usageAspectRatio":request.usage_aspect_ratio,"maxWidth":request.max_width,"srcset":request.srcset,"visibleScopeNodeId":request.visible_scope_node_id, - "nodeId": request.node_id, - "field": request.field, - "imageHash": request.image_hash, - "format": request.format, - "quality": request.quality, - "lossless": request.lossless, - "scale": request.scale, - "timeoutMs": crate::asset_timeout().as_millis() as u64, - "version": version - }) - }) - .unwrap_or_else(|| json!({})); - let asset = serde_json::to_string(&asset).expect("asset options serialize"); - let theme = serde_json::to_string(&json!({ "offset": inputs.theme_offset.unwrap_or(0) })) - .expect("theme page options serialize"); - source - .replace( - "\"__DEVUP_COMPONENT_IDENTITY_HELPERS__\"", - include_str!("scripts/component_identity_helpers.js"), - ) - .replace( - "\"__DEVUP_ASSET_RENDER_HELPERS__\"", - include_str!("scripts/asset_render_helpers.js"), - ) - .replace( - "\"__DEVUP_LARGE_VALUE_HELPERS__\"", - include_str!("scripts/large_value_helpers.js"), - ) - .replace("\"__DEVUP_NODE_ID__\"", &node_id) - .replace("\"__DEVUP_ROOT_IDS__\"", &root_ids) - .replace( - "\"__DEVUP_PLUGIN_API_MANIFEST__\"", - include_str!("plugin_api_manifest.json"), - ) - .replace( - "\"__DEVUP_TEXT_SEGMENT_MANIFEST__\"", - include_str!("text_segment_manifest.json"), - ) - .replace("\"__DEVUP_RESOURCE_BATCH__\"", &resources) - .replace("\"__DEVUP_SEARCH__\"", &search) - .replace("\"__DEVUP_EXPLORE__\"", &explore) - .replace("\"__DEVUP_SNAPSHOT__\"", &snapshot) - .replace("\"__DEVUP_LARGE_VALUE__\"", &large_value) - .replace("\"__DEVUP_ASSET__\"", &asset) - .replace("\"__DEVUP_THEME__\"", &theme) - } -} - -#[derive(Default)] -struct ScriptInputs<'a> { - resources: Option<&'a ResourceBatch>, - search: Option<&'a SearchReadOptions>, - explore: Option<&'a ExploreReadOptions>, - snapshot: Option<&'a SnapshotReadOptions>, - root_ids: Option<&'a [String]>, - large_value: Option<&'a LargeValueReadOptions>, - asset: Option<(&'a AssetRequest, Option<&'a str>)>, - theme_offset: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SnapshotReadOptions { - /// 봉투 전체가 넘을 수 없는 바이트 수. `0` 이면 스크립트 기본 천장. - /// - /// 공식 MCP 는 텍스트 결과를 자르므로 그 아래로 쪼개 여러 번 읽어야 한다. - /// 로컬 소켓에는 그 자름이 없어 한 번에 담을 수 있고, 그만큼 왕복이 사라진다. - #[serde(default)] - pub max_envelope_bytes: usize, - pub offset: usize, - pub max_payload_bytes: usize, - pub max_field_bytes: usize, -} - -impl Default for SnapshotReadOptions { - fn default() -> Self { - Self { - offset: 0, - // The node payload of one page. The Figma MCP cuts a text result at - // 20,480 UTF-8 bytes, and the fast script packs the page against - // that with the resources it carries; the measurement is written - // up in `scripts/fast_snapshot.js`. - max_payload_bytes: 15_000, - max_field_bytes: 4_096, - // 0 이면 스크립트가 위에서 잰 천장을 그대로 쓴다. 자름이 없는 전송 - // (로컬 브리지)만 이 값을 올려 한 페이지에 전부 담는다. - max_envelope_bytes: 0, - } - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SearchReadOptions { - pub query: String, - #[serde(default)] - pub node_types: Vec, - pub match_kind: String, - pub limit: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ExploreReadOptions { - pub projection_limit: usize, - pub text_preview_limit: usize, -} - -impl Default for ExploreReadOptions { - fn default() -> Self { - Self { - projection_limit: 200, - text_preview_limit: 160, - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ReadToolCall { - Metadata { - file_key: String, - node_id: Option, - }, - VariableDefs { - file_key: String, - node_id: String, - }, - DesignContext { - file_key: String, - node_id: String, - }, - CodeConnectMap { - file_key: String, - node_id: String, - }, - Screenshot { - file_key: String, - node_id: String, - }, - Snapshot { - file_key: String, - node_id: String, - script: BuiltinScript, - resources: Option, - snapshot: Option, - root_ids: Option>, - }, - SearchSnapshot { - file_key: String, - node_id: String, - options: SearchReadOptions, - }, - PageCatalog { - file_key: String, - }, - ExploreSnapshot { - file_key: String, - node_id: String, - options: ExploreReadOptions, - }, - FastTheme { - file_key: String, - /// Where in the file's resources the page starts; the first page is - /// 0, and each page says where the next begins. - offset: usize, - }, - LargeValue { - file_key: String, - options: LargeValueReadOptions, - }, - AssetExport { - file_key: String, - version: Option, - request: Box, - }, -} - -impl ReadToolCall { - /// Fixed, bounded node-list read. No descendants, resources or image bytes. - pub fn measure_nodes(file_key: impl Into, node_ids: Vec) -> Self { - Self::Snapshot { - file_key: file_key.into(), - node_id: String::new(), - script: BuiltinScript::MeasureNodes, - resources: None, - snapshot: None, - root_ids: Some(node_ids), - } - } - - pub fn metadata(file_key: impl Into, node_id: Option<&str>) -> Self { - Self::Metadata { - file_key: file_key.into(), - node_id: node_id.map(str::to_owned), - } - } - - pub fn variable_defs(file_key: impl Into, node_id: impl Into) -> Self { - Self::VariableDefs { - file_key: file_key.into(), - node_id: node_id.into(), - } - } - - pub fn design_context(file_key: impl Into, node_id: impl Into) -> Self { - Self::DesignContext { - file_key: file_key.into(), - node_id: node_id.into(), - } - } - - pub fn code_connect_map(file_key: impl Into, node_id: impl Into) -> Self { - Self::CodeConnectMap { - file_key: file_key.into(), - node_id: node_id.into(), - } - } - - pub fn screenshot(file_key: impl Into, node_id: impl Into) -> Self { - Self::Screenshot { - file_key: file_key.into(), - node_id: node_id.into(), - } - } - - pub fn snapshot( - file_key: impl Into, - node_id: impl Into, - script: BuiltinScript, - ) -> Self { - let snapshot = (script == BuiltinScript::NodeSnapshot).then(SnapshotReadOptions::default); - Self::Snapshot { - file_key: file_key.into(), - node_id: node_id.into(), - script, - resources: None, - snapshot, - root_ids: None, - } - } - - pub fn snapshot_chunk( - 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::NodeSnapshot, - resources: None, - snapshot: Some(options), - root_ids: None, - } - } - - 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: Some(options), - root_ids: None, - } - } - - pub fn section_index(file_key: impl Into, node_id: impl Into) -> Self { - Self::Snapshot { - file_key: file_key.into(), - node_id: node_id.into(), - script: BuiltinScript::SectionIndex, - resources: None, - snapshot: None, - root_ids: None, - } - } - - pub fn multi_root_snapshot( - file_key: impl Into, - section_id: impl Into, - root_ids: Vec, - ) -> Self { - Self::Snapshot { - file_key: file_key.into(), - node_id: section_id.into(), - script: BuiltinScript::MultiRootSnapshotEnvelope, - resources: None, - snapshot: None, - root_ids: Some(root_ids), - } - } - - pub fn fast_theme(file_key: impl Into) -> Self { - Self::fast_theme_page(file_key, 0) - } - - /// One page of the file's resources, from `offset`. - pub fn fast_theme_page(file_key: impl Into, offset: usize) -> Self { - Self::FastTheme { - file_key: file_key.into(), - offset, - } - } - - pub fn large_value(file_key: impl Into, options: LargeValueReadOptions) -> Self { - Self::LargeValue { - file_key: file_key.into(), - options, - } - } - - pub fn asset_export( - file_key: impl Into, - version: Option, - request: AssetRequest, - ) -> Self { - Self::AssetExport { - file_key: file_key.into(), - version, - request: Box::new(request), - } - } - - pub fn resource_batch( - file_key: impl Into, - node_id: impl Into, - resources: ResourceBatch, - ) -> Self { - Self::Snapshot { - file_key: file_key.into(), - node_id: node_id.into(), - script: BuiltinScript::LocalVariables, - resources: Some(resources), - snapshot: None, - root_ids: None, - } - } - - pub fn used_resources( - file_key: impl Into, - node_id: impl Into, - resources: ResourceBatch, - ) -> Self { - Self::Snapshot { - file_key: file_key.into(), - node_id: node_id.into(), - script: BuiltinScript::UsedResources, - resources: Some(resources), - snapshot: None, - root_ids: None, - } - } - - pub fn search_snapshot( - file_key: impl Into, - node_id: impl Into, - options: SearchReadOptions, - ) -> Self { - Self::SearchSnapshot { - file_key: file_key.into(), - node_id: node_id.into(), - options, - } - } - - pub fn page_catalog(file_key: impl Into) -> Self { - Self::PageCatalog { - file_key: file_key.into(), - } - } - - pub fn explore_snapshot( - file_key: impl Into, - node_id: impl Into, - options: ExploreReadOptions, - ) -> Self { - Self::ExploreSnapshot { - file_key: file_key.into(), - node_id: node_id.into(), - options, - } - } - - pub fn tool_name(&self) -> &'static str { - match self { - Self::Metadata { .. } => "get_metadata", - Self::VariableDefs { .. } => "get_variable_defs", - Self::DesignContext { .. } => "get_design_context", - Self::CodeConnectMap { .. } => "get_code_connect_map", - Self::Screenshot { .. } => "get_screenshot", - Self::Snapshot { .. } - | Self::SearchSnapshot { .. } - | Self::PageCatalog { .. } - | Self::ExploreSnapshot { .. } - | Self::FastTheme { .. } - | Self::LargeValue { .. } - | Self::AssetExport { .. } => "use_figma", - } - } - - pub fn arguments(&self) -> Map { - let value = match self { - // The file's own metadata - its pages - is asked for with no - // `nodeId` at all: the official schema takes the key as optional - // and refuses it as `null` ("expected string, received null"), - // which is what a file-scope theme export ran into. - Self::Metadata { - file_key, - node_id: None, - } => json!({ "fileKey": file_key }), - Self::Metadata { - file_key, - node_id: Some(node_id), - } => json!({ "fileKey": file_key, "nodeId": node_id }), - Self::VariableDefs { file_key, node_id } - | Self::DesignContext { file_key, node_id } - | Self::CodeConnectMap { file_key, node_id } => { - json!({ "fileKey": file_key, "nodeId": node_id }) - } - // The official `get_screenshot` answers by default with the PNG's - // URL and curl instructions as text, and inlines the PNG only when - // asked; the collector has no HTTP client, so it asks. And it caps - // the longer edge at 1024px unless told otherwise, which would - // hand back a 1920px screen at half size; the cap is raised to the - // most this accepts, and a node smaller than that keeps its size. - Self::Screenshot { file_key, node_id } => json!({ - "fileKey": file_key, - "nodeId": node_id, - "enableBase64Response": true, - "maxDimension": MAX_REFERENCE_PNG_DIMENSION, - }), - // 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, - script, - resources, - snapshot, - root_ids, - } => json!({ - "fileKey": file_key, - "description": self.description(), - "code": script.source(node_id, ScriptInputs { - resources: resources.as_ref(), - snapshot: snapshot.as_ref(), - root_ids: root_ids.as_deref(), - ..ScriptInputs::default() - }) - }), - Self::SearchSnapshot { - file_key, - node_id, - options, - } => json!({ - "fileKey": file_key, - "description": self.description(), - "code": BuiltinScript::SearchSnapshot.source(node_id, ScriptInputs { - search: Some(options), - ..ScriptInputs::default() - }) - }), - Self::PageCatalog { file_key } => json!({ - "fileKey": file_key, - "description": self.description(), - "code": BuiltinScript::PageCatalog.source("", ScriptInputs::default()) - }), - Self::ExploreSnapshot { - file_key, - node_id, - options, - } => json!({ - "fileKey": file_key, - "description": self.description(), - "code": BuiltinScript::ExploreSnapshot.source(node_id, ScriptInputs { - explore: Some(options), - ..ScriptInputs::default() - }) - }), - Self::FastTheme { file_key, offset } => json!({ - "fileKey": file_key, - "description": self.description(), - "code": BuiltinScript::FastThemeEnvelope.source("", ScriptInputs { - theme_offset: Some(*offset), - ..ScriptInputs::default() - }) - }), - Self::LargeValue { file_key, options } => json!({ - "fileKey": file_key, - "description": self.description(), - "code": BuiltinScript::LargeValue.source(&options.node_id, ScriptInputs { - large_value: Some(options), - ..ScriptInputs::default() - }) - }), - Self::AssetExport { - file_key, - version, - request, - } => json!({ - "fileKey": file_key, - "description": self.description(), - "code": BuiltinScript::AssetExport.source(&request.node_id, ScriptInputs { - asset: Some((request, version.as_deref())), - ..ScriptInputs::default() - }) - }), - }; - 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)] -pub struct UpstreamResult { - pub raw: Value, -} - -/// 리소스를 몇 개씩 묶어 물어볼 수 있는지. -/// -/// 기본값은 공식 MCP 가 텍스트 결과를 자르는 지점에서 나왔다. 변수 8개, 스타일 -/// 8개씩 끊어 묻느라 화면 하나에 수십 번을 오가고, 그 왕복 하나하나가 창이 뒤에 -/// 있을 때 10초를 넘는다. 자르지 않는 전송은 한 번에 다 물어도 된다. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BatchBudget { - pub variable_items: usize, - pub style_items: usize, - pub used_resource_items: usize, - pub used_resource_bytes: usize, -} - -impl Default for BatchBudget { - fn default() -> Self { - Self { - variable_items: 8, - style_items: 8, - used_resource_items: 12, - used_resource_bytes: 12_000, - } - } -} - -#[async_trait] -pub trait FigmaUpstream: Send + Sync { - async fn list_tools(&self) -> Result, DevupError>; - async fn call_read_tool(&self, call: ReadToolCall) -> Result; - - /// 이 전송으로 한 번에 물어볼 수 있는 양. - fn batch_budget(&self) -> BatchBudget { - BatchBudget::default() - } - - /// 이 파일을 Figma 자격증명 없이 읽을 수 있는지 — 즉 이 파일을 열어 둔 브리지 - /// 플러그인이 붙어 있는지. - /// - /// 로그인을 요구하기 전에 물어야 하는 값이다. 한도를 쓰지 않으려고 플러그인을 - /// 띄운 사람에게 한도를 쓰는 경로부터 열라고 시키는 것은 순서가 거꾸로다. - /// 원격만 아는 상류는 기본값 `false` 를 그대로 쓴다. - async fn serves_without_credentials(&self, _file_key: &str) -> bool { - false - } - - /// 브리지 경로의 실측 상태. 브리지를 열지 않은 상류는 `None` 이다. - /// - /// 진단 전용이다 — `devup_figma_auth doctor` 가 direct 말고도 경로가 있다는 - /// 것을 말할 수 있어야 한다. - async fn bridge_path_snapshot(&self) -> Option { - None - } - - /// Refresh only through the attached plugin. Never use remote selection or - /// silently promote the last delivered event to current evidence. - async fn refresh_bridge_context( - &self, - _file_key: &str, - ) -> Result { - Err(DevupError::new( - crate::ErrorCode::DevupFigmaDirectUnavailable, - "The attached plugin cannot refresh its current selection.", - false, - )) - } - - /// 이 읽기가 Figma 가 세는 경로로 가는지 — 분당 한도에 맞춰 늦춰야 하는지. - /// - /// 플러그인이 답하는 읽기는 한도를 쓰지 않는다. 그런데도 모든 읽기를 한도의 - /// 속도(분당 여덟)로 늦췄더니, 같은 서버의 두 번째 export 가 쓰지도 않는 한도를 - /// 1분 가까이 기다렸다. 원격만 아는 상류는 모든 읽기가 그 경로로 간다. - async fn is_metered(&self, _call: &ReadToolCall) -> bool { - true - } - - /// 지금 보내면 시도라도 되는지. 원격은 저장된 토큰이 있어야 한다. - /// - /// 플러그인이 답한 읽기를 원격으로 다시 읽기 전에 묻는다. 토큰이 없으면 그 읽기는 - /// 로그인 요구로 끝날 뿐인데, 그 전에 한도의 속도에 맞춰 1분까지 기다리게 된다. - async fn has_credentials(&self) -> bool { - true - } -} - -#[derive(Clone)] -pub struct RemoteFigmaClient { - endpoint: String, - oauth: OAuthManager, - sessions: RemoteSessionCache, - call_tool_timeout: Duration, -} - -impl RemoteFigmaClient { - pub fn new(oauth: OAuthManager) -> Self { - Self { - endpoint: DEFAULT_FIGMA_MCP_ENDPOINT.to_owned(), - oauth, - sessions: RemoteSessionCache::new(REMOTE_SESSION_TTL), - call_tool_timeout: REMOTE_CALL_TOOL_TIMEOUT, - } - } - - pub fn with_endpoint(endpoint: impl Into, oauth: OAuthManager) -> Self { - Self { - endpoint: endpoint.into(), - oauth, - sessions: RemoteSessionCache::new(REMOTE_SESSION_TTL), - call_tool_timeout: REMOTE_CALL_TOOL_TIMEOUT, - } - } - - async fn connect(&self) -> Result { - let token = self.oauth.access_token().await?; - let config = StreamableHttpClientTransportConfig::with_uri(self.endpoint.clone()) - .auth_header(token.expose().to_owned()) - .max_sse_event_size(MAX_SSE_EVENT_SIZE) - .reinit_on_expired_session(true); - ().serve(StreamableHttpClientTransport::from_config(config)) - .await - .map_err(|error| map_upstream_error(UpstreamFailureContext::Connect, error)) - } - - async fn session(&self) -> Result { - self.sessions.get_or_connect(|| self.connect()).await - } -} - -#[async_trait] -impl FigmaUpstream for RemoteFigmaClient { - async fn list_tools(&self) -> Result, DevupError> { - let session = self.session().await?; - Ok(session.capabilities.iter().cloned().collect()) - } - - async fn call_read_tool(&self, call: ReadToolCall) -> Result { - if !is_read_only_tool_name(call.tool_name()) { - return Err(UpstreamFailureKind::CapabilityUnavailable.into_devup_error(None)); - } - let session = self.session().await?; - if !session.capabilities.contains(call.tool_name()) { - return Err(UpstreamFailureKind::CapabilityUnavailable.into_devup_error(None)); - } - let result = self - .sessions - .call_tool( - &session, - &call, - if call.is_asset_read() { - crate::asset_timeout::asset_transport_timeout() - } else { - self.call_tool_timeout - }, - ) - .await?; - let raw = serde_json::to_value(result) - .map_err(|error| map_upstream_error(UpstreamFailureContext::Decode, error))?; - Ok(UpstreamResult { raw }) - } - - async fn has_credentials(&self) -> bool { - matches!(self.oauth.status().await, Ok(AuthStatus::Connected)) - } -} - -fn map_upstream_error( - context: UpstreamFailureContext, - error: E, -) -> DevupError { - upstream_failure_error(context, None, &error.to_string()) -} - -fn upstream_timeout_error(context: UpstreamFailureContext) -> DevupError { - let phase = match context { - UpstreamFailureContext::Connect => "connect", - UpstreamFailureContext::ListTools => "listTools", - UpstreamFailureContext::CallTool => "callTool", - UpstreamFailureContext::RegisterClient => "registerClient", - UpstreamFailureContext::Decode => "decode", - }; - let mut error = UpstreamFailureKind::Transport.into_devup_error(None); - error.details = json!({ - "source": "direct", - "status": null, - "reason": "timeout", - "phase": phase, - }); - error -} - -impl BuiltinScript { - /// 플러그인이 아는 이름. - /// - /// 코드젠이 파일명에서 만든 것과 같아야 한다(`fast_snapshot.js` → - /// `fastSnapshot`). 어긋나면 플러그인이 `DEVUP_BRIDGE_UNKNOWN_SCRIPT` 를 - /// 돌려주며 아는 이름을 함께 알려 준다. - pub fn plugin_name(self) -> &'static str { - match self { - Self::MeasureNodes => "measureNodes", - Self::BridgeContext => "bridgeContext", - Self::NodeSnapshot => "snapshot", - // 두 변형이 같은 원본을 쓴다. 나뉘는 곳은 넘기는 rootIds 뿐이다. - Self::FastSnapshotEnvelope | Self::MultiRootSnapshotEnvelope => "fastSnapshot", - Self::FastThemeEnvelope => "fastTheme", - Self::PageCatalog => "pageCatalog", - Self::SearchSnapshot => "search", - Self::VariableCatalog => "variableCatalog", - Self::LocalVariables => "variables", - Self::UsedResources => "usedResources", - Self::ExploreSnapshot => "explore", - Self::SectionIndex => "sectionIndex", - Self::LargeValue => "largeValue", - Self::AssetExport => "assets", - } - } -} - -impl ReadToolCall { - /// An asset rendition or a fragment of one, rather than a document field. - pub fn is_asset_read(&self) -> bool { - matches!(self, Self::AssetExport { .. }) - || matches!(self, Self::LargeValue { options, .. } if options.field.starts_with("$export:")) - } - /// 이 읽기가 향하는 파일. - pub fn file_key(&self) -> &str { - match self { - Self::Metadata { file_key, .. } - | Self::VariableDefs { file_key, .. } - | Self::DesignContext { file_key, .. } - | Self::CodeConnectMap { file_key, .. } - | Self::Screenshot { file_key, .. } - | Self::Snapshot { file_key, .. } - | Self::SearchSnapshot { file_key, .. } - | Self::PageCatalog { file_key } - | Self::ExploreSnapshot { file_key, .. } - | Self::FastTheme { file_key, .. } - | Self::LargeValue { file_key, .. } - | Self::AssetExport { file_key, .. } => file_key, - } - } - - /// 브리지가 대신할 수 있는 읽기면 스크립트 이름과 값을, 아니면 `None`. - /// - /// 값이 놓이는 자리는 [`BuiltinScript::source`] 의 치환 자리와 1:1 이다. 한쪽만 - /// 고치면 브리지 경로에서만 빈 값이 들어가 조용히 다른 결과가 나온다. - /// - /// 공식 도구 이름으로 가는 읽기는 디코더가 두 경로를 같은 타입으로 환원할 때만 - /// 브리지가 맡는다. 그렇지 않은 것은 `None` 으로 두어 원격이 처리하게 한다. - pub fn bridge_job(&self) -> Option { - use crate::bridge::BridgeJob; - - let job = match self { - // `metadata.js` 의 JSON 과 공식 경로의 XML 은 `find_metadata` 에서 - // 같은 `MetadataDocument` 로 환원된다. - Self::Metadata { - node_id: Some(node_id), - .. - } => BridgeJob { - script: "metadata", - params: json!({ "nodeId": node_id }), - }, - - // 노드 없는 읽기는 최상위 페이지 목록이다. 플러그인의 페이지 목록을 브리지 - // 클라이언트가 공식 `get_metadata` 와 같은 글로 감싼다. - Self::Metadata { node_id: None, .. } => BridgeJob { - script: BuiltinScript::PageCatalog.plugin_name(), - params: json!({}), - }, - - // 공식 `get_screenshot` 대신 플러그인이 노드를 PNG 로 export 한다. - // 브리지 클라이언트가 그 답을 공식 도구와 같은 이미지 블록으로 감싼다. - Self::Screenshot { node_id, .. } => BridgeJob { - script: "referencePng", - params: json!({ "nodeId": node_id }), - }, - - Self::VariableDefs { .. } - | Self::DesignContext { .. } - | Self::CodeConnectMap { .. } => { - return None; - } - - Self::Snapshot { - node_id, - script, - resources, - snapshot, - root_ids, - .. - } => BridgeJob { - script: script.plugin_name(), - params: json!({ - "nodeId": node_id, - // `source` 와 같은 기본값. 단일 루트 읽기는 대상 노드 하나다. - "rootIds": root_ids.clone() - .unwrap_or_else(|| vec![node_id.clone()]), - "snapshot": snapshot.clone().unwrap_or_default(), - // `source` 의 `empty_resources` 와 같은 빈 값. 스크립트는 두 - // 배열이 있다고 전제하므로 없는 채로 두면 거기서 죽는다. - "resources": resources.clone().unwrap_or(ResourceBatch { - variable_ids: Vec::new(), - styles: Vec::new(), - }), - }), - }, - - Self::SearchSnapshot { - node_id, options, .. - } => BridgeJob { - script: BuiltinScript::SearchSnapshot.plugin_name(), - params: json!({ "nodeId": node_id, "search": options }), - }, - - Self::PageCatalog { .. } => BridgeJob { - script: BuiltinScript::PageCatalog.plugin_name(), - params: json!({}), - }, - - Self::ExploreSnapshot { - node_id, options, .. - } => BridgeJob { - script: BuiltinScript::ExploreSnapshot.plugin_name(), - params: json!({ "nodeId": node_id, "explore": options }), - }, - - Self::FastTheme { offset, .. } => BridgeJob { - script: BuiltinScript::FastThemeEnvelope.plugin_name(), - params: json!({ "theme": { "offset": offset } }), - }, - - Self::LargeValue { options, .. } => { - let mut params = json!({ "nodeId": options.node_id, "largeValue": options }); - if self.is_asset_read() { - params["largeValue"]["timeoutMs"] = - json!(crate::asset_timeout().as_millis() as u64); - } - BridgeJob { - script: BuiltinScript::LargeValue.plugin_name(), - params, - } - } - - Self::AssetExport { - version, request, .. - } => BridgeJob { - script: BuiltinScript::AssetExport.plugin_name(), - params: json!({ - "nodeId": request.node_id, - "asset": { - "transport": "bridge", - "assetId": request.asset_id, - "renderMode": request.render_mode, - "usageAspectRatio":request.usage_aspect_ratio,"maxWidth":request.max_width,"srcset":request.srcset,"visibleScopeNodeId":request.visible_scope_node_id, - "nodeId": request.node_id, - "field": request.field, - "imageHash": request.image_hash, - "format": request.format, - "quality": request.quality, - "lossless": request.lossless, - "scale": request.scale, - "timeoutMs": crate::asset_timeout().as_millis() as u64, - "version": version, - }, - }), - }, - }; - Some(job) - } -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - - use rmcp::{ - ErrorData, RoleServer, ServerHandler, ServiceExt, - model::{ - CallToolResponse, CallToolResult, ContentBlock, ListToolsResult, - PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, - }, - service::RequestContext, - }; - - use crate::ErrorCode; - - use super::*; - - #[derive(Clone)] - struct CountingToolServer { - list_calls: Arc, - tool_calls: Arc, - } - - #[derive(Clone)] - struct StalledToolServer { - stall_list: bool, - stall_call: bool, - fail_call: bool, - } - - impl ServerHandler for StalledToolServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - async fn list_tools( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - if self.stall_list { - std::future::pending().await - } - Ok(ListToolsResult { - tools: vec![Tool::new("get_metadata", "read metadata", Map::new())], - ..ListToolsResult::default() - }) - } - - async fn call_tool( - &self, - _request: CallToolRequestParams, - _context: RequestContext, - ) -> Result { - if self.stall_call { - std::future::pending().await - } - if self.fail_call { - return Err(ErrorData::new( - rmcp::model::ErrorCode::INTERNAL_ERROR, - "test failure", - None, - )); - } - Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) - } - } - - impl ServerHandler for CountingToolServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) - } - - async fn list_tools( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - self.list_calls.fetch_add(1, Ordering::SeqCst); - Ok(ListToolsResult { - tools: vec![ - Tool::new("get_metadata", "read metadata", Map::new()), - Tool::new("delete_node", "write-like server tool", Map::new()), - ], - ..ListToolsResult::default() - }) - } - - async fn call_tool( - &self, - _request: CallToolRequestParams, - _context: RequestContext, - ) -> Result { - self.tool_calls.fetch_add(1, Ordering::SeqCst); - Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) - } - } - - async fn connect_counting_server( - connections: Arc, - list_calls: Arc, - tool_calls: Arc, - ) -> Result { - connections.fetch_add(1, Ordering::SeqCst); - let (server_transport, client_transport) = tokio::io::duplex(4_096); - tokio::spawn(async move { - let server = CountingToolServer { - list_calls, - tool_calls, - } - .serve(server_transport) - .await - .expect("test MCP server starts"); - let _ = server.waiting().await; - }); - ().serve(client_transport) - .await - .map_err(|error| map_upstream_error(UpstreamFailureContext::Connect, error)) - } - - async fn connect_stalled_server( - connections: Arc, - stall_list: bool, - stall_call: bool, - fail_call: bool, - ) -> Result { - connections.fetch_add(1, Ordering::SeqCst); - let (server_transport, client_transport) = tokio::io::duplex(4_096); - tokio::spawn(async move { - let server = StalledToolServer { - stall_list, - stall_call, - fail_call, - } - .serve(server_transport) - .await - .expect("test MCP server starts"); - let _ = server.waiting().await; - }); - ().serve(client_transport) - .await - .map_err(|error| map_upstream_error(UpstreamFailureContext::Connect, error)) - } - - #[tokio::test] - async fn bounded_session_reuses_one_connection_and_one_filtered_capability_catalog() { - let connections = Arc::new(AtomicUsize::new(0)); - let list_calls = Arc::new(AtomicUsize::new(0)); - let tool_calls = Arc::new(AtomicUsize::new(0)); - let cache = RemoteSessionCache::new(Duration::from_secs(30)); - - let connect = || { - let connections = connections.clone(); - let list_calls = list_calls.clone(); - let tool_calls = tool_calls.clone(); - connect_counting_server(connections, list_calls, tool_calls) - }; - - let first = cache.get_or_connect(connect).await.unwrap(); - let second = cache.get_or_connect(connect).await.unwrap(); - assert!(Arc::ptr_eq(&first.client, &second.client)); - assert_eq!( - first - .capabilities - .iter() - .map(String::as_str) - .collect::>(), - ["get_metadata"] - ); - assert!(!first.capabilities.contains("delete_node")); - for session in [&first, &second] { - session - .client - .call_tool(CallToolRequestParams::new("get_metadata").with_arguments(Map::new())) - .await - .unwrap(); - } - - assert_eq!(connections.load(Ordering::SeqCst), 1); - assert_eq!(list_calls.load(Ordering::SeqCst), 1); - assert_eq!(tool_calls.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn expired_session_reconnects_and_reverifies_capabilities() { - let connections = Arc::new(AtomicUsize::new(0)); - let list_calls = Arc::new(AtomicUsize::new(0)); - let tool_calls = Arc::new(AtomicUsize::new(0)); - let cache = RemoteSessionCache::new(Duration::ZERO); - - let connect = - || connect_counting_server(connections.clone(), list_calls.clone(), tool_calls.clone()); - let first = cache.get_or_connect(connect).await.unwrap(); - let second = cache.get_or_connect(connect).await.unwrap(); - - assert!(!Arc::ptr_eq(&first.client, &second.client)); - assert_eq!(connections.load(Ordering::SeqCst), 2); - assert_eq!(list_calls.load(Ordering::SeqCst), 2); - assert!(!second.capabilities.contains("delete_node")); - } - - #[tokio::test] - async fn stalled_capability_catalog_returns_a_bounded_safe_timeout() { - let connections = Arc::new(AtomicUsize::new(0)); - let cache = RemoteSessionCache::with_timeouts( - Duration::from_secs(30), - Duration::from_millis(100), - Duration::from_millis(10), - ); - - let result = tokio::time::timeout( - Duration::from_millis(250), - cache - .get_or_connect(|| connect_stalled_server(connections.clone(), true, false, false)), - ) - .await - .expect("product deadline must complete before the test guard"); - let Err(result) = result else { - panic!("stalled capability catalog must time out") - }; - - assert_eq!(result.code, ErrorCode::DevupFigmaDirectUnavailable); - assert!(result.retryable); - assert_eq!(result.details["reason"], "timeout"); - assert_eq!(result.details["phase"], "listTools"); - } - - #[tokio::test] - async fn stalled_connection_returns_a_bounded_safe_timeout() { - let cache = RemoteSessionCache::with_timeouts( - Duration::from_secs(30), - Duration::from_millis(10), - Duration::from_millis(100), - ); - - let result = tokio::time::timeout( - Duration::from_millis(250), - cache.get_or_connect(|| { - std::future::pending::>() - }), - ) - .await - .expect("product deadline must complete before the test guard"); - let Err(result) = result else { - panic!("stalled connection must time out") - }; - - assert_eq!(result.code, ErrorCode::DevupFigmaDirectUnavailable); - assert!(result.retryable); - assert_eq!(result.details["reason"], "timeout"); - assert_eq!(result.details["phase"], "connect"); - } - - #[tokio::test] - async fn stalled_tool_call_times_out_and_evicts_its_remote_session() { - let connections = Arc::new(AtomicUsize::new(0)); - let cache = RemoteSessionCache::with_timeouts( - Duration::from_secs(30), - Duration::from_millis(100), - Duration::from_millis(100), - ); - let session = cache - .get_or_connect(|| connect_stalled_server(connections.clone(), false, true, false)) - .await - .expect("initial session"); - let call = ReadToolCall::metadata("FileKey123", Some("1:2")); - - let error = tokio::time::timeout( - Duration::from_millis(250), - cache.call_tool(&session, &call, Duration::from_millis(10)), - ) - .await - .expect("product deadline must complete before the test guard") - .expect_err("stalled tool call must time out"); - assert_eq!(error.code, ErrorCode::DevupFigmaDirectUnavailable); - assert!(error.retryable); - assert_eq!(error.details["reason"], "timeout"); - assert_eq!(error.details["phase"], "callTool"); - - let replacement = cache - .get_or_connect(|| connect_stalled_server(connections.clone(), false, false, false)) - .await - .expect("replacement session"); - assert!(!Arc::ptr_eq(&session.client, &replacement.client)); - assert_eq!(connections.load(Ordering::SeqCst), 2); - } - - #[tokio::test] - async fn ordinary_tool_error_evicts_its_remote_session() { - let connections = Arc::new(AtomicUsize::new(0)); - let cache = RemoteSessionCache::with_timeouts( - Duration::from_secs(30), - Duration::from_millis(100), - Duration::from_millis(100), - ); - let session = cache - .get_or_connect(|| connect_stalled_server(connections.clone(), false, false, true)) - .await - .expect("initial session"); - let call = ReadToolCall::metadata("FileKey123", Some("1:2")); - - let error = cache - .call_tool(&session, &call, Duration::from_millis(100)) - .await - .expect_err("ordinary remote tool error must be classified"); - assert_eq!(error.code, ErrorCode::DevupFigmaDirectUnavailable); - assert!(error.retryable); - - let replacement = cache - .get_or_connect(|| connect_stalled_server(connections.clone(), false, false, false)) - .await - .expect("replacement session"); - assert!(!Arc::ptr_eq(&session.client, &replacement.client)); - assert_eq!(connections.load(Ordering::SeqCst), 2); - } -} +// Source sections share this module to preserve privacy and test names. +include!("upstream/connection_and_reads.rs"); +include!("upstream/map_upstream_error.rs"); diff --git a/crates/devup-mcp-figma/src/upstream/connection_and_reads.rs b/crates/devup-mcp-figma/src/upstream/connection_and_reads.rs new file mode 100644 index 00000000..004ae1e7 --- /dev/null +++ b/crates/devup-mcp-figma/src/upstream/connection_and_reads.rs @@ -0,0 +1,996 @@ +use std::{ + collections::BTreeSet, + future::Future, + sync::Arc, + time::{Duration, Instant}, +}; + +use async_trait::async_trait; +use rmcp::{ + ServiceExt, + model::CallToolRequestParams, + transport::{ + StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig, + }, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use tokio::sync::Mutex; + +use super::{ + AssetRequest, AuthStatus, CredentialStore, DevupError, LargeValueReadOptions, OAuthManager, + ResourceBatch, UpstreamFailureContext, UpstreamFailureKind, + collector::MAX_REFERENCE_PNG_DIMENSION, upstream_failure_error, +}; + +const DEFAULT_FIGMA_MCP_ENDPOINT: &str = "https://mcp.figma.com/mcp"; +const MAX_SSE_EVENT_SIZE: usize = 16 * 1024 * 1024; +const REMOTE_SESSION_TTL: Duration = Duration::from_secs(30); +const REMOTE_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const REMOTE_LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(30); +const REMOTE_CALL_TOOL_TIMEOUT: Duration = Duration::from_secs(5 * 60); +const READ_ONLY_TOOL_NAMES: [&str; 6] = [ + "get_metadata", + "get_variable_defs", + "get_design_context", + "get_code_connect_map", + "get_screenshot", + "use_figma", +]; + +type RunningFigmaClient = rmcp::service::RunningService; + +// Keep at most one initialized MCP session for 30 seconds. Its capability catalog is fetched +// once, intersected with the locally compiled ReadToolCall allowlist, and reused concurrently. +// A closed session, TTL expiry, or tools/call transport failure evicts it; the next request then +// reconnects with a freshly resolved OAuth token and re-verifies the catalog. No remote-only tool +// name can cross this boundary, even when the server advertises it. +#[derive(Clone)] +struct RemoteSessionCache { + ttl: Duration, + connect_timeout: Duration, + list_tools_timeout: Duration, + state: Arc>>, +} + +#[derive(Clone)] +struct RemoteSession { + client: Arc, + capabilities: Arc>, +} + +struct CachedRemoteSession { + created_at: Instant, + session: RemoteSession, +} + +impl RemoteSessionCache { + fn new(ttl: Duration) -> Self { + Self::with_timeouts(ttl, REMOTE_CONNECT_TIMEOUT, REMOTE_LIST_TOOLS_TIMEOUT) + } + + fn with_timeouts( + ttl: Duration, + connect_timeout: Duration, + list_tools_timeout: Duration, + ) -> Self { + Self { + ttl, + connect_timeout, + list_tools_timeout, + state: Arc::new(Mutex::new(None)), + } + } + + async fn get_or_connect(&self, connect: F) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let mut cached = self.state.lock().await; + if let Some(current) = cached.as_ref() + && current.created_at.elapsed() < self.ttl + && !current.session.client.is_closed() + { + return Ok(current.session.clone()); + } + let client = Arc::new( + tokio::time::timeout(self.connect_timeout, connect()) + .await + .map_err(|_| upstream_timeout_error(UpstreamFailureContext::Connect))??, + ); + let capabilities = tokio::time::timeout(self.list_tools_timeout, client.list_all_tools()) + .await + .map_err(|_| upstream_timeout_error(UpstreamFailureContext::ListTools))? + .map_err(|error| map_upstream_error(UpstreamFailureContext::ListTools, error))? + .into_iter() + .map(|tool| tool.name.to_string()) + .filter(|name| is_read_only_tool_name(name)) + .collect::>(); + let session = RemoteSession { + client, + capabilities: Arc::new(capabilities), + }; + *cached = Some(CachedRemoteSession { + created_at: Instant::now(), + session: session.clone(), + }); + Ok(session) + } + + async fn invalidate(&self, session: &RemoteSession) { + let mut cached = self.state.lock().await; + if cached + .as_ref() + .is_some_and(|current| Arc::ptr_eq(¤t.session.client, &session.client)) + { + cached.take(); + } + } + + async fn call_tool( + &self, + session: &RemoteSession, + call: &ReadToolCall, + timeout: Duration, + ) -> Result { + let result = tokio::time::timeout( + timeout, + session.client.call_tool( + CallToolRequestParams::new(call.tool_name()).with_arguments(call.arguments()), + ), + ) + .await; + match result { + Ok(Ok(result)) => Ok(result), + Ok(Err(error)) => { + self.invalidate(session).await; + Err(map_upstream_error(UpstreamFailureContext::CallTool, error)) + } + Err(_) => { + self.invalidate(session).await; + Err(upstream_timeout_error(UpstreamFailureContext::CallTool)) + } + } + } +} + +fn is_read_only_tool_name(name: &str) -> bool { + READ_ONLY_TOOL_NAMES.contains(&name) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BuiltinScript { + MeasureNodes, + BridgeContext, + NodeSnapshot, + FastSnapshotEnvelope, + FastThemeEnvelope, + PageCatalog, + SearchSnapshot, + VariableCatalog, + LocalVariables, + UsedResources, + ExploreSnapshot, + SectionIndex, + MultiRootSnapshotEnvelope, + LargeValue, + AssetExport, +} + +impl BuiltinScript { + fn source(self, node_id: &str, inputs: ScriptInputs<'_>) -> String { + let default_root_ids = [node_id.to_owned()]; + let node_id = serde_json::to_string(node_id).expect("node id serializes"); + let source = match self { + Self::MeasureNodes => include_str!("../scripts/measure_nodes.js"), + Self::BridgeContext => include_str!("../scripts/bridge_context.js"), + Self::NodeSnapshot => include_str!("../scripts/snapshot.js"), + Self::FastSnapshotEnvelope => include_str!("../scripts/fast_snapshot.js"), + Self::FastThemeEnvelope => include_str!("../scripts/fast_theme.js"), + Self::PageCatalog => include_str!("../scripts/page_catalog.js"), + Self::SearchSnapshot => include_str!("../scripts/search.js"), + Self::VariableCatalog => include_str!("../scripts/variable_catalog.js"), + Self::LocalVariables => include_str!("../scripts/variables.js"), + Self::UsedResources => include_str!("../scripts/used_resources.js"), + Self::ExploreSnapshot => include_str!("../scripts/explore.js"), + Self::SectionIndex => include_str!("../scripts/section_index.js"), + Self::MultiRootSnapshotEnvelope => include_str!("../scripts/fast_snapshot.js"), + Self::LargeValue => include_str!("../scripts/large_value.js"), + Self::AssetExport => include_str!("../scripts/assets.js"), + }; + let empty_resources = ResourceBatch { + variable_ids: Vec::new(), + styles: Vec::new(), + }; + let resources = serde_json::to_string(inputs.resources.unwrap_or(&empty_resources)) + .expect("resource batch serializes"); + let search = serde_json::to_string(inputs.search.unwrap_or(&SearchReadOptions::default())) + .expect("search options serialize"); + let explore = + serde_json::to_string(inputs.explore.unwrap_or(&ExploreReadOptions::default())) + .expect("explore options serialize"); + let snapshot = + serde_json::to_string(inputs.snapshot.unwrap_or(&SnapshotReadOptions::default())) + .expect("snapshot options serialize"); + let root_ids = serde_json::to_string(inputs.root_ids.unwrap_or(&default_root_ids)) + .expect("root ids serialize"); + let mut large_value = + serde_json::to_value(inputs.large_value.unwrap_or(&LargeValueReadOptions { + node_id: String::new(), + field: String::new(), + offset: 0, + max_chunk_bytes: 1, + byte_length: 0, + sha256: String::new(), + version: None, + })) + .expect("large value options serialize"); + if large_value["field"] + .as_str() + .is_some_and(|field| field.starts_with("$export:")) + { + large_value["timeoutMs"] = json!(crate::asset_timeout().as_millis() as u64); + } + let large_value = large_value.to_string(); + let asset = inputs + .asset + .map(|(request, version)| { + json!({ + "assetId": request.asset_id, + "renderMode": request.render_mode, + "usageAspectRatio":request.usage_aspect_ratio,"maxWidth":request.max_width,"srcset":request.srcset,"visibleScopeNodeId":request.visible_scope_node_id, + "nodeId": request.node_id, + "field": request.field, + "imageHash": request.image_hash, + "format": request.format, + "quality": request.quality, + "lossless": request.lossless, + "scale": request.scale, + "timeoutMs": crate::asset_timeout().as_millis() as u64, + "version": version + }) + }) + .unwrap_or_else(|| json!({})); + let asset = serde_json::to_string(&asset).expect("asset options serialize"); + let theme = serde_json::to_string(&json!({ "offset": inputs.theme_offset.unwrap_or(0) })) + .expect("theme page options serialize"); + source + .replace( + "\"__DEVUP_COMPONENT_IDENTITY_HELPERS__\"", + include_str!("../scripts/component_identity_helpers.js"), + ) + .replace( + "\"__DEVUP_ASSET_RENDER_HELPERS__\"", + include_str!("../scripts/asset_render_helpers.js"), + ) + .replace( + "\"__DEVUP_LARGE_VALUE_HELPERS__\"", + include_str!("../scripts/large_value_helpers.js"), + ) + .replace("\"__DEVUP_NODE_ID__\"", &node_id) + .replace("\"__DEVUP_ROOT_IDS__\"", &root_ids) + .replace( + "\"__DEVUP_PLUGIN_API_MANIFEST__\"", + include_str!("../plugin_api_manifest.json"), + ) + .replace( + "\"__DEVUP_TEXT_SEGMENT_MANIFEST__\"", + include_str!("../text_segment_manifest.json"), + ) + .replace("\"__DEVUP_RESOURCE_BATCH__\"", &resources) + .replace("\"__DEVUP_SEARCH__\"", &search) + .replace("\"__DEVUP_EXPLORE__\"", &explore) + .replace("\"__DEVUP_SNAPSHOT__\"", &snapshot) + .replace("\"__DEVUP_LARGE_VALUE__\"", &large_value) + .replace("\"__DEVUP_ASSET__\"", &asset) + .replace("\"__DEVUP_THEME__\"", &theme) + } +} + +#[derive(Default)] +struct ScriptInputs<'a> { + resources: Option<&'a ResourceBatch>, + search: Option<&'a SearchReadOptions>, + explore: Option<&'a ExploreReadOptions>, + snapshot: Option<&'a SnapshotReadOptions>, + root_ids: Option<&'a [String]>, + large_value: Option<&'a LargeValueReadOptions>, + asset: Option<(&'a AssetRequest, Option<&'a str>)>, + theme_offset: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotReadOptions { + /// 봉투 전체가 넘을 수 없는 바이트 수. `0` 이면 스크립트 기본 천장. + /// + /// 공식 MCP 는 텍스트 결과를 자르므로 그 아래로 쪼개 여러 번 읽어야 한다. + /// 로컬 소켓에는 그 자름이 없어 한 번에 담을 수 있고, 그만큼 왕복이 사라진다. + #[serde(default)] + pub max_envelope_bytes: usize, + pub offset: usize, + pub max_payload_bytes: usize, + pub max_field_bytes: usize, +} + +impl Default for SnapshotReadOptions { + fn default() -> Self { + Self { + offset: 0, + // The node payload of one page. The Figma MCP cuts a text result at + // 20,480 UTF-8 bytes, and the fast script packs the page against + // that with the resources it carries; the measurement is written + // up in `scripts/fast_snapshot.js`. + max_payload_bytes: 15_000, + max_field_bytes: 4_096, + // 0 이면 스크립트가 위에서 잰 천장을 그대로 쓴다. 자름이 없는 전송 + // (로컬 브리지)만 이 값을 올려 한 페이지에 전부 담는다. + max_envelope_bytes: 0, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchReadOptions { + pub query: String, + #[serde(default)] + pub node_types: Vec, + pub match_kind: String, + pub limit: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExploreReadOptions { + pub projection_limit: usize, + pub text_preview_limit: usize, +} + +impl Default for ExploreReadOptions { + fn default() -> Self { + Self { + projection_limit: 200, + text_preview_limit: 160, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ReadToolCall { + Metadata { + file_key: String, + node_id: Option, + }, + VariableDefs { + file_key: String, + node_id: String, + }, + DesignContext { + file_key: String, + node_id: String, + }, + CodeConnectMap { + file_key: String, + node_id: String, + }, + Screenshot { + file_key: String, + node_id: String, + }, + Snapshot { + file_key: String, + node_id: String, + script: BuiltinScript, + resources: Option, + snapshot: Option, + root_ids: Option>, + }, + SearchSnapshot { + file_key: String, + node_id: String, + options: SearchReadOptions, + }, + PageCatalog { + file_key: String, + }, + ExploreSnapshot { + file_key: String, + node_id: String, + options: ExploreReadOptions, + }, + FastTheme { + file_key: String, + /// Where in the file's resources the page starts; the first page is + /// 0, and each page says where the next begins. + offset: usize, + }, + LargeValue { + file_key: String, + options: LargeValueReadOptions, + }, + AssetExport { + file_key: String, + version: Option, + request: Box, + }, +} + +impl ReadToolCall { + /// Fixed, bounded node-list read. No descendants, resources or image bytes. + pub fn measure_nodes(file_key: impl Into, node_ids: Vec) -> Self { + Self::Snapshot { + file_key: file_key.into(), + node_id: String::new(), + script: BuiltinScript::MeasureNodes, + resources: None, + snapshot: None, + root_ids: Some(node_ids), + } + } + + pub fn metadata(file_key: impl Into, node_id: Option<&str>) -> Self { + Self::Metadata { + file_key: file_key.into(), + node_id: node_id.map(str::to_owned), + } + } + + pub fn variable_defs(file_key: impl Into, node_id: impl Into) -> Self { + Self::VariableDefs { + file_key: file_key.into(), + node_id: node_id.into(), + } + } + + pub fn design_context(file_key: impl Into, node_id: impl Into) -> Self { + Self::DesignContext { + file_key: file_key.into(), + node_id: node_id.into(), + } + } + + pub fn code_connect_map(file_key: impl Into, node_id: impl Into) -> Self { + Self::CodeConnectMap { + file_key: file_key.into(), + node_id: node_id.into(), + } + } + + pub fn screenshot(file_key: impl Into, node_id: impl Into) -> Self { + Self::Screenshot { + file_key: file_key.into(), + node_id: node_id.into(), + } + } + + pub fn snapshot( + file_key: impl Into, + node_id: impl Into, + script: BuiltinScript, + ) -> Self { + let snapshot = (script == BuiltinScript::NodeSnapshot).then(SnapshotReadOptions::default); + Self::Snapshot { + file_key: file_key.into(), + node_id: node_id.into(), + script, + resources: None, + snapshot, + root_ids: None, + } + } + + pub fn snapshot_chunk( + 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::NodeSnapshot, + resources: None, + snapshot: Some(options), + root_ids: None, + } + } + + 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: Some(options), + root_ids: None, + } + } + + pub fn section_index(file_key: impl Into, node_id: impl Into) -> Self { + Self::Snapshot { + file_key: file_key.into(), + node_id: node_id.into(), + script: BuiltinScript::SectionIndex, + resources: None, + snapshot: None, + root_ids: None, + } + } + + pub fn multi_root_snapshot( + file_key: impl Into, + section_id: impl Into, + root_ids: Vec, + ) -> Self { + Self::Snapshot { + file_key: file_key.into(), + node_id: section_id.into(), + script: BuiltinScript::MultiRootSnapshotEnvelope, + resources: None, + snapshot: None, + root_ids: Some(root_ids), + } + } + + pub fn fast_theme(file_key: impl Into) -> Self { + Self::fast_theme_page(file_key, 0) + } + + /// One page of the file's resources, from `offset`. + pub fn fast_theme_page(file_key: impl Into, offset: usize) -> Self { + Self::FastTheme { + file_key: file_key.into(), + offset, + } + } + + pub fn large_value(file_key: impl Into, options: LargeValueReadOptions) -> Self { + Self::LargeValue { + file_key: file_key.into(), + options, + } + } + + pub fn asset_export( + file_key: impl Into, + version: Option, + request: AssetRequest, + ) -> Self { + Self::AssetExport { + file_key: file_key.into(), + version, + request: Box::new(request), + } + } + + pub fn resource_batch( + file_key: impl Into, + node_id: impl Into, + resources: ResourceBatch, + ) -> Self { + Self::Snapshot { + file_key: file_key.into(), + node_id: node_id.into(), + script: BuiltinScript::LocalVariables, + resources: Some(resources), + snapshot: None, + root_ids: None, + } + } + + pub fn used_resources( + file_key: impl Into, + node_id: impl Into, + resources: ResourceBatch, + ) -> Self { + Self::Snapshot { + file_key: file_key.into(), + node_id: node_id.into(), + script: BuiltinScript::UsedResources, + resources: Some(resources), + snapshot: None, + root_ids: None, + } + } + + pub fn search_snapshot( + file_key: impl Into, + node_id: impl Into, + options: SearchReadOptions, + ) -> Self { + Self::SearchSnapshot { + file_key: file_key.into(), + node_id: node_id.into(), + options, + } + } + + pub fn page_catalog(file_key: impl Into) -> Self { + Self::PageCatalog { + file_key: file_key.into(), + } + } + + pub fn explore_snapshot( + file_key: impl Into, + node_id: impl Into, + options: ExploreReadOptions, + ) -> Self { + Self::ExploreSnapshot { + file_key: file_key.into(), + node_id: node_id.into(), + options, + } + } + + pub fn tool_name(&self) -> &'static str { + match self { + Self::Metadata { .. } => "get_metadata", + Self::VariableDefs { .. } => "get_variable_defs", + Self::DesignContext { .. } => "get_design_context", + Self::CodeConnectMap { .. } => "get_code_connect_map", + Self::Screenshot { .. } => "get_screenshot", + Self::Snapshot { .. } + | Self::SearchSnapshot { .. } + | Self::PageCatalog { .. } + | Self::ExploreSnapshot { .. } + | Self::FastTheme { .. } + | Self::LargeValue { .. } + | Self::AssetExport { .. } => "use_figma", + } + } + + pub fn arguments(&self) -> Map { + let value = match self { + // The file's own metadata - its pages - is asked for with no + // `nodeId` at all: the official schema takes the key as optional + // and refuses it as `null` ("expected string, received null"), + // which is what a file-scope theme export ran into. + Self::Metadata { + file_key, + node_id: None, + } => json!({ "fileKey": file_key }), + Self::Metadata { + file_key, + node_id: Some(node_id), + } => json!({ "fileKey": file_key, "nodeId": node_id }), + Self::VariableDefs { file_key, node_id } + | Self::DesignContext { file_key, node_id } + | Self::CodeConnectMap { file_key, node_id } => { + json!({ "fileKey": file_key, "nodeId": node_id }) + } + // The official `get_screenshot` answers by default with the PNG's + // URL and curl instructions as text, and inlines the PNG only when + // asked; the collector has no HTTP client, so it asks. And it caps + // the longer edge at 1024px unless told otherwise, which would + // hand back a 1920px screen at half size; the cap is raised to the + // most this accepts, and a node smaller than that keeps its size. + Self::Screenshot { file_key, node_id } => json!({ + "fileKey": file_key, + "nodeId": node_id, + "enableBase64Response": true, + "maxDimension": MAX_REFERENCE_PNG_DIMENSION, + }), + // 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, + script, + resources, + snapshot, + root_ids, + } => json!({ + "fileKey": file_key, + "description": self.description(), + "code": script.source(node_id, ScriptInputs { + resources: resources.as_ref(), + snapshot: snapshot.as_ref(), + root_ids: root_ids.as_deref(), + ..ScriptInputs::default() + }) + }), + Self::SearchSnapshot { + file_key, + node_id, + options, + } => json!({ + "fileKey": file_key, + "description": self.description(), + "code": BuiltinScript::SearchSnapshot.source(node_id, ScriptInputs { + search: Some(options), + ..ScriptInputs::default() + }) + }), + Self::PageCatalog { file_key } => json!({ + "fileKey": file_key, + "description": self.description(), + "code": BuiltinScript::PageCatalog.source("", ScriptInputs::default()) + }), + Self::ExploreSnapshot { + file_key, + node_id, + options, + } => json!({ + "fileKey": file_key, + "description": self.description(), + "code": BuiltinScript::ExploreSnapshot.source(node_id, ScriptInputs { + explore: Some(options), + ..ScriptInputs::default() + }) + }), + Self::FastTheme { file_key, offset } => json!({ + "fileKey": file_key, + "description": self.description(), + "code": BuiltinScript::FastThemeEnvelope.source("", ScriptInputs { + theme_offset: Some(*offset), + ..ScriptInputs::default() + }) + }), + Self::LargeValue { file_key, options } => json!({ + "fileKey": file_key, + "description": self.description(), + "code": BuiltinScript::LargeValue.source(&options.node_id, ScriptInputs { + large_value: Some(options), + ..ScriptInputs::default() + }) + }), + Self::AssetExport { + file_key, + version, + request, + } => json!({ + "fileKey": file_key, + "description": self.description(), + "code": BuiltinScript::AssetExport.source(&request.node_id, ScriptInputs { + asset: Some((request, version.as_deref())), + ..ScriptInputs::default() + }) + }), + }; + 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)] +pub struct UpstreamResult { + pub raw: Value, +} + +/// 리소스를 몇 개씩 묶어 물어볼 수 있는지. +/// +/// 기본값은 공식 MCP 가 텍스트 결과를 자르는 지점에서 나왔다. 변수 8개, 스타일 +/// 8개씩 끊어 묻느라 화면 하나에 수십 번을 오가고, 그 왕복 하나하나가 창이 뒤에 +/// 있을 때 10초를 넘는다. 자르지 않는 전송은 한 번에 다 물어도 된다. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BatchBudget { + pub variable_items: usize, + pub style_items: usize, + pub used_resource_items: usize, + pub used_resource_bytes: usize, +} + +impl Default for BatchBudget { + fn default() -> Self { + Self { + variable_items: 8, + style_items: 8, + used_resource_items: 12, + used_resource_bytes: 12_000, + } + } +} + +#[async_trait] +pub trait FigmaUpstream: Send + Sync { + async fn list_tools(&self) -> Result, DevupError>; + async fn call_read_tool(&self, call: ReadToolCall) -> Result; + + /// 이 전송으로 한 번에 물어볼 수 있는 양. + fn batch_budget(&self) -> BatchBudget { + BatchBudget::default() + } + + /// 이 파일을 Figma 자격증명 없이 읽을 수 있는지 — 즉 이 파일을 열어 둔 브리지 + /// 플러그인이 붙어 있는지. + /// + /// 로그인을 요구하기 전에 물어야 하는 값이다. 한도를 쓰지 않으려고 플러그인을 + /// 띄운 사람에게 한도를 쓰는 경로부터 열라고 시키는 것은 순서가 거꾸로다. + /// 원격만 아는 상류는 기본값 `false` 를 그대로 쓴다. + async fn serves_without_credentials(&self, _file_key: &str) -> bool { + false + } + + /// 브리지 경로의 실측 상태. 브리지를 열지 않은 상류는 `None` 이다. + /// + /// 진단 전용이다 — `devup_figma_auth doctor` 가 direct 말고도 경로가 있다는 + /// 것을 말할 수 있어야 한다. + async fn bridge_path_snapshot(&self) -> Option { + None + } + + /// Refresh only through the attached plugin. Never use remote selection or + /// silently promote the last delivered event to current evidence. + async fn refresh_bridge_context( + &self, + _file_key: &str, + ) -> Result { + Err(DevupError::new( + crate::ErrorCode::DevupFigmaDirectUnavailable, + "The attached plugin cannot refresh its current selection.", + false, + )) + } + + /// 이 읽기가 Figma 가 세는 경로로 가는지 — 분당 한도에 맞춰 늦춰야 하는지. + /// + /// 플러그인이 답하는 읽기는 한도를 쓰지 않는다. 그런데도 모든 읽기를 한도의 + /// 속도(분당 여덟)로 늦췄더니, 같은 서버의 두 번째 export 가 쓰지도 않는 한도를 + /// 1분 가까이 기다렸다. 원격만 아는 상류는 모든 읽기가 그 경로로 간다. + async fn is_metered(&self, _call: &ReadToolCall) -> bool { + true + } + + /// 지금 보내면 시도라도 되는지. 원격은 저장된 토큰이 있어야 한다. + /// + /// 플러그인이 답한 읽기를 원격으로 다시 읽기 전에 묻는다. 토큰이 없으면 그 읽기는 + /// 로그인 요구로 끝날 뿐인데, 그 전에 한도의 속도에 맞춰 1분까지 기다리게 된다. + async fn has_credentials(&self) -> bool { + true + } +} + +#[derive(Clone)] +pub struct RemoteFigmaClient { + endpoint: String, + oauth: OAuthManager, + sessions: RemoteSessionCache, + call_tool_timeout: Duration, +} + +impl RemoteFigmaClient { + pub fn new(oauth: OAuthManager) -> Self { + Self { + endpoint: DEFAULT_FIGMA_MCP_ENDPOINT.to_owned(), + oauth, + sessions: RemoteSessionCache::new(REMOTE_SESSION_TTL), + call_tool_timeout: REMOTE_CALL_TOOL_TIMEOUT, + } + } + + pub fn with_endpoint(endpoint: impl Into, oauth: OAuthManager) -> Self { + Self { + endpoint: endpoint.into(), + oauth, + sessions: RemoteSessionCache::new(REMOTE_SESSION_TTL), + call_tool_timeout: REMOTE_CALL_TOOL_TIMEOUT, + } + } + + async fn connect(&self) -> Result { + let token = self.oauth.access_token().await?; + let config = StreamableHttpClientTransportConfig::with_uri(self.endpoint.clone()) + .auth_header(token.expose().to_owned()) + .max_sse_event_size(MAX_SSE_EVENT_SIZE) + .reinit_on_expired_session(true); + ().serve(StreamableHttpClientTransport::from_config(config)) + .await + .map_err(|error| map_upstream_error(UpstreamFailureContext::Connect, error)) + } + + async fn session(&self) -> Result { + self.sessions.get_or_connect(|| self.connect()).await + } +} + +#[async_trait] +impl FigmaUpstream for RemoteFigmaClient { + async fn list_tools(&self) -> Result, DevupError> { + let session = self.session().await?; + Ok(session.capabilities.iter().cloned().collect()) + } + + async fn call_read_tool(&self, call: ReadToolCall) -> Result { + if !is_read_only_tool_name(call.tool_name()) { + return Err(UpstreamFailureKind::CapabilityUnavailable.into_devup_error(None)); + } + let session = self.session().await?; + if !session.capabilities.contains(call.tool_name()) { + return Err(UpstreamFailureKind::CapabilityUnavailable.into_devup_error(None)); + } + let result = self + .sessions + .call_tool( + &session, + &call, + if call.is_asset_read() { + crate::asset_timeout::asset_transport_timeout() + } else { + self.call_tool_timeout + }, + ) + .await?; + let raw = serde_json::to_value(result) + .map_err(|error| map_upstream_error(UpstreamFailureContext::Decode, error))?; + Ok(UpstreamResult { raw }) + } + + async fn has_credentials(&self) -> bool { + matches!(self.oauth.status().await, Ok(AuthStatus::Connected)) + } +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/src/upstream/map_upstream_error.rs b/crates/devup-mcp-figma/src/upstream/map_upstream_error.rs new file mode 100644 index 00000000..f2f53b2c --- /dev/null +++ b/crates/devup-mcp-figma/src/upstream/map_upstream_error.rs @@ -0,0 +1,523 @@ + + +fn map_upstream_error( + context: UpstreamFailureContext, + error: E, +) -> DevupError { + upstream_failure_error(context, None, &error.to_string()) +} + +fn upstream_timeout_error(context: UpstreamFailureContext) -> DevupError { + let phase = match context { + UpstreamFailureContext::Connect => "connect", + UpstreamFailureContext::ListTools => "listTools", + UpstreamFailureContext::CallTool => "callTool", + UpstreamFailureContext::RegisterClient => "registerClient", + UpstreamFailureContext::Decode => "decode", + }; + let mut error = UpstreamFailureKind::Transport.into_devup_error(None); + error.details = json!({ + "source": "direct", + "status": null, + "reason": "timeout", + "phase": phase, + }); + error +} + +impl BuiltinScript { + /// 플러그인이 아는 이름. + /// + /// 코드젠이 파일명에서 만든 것과 같아야 한다(`fast_snapshot.js` → + /// `fastSnapshot`). 어긋나면 플러그인이 `DEVUP_BRIDGE_UNKNOWN_SCRIPT` 를 + /// 돌려주며 아는 이름을 함께 알려 준다. + pub fn plugin_name(self) -> &'static str { + match self { + Self::MeasureNodes => "measureNodes", + Self::BridgeContext => "bridgeContext", + Self::NodeSnapshot => "snapshot", + // 두 변형이 같은 원본을 쓴다. 나뉘는 곳은 넘기는 rootIds 뿐이다. + Self::FastSnapshotEnvelope | Self::MultiRootSnapshotEnvelope => "fastSnapshot", + Self::FastThemeEnvelope => "fastTheme", + Self::PageCatalog => "pageCatalog", + Self::SearchSnapshot => "search", + Self::VariableCatalog => "variableCatalog", + Self::LocalVariables => "variables", + Self::UsedResources => "usedResources", + Self::ExploreSnapshot => "explore", + Self::SectionIndex => "sectionIndex", + Self::LargeValue => "largeValue", + Self::AssetExport => "assets", + } + } +} + +impl ReadToolCall { + /// An asset rendition or a fragment of one, rather than a document field. + pub fn is_asset_read(&self) -> bool { + matches!(self, Self::AssetExport { .. }) + || matches!(self, Self::LargeValue { options, .. } if options.field.starts_with("$export:")) + } + /// 이 읽기가 향하는 파일. + pub fn file_key(&self) -> &str { + match self { + Self::Metadata { file_key, .. } + | Self::VariableDefs { file_key, .. } + | Self::DesignContext { file_key, .. } + | Self::CodeConnectMap { file_key, .. } + | Self::Screenshot { file_key, .. } + | Self::Snapshot { file_key, .. } + | Self::SearchSnapshot { file_key, .. } + | Self::PageCatalog { file_key } + | Self::ExploreSnapshot { file_key, .. } + | Self::FastTheme { file_key, .. } + | Self::LargeValue { file_key, .. } + | Self::AssetExport { file_key, .. } => file_key, + } + } + + /// 브리지가 대신할 수 있는 읽기면 스크립트 이름과 값을, 아니면 `None`. + /// + /// 값이 놓이는 자리는 [`BuiltinScript::source`] 의 치환 자리와 1:1 이다. 한쪽만 + /// 고치면 브리지 경로에서만 빈 값이 들어가 조용히 다른 결과가 나온다. + /// + /// 공식 도구 이름으로 가는 읽기는 디코더가 두 경로를 같은 타입으로 환원할 때만 + /// 브리지가 맡는다. 그렇지 않은 것은 `None` 으로 두어 원격이 처리하게 한다. + pub fn bridge_job(&self) -> Option { + use crate::bridge::BridgeJob; + + let job = match self { + // `metadata.js` 의 JSON 과 공식 경로의 XML 은 `find_metadata` 에서 + // 같은 `MetadataDocument` 로 환원된다. + Self::Metadata { + node_id: Some(node_id), + .. + } => BridgeJob { + script: "metadata", + params: json!({ "nodeId": node_id }), + }, + + // 노드 없는 읽기는 최상위 페이지 목록이다. 플러그인의 페이지 목록을 브리지 + // 클라이언트가 공식 `get_metadata` 와 같은 글로 감싼다. + Self::Metadata { node_id: None, .. } => BridgeJob { + script: BuiltinScript::PageCatalog.plugin_name(), + params: json!({}), + }, + + // 공식 `get_screenshot` 대신 플러그인이 노드를 PNG 로 export 한다. + // 브리지 클라이언트가 그 답을 공식 도구와 같은 이미지 블록으로 감싼다. + Self::Screenshot { node_id, .. } => BridgeJob { + script: "referencePng", + params: json!({ "nodeId": node_id }), + }, + + Self::VariableDefs { .. } + | Self::DesignContext { .. } + | Self::CodeConnectMap { .. } => { + return None; + } + + Self::Snapshot { + node_id, + script, + resources, + snapshot, + root_ids, + .. + } => BridgeJob { + script: script.plugin_name(), + params: json!({ + "nodeId": node_id, + // `source` 와 같은 기본값. 단일 루트 읽기는 대상 노드 하나다. + "rootIds": root_ids.clone() + .unwrap_or_else(|| vec![node_id.clone()]), + "snapshot": snapshot.clone().unwrap_or_default(), + // `source` 의 `empty_resources` 와 같은 빈 값. 스크립트는 두 + // 배열이 있다고 전제하므로 없는 채로 두면 거기서 죽는다. + "resources": resources.clone().unwrap_or(ResourceBatch { + variable_ids: Vec::new(), + styles: Vec::new(), + }), + }), + }, + + Self::SearchSnapshot { + node_id, options, .. + } => BridgeJob { + script: BuiltinScript::SearchSnapshot.plugin_name(), + params: json!({ "nodeId": node_id, "search": options }), + }, + + Self::PageCatalog { .. } => BridgeJob { + script: BuiltinScript::PageCatalog.plugin_name(), + params: json!({}), + }, + + Self::ExploreSnapshot { + node_id, options, .. + } => BridgeJob { + script: BuiltinScript::ExploreSnapshot.plugin_name(), + params: json!({ "nodeId": node_id, "explore": options }), + }, + + Self::FastTheme { offset, .. } => BridgeJob { + script: BuiltinScript::FastThemeEnvelope.plugin_name(), + params: json!({ "theme": { "offset": offset } }), + }, + + Self::LargeValue { options, .. } => { + let mut params = json!({ "nodeId": options.node_id, "largeValue": options }); + if self.is_asset_read() { + params["largeValue"]["timeoutMs"] = + json!(crate::asset_timeout().as_millis() as u64); + } + BridgeJob { + script: BuiltinScript::LargeValue.plugin_name(), + params, + } + } + + Self::AssetExport { + version, request, .. + } => BridgeJob { + script: BuiltinScript::AssetExport.plugin_name(), + params: json!({ + "nodeId": request.node_id, + "asset": { + "transport": "bridge", + "assetId": request.asset_id, + "renderMode": request.render_mode, + "usageAspectRatio":request.usage_aspect_ratio,"maxWidth":request.max_width,"srcset":request.srcset,"visibleScopeNodeId":request.visible_scope_node_id, + "nodeId": request.node_id, + "field": request.field, + "imageHash": request.image_hash, + "format": request.format, + "quality": request.quality, + "lossless": request.lossless, + "scale": request.scale, + "timeoutMs": crate::asset_timeout().as_millis() as u64, + "version": version, + }, + }), + }, + }; + Some(job) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use rmcp::{ + ErrorData, RoleServer, ServerHandler, ServiceExt, + model::{ + CallToolResponse, CallToolResult, ContentBlock, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, + }, + service::RequestContext, + }; + + use crate::ErrorCode; + + use super::*; + + #[derive(Clone)] + struct CountingToolServer { + list_calls: Arc, + tool_calls: Arc, + } + + #[derive(Clone)] + struct StalledToolServer { + stall_list: bool, + stall_call: bool, + fail_call: bool, + } + + impl ServerHandler for StalledToolServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + if self.stall_list { + std::future::pending().await + } + Ok(ListToolsResult { + tools: vec![Tool::new("get_metadata", "read metadata", Map::new())], + ..ListToolsResult::default() + }) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + if self.stall_call { + std::future::pending().await + } + if self.fail_call { + return Err(ErrorData::new( + rmcp::model::ErrorCode::INTERNAL_ERROR, + "test failure", + None, + )); + } + Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) + } + } + + impl ServerHandler for CountingToolServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + self.list_calls.fetch_add(1, Ordering::SeqCst); + Ok(ListToolsResult { + tools: vec![ + Tool::new("get_metadata", "read metadata", Map::new()), + Tool::new("delete_node", "write-like server tool", Map::new()), + ], + ..ListToolsResult::default() + }) + } + + async fn call_tool( + &self, + _request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + self.tool_calls.fetch_add(1, Ordering::SeqCst); + Ok(CallToolResult::success(vec![ContentBlock::text("ok")]).into()) + } + } + + async fn connect_counting_server( + connections: Arc, + list_calls: Arc, + tool_calls: Arc, + ) -> Result { + connections.fetch_add(1, Ordering::SeqCst); + let (server_transport, client_transport) = tokio::io::duplex(4_096); + tokio::spawn(async move { + let server = CountingToolServer { + list_calls, + tool_calls, + } + .serve(server_transport) + .await + .expect("test MCP server starts"); + let _ = server.waiting().await; + }); + ().serve(client_transport) + .await + .map_err(|error| map_upstream_error(UpstreamFailureContext::Connect, error)) + } + + async fn connect_stalled_server( + connections: Arc, + stall_list: bool, + stall_call: bool, + fail_call: bool, + ) -> Result { + connections.fetch_add(1, Ordering::SeqCst); + let (server_transport, client_transport) = tokio::io::duplex(4_096); + tokio::spawn(async move { + let server = StalledToolServer { + stall_list, + stall_call, + fail_call, + } + .serve(server_transport) + .await + .expect("test MCP server starts"); + let _ = server.waiting().await; + }); + ().serve(client_transport) + .await + .map_err(|error| map_upstream_error(UpstreamFailureContext::Connect, error)) + } + + #[tokio::test] + async fn bounded_session_reuses_one_connection_and_one_filtered_capability_catalog() { + let connections = Arc::new(AtomicUsize::new(0)); + let list_calls = Arc::new(AtomicUsize::new(0)); + let tool_calls = Arc::new(AtomicUsize::new(0)); + let cache = RemoteSessionCache::new(Duration::from_secs(30)); + + let connect = || { + let connections = connections.clone(); + let list_calls = list_calls.clone(); + let tool_calls = tool_calls.clone(); + connect_counting_server(connections, list_calls, tool_calls) + }; + + let first = cache.get_or_connect(connect).await.unwrap(); + let second = cache.get_or_connect(connect).await.unwrap(); + assert!(Arc::ptr_eq(&first.client, &second.client)); + assert_eq!( + first + .capabilities + .iter() + .map(String::as_str) + .collect::>(), + ["get_metadata"] + ); + assert!(!first.capabilities.contains("delete_node")); + for session in [&first, &second] { + session + .client + .call_tool(CallToolRequestParams::new("get_metadata").with_arguments(Map::new())) + .await + .unwrap(); + } + + assert_eq!(connections.load(Ordering::SeqCst), 1); + assert_eq!(list_calls.load(Ordering::SeqCst), 1); + assert_eq!(tool_calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn expired_session_reconnects_and_reverifies_capabilities() { + let connections = Arc::new(AtomicUsize::new(0)); + let list_calls = Arc::new(AtomicUsize::new(0)); + let tool_calls = Arc::new(AtomicUsize::new(0)); + let cache = RemoteSessionCache::new(Duration::ZERO); + + let connect = + || connect_counting_server(connections.clone(), list_calls.clone(), tool_calls.clone()); + let first = cache.get_or_connect(connect).await.unwrap(); + let second = cache.get_or_connect(connect).await.unwrap(); + + assert!(!Arc::ptr_eq(&first.client, &second.client)); + assert_eq!(connections.load(Ordering::SeqCst), 2); + assert_eq!(list_calls.load(Ordering::SeqCst), 2); + assert!(!second.capabilities.contains("delete_node")); + } + + #[tokio::test] + async fn stalled_capability_catalog_returns_a_bounded_safe_timeout() { + let connections = Arc::new(AtomicUsize::new(0)); + let cache = RemoteSessionCache::with_timeouts( + Duration::from_secs(30), + Duration::from_millis(100), + Duration::from_millis(10), + ); + + let result = tokio::time::timeout( + Duration::from_millis(250), + cache + .get_or_connect(|| connect_stalled_server(connections.clone(), true, false, false)), + ) + .await + .expect("product deadline must complete before the test guard"); + let Err(result) = result else { + panic!("stalled capability catalog must time out") + }; + + assert_eq!(result.code, ErrorCode::DevupFigmaDirectUnavailable); + assert!(result.retryable); + assert_eq!(result.details["reason"], "timeout"); + assert_eq!(result.details["phase"], "listTools"); + } + + #[tokio::test] + async fn stalled_connection_returns_a_bounded_safe_timeout() { + let cache = RemoteSessionCache::with_timeouts( + Duration::from_secs(30), + Duration::from_millis(10), + Duration::from_millis(100), + ); + + let result = tokio::time::timeout( + Duration::from_millis(250), + cache.get_or_connect(|| { + std::future::pending::>() + }), + ) + .await + .expect("product deadline must complete before the test guard"); + let Err(result) = result else { + panic!("stalled connection must time out") + }; + + assert_eq!(result.code, ErrorCode::DevupFigmaDirectUnavailable); + assert!(result.retryable); + assert_eq!(result.details["reason"], "timeout"); + assert_eq!(result.details["phase"], "connect"); + } + + #[tokio::test] + async fn stalled_tool_call_times_out_and_evicts_its_remote_session() { + let connections = Arc::new(AtomicUsize::new(0)); + let cache = RemoteSessionCache::with_timeouts( + Duration::from_secs(30), + Duration::from_millis(100), + Duration::from_millis(100), + ); + let session = cache + .get_or_connect(|| connect_stalled_server(connections.clone(), false, true, false)) + .await + .expect("initial session"); + let call = ReadToolCall::metadata("FileKey123", Some("1:2")); + + let error = tokio::time::timeout( + Duration::from_millis(250), + cache.call_tool(&session, &call, Duration::from_millis(10)), + ) + .await + .expect("product deadline must complete before the test guard") + .expect_err("stalled tool call must time out"); + assert_eq!(error.code, ErrorCode::DevupFigmaDirectUnavailable); + assert!(error.retryable); + assert_eq!(error.details["reason"], "timeout"); + assert_eq!(error.details["phase"], "callTool"); + + let replacement = cache + .get_or_connect(|| connect_stalled_server(connections.clone(), false, false, false)) + .await + .expect("replacement session"); + assert!(!Arc::ptr_eq(&session.client, &replacement.client)); + assert_eq!(connections.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn ordinary_tool_error_evicts_its_remote_session() { + let connections = Arc::new(AtomicUsize::new(0)); + let cache = RemoteSessionCache::with_timeouts( + Duration::from_secs(30), + Duration::from_millis(100), + Duration::from_millis(100), + ); + let session = cache + .get_or_connect(|| connect_stalled_server(connections.clone(), false, false, true)) + .await + .expect("initial session"); + let call = ReadToolCall::metadata("FileKey123", Some("1:2")); + + let error = cache + .call_tool(&session, &call, Duration::from_millis(100)) + .await + .expect_err("ordinary remote tool error must be classified"); + assert_eq!(error.code, ErrorCode::DevupFigmaDirectUnavailable); + assert!(error.retryable); + + let replacement = cache + .get_or_connect(|| connect_stalled_server(connections.clone(), false, false, false)) + .await + .expect("replacement session"); + assert!(!Arc::ptr_eq(&session.client, &replacement.client)); + assert_eq!(connections.load(Ordering::SeqCst), 2); + } +} diff --git a/crates/devup-mcp-figma/tests/bridge_transport.rs b/crates/devup-mcp-figma/tests/bridge_transport.rs index 28131dbc..d8017758 100644 --- a/crates/devup-mcp-figma/tests/bridge_transport.rs +++ b/crates/devup-mcp-figma/tests/bridge_transport.rs @@ -4,1529 +4,6 @@ //! 세운다. 그 너머(스크립트 실행)는 플러그인 빌드가 책임지고, 여기서는 그 앞의 //! 모든 것 — 등록, 이름·값 매핑, 상관, 봉투 모양, 라우팅 판정 — 을 확인한다. -use devup_mcp_figma::{ - BridgeFigmaClient, BridgeServed, BridgeServer, DevupError, ErrorCode, FallbackUpstream, - FigmaUpstream, PreferredUpstream, ReadToolCall, UpstreamResult, -}; -use futures_util::{SinkExt, StreamExt}; -use serde_json::{Value, json}; -use tokio::net::TcpStream; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message}; - -const FILE_KEY: &str = "FileKey123"; - -type Plugin = WebSocketStream>; - -/// 플러그인 흉내: 붙어서 자기 파일을 알리고, 등록될 때까지 기다린다. -/// -/// `file_key` 가 `None` 이면 키 없이 붙는다. 지어낸 경우가 아니라 실제로 일어난다 — -/// 실기기에서 `figma.fileKey` 가 비어 오는 것을 확인했다. -async fn connect_plugin_as(server: &BridgeServer, file_key: Option<&str>) -> Plugin { - let (mut socket, _) = connect_async(format!("ws://127.0.0.1:{}/plugin", server.port())) - .await - .expect("bridge accepts a plugin"); - socket - .send(Message::Text( - json!({ "kind": "hello", "fileKey": file_key }) - .to_string() - .into(), - )) - .await - .expect("hello is sent"); - - // 등록은 서버 쪽 태스크에서 일어난다. 보낸 직후에는 아직일 수 있다. - let expected = file_key.unwrap_or_default(); - let state = server.state(); - for _ in 0..200 { - if state.connected_files().await.iter().any(|k| k == expected) { - return socket; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("plugin never registered"); -} - -async fn connect_plugin(server: &BridgeServer) -> Plugin { - connect_plugin_as(server, Some(FILE_KEY)).await -} - -/// 다음 작업 하나를 받아 요청 id 와 함께 돌려준다. -async fn next_job(socket: &mut Plugin) -> Value { - let message = socket - .next() - .await - .expect("a job arrives") - .expect("the socket stays open"); - let Message::Text(text) = message else { - panic!("jobs are sent as text"); - }; - serde_json::from_str(&text).expect("a job is JSON") -} - -#[tokio::test] -async fn a_script_read_reaches_the_plugin_and_comes_back_wrapped() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let client = BridgeFigmaClient::new(server.state()); - - let call = ReadToolCall::fast_snapshot(FILE_KEY, "1:2"); - let reading = tokio::spawn(async move { client.call_read_tool(call).await }); - - let job = next_job(&mut plugin).await; - assert_eq!(job["kind"], "devup-job"); - // 플러그인이 아는 이름이어야 한다. 코드젠이 파일명에서 만든 것과 같다. - assert_eq!(job["script"], "fastSnapshot"); - assert_eq!(job["params"]["nodeId"], "1:2"); - // 단일 루트 읽기는 대상 노드 하나를 루트로 넘긴다 — `source` 의 기본값과 같다. - assert_eq!(job["params"]["rootIds"], json!(["1:2"])); - - let payload = json!({ "fileKey": FILE_KEY, "nodes": [{ "id": "1:2" }] }); - plugin - .send(Message::Text( - json!({ - "kind": "devup-result", - "requestId": job["requestId"], - "data": payload, - }) - .to_string() - .into(), - )) - .await - .expect("the result is sent"); - - let result = reading - .await - .expect("the read task finishes") - .expect("the bridge answers"); - - // 원격 `use_figma` 와 같은 모양이어야 디코더들이 그대로 동작한다. - let text = result.raw["content"][0]["text"] - .as_str() - .expect("the payload rides in content[].text"); - assert_eq!( - serde_json::from_str::(text).expect("the carried text is JSON"), - payload, - ); -} - -#[tokio::test] -async fn each_script_read_maps_to_the_name_the_plugin_knows() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let client = BridgeFigmaClient::new(server.state()); - - for (call, expected) in [ - (ReadToolCall::section_index(FILE_KEY, "1:2"), "sectionIndex"), - (ReadToolCall::page_catalog(FILE_KEY), "pageCatalog"), - (ReadToolCall::fast_theme(FILE_KEY), "fastTheme"), - ] { - let client = client.clone(); - let reading = tokio::spawn(async move { client.call_read_tool(call).await }); - - let job = next_job(&mut plugin).await; - assert_eq!(job["script"], expected, "script name for {expected}"); - - plugin - .send(Message::Text( - json!({ - "kind": "devup-result", - "requestId": job["requestId"], - "data": { "ok": true }, - }) - .to_string() - .into(), - )) - .await - .expect("the result is sent"); - reading - .await - .expect("the read task finishes") - .expect("the bridge answers"); - } -} - -#[tokio::test] -async fn a_failing_script_surfaces_its_own_code() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let client = BridgeFigmaClient::new(server.state()); - - let call = ReadToolCall::fast_snapshot(FILE_KEY, "9:9"); - let reading = tokio::spawn(async move { client.call_read_tool(call).await }); - - let job = next_job(&mut plugin).await; - plugin - .send(Message::Text( - json!({ - "kind": "devup-result", - "requestId": job["requestId"], - "error": "DEVUP_NODE_NOT_FOUND", - }) - .to_string() - .into(), - )) - .await - .expect("the failure is sent"); - - let result = reading - .await - .expect("the read task finishes") - .expect("the plugin answered; its failure is the answer"); - // 원격 `use_figma` 와 같은 모양이어야 위쪽 분기가 동일하게 동작한다 — 없는 - // 노드는 호출자 오류로, Figma 런타임의 말은 플러그인 실행 오류로. - assert_eq!(result.raw["isError"], true); - assert_eq!( - result.raw["content"][0]["text"], - "Error: DEVUP_NODE_NOT_FOUND" - ); -} - -#[tokio::test] -async fn routing_is_decided_before_the_call() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let client = BridgeFigmaClient::new(server.state()); - - // 플러그인이 붙기 전에는 스크립트 읽기도 맡지 않는다 — 원격이 받아야 한다. - assert!( - !client - .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) - .await - ); - - let _plugin = connect_plugin(&server).await; - assert!( - client - .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) - .await - ); - - // 메타데이터는 노드가 있든 없든 플러그인이 맡는다. 노드가 있으면 두 경로가 같은 - // `MetadataDocument` 로 환원되고, 없으면 최상위 페이지 목록을 공식 `get_metadata` - // 와 같은 글로 쓴다. - assert!( - client - .can_serve(&ReadToolCall::metadata(FILE_KEY, Some("1:2"))) - .await - ); - assert!( - client - .can_serve(&ReadToolCall::metadata(FILE_KEY, None)) - .await - ); - // 다른 파일은 그 파일의 플러그인이 필요하다. - assert!( - !client - .can_serve(&ReadToolCall::fast_snapshot("OtherFile", "1:2")) - .await - ); -} - -/// 로그인을 요구할지 말지는 이 값이 정한다. -/// -/// 브리지는 Figma 한도도 자격증명도 쓰지 않으므로, 이 파일을 맡은 플러그인이 -/// 있으면 수집은 토큰 없이 성립한다. 판정은 파일 단위여야 한다 — 다른 파일을 -/// 열어 둔 플러그인이 붙어 있다고 해서 이 파일을 읽을 수 있는 것은 아니다. -#[tokio::test] -async fn a_plugin_holding_the_file_makes_it_readable_without_credentials() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let client = BridgeFigmaClient::new(server.state()); - - assert!(!client.serves_without_credentials(FILE_KEY).await); - - let _plugin = connect_plugin(&server).await; - assert!(client.serves_without_credentials(FILE_KEY).await); - assert!(!client.serves_without_credentials("OtherFile").await); -} - -/// 키 없이 붙은 플러그인도 혼자면 맡는다. -/// -/// `figma.fileKey` 는 늘 오는 값이 아니다. 실기기에서 비어 온 적이 있고, 그때 등록을 -/// 건너뛰었더니 플러그인 창은 "연결됨"이라고 하는데 읽기는 한 건도 도착하지 않았다. -/// 연결이 스스로를 멀쩡하다고 보고하는 실패라 원인을 찾기가 가장 어렵다. -#[tokio::test] -async fn a_plugin_without_a_file_key_serves_when_it_is_the_only_one() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let client = BridgeFigmaClient::new(server.state()); - let mut plugin = connect_plugin_as(&server, None).await; - - // 어느 파일을 물어도 이 하나가 답한다. 무엇을 열고 있는지 모르지만, 하나뿐이라 - // 그것이 열려 있는 파일이다. - assert!( - client - .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) - .await - ); - - let reading = { - let client = client.clone(); - tokio::spawn(async move { - client - .call_read_tool(ReadToolCall::page_catalog(FILE_KEY)) - .await - }) - }; - let job = next_job(&mut plugin).await; - assert_eq!(job["script"], "pageCatalog"); - plugin - .send(Message::Text( - json!({ - "kind": "devup-result", - "requestId": job["requestId"], - "data": { "ok": true }, - }) - .to_string() - .into(), - )) - .await - .expect("the result is sent"); - reading - .await - .expect("the read task finishes") - .expect("a keyless plugin still answers"); -} - -/// 키 없는 플러그인이 보낸 빠른 봉투는 물은 파일의 것으로 디코딩된다. -/// -/// 빠른 스냅샷과 테마 봉투는 키를 `source`·`snapshot` 안에 싣는다. 그 자리가 빈 -/// 채로 디코더에 가면 대상 불일치로 빠른 경로를 버리는데, 실기기에서 화면마다 그랬다. -#[tokio::test] -async fn a_keyless_plugins_fast_envelopes_decode_as_the_file_asked_for() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let client = BridgeFigmaClient::new(server.state()); - let mut plugin = connect_plugin_as(&server, None).await; - let target = devup_mcp_figma::FigmaTarget::parse(&format!( - "https://www.figma.com/design/{FILE_KEY}/x?node-id=1-1" - )) - .expect("a design link"); - - let reading = { - let client = client.clone(); - tokio::spawn(async move { - client - .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:1")) - .await - }) - }; - let job = next_job(&mut plugin).await; - let envelope = json!({ - "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, - "source": { "fileKey": "", "rootId": "1:1" }, - "snapshot": { "fileKey": "", "version": null, "rootIds": ["1:1"], - "nodes": [{ "id": "1:1", "type": "FRAME", "fields": { "childrenIds": [] } }], - "diagnostics": [] }, - "resources": { "collections": [], "variables": [], "styles": [], - "usedRemoteVariables": [], "localComplete": false, "usedRemoteComplete": true, - "unresolved": [] }, - "integrity": { "nodeCount": 1, "variableRefCount": 0, "styleRefCount": 0, "utf8Bytes": 0 } - }); - answer(&mut plugin, &job, envelope).await; - let result = reading - .await - .expect("the read task finishes") - .expect("the bridge answers"); - let decoded = devup_mcp_figma::decode_fast_snapshot(&result, &target) - .expect("a keyless plugin's envelope is the asked-for file's"); - assert_eq!(decoded.snapshot.root_ids, ["1:1"]); - - let reading = tokio::spawn(async move { - client - .call_read_tool(ReadToolCall::fast_theme(FILE_KEY)) - .await - }); - let job = next_job(&mut plugin).await; - let theme = json!({ - "kind": "devupFastThemeEnvelope", "schemaVersion": 1, - "source": { "fileKey": "", "version": null }, - "resources": { "collections": [], "variables": [], "styles": [], - "usedRemoteVariables": [], "usedVariableIds": [], "usedStyleIds": [], - "localComplete": true, "usedRemoteComplete": true, "unresolved": [] }, - "integrity": { "collectionCount": 0, "variableCount": 0, "styleCount": 0, - "unresolvedCount": 0, "utf8Bytes": 0 } - }); - answer(&mut plugin, &job, theme).await; - let result = reading - .await - .expect("the read task finishes") - .expect("the bridge answers"); - devup_mcp_figma::decode_fast_theme(&result, FILE_KEY) - .expect("a keyless plugin's theme envelope is the asked-for file's"); -} - -/// 키 없는 플러그인이 둘이면 아무도 맡지 않는다. -/// -/// 어느 쪽이 대상 파일을 열고 있는지 가릴 수 없다. 엉뚱한 문서를 읽어 주느니 요금을 -/// 쓰더라도 원격으로 넘기는 편이 낫다. -#[tokio::test] -async fn two_keyless_plugins_are_ambiguous_and_neither_serves() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let client = BridgeFigmaClient::new(server.state()); - - let _first = connect_plugin_as(&server, None).await; - // 다른 파일을 연 플러그인이 하나 더 붙으면 키 없는 쪽은 더는 혼자가 아니다. - // 둘 다 키가 없는 경우는 `two_keyless_plugins_are_counted_apart` 가 본다. - let _second = connect_plugin_as(&server, Some("AnotherFile")).await; - - assert!( - !client - .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) - .await - ); -} - -/// 플러그인 흉내: `hello` 를 그대로 보내고, 붙은 플러그인이 `count` 개가 될 -/// 때까지 기다린다. 키 없는 플러그인이 둘이면 `connected_files` 로는 둘째의 -/// 등록을 가릴 수 없어서 수로 기다린다. -async fn attach(server: &BridgeServer, hello: Value, count: usize) -> Plugin { - let (mut socket, _) = connect_async(format!("ws://127.0.0.1:{}/plugin", server.port())) - .await - .expect("bridge accepts a plugin"); - socket - .send(Message::Text(hello.to_string().into())) - .await - .expect("hello is sent"); - let state = server.state(); - for _ in 0..200 { - if state.attached_files().await.len() == count { - return socket; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("plugin never registered"); -} - -async fn answer(plugin: &mut Plugin, job: &Value, data: Value) { - plugin - .send(Message::Text( - json!({ "kind": "devup-result", "requestId": job["requestId"], "data": data }) - .to_string() - .into(), - )) - .await - .expect("the result is sent"); -} - -/// 플러그인이 보고하는 페이지와 선택은 `status` 가 그대로 보여 주고, url 없는 -/// 요청은 그것으로 대상을 고른다. 키를 보고하지 못한 플러그인에는 그 연결만 -/// 가리키는 키가 주어지고, 그 키로 보낸 읽기는 그 플러그인에 닿으며, 답에는 -/// 파일 키를 지어내지 않은 출처가 실린다. -#[tokio::test] -async fn a_keyless_plugin_reports_where_it_is_and_answers_its_own_connection() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = attach( - &server, - json!({ - "kind": "hello", "fileKey": null, "fileName": "Landing", - "currentPage": { "id": "0:1", "name": "Page 1" }, - "selection": [{ "id": "1:2", "name": "Hero", "type": "FRAME" }], - "selectionCount": 1, - }), - 1, - ) - .await; - let state = server.state(); - let attached = state.attached_files().await; - let file = &attached[0]; - assert_eq!(file.file_key, None); - assert_eq!(file.file_name.as_deref(), Some("Landing")); - assert_eq!( - file.context.single_selection().map(|node| node.id.as_str()), - Some("1:2") - ); - assert!(devup_mcp_figma::is_bridge_only_key(&file.target_key)); - - let client = BridgeFigmaClient::new(state.clone()).with_port(server.port()); - let call = ReadToolCall::fast_snapshot(file.target_key.clone(), "1:2"); - let reading = tokio::spawn(async move { client.call_read_tool(call).await }); - let job = next_job(&mut plugin).await; - assert_eq!(job["params"]["nodeId"], "1:2"); - answer(&mut plugin, &job, json!({ "fileKey": "", "nodes": [] })).await; - let result = reading.await.unwrap().expect("the plugin answers"); - let served = - serde_json::from_value::(result.raw["_meta"]["devup/bridge"].clone()) - .expect("a bridge answer names the plugin that gave it"); - assert_eq!(served.reads, 1); - assert_eq!(served.port, Some(server.port())); - assert_eq!( - served.file_key, None, - "no key is claimed for a keyless plugin" - ); - assert_eq!(served.file_name.as_deref(), Some("Landing")); - assert_eq!(served.page_name.as_deref(), Some("Page 1")); - - // A new selection reaches the server without a reconnect. - plugin - .send(Message::Text( - json!({ - "kind": "context", - "currentPage": { "id": "0:2", "name": "Page 2" }, - "selection": [], - "selectionCount": 0, - "inView": false, - }) - .to_string() - .into(), - )) - .await - .expect("the context is sent"); - for _ in 0..200 { - let context = state.attached_files().await[0].context.clone(); - if context.current_page.as_ref().map(|page| page.name.as_str()) == Some("Page 2") { - assert_eq!(context.selection, Some(vec![])); - assert_eq!(context.in_view, Some(false)); - return; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("the context update never arrived"); -} - -/// 키 없는 플러그인 둘은 두 개로 센다. 한 자리에 묶이면 "정확히 하나"를 셀 수 -/// 없고, 먼저 붙은 쪽이 끊길 때 나중 쪽의 등록까지 지워진다. 둘이면 아무 키도 -/// 맡지 않지만, 각 연결의 키는 여전히 제 플러그인에만 닿는다. -#[tokio::test] -async fn two_keyless_plugins_are_counted_apart() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let hello = json!({ "kind": "hello", "fileKey": null }); - let first = attach(&server, hello.clone(), 1).await; - let _second = attach(&server, hello, 2).await; - let state = server.state(); - let client = BridgeFigmaClient::new(state.clone()); - assert!( - !client - .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) - .await - ); - let attached = state.attached_files().await; - assert_eq!(attached.len(), 2); - for file in &attached { - assert!( - client - .can_serve(&ReadToolCall::fast_snapshot(file.target_key.clone(), "1:2")) - .await - ); - } - - drop(first); - for _ in 0..200 { - if state.attached_files().await.len() == 1 { - return; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("the first plugin's disconnect must leave the second attached"); -} - -/// 원격만 아는 상류. 몇 번 불렸는지만 센다. -struct CountingRemote { - calls: std::sync::Arc, -} - -#[async_trait::async_trait] -impl FigmaUpstream for CountingRemote { - async fn list_tools(&self) -> Result, DevupError> { - Ok(vec!["use_figma".to_owned()]) - } - - async fn call_read_tool(&self, _call: ReadToolCall) -> Result { - self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(UpstreamResult { raw: json!({}) }) - } -} - -/// 브리지만 읽을 수 있는 키는 원격으로 넘어가지 않는다. Figma 에 없는 키를 -/// 물어 봐야 원인과 무관한 거절만 돌아오고, 로그인이 필요 없는 요청에 로그인이 -/// 요구된다. -#[tokio::test] -async fn a_bridge_only_key_never_reaches_the_remote_path() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let remote_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let upstream = FallbackUpstream::new( - BridgeFigmaClient::new(server.state()), - CountingRemote { - calls: remote_calls.clone(), - }, - ); - assert!(upstream.serves_without_credentials("bridge:41").await); - let error = upstream - .call_read_tool(ReadToolCall::fast_snapshot("bridge:41", "1:2")) - .await - .expect_err("no plugin holds this connection"); - assert_eq!(error.details["stage"], "bridge-routing"); - let error = upstream - .call_read_tool(ReadToolCall::metadata("bridge:41", None)) - .await - .expect_err("no plugin holds this connection to list its pages"); - assert_eq!(error.details["tool"], "get_metadata"); - - assert_eq!(remote_calls.load(std::sync::atomic::Ordering::SeqCst), 0); - - // An ordinary key still falls through when no plugin holds it. - upstream - .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) - .await - .expect("the remote path answers"); - assert_eq!(remote_calls.load(std::sync::atomic::Ordering::SeqCst), 1); -} - -/// referencePng 도 플러그인이 맡는다. 플러그인은 노드를 PNG 로 export 하고, 브리지는 -/// 그것을 공식 `get_screenshot` 처럼 이미지 블록 하나로 돌려준다 — 수집기가 두 경로를 -/// 같은 디코더로 받는다. 플러그인이 그 그림에 단 말은 `_meta` 에 실려 수집기로 간다. -#[tokio::test] -async fn a_reference_png_is_exported_by_the_plugin_and_comes_back_as_one_image() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let client = BridgeFigmaClient::new(server.state()); - let call = ReadToolCall::screenshot(FILE_KEY, "1:2"); - assert!(client.can_serve(&call).await); - let reading = tokio::spawn(async move { client.call_read_tool(call).await }); - - let job = next_job(&mut plugin).await; - assert_eq!(job["script"], "referencePng"); - assert_eq!(job["params"]["nodeId"], "1:2"); - let note = "INSTANCE \"Header\" reaches into it and may show here"; - answer( - &mut plugin, - &job, - json!({ "kind": "devupReferencePng", "nodeId": "1:2", "mimeType": "image/png", - "byteLength": 4, "data": "iVBORw==", "note": note }), - ) - .await; - - let result = reading - .await - .expect("the read task finishes") - .expect("the bridge answers"); - assert_eq!(result.raw["isError"], false); - assert_eq!( - result.raw["content"], - json!([{ "type": "image", "data": "iVBORw==", "mimeType": "image/png" }]) - ); - assert!(result.raw["_meta"]["devup/bridge"].is_object()); - assert_eq!(result.raw["_meta"]["devup/referencePngNote"], note); -} - -fn png_base64(width: u32, height: u32, rgba: Vec) -> String { - use base64::Engine as _; - let pixels = image::RgbaImage::from_raw(width, height, rgba).expect("pixels fill the image"); - let mut encoded = Vec::new(); - pixels - .write_with_encoder(image::codecs::png::PngEncoder::new(&mut encoded)) - .expect("a PNG encodes"); - base64::engine::general_purpose::STANDARD.encode(encoded) -} - -/// `get_screenshot` 은 프레임의 반투명한 자리를 캔버스로 채운 불투명한 PNG 를 준다. -/// 플러그인의 export 는 알파를 그대로 두므로, 브리지가 플러그인이 알려 준 페이지 배경 -/// 위로 덮는다. 처음부터 불투명한 export 는 다시 인코딩하지 않는다. -#[tokio::test] -async fn a_translucent_reference_png_is_flattened_onto_the_page_background() { - use base64::Engine as _; - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let read = |plugin_answer: Value| { - let client = BridgeFigmaClient::new(server.state()); - ( - tokio::spawn(async move { - client - .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) - .await - }), - plugin_answer, - ) - }; - let answer_with = |data: String| { - json!({ "kind": "devupReferencePng", "nodeId": "1:2", "mimeType": "image/png", - "byteLength": 0, "data": data, "backdrop": [30, 30, 30] }) - }; - - let (reading, plugin_answer) = read(answer_with(png_base64( - 2, - 1, - vec![0, 0, 0, 178, 255, 255, 255, 255], - ))); - let job = next_job(&mut plugin).await; - answer(&mut plugin, &job, plugin_answer).await; - let result = reading.await.expect("the read finishes").expect("answered"); - let data = result.raw["content"][0]["data"].as_str().expect("an image"); - let decoded = image::load_from_memory( - &base64::engine::general_purpose::STANDARD - .decode(data) - .expect("base64"), - ) - .expect("a PNG") - .into_rgba8(); - assert_eq!(decoded.into_raw(), vec![9, 9, 9, 255, 255, 255, 255, 255]); - - let opaque = png_base64(1, 1, vec![10, 20, 30, 255]); - let (reading, plugin_answer) = read(answer_with(opaque.clone())); - let job = next_job(&mut plugin).await; - answer(&mut plugin, &job, plugin_answer).await; - let result = reading.await.expect("the read finishes").expect("answered"); - assert_eq!(result.raw["content"][0]["data"], opaque); -} - -/// 플러그인이 시도하지 않았거나 답 하나에 담지 못한 읽기 — 이 devup-mcp 보다 먼저 -/// 빌드돼 스크립트를 모르거나, PNG 가 답 하나보다 크다 — 는 원격이 읽을 수 있는 키면 -/// 원격이 대신 답하고, 플러그인만 아는 키면 그 답이 할 일과 함께 올라간다. -#[tokio::test] -async fn a_plugin_older_than_the_server_leaves_a_keyed_read_to_the_remote_path() { - let older = "DEVUP_BRIDGE_UNKNOWN_SCRIPT: referencePng (아는 스크립트: assets, snapshot)"; - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let remote_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let upstream = std::sync::Arc::new(FallbackUpstream::new( - BridgeFigmaClient::new(server.state()), - CountingRemote { - calls: remote_calls.clone(), - }, - )); - - for (asked, refusal) in [ - older, - "DEVUP_REFERENCE_PNG_TOO_LARGE 9000000 bytes, over the 8388608 one answer carries", - ] - .into_iter() - .enumerate() - { - let reading = { - let upstream = upstream.clone(); - tokio::spawn(async move { - upstream - .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) - .await - }) - }; - let job = next_job(&mut plugin).await; - plugin - .send(Message::Text( - json!({ "kind": "devup-result", "requestId": job["requestId"], "error": refusal }) - .to_string() - .into(), - )) - .await - .expect("the refusal is sent"); - reading - .await - .expect("the read task finishes") - .expect("the remote path answers in the plugin's place"); - assert_eq!( - remote_calls.load(std::sync::atomic::Ordering::SeqCst), - asked + 1, - "{refusal}" - ); - } - remote_calls.store(1, std::sync::atomic::Ordering::SeqCst); - - let keyless = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut lone = connect_plugin_as(&keyless, None).await; - let target = keyless.state().attached_files().await[0].target_key.clone(); - let upstream = std::sync::Arc::new(FallbackUpstream::new( - BridgeFigmaClient::new(keyless.state()), - CountingRemote { - calls: remote_calls.clone(), - }, - )); - let reading = tokio::spawn(async move { - upstream - .call_read_tool(ReadToolCall::screenshot(target, "1:2")) - .await - }); - let job = next_job(&mut lone).await; - lone.send(Message::Text( - json!({ "kind": "devup-result", "requestId": job["requestId"], "error": older }) - .to_string() - .into(), - )) - .await - .expect("the refusal is sent"); - let result = reading - .await - .expect("the read task finishes") - .expect("the plugin's refusal is its answer"); - assert_eq!(result.raw["isError"], true); - assert_eq!(remote_calls.load(std::sync::atomic::Ordering::SeqCst), 1); -} - -struct SignedOutRemote; - -#[async_trait::async_trait] -impl FigmaUpstream for SignedOutRemote { - async fn list_tools(&self) -> Result, DevupError> { - Ok(Vec::new()) - } - - async fn call_read_tool(&self, _call: ReadToolCall) -> Result { - Err(DevupError::new( - ErrorCode::DevupAuthRequired, - "Figma login is required.", - false, - )) - } - - async fn has_credentials(&self) -> bool { - false - } -} - -/// 브리지 답 하나에 실리지 않는 참조 PNG 는 원격에 묻는다. 원격이 로그인 없이 거절하면 -/// 답은 로그인 요구가 아니라 플러그인의 거절이다. -#[tokio::test] -async fn a_signed_out_remote_leaves_the_plugins_refusal_standing() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let upstream = FallbackUpstream::new(BridgeFigmaClient::new(server.state()), SignedOutRemote); - let reading = tokio::spawn(async move { - upstream - .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) - .await - }); - let job = next_job(&mut plugin).await; - plugin - .send(Message::Text( - json!({ "kind": "devup-result", "requestId": job["requestId"], - "error": "DEVUP_REFERENCE_PNG_TOO_LARGE 9000000 bytes, over the 8388608 one answer carries" }) - .to_string() - .into(), - )) - .await - .expect("the refusal is sent"); - let result = reading - .await - .expect("the read task finishes") - .expect("the plugin's refusal is the answer"); - assert_eq!(result.raw["isError"], true); - assert!( - result.raw["content"][0]["text"] - .as_str() - .is_some_and(|text| text.contains("DEVUP_REFERENCE_PNG_TOO_LARGE")), - "{}", - result.raw - ); -} - -/// 노드 없는 metadata 읽기 — 파일의 최상위 페이지 목록 — 도 플러그인이 맡는다. -/// 플러그인의 페이지 목록을 공식 `get_metadata` 가 쓰는 글로 옮겨, 수집기가 두 경로의 -/// 답을 같은 파서로 받는다. -#[tokio::test] -async fn a_files_page_list_comes_from_the_plugins_page_catalog() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let client = BridgeFigmaClient::new(server.state()); - let reading = tokio::spawn(async move { - client - .call_read_tool(ReadToolCall::metadata(FILE_KEY, None)) - .await - }); - let job = next_job(&mut plugin).await; - assert_eq!(job["script"], "pageCatalog"); - answer( - &mut plugin, - &job, - json!({ "fileKey": FILE_KEY, "nodes": [ - { "id": "0:1", "type": "PAGE", "fields": { "name": "Cover" } }, - { "id": "5:2", "type": "PAGE", "fields": { "name": "Screens" } }, - ]}), - ) - .await; - let result = reading - .await - .expect("the read task finishes") - .expect("the bridge answers"); - assert_eq!( - result.raw["content"][0]["text"], - "Top-level pages of the document:\n- 0:1: Cover\n- 5:2: Screens\n" - ); - assert!(result.raw["_meta"]["devup/bridge"].is_object()); -} - -/// JSON 을 플러그인이 붙들어 둘 때처럼 ASCII 로 쓴다 — 그 밖의 글자는 `\uXXXX`. -fn ascii_json(value: &Value) -> String { - let mut text = String::new(); - for unit in serde_json::to_string(value) - .expect("JSON encodes") - .encode_utf16() - { - match u8::try_from(unit) { - Ok(byte) if byte.is_ascii() => text.push(char::from(byte)), - _ => text.push_str(&format!("\\u{unit:04x}")), - } - } - text -} - -/// 브리지 답 하나에 들어가지 않는 참조 PNG 답은 플러그인이 붙들어 두고 조각으로 -/// 내준다. 브리지는 조각을 이어 받아 하나로 되돌리고, 그 답을 평소처럼 감싼다 — 노트의 -/// 한글 레이어 이름까지 그대로. -#[tokio::test] -async fn a_held_reference_answer_is_read_back_in_pieces() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let client = BridgeFigmaClient::new(server.state()); - let opaque = png_base64(1, 1, vec![10, 20, 30, 255]); - let note = "TEXT \"공지 제목\" reaches into it and may show here"; - let whole = ascii_json(&json!({ "kind": "devupReferencePng", "nodeId": "1:2", - "mimeType": "image/png", "byteLength": 0, "data": opaque, "note": note })); - let reading = tokio::spawn(async move { - client - .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) - .await - }); - - let job = next_job(&mut plugin).await; - assert_eq!(job["script"], "referencePng"); - answer( - &mut plugin, - &job, - json!({ "kind": "devupReferencePngChunked", "nodeId": "1:2", "id": "1:2@1", - "length": whole.len() }), - ) - .await; - let mut offset = 0; - while offset < whole.len() { - let job = next_job(&mut plugin).await; - assert_eq!(job["script"], "referencePngChunk"); - assert_eq!(job["params"]["largeValue"]["id"], "1:2@1"); - assert_eq!(job["params"]["largeValue"]["offset"], offset); - let end = (offset + 40).min(whole.len()); - answer( - &mut plugin, - &job, - json!({ "kind": "devupReferencePngChunk", "id": "1:2@1", "offset": offset, - "nextOffset": end, "complete": end == whole.len(), "text": &whole[offset..end] }), - ) - .await; - offset = end; - } - - let result = reading - .await - .expect("the read task finishes") - .expect("the bridge answers"); - assert_eq!(result.raw["content"][0]["data"], opaque); - assert_eq!(result.raw["_meta"]["devup/referencePngNote"], note); -} - -/// 다른 레이어가 노드에 닿으면 플러그인은 노드만 export 하고 조상들을 설명한다. -/// 브리지가 그 나머지를 그려 `get_screenshot` 이 그리는 그림을 만든다 — 캔버스 export 는 -/// 그리지 못할 때를 위해 함께 올 뿐 쓰이지 않는다. -#[tokio::test] -async fn an_overlapped_node_is_composed_over_its_ancestors() { - use base64::Engine as _; - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let client = BridgeFigmaClient::new(server.state()); - let reading = tokio::spawn(async move { - client - .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) - .await - }); - let job = next_job(&mut plugin).await; - answer( - &mut plugin, - &job, - json!({ "kind": "devupReferencePng", "nodeId": "1:2", "mimeType": "image/png", - "byteLength": 0, "data": png_base64(2, 1, vec![0, 0, 0, 255, 0, 0, 0, 255]), - "backdrop": [30, 30, 30], "reaching": "TEXT \"Caption\"", - "compose": { - "area": { "x": 0, "y": 0, "width": 2, "height": 1 }, "scale": 1, - "width": 2, "height": 1, "page": [30, 30, 30], - "layers": [{ "transform": [[1, 0, 0], [0, 1, 0]], "width": 2, "height": 1, - "fills": [{ "type": "SOLID", "color": { "r": 1, "g": 0, "b": 0 } }] }], - "node": { "picture": { "data": png_base64(1, 1, vec![255, 255, 255, 255]), - "area": { "x": 1, "y": 0, "width": 1, "height": 1 } } }, - "images": {} - } }), - ) - .await; - let result = reading - .await - .expect("the read task finishes") - .expect("the bridge answers"); - let data = result.raw["content"][0]["data"].as_str().expect("an image"); - let decoded = image::load_from_memory( - &base64::engine::general_purpose::STANDARD - .decode(data) - .expect("base64"), - ) - .expect("a PNG") - .into_rgba8(); - assert_eq!(decoded.into_raw(), vec![255, 0, 0, 255, 255, 255, 255, 255]); - assert!(result.raw["_meta"].get("devup/referencePngNote").is_none()); - assert!( - result.raw["_meta"] - .get("devup/referencePngApproximate") - .is_none() - ); -} - -/// 원격 경로로 다시 읽으려 한 횟수를 센다. `room` 이 거짓이면 한도 자리가 없다고 답한다. -struct CountingMeter { - calls: std::sync::Arc, - room: bool, -} - -#[async_trait::async_trait] -impl devup_mcp_figma::Meter for CountingMeter { - async fn acquire_within(&self, _budget: std::time::Duration) -> bool { - self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - self.room - } -} - -fn meter( - calls: &std::sync::Arc, - room: bool, -) -> std::sync::Arc { - std::sync::Arc::new(CountingMeter { - calls: calls.clone(), - room, - }) -} - -/// 정해 둔 답을 내는 원격. 몇 번 불렸는지 센다. -struct FixedRemote { - calls: std::sync::Arc, - raw: Value, -} - -#[async_trait::async_trait] -impl FigmaUpstream for FixedRemote { - async fn list_tools(&self) -> Result, DevupError> { - Ok(vec!["use_figma".to_owned()]) - } - - async fn call_read_tool(&self, _call: ReadToolCall) -> Result { - self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(UpstreamResult { - raw: self.raw.clone(), - }) - } -} - -struct Counts { - remote: std::sync::Arc, - metered: std::sync::Arc, -} - -impl Counts { - fn new() -> Self { - Self { - remote: std::sync::Arc::default(), - metered: std::sync::Arc::default(), - } - } - - fn get(&self) -> (usize, usize) { - use std::sync::atomic::Ordering::SeqCst; - (self.remote.load(SeqCst), self.metered.load(SeqCst)) - } -} - -/// 플러그인에 `call` 을 보내 `plugin_answer` 로 답하게 하고, 그 뒤의 결과를 돌려준다. -async fn through_fallback( - upstream: FallbackUpstream, - plugin: &mut Plugin, - call: ReadToolCall, - plugin_answer: Value, -) -> UpstreamResult { - let reading = tokio::spawn(async move { upstream.call_read_tool(call).await }); - let job = next_job(plugin).await; - let mut message = plugin_answer; - message["kind"] = json!("devup-result"); - message["requestId"] = job["requestId"].clone(); - plugin - .send(Message::Text(message.to_string().into())) - .await - .expect("the answer is sent"); - reading - .await - .expect("the read task finishes") - .expect("an answer comes back") -} - -fn fixed_fallback( - server: &BridgeServer, - counts: &Counts, - raw: Value, -) -> FallbackUpstream { - FallbackUpstream::new( - BridgeFigmaClient::new(server.state()), - FixedRemote { - calls: counts.remote.clone(), - raw, - }, - ) - .with_meter(meter(&counts.metered, true)) -} - -const UNREACHABLE: &str = "Unable to establish connection to Figma after 10 seconds. Please check your internet connection."; - -/// 파일이 화면에 보이지 않으면 Figma 는 불러오지 않은 데이터를 플러그인에게 주지 -/// 않는다. 그 말이 섞인 플러그인의 답은 원격 경로가 다시 읽는다 — 원격은 그 상태와 -/// 무관하다. 한도를 쓰는 읽기라 속도에 맞춰 보내고, 왜 다시 읽었는지 결과에 남긴다. -#[tokio::test] -async fn a_read_figma_withheld_out_of_sight_is_read_again_on_the_direct_path() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let short = json!({ "data": { "fileKey": FILE_KEY, "resources": { "unresolved": [ - { "id": "VariableID:1:2", "reason": "notFoundOrUnavailable", "error": UNREACHABLE }] } } }); - - let counts = Counts::new(); - let direct = json!({ "content": [{ "type": "text", "text": "{\"fileKey\":\"FileKey123\"}" }] }); - let result = through_fallback( - fixed_fallback(&server, &counts, direct.clone()), - &mut plugin, - ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), - short.clone(), - ) - .await; - assert_eq!(result.raw["content"], direct["content"]); - assert_eq!( - result.raw["_meta"]["devup/directFallback"], - "file-out-of-sight" - ); - assert_eq!(counts.get(), (1, 1)); - - // 원격의 답이 공식 MCP 의 20 KB 에서 잘렸으면 디코딩되지 않으니 플러그인의 답을 쓴다. - let counts = Counts::new(); - let truncated = json!({ "content": [{ "type": "text", - "text": "{\"fileKey\":\"FileKey123\",\"nodes\":[ // truncated to 20kb" }] }); - let result = through_fallback( - fixed_fallback(&server, &counts, truncated), - &mut plugin, - ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), - short.clone(), - ) - .await; - assert!( - result.raw["content"][0]["text"] - .as_str() - .is_some_and(|text| text.contains("notFoundOrUnavailable")), - "{}", - result.raw - ); - assert_eq!(counts.get(), (1, 1)); - - // 한도에 걸렸다는 문장은 `isError` 없이도 온다. 그것도 쓸 수 없는 답이니 플러그인의 답을 쓴다. - let counts = Counts::new(); - let refusal = json!({ "content": [{ "type": "text", - "text": "You've reached the tool call limit for your plan. Try again later." }] }); - let result = through_fallback( - fixed_fallback(&server, &counts, refusal), - &mut plugin, - ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), - short.clone(), - ) - .await; - assert!( - result.raw["_meta"]["devup/bridge"].is_object(), - "{}", - result.raw - ); - assert_eq!(counts.get(), (1, 1)); - - // 정해진 시간 안에 한도 자리가 나지 않으면 기다리지 않고 플러그인의 답을 쓴다 — 이미 받은 - // 답을 읽기 시간 초과로 잃지 않는다. - let counts = Counts::new(); - let upstream = FallbackUpstream::new( - BridgeFigmaClient::new(server.state()), - FixedRemote { - calls: counts.remote.clone(), - raw: direct.clone(), - }, - ) - .with_meter(meter(&counts.metered, false)); - let result = through_fallback( - upstream, - &mut plugin, - ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), - short.clone(), - ) - .await; - assert!( - result.raw["_meta"]["devup/bridge"].is_object(), - "{}", - result.raw - ); - assert_eq!(counts.get(), (0, 1)); - - // 디자인의 글에 같은 문장이 있다고 다시 읽지 않는다. 오류를 싣는 자리에 있을 때만이다. - let counts = Counts::new(); - let quoted = json!({ "data": { "fileKey": FILE_KEY, "nodes": [{ "id": "1:2", "type": "TEXT", - "fields": { "characters": UNREACHABLE } }] } }); - let result = through_fallback( - fixed_fallback(&server, &counts, direct.clone()), - &mut plugin, - ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), - quoted, - ) - .await; - assert!( - result.raw["_meta"]["devup/bridge"].is_object(), - "{}", - result.raw - ); - assert_eq!(counts.get(), (0, 0)); - - // 로그인이 없으면 원격은 시도도 되지 않는다. 속도에 맞춰 기다리지 않고 플러그인의 답을 쓴다. - let metered = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let upstream = FallbackUpstream::new(BridgeFigmaClient::new(server.state()), SignedOutRemote) - .with_meter(meter(&metered, true)); - let result = through_fallback( - upstream, - &mut plugin, - ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), - short, - ) - .await; - assert!(result.raw["_meta"]["devup/bridge"].is_object()); - assert_eq!(metered.load(std::sync::atomic::Ordering::SeqCst), 0); -} - -/// `wait` 만큼 기다려야 자리가 나는 한도. `CallPacer` 처럼, 주어진 시간 안에 자리가 나지 -/// 않으면 기다리지 않고 거절한다. -struct PacedMeter { - wait: std::time::Duration, - calls: std::sync::Arc, -} - -#[async_trait::async_trait] -impl devup_mcp_figma::Meter for PacedMeter { - async fn acquire_within(&self, budget: std::time::Duration) -> bool { - self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if self.wait > budget { - return false; - } - tokio::time::sleep(self.wait).await; - true - } -} - -/// `delay` 뒤에 `get_screenshot` 과 다 같게 그리지 못한 그림으로 답하는 플러그인. -struct SlowPlugin { - delay: std::time::Duration, -} - -#[async_trait::async_trait] -impl FigmaUpstream for SlowPlugin { - async fn list_tools(&self) -> Result, DevupError> { - Ok(vec!["use_figma".to_owned()]) - } - - async fn call_read_tool(&self, _call: ReadToolCall) -> Result { - tokio::time::sleep(self.delay).await; - Ok(UpstreamResult { - raw: json!({ "content": [{ "type": "image", "data": "cGx1Z2lu", "mimeType": "image/png" }], - "_meta": { "devup/referencePngApproximate": "an ancestor's stroke" } }), - }) - } -} - -#[async_trait::async_trait] -impl PreferredUpstream for SlowPlugin { - async fn can_serve(&self, _call: &ReadToolCall) -> bool { - true - } - - fn is_live(&self) -> bool { - true - } -} - -/// 로그인을 확인하는 데 `sign_in` 이 걸리는 원격. 늘 정해 둔 그림으로 답한다. -struct SlowSignIn { - sign_in: std::time::Duration, - calls: std::sync::Arc, -} - -#[async_trait::async_trait] -impl FigmaUpstream for SlowSignIn { - async fn list_tools(&self) -> Result, DevupError> { - Ok(vec!["use_figma".to_owned()]) - } - - async fn call_read_tool(&self, _call: ReadToolCall) -> Result { - self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(UpstreamResult { - raw: json!({ "content": [{ "type": "image", "data": "ZGlyZWN0", "mimeType": "image/png" }] }), - }) - } - - async fn has_credentials(&self) -> bool { - tokio::time::sleep(self.sign_in).await; - true - } -} - -/// 원격으로 다시 읽는 일 — 로그인 확인, 한도 자리, 원격의 답 — 은 읽기 하나의 시간 -/// (`READ_TIME_LIMIT`)에서 플러그인이 쓰고 남은 만큼만 한다. 60초 걸려 온 플러그인의 -/// 그림은, 35초를 기다려야 자리가 나는 한도나 35초 걸리는 로그인 확인 때문에 읽기의 시간 -/// 제한에서 잃지 않는다. 시간이 남았으면 그대로 원격이 다시 읽는다. -#[tokio::test(start_paused = true)] -async fn re_reading_on_the_direct_path_fits_in_what_the_plugin_left_of_the_read() { - for (plugin_took, meter_wait, sign_in, counted) in [ - (60, 35, 0, (0, 1)), - (60, 0, 35, (0, 0)), - (10, 35, 0, (1, 1)), - ] { - let counts = Counts::new(); - let upstream = FallbackUpstream::new( - SlowPlugin { - delay: std::time::Duration::from_secs(plugin_took), - }, - SlowSignIn { - sign_in: std::time::Duration::from_secs(sign_in), - calls: counts.remote.clone(), - }, - ) - .with_meter(std::sync::Arc::new(PacedMeter { - wait: std::time::Duration::from_secs(meter_wait), - calls: counts.metered.clone(), - })); - let result = tokio::time::timeout( - devup_mcp_figma::READ_TIME_LIMIT, - upstream.call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:2")), - ) - .await - .expect("answered within the read's time") - .expect("an answer"); - let expected = if counted.0 == 0 { - "cGx1Z2lu" - } else { - "ZGlyZWN0" - }; - let case = format!("plugin {plugin_took} s, meter {meter_wait} s, sign-in {sign_in} s"); - assert_eq!(result.raw["content"][0]["data"], expected, "{case}"); - assert_eq!(counts.get(), counted, "{case}"); - } -} - -/// 플러그인이 `get_screenshot` 과 같은 그림을 그리지 못하면 — 여기서는 그릴 수 없는 -/// 칠 — 캔버스 그림에 이유를 달고, 원격이 열려 있으면 원격의 `get_screenshot` 이 대신 -/// 답한다. 열려 있지 않으면 캔버스 그림과 그 이유가 답이다. -#[tokio::test] -async fn a_picture_the_plugin_cannot_compose_goes_to_get_screenshot() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let canvas = png_base64(1, 1, vec![10, 20, 30, 255]); - let uncomposable = json!({ "data": { "kind": "devupReferencePng", "nodeId": "1:2", - "mimeType": "image/png", "byteLength": 0, "data": canvas, "backdrop": [30, 30, 30], - "reaching": "TEXT \"Caption\"", - "compose": { "area": { "x": 0, "y": 0 }, "width": 1, "height": 1, "page": [30, 30, 30], - "layers": [{ "transform": [[1, 0, 0], [0, 1, 0]], "width": 1, "height": 1, - "fills": [{ "type": "VIDEO" }] }], - "node": { "picture": { "data": canvas, "area": { "x": 0, "y": 0 } } } } } }); - - let counts = Counts::new(); - let shot = json!({ "content": [{ "type": "image", "data": "aWdub3JlZA==", "mimeType": "image/png" }] }); - let result = through_fallback( - fixed_fallback(&server, &counts, shot.clone()), - &mut plugin, - ReadToolCall::screenshot(FILE_KEY, "1:2"), - uncomposable.clone(), - ) - .await; - assert_eq!(result.raw["content"], shot["content"]); - assert_eq!( - result.raw["_meta"]["devup/directFallback"], - "reference-png-approximate" - ); - assert_eq!(counts.get(), (1, 1)); - - let upstream = FallbackUpstream::new(BridgeFigmaClient::new(server.state()), SignedOutRemote); - let result = through_fallback( - upstream, - &mut plugin, - ReadToolCall::screenshot(FILE_KEY, "1:2"), - uncomposable, - ) - .await; - assert_eq!(result.raw["content"][0]["data"], canvas); - let note = result.raw["_meta"]["devup/referencePngNote"] - .as_str() - .expect("the canvas picture says why"); - assert!( - note.contains("VIDEO") && note.contains("TEXT \"Caption\""), - "{note}" - ); -} - -/// 플러그인이 답하지 못해 원격이 읽은 자산의 이어 읽기는, 플러그인이 다시 export 한 -/// 바이트가 원격의 해시와 다르면 원격이 답한다. 그 바이트는 원격의 렌더러에서만 나온다. -#[tokio::test] -async fn a_value_the_direct_path_produced_is_read_on_from_it() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let counts = Counts::new(); - let fragment = json!({ "content": [{ "type": "text", "text": "{\"kind\":\"devupLargeValueFragment\"}" }] }); - let call = ReadToolCall::large_value( - FILE_KEY, - devup_mcp_figma::LargeValueReadOptions { - node_id: "1:2".to_owned(), - field: "$export/png/1".to_owned(), - offset: 8192, - max_chunk_bytes: 8192, - byte_length: 20_000, - sha256: "0".repeat(64), - version: None, - }, - ); - let result = through_fallback( - fixed_fallback(&server, &counts, fragment.clone()), - &mut plugin, - call, - json!({ "error": "DEVUP_LARGE_VALUE_CHANGED" }), - ) - .await; - assert_eq!(result.raw["content"], fragment["content"]); - assert_eq!( - result.raw["_meta"]["devup/directFallback"], - "value-from-direct-path" - ); - assert_eq!(counts.get(), (1, 1)); -} - -/// 플러그인이 큰 답들을 붙들고 있어 바쁘다고 하면 브리지는 잠시 뒤 다시 묻는다. 붙들어 둔 -/// 답이 읽기 전에 사라졌으면(플러그인이 다시 실행되는 등) 한 번 다시 받는다. 어느 쪽도 실패가 -/// 아니다. -#[tokio::test] -async fn a_busy_plugin_or_a_vanished_answer_is_asked_again() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let opaque = png_base64(1, 1, vec![10, 20, 30, 255]); - let picture = json!({ "kind": "devupReferencePng", "nodeId": "1:2", "mimeType": "image/png", - "byteLength": 0, "data": opaque }); - let refuse = |job: &Value, error: &str| { - Message::Text( - json!({ "kind": "devup-result", "requestId": job["requestId"], "error": error }) - .to_string() - .into(), - ) - }; - - let client = BridgeFigmaClient::new(server.state()); - let reading = tokio::spawn(async move { - client - .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) - .await - }); - let job = next_job(&mut plugin).await; - plugin - .send(refuse( - &job, - "DEVUP_REFERENCE_PNG_BUSY 201326592 characters are held for other reads", - )) - .await - .expect("busy is sent"); - let job = next_job(&mut plugin).await; - assert_eq!(job["script"], "referencePng"); - answer(&mut plugin, &job, picture.clone()).await; - let result = reading.await.expect("the read finishes").expect("answered"); - assert_eq!(result.raw["content"][0]["data"], opaque); - - let client = BridgeFigmaClient::new(server.state()); - let reading = tokio::spawn(async move { - client - .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) - .await - }); - let job = next_job(&mut plugin).await; - answer( - &mut plugin, - &job, - json!({ "kind": "devupReferencePngChunked", "nodeId": "1:2", "id": "1:2@1", "length": 10 }), - ) - .await; - let job = next_job(&mut plugin).await; - assert_eq!(job["script"], "referencePngChunk"); - plugin - .send(refuse(&job, "DEVUP_REFERENCE_PNG_ANSWER_GONE 1:2@1")) - .await - .expect("gone is sent"); - let job = next_job(&mut plugin).await; - assert_eq!(job["script"], "referencePng"); - answer(&mut plugin, &job, picture).await; - let result = reading.await.expect("the read finishes").expect("answered"); - assert_eq!(result.raw["content"][0]["data"], opaque); -} - -/// 플러그인이 답하지 못한 읽기(예전 플러그인이 모르는 스크립트)를 원격으로 넘기려는데 정해진 -/// 시간 안에 한도 자리가 나지 않으면, 한도가 찬 것으로 알려 수집이 기다렸다 다시 하게 한다. -#[tokio::test] -async fn a_read_the_plugin_cannot_answer_waits_for_room_rather_than_fail() { - let server = BridgeServer::start(0).expect("an ephemeral port is free"); - let mut plugin = connect_plugin(&server).await; - let counts = Counts::new(); - let upstream = FallbackUpstream::new( - BridgeFigmaClient::new(server.state()), - FixedRemote { - calls: counts.remote.clone(), - raw: json!({ "content": [] }), - }, - ) - .with_meter(meter(&counts.metered, false)); - let reading = tokio::spawn(async move { - upstream - .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) - .await - }); - let job = next_job(&mut plugin).await; - plugin - .send(Message::Text( - json!({ "kind": "devup-result", "requestId": job["requestId"], - "error": "DEVUP_BRIDGE_UNKNOWN_SCRIPT: referencePng (아는 스크립트: snapshot)" }) - .to_string() - .into(), - )) - .await - .expect("the refusal is sent"); - let error = reading - .await - .expect("the read finishes") - .expect_err("there was no room to read it on the metered path"); - assert_eq!(error.code, ErrorCode::DevupFigmaRateLimited); - assert!(error.retryable); - assert_eq!(counts.get(), (0, 1)); -} - -#[tokio::test] -async fn original_upload_crosses_the_socket_without_remote_text_truncation() { - use base64::{Engine as _, engine::general_purpose::STANDARD}; - use devup_mcp_figma::{AssetRequest, original_image_from_result}; - use sha2::{Digest, Sha256}; - - let server = BridgeServer::start(0).unwrap(); - let mut plugin = connect_plugin(&server).await; - let client = BridgeFigmaClient::new(server.state()); - let request = AssetRequest::original_image("n", 2, "hash"); - let call = ReadToolCall::asset_export(FILE_KEY, Some("v1".into()), request.clone()); - let reading = tokio::spawn(async move { client.call_read_tool(call).await }); - let job = next_job(&mut plugin).await; - assert_eq!(job["script"], "assets"); - assert_eq!(job["params"]["asset"]["transport"], "bridge"); - assert_eq!(job["params"]["asset"]["field"], "$original-image/fills/2"); - let mut bytes = vec![31u8; 1_100_000]; - bytes[..3].copy_from_slice(&[255, 216, 255]); - let hash: String = Sha256::digest(&bytes) - .iter() - .map(|b| format!("{b:02x}")) - .collect(); - plugin - .send(Message::Text( - json!({ - "kind":"devup-result", "requestId":job["requestId"], - "data":{"kind":"devupOriginalImage", "representation":"original-image-v1", - "fileKey":FILE_KEY, "version":"v1", "nodeId":"n", "assetId":request.asset_id, - "field":request.field, "imageHash":"hash", "status":"exported", - "format":null, "scale":null, "mimeType":"image/jpeg", "width":100, "height":100, - "byteLength":bytes.len(), "sha256":hash, "data":STANDARD.encode(&bytes)} - }) - .to_string() - .into(), - )) - .await - .unwrap(); - let result = reading.await.unwrap().unwrap(); - let original = original_image_from_result(&result, FILE_KEY, Some("v1"), &request).unwrap(); - assert_eq!(original.bytes, bytes); -} +// Source sections share this module to preserve privacy and test names. +include!("bridge_transport/fixtures_and_cases.rs"); +include!("bridge_transport/impl.rs"); diff --git a/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs b/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs new file mode 100644 index 00000000..9be69826 --- /dev/null +++ b/crates/devup-mcp-figma/tests/bridge_transport/fixtures_and_cases.rs @@ -0,0 +1,985 @@ + +use devup_mcp_figma::{ + BridgeFigmaClient, BridgeServed, BridgeServer, DevupError, ErrorCode, FallbackUpstream, + FigmaUpstream, PreferredUpstream, ReadToolCall, UpstreamResult, +}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::net::TcpStream; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message}; + +const FILE_KEY: &str = "FileKey123"; + +type Plugin = WebSocketStream>; + +/// 플러그인 흉내: 붙어서 자기 파일을 알리고, 등록될 때까지 기다린다. +/// +/// `file_key` 가 `None` 이면 키 없이 붙는다. 지어낸 경우가 아니라 실제로 일어난다 — +/// 실기기에서 `figma.fileKey` 가 비어 오는 것을 확인했다. +async fn connect_plugin_as(server: &BridgeServer, file_key: Option<&str>) -> Plugin { + let (mut socket, _) = connect_async(format!("ws://127.0.0.1:{}/plugin", server.port())) + .await + .expect("bridge accepts a plugin"); + socket + .send(Message::Text( + json!({ "kind": "hello", "fileKey": file_key }) + .to_string() + .into(), + )) + .await + .expect("hello is sent"); + + // 등록은 서버 쪽 태스크에서 일어난다. 보낸 직후에는 아직일 수 있다. + let expected = file_key.unwrap_or_default(); + let state = server.state(); + for _ in 0..200 { + if state.connected_files().await.iter().any(|k| k == expected) { + return socket; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("plugin never registered"); +} + +async fn connect_plugin(server: &BridgeServer) -> Plugin { + connect_plugin_as(server, Some(FILE_KEY)).await +} + +/// 다음 작업 하나를 받아 요청 id 와 함께 돌려준다. +async fn next_job(socket: &mut Plugin) -> Value { + let message = socket + .next() + .await + .expect("a job arrives") + .expect("the socket stays open"); + let Message::Text(text) = message else { + panic!("jobs are sent as text"); + }; + serde_json::from_str(&text).expect("a job is JSON") +} + +#[tokio::test] +async fn a_script_read_reaches_the_plugin_and_comes_back_wrapped() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let client = BridgeFigmaClient::new(server.state()); + + let call = ReadToolCall::fast_snapshot(FILE_KEY, "1:2"); + let reading = tokio::spawn(async move { client.call_read_tool(call).await }); + + let job = next_job(&mut plugin).await; + assert_eq!(job["kind"], "devup-job"); + // 플러그인이 아는 이름이어야 한다. 코드젠이 파일명에서 만든 것과 같다. + assert_eq!(job["script"], "fastSnapshot"); + assert_eq!(job["params"]["nodeId"], "1:2"); + // 단일 루트 읽기는 대상 노드 하나를 루트로 넘긴다 — `source` 의 기본값과 같다. + assert_eq!(job["params"]["rootIds"], json!(["1:2"])); + + let payload = json!({ "fileKey": FILE_KEY, "nodes": [{ "id": "1:2" }] }); + plugin + .send(Message::Text( + json!({ + "kind": "devup-result", + "requestId": job["requestId"], + "data": payload, + }) + .to_string() + .into(), + )) + .await + .expect("the result is sent"); + + let result = reading + .await + .expect("the read task finishes") + .expect("the bridge answers"); + + // 원격 `use_figma` 와 같은 모양이어야 디코더들이 그대로 동작한다. + let text = result.raw["content"][0]["text"] + .as_str() + .expect("the payload rides in content[].text"); + assert_eq!( + serde_json::from_str::(text).expect("the carried text is JSON"), + payload, + ); +} + +#[tokio::test] +async fn each_script_read_maps_to_the_name_the_plugin_knows() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let client = BridgeFigmaClient::new(server.state()); + + for (call, expected) in [ + (ReadToolCall::section_index(FILE_KEY, "1:2"), "sectionIndex"), + (ReadToolCall::page_catalog(FILE_KEY), "pageCatalog"), + (ReadToolCall::fast_theme(FILE_KEY), "fastTheme"), + ] { + let client = client.clone(); + let reading = tokio::spawn(async move { client.call_read_tool(call).await }); + + let job = next_job(&mut plugin).await; + assert_eq!(job["script"], expected, "script name for {expected}"); + + plugin + .send(Message::Text( + json!({ + "kind": "devup-result", + "requestId": job["requestId"], + "data": { "ok": true }, + }) + .to_string() + .into(), + )) + .await + .expect("the result is sent"); + reading + .await + .expect("the read task finishes") + .expect("the bridge answers"); + } +} + +#[tokio::test] +async fn a_failing_script_surfaces_its_own_code() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let client = BridgeFigmaClient::new(server.state()); + + let call = ReadToolCall::fast_snapshot(FILE_KEY, "9:9"); + let reading = tokio::spawn(async move { client.call_read_tool(call).await }); + + let job = next_job(&mut plugin).await; + plugin + .send(Message::Text( + json!({ + "kind": "devup-result", + "requestId": job["requestId"], + "error": "DEVUP_NODE_NOT_FOUND", + }) + .to_string() + .into(), + )) + .await + .expect("the failure is sent"); + + let result = reading + .await + .expect("the read task finishes") + .expect("the plugin answered; its failure is the answer"); + // 원격 `use_figma` 와 같은 모양이어야 위쪽 분기가 동일하게 동작한다 — 없는 + // 노드는 호출자 오류로, Figma 런타임의 말은 플러그인 실행 오류로. + assert_eq!(result.raw["isError"], true); + assert_eq!( + result.raw["content"][0]["text"], + "Error: DEVUP_NODE_NOT_FOUND" + ); +} + +#[tokio::test] +async fn routing_is_decided_before_the_call() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let client = BridgeFigmaClient::new(server.state()); + + // 플러그인이 붙기 전에는 스크립트 읽기도 맡지 않는다 — 원격이 받아야 한다. + assert!( + !client + .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + ); + + let _plugin = connect_plugin(&server).await; + assert!( + client + .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + ); + + // 메타데이터는 노드가 있든 없든 플러그인이 맡는다. 노드가 있으면 두 경로가 같은 + // `MetadataDocument` 로 환원되고, 없으면 최상위 페이지 목록을 공식 `get_metadata` + // 와 같은 글로 쓴다. + assert!( + client + .can_serve(&ReadToolCall::metadata(FILE_KEY, Some("1:2"))) + .await + ); + assert!( + client + .can_serve(&ReadToolCall::metadata(FILE_KEY, None)) + .await + ); + // 다른 파일은 그 파일의 플러그인이 필요하다. + assert!( + !client + .can_serve(&ReadToolCall::fast_snapshot("OtherFile", "1:2")) + .await + ); +} + +/// 로그인을 요구할지 말지는 이 값이 정한다. +/// +/// 브리지는 Figma 한도도 자격증명도 쓰지 않으므로, 이 파일을 맡은 플러그인이 +/// 있으면 수집은 토큰 없이 성립한다. 판정은 파일 단위여야 한다 — 다른 파일을 +/// 열어 둔 플러그인이 붙어 있다고 해서 이 파일을 읽을 수 있는 것은 아니다. +#[tokio::test] +async fn a_plugin_holding_the_file_makes_it_readable_without_credentials() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let client = BridgeFigmaClient::new(server.state()); + + assert!(!client.serves_without_credentials(FILE_KEY).await); + + let _plugin = connect_plugin(&server).await; + assert!(client.serves_without_credentials(FILE_KEY).await); + assert!(!client.serves_without_credentials("OtherFile").await); +} + +/// 키 없이 붙은 플러그인도 혼자면 맡는다. +/// +/// `figma.fileKey` 는 늘 오는 값이 아니다. 실기기에서 비어 온 적이 있고, 그때 등록을 +/// 건너뛰었더니 플러그인 창은 "연결됨"이라고 하는데 읽기는 한 건도 도착하지 않았다. +/// 연결이 스스로를 멀쩡하다고 보고하는 실패라 원인을 찾기가 가장 어렵다. +#[tokio::test] +async fn a_plugin_without_a_file_key_serves_when_it_is_the_only_one() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let client = BridgeFigmaClient::new(server.state()); + let mut plugin = connect_plugin_as(&server, None).await; + + // 어느 파일을 물어도 이 하나가 답한다. 무엇을 열고 있는지 모르지만, 하나뿐이라 + // 그것이 열려 있는 파일이다. + assert!( + client + .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + ); + + let reading = { + let client = client.clone(); + tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::page_catalog(FILE_KEY)) + .await + }) + }; + let job = next_job(&mut plugin).await; + assert_eq!(job["script"], "pageCatalog"); + plugin + .send(Message::Text( + json!({ + "kind": "devup-result", + "requestId": job["requestId"], + "data": { "ok": true }, + }) + .to_string() + .into(), + )) + .await + .expect("the result is sent"); + reading + .await + .expect("the read task finishes") + .expect("a keyless plugin still answers"); +} + +/// 키 없는 플러그인이 보낸 빠른 봉투는 물은 파일의 것으로 디코딩된다. +/// +/// 빠른 스냅샷과 테마 봉투는 키를 `source`·`snapshot` 안에 싣는다. 그 자리가 빈 +/// 채로 디코더에 가면 대상 불일치로 빠른 경로를 버리는데, 실기기에서 화면마다 그랬다. +#[tokio::test] +async fn a_keyless_plugins_fast_envelopes_decode_as_the_file_asked_for() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let client = BridgeFigmaClient::new(server.state()); + let mut plugin = connect_plugin_as(&server, None).await; + let target = devup_mcp_figma::FigmaTarget::parse(&format!( + "https://www.figma.com/design/{FILE_KEY}/x?node-id=1-1" + )) + .expect("a design link"); + + let reading = { + let client = client.clone(); + tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:1")) + .await + }) + }; + let job = next_job(&mut plugin).await; + let envelope = json!({ + "kind": "devupFastSnapshotEnvelope", "schemaVersion": 1, + "source": { "fileKey": "", "rootId": "1:1" }, + "snapshot": { "fileKey": "", "version": null, "rootIds": ["1:1"], + "nodes": [{ "id": "1:1", "type": "FRAME", "fields": { "childrenIds": [] } }], + "diagnostics": [] }, + "resources": { "collections": [], "variables": [], "styles": [], + "usedRemoteVariables": [], "localComplete": false, "usedRemoteComplete": true, + "unresolved": [] }, + "integrity": { "nodeCount": 1, "variableRefCount": 0, "styleRefCount": 0, "utf8Bytes": 0 } + }); + answer(&mut plugin, &job, envelope).await; + let result = reading + .await + .expect("the read task finishes") + .expect("the bridge answers"); + let decoded = devup_mcp_figma::decode_fast_snapshot(&result, &target) + .expect("a keyless plugin's envelope is the asked-for file's"); + assert_eq!(decoded.snapshot.root_ids, ["1:1"]); + + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::fast_theme(FILE_KEY)) + .await + }); + let job = next_job(&mut plugin).await; + let theme = json!({ + "kind": "devupFastThemeEnvelope", "schemaVersion": 1, + "source": { "fileKey": "", "version": null }, + "resources": { "collections": [], "variables": [], "styles": [], + "usedRemoteVariables": [], "usedVariableIds": [], "usedStyleIds": [], + "localComplete": true, "usedRemoteComplete": true, "unresolved": [] }, + "integrity": { "collectionCount": 0, "variableCount": 0, "styleCount": 0, + "unresolvedCount": 0, "utf8Bytes": 0 } + }); + answer(&mut plugin, &job, theme).await; + let result = reading + .await + .expect("the read task finishes") + .expect("the bridge answers"); + devup_mcp_figma::decode_fast_theme(&result, FILE_KEY) + .expect("a keyless plugin's theme envelope is the asked-for file's"); +} + +/// 키 없는 플러그인이 둘이면 아무도 맡지 않는다. +/// +/// 어느 쪽이 대상 파일을 열고 있는지 가릴 수 없다. 엉뚱한 문서를 읽어 주느니 요금을 +/// 쓰더라도 원격으로 넘기는 편이 낫다. +#[tokio::test] +async fn two_keyless_plugins_are_ambiguous_and_neither_serves() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let client = BridgeFigmaClient::new(server.state()); + + let _first = connect_plugin_as(&server, None).await; + // 다른 파일을 연 플러그인이 하나 더 붙으면 키 없는 쪽은 더는 혼자가 아니다. + // 둘 다 키가 없는 경우는 `two_keyless_plugins_are_counted_apart` 가 본다. + let _second = connect_plugin_as(&server, Some("AnotherFile")).await; + + assert!( + !client + .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + ); +} + +/// 플러그인 흉내: `hello` 를 그대로 보내고, 붙은 플러그인이 `count` 개가 될 +/// 때까지 기다린다. 키 없는 플러그인이 둘이면 `connected_files` 로는 둘째의 +/// 등록을 가릴 수 없어서 수로 기다린다. +async fn attach(server: &BridgeServer, hello: Value, count: usize) -> Plugin { + let (mut socket, _) = connect_async(format!("ws://127.0.0.1:{}/plugin", server.port())) + .await + .expect("bridge accepts a plugin"); + socket + .send(Message::Text(hello.to_string().into())) + .await + .expect("hello is sent"); + let state = server.state(); + for _ in 0..200 { + if state.attached_files().await.len() == count { + return socket; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("plugin never registered"); +} + +async fn answer(plugin: &mut Plugin, job: &Value, data: Value) { + plugin + .send(Message::Text( + json!({ "kind": "devup-result", "requestId": job["requestId"], "data": data }) + .to_string() + .into(), + )) + .await + .expect("the result is sent"); +} + +/// 플러그인이 보고하는 페이지와 선택은 `status` 가 그대로 보여 주고, url 없는 +/// 요청은 그것으로 대상을 고른다. 키를 보고하지 못한 플러그인에는 그 연결만 +/// 가리키는 키가 주어지고, 그 키로 보낸 읽기는 그 플러그인에 닿으며, 답에는 +/// 파일 키를 지어내지 않은 출처가 실린다. +#[tokio::test] +async fn a_keyless_plugin_reports_where_it_is_and_answers_its_own_connection() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = attach( + &server, + json!({ + "kind": "hello", "fileKey": null, "fileName": "Landing", + "currentPage": { "id": "0:1", "name": "Page 1" }, + "selection": [{ "id": "1:2", "name": "Hero", "type": "FRAME" }], + "selectionCount": 1, + }), + 1, + ) + .await; + let state = server.state(); + let attached = state.attached_files().await; + let file = &attached[0]; + assert_eq!(file.file_key, None); + assert_eq!(file.file_name.as_deref(), Some("Landing")); + assert_eq!( + file.context.single_selection().map(|node| node.id.as_str()), + Some("1:2") + ); + assert!(devup_mcp_figma::is_bridge_only_key(&file.target_key)); + + let client = BridgeFigmaClient::new(state.clone()).with_port(server.port()); + let call = ReadToolCall::fast_snapshot(file.target_key.clone(), "1:2"); + let reading = tokio::spawn(async move { client.call_read_tool(call).await }); + let job = next_job(&mut plugin).await; + assert_eq!(job["params"]["nodeId"], "1:2"); + answer(&mut plugin, &job, json!({ "fileKey": "", "nodes": [] })).await; + let result = reading.await.unwrap().expect("the plugin answers"); + let served = + serde_json::from_value::(result.raw["_meta"]["devup/bridge"].clone()) + .expect("a bridge answer names the plugin that gave it"); + assert_eq!(served.reads, 1); + assert_eq!(served.port, Some(server.port())); + assert_eq!( + served.file_key, None, + "no key is claimed for a keyless plugin" + ); + assert_eq!(served.file_name.as_deref(), Some("Landing")); + assert_eq!(served.page_name.as_deref(), Some("Page 1")); + + // A new selection reaches the server without a reconnect. + plugin + .send(Message::Text( + json!({ + "kind": "context", + "currentPage": { "id": "0:2", "name": "Page 2" }, + "selection": [], + "selectionCount": 0, + "inView": false, + }) + .to_string() + .into(), + )) + .await + .expect("the context is sent"); + for _ in 0..200 { + let context = state.attached_files().await[0].context.clone(); + if context.current_page.as_ref().map(|page| page.name.as_str()) == Some("Page 2") { + assert_eq!(context.selection, Some(vec![])); + assert_eq!(context.in_view, Some(false)); + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("the context update never arrived"); +} + +/// 키 없는 플러그인 둘은 두 개로 센다. 한 자리에 묶이면 "정확히 하나"를 셀 수 +/// 없고, 먼저 붙은 쪽이 끊길 때 나중 쪽의 등록까지 지워진다. 둘이면 아무 키도 +/// 맡지 않지만, 각 연결의 키는 여전히 제 플러그인에만 닿는다. +#[tokio::test] +async fn two_keyless_plugins_are_counted_apart() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let hello = json!({ "kind": "hello", "fileKey": null }); + let first = attach(&server, hello.clone(), 1).await; + let _second = attach(&server, hello, 2).await; + let state = server.state(); + let client = BridgeFigmaClient::new(state.clone()); + assert!( + !client + .can_serve(&ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + ); + let attached = state.attached_files().await; + assert_eq!(attached.len(), 2); + for file in &attached { + assert!( + client + .can_serve(&ReadToolCall::fast_snapshot(file.target_key.clone(), "1:2")) + .await + ); + } + + drop(first); + for _ in 0..200 { + if state.attached_files().await.len() == 1 { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + panic!("the first plugin's disconnect must leave the second attached"); +} + +/// 원격만 아는 상류. 몇 번 불렸는지만 센다. +struct CountingRemote { + calls: std::sync::Arc, +} + +#[async_trait::async_trait] +impl FigmaUpstream for CountingRemote { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(UpstreamResult { raw: json!({}) }) + } +} + +/// 브리지만 읽을 수 있는 키는 원격으로 넘어가지 않는다. Figma 에 없는 키를 +/// 물어 봐야 원인과 무관한 거절만 돌아오고, 로그인이 필요 없는 요청에 로그인이 +/// 요구된다. +#[tokio::test] +async fn a_bridge_only_key_never_reaches_the_remote_path() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let remote_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let upstream = FallbackUpstream::new( + BridgeFigmaClient::new(server.state()), + CountingRemote { + calls: remote_calls.clone(), + }, + ); + assert!(upstream.serves_without_credentials("bridge:41").await); + let error = upstream + .call_read_tool(ReadToolCall::fast_snapshot("bridge:41", "1:2")) + .await + .expect_err("no plugin holds this connection"); + assert_eq!(error.details["stage"], "bridge-routing"); + let error = upstream + .call_read_tool(ReadToolCall::metadata("bridge:41", None)) + .await + .expect_err("no plugin holds this connection to list its pages"); + assert_eq!(error.details["tool"], "get_metadata"); + + assert_eq!(remote_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + + // An ordinary key still falls through when no plugin holds it. + upstream + .call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:2")) + .await + .expect("the remote path answers"); + assert_eq!(remote_calls.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +/// referencePng 도 플러그인이 맡는다. 플러그인은 노드를 PNG 로 export 하고, 브리지는 +/// 그것을 공식 `get_screenshot` 처럼 이미지 블록 하나로 돌려준다 — 수집기가 두 경로를 +/// 같은 디코더로 받는다. 플러그인이 그 그림에 단 말은 `_meta` 에 실려 수집기로 간다. +#[tokio::test] +async fn a_reference_png_is_exported_by_the_plugin_and_comes_back_as_one_image() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let client = BridgeFigmaClient::new(server.state()); + let call = ReadToolCall::screenshot(FILE_KEY, "1:2"); + assert!(client.can_serve(&call).await); + let reading = tokio::spawn(async move { client.call_read_tool(call).await }); + + let job = next_job(&mut plugin).await; + assert_eq!(job["script"], "referencePng"); + assert_eq!(job["params"]["nodeId"], "1:2"); + let note = "INSTANCE \"Header\" reaches into it and may show here"; + answer( + &mut plugin, + &job, + json!({ "kind": "devupReferencePng", "nodeId": "1:2", "mimeType": "image/png", + "byteLength": 4, "data": "iVBORw==", "note": note }), + ) + .await; + + let result = reading + .await + .expect("the read task finishes") + .expect("the bridge answers"); + assert_eq!(result.raw["isError"], false); + assert_eq!( + result.raw["content"], + json!([{ "type": "image", "data": "iVBORw==", "mimeType": "image/png" }]) + ); + assert!(result.raw["_meta"]["devup/bridge"].is_object()); + assert_eq!(result.raw["_meta"]["devup/referencePngNote"], note); +} + +fn png_base64(width: u32, height: u32, rgba: Vec) -> String { + use base64::Engine as _; + let pixels = image::RgbaImage::from_raw(width, height, rgba).expect("pixels fill the image"); + let mut encoded = Vec::new(); + pixels + .write_with_encoder(image::codecs::png::PngEncoder::new(&mut encoded)) + .expect("a PNG encodes"); + base64::engine::general_purpose::STANDARD.encode(encoded) +} + +/// `get_screenshot` 은 프레임의 반투명한 자리를 캔버스로 채운 불투명한 PNG 를 준다. +/// 플러그인의 export 는 알파를 그대로 두므로, 브리지가 플러그인이 알려 준 페이지 배경 +/// 위로 덮는다. 처음부터 불투명한 export 는 다시 인코딩하지 않는다. +#[tokio::test] +async fn a_translucent_reference_png_is_flattened_onto_the_page_background() { + use base64::Engine as _; + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let read = |plugin_answer: Value| { + let client = BridgeFigmaClient::new(server.state()); + ( + tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) + .await + }), + plugin_answer, + ) + }; + let answer_with = |data: String| { + json!({ "kind": "devupReferencePng", "nodeId": "1:2", "mimeType": "image/png", + "byteLength": 0, "data": data, "backdrop": [30, 30, 30] }) + }; + + let (reading, plugin_answer) = read(answer_with(png_base64( + 2, + 1, + vec![0, 0, 0, 178, 255, 255, 255, 255], + ))); + let job = next_job(&mut plugin).await; + answer(&mut plugin, &job, plugin_answer).await; + let result = reading.await.expect("the read finishes").expect("answered"); + let data = result.raw["content"][0]["data"].as_str().expect("an image"); + let decoded = image::load_from_memory( + &base64::engine::general_purpose::STANDARD + .decode(data) + .expect("base64"), + ) + .expect("a PNG") + .into_rgba8(); + assert_eq!(decoded.into_raw(), vec![9, 9, 9, 255, 255, 255, 255, 255]); + + let opaque = png_base64(1, 1, vec![10, 20, 30, 255]); + let (reading, plugin_answer) = read(answer_with(opaque.clone())); + let job = next_job(&mut plugin).await; + answer(&mut plugin, &job, plugin_answer).await; + let result = reading.await.expect("the read finishes").expect("answered"); + assert_eq!(result.raw["content"][0]["data"], opaque); +} + +/// 플러그인이 시도하지 않았거나 답 하나에 담지 못한 읽기 — 이 devup-mcp 보다 먼저 +/// 빌드돼 스크립트를 모르거나, PNG 가 답 하나보다 크다 — 는 원격이 읽을 수 있는 키면 +/// 원격이 대신 답하고, 플러그인만 아는 키면 그 답이 할 일과 함께 올라간다. +#[tokio::test] +async fn a_plugin_older_than_the_server_leaves_a_keyed_read_to_the_remote_path() { + let older = "DEVUP_BRIDGE_UNKNOWN_SCRIPT: referencePng (아는 스크립트: assets, snapshot)"; + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let remote_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let upstream = std::sync::Arc::new(FallbackUpstream::new( + BridgeFigmaClient::new(server.state()), + CountingRemote { + calls: remote_calls.clone(), + }, + )); + + for (asked, refusal) in [ + older, + "DEVUP_REFERENCE_PNG_TOO_LARGE 9000000 bytes, over the 8388608 one answer carries", + ] + .into_iter() + .enumerate() + { + let reading = { + let upstream = upstream.clone(); + tokio::spawn(async move { + upstream + .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) + .await + }) + }; + let job = next_job(&mut plugin).await; + plugin + .send(Message::Text( + json!({ "kind": "devup-result", "requestId": job["requestId"], "error": refusal }) + .to_string() + .into(), + )) + .await + .expect("the refusal is sent"); + reading + .await + .expect("the read task finishes") + .expect("the remote path answers in the plugin's place"); + assert_eq!( + remote_calls.load(std::sync::atomic::Ordering::SeqCst), + asked + 1, + "{refusal}" + ); + } + remote_calls.store(1, std::sync::atomic::Ordering::SeqCst); + + let keyless = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut lone = connect_plugin_as(&keyless, None).await; + let target = keyless.state().attached_files().await[0].target_key.clone(); + let upstream = std::sync::Arc::new(FallbackUpstream::new( + BridgeFigmaClient::new(keyless.state()), + CountingRemote { + calls: remote_calls.clone(), + }, + )); + let reading = tokio::spawn(async move { + upstream + .call_read_tool(ReadToolCall::screenshot(target, "1:2")) + .await + }); + let job = next_job(&mut lone).await; + lone.send(Message::Text( + json!({ "kind": "devup-result", "requestId": job["requestId"], "error": older }) + .to_string() + .into(), + )) + .await + .expect("the refusal is sent"); + let result = reading + .await + .expect("the read task finishes") + .expect("the plugin's refusal is its answer"); + assert_eq!(result.raw["isError"], true); + assert_eq!(remote_calls.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +struct SignedOutRemote; + +#[async_trait::async_trait] +impl FigmaUpstream for SignedOutRemote { + async fn list_tools(&self) -> Result, DevupError> { + Ok(Vec::new()) + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + Err(DevupError::new( + ErrorCode::DevupAuthRequired, + "Figma login is required.", + false, + )) + } + + async fn has_credentials(&self) -> bool { + false + } +} + +/// 브리지 답 하나에 실리지 않는 참조 PNG 는 원격에 묻는다. 원격이 로그인 없이 거절하면 +/// 답은 로그인 요구가 아니라 플러그인의 거절이다. +#[tokio::test] +async fn a_signed_out_remote_leaves_the_plugins_refusal_standing() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let upstream = FallbackUpstream::new(BridgeFigmaClient::new(server.state()), SignedOutRemote); + let reading = tokio::spawn(async move { + upstream + .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) + .await + }); + let job = next_job(&mut plugin).await; + plugin + .send(Message::Text( + json!({ "kind": "devup-result", "requestId": job["requestId"], + "error": "DEVUP_REFERENCE_PNG_TOO_LARGE 9000000 bytes, over the 8388608 one answer carries" }) + .to_string() + .into(), + )) + .await + .expect("the refusal is sent"); + let result = reading + .await + .expect("the read task finishes") + .expect("the plugin's refusal is the answer"); + assert_eq!(result.raw["isError"], true); + assert!( + result.raw["content"][0]["text"] + .as_str() + .is_some_and(|text| text.contains("DEVUP_REFERENCE_PNG_TOO_LARGE")), + "{}", + result.raw + ); +} + +/// 노드 없는 metadata 읽기 — 파일의 최상위 페이지 목록 — 도 플러그인이 맡는다. +/// 플러그인의 페이지 목록을 공식 `get_metadata` 가 쓰는 글로 옮겨, 수집기가 두 경로의 +/// 답을 같은 파서로 받는다. +#[tokio::test] +async fn a_files_page_list_comes_from_the_plugins_page_catalog() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let client = BridgeFigmaClient::new(server.state()); + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::metadata(FILE_KEY, None)) + .await + }); + let job = next_job(&mut plugin).await; + assert_eq!(job["script"], "pageCatalog"); + answer( + &mut plugin, + &job, + json!({ "fileKey": FILE_KEY, "nodes": [ + { "id": "0:1", "type": "PAGE", "fields": { "name": "Cover" } }, + { "id": "5:2", "type": "PAGE", "fields": { "name": "Screens" } }, + ]}), + ) + .await; + let result = reading + .await + .expect("the read task finishes") + .expect("the bridge answers"); + assert_eq!( + result.raw["content"][0]["text"], + "Top-level pages of the document:\n- 0:1: Cover\n- 5:2: Screens\n" + ); + assert!(result.raw["_meta"]["devup/bridge"].is_object()); +} + +/// JSON 을 플러그인이 붙들어 둘 때처럼 ASCII 로 쓴다 — 그 밖의 글자는 `\uXXXX`. +fn ascii_json(value: &Value) -> String { + let mut text = String::new(); + for unit in serde_json::to_string(value) + .expect("JSON encodes") + .encode_utf16() + { + match u8::try_from(unit) { + Ok(byte) if byte.is_ascii() => text.push(char::from(byte)), + _ => text.push_str(&format!("\\u{unit:04x}")), + } + } + text +} + +/// 브리지 답 하나에 들어가지 않는 참조 PNG 답은 플러그인이 붙들어 두고 조각으로 +/// 내준다. 브리지는 조각을 이어 받아 하나로 되돌리고, 그 답을 평소처럼 감싼다 — 노트의 +/// 한글 레이어 이름까지 그대로. +#[tokio::test] +async fn a_held_reference_answer_is_read_back_in_pieces() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let client = BridgeFigmaClient::new(server.state()); + let opaque = png_base64(1, 1, vec![10, 20, 30, 255]); + let note = "TEXT \"공지 제목\" reaches into it and may show here"; + let whole = ascii_json(&json!({ "kind": "devupReferencePng", "nodeId": "1:2", + "mimeType": "image/png", "byteLength": 0, "data": opaque, "note": note })); + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) + .await + }); + + let job = next_job(&mut plugin).await; + assert_eq!(job["script"], "referencePng"); + answer( + &mut plugin, + &job, + json!({ "kind": "devupReferencePngChunked", "nodeId": "1:2", "id": "1:2@1", + "length": whole.len() }), + ) + .await; + let mut offset = 0; + while offset < whole.len() { + let job = next_job(&mut plugin).await; + assert_eq!(job["script"], "referencePngChunk"); + assert_eq!(job["params"]["largeValue"]["id"], "1:2@1"); + assert_eq!(job["params"]["largeValue"]["offset"], offset); + let end = (offset + 40).min(whole.len()); + answer( + &mut plugin, + &job, + json!({ "kind": "devupReferencePngChunk", "id": "1:2@1", "offset": offset, + "nextOffset": end, "complete": end == whole.len(), "text": &whole[offset..end] }), + ) + .await; + offset = end; + } + + let result = reading + .await + .expect("the read task finishes") + .expect("the bridge answers"); + assert_eq!(result.raw["content"][0]["data"], opaque); + assert_eq!(result.raw["_meta"]["devup/referencePngNote"], note); +} + +/// 다른 레이어가 노드에 닿으면 플러그인은 노드만 export 하고 조상들을 설명한다. +/// 브리지가 그 나머지를 그려 `get_screenshot` 이 그리는 그림을 만든다 — 캔버스 export 는 +/// 그리지 못할 때를 위해 함께 올 뿐 쓰이지 않는다. +#[tokio::test] +async fn an_overlapped_node_is_composed_over_its_ancestors() { + use base64::Engine as _; + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let client = BridgeFigmaClient::new(server.state()); + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) + .await + }); + let job = next_job(&mut plugin).await; + answer( + &mut plugin, + &job, + json!({ "kind": "devupReferencePng", "nodeId": "1:2", "mimeType": "image/png", + "byteLength": 0, "data": png_base64(2, 1, vec![0, 0, 0, 255, 0, 0, 0, 255]), + "backdrop": [30, 30, 30], "reaching": "TEXT \"Caption\"", + "compose": { + "area": { "x": 0, "y": 0, "width": 2, "height": 1 }, "scale": 1, + "width": 2, "height": 1, "page": [30, 30, 30], + "layers": [{ "transform": [[1, 0, 0], [0, 1, 0]], "width": 2, "height": 1, + "fills": [{ "type": "SOLID", "color": { "r": 1, "g": 0, "b": 0 } }] }], + "node": { "picture": { "data": png_base64(1, 1, vec![255, 255, 255, 255]), + "area": { "x": 1, "y": 0, "width": 1, "height": 1 } } }, + "images": {} + } }), + ) + .await; + let result = reading + .await + .expect("the read task finishes") + .expect("the bridge answers"); + let data = result.raw["content"][0]["data"].as_str().expect("an image"); + let decoded = image::load_from_memory( + &base64::engine::general_purpose::STANDARD + .decode(data) + .expect("base64"), + ) + .expect("a PNG") + .into_rgba8(); + assert_eq!(decoded.into_raw(), vec![255, 0, 0, 255, 255, 255, 255, 255]); + assert!(result.raw["_meta"].get("devup/referencePngNote").is_none()); + assert!( + result.raw["_meta"] + .get("devup/referencePngApproximate") + .is_none() + ); +} + +/// 원격 경로로 다시 읽으려 한 횟수를 센다. `room` 이 거짓이면 한도 자리가 없다고 답한다. +struct CountingMeter { + calls: std::sync::Arc, + room: bool, +} + +#[async_trait::async_trait] +impl devup_mcp_figma::Meter for CountingMeter { + async fn acquire_within(&self, _budget: std::time::Duration) -> bool { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.room + } +} + +fn meter( + calls: &std::sync::Arc, + room: bool, +) -> std::sync::Arc { + std::sync::Arc::new(CountingMeter { + calls: calls.clone(), + room, + }) +} + +/// 정해 둔 답을 내는 원격. 몇 번 불렸는지 센다. +struct FixedRemote { + calls: std::sync::Arc, + raw: Value, +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/tests/bridge_transport/impl.rs b/crates/devup-mcp-figma/tests/bridge_transport/impl.rs new file mode 100644 index 00000000..35871acb --- /dev/null +++ b/crates/devup-mcp-figma/tests/bridge_transport/impl.rs @@ -0,0 +1,543 @@ + + +#[async_trait::async_trait] +impl FigmaUpstream for FixedRemote { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(UpstreamResult { + raw: self.raw.clone(), + }) + } +} + +struct Counts { + remote: std::sync::Arc, + metered: std::sync::Arc, +} + +impl Counts { + fn new() -> Self { + Self { + remote: std::sync::Arc::default(), + metered: std::sync::Arc::default(), + } + } + + fn get(&self) -> (usize, usize) { + use std::sync::atomic::Ordering::SeqCst; + (self.remote.load(SeqCst), self.metered.load(SeqCst)) + } +} + +/// 플러그인에 `call` 을 보내 `plugin_answer` 로 답하게 하고, 그 뒤의 결과를 돌려준다. +async fn through_fallback( + upstream: FallbackUpstream, + plugin: &mut Plugin, + call: ReadToolCall, + plugin_answer: Value, +) -> UpstreamResult { + let reading = tokio::spawn(async move { upstream.call_read_tool(call).await }); + let job = next_job(plugin).await; + let mut message = plugin_answer; + message["kind"] = json!("devup-result"); + message["requestId"] = job["requestId"].clone(); + plugin + .send(Message::Text(message.to_string().into())) + .await + .expect("the answer is sent"); + reading + .await + .expect("the read task finishes") + .expect("an answer comes back") +} + +fn fixed_fallback( + server: &BridgeServer, + counts: &Counts, + raw: Value, +) -> FallbackUpstream { + FallbackUpstream::new( + BridgeFigmaClient::new(server.state()), + FixedRemote { + calls: counts.remote.clone(), + raw, + }, + ) + .with_meter(meter(&counts.metered, true)) +} + +const UNREACHABLE: &str = "Unable to establish connection to Figma after 10 seconds. Please check your internet connection."; + +/// 파일이 화면에 보이지 않으면 Figma 는 불러오지 않은 데이터를 플러그인에게 주지 +/// 않는다. 그 말이 섞인 플러그인의 답은 원격 경로가 다시 읽는다 — 원격은 그 상태와 +/// 무관하다. 한도를 쓰는 읽기라 속도에 맞춰 보내고, 왜 다시 읽었는지 결과에 남긴다. +#[tokio::test] +async fn a_read_figma_withheld_out_of_sight_is_read_again_on_the_direct_path() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let short = json!({ "data": { "fileKey": FILE_KEY, "resources": { "unresolved": [ + { "id": "VariableID:1:2", "reason": "notFoundOrUnavailable", "error": UNREACHABLE }] } } }); + + let counts = Counts::new(); + let direct = json!({ "content": [{ "type": "text", "text": "{\"fileKey\":\"FileKey123\"}" }] }); + let result = through_fallback( + fixed_fallback(&server, &counts, direct.clone()), + &mut plugin, + ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), + short.clone(), + ) + .await; + assert_eq!(result.raw["content"], direct["content"]); + assert_eq!( + result.raw["_meta"]["devup/directFallback"], + "file-out-of-sight" + ); + assert_eq!(counts.get(), (1, 1)); + + // 원격의 답이 공식 MCP 의 20 KB 에서 잘렸으면 디코딩되지 않으니 플러그인의 답을 쓴다. + let counts = Counts::new(); + let truncated = json!({ "content": [{ "type": "text", + "text": "{\"fileKey\":\"FileKey123\",\"nodes\":[ // truncated to 20kb" }] }); + let result = through_fallback( + fixed_fallback(&server, &counts, truncated), + &mut plugin, + ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), + short.clone(), + ) + .await; + assert!( + result.raw["content"][0]["text"] + .as_str() + .is_some_and(|text| text.contains("notFoundOrUnavailable")), + "{}", + result.raw + ); + assert_eq!(counts.get(), (1, 1)); + + // 한도에 걸렸다는 문장은 `isError` 없이도 온다. 그것도 쓸 수 없는 답이니 플러그인의 답을 쓴다. + let counts = Counts::new(); + let refusal = json!({ "content": [{ "type": "text", + "text": "You've reached the tool call limit for your plan. Try again later." }] }); + let result = through_fallback( + fixed_fallback(&server, &counts, refusal), + &mut plugin, + ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), + short.clone(), + ) + .await; + assert!( + result.raw["_meta"]["devup/bridge"].is_object(), + "{}", + result.raw + ); + assert_eq!(counts.get(), (1, 1)); + + // 정해진 시간 안에 한도 자리가 나지 않으면 기다리지 않고 플러그인의 답을 쓴다 — 이미 받은 + // 답을 읽기 시간 초과로 잃지 않는다. + let counts = Counts::new(); + let upstream = FallbackUpstream::new( + BridgeFigmaClient::new(server.state()), + FixedRemote { + calls: counts.remote.clone(), + raw: direct.clone(), + }, + ) + .with_meter(meter(&counts.metered, false)); + let result = through_fallback( + upstream, + &mut plugin, + ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), + short.clone(), + ) + .await; + assert!( + result.raw["_meta"]["devup/bridge"].is_object(), + "{}", + result.raw + ); + assert_eq!(counts.get(), (0, 1)); + + // 디자인의 글에 같은 문장이 있다고 다시 읽지 않는다. 오류를 싣는 자리에 있을 때만이다. + let counts = Counts::new(); + let quoted = json!({ "data": { "fileKey": FILE_KEY, "nodes": [{ "id": "1:2", "type": "TEXT", + "fields": { "characters": UNREACHABLE } }] } }); + let result = through_fallback( + fixed_fallback(&server, &counts, direct.clone()), + &mut plugin, + ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), + quoted, + ) + .await; + assert!( + result.raw["_meta"]["devup/bridge"].is_object(), + "{}", + result.raw + ); + assert_eq!(counts.get(), (0, 0)); + + // 로그인이 없으면 원격은 시도도 되지 않는다. 속도에 맞춰 기다리지 않고 플러그인의 답을 쓴다. + let metered = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let upstream = FallbackUpstream::new(BridgeFigmaClient::new(server.state()), SignedOutRemote) + .with_meter(meter(&metered, true)); + let result = through_fallback( + upstream, + &mut plugin, + ReadToolCall::fast_snapshot(FILE_KEY, "1:2"), + short, + ) + .await; + assert!(result.raw["_meta"]["devup/bridge"].is_object()); + assert_eq!(metered.load(std::sync::atomic::Ordering::SeqCst), 0); +} + +/// `wait` 만큼 기다려야 자리가 나는 한도. `CallPacer` 처럼, 주어진 시간 안에 자리가 나지 +/// 않으면 기다리지 않고 거절한다. +struct PacedMeter { + wait: std::time::Duration, + calls: std::sync::Arc, +} + +#[async_trait::async_trait] +impl devup_mcp_figma::Meter for PacedMeter { + async fn acquire_within(&self, budget: std::time::Duration) -> bool { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if self.wait > budget { + return false; + } + tokio::time::sleep(self.wait).await; + true + } +} + +/// `delay` 뒤에 `get_screenshot` 과 다 같게 그리지 못한 그림으로 답하는 플러그인. +struct SlowPlugin { + delay: std::time::Duration, +} + +#[async_trait::async_trait] +impl FigmaUpstream for SlowPlugin { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + tokio::time::sleep(self.delay).await; + Ok(UpstreamResult { + raw: json!({ "content": [{ "type": "image", "data": "cGx1Z2lu", "mimeType": "image/png" }], + "_meta": { "devup/referencePngApproximate": "an ancestor's stroke" } }), + }) + } +} + +#[async_trait::async_trait] +impl PreferredUpstream for SlowPlugin { + async fn can_serve(&self, _call: &ReadToolCall) -> bool { + true + } + + fn is_live(&self) -> bool { + true + } +} + +/// 로그인을 확인하는 데 `sign_in` 이 걸리는 원격. 늘 정해 둔 그림으로 답한다. +struct SlowSignIn { + sign_in: std::time::Duration, + calls: std::sync::Arc, +} + +#[async_trait::async_trait] +impl FigmaUpstream for SlowSignIn { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".to_owned()]) + } + + async fn call_read_tool(&self, _call: ReadToolCall) -> Result { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(UpstreamResult { + raw: json!({ "content": [{ "type": "image", "data": "ZGlyZWN0", "mimeType": "image/png" }] }), + }) + } + + async fn has_credentials(&self) -> bool { + tokio::time::sleep(self.sign_in).await; + true + } +} + +/// 원격으로 다시 읽는 일 — 로그인 확인, 한도 자리, 원격의 답 — 은 읽기 하나의 시간 +/// (`READ_TIME_LIMIT`)에서 플러그인이 쓰고 남은 만큼만 한다. 60초 걸려 온 플러그인의 +/// 그림은, 35초를 기다려야 자리가 나는 한도나 35초 걸리는 로그인 확인 때문에 읽기의 시간 +/// 제한에서 잃지 않는다. 시간이 남았으면 그대로 원격이 다시 읽는다. +#[tokio::test(start_paused = true)] +async fn re_reading_on_the_direct_path_fits_in_what_the_plugin_left_of_the_read() { + for (plugin_took, meter_wait, sign_in, counted) in [ + (60, 35, 0, (0, 1)), + (60, 0, 35, (0, 0)), + (10, 35, 0, (1, 1)), + ] { + let counts = Counts::new(); + let upstream = FallbackUpstream::new( + SlowPlugin { + delay: std::time::Duration::from_secs(plugin_took), + }, + SlowSignIn { + sign_in: std::time::Duration::from_secs(sign_in), + calls: counts.remote.clone(), + }, + ) + .with_meter(std::sync::Arc::new(PacedMeter { + wait: std::time::Duration::from_secs(meter_wait), + calls: counts.metered.clone(), + })); + let result = tokio::time::timeout( + devup_mcp_figma::READ_TIME_LIMIT, + upstream.call_read_tool(ReadToolCall::fast_snapshot(FILE_KEY, "1:2")), + ) + .await + .expect("answered within the read's time") + .expect("an answer"); + let expected = if counted.0 == 0 { + "cGx1Z2lu" + } else { + "ZGlyZWN0" + }; + let case = format!("plugin {plugin_took} s, meter {meter_wait} s, sign-in {sign_in} s"); + assert_eq!(result.raw["content"][0]["data"], expected, "{case}"); + assert_eq!(counts.get(), counted, "{case}"); + } +} + +/// 플러그인이 `get_screenshot` 과 같은 그림을 그리지 못하면 — 여기서는 그릴 수 없는 +/// 칠 — 캔버스 그림에 이유를 달고, 원격이 열려 있으면 원격의 `get_screenshot` 이 대신 +/// 답한다. 열려 있지 않으면 캔버스 그림과 그 이유가 답이다. +#[tokio::test] +async fn a_picture_the_plugin_cannot_compose_goes_to_get_screenshot() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let canvas = png_base64(1, 1, vec![10, 20, 30, 255]); + let uncomposable = json!({ "data": { "kind": "devupReferencePng", "nodeId": "1:2", + "mimeType": "image/png", "byteLength": 0, "data": canvas, "backdrop": [30, 30, 30], + "reaching": "TEXT \"Caption\"", + "compose": { "area": { "x": 0, "y": 0 }, "width": 1, "height": 1, "page": [30, 30, 30], + "layers": [{ "transform": [[1, 0, 0], [0, 1, 0]], "width": 1, "height": 1, + "fills": [{ "type": "VIDEO" }] }], + "node": { "picture": { "data": canvas, "area": { "x": 0, "y": 0 } } } } } }); + + let counts = Counts::new(); + let shot = json!({ "content": [{ "type": "image", "data": "aWdub3JlZA==", "mimeType": "image/png" }] }); + let result = through_fallback( + fixed_fallback(&server, &counts, shot.clone()), + &mut plugin, + ReadToolCall::screenshot(FILE_KEY, "1:2"), + uncomposable.clone(), + ) + .await; + assert_eq!(result.raw["content"], shot["content"]); + assert_eq!( + result.raw["_meta"]["devup/directFallback"], + "reference-png-approximate" + ); + assert_eq!(counts.get(), (1, 1)); + + let upstream = FallbackUpstream::new(BridgeFigmaClient::new(server.state()), SignedOutRemote); + let result = through_fallback( + upstream, + &mut plugin, + ReadToolCall::screenshot(FILE_KEY, "1:2"), + uncomposable, + ) + .await; + assert_eq!(result.raw["content"][0]["data"], canvas); + let note = result.raw["_meta"]["devup/referencePngNote"] + .as_str() + .expect("the canvas picture says why"); + assert!( + note.contains("VIDEO") && note.contains("TEXT \"Caption\""), + "{note}" + ); +} + +/// 플러그인이 답하지 못해 원격이 읽은 자산의 이어 읽기는, 플러그인이 다시 export 한 +/// 바이트가 원격의 해시와 다르면 원격이 답한다. 그 바이트는 원격의 렌더러에서만 나온다. +#[tokio::test] +async fn a_value_the_direct_path_produced_is_read_on_from_it() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let counts = Counts::new(); + let fragment = json!({ "content": [{ "type": "text", "text": "{\"kind\":\"devupLargeValueFragment\"}" }] }); + let call = ReadToolCall::large_value( + FILE_KEY, + devup_mcp_figma::LargeValueReadOptions { + node_id: "1:2".to_owned(), + field: "$export/png/1".to_owned(), + offset: 8192, + max_chunk_bytes: 8192, + byte_length: 20_000, + sha256: "0".repeat(64), + version: None, + }, + ); + let result = through_fallback( + fixed_fallback(&server, &counts, fragment.clone()), + &mut plugin, + call, + json!({ "error": "DEVUP_LARGE_VALUE_CHANGED" }), + ) + .await; + assert_eq!(result.raw["content"], fragment["content"]); + assert_eq!( + result.raw["_meta"]["devup/directFallback"], + "value-from-direct-path" + ); + assert_eq!(counts.get(), (1, 1)); +} + +/// 플러그인이 큰 답들을 붙들고 있어 바쁘다고 하면 브리지는 잠시 뒤 다시 묻는다. 붙들어 둔 +/// 답이 읽기 전에 사라졌으면(플러그인이 다시 실행되는 등) 한 번 다시 받는다. 어느 쪽도 실패가 +/// 아니다. +#[tokio::test] +async fn a_busy_plugin_or_a_vanished_answer_is_asked_again() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let opaque = png_base64(1, 1, vec![10, 20, 30, 255]); + let picture = json!({ "kind": "devupReferencePng", "nodeId": "1:2", "mimeType": "image/png", + "byteLength": 0, "data": opaque }); + let refuse = |job: &Value, error: &str| { + Message::Text( + json!({ "kind": "devup-result", "requestId": job["requestId"], "error": error }) + .to_string() + .into(), + ) + }; + + let client = BridgeFigmaClient::new(server.state()); + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) + .await + }); + let job = next_job(&mut plugin).await; + plugin + .send(refuse( + &job, + "DEVUP_REFERENCE_PNG_BUSY 201326592 characters are held for other reads", + )) + .await + .expect("busy is sent"); + let job = next_job(&mut plugin).await; + assert_eq!(job["script"], "referencePng"); + answer(&mut plugin, &job, picture.clone()).await; + let result = reading.await.expect("the read finishes").expect("answered"); + assert_eq!(result.raw["content"][0]["data"], opaque); + + let client = BridgeFigmaClient::new(server.state()); + let reading = tokio::spawn(async move { + client + .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) + .await + }); + let job = next_job(&mut plugin).await; + answer( + &mut plugin, + &job, + json!({ "kind": "devupReferencePngChunked", "nodeId": "1:2", "id": "1:2@1", "length": 10 }), + ) + .await; + let job = next_job(&mut plugin).await; + assert_eq!(job["script"], "referencePngChunk"); + plugin + .send(refuse(&job, "DEVUP_REFERENCE_PNG_ANSWER_GONE 1:2@1")) + .await + .expect("gone is sent"); + let job = next_job(&mut plugin).await; + assert_eq!(job["script"], "referencePng"); + answer(&mut plugin, &job, picture).await; + let result = reading.await.expect("the read finishes").expect("answered"); + assert_eq!(result.raw["content"][0]["data"], opaque); +} + +/// 플러그인이 답하지 못한 읽기(예전 플러그인이 모르는 스크립트)를 원격으로 넘기려는데 정해진 +/// 시간 안에 한도 자리가 나지 않으면, 한도가 찬 것으로 알려 수집이 기다렸다 다시 하게 한다. +#[tokio::test] +async fn a_read_the_plugin_cannot_answer_waits_for_room_rather_than_fail() { + let server = BridgeServer::start(0).expect("an ephemeral port is free"); + let mut plugin = connect_plugin(&server).await; + let counts = Counts::new(); + let upstream = FallbackUpstream::new( + BridgeFigmaClient::new(server.state()), + FixedRemote { + calls: counts.remote.clone(), + raw: json!({ "content": [] }), + }, + ) + .with_meter(meter(&counts.metered, false)); + let reading = tokio::spawn(async move { + upstream + .call_read_tool(ReadToolCall::screenshot(FILE_KEY, "1:2")) + .await + }); + let job = next_job(&mut plugin).await; + plugin + .send(Message::Text( + json!({ "kind": "devup-result", "requestId": job["requestId"], + "error": "DEVUP_BRIDGE_UNKNOWN_SCRIPT: referencePng (아는 스크립트: snapshot)" }) + .to_string() + .into(), + )) + .await + .expect("the refusal is sent"); + let error = reading + .await + .expect("the read finishes") + .expect_err("there was no room to read it on the metered path"); + assert_eq!(error.code, ErrorCode::DevupFigmaRateLimited); + assert!(error.retryable); + assert_eq!(counts.get(), (0, 1)); +} + +#[tokio::test] +async fn original_upload_crosses_the_socket_without_remote_text_truncation() { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use devup_mcp_figma::{AssetRequest, original_image_from_result}; + use sha2::{Digest, Sha256}; + + let server = BridgeServer::start(0).unwrap(); + let mut plugin = connect_plugin(&server).await; + let client = BridgeFigmaClient::new(server.state()); + let request = AssetRequest::original_image("n", 2, "hash"); + let call = ReadToolCall::asset_export(FILE_KEY, Some("v1".into()), request.clone()); + let reading = tokio::spawn(async move { client.call_read_tool(call).await }); + let job = next_job(&mut plugin).await; + assert_eq!(job["script"], "assets"); + assert_eq!(job["params"]["asset"]["transport"], "bridge"); + assert_eq!(job["params"]["asset"]["field"], "$original-image/fills/2"); + let mut bytes = vec![31u8; 1_100_000]; + bytes[..3].copy_from_slice(&[255, 216, 255]); + let hash: String = Sha256::digest(&bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + plugin + .send(Message::Text( + json!({ + "kind":"devup-result", "requestId":job["requestId"], + "data":{"kind":"devupOriginalImage", "representation":"original-image-v1", + "fileKey":FILE_KEY, "version":"v1", "nodeId":"n", "assetId":request.asset_id, + "field":request.field, "imageHash":"hash", "status":"exported", + "format":null, "scale":null, "mimeType":"image/jpeg", "width":100, "height":100, + "byteLength":bytes.len(), "sha256":hash, "data":STANDARD.encode(&bytes)} + }) + .to_string() + .into(), + )) + .await + .unwrap(); + let result = reading.await.unwrap().unwrap(); + let original = original_image_from_result(&result, FILE_KEY, Some("v1"), &request).unwrap(); + assert_eq!(original.bytes, bytes); +} diff --git a/crates/devup-mcp-figma/tests/collector.rs b/crates/devup-mcp-figma/tests/collector.rs index 6d35379f..175f899f 100644 --- a/crates/devup-mcp-figma/tests/collector.rs +++ b/crates/devup-mcp-figma/tests/collector.rs @@ -1,2553 +1,4 @@ -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use devup_mcp_figma::{ - BuiltinScript, CollectionRequest, CollectionScope, CollectorSession, CollectorStep, DevupError, - DiagnosticSeverity, ErrorCode, ExploreBounds, ExploreReadOptions, FigmaTarget, ReadToolCall, - ResourceScope, SectionCandidate, SectionIndex, SectionReadOptions, SectionSummary, - UpstreamResult, merge_chunks, -}; -use image::{ExtendedColorType, ImageEncoder, codecs::png::PngEncoder}; -use serde_json::{Value, json}; - -#[test] -fn exact_node_fast_path_completes_in_one_call() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { - 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!( - arguments["code"] - .as_str() - .unwrap() - .contains("devupFastSnapshotEnvelope") - ); - assert!( - !arguments["code"] - .as_str() - .unwrap() - .contains("figma.io.write") - ); - - collector - .accept(&fast_call.id, fast_envelope_result()) - .unwrap(); - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("fast snapshot should complete without another 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, "text"); - assert!(!parts.stats.fallback_used); - assert_eq!(parts.stats.node_count, 1); - assert_eq!(parts.stats.variable_count, 0); - assert_eq!(parts.stats.style_count, 0); - let metadata = parts.metadata.to_string(); - assert!(!metadata.contains("image/png")); - assert!(!metadata.contains("devupFastSnapshotDescriptor")); -} - -#[test] -fn exact_node_fast_path_accepts_the_stringified_handoff_contract() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { - panic!("fast snapshot call expected") - }; - let result = fast_envelope_result(); - collector - .accept( - &fast_call.id, - UpstreamResult { - raw: Value::String(result.raw.to_string()), - }, - ) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("stringified fast snapshot should complete without fallback") - }; - assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.stats.transport, "text"); - assert!(!parts.stats.fallback_used); -} - -/// The about screen as the script really answers it: asked for `mobile`, it -/// gathers `tablet` and `desktop` beside it and pages the three through the -/// fast path. The collector must ride that to the end — it had been reading -/// the family as a target mismatch and restarting a legacy walk that cost -/// 282 calls for a screen the fast path pages through in a handful. -#[test] -fn a_family_gathered_around_the_target_pages_through_the_fast_path() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(first_call) = collector.advance().unwrap() else { - panic!("fast snapshot call expected") - }; - let family = json!(["1:2", "2:2", "3:2"]); - collector - .accept( - &first_call.id, - family_page( - &family, - json!([ - {"id": "1:2", "type": "FRAME", "fields": {"name": "mobile", "childrenIds": ["1:3"]}, "extra": {}, "fieldErrors": {}}, - {"id": "2:2", "type": "FRAME", "fields": {"name": "tablet", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, - {"id": "3:2", "type": "FRAME", "fields": {"name": "desktop", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, - ]), - (0, 3, false, 4), - ), - ) - .unwrap(); - - let CollectorStep::Call(second_call) = collector.advance().unwrap() else { - panic!("the second fast page expected, not a legacy restart") - }; - assert_eq!(second_call.call.tool_name(), "use_figma"); - let code = second_call.call.arguments()["code"] - .as_str() - .unwrap() - .to_owned(); - assert!(code.contains("devupFastSnapshotEnvelope"), "{code}"); - assert!(code.contains("\"offset\":3"), "{code}"); - collector - .accept( - &second_call.id, - family_page( - &family, - json!([ - {"id": "1:3", "type": "TEXT", "fields": {"name": "Child", "characters": "Done", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, - ]), - (3, 4, true, 4), - ), - ) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("the family should complete on the fast path") - }; - assert_eq!(parts.stats.figma_tool_calls, 2); - assert_eq!(parts.stats.transport, "text-paginated"); - assert!( - !parts.stats.fallback_used, - "{:?}", - parts.stats.fallback_reason - ); - assert_eq!(parts.stats.node_count, 4); - assert_eq!(parts.metadata["rootId"], "1:2"); - assert!( - parts - .snapshot_chunks - .iter() - .all(|chunk| json!(chunk.root_ids) == family), - "every page is rooted at the family" - ); -} - -/// One page of a fast snapshot that the script rooted at `root_ids`. -fn family_page( - root_ids: &Value, - nodes: Value, - (offset, next_offset, complete, total_nodes): (u64, u64, bool, u64), -) -> UpstreamResult { - let mut nodes = nodes.as_array().cloned().unwrap(); - // The cursor marker is a node of the page and counts in `nodeCount`, as - // the script counts it. - let node_count = nodes.len() + 1; - nodes.push(json!({ - "id": "__DEVUP_SNAPSHOT_CURSOR__", - "type": "DEVUP_INTERNAL", - "fields": {"offset": offset, "nextOffset": next_offset, "complete": complete, "totalNodes": total_nodes}, - "extra": {}, - "fieldErrors": {} - })); - let mut envelope = json!({ - "kind": "devupFastSnapshotEnvelope", - "schemaVersion": 1, - "source": {"fileKey": "FileKey123", "rootId": "1:2"}, - "snapshot": { - "fileKey": "FileKey123", - "version": "v1", - "rootIds": root_ids, - "nodes": nodes, - "diagnostics": [] - }, - "resources": { - "collections": [], - "variables": [], - "styles": [], - "usedRemoteVariables": [], - "localComplete": false, - "usedRemoteComplete": true, - "unresolved": [] - }, - "integrity": { - "nodeCount": node_count, - "variableRefCount": 0, - "styleRefCount": 0, - "utf8Bytes": 0 - } - }); - loop { - let bytes = serde_json::to_vec(&envelope).unwrap(); - if envelope["integrity"]["utf8Bytes"] == bytes.len() as u64 { - break; - } - envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); - } - UpstreamResult { - raw: json!({"content": [{"type": "text", "text": envelope.to_string()}]}), - } -} - -#[test] -fn requested_reference_png_is_collected_after_the_design_snapshot() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - request.reference_png = true; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { - panic!("fast snapshot call expected") - }; - collector - .accept(&fast_call.id, fast_envelope_result()) - .unwrap(); - - let CollectorStep::Call(screenshot_call) = collector.advance().unwrap() else { - panic!("reference screenshot call expected") - }; - assert_eq!(screenshot_call.call.tool_name(), "get_screenshot"); - assert_eq!(screenshot_call.call.arguments()["fileKey"], "FileKey123"); - assert_eq!(screenshot_call.call.arguments()["nodeId"], "1:2"); - // The official tool inlines the PNG only when asked, and halves a 1920px - // screen unless its 1024px cap is raised. - assert_eq!( - screenshot_call.call.arguments()["enableBase64Response"], - true - ); - assert_eq!(screenshot_call.call.arguments()["maxDimension"], 8192); - let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; - // As the official tool answers, measured 2026-09-07: the URL as JSON in - // a text block, how to fetch it in another, and the PNG inlined after. - collector - .accept( - &screenshot_call.id, - UpstreamResult { - raw: json!({"content": [ - {"type": "text", "text": "{\"image_url\":\"https://www.figma.com/api/mcp/asset/x.png\",\"width\":1,\"height\":1,\"format\":\"png\"}"}, - {"type": "text", "text": "The screenshot is hosted at the URL in the first content entry (as JSON)."}, - {"type": "image", "mimeType": "image/png", "data": data} - ]}), - }, - ) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("collection should complete with its reference PNG") - }; - let reference = parts.reference_png.expect("reference PNG"); - assert_eq!(reference.mime_type, "image/png"); - assert_eq!(reference.data_base64, data); - assert_eq!(reference.byte_length, STANDARD.decode(data).unwrap().len()); - assert_eq!(reference.sha256.len(), 64); - assert_eq!(parts.stats.figma_tool_calls, 2); -} - -#[test] -fn reference_png_rejects_a_signature_only_payload() { - let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); - let signature_only = STANDARD.encode(b"\x89PNG\r\n\x1a\n"); - - let error = collector - .accept( - &screenshot_call_id, - screenshot_result(json!({ - "type": "image", - "mimeType": "image/png", - "data": signature_only - })), - ) - .expect_err("a PNG signature without chunks or pixels must be rejected"); - - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[test] -fn reference_png_rejects_a_truncated_image() { - let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); - let mut png = STANDARD.decode(valid_reference_png_base64()).unwrap(); - png.truncate(png.len() - 8); - - let error = collector - .accept( - &screenshot_call_id, - screenshot_result(json!({ - "type": "image", - "mimeType": "image/png", - "data": STANDARD.encode(png) - })), - ) - .expect_err("a truncated PNG must be rejected even when its signature is intact"); - - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[test] -fn reference_png_rejects_multiple_image_content_blocks() { - let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); - let image = json!({ - "type": "image", - "mimeType": "image/png", - "data": valid_reference_png_base64() - }); - - let error = collector - .accept( - &screenshot_call_id, - UpstreamResult { - raw: json!({"content": [image.clone(), image]}), - }, - ) - .expect_err("referencePng accepts exactly one official ImageContent block"); - - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[test] -fn reference_png_rejects_zero_content_blocks() { - let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); - - let error = collector - .accept( - &screenshot_call_id, - UpstreamResult { - raw: json!({"content": []}), - }, - ) - .expect_err("referencePng requires exactly one ImageContent block"); - - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[test] -fn reference_png_rejects_an_image_hidden_in_json_text() { - let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); - let hidden = json!({ - "type": "image", - "mimeType": "image/png", - "data": valid_reference_png_base64() - }) - .to_string(); - - let error = collector - .accept( - &screenshot_call_id, - screenshot_result(json!({"type": "text", "text": hidden})), - ) - .expect_err("JSON embedded in text is not an official ImageContent response"); - - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[test] -fn reference_png_rejects_an_image_nested_outside_the_official_result() { - let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); - - let error = collector - .accept( - &screenshot_call_id, - UpstreamResult { - raw: json!({ - "result": { - "content": [{ - "type": "image", - "mimeType": "image/png", - "data": valid_reference_png_base64() - }] - } - }), - }, - ) - .expect_err("nested lookalikes are not the official CallToolResult shape"); - - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[test] -fn reference_png_rejects_dimensions_above_the_decode_bound() { - let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); - let pixels = vec![0_u8; 8_193]; - let mut png = Vec::new(); - PngEncoder::new(&mut png) - .write_image(&pixels, 8_193, 1, ExtendedColorType::L8) - .unwrap(); - - let error = collector - .accept( - &screenshot_call_id, - screenshot_result(json!({ - "type": "image", - "mimeType": "image/png", - "data": STANDARD.encode(png) - })), - ) - .expect_err("decoded dimensions are bounded independently of compressed size"); - - assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); -} - -#[test] -fn malformed_fast_result_restarts_legacy_from_metadata() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { - panic!("fast call expected") - }; - collector - .accept( - &fast_call.id, - UpstreamResult { - raw: json!({"content": [{"type": "text", "text": "unsupported"}]}), - }, - ) - .unwrap(); - - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!("legacy metadata must restart after a malformed envelope") - }; - assert_eq!(metadata_call.call.tool_name(), "get_metadata"); - collector - .accept(&metadata_call.id, metadata("FRAME", &[], 1)) - .unwrap(); - let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { - panic!("legacy snapshot expected") - }; - assert!( - snapshot_call.call.arguments()["code"] - .as_str() - .unwrap() - .contains("__DEVUP_SNAPSHOT_CURSOR__") - ); - collector - .accept(&snapshot_call.id, snapshot("1:2")) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("legacy fallback should complete") - }; - assert_eq!(parts.stats.figma_tool_calls, 3); - assert_eq!(parts.stats.transport, "legacy-cursor"); - assert!(parts.stats.fallback_used); - assert_eq!( - parts.stats.fallback_reason.as_deref(), - Some("textEnvelopeMissing") - ); - assert_eq!(parts.stats.node_count, 1); -} - -#[test] -fn a_split_subtree_still_snapshots_the_node_to_convert() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { - panic!("fast call expected") - }; - collector - .accept( - &fast_call.id, - UpstreamResult { - raw: json!({"content": [{"type": "text", "text": "unsupported"}]}), - }, - ) - .unwrap(); - - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!("legacy metadata must restart after a malformed envelope") - }; - collector - .accept(&metadata_call.id, metadata("FRAME", &["1:3"], 201)) - .unwrap(); - - let CollectorStep::Call(child) = collector.advance().unwrap() else { - panic!("the split child snapshot is expected first") - }; - let CollectorStep::Call(root) = collector.advance().unwrap() else { - panic!("the node to convert must be snapshotted as well") - }; - let child_code = child.call.arguments()["code"].as_str().unwrap().to_owned(); - let root_code = root.call.arguments()["code"].as_str().unwrap().to_owned(); - assert!(child_code.contains("\"1:3\""), "{child_code}"); - assert!(root_code.contains("\"1:2\""), "{root_code}"); -} - -#[test] -fn rejected_fast_call_can_restart_legacy_collection() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { - panic!("fast call expected") - }; - - let recovered = collector - .reject( - &fast_call.id, - &DevupError::new( - ErrorCode::DevupFigmaDirectUnavailable, - "private upstream detail", - true, - ), - ) - .unwrap(); - - assert!(recovered); - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!("legacy metadata call expected") - }; - assert_eq!(metadata_call.call.tool_name(), "get_metadata"); -} - -#[test] -fn legacy_used_resources_combine_variables_and_styles_in_one_call() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept( - &fast_call.id, - UpstreamResult { - raw: json!({"content": [{"type": "text", "text": "fallback"}]}), - }, - ) - .unwrap(); - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept(&metadata_call.id, metadata("FRAME", &[], 1)) - .unwrap(); - let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { - panic!() - }; - let mut used_snapshot = snapshot("1:2"); - used_snapshot.raw["nodes"][0]["fields"] = json!({ - "name": "Synthetic", - "boundVariables": { - "fills": [{"type": "VARIABLE_ALIAS", "id": "VariableID:1:2"}] - }, - "textStyleId": "S:text" - }); - collector.accept(&snapshot_call.id, used_snapshot).unwrap(); - - let CollectorStep::Call(resources_call) = collector.advance().unwrap() else { - panic!("one combined resource call expected") - }; - let code = resources_call.call.arguments()["code"] - .as_str() - .unwrap() - .to_owned(); - assert!(code.contains("VariableID:1:2")); - assert!(code.contains("S:text")); - assert!(matches!( - collector.advance().unwrap(), - CollectorStep::AwaitingResults - )); -} - -fn target(node_id: &str) -> FigmaTarget { - FigmaTarget::parse(&format!( - "https://www.figma.com/design/FileKey123/Fixture?node-id={}", - node_id.replace(':', "-") - )) - .unwrap() -} - -fn collector_awaiting_reference_png() -> (CollectorSession, String) { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - request.reference_png = true; - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { - panic!("fast snapshot call expected") - }; - collector - .accept(&fast_call.id, fast_envelope_result()) - .unwrap(); - let CollectorStep::Call(screenshot_call) = collector.advance().unwrap() else { - panic!("reference screenshot call expected") - }; - (collector, screenshot_call.id) -} - -fn screenshot_result(content: Value) -> UpstreamResult { - UpstreamResult { - raw: json!({"content": [content]}), - } -} - -fn valid_reference_png_base64() -> &'static str { - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" -} - -fn fast_envelope_result() -> 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": []}, - "extra": {}, - "fieldErrors": {} - }], - "diagnostics": [] - }, - "resources": { - "collections": [], - "variables": [], - "styles": [], - "usedRemoteVariables": [], - "localComplete": false, - "usedRemoteComplete": true, - "unresolved": [] - }, - "integrity": { - "nodeCount": 1, - "variableRefCount": 0, - "styleRefCount": 0, - "utf8Bytes": 0 - } - }); - let envelope_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 _ = 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": envelope.to_string()} - ]}), - } -} - -fn fast_theme_envelope_result() -> UpstreamResult { - let mut envelope = json!({ - "kind": "devupFastThemeEnvelope", - "schemaVersion": 1, - "source": {"fileKey": "FileKey123", "version": "v2"}, - "resources": { - "collections": [{"id": "c", "name": "Theme"}], - "variables": [{"id": "v", "name": "primary"}], - "styles": [{"id": "s", "name": "body", "styleType": "TEXT"}], - "usedRemoteVariables": [], - "usedVariableIds": ["v"], - "usedStyleIds": ["s"], - "localComplete": true, - "usedRemoteComplete": true, - "unresolved": [] - }, - "integrity": { - "collectionCount": 1, - "variableCount": 1, - "styleCount": 1, - "unresolvedCount": 0, - "utf8Bytes": 0 - } - }); - let envelope_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 _ = envelope_bytes; - UpstreamResult { - raw: json!({"content": [ - {"type": "text", "text": envelope.to_string()} - ]}), - } -} - -fn file_target() -> FigmaTarget { - FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture").unwrap() -} - -fn metadata(node_type: &str, children: &[&str], descendant_count: usize) -> UpstreamResult { - UpstreamResult { - raw: json!({ - "structuredContent": { - "devupMetadata": { - "fileKey": "FileKey123", - "version": "v1", - "rootId": "1:2", - "nodes": [{ - "id": "1:2", - "type": node_type, - "childrenIds": children, - "descendantCount": descendant_count - }] - } - } - }), - } -} - -fn snapshot(root_id: &str) -> UpstreamResult { - UpstreamResult { - raw: json!({ - "fileKey": "FileKey123", - "version": "v1", - "rootIds": [root_id], - "nodes": [{ - "id": root_id, - "type": "FRAME", - "fields": {"name": "Synthetic"}, - "extra": {}, - "fieldErrors": {} - }] - }), - } -} - -fn official_xml_metadata() -> UpstreamResult { - UpstreamResult { - raw: json!({ - "content": [ - { - "type": "text", - "text": "" - }, - { - "type": "text", - "text": "Synthetic guidance that is not metadata" - } - ] - }), - } -} - -fn official_top_level_pages() -> UpstreamResult { - 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: Cover\n- 12:34: Body: Proofread" - }] - }), - } -} - -fn file_page_metadata() -> UpstreamResult { - UpstreamResult { - raw: json!({ - "structuredContent": { - "devupMetadata": { - "fileKey": "FileKey123", - "version": "v1", - "rootId": "0:1", - "nodes": [ - { - "id": "0:1", - "type": "PAGE", - "name": "Cover", - "childrenIds": ["1:2"], - "descendantCount": 1 - }, - { - "id": "1:2", - "type": "FRAME", - "name": "A : STORY-F-PROOFREAD", - "childrenIds": [], - "descendantCount": 0 - } - ] - } - } - }), - } -} - -#[test] -fn file_scope_starts_from_the_file_even_when_the_url_contains_a_node() { - let request = CollectionRequest::new(target("1:2"), CollectionScope::File); - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!("metadata call expected") - }; - assert_eq!(metadata_call.call.tool_name(), "get_metadata"); - assert_eq!(metadata_call.expected_node_id, None); - // Omitted, not null: the official schema refuses `nodeId: null`. - assert!(!metadata_call.call.arguments().contains_key("nodeId")); -} - -#[test] -fn official_top_level_page_list_expands_to_page_metadata_calls() { - let request = CollectionRequest::new(file_target(), CollectionScope::File); - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(file_metadata) = collector.advance().unwrap() else { - panic!("file metadata call expected") - }; - - collector - .accept(&file_metadata.id, official_top_level_pages()) - .unwrap(); - - let CollectorStep::Call(first_page) = collector.advance().unwrap() else { - panic!("first page metadata call expected") - }; - let CollectorStep::Call(second_page) = collector.advance().unwrap() else { - panic!("second page metadata call expected") - }; - assert_eq!(first_page.call.tool_name(), "get_metadata"); - assert_eq!(first_page.expected_node_id.as_deref(), Some("0:1")); - assert_eq!(second_page.call.tool_name(), "get_metadata"); - assert_eq!(second_page.expected_node_id.as_deref(), Some("12:34")); -} - -#[test] -fn metadata_only_file_collection_completes_without_snapshot_calls() { - let mut request = CollectionRequest::new(file_target(), CollectionScope::File); - request.metadata_only = true; - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(file_metadata) = collector.advance().unwrap() else { - panic!("file metadata call expected") - }; - collector - .accept(&file_metadata.id, official_top_level_pages()) - .unwrap(); - - let CollectorStep::Call(first_page) = collector.advance().unwrap() else { - panic!("first page metadata call expected") - }; - let CollectorStep::Call(second_page) = collector.advance().unwrap() else { - panic!("second page metadata call expected") - }; - collector - .accept(&first_page.id, file_page_metadata()) - .unwrap(); - let mut second = file_page_metadata(); - second.raw["structuredContent"]["devupMetadata"]["rootId"] = json!("12:34"); - second.raw["structuredContent"]["devupMetadata"]["nodes"] = json!([{ - "id": "12:34", - "type": "PAGE", - "name": "Body: Proofread", - "childrenIds": [], - "descendantCount": 0 - }]); - collector.accept(&second_page.id, second).unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("metadata-only collection should complete") - }; - assert_eq!(parts.snapshot_chunks.len(), 1); - let chunk = &parts.snapshot_chunks[0]; - assert_eq!(chunk.root_ids, ["0:1", "12:34"]); - assert_eq!(chunk.nodes.len(), 3); - let match_node = chunk.nodes.iter().find(|node| node.id == "1:2").unwrap(); - assert_eq!(match_node.fields["name"], json!("A : STORY-F-PROOFREAD")); -} - -#[test] -fn variables_only_file_collection_skips_page_and_node_snapshots() { - let mut request = CollectionRequest::new(file_target(), CollectionScope::File); - request.resource_scope = ResourceScope::File; - request.variables_only = true; - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(fast_theme) = collector.advance().unwrap() else { - 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!( - arguments["code"] - .as_str() - .unwrap() - .contains("devupFastThemeEnvelope") - ); - collector - .accept(&fast_theme.id, fast_theme_envelope_result()) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("valid fast theme should complete in one call") - }; - assert_eq!(parts.stats.figma_tool_calls, 1); - 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); - assert_eq!(parts.variables.as_ref().unwrap().raw["localComplete"], true); -} - -/// A file's resources rarely fit one answer, so the theme script pages -/// them: each page carries a run of the resources from an offset and says -/// where the next starts, and the collector asks for the next page from -/// there until one says it is the last. The pages are merged in order, a -/// resource two pages both carry is kept once, and the scan every page -/// repeats comes from the last one. -#[test] -fn a_paged_fast_theme_is_read_page_by_page_and_merged() { - let mut request = CollectionRequest::new(file_target(), CollectionScope::File); - request.resource_scope = ResourceScope::File; - request.variables_only = true; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(first) = collector.advance().unwrap() else { - panic!("fast theme call expected") - }; - assert!( - first.call.arguments()["code"] - .as_str() - .unwrap() - .contains("{\"offset\":0}") - ); - collector - .accept( - &first.id, - fast_theme_page_result( - json!({"collections": [{"id": "c", "name": "Theme"}], "variables": [{"id": "v1", "name": "primary"}], "styles": [], "usedVariableIds": ["v1"]}), - json!({"offset": 0, "nextOffset": 2, "complete": false, "totalItems": 6}), - ), - ) - .unwrap(); - - let CollectorStep::Call(second) = collector.advance().unwrap() else { - panic!("the next page is asked for") - }; - assert_eq!(second.call.tool_name(), "use_figma"); - assert!( - second.call.arguments()["code"] - .as_str() - .unwrap() - .contains("{\"offset\":2}") - ); - collector - .accept( - &second.id, - fast_theme_page_result( - json!({"collections": [], "variables": [{"id": "v1", "name": "primary"}, {"id": "v2", "name": "text"}], "styles": [{"id": "s", "name": "body", "styleType": "TEXT"}], "usedVariableIds": ["v1", "v2"], "usedStyleIds": ["s"]}), - json!({"offset": 2, "nextOffset": 6, "complete": true, "totalItems": 6}), - ), - ) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("the last page completes the theme") - }; - assert_eq!(parts.stats.figma_tool_calls, 2); - assert_eq!(parts.stats.transport, "text"); - assert!(!parts.stats.fallback_used); - let resources = &parts.variables.as_ref().unwrap().raw; - assert_eq!(resources["collections"].as_array().unwrap().len(), 1); - // v1 was on both pages and is kept once. - assert_eq!( - resources["variables"] - .as_array() - .unwrap() - .iter() - .map(|variable| variable["id"].as_str().unwrap()) - .collect::>(), - ["v1", "v2"] - ); - assert_eq!(resources["styles"].as_array().unwrap().len(), 1); - assert_eq!(resources["localComplete"], true); - // The scan came in two shares, v1 on both, and is whole and unrepeated. - assert_eq!(resources["usedVariableIds"], json!(["v1", "v2"])); - assert_eq!(resources["usedStyleIds"], json!(["s"])); - assert_eq!(parts.stats.variable_count, 2); - assert_eq!(parts.stats.style_count, 1); - assert_eq!(parts.metadata["pageCount"], 2); -} - -/// A page that does not move forward is refused, not asked for forever. -#[test] -fn a_fast_theme_page_that_does_not_advance_is_refused() { - let mut request = CollectionRequest::new(file_target(), CollectionScope::File); - request.resource_scope = ResourceScope::File; - request.variables_only = true; - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(first) = collector.advance().unwrap() else { - panic!("fast theme call expected") - }; - let error = collector - .accept( - &first.id, - fast_theme_page_result( - json!({"collections": [], "variables": [], "styles": []}), - json!({"offset": 0, "nextOffset": 0, "complete": false, "totalItems": 4}), - ), - ) - .expect_err("a page cursor that stands still is an error"); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -fn fast_theme_page_result(resources: Value, page: Value) -> UpstreamResult { - // The scan is paged like the resources: this page's share of the used - // ids, or none. - let used_variable_ids = resources - .get("usedVariableIds") - .cloned() - .unwrap_or_else(|| json!([])); - let used_style_ids = resources - .get("usedStyleIds") - .cloned() - .unwrap_or_else(|| json!([])); - let mut envelope = json!({ - "kind": "devupFastThemeEnvelope", - "schemaVersion": 1, - "source": {"fileKey": "FileKey123", "version": "v2"}, - "resources": { - "collections": resources["collections"], - "variables": resources["variables"], - "styles": resources["styles"], - "usedRemoteVariables": [], - "usedVariableIds": used_variable_ids, - "usedStyleIds": used_style_ids, - "localComplete": true, - "usedRemoteComplete": true, - "unresolved": [] - }, - "page": page, - "integrity": { - "collectionCount": resources["collections"].as_array().map_or(0, Vec::len), - "variableCount": resources["variables"].as_array().map_or(0, Vec::len), - "styleCount": resources["styles"].as_array().map_or(0, Vec::len), - "unresolvedCount": 0, - "utf8Bytes": 0 - } - }); - loop { - let bytes = serde_json::to_vec(&envelope).unwrap(); - if envelope["integrity"]["utf8Bytes"] == bytes.len() as u64 { - break; - } - envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); - } - UpstreamResult { - raw: json!({"content": [{"type": "text", "text": envelope.to_string()}]}), - } -} - -#[test] -fn malformed_or_rejected_fast_theme_restarts_legacy_collection_atomically() { - let mut request = CollectionRequest::new(file_target(), CollectionScope::File); - request.resource_scope = ResourceScope::File; - request.variables_only = true; - let mut malformed = CollectorSession::new(request.clone()); - let CollectorStep::Call(fast) = malformed.advance().unwrap() else { - panic!("fast theme call expected") - }; - malformed - .accept( - &fast.id, - UpstreamResult { - raw: json!({"content": [{"type": "text", "text": "corrupt"}]}), - }, - ) - .unwrap(); - let CollectorStep::Call(metadata) = malformed.advance().unwrap() else { - panic!("legacy metadata must restart from zero") - }; - assert_eq!(metadata.call.tool_name(), "get_metadata"); - assert_eq!(metadata.expected_node_id, None); - - let mut rejected = CollectorSession::new(request); - let CollectorStep::Call(fast) = rejected.advance().unwrap() else { - panic!("fast theme call expected") - }; - assert!( - rejected - .reject( - &fast.id, - &DevupError::new( - ErrorCode::DevupFigmaDirectUnavailable, - "fast theme unavailable", - true, - ), - ) - .unwrap() - ); - let CollectorStep::Call(metadata) = rejected.advance().unwrap() else { - panic!("rejected fast theme must restart legacy metadata") - }; - assert_eq!(metadata.call.tool_name(), "get_metadata"); -} - -#[test] -fn node_collection_advances_from_metadata_to_snapshot_to_complete() { - let request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!("metadata call expected") - }; - assert_eq!(metadata_call.call.tool_name(), "get_metadata"); - collector - .accept(&metadata_call.id, metadata("FRAME", &[], 1)) - .unwrap(); - - let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { - panic!("snapshot call expected") - }; - assert_eq!(snapshot_call.call.tool_name(), "use_figma"); - assert_eq!(snapshot_call.expected_node_id.as_deref(), Some("1:2")); - collector - .accept(&snapshot_call.id, snapshot("1:2")) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("collection should be complete") - }; - assert_eq!(parts.target.node_id.as_deref(), Some("1:2")); - assert_eq!(parts.source_version.as_deref(), Some("v1")); - assert_eq!(parts.snapshot_chunks.len(), 1); -} - -#[test] -fn large_page_is_split_in_declared_child_order() { - let request = CollectionRequest::new(target("1:2"), CollectionScope::Page); - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept(&call.id, metadata("PAGE", &["1:3", "1:4"], 500)) - .unwrap(); - - let CollectorStep::Call(first) = collector.advance().unwrap() else { - panic!() - }; - let CollectorStep::Call(second) = collector.advance().unwrap() else { - panic!() - }; - assert_eq!(first.expected_node_id.as_deref(), Some("1:3")); - assert_eq!(second.expected_node_id.as_deref(), Some("1:4")); - - collector.accept(&second.id, snapshot("1:4")).unwrap(); - collector.accept(&first.id, snapshot("1:3")).unwrap(); - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!() - }; - let root_order = parts - .snapshot_chunks - .iter() - .flat_map(|chunk| chunk.root_ids.iter().map(String::as_str)) - .collect::>(); - assert_eq!(root_order, ["1:3", "1:4"]); -} - -#[test] -fn rejects_snapshot_from_a_different_file_version() { - let request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept(&metadata_call.id, metadata("FRAME", &[], 1)) - .unwrap(); - let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { - panic!() - }; - let mut wrong_version = snapshot("1:2"); - wrong_version.raw["version"] = json!("v2"); - - let error = collector - .accept(&snapshot_call.id, wrong_version) - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaVersionChanged); -} - -#[test] -fn exposes_at_most_four_in_flight_calls() { - let request = CollectionRequest::new(target("1:2"), CollectionScope::Page); - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept( - &metadata_call.id, - metadata("PAGE", &["1:3", "1:4", "1:5", "1:6", "1:7"], 500), - ) - .unwrap(); - - let mut issued = Vec::new(); - for _ in 0..4 { - let CollectorStep::Call(call) = collector.advance().unwrap() else { - panic!("four calls should be available") - }; - issued.push(call); - } - assert!(matches!( - collector.advance().unwrap(), - CollectorStep::AwaitingResults - )); - - collector.accept(&issued[0].id, snapshot("1:3")).unwrap(); - let CollectorStep::Call(fifth) = collector.advance().unwrap() else { - panic!("the fifth call should be released after one result") - }; - assert_eq!(fifth.expected_node_id.as_deref(), Some("1:7")); -} - -#[test] -fn rejects_unknown_or_replayed_call_ids() { - let request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(call) = collector.advance().unwrap() else { - panic!() - }; - - let unknown = collector - .accept("unknown", metadata("FRAME", &[], 1)) - .unwrap_err(); - assert_eq!(unknown.code, ErrorCode::DevupFigmaHandoffInvalid); - - collector - .accept(&call.id, metadata("FRAME", &[], 1)) - .unwrap(); - let replayed = collector - .accept(&call.id, metadata("FRAME", &[], 1)) - .unwrap_err(); - assert_eq!(replayed.code, ErrorCode::DevupFigmaHandoffInvalid); -} - -#[test] -fn variable_collection_uses_catalog_then_batched_resources() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::File; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept(&metadata_call.id, metadata("FRAME", &[], 1)) - .unwrap(); - let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept(&snapshot_call.id, snapshot("1:2")) - .unwrap(); - - let CollectorStep::Call(catalog_call) = collector.advance().unwrap() else { - panic!("variable/style catalog should follow the snapshot") - }; - let catalog_code = catalog_call.call.arguments()["code"] - .as_str() - .unwrap() - .to_owned(); - assert!(catalog_code.contains("getLocalVariableCollectionsAsync")); - assert!(!catalog_code.contains("getLocalVariablesAsync")); - let catalog = UpstreamResult { - raw: json!({ - "content": [{ - "type": "text", - "text": "{\"collections\":[{\"id\":\"c1\",\"name\":\"Synthetic\",\"defaultModeId\":\"m1\",\"modes\":[]}],\"variableIds\":[\"v1\"],\"styles\":[{\"id\":\"s1\",\"styleType\":\"TEXT\"}],\"localComplete\":true,\"usedRemoteComplete\":false}" - }] - }), - }; - collector.accept(&catalog_call.id, catalog).unwrap(); - - let CollectorStep::Call(batch_call) = collector.advance().unwrap() else { - panic!("resource batch should follow the catalog") - }; - let batch_code = batch_call.call.arguments()["code"] - .as_str() - .unwrap() - .to_owned(); - assert!(batch_code.contains("getVariableByIdAsync")); - assert!(batch_code.contains("getStyleByIdAsync")); - let variable_batch = UpstreamResult { - raw: json!({ - "content": [{ - "type": "text", - "text": "{\"variables\":[{\"id\":\"v1\",\"name\":\"Synthetic Variable\"}],\"styles\":[]}" - }] - }), - }; - collector.accept(&batch_call.id, variable_batch).unwrap(); - let CollectorStep::Call(style_call) = collector.advance().unwrap() else { - panic!("style batch should be separate") - }; - collector - .accept( - &style_call.id, - UpstreamResult { - raw: json!({ - "variables": [], - "styles": [{"id": "s1", "name": "Synthetic Style", "styleType": "TEXT", "value": {}}] - }), - }, - ) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("collection should finish after variables") - }; - let merged = &parts.variables.as_ref().unwrap().raw; - assert_eq!(merged["collections"][0]["id"], "c1"); - assert_eq!(merged["variables"][0]["id"], "v1"); - assert_eq!(merged["styles"][0]["id"], "s1"); - assert_eq!(parts.styles.as_ref().unwrap().raw, *merged); -} - -#[test] -fn a_file_theme_reads_the_library_resources_its_nodes_use_and_its_variables_alias() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::File; - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept(&metadata_call.id, metadata("FRAME", &[], 1)) - .unwrap(); - let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { - panic!() - }; - // The node pads with a local variable and fills with a library one. - collector - .accept( - &snapshot_call.id, - UpstreamResult { - raw: json!({ - "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], - "nodes": [{"id": "1:2", "type": "FRAME", "fields": { - "name": "Frame", "childrenIds": [], - "boundVariables": {"paddingLeft": {"type": "VARIABLE_ALIAS", "id": "VariableID:1:1"}}, - "fills": [{"type": "SOLID", "boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": "VariableID:lib/2:2"}}, "extra": {}, "fieldErrors": {}}] - }}] - }), - }, - ) - .unwrap(); - let CollectorStep::Call(catalog_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept( - &catalog_call.id, - UpstreamResult { - raw: json!({ - "collections": [{"id": "c1", "name": "Local", "defaultModeId": "m", "modes": [{"modeId": "m", "name": "Mobile"}]}], - "variableIds": ["VariableID:1:1"], "styles": [], - "localComplete": true, "usedRemoteComplete": false - }), - }, - ) - .unwrap(); - let CollectorStep::Call(local_batch) = collector.advance().unwrap() else { - panic!() - }; - // The local variable aliases a library primitive no node binds. - collector - .accept( - &local_batch.id, - UpstreamResult { - raw: json!({"variables": [{"id": "VariableID:1:1", "name": "containerX", "variableCollectionId": "c1", - "valuesByMode": {"m": {"type": "VARIABLE_ALIAS", "id": "VariableID:lib/3:3"}}}], "styles": []}), - }, - ) - .unwrap(); - let CollectorStep::Call(library_call) = collector.advance().unwrap() else { - panic!("the library resources should be read after the catalog") - }; - let code = library_call.call.arguments()["code"] - .as_str() - .unwrap() - .to_owned(); - assert!( - code.contains("VariableID:lib/2:2"), - "the bound library variable is read" - ); - assert!( - code.contains("VariableID:lib/3:3"), - "the aliased library variable is read" - ); - assert!( - !code.contains("\"VariableID:1:1\""), - "the local variable is not read again" - ); - collector - .accept( - &library_call.id, - UpstreamResult { - raw: json!({ - "collections": [{"id": "lib", "name": "Library", "defaultModeId": "x", "modes": [{"modeId": "x", "name": "Mode 1"}]}], - "variables": [ - {"id": "VariableID:lib/2:2", "name": "gray", "remote": true, "variableCollectionId": "lib", "valuesByMode": {"x": {"r": 0, "g": 0, "b": 0, "a": 1}}}, - {"id": "VariableID:lib/3:3", "name": "spacing20", "remote": true, "variableCollectionId": "lib", "valuesByMode": {"x": {"type": "VARIABLE_ALIAS", "id": "VariableID:lib/4:4"}}}, - // What the read followed: the primitive the spacing aliases. - {"id": "VariableID:lib/4:4", "name": "base20", "remote": true, "variableCollectionId": "lib", "valuesByMode": {"x": 20}} - ], - "styles": [], "unresolved": [] - }), - }, - ) - .unwrap(); - let step = collector.advance().unwrap(); - let CollectorStep::Complete(parts) = step else { - panic!("collection should finish after the library resources: {step:?}") - }; - let raw = &parts.variables.as_ref().unwrap().raw; - let ids = |key: &str| { - raw[key] - .as_array() - .unwrap() - .iter() - .map(|value| value["id"].as_str().unwrap().to_owned()) - .collect::>() - }; - assert_eq!( - ids("variables"), - [ - "VariableID:1:1", - "VariableID:lib/2:2", - "VariableID:lib/3:3", - "VariableID:lib/4:4" - ] - ); - assert_eq!( - ids("usedRemoteVariables"), - [ - "VariableID:lib/2:2", - "VariableID:lib/3:3", - "VariableID:lib/4:4" - ] - ); - assert_eq!(ids("collections"), ["c1", "lib"]); - assert_eq!(raw["localComplete"], true); - assert_eq!(raw["usedRemoteComplete"], true); -} - -#[test] -fn accepts_the_official_xml_metadata_envelope_without_inventing_values() { - let request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!() - }; - - collector - .accept(&metadata_call.id, official_xml_metadata()) - .unwrap(); - let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { - panic!() - }; - assert_eq!(snapshot_call.expected_file_key, "FileKey123"); - assert_eq!(snapshot_call.expected_node_id.as_deref(), Some("1:2")); -} - -#[test] -fn variable_batches_merge_in_catalog_order_when_results_arrive_out_of_order() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::File; - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept(&metadata_call.id, metadata("FRAME", &[], 1)) - .unwrap(); - let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept(&snapshot_call.id, snapshot("1:2")) - .unwrap(); - let CollectorStep::Call(catalog_call) = collector.advance().unwrap() else { - panic!() - }; - let variable_ids = (0..9) - .map(|index| format!("v{index:02}")) - .collect::>(); - let expected_ids = variable_ids.clone(); - collector - .accept( - &catalog_call.id, - UpstreamResult { - raw: json!({ - "collections": [], - "variableIds": variable_ids, - "styles": [], - "localComplete": true, - "usedRemoteComplete": false - }), - }, - ) - .unwrap(); - let CollectorStep::Call(first) = collector.advance().unwrap() else { - panic!() - }; - let CollectorStep::Call(second) = collector.advance().unwrap() else { - panic!() - }; - let first_values = (0..8) - .map(|index| json!({"id": format!("v{index:02}")})) - .collect::>(); - collector - .accept( - &second.id, - UpstreamResult { - raw: json!({"variables": [{"id": "v08"}], "styles": []}), - }, - ) - .unwrap(); - collector - .accept( - &first.id, - UpstreamResult { - raw: json!({"variables": first_values, "styles": []}), - }, - ) - .unwrap(); - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!() - }; - let variables = parts.variables.unwrap(); - let ids = variables.raw["variables"] - .as_array() - .unwrap() - .iter() - .map(|variable| variable["id"].as_str().unwrap()) - .collect::>(); - assert_eq!(ids, expected_ids); -} - -#[test] -fn style_consumers_are_planned_as_compact_bounded_fragments() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::File; - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept(&metadata_call.id, metadata("FRAME", &[], 1)) - .unwrap(); - let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept(&snapshot_call.id, snapshot("1:2")) - .unwrap(); - let CollectorStep::Call(catalog_call) = collector.advance().unwrap() else { - panic!() - }; - collector - .accept( - &catalog_call.id, - UpstreamResult { - raw: json!({ - "collections": [], - "variableIds": [], - "styles": [ - {"id": "s1", "styleType": "TEXT"}, - {"id": "s2", "styleType": "PAINT"} - ], - "localComplete": true, - "usedRemoteComplete": false - }), - }, - ) - .unwrap(); - - let CollectorStep::Call(base_styles) = collector.advance().unwrap() else { - panic!("style definitions should be batched") - }; - let base_arguments = base_styles.call.arguments(); - let base_code = base_arguments["code"].as_str().unwrap(); - assert!(base_code.contains("s1")); - assert!(base_code.contains("s2")); - collector - .accept( - &base_styles.id, - UpstreamResult { - raw: json!({ - "variables": [], - "styles": [ - {"id": "s1", "name": "Text", "styleType": "TEXT", "value": {}, "$consumerCount": 321}, - {"id": "s2", "name": "Paint", "styleType": "PAINT", "value": [], "$consumerCount": 0} - ] - }), - }, - ) - .unwrap(); - - let CollectorStep::Call(first) = collector.advance().unwrap() else { - panic!("first consumer fragment should be scheduled") - }; - let CollectorStep::Call(second) = collector.advance().unwrap() else { - panic!("second consumer fragment should be scheduled") - }; - let first_arguments = first.call.arguments(); - let second_arguments = second.call.arguments(); - let first_code = first_arguments["code"].as_str().unwrap(); - let second_code = second_arguments["code"].as_str().unwrap(); - assert!(first_code.contains("s1")); - assert!(first_code.contains("\"consumerStart\":0")); - assert!(first_code.contains("\"consumerEnd\":320")); - assert!(second_code.contains("s1")); - assert!(second_code.contains("\"consumerStart\":320")); - assert!(second_code.contains("\"consumerEnd\":321")); -} - -#[test] -fn used_scope_resolves_only_snapshot_references_and_keeps_partial_results() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { - panic!("fast call expected") - }; - collector - .accept( - &fast_call.id, - UpstreamResult { - raw: json!({"content": [{"type": "text", "text": "fallback"}]}), - }, - ) - .unwrap(); - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!("metadata call expected") - }; - collector - .accept(&metadata_call.id, metadata("FRAME", &[], 1)) - .unwrap(); - let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { - panic!("snapshot call expected") - }; - let mut used_snapshot = snapshot("1:2"); - used_snapshot.raw["nodes"][0]["fields"] = json!({ - "name": "Synthetic", - "boundVariables": { - "fills": [{"type": "VARIABLE_ALIAS", "id": "VariableID:1:2"}] - }, - "textStyleId": "S:text" - }); - collector.accept(&snapshot_call.id, used_snapshot).unwrap(); - - let CollectorStep::Call(resource_call) = collector.advance().unwrap() else { - panic!("combined used resource batch should follow the snapshot") - }; - let resource_code = resource_call.call.arguments()["code"] - .as_str() - .unwrap() - .to_owned(); - assert!(resource_code.contains("getVariableByIdAsync")); - assert!(resource_code.contains("VariableID:1:2")); - assert!(resource_code.contains("S:text")); - assert!(!resource_code.contains("getStyleConsumersAsync")); - - collector - .accept( - &resource_call.id, - UpstreamResult { - raw: json!({ - "collections": [{ - "id": "collection:1", - "name": "Foundation", - "defaultModeId": "mode:1", - "modes": [{"modeId": "mode:1", "name": "Default"}] - }], - "variables": [{ - "id": "VariableID:1:2", - "name": "primary", - "remote": true, - "variableCollectionId": "collection:1", - "resolvedType": "COLOR", - "valuesByMode": {"mode:1": {"r": 1, "g": 0, "b": 0, "a": 1}} - }], - "styles": [], - "unresolved": [{"id": "S:text", "kind": "style", "reason": "notFoundOrUnavailable"}] - }), - }, - ) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("used resource collection should complete") - }; - let resources = &parts.variables.as_ref().unwrap().raw; - assert_eq!(resources["collections"][0]["id"], "collection:1"); - assert_eq!(resources["variables"][0]["name"], "primary"); - assert_eq!(resources["styles"], json!([])); - assert_eq!(resources["usedRemoteComplete"], false); - assert_eq!(resources["unresolved"][0]["id"], "S:text"); - assert_eq!(resources["usedVariableIds"], json!(["VariableID:1:2"])); - assert_eq!(resources["usedStyleIds"], json!(["S:text"])); - let diagnostic = parts.snapshot_chunks[0] - .diagnostics - .iter() - .find(|diagnostic| diagnostic.code == "DEVUP_RESOURCE_UNRESOLVED") - .expect("unresolved resource diagnostic"); - assert_eq!(diagnostic.node_id.as_deref(), Some("1:2")); - assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::Warning)); - assert_eq!(diagnostic.property.as_deref(), Some("textStyleId")); - assert_eq!(diagnostic.resource_kind.as_deref(), Some("style")); - assert_eq!(diagnostic.resource_id.as_deref(), Some("S:text")); - assert_eq!(diagnostic.fallback.as_deref(), Some("raw-value")); - assert_eq!(diagnostic.recoverable, Some(true)); -} - -#[test] -fn explore_collection_starts_with_one_bounded_projection_and_no_resources() { - let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - request.explore = Some(ExploreReadOptions::default()); - request.resource_scope = ResourceScope::None; - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(explore_call) = collector.advance().unwrap() else { - panic!("explore projection call expected") - }; - assert_eq!(explore_call.call.tool_name(), "use_figma"); - assert_eq!(explore_call.expected_node_id.as_deref(), Some("1:2")); - let code = explore_call.call.arguments()["code"] - .as_str() - .unwrap() - .to_owned(); - assert!(code.contains("projectionTruncated")); - assert!(!code.contains("getLocalVariableCollectionsAsync")); - assert!(!code.contains("getVariableByIdAsync")); - - collector - .accept( - &explore_call.id, - UpstreamResult { - raw: json!({ - "fileKey": "FileKey123", - "version": null, - "rootIds": ["0:1"], - "nodes": [ - { - "id": "0:1", "type": "PAGE", - "fields": {"name": "Page", "x": 0, "y": 0, "width": 1000, "height": 1000, "projectionTruncated": false}, - "extra": {}, "fieldErrors": {} - }, - { - "id": "1:2", "type": "FRAME", - "fields": {"name": "Heading", "parentId": "0:1", "x": 0, "y": 0, "width": 1000, "height": 80, "childCount": 1}, - "extra": {}, "fieldErrors": {} - } - ], - "diagnostics": [] - }), - }, - ) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("explore collection should complete after one projection") - }; - assert_eq!(parts.snapshot_chunks.len(), 1); - assert_eq!(parts.snapshot_chunks[0].nodes.len(), 2); - assert!(parts.variables.is_none()); -} - -#[test] -fn node_snapshot_follows_the_compiled_cursor_until_complete() { - let request = CollectionRequest::new(target("1:2"), CollectionScope::Node); - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { - panic!("metadata call expected") - }; - collector - .accept(&metadata_call.id, metadata("FRAME", &[], 2)) - .unwrap(); - - let CollectorStep::Call(first_call) = collector.advance().unwrap() else { - panic!("first snapshot chunk expected") - }; - assert!( - first_call.call.arguments()["code"] - .as_str() - .unwrap() - .contains("\"offset\":0") - ); - collector - .accept( - &first_call.id, - UpstreamResult { - raw: json!({ - "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": {"offset":0,"nextOffset": 1, "complete": false, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} - ], "diagnostics": [] - }), - }, - ) - .unwrap(); - - let CollectorStep::Call(second_call) = collector.advance().unwrap() else { - panic!("second snapshot chunk expected") - }; - assert!( - second_call.call.arguments()["code"] - .as_str() - .unwrap() - .contains("\"offset\":1") - ); - collector - .accept( - &second_call.id, - UpstreamResult { - raw: json!({ - "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], - "nodes": [ - {"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": [] - }), - }, - ) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("snapshot pagination should complete") - }; - assert_eq!(parts.snapshot_chunks.len(), 2); - assert!( - parts - .snapshot_chunks - .iter() - .flat_map(|chunk| &chunk.nodes) - .all(|node| node.id != "__DEVUP_SNAPSHOT_CURSOR__") - ); -} - -#[test] -fn section_collection_indexes_before_planning_selected_roots() { - let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - request.section = Some(SectionReadOptions { - frame_ids: vec!["10:2".to_owned(), "10:3".to_owned()], - all_screens: false, - }); - let mut collector = CollectorSession::new(request); - - 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") - ); - collector - .accept(&index_call.id, compact_section_index()) - .unwrap(); - - 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") - }; - 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] -fn failed_section_batch_retries_only_its_root_and_preserves_fast_resources() { - 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(), - "root-2".to_owned(), - ], - all_screens: false, - }); - request.cached_section_index = Some(three_batch_section_index()); - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(first_fast) = collector.advance().unwrap() else { - panic!("first fast batch expected") - }; - let CollectorStep::Call(second_fast) = collector.advance().unwrap() else { - panic!("second fast batch expected") - }; - assert_eq!(multi_root_ids(&first_fast.call), ["root-0"]); - assert_eq!(multi_root_ids(&second_fast.call), ["root-1"]); - collector - .accept( - &second_fast.id, - fast_multi_envelope_result(&["root-1"], &["variable-success-1"]), - ) - .unwrap(); - assert!( - collector - .reject( - &first_fast.id, - &DevupError::new( - ErrorCode::DevupFigmaDirectUnavailable, - "synthetic fast batch failure", - true, - ), - ) - .unwrap() - ); - - let CollectorStep::Call(third_fast) = collector.advance().unwrap() else { - panic!("the untouched third fast batch must remain queued") - }; - assert_eq!(multi_root_ids(&third_fast.call), ["root-2"]); - let CollectorStep::Call(failed_root_retry) = collector.advance().unwrap() else { - panic!("only the failed root must be retried through the legacy cursor") - }; - assert_eq!(legacy_root_id(&failed_root_retry.call), "root-0"); - collector - .accept( - &third_fast.id, - fast_multi_envelope_result(&["root-2"], &["variable-success-2"]), - ) - .unwrap(); - let mut failed_snapshot = snapshot("root-0"); - failed_snapshot.raw["nodes"][0]["fields"] = json!({ - "name": "Failed fast root", - "boundVariables": { - "fills": [{"type": "VARIABLE_ALIAS", "id": "variable-fallback"}] - } - }); - collector - .accept(&failed_root_retry.id, failed_snapshot) - .unwrap(); - - let CollectorStep::Call(missing_resources) = collector.advance().unwrap() else { - panic!("only resources missing from the failed fast batch should be acquired") - }; - let resource_code = missing_resources.call.arguments()["code"] - .as_str() - .unwrap() - .to_owned(); - assert!(resource_code.contains("variable-fallback")); - assert!(!resource_code.contains("variable-success-1")); - assert!(!resource_code.contains("variable-success-2")); - collector - .accept( - &missing_resources.id, - UpstreamResult { - raw: json!({ - "collections": [], - "variables": [{"id": "variable-fallback", "name": "Fallback"}], - "styles": [] - }), - }, - ) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("partial fast fallback should complete") - }; - assert_eq!(parts.stats.figma_tool_calls, 5); - assert_eq!(parts.stats.transport, "hybrid-multi-root-cursor"); - assert_eq!( - merge_chunks(parts.snapshot_chunks.clone()).unwrap().roots, - ["root-0", "root-1", "root-2"] - ); - let resources = parts.variables.unwrap(); - let variable_ids = resources.raw["variables"] - .as_array() - .unwrap() - .iter() - .map(|variable| variable["id"].as_str().unwrap()) - .collect::>(); - assert_eq!( - variable_ids, - std::collections::BTreeSet::from([ - "variable-fallback", - "variable-success-1", - "variable-success-2" - ]) - ); -} - -#[test] -fn oversized_section_root_uses_legacy_once_and_preserves_fast_resources() { - 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(&[1_000, 5_000])); - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(fast) = collector.advance().unwrap() else { - panic!("bounded root should use the fast batch") - }; - let CollectorStep::Call(oversized) = collector.advance().unwrap() else { - panic!("oversized root should be scheduled once through the legacy cursor") - }; - assert_eq!(multi_root_ids(&fast.call), ["root-0"]); - assert_eq!(legacy_root_id(&oversized.call), "root-1"); - collector - .accept( - &fast.id, - fast_multi_envelope_result(&["root-0"], &["variable-fast"]), - ) - .unwrap(); - let mut oversized_snapshot = snapshot("root-1"); - oversized_snapshot.raw["nodes"][0]["fields"] = json!({ - "name": "Oversized root", - "boundVariables": { - "fills": [{"type": "VARIABLE_ALIAS", "id": "variable-oversized"}] - } - }); - collector.accept(&oversized.id, oversized_snapshot).unwrap(); - - let CollectorStep::Call(resources) = collector.advance().unwrap() else { - panic!("the oversized root's missing resources should be collected") - }; - let resource_arguments = resources.call.arguments(); - let resource_code = resource_arguments["code"].as_str().unwrap(); - assert!(resource_code.contains("variable-oversized")); - assert!(!resource_code.contains("variable-fast")); - collector - .accept( - &resources.id, - UpstreamResult { - raw: json!({ - "collections": [], - "variables": [{"id": "variable-oversized", "name": "Oversized"}], - "styles": [] - }), - }, - ) - .unwrap(); - - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("hybrid oversized collection should complete") - }; - assert_eq!(parts.stats.figma_tool_calls, 3); - assert_eq!(parts.stats.transport, "hybrid-multi-root-cursor"); - assert_eq!( - merge_chunks(parts.snapshot_chunks.clone()).unwrap().roots, - ["root-0", "root-1"] - ); - let variables = parts.variables.unwrap(); - assert_eq!(variables.raw["variables"].as_array().unwrap().len(), 2); -} - -#[test] -fn section_collection_without_selection_completes_after_the_compact_index() { - let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - request.section = Some(SectionReadOptions { - frame_ids: Vec::new(), - all_screens: false, - }); - let mut collector = CollectorSession::new(request); - - let CollectorStep::Call(index_call) = collector.advance().unwrap() else { - panic!("compact section index expected") - }; - collector - .accept(&index_call.id, compact_section_index()) - .unwrap(); - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("selection discovery must not collect a full subtree") - }; - - assert_eq!(parts.stats.figma_tool_calls, 1); - assert_eq!(parts.snapshot_chunks.len(), 1); - assert_eq!(parts.snapshot_chunks[0].nodes.len(), 3); - assert_eq!( - parts.metadata["sectionIndex"]["candidates"] - .as_array() - .unwrap() - .len(), - 2 - ); -} - -fn compact_section_index() -> UpstreamResult { - UpstreamResult { - raw: json!({ - "fileKey": "FileKey123", "version": null, "rootIds": ["10:1"], - "nodes": [ - {"id": "10:1", "type": "SECTION", "fields": { - "name": "Proofread", "parentId": "0:1", "childrenIds": ["10:2", "10:3"], - "visible": true, "projectionTruncated": false, - "absoluteBoundingBox": {"x": 0, "y": 0, "width": 1200, "height": 1000} - }, "extra": {}, "fieldErrors": {}}, - {"id": "10:2", "type": "FRAME", "fields": { - "name": "Second", "parentId": "10:1", "childrenIds": [], "visible": true, - "directChildCount": 5, "subtreeNodeCount": 20, "estimatedSerializedBytes": 10000, - "absoluteBoundingBox": {"x": 500, "y": 120, "width": 360, "height": 740} - }, "extra": {}, "fieldErrors": {}}, - {"id": "10:3", "type": "FRAME", "fields": { - "name": "First", "parentId": "10:1", "childrenIds": [], "visible": true, - "directChildCount": 5, "subtreeNodeCount": 20, "estimatedSerializedBytes": 10000, - "absoluteBoundingBox": {"x": 100, "y": 120, "width": 360, "height": 740} - }, "extra": {}, "fieldErrors": {}} - ], - "diagnostics": [] - }), - } -} - -fn three_batch_section_index() -> SectionIndex { - section_index_with_node_counts(&[3_000, 3_000, 3_000]) -} - -fn section_index_with_node_counts(node_counts: &[usize]) -> SectionIndex { - let section_bounds = ExploreBounds { - x: 0.0, - y: 0.0, - width: 1000.0, - height: 1000.0, - }; - SectionIndex { - node_screen_ids: std::collections::BTreeMap::new(), - file_key: "FileKey123".to_owned(), - source_version: Some("v1".to_owned()), - section: SectionSummary { - node_id: "10:1".to_owned(), - name: "Section".to_owned(), - bounds: section_bounds, - }, - candidates: node_counts - .iter() - .enumerate() - .map(|(index, node_count)| SectionCandidate { - node_id: format!("root-{index}"), - name: format!("Root {index}"), - node_type: "FRAME".to_owned(), - text_preview: String::new(), - text_preview_state: None, - visible: true, - bounds: ExploreBounds { - y: index as f64 * 200.0, - ..section_bounds - }, - parent_id: Some("10:1".to_owned()), - breadcrumb: vec!["Section".to_owned(), format!("Root {index}")], - direct_child_count: 0, - subtree_node_count: *node_count, - estimated_serialized_bytes: 1_000, - selection_reasons: vec!["screen-like".to_owned()], - canonical_url: format!( - "https://www.figma.com/design/FileKey123/devup?node-id=root-{index}" - ), - }) - .collect(), - truncated: false, - } -} - -fn multi_root_ids(call: &ReadToolCall) -> Vec<&str> { - let ReadToolCall::Snapshot { - script: BuiltinScript::MultiRootSnapshotEnvelope, - root_ids: Some(root_ids), - .. - } = call - else { - panic!("multi-root fast snapshot call expected") - }; - root_ids.iter().map(String::as_str).collect() -} - -fn boolean_logo_page(root: &str, offset: usize) -> UpstreamResult { - let variable = format!("{root}-variable-{offset}"); - let base = fast_multi_envelope_result(&[root], &[&variable]); - let mut envelope: Value = - serde_json::from_str(base.raw["content"][0]["text"].as_str().unwrap()).unwrap(); - let union = format!("{root}-union"); - let operands = (0..6) - .map(|i| format!("{root}-vector-{i}")) - .collect::>(); - let mut first = envelope["snapshot"]["nodes"][0].clone(); - let mut nodes = if offset == 0 { - first["fields"]["childrenIds"] = json!([union]); - vec![ - first, - json!({"id": union, "type": "BOOLEAN_OPERATION", "fields": {"parentId": root, "childrenIds": operands}}), - ] - } else { - first["id"] = json!(operands[0]); - first["type"] = json!("VECTOR"); - first["fields"]["parentId"] = json!(union); - let mut nodes = vec![first]; - nodes.extend( - operands - .iter() - .skip(1) - .map(|id| json!({"id": id, "type": "VECTOR", "fields": {"parentId": union}})), - ); - nodes - }; - nodes.push(json!({"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": { - "offset": offset, "nextOffset": if offset == 0 {2} else {8}, "totalNodes": 8, "complete": offset != 0 - }})); - envelope["integrity"]["nodeCount"] = json!(nodes.len()); - envelope["snapshot"]["nodes"] = json!(nodes); - UpstreamResult { - raw: json!({"content": [{"type": "text", "text": envelope.to_string()}]}), - } -} - -#[test] -fn section_boolean_operands_are_collected_from_each_roots_continuation() { - let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - request.section = Some(SectionReadOptions { - frame_ids: vec!["root-0".into(), "root-1".into()], - all_screens: false, - }); - request.cached_section_index = Some(section_index_with_node_counts(&[8, 8])); - let mut collector = CollectorSession::new(request); - let mut first = Vec::new(); - for _ in 0..2 { - let CollectorStep::Call(call) = collector.advance().unwrap() else { - panic!("first page expected") - }; - first.push(call); - } - // Complete siblings out of order, as the server does with concurrent reads. - for call in first.iter().rev() { - let root = multi_root_ids(&call.call)[0]; - collector - .accept(&call.id, boolean_logo_page(root, 0)) - .unwrap(); - } - for _ in 0..2 { - let CollectorStep::Call(call) = collector.advance().unwrap() else { - panic!("six boolean operands still need a continuation page") - }; - let root = multi_root_ids(&call.call)[0]; - assert_eq!(call.expected_node_id.as_deref(), Some("10:1")); - assert!( - call.call.arguments()["code"] - .as_str() - .unwrap() - .contains("\"offset\":2") - ); - collector - .accept(&call.id, boolean_logo_page(root, 2)) - .unwrap(); - } - let CollectorStep::Complete(parts) = collector.advance().unwrap() else { - panic!("all pages should complete") - }; - let snapshot = merge_chunks(parts.snapshot_chunks).unwrap(); - assert_eq!(snapshot.nodes.len(), 16); - assert!(snapshot.audit().missing_children.is_empty()); - assert!(!snapshot.nodes.contains_key("__DEVUP_SNAPSHOT_CURSOR__")); - assert_eq!( - parts.variables.unwrap().raw["variables"] - .as_array() - .unwrap() - .len(), - 4 - ); -} - -#[test] -fn section_pages_reject_mismatched_or_nonadvancing_cursors() { - for (field, value) in [ - ("offset", json!(1)), - ("nextOffset", json!(0)), - ("nextOffset", json!(3)), - ("totalNodes", json!(1)), - // nextOffset == totalNodes requires complete: true. - ("totalNodes", json!(2)), - ] { - let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - request.section = Some(SectionReadOptions { - frame_ids: vec!["root-0".into()], - all_screens: false, - }); - request.cached_section_index = Some(section_index_with_node_counts(&[8])); - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(call) = collector.advance().unwrap() else { - panic!("first page expected") - }; - let mut result = boolean_logo_page("root-0", 0); - let mut envelope: Value = - serde_json::from_str(result.raw["content"][0]["text"].as_str().unwrap()).unwrap(); - envelope["snapshot"]["nodes"][2]["fields"][field] = value; - result.raw["content"][0]["text"] = json!(envelope.to_string()); - assert!( - collector.accept(&call.id, result).is_err(), - "invalid {field} must not be cached or followed" - ); - } -} - -#[test] -fn failed_section_continuations_do_not_restart_over_accepted_large_values() { - for malformed_response in [false, true] { - let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); - request.resource_scope = ResourceScope::Used; - request.section = Some(SectionReadOptions { - frame_ids: vec!["root-0".into()], - all_screens: false, - }); - request.cached_section_index = Some(section_index_with_node_counts(&[8])); - let mut collector = CollectorSession::new(request); - let CollectorStep::Call(first) = collector.advance().unwrap() else { - panic!("first page expected") - }; - let mut page = boolean_logo_page("root-0", 0); - let mut envelope: Value = - serde_json::from_str(page.raw["content"][0]["text"].as_str().unwrap()).unwrap(); - envelope["snapshot"]["nodes"][0]["fields"]["characters"] = json!({"$largeValue": { - "nodeId": "root-0", "field": "characters", "byteLength": 19, - "sha256": "5ab6efd34df9db2f30a0581487fd5d023fde8f658c3dfe9378dbed52332e11f8", - "cursor": {"nextOffset": 0, "maxChunkBytes": 8} - }}); - page.raw["content"][0]["text"] = json!(envelope.to_string()); - collector.accept(&first.id, page).unwrap(); - let CollectorStep::Call(large_value) = collector.advance().unwrap() else { - panic!("large value read expected") - }; - assert!(matches!(large_value.call, ReadToolCall::LargeValue { .. })); - let CollectorStep::Call(continuation) = collector.advance().unwrap() else { - panic!("continuation expected") - }; - if malformed_response { - assert!( - collector - .accept( - &continuation.id, - UpstreamResult { - raw: json!({"content": []}) - } - ) - .is_err() - ); - } else { - let error = DevupError::new( - ErrorCode::DevupFigmaDirectUnavailable, - "continuation failed", - true, - ); - assert!( - !collector.reject(&continuation.id, &error).unwrap(), - "propagate the failure instead of restarting at offset zero" - ); - } - assert!( - matches!(collector.advance().unwrap(), CollectorStep::AwaitingResults), - "no legacy restart may mix with the pending field read" - ); - } -} - -fn legacy_root_id(call: &ReadToolCall) -> &str { - let ReadToolCall::Snapshot { - node_id, - script: BuiltinScript::NodeSnapshot, - root_ids: None, - .. - } = call - else { - panic!("legacy root snapshot call expected") - }; - node_id -} - -fn fast_multi_envelope_result(root_ids: &[&str], variable_ids: &[&str]) -> UpstreamResult { - assert_eq!(root_ids.len(), variable_ids.len()); - let nodes = root_ids - .iter() - .zip(variable_ids) - .map(|(root_id, variable_id)| { - json!({ - "id": root_id, - "type": "FRAME", - "fields": { - "name": root_id, - "childrenIds": [], - "boundVariables": { - "fills": [{"type": "VARIABLE_ALIAS", "id": variable_id}] - } - }, - "extra": {}, - "fieldErrors": {} - }) - }) - .collect::>(); - let variables = variable_ids - .iter() - .map(|id| json!({"id": id, "name": id})) - .collect::>(); - let mut envelope = json!({ - "kind": "devupFastSnapshotEnvelope", - "schemaVersion": 1, - "source": {"fileKey": "FileKey123", "rootId": "10:1"}, - "snapshot": { - "fileKey": "FileKey123", - "version": "v1", - "rootIds": root_ids, - "nodes": nodes, - "diagnostics": [] - }, - "resources": { - "collections": [], - "variables": variables, - "styles": [], - "usedRemoteVariables": [], - "usedVariableIds": variable_ids, - "usedStyleIds": [], - "localComplete": false, - "usedRemoteComplete": true, - "unresolved": [] - }, - "integrity": { - "nodeCount": root_ids.len(), - "variableRefCount": variable_ids.len(), - "styleRefCount": 0, - "utf8Bytes": 0 - } - }); - let envelope_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 _ = envelope_bytes; - UpstreamResult { - raw: json!({"content": [ - {"type": "text", "text": envelope.to_string()} - ]}), - } -} +// Source sections share this module to preserve privacy and test names. +include!("collector/fixtures_and_cases.rs"); +include!("collector/a_fast_theme_page_that_does_not_advance_is_refused.rs"); +include!("collector/failed_section_batch_retries_only_its_root_and_preserves_fast_resource.rs"); diff --git a/crates/devup-mcp-figma/tests/collector/a_fast_theme_page_that_does_not_advance_is_refused.rs b/crates/devup-mcp-figma/tests/collector/a_fast_theme_page_that_does_not_advance_is_refused.rs new file mode 100644 index 00000000..21f5e409 --- /dev/null +++ b/crates/devup-mcp-figma/tests/collector/a_fast_theme_page_that_does_not_advance_is_refused.rs @@ -0,0 +1,989 @@ + + +/// A page that does not move forward is refused, not asked for forever. +#[test] +fn a_fast_theme_page_that_does_not_advance_is_refused() { + let mut request = CollectionRequest::new(file_target(), CollectionScope::File); + request.resource_scope = ResourceScope::File; + request.variables_only = true; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!("fast theme call expected") + }; + let error = collector + .accept( + &first.id, + fast_theme_page_result( + json!({"collections": [], "variables": [], "styles": []}), + json!({"offset": 0, "nextOffset": 0, "complete": false, "totalItems": 4}), + ), + ) + .expect_err("a page cursor that stands still is an error"); + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); +} + +fn fast_theme_page_result(resources: Value, page: Value) -> UpstreamResult { + // The scan is paged like the resources: this page's share of the used + // ids, or none. + let used_variable_ids = resources + .get("usedVariableIds") + .cloned() + .unwrap_or_else(|| json!([])); + let used_style_ids = resources + .get("usedStyleIds") + .cloned() + .unwrap_or_else(|| json!([])); + let mut envelope = json!({ + "kind": "devupFastThemeEnvelope", + "schemaVersion": 1, + "source": {"fileKey": "FileKey123", "version": "v2"}, + "resources": { + "collections": resources["collections"], + "variables": resources["variables"], + "styles": resources["styles"], + "usedRemoteVariables": [], + "usedVariableIds": used_variable_ids, + "usedStyleIds": used_style_ids, + "localComplete": true, + "usedRemoteComplete": true, + "unresolved": [] + }, + "page": page, + "integrity": { + "collectionCount": resources["collections"].as_array().map_or(0, Vec::len), + "variableCount": resources["variables"].as_array().map_or(0, Vec::len), + "styleCount": resources["styles"].as_array().map_or(0, Vec::len), + "unresolvedCount": 0, + "utf8Bytes": 0 + } + }); + loop { + let bytes = serde_json::to_vec(&envelope).unwrap(); + if envelope["integrity"]["utf8Bytes"] == bytes.len() as u64 { + break; + } + envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); + } + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": envelope.to_string()}]}), + } +} + +#[test] +fn malformed_or_rejected_fast_theme_restarts_legacy_collection_atomically() { + let mut request = CollectionRequest::new(file_target(), CollectionScope::File); + request.resource_scope = ResourceScope::File; + request.variables_only = true; + let mut malformed = CollectorSession::new(request.clone()); + let CollectorStep::Call(fast) = malformed.advance().unwrap() else { + panic!("fast theme call expected") + }; + malformed + .accept( + &fast.id, + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": "corrupt"}]}), + }, + ) + .unwrap(); + let CollectorStep::Call(metadata) = malformed.advance().unwrap() else { + panic!("legacy metadata must restart from zero") + }; + assert_eq!(metadata.call.tool_name(), "get_metadata"); + assert_eq!(metadata.expected_node_id, None); + + let mut rejected = CollectorSession::new(request); + let CollectorStep::Call(fast) = rejected.advance().unwrap() else { + panic!("fast theme call expected") + }; + assert!( + rejected + .reject( + &fast.id, + &DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "fast theme unavailable", + true, + ), + ) + .unwrap() + ); + let CollectorStep::Call(metadata) = rejected.advance().unwrap() else { + panic!("rejected fast theme must restart legacy metadata") + }; + assert_eq!(metadata.call.tool_name(), "get_metadata"); +} + +#[test] +fn node_collection_advances_from_metadata_to_snapshot_to_complete() { + let request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!("metadata call expected") + }; + assert_eq!(metadata_call.call.tool_name(), "get_metadata"); + collector + .accept(&metadata_call.id, metadata("FRAME", &[], 1)) + .unwrap(); + + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!("snapshot call expected") + }; + assert_eq!(snapshot_call.call.tool_name(), "use_figma"); + assert_eq!(snapshot_call.expected_node_id.as_deref(), Some("1:2")); + collector + .accept(&snapshot_call.id, snapshot("1:2")) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("collection should be complete") + }; + assert_eq!(parts.target.node_id.as_deref(), Some("1:2")); + assert_eq!(parts.source_version.as_deref(), Some("v1")); + assert_eq!(parts.snapshot_chunks.len(), 1); +} + +#[test] +fn large_page_is_split_in_declared_child_order() { + let request = CollectionRequest::new(target("1:2"), CollectionScope::Page); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&call.id, metadata("PAGE", &["1:3", "1:4"], 500)) + .unwrap(); + + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!() + }; + let CollectorStep::Call(second) = collector.advance().unwrap() else { + panic!() + }; + assert_eq!(first.expected_node_id.as_deref(), Some("1:3")); + assert_eq!(second.expected_node_id.as_deref(), Some("1:4")); + + collector.accept(&second.id, snapshot("1:4")).unwrap(); + collector.accept(&first.id, snapshot("1:3")).unwrap(); + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!() + }; + let root_order = parts + .snapshot_chunks + .iter() + .flat_map(|chunk| chunk.root_ids.iter().map(String::as_str)) + .collect::>(); + assert_eq!(root_order, ["1:3", "1:4"]); +} + +#[test] +fn rejects_snapshot_from_a_different_file_version() { + let request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&metadata_call.id, metadata("FRAME", &[], 1)) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!() + }; + let mut wrong_version = snapshot("1:2"); + wrong_version.raw["version"] = json!("v2"); + + let error = collector + .accept(&snapshot_call.id, wrong_version) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupFigmaVersionChanged); +} + +#[test] +fn exposes_at_most_four_in_flight_calls() { + let request = CollectionRequest::new(target("1:2"), CollectionScope::Page); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept( + &metadata_call.id, + metadata("PAGE", &["1:3", "1:4", "1:5", "1:6", "1:7"], 500), + ) + .unwrap(); + + let mut issued = Vec::new(); + for _ in 0..4 { + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!("four calls should be available") + }; + issued.push(call); + } + assert!(matches!( + collector.advance().unwrap(), + CollectorStep::AwaitingResults + )); + + collector.accept(&issued[0].id, snapshot("1:3")).unwrap(); + let CollectorStep::Call(fifth) = collector.advance().unwrap() else { + panic!("the fifth call should be released after one result") + }; + assert_eq!(fifth.expected_node_id.as_deref(), Some("1:7")); +} + +#[test] +fn rejects_unknown_or_replayed_call_ids() { + let request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!() + }; + + let unknown = collector + .accept("unknown", metadata("FRAME", &[], 1)) + .unwrap_err(); + assert_eq!(unknown.code, ErrorCode::DevupFigmaHandoffInvalid); + + collector + .accept(&call.id, metadata("FRAME", &[], 1)) + .unwrap(); + let replayed = collector + .accept(&call.id, metadata("FRAME", &[], 1)) + .unwrap_err(); + assert_eq!(replayed.code, ErrorCode::DevupFigmaHandoffInvalid); +} + +#[test] +fn variable_collection_uses_catalog_then_batched_resources() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::File; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&metadata_call.id, metadata("FRAME", &[], 1)) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&snapshot_call.id, snapshot("1:2")) + .unwrap(); + + let CollectorStep::Call(catalog_call) = collector.advance().unwrap() else { + panic!("variable/style catalog should follow the snapshot") + }; + let catalog_code = catalog_call.call.arguments()["code"] + .as_str() + .unwrap() + .to_owned(); + assert!(catalog_code.contains("getLocalVariableCollectionsAsync")); + assert!(!catalog_code.contains("getLocalVariablesAsync")); + let catalog = UpstreamResult { + raw: json!({ + "content": [{ + "type": "text", + "text": "{\"collections\":[{\"id\":\"c1\",\"name\":\"Synthetic\",\"defaultModeId\":\"m1\",\"modes\":[]}],\"variableIds\":[\"v1\"],\"styles\":[{\"id\":\"s1\",\"styleType\":\"TEXT\"}],\"localComplete\":true,\"usedRemoteComplete\":false}" + }] + }), + }; + collector.accept(&catalog_call.id, catalog).unwrap(); + + let CollectorStep::Call(batch_call) = collector.advance().unwrap() else { + panic!("resource batch should follow the catalog") + }; + let batch_code = batch_call.call.arguments()["code"] + .as_str() + .unwrap() + .to_owned(); + assert!(batch_code.contains("getVariableByIdAsync")); + assert!(batch_code.contains("getStyleByIdAsync")); + let variable_batch = UpstreamResult { + raw: json!({ + "content": [{ + "type": "text", + "text": "{\"variables\":[{\"id\":\"v1\",\"name\":\"Synthetic Variable\"}],\"styles\":[]}" + }] + }), + }; + collector.accept(&batch_call.id, variable_batch).unwrap(); + let CollectorStep::Call(style_call) = collector.advance().unwrap() else { + panic!("style batch should be separate") + }; + collector + .accept( + &style_call.id, + UpstreamResult { + raw: json!({ + "variables": [], + "styles": [{"id": "s1", "name": "Synthetic Style", "styleType": "TEXT", "value": {}}] + }), + }, + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("collection should finish after variables") + }; + let merged = &parts.variables.as_ref().unwrap().raw; + assert_eq!(merged["collections"][0]["id"], "c1"); + assert_eq!(merged["variables"][0]["id"], "v1"); + assert_eq!(merged["styles"][0]["id"], "s1"); + assert_eq!(parts.styles.as_ref().unwrap().raw, *merged); +} + +#[test] +fn a_file_theme_reads_the_library_resources_its_nodes_use_and_its_variables_alias() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::File; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&metadata_call.id, metadata("FRAME", &[], 1)) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!() + }; + // The node pads with a local variable and fills with a library one. + collector + .accept( + &snapshot_call.id, + UpstreamResult { + raw: json!({ + "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], + "nodes": [{"id": "1:2", "type": "FRAME", "fields": { + "name": "Frame", "childrenIds": [], + "boundVariables": {"paddingLeft": {"type": "VARIABLE_ALIAS", "id": "VariableID:1:1"}}, + "fills": [{"type": "SOLID", "boundVariables": {"color": {"type": "VARIABLE_ALIAS", "id": "VariableID:lib/2:2"}}, "extra": {}, "fieldErrors": {}}] + }}] + }), + }, + ) + .unwrap(); + let CollectorStep::Call(catalog_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept( + &catalog_call.id, + UpstreamResult { + raw: json!({ + "collections": [{"id": "c1", "name": "Local", "defaultModeId": "m", "modes": [{"modeId": "m", "name": "Mobile"}]}], + "variableIds": ["VariableID:1:1"], "styles": [], + "localComplete": true, "usedRemoteComplete": false + }), + }, + ) + .unwrap(); + let CollectorStep::Call(local_batch) = collector.advance().unwrap() else { + panic!() + }; + // The local variable aliases a library primitive no node binds. + collector + .accept( + &local_batch.id, + UpstreamResult { + raw: json!({"variables": [{"id": "VariableID:1:1", "name": "containerX", "variableCollectionId": "c1", + "valuesByMode": {"m": {"type": "VARIABLE_ALIAS", "id": "VariableID:lib/3:3"}}}], "styles": []}), + }, + ) + .unwrap(); + let CollectorStep::Call(library_call) = collector.advance().unwrap() else { + panic!("the library resources should be read after the catalog") + }; + let code = library_call.call.arguments()["code"] + .as_str() + .unwrap() + .to_owned(); + assert!( + code.contains("VariableID:lib/2:2"), + "the bound library variable is read" + ); + assert!( + code.contains("VariableID:lib/3:3"), + "the aliased library variable is read" + ); + assert!( + !code.contains("\"VariableID:1:1\""), + "the local variable is not read again" + ); + collector + .accept( + &library_call.id, + UpstreamResult { + raw: json!({ + "collections": [{"id": "lib", "name": "Library", "defaultModeId": "x", "modes": [{"modeId": "x", "name": "Mode 1"}]}], + "variables": [ + {"id": "VariableID:lib/2:2", "name": "gray", "remote": true, "variableCollectionId": "lib", "valuesByMode": {"x": {"r": 0, "g": 0, "b": 0, "a": 1}}}, + {"id": "VariableID:lib/3:3", "name": "spacing20", "remote": true, "variableCollectionId": "lib", "valuesByMode": {"x": {"type": "VARIABLE_ALIAS", "id": "VariableID:lib/4:4"}}}, + // What the read followed: the primitive the spacing aliases. + {"id": "VariableID:lib/4:4", "name": "base20", "remote": true, "variableCollectionId": "lib", "valuesByMode": {"x": 20}} + ], + "styles": [], "unresolved": [] + }), + }, + ) + .unwrap(); + let step = collector.advance().unwrap(); + let CollectorStep::Complete(parts) = step else { + panic!("collection should finish after the library resources: {step:?}") + }; + let raw = &parts.variables.as_ref().unwrap().raw; + let ids = |key: &str| { + raw[key] + .as_array() + .unwrap() + .iter() + .map(|value| value["id"].as_str().unwrap().to_owned()) + .collect::>() + }; + assert_eq!( + ids("variables"), + [ + "VariableID:1:1", + "VariableID:lib/2:2", + "VariableID:lib/3:3", + "VariableID:lib/4:4" + ] + ); + assert_eq!( + ids("usedRemoteVariables"), + [ + "VariableID:lib/2:2", + "VariableID:lib/3:3", + "VariableID:lib/4:4" + ] + ); + assert_eq!(ids("collections"), ["c1", "lib"]); + assert_eq!(raw["localComplete"], true); + assert_eq!(raw["usedRemoteComplete"], true); +} + +#[test] +fn accepts_the_official_xml_metadata_envelope_without_inventing_values() { + let request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!() + }; + + collector + .accept(&metadata_call.id, official_xml_metadata()) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!() + }; + assert_eq!(snapshot_call.expected_file_key, "FileKey123"); + assert_eq!(snapshot_call.expected_node_id.as_deref(), Some("1:2")); +} + +#[test] +fn variable_batches_merge_in_catalog_order_when_results_arrive_out_of_order() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::File; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&metadata_call.id, metadata("FRAME", &[], 1)) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&snapshot_call.id, snapshot("1:2")) + .unwrap(); + let CollectorStep::Call(catalog_call) = collector.advance().unwrap() else { + panic!() + }; + let variable_ids = (0..9) + .map(|index| format!("v{index:02}")) + .collect::>(); + let expected_ids = variable_ids.clone(); + collector + .accept( + &catalog_call.id, + UpstreamResult { + raw: json!({ + "collections": [], + "variableIds": variable_ids, + "styles": [], + "localComplete": true, + "usedRemoteComplete": false + }), + }, + ) + .unwrap(); + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!() + }; + let CollectorStep::Call(second) = collector.advance().unwrap() else { + panic!() + }; + let first_values = (0..8) + .map(|index| json!({"id": format!("v{index:02}")})) + .collect::>(); + collector + .accept( + &second.id, + UpstreamResult { + raw: json!({"variables": [{"id": "v08"}], "styles": []}), + }, + ) + .unwrap(); + collector + .accept( + &first.id, + UpstreamResult { + raw: json!({"variables": first_values, "styles": []}), + }, + ) + .unwrap(); + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!() + }; + let variables = parts.variables.unwrap(); + let ids = variables.raw["variables"] + .as_array() + .unwrap() + .iter() + .map(|variable| variable["id"].as_str().unwrap()) + .collect::>(); + assert_eq!(ids, expected_ids); +} + +#[test] +fn style_consumers_are_planned_as_compact_bounded_fragments() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::File; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&metadata_call.id, metadata("FRAME", &[], 1)) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&snapshot_call.id, snapshot("1:2")) + .unwrap(); + let CollectorStep::Call(catalog_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept( + &catalog_call.id, + UpstreamResult { + raw: json!({ + "collections": [], + "variableIds": [], + "styles": [ + {"id": "s1", "styleType": "TEXT"}, + {"id": "s2", "styleType": "PAINT"} + ], + "localComplete": true, + "usedRemoteComplete": false + }), + }, + ) + .unwrap(); + + let CollectorStep::Call(base_styles) = collector.advance().unwrap() else { + panic!("style definitions should be batched") + }; + let base_arguments = base_styles.call.arguments(); + let base_code = base_arguments["code"].as_str().unwrap(); + assert!(base_code.contains("s1")); + assert!(base_code.contains("s2")); + collector + .accept( + &base_styles.id, + UpstreamResult { + raw: json!({ + "variables": [], + "styles": [ + {"id": "s1", "name": "Text", "styleType": "TEXT", "value": {}, "$consumerCount": 321}, + {"id": "s2", "name": "Paint", "styleType": "PAINT", "value": [], "$consumerCount": 0} + ] + }), + }, + ) + .unwrap(); + + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!("first consumer fragment should be scheduled") + }; + let CollectorStep::Call(second) = collector.advance().unwrap() else { + panic!("second consumer fragment should be scheduled") + }; + let first_arguments = first.call.arguments(); + let second_arguments = second.call.arguments(); + let first_code = first_arguments["code"].as_str().unwrap(); + let second_code = second_arguments["code"].as_str().unwrap(); + assert!(first_code.contains("s1")); + assert!(first_code.contains("\"consumerStart\":0")); + assert!(first_code.contains("\"consumerEnd\":320")); + assert!(second_code.contains("s1")); + assert!(second_code.contains("\"consumerStart\":320")); + assert!(second_code.contains("\"consumerEnd\":321")); +} + +#[test] +fn used_scope_resolves_only_snapshot_references_and_keeps_partial_results() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + panic!("fast call expected") + }; + collector + .accept( + &fast_call.id, + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": "fallback"}]}), + }, + ) + .unwrap(); + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!("metadata call expected") + }; + collector + .accept(&metadata_call.id, metadata("FRAME", &[], 1)) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!("snapshot call expected") + }; + let mut used_snapshot = snapshot("1:2"); + used_snapshot.raw["nodes"][0]["fields"] = json!({ + "name": "Synthetic", + "boundVariables": { + "fills": [{"type": "VARIABLE_ALIAS", "id": "VariableID:1:2"}] + }, + "textStyleId": "S:text" + }); + collector.accept(&snapshot_call.id, used_snapshot).unwrap(); + + let CollectorStep::Call(resource_call) = collector.advance().unwrap() else { + panic!("combined used resource batch should follow the snapshot") + }; + let resource_code = resource_call.call.arguments()["code"] + .as_str() + .unwrap() + .to_owned(); + assert!(resource_code.contains("getVariableByIdAsync")); + assert!(resource_code.contains("VariableID:1:2")); + assert!(resource_code.contains("S:text")); + assert!(!resource_code.contains("getStyleConsumersAsync")); + + collector + .accept( + &resource_call.id, + UpstreamResult { + raw: json!({ + "collections": [{ + "id": "collection:1", + "name": "Foundation", + "defaultModeId": "mode:1", + "modes": [{"modeId": "mode:1", "name": "Default"}] + }], + "variables": [{ + "id": "VariableID:1:2", + "name": "primary", + "remote": true, + "variableCollectionId": "collection:1", + "resolvedType": "COLOR", + "valuesByMode": {"mode:1": {"r": 1, "g": 0, "b": 0, "a": 1}} + }], + "styles": [], + "unresolved": [{"id": "S:text", "kind": "style", "reason": "notFoundOrUnavailable"}] + }), + }, + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("used resource collection should complete") + }; + let resources = &parts.variables.as_ref().unwrap().raw; + assert_eq!(resources["collections"][0]["id"], "collection:1"); + assert_eq!(resources["variables"][0]["name"], "primary"); + assert_eq!(resources["styles"], json!([])); + assert_eq!(resources["usedRemoteComplete"], false); + assert_eq!(resources["unresolved"][0]["id"], "S:text"); + assert_eq!(resources["usedVariableIds"], json!(["VariableID:1:2"])); + assert_eq!(resources["usedStyleIds"], json!(["S:text"])); + let diagnostic = parts.snapshot_chunks[0] + .diagnostics + .iter() + .find(|diagnostic| diagnostic.code == "DEVUP_RESOURCE_UNRESOLVED") + .expect("unresolved resource diagnostic"); + assert_eq!(diagnostic.node_id.as_deref(), Some("1:2")); + assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::Warning)); + assert_eq!(diagnostic.property.as_deref(), Some("textStyleId")); + assert_eq!(diagnostic.resource_kind.as_deref(), Some("style")); + assert_eq!(diagnostic.resource_id.as_deref(), Some("S:text")); + assert_eq!(diagnostic.fallback.as_deref(), Some("raw-value")); + assert_eq!(diagnostic.recoverable, Some(true)); +} + +#[test] +fn explore_collection_starts_with_one_bounded_projection_and_no_resources() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.explore = Some(ExploreReadOptions::default()); + request.resource_scope = ResourceScope::None; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(explore_call) = collector.advance().unwrap() else { + panic!("explore projection call expected") + }; + assert_eq!(explore_call.call.tool_name(), "use_figma"); + assert_eq!(explore_call.expected_node_id.as_deref(), Some("1:2")); + let code = explore_call.call.arguments()["code"] + .as_str() + .unwrap() + .to_owned(); + assert!(code.contains("projectionTruncated")); + assert!(!code.contains("getLocalVariableCollectionsAsync")); + assert!(!code.contains("getVariableByIdAsync")); + + collector + .accept( + &explore_call.id, + UpstreamResult { + raw: json!({ + "fileKey": "FileKey123", + "version": null, + "rootIds": ["0:1"], + "nodes": [ + { + "id": "0:1", "type": "PAGE", + "fields": {"name": "Page", "x": 0, "y": 0, "width": 1000, "height": 1000, "projectionTruncated": false}, + "extra": {}, "fieldErrors": {} + }, + { + "id": "1:2", "type": "FRAME", + "fields": {"name": "Heading", "parentId": "0:1", "x": 0, "y": 0, "width": 1000, "height": 80, "childCount": 1}, + "extra": {}, "fieldErrors": {} + } + ], + "diagnostics": [] + }), + }, + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("explore collection should complete after one projection") + }; + assert_eq!(parts.snapshot_chunks.len(), 1); + assert_eq!(parts.snapshot_chunks[0].nodes.len(), 2); + assert!(parts.variables.is_none()); +} + +#[test] +fn node_snapshot_follows_the_compiled_cursor_until_complete() { + let request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!("metadata call expected") + }; + collector + .accept(&metadata_call.id, metadata("FRAME", &[], 2)) + .unwrap(); + + let CollectorStep::Call(first_call) = collector.advance().unwrap() else { + panic!("first snapshot chunk expected") + }; + assert!( + first_call.call.arguments()["code"] + .as_str() + .unwrap() + .contains("\"offset\":0") + ); + collector + .accept( + &first_call.id, + UpstreamResult { + raw: json!({ + "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": {"offset":0,"nextOffset": 1, "complete": false, "totalNodes": 2}, "extra": {}, "fieldErrors": {}} + ], "diagnostics": [] + }), + }, + ) + .unwrap(); + + let CollectorStep::Call(second_call) = collector.advance().unwrap() else { + panic!("second snapshot chunk expected") + }; + assert!( + second_call.call.arguments()["code"] + .as_str() + .unwrap() + .contains("\"offset\":1") + ); + collector + .accept( + &second_call.id, + UpstreamResult { + raw: json!({ + "fileKey": "FileKey123", "version": "v1", "rootIds": ["1:2"], + "nodes": [ + {"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": [] + }), + }, + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("snapshot pagination should complete") + }; + assert_eq!(parts.snapshot_chunks.len(), 2); + assert!( + parts + .snapshot_chunks + .iter() + .flat_map(|chunk| &chunk.nodes) + .all(|node| node.id != "__DEVUP_SNAPSHOT_CURSOR__") + ); +} + +#[test] +fn section_collection_indexes_before_planning_selected_roots() { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: vec!["10:2".to_owned(), "10:3".to_owned()], + all_screens: false, + }); + let mut collector = CollectorSession::new(request); + + 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") + ); + collector + .accept(&index_call.id, compact_section_index()) + .unwrap(); + + 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") + }; + 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 + ); +} \ No newline at end of file diff --git a/crates/devup-mcp-figma/tests/collector/failed_section_batch_retries_only_its_root_and_preserves_fast_resource.rs b/crates/devup-mcp-figma/tests/collector/failed_section_batch_retries_only_its_root_and_preserves_fast_resource.rs new file mode 100644 index 00000000..5f405859 --- /dev/null +++ b/crates/devup-mcp-figma/tests/collector/failed_section_batch_retries_only_its_root_and_preserves_fast_resource.rs @@ -0,0 +1,573 @@ + + +#[test] +fn failed_section_batch_retries_only_its_root_and_preserves_fast_resources() { + 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(), + "root-2".to_owned(), + ], + all_screens: false, + }); + request.cached_section_index = Some(three_batch_section_index()); + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(first_fast) = collector.advance().unwrap() else { + panic!("first fast batch expected") + }; + let CollectorStep::Call(second_fast) = collector.advance().unwrap() else { + panic!("second fast batch expected") + }; + assert_eq!(multi_root_ids(&first_fast.call), ["root-0"]); + assert_eq!(multi_root_ids(&second_fast.call), ["root-1"]); + collector + .accept( + &second_fast.id, + fast_multi_envelope_result(&["root-1"], &["variable-success-1"]), + ) + .unwrap(); + assert!( + collector + .reject( + &first_fast.id, + &DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "synthetic fast batch failure", + true, + ), + ) + .unwrap() + ); + + let CollectorStep::Call(third_fast) = collector.advance().unwrap() else { + panic!("the untouched third fast batch must remain queued") + }; + assert_eq!(multi_root_ids(&third_fast.call), ["root-2"]); + let CollectorStep::Call(failed_root_retry) = collector.advance().unwrap() else { + panic!("only the failed root must be retried through the legacy cursor") + }; + assert_eq!(legacy_root_id(&failed_root_retry.call), "root-0"); + collector + .accept( + &third_fast.id, + fast_multi_envelope_result(&["root-2"], &["variable-success-2"]), + ) + .unwrap(); + let mut failed_snapshot = snapshot("root-0"); + failed_snapshot.raw["nodes"][0]["fields"] = json!({ + "name": "Failed fast root", + "boundVariables": { + "fills": [{"type": "VARIABLE_ALIAS", "id": "variable-fallback"}] + } + }); + collector + .accept(&failed_root_retry.id, failed_snapshot) + .unwrap(); + + let CollectorStep::Call(missing_resources) = collector.advance().unwrap() else { + panic!("only resources missing from the failed fast batch should be acquired") + }; + let resource_code = missing_resources.call.arguments()["code"] + .as_str() + .unwrap() + .to_owned(); + assert!(resource_code.contains("variable-fallback")); + assert!(!resource_code.contains("variable-success-1")); + assert!(!resource_code.contains("variable-success-2")); + collector + .accept( + &missing_resources.id, + UpstreamResult { + raw: json!({ + "collections": [], + "variables": [{"id": "variable-fallback", "name": "Fallback"}], + "styles": [] + }), + }, + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("partial fast fallback should complete") + }; + assert_eq!(parts.stats.figma_tool_calls, 5); + assert_eq!(parts.stats.transport, "hybrid-multi-root-cursor"); + assert_eq!( + merge_chunks(parts.snapshot_chunks.clone()).unwrap().roots, + ["root-0", "root-1", "root-2"] + ); + let resources = parts.variables.unwrap(); + let variable_ids = resources.raw["variables"] + .as_array() + .unwrap() + .iter() + .map(|variable| variable["id"].as_str().unwrap()) + .collect::>(); + assert_eq!( + variable_ids, + std::collections::BTreeSet::from([ + "variable-fallback", + "variable-success-1", + "variable-success-2" + ]) + ); +} + +#[test] +fn oversized_section_root_uses_legacy_once_and_preserves_fast_resources() { + 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(&[1_000, 5_000])); + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(fast) = collector.advance().unwrap() else { + panic!("bounded root should use the fast batch") + }; + let CollectorStep::Call(oversized) = collector.advance().unwrap() else { + panic!("oversized root should be scheduled once through the legacy cursor") + }; + assert_eq!(multi_root_ids(&fast.call), ["root-0"]); + assert_eq!(legacy_root_id(&oversized.call), "root-1"); + collector + .accept( + &fast.id, + fast_multi_envelope_result(&["root-0"], &["variable-fast"]), + ) + .unwrap(); + let mut oversized_snapshot = snapshot("root-1"); + oversized_snapshot.raw["nodes"][0]["fields"] = json!({ + "name": "Oversized root", + "boundVariables": { + "fills": [{"type": "VARIABLE_ALIAS", "id": "variable-oversized"}] + } + }); + collector.accept(&oversized.id, oversized_snapshot).unwrap(); + + let CollectorStep::Call(resources) = collector.advance().unwrap() else { + panic!("the oversized root's missing resources should be collected") + }; + let resource_arguments = resources.call.arguments(); + let resource_code = resource_arguments["code"].as_str().unwrap(); + assert!(resource_code.contains("variable-oversized")); + assert!(!resource_code.contains("variable-fast")); + collector + .accept( + &resources.id, + UpstreamResult { + raw: json!({ + "collections": [], + "variables": [{"id": "variable-oversized", "name": "Oversized"}], + "styles": [] + }), + }, + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("hybrid oversized collection should complete") + }; + assert_eq!(parts.stats.figma_tool_calls, 3); + assert_eq!(parts.stats.transport, "hybrid-multi-root-cursor"); + assert_eq!( + merge_chunks(parts.snapshot_chunks.clone()).unwrap().roots, + ["root-0", "root-1"] + ); + let variables = parts.variables.unwrap(); + assert_eq!(variables.raw["variables"].as_array().unwrap().len(), 2); +} + +#[test] +fn section_collection_without_selection_completes_after_the_compact_index() { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: Vec::new(), + all_screens: false, + }); + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(index_call) = collector.advance().unwrap() else { + panic!("compact section index expected") + }; + collector + .accept(&index_call.id, compact_section_index()) + .unwrap(); + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("selection discovery must not collect a full subtree") + }; + + assert_eq!(parts.stats.figma_tool_calls, 1); + assert_eq!(parts.snapshot_chunks.len(), 1); + assert_eq!(parts.snapshot_chunks[0].nodes.len(), 3); + assert_eq!( + parts.metadata["sectionIndex"]["candidates"] + .as_array() + .unwrap() + .len(), + 2 + ); +} + +fn compact_section_index() -> UpstreamResult { + UpstreamResult { + raw: json!({ + "fileKey": "FileKey123", "version": null, "rootIds": ["10:1"], + "nodes": [ + {"id": "10:1", "type": "SECTION", "fields": { + "name": "Proofread", "parentId": "0:1", "childrenIds": ["10:2", "10:3"], + "visible": true, "projectionTruncated": false, + "absoluteBoundingBox": {"x": 0, "y": 0, "width": 1200, "height": 1000} + }, "extra": {}, "fieldErrors": {}}, + {"id": "10:2", "type": "FRAME", "fields": { + "name": "Second", "parentId": "10:1", "childrenIds": [], "visible": true, + "directChildCount": 5, "subtreeNodeCount": 20, "estimatedSerializedBytes": 10000, + "absoluteBoundingBox": {"x": 500, "y": 120, "width": 360, "height": 740} + }, "extra": {}, "fieldErrors": {}}, + {"id": "10:3", "type": "FRAME", "fields": { + "name": "First", "parentId": "10:1", "childrenIds": [], "visible": true, + "directChildCount": 5, "subtreeNodeCount": 20, "estimatedSerializedBytes": 10000, + "absoluteBoundingBox": {"x": 100, "y": 120, "width": 360, "height": 740} + }, "extra": {}, "fieldErrors": {}} + ], + "diagnostics": [] + }), + } +} + +fn three_batch_section_index() -> SectionIndex { + section_index_with_node_counts(&[3_000, 3_000, 3_000]) +} + +fn section_index_with_node_counts(node_counts: &[usize]) -> SectionIndex { + let section_bounds = ExploreBounds { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }; + SectionIndex { + node_screen_ids: std::collections::BTreeMap::new(), + file_key: "FileKey123".to_owned(), + source_version: Some("v1".to_owned()), + section: SectionSummary { + node_id: "10:1".to_owned(), + name: "Section".to_owned(), + bounds: section_bounds, + }, + candidates: node_counts + .iter() + .enumerate() + .map(|(index, node_count)| SectionCandidate { + node_id: format!("root-{index}"), + name: format!("Root {index}"), + node_type: "FRAME".to_owned(), + text_preview: String::new(), + text_preview_state: None, + visible: true, + bounds: ExploreBounds { + y: index as f64 * 200.0, + ..section_bounds + }, + parent_id: Some("10:1".to_owned()), + breadcrumb: vec!["Section".to_owned(), format!("Root {index}")], + direct_child_count: 0, + subtree_node_count: *node_count, + estimated_serialized_bytes: 1_000, + selection_reasons: vec!["screen-like".to_owned()], + canonical_url: format!( + "https://www.figma.com/design/FileKey123/devup?node-id=root-{index}" + ), + }) + .collect(), + truncated: false, + } +} + +fn multi_root_ids(call: &ReadToolCall) -> Vec<&str> { + let ReadToolCall::Snapshot { + script: BuiltinScript::MultiRootSnapshotEnvelope, + root_ids: Some(root_ids), + .. + } = call + else { + panic!("multi-root fast snapshot call expected") + }; + root_ids.iter().map(String::as_str).collect() +} + +fn boolean_logo_page(root: &str, offset: usize) -> UpstreamResult { + let variable = format!("{root}-variable-{offset}"); + let base = fast_multi_envelope_result(&[root], &[&variable]); + let mut envelope: Value = + serde_json::from_str(base.raw["content"][0]["text"].as_str().unwrap()).unwrap(); + let union = format!("{root}-union"); + let operands = (0..6) + .map(|i| format!("{root}-vector-{i}")) + .collect::>(); + let mut first = envelope["snapshot"]["nodes"][0].clone(); + let mut nodes = if offset == 0 { + first["fields"]["childrenIds"] = json!([union]); + vec![ + first, + json!({"id": union, "type": "BOOLEAN_OPERATION", "fields": {"parentId": root, "childrenIds": operands}}), + ] + } else { + first["id"] = json!(operands[0]); + first["type"] = json!("VECTOR"); + first["fields"]["parentId"] = json!(union); + let mut nodes = vec![first]; + nodes.extend( + operands + .iter() + .skip(1) + .map(|id| json!({"id": id, "type": "VECTOR", "fields": {"parentId": union}})), + ); + nodes + }; + nodes.push(json!({"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": { + "offset": offset, "nextOffset": if offset == 0 {2} else {8}, "totalNodes": 8, "complete": offset != 0 + }})); + envelope["integrity"]["nodeCount"] = json!(nodes.len()); + envelope["snapshot"]["nodes"] = json!(nodes); + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": envelope.to_string()}]}), + } +} + +#[test] +fn section_boolean_operands_are_collected_from_each_roots_continuation() { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: vec!["root-0".into(), "root-1".into()], + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[8, 8])); + let mut collector = CollectorSession::new(request); + let mut first = Vec::new(); + for _ in 0..2 { + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!("first page expected") + }; + first.push(call); + } + // Complete siblings out of order, as the server does with concurrent reads. + for call in first.iter().rev() { + let root = multi_root_ids(&call.call)[0]; + collector + .accept(&call.id, boolean_logo_page(root, 0)) + .unwrap(); + } + for _ in 0..2 { + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!("six boolean operands still need a continuation page") + }; + let root = multi_root_ids(&call.call)[0]; + assert_eq!(call.expected_node_id.as_deref(), Some("10:1")); + assert!( + call.call.arguments()["code"] + .as_str() + .unwrap() + .contains("\"offset\":2") + ); + collector + .accept(&call.id, boolean_logo_page(root, 2)) + .unwrap(); + } + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("all pages should complete") + }; + let snapshot = merge_chunks(parts.snapshot_chunks).unwrap(); + assert_eq!(snapshot.nodes.len(), 16); + assert!(snapshot.audit().missing_children.is_empty()); + assert!(!snapshot.nodes.contains_key("__DEVUP_SNAPSHOT_CURSOR__")); + assert_eq!( + parts.variables.unwrap().raw["variables"] + .as_array() + .unwrap() + .len(), + 4 + ); +} + +#[test] +fn section_pages_reject_mismatched_or_nonadvancing_cursors() { + for (field, value) in [ + ("offset", json!(1)), + ("nextOffset", json!(0)), + ("nextOffset", json!(3)), + ("totalNodes", json!(1)), + // nextOffset == totalNodes requires complete: true. + ("totalNodes", json!(2)), + ] { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: vec!["root-0".into()], + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[8])); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!("first page expected") + }; + let mut result = boolean_logo_page("root-0", 0); + let mut envelope: Value = + serde_json::from_str(result.raw["content"][0]["text"].as_str().unwrap()).unwrap(); + envelope["snapshot"]["nodes"][2]["fields"][field] = value; + result.raw["content"][0]["text"] = json!(envelope.to_string()); + assert!( + collector.accept(&call.id, result).is_err(), + "invalid {field} must not be cached or followed" + ); + } +} + +#[test] +fn failed_section_continuations_do_not_restart_over_accepted_large_values() { + for malformed_response in [false, true] { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: vec!["root-0".into()], + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[8])); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!("first page expected") + }; + let mut page = boolean_logo_page("root-0", 0); + let mut envelope: Value = + serde_json::from_str(page.raw["content"][0]["text"].as_str().unwrap()).unwrap(); + envelope["snapshot"]["nodes"][0]["fields"]["characters"] = json!({"$largeValue": { + "nodeId": "root-0", "field": "characters", "byteLength": 19, + "sha256": "5ab6efd34df9db2f30a0581487fd5d023fde8f658c3dfe9378dbed52332e11f8", + "cursor": {"nextOffset": 0, "maxChunkBytes": 8} + }}); + page.raw["content"][0]["text"] = json!(envelope.to_string()); + collector.accept(&first.id, page).unwrap(); + let CollectorStep::Call(large_value) = collector.advance().unwrap() else { + panic!("large value read expected") + }; + assert!(matches!(large_value.call, ReadToolCall::LargeValue { .. })); + let CollectorStep::Call(continuation) = collector.advance().unwrap() else { + panic!("continuation expected") + }; + if malformed_response { + assert!( + collector + .accept( + &continuation.id, + UpstreamResult { + raw: json!({"content": []}) + } + ) + .is_err() + ); + } else { + let error = DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "continuation failed", + true, + ); + assert!( + !collector.reject(&continuation.id, &error).unwrap(), + "propagate the failure instead of restarting at offset zero" + ); + } + assert!( + matches!(collector.advance().unwrap(), CollectorStep::AwaitingResults), + "no legacy restart may mix with the pending field read" + ); + } +} + +fn legacy_root_id(call: &ReadToolCall) -> &str { + let ReadToolCall::Snapshot { + node_id, + script: BuiltinScript::NodeSnapshot, + root_ids: None, + .. + } = call + else { + panic!("legacy root snapshot call expected") + }; + node_id +} + +fn fast_multi_envelope_result(root_ids: &[&str], variable_ids: &[&str]) -> UpstreamResult { + assert_eq!(root_ids.len(), variable_ids.len()); + let nodes = root_ids + .iter() + .zip(variable_ids) + .map(|(root_id, variable_id)| { + json!({ + "id": root_id, + "type": "FRAME", + "fields": { + "name": root_id, + "childrenIds": [], + "boundVariables": { + "fills": [{"type": "VARIABLE_ALIAS", "id": variable_id}] + } + }, + "extra": {}, + "fieldErrors": {} + }) + }) + .collect::>(); + let variables = variable_ids + .iter() + .map(|id| json!({"id": id, "name": id})) + .collect::>(); + let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", + "schemaVersion": 1, + "source": {"fileKey": "FileKey123", "rootId": "10:1"}, + "snapshot": { + "fileKey": "FileKey123", + "version": "v1", + "rootIds": root_ids, + "nodes": nodes, + "diagnostics": [] + }, + "resources": { + "collections": [], + "variables": variables, + "styles": [], + "usedRemoteVariables": [], + "usedVariableIds": variable_ids, + "usedStyleIds": [], + "localComplete": false, + "usedRemoteComplete": true, + "unresolved": [] + }, + "integrity": { + "nodeCount": root_ids.len(), + "variableRefCount": variable_ids.len(), + "styleRefCount": 0, + "utf8Bytes": 0 + } + }); + let envelope_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 _ = envelope_bytes; + UpstreamResult { + raw: json!({"content": [ + {"type": "text", "text": envelope.to_string()} + ]}), + } +} diff --git a/crates/devup-mcp-figma/tests/collector/fixtures_and_cases.rs b/crates/devup-mcp-figma/tests/collector/fixtures_and_cases.rs new file mode 100644 index 00000000..10b13fd0 --- /dev/null +++ b/crates/devup-mcp-figma/tests/collector/fixtures_and_cases.rs @@ -0,0 +1,993 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use devup_mcp_figma::{ + BuiltinScript, CollectionRequest, CollectionScope, CollectorSession, CollectorStep, DevupError, + DiagnosticSeverity, ErrorCode, ExploreBounds, ExploreReadOptions, FigmaTarget, ReadToolCall, + ResourceScope, SectionCandidate, SectionIndex, SectionReadOptions, SectionSummary, + UpstreamResult, merge_chunks, +}; +use image::{ExtendedColorType, ImageEncoder, codecs::png::PngEncoder}; +use serde_json::{Value, json}; + +#[test] +fn exact_node_fast_path_completes_in_one_call() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + 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!( + arguments["code"] + .as_str() + .unwrap() + .contains("devupFastSnapshotEnvelope") + ); + assert!( + !arguments["code"] + .as_str() + .unwrap() + .contains("figma.io.write") + ); + + collector + .accept(&fast_call.id, fast_envelope_result()) + .unwrap(); + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("fast snapshot should complete without another 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, "text"); + assert!(!parts.stats.fallback_used); + assert_eq!(parts.stats.node_count, 1); + assert_eq!(parts.stats.variable_count, 0); + assert_eq!(parts.stats.style_count, 0); + let metadata = parts.metadata.to_string(); + assert!(!metadata.contains("image/png")); + assert!(!metadata.contains("devupFastSnapshotDescriptor")); +} + +#[test] +fn exact_node_fast_path_accepts_the_stringified_handoff_contract() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + panic!("fast snapshot call expected") + }; + let result = fast_envelope_result(); + collector + .accept( + &fast_call.id, + UpstreamResult { + raw: Value::String(result.raw.to_string()), + }, + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("stringified fast snapshot should complete without fallback") + }; + assert_eq!(parts.stats.figma_tool_calls, 1); + assert_eq!(parts.stats.transport, "text"); + assert!(!parts.stats.fallback_used); +} + +/// The about screen as the script really answers it: asked for `mobile`, it +/// gathers `tablet` and `desktop` beside it and pages the three through the +/// fast path. The collector must ride that to the end — it had been reading +/// the family as a target mismatch and restarting a legacy walk that cost +/// 282 calls for a screen the fast path pages through in a handful. +#[test] +fn a_family_gathered_around_the_target_pages_through_the_fast_path() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(first_call) = collector.advance().unwrap() else { + panic!("fast snapshot call expected") + }; + let family = json!(["1:2", "2:2", "3:2"]); + collector + .accept( + &first_call.id, + family_page( + &family, + json!([ + {"id": "1:2", "type": "FRAME", "fields": {"name": "mobile", "childrenIds": ["1:3"]}, "extra": {}, "fieldErrors": {}}, + {"id": "2:2", "type": "FRAME", "fields": {"name": "tablet", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, + {"id": "3:2", "type": "FRAME", "fields": {"name": "desktop", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, + ]), + (0, 3, false, 4), + ), + ) + .unwrap(); + + let CollectorStep::Call(second_call) = collector.advance().unwrap() else { + panic!("the second fast page expected, not a legacy restart") + }; + assert_eq!(second_call.call.tool_name(), "use_figma"); + let code = second_call.call.arguments()["code"] + .as_str() + .unwrap() + .to_owned(); + assert!(code.contains("devupFastSnapshotEnvelope"), "{code}"); + assert!(code.contains("\"offset\":3"), "{code}"); + collector + .accept( + &second_call.id, + family_page( + &family, + json!([ + {"id": "1:3", "type": "TEXT", "fields": {"name": "Child", "characters": "Done", "childrenIds": []}, "extra": {}, "fieldErrors": {}}, + ]), + (3, 4, true, 4), + ), + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("the family should complete on the fast path") + }; + assert_eq!(parts.stats.figma_tool_calls, 2); + assert_eq!(parts.stats.transport, "text-paginated"); + assert!( + !parts.stats.fallback_used, + "{:?}", + parts.stats.fallback_reason + ); + assert_eq!(parts.stats.node_count, 4); + assert_eq!(parts.metadata["rootId"], "1:2"); + assert!( + parts + .snapshot_chunks + .iter() + .all(|chunk| json!(chunk.root_ids) == family), + "every page is rooted at the family" + ); +} + +/// One page of a fast snapshot that the script rooted at `root_ids`. +fn family_page( + root_ids: &Value, + nodes: Value, + (offset, next_offset, complete, total_nodes): (u64, u64, bool, u64), +) -> UpstreamResult { + let mut nodes = nodes.as_array().cloned().unwrap(); + // The cursor marker is a node of the page and counts in `nodeCount`, as + // the script counts it. + let node_count = nodes.len() + 1; + nodes.push(json!({ + "id": "__DEVUP_SNAPSHOT_CURSOR__", + "type": "DEVUP_INTERNAL", + "fields": {"offset": offset, "nextOffset": next_offset, "complete": complete, "totalNodes": total_nodes}, + "extra": {}, + "fieldErrors": {} + })); + let mut envelope = json!({ + "kind": "devupFastSnapshotEnvelope", + "schemaVersion": 1, + "source": {"fileKey": "FileKey123", "rootId": "1:2"}, + "snapshot": { + "fileKey": "FileKey123", + "version": "v1", + "rootIds": root_ids, + "nodes": nodes, + "diagnostics": [] + }, + "resources": { + "collections": [], + "variables": [], + "styles": [], + "usedRemoteVariables": [], + "localComplete": false, + "usedRemoteComplete": true, + "unresolved": [] + }, + "integrity": { + "nodeCount": node_count, + "variableRefCount": 0, + "styleRefCount": 0, + "utf8Bytes": 0 + } + }); + loop { + let bytes = serde_json::to_vec(&envelope).unwrap(); + if envelope["integrity"]["utf8Bytes"] == bytes.len() as u64 { + break; + } + envelope["integrity"]["utf8Bytes"] = Value::from(bytes.len()); + } + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": envelope.to_string()}]}), + } +} + +#[test] +fn requested_reference_png_is_collected_after_the_design_snapshot() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.reference_png = true; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + panic!("fast snapshot call expected") + }; + collector + .accept(&fast_call.id, fast_envelope_result()) + .unwrap(); + + let CollectorStep::Call(screenshot_call) = collector.advance().unwrap() else { + panic!("reference screenshot call expected") + }; + assert_eq!(screenshot_call.call.tool_name(), "get_screenshot"); + assert_eq!(screenshot_call.call.arguments()["fileKey"], "FileKey123"); + assert_eq!(screenshot_call.call.arguments()["nodeId"], "1:2"); + // The official tool inlines the PNG only when asked, and halves a 1920px + // screen unless its 1024px cap is raised. + assert_eq!( + screenshot_call.call.arguments()["enableBase64Response"], + true + ); + assert_eq!(screenshot_call.call.arguments()["maxDimension"], 8192); + let data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + // As the official tool answers, measured 2026-09-07: the URL as JSON in + // a text block, how to fetch it in another, and the PNG inlined after. + collector + .accept( + &screenshot_call.id, + UpstreamResult { + raw: json!({"content": [ + {"type": "text", "text": "{\"image_url\":\"https://www.figma.com/api/mcp/asset/x.png\",\"width\":1,\"height\":1,\"format\":\"png\"}"}, + {"type": "text", "text": "The screenshot is hosted at the URL in the first content entry (as JSON)."}, + {"type": "image", "mimeType": "image/png", "data": data} + ]}), + }, + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("collection should complete with its reference PNG") + }; + let reference = parts.reference_png.expect("reference PNG"); + assert_eq!(reference.mime_type, "image/png"); + assert_eq!(reference.data_base64, data); + assert_eq!(reference.byte_length, STANDARD.decode(data).unwrap().len()); + assert_eq!(reference.sha256.len(), 64); + assert_eq!(parts.stats.figma_tool_calls, 2); +} + +#[test] +fn reference_png_rejects_a_signature_only_payload() { + let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); + let signature_only = STANDARD.encode(b"\x89PNG\r\n\x1a\n"); + + let error = collector + .accept( + &screenshot_call_id, + screenshot_result(json!({ + "type": "image", + "mimeType": "image/png", + "data": signature_only + })), + ) + .expect_err("a PNG signature without chunks or pixels must be rejected"); + + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); +} + +#[test] +fn reference_png_rejects_a_truncated_image() { + let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); + let mut png = STANDARD.decode(valid_reference_png_base64()).unwrap(); + png.truncate(png.len() - 8); + + let error = collector + .accept( + &screenshot_call_id, + screenshot_result(json!({ + "type": "image", + "mimeType": "image/png", + "data": STANDARD.encode(png) + })), + ) + .expect_err("a truncated PNG must be rejected even when its signature is intact"); + + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); +} + +#[test] +fn reference_png_rejects_multiple_image_content_blocks() { + let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); + let image = json!({ + "type": "image", + "mimeType": "image/png", + "data": valid_reference_png_base64() + }); + + let error = collector + .accept( + &screenshot_call_id, + UpstreamResult { + raw: json!({"content": [image.clone(), image]}), + }, + ) + .expect_err("referencePng accepts exactly one official ImageContent block"); + + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); +} + +#[test] +fn reference_png_rejects_zero_content_blocks() { + let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); + + let error = collector + .accept( + &screenshot_call_id, + UpstreamResult { + raw: json!({"content": []}), + }, + ) + .expect_err("referencePng requires exactly one ImageContent block"); + + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); +} + +#[test] +fn reference_png_rejects_an_image_hidden_in_json_text() { + let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); + let hidden = json!({ + "type": "image", + "mimeType": "image/png", + "data": valid_reference_png_base64() + }) + .to_string(); + + let error = collector + .accept( + &screenshot_call_id, + screenshot_result(json!({"type": "text", "text": hidden})), + ) + .expect_err("JSON embedded in text is not an official ImageContent response"); + + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); +} + +#[test] +fn reference_png_rejects_an_image_nested_outside_the_official_result() { + let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); + + let error = collector + .accept( + &screenshot_call_id, + UpstreamResult { + raw: json!({ + "result": { + "content": [{ + "type": "image", + "mimeType": "image/png", + "data": valid_reference_png_base64() + }] + } + }), + }, + ) + .expect_err("nested lookalikes are not the official CallToolResult shape"); + + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffInvalid); +} + +#[test] +fn reference_png_rejects_dimensions_above_the_decode_bound() { + let (mut collector, screenshot_call_id) = collector_awaiting_reference_png(); + let pixels = vec![0_u8; 8_193]; + let mut png = Vec::new(); + PngEncoder::new(&mut png) + .write_image(&pixels, 8_193, 1, ExtendedColorType::L8) + .unwrap(); + + let error = collector + .accept( + &screenshot_call_id, + screenshot_result(json!({ + "type": "image", + "mimeType": "image/png", + "data": STANDARD.encode(png) + })), + ) + .expect_err("decoded dimensions are bounded independently of compressed size"); + + assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); +} + +#[test] +fn malformed_fast_result_restarts_legacy_from_metadata() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + panic!("fast call expected") + }; + collector + .accept( + &fast_call.id, + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": "unsupported"}]}), + }, + ) + .unwrap(); + + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!("legacy metadata must restart after a malformed envelope") + }; + assert_eq!(metadata_call.call.tool_name(), "get_metadata"); + collector + .accept(&metadata_call.id, metadata("FRAME", &[], 1)) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!("legacy snapshot expected") + }; + assert!( + snapshot_call.call.arguments()["code"] + .as_str() + .unwrap() + .contains("__DEVUP_SNAPSHOT_CURSOR__") + ); + collector + .accept(&snapshot_call.id, snapshot("1:2")) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("legacy fallback should complete") + }; + assert_eq!(parts.stats.figma_tool_calls, 3); + assert_eq!(parts.stats.transport, "legacy-cursor"); + assert!(parts.stats.fallback_used); + assert_eq!( + parts.stats.fallback_reason.as_deref(), + Some("textEnvelopeMissing") + ); + assert_eq!(parts.stats.node_count, 1); +} + +#[test] +fn a_split_subtree_still_snapshots_the_node_to_convert() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + panic!("fast call expected") + }; + collector + .accept( + &fast_call.id, + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": "unsupported"}]}), + }, + ) + .unwrap(); + + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!("legacy metadata must restart after a malformed envelope") + }; + collector + .accept(&metadata_call.id, metadata("FRAME", &["1:3"], 201)) + .unwrap(); + + let CollectorStep::Call(child) = collector.advance().unwrap() else { + panic!("the split child snapshot is expected first") + }; + let CollectorStep::Call(root) = collector.advance().unwrap() else { + panic!("the node to convert must be snapshotted as well") + }; + let child_code = child.call.arguments()["code"].as_str().unwrap().to_owned(); + let root_code = root.call.arguments()["code"].as_str().unwrap().to_owned(); + assert!(child_code.contains("\"1:3\""), "{child_code}"); + assert!(root_code.contains("\"1:2\""), "{root_code}"); +} + +#[test] +fn rejected_fast_call_can_restart_legacy_collection() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + panic!("fast call expected") + }; + + let recovered = collector + .reject( + &fast_call.id, + &DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "private upstream detail", + true, + ), + ) + .unwrap(); + + assert!(recovered); + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!("legacy metadata call expected") + }; + assert_eq!(metadata_call.call.tool_name(), "get_metadata"); +} + +#[test] +fn legacy_used_resources_combine_variables_and_styles_in_one_call() { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept( + &fast_call.id, + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": "fallback"}]}), + }, + ) + .unwrap(); + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!() + }; + collector + .accept(&metadata_call.id, metadata("FRAME", &[], 1)) + .unwrap(); + let CollectorStep::Call(snapshot_call) = collector.advance().unwrap() else { + panic!() + }; + let mut used_snapshot = snapshot("1:2"); + used_snapshot.raw["nodes"][0]["fields"] = json!({ + "name": "Synthetic", + "boundVariables": { + "fills": [{"type": "VARIABLE_ALIAS", "id": "VariableID:1:2"}] + }, + "textStyleId": "S:text" + }); + collector.accept(&snapshot_call.id, used_snapshot).unwrap(); + + let CollectorStep::Call(resources_call) = collector.advance().unwrap() else { + panic!("one combined resource call expected") + }; + let code = resources_call.call.arguments()["code"] + .as_str() + .unwrap() + .to_owned(); + assert!(code.contains("VariableID:1:2")); + assert!(code.contains("S:text")); + assert!(matches!( + collector.advance().unwrap(), + CollectorStep::AwaitingResults + )); +} + +fn target(node_id: &str) -> FigmaTarget { + FigmaTarget::parse(&format!( + "https://www.figma.com/design/FileKey123/Fixture?node-id={}", + node_id.replace(':', "-") + )) + .unwrap() +} + +fn collector_awaiting_reference_png() -> (CollectorSession, String) { + let mut request = CollectionRequest::new(target("1:2"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.reference_png = true; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(fast_call) = collector.advance().unwrap() else { + panic!("fast snapshot call expected") + }; + collector + .accept(&fast_call.id, fast_envelope_result()) + .unwrap(); + let CollectorStep::Call(screenshot_call) = collector.advance().unwrap() else { + panic!("reference screenshot call expected") + }; + (collector, screenshot_call.id) +} + +fn screenshot_result(content: Value) -> UpstreamResult { + UpstreamResult { + raw: json!({"content": [content]}), + } +} + +fn valid_reference_png_base64() -> &'static str { + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +} + +fn fast_envelope_result() -> 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": []}, + "extra": {}, + "fieldErrors": {} + }], + "diagnostics": [] + }, + "resources": { + "collections": [], + "variables": [], + "styles": [], + "usedRemoteVariables": [], + "localComplete": false, + "usedRemoteComplete": true, + "unresolved": [] + }, + "integrity": { + "nodeCount": 1, + "variableRefCount": 0, + "styleRefCount": 0, + "utf8Bytes": 0 + } + }); + let envelope_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 _ = 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": envelope.to_string()} + ]}), + } +} + +fn fast_theme_envelope_result() -> UpstreamResult { + let mut envelope = json!({ + "kind": "devupFastThemeEnvelope", + "schemaVersion": 1, + "source": {"fileKey": "FileKey123", "version": "v2"}, + "resources": { + "collections": [{"id": "c", "name": "Theme"}], + "variables": [{"id": "v", "name": "primary"}], + "styles": [{"id": "s", "name": "body", "styleType": "TEXT"}], + "usedRemoteVariables": [], + "usedVariableIds": ["v"], + "usedStyleIds": ["s"], + "localComplete": true, + "usedRemoteComplete": true, + "unresolved": [] + }, + "integrity": { + "collectionCount": 1, + "variableCount": 1, + "styleCount": 1, + "unresolvedCount": 0, + "utf8Bytes": 0 + } + }); + let envelope_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 _ = envelope_bytes; + UpstreamResult { + raw: json!({"content": [ + {"type": "text", "text": envelope.to_string()} + ]}), + } +} + +fn file_target() -> FigmaTarget { + FigmaTarget::parse("https://www.figma.com/design/FileKey123/Fixture").unwrap() +} + +fn metadata(node_type: &str, children: &[&str], descendant_count: usize) -> UpstreamResult { + UpstreamResult { + raw: json!({ + "structuredContent": { + "devupMetadata": { + "fileKey": "FileKey123", + "version": "v1", + "rootId": "1:2", + "nodes": [{ + "id": "1:2", + "type": node_type, + "childrenIds": children, + "descendantCount": descendant_count + }] + } + } + }), + } +} + +fn snapshot(root_id: &str) -> UpstreamResult { + UpstreamResult { + raw: json!({ + "fileKey": "FileKey123", + "version": "v1", + "rootIds": [root_id], + "nodes": [{ + "id": root_id, + "type": "FRAME", + "fields": {"name": "Synthetic"}, + "extra": {}, + "fieldErrors": {} + }] + }), + } +} + +fn official_xml_metadata() -> UpstreamResult { + UpstreamResult { + raw: json!({ + "content": [ + { + "type": "text", + "text": "" + }, + { + "type": "text", + "text": "Synthetic guidance that is not metadata" + } + ] + }), + } +} + +fn official_top_level_pages() -> UpstreamResult { + 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: Cover\n- 12:34: Body: Proofread" + }] + }), + } +} + +fn file_page_metadata() -> UpstreamResult { + UpstreamResult { + raw: json!({ + "structuredContent": { + "devupMetadata": { + "fileKey": "FileKey123", + "version": "v1", + "rootId": "0:1", + "nodes": [ + { + "id": "0:1", + "type": "PAGE", + "name": "Cover", + "childrenIds": ["1:2"], + "descendantCount": 1 + }, + { + "id": "1:2", + "type": "FRAME", + "name": "A : STORY-F-PROOFREAD", + "childrenIds": [], + "descendantCount": 0 + } + ] + } + } + }), + } +} + +#[test] +fn file_scope_starts_from_the_file_even_when_the_url_contains_a_node() { + let request = CollectionRequest::new(target("1:2"), CollectionScope::File); + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(metadata_call) = collector.advance().unwrap() else { + panic!("metadata call expected") + }; + assert_eq!(metadata_call.call.tool_name(), "get_metadata"); + assert_eq!(metadata_call.expected_node_id, None); + // Omitted, not null: the official schema refuses `nodeId: null`. + assert!(!metadata_call.call.arguments().contains_key("nodeId")); +} + +#[test] +fn official_top_level_page_list_expands_to_page_metadata_calls() { + let request = CollectionRequest::new(file_target(), CollectionScope::File); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(file_metadata) = collector.advance().unwrap() else { + panic!("file metadata call expected") + }; + + collector + .accept(&file_metadata.id, official_top_level_pages()) + .unwrap(); + + let CollectorStep::Call(first_page) = collector.advance().unwrap() else { + panic!("first page metadata call expected") + }; + let CollectorStep::Call(second_page) = collector.advance().unwrap() else { + panic!("second page metadata call expected") + }; + assert_eq!(first_page.call.tool_name(), "get_metadata"); + assert_eq!(first_page.expected_node_id.as_deref(), Some("0:1")); + assert_eq!(second_page.call.tool_name(), "get_metadata"); + assert_eq!(second_page.expected_node_id.as_deref(), Some("12:34")); +} + +#[test] +fn metadata_only_file_collection_completes_without_snapshot_calls() { + let mut request = CollectionRequest::new(file_target(), CollectionScope::File); + request.metadata_only = true; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(file_metadata) = collector.advance().unwrap() else { + panic!("file metadata call expected") + }; + collector + .accept(&file_metadata.id, official_top_level_pages()) + .unwrap(); + + let CollectorStep::Call(first_page) = collector.advance().unwrap() else { + panic!("first page metadata call expected") + }; + let CollectorStep::Call(second_page) = collector.advance().unwrap() else { + panic!("second page metadata call expected") + }; + collector + .accept(&first_page.id, file_page_metadata()) + .unwrap(); + let mut second = file_page_metadata(); + second.raw["structuredContent"]["devupMetadata"]["rootId"] = json!("12:34"); + second.raw["structuredContent"]["devupMetadata"]["nodes"] = json!([{ + "id": "12:34", + "type": "PAGE", + "name": "Body: Proofread", + "childrenIds": [], + "descendantCount": 0 + }]); + collector.accept(&second_page.id, second).unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("metadata-only collection should complete") + }; + assert_eq!(parts.snapshot_chunks.len(), 1); + let chunk = &parts.snapshot_chunks[0]; + assert_eq!(chunk.root_ids, ["0:1", "12:34"]); + assert_eq!(chunk.nodes.len(), 3); + let match_node = chunk.nodes.iter().find(|node| node.id == "1:2").unwrap(); + assert_eq!(match_node.fields["name"], json!("A : STORY-F-PROOFREAD")); +} + +#[test] +fn variables_only_file_collection_skips_page_and_node_snapshots() { + let mut request = CollectionRequest::new(file_target(), CollectionScope::File); + request.resource_scope = ResourceScope::File; + request.variables_only = true; + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(fast_theme) = collector.advance().unwrap() else { + 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!( + arguments["code"] + .as_str() + .unwrap() + .contains("devupFastThemeEnvelope") + ); + collector + .accept(&fast_theme.id, fast_theme_envelope_result()) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("valid fast theme should complete in one call") + }; + assert_eq!(parts.stats.figma_tool_calls, 1); + 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); + assert_eq!(parts.variables.as_ref().unwrap().raw["localComplete"], true); +} + +/// A file's resources rarely fit one answer, so the theme script pages +/// them: each page carries a run of the resources from an offset and says +/// where the next starts, and the collector asks for the next page from +/// there until one says it is the last. The pages are merged in order, a +/// resource two pages both carry is kept once, and the scan every page +/// repeats comes from the last one. +#[test] +fn a_paged_fast_theme_is_read_page_by_page_and_merged() { + let mut request = CollectionRequest::new(file_target(), CollectionScope::File); + request.resource_scope = ResourceScope::File; + request.variables_only = true; + let mut collector = CollectorSession::new(request); + + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!("fast theme call expected") + }; + assert!( + first.call.arguments()["code"] + .as_str() + .unwrap() + .contains("{\"offset\":0}") + ); + collector + .accept( + &first.id, + fast_theme_page_result( + json!({"collections": [{"id": "c", "name": "Theme"}], "variables": [{"id": "v1", "name": "primary"}], "styles": [], "usedVariableIds": ["v1"]}), + json!({"offset": 0, "nextOffset": 2, "complete": false, "totalItems": 6}), + ), + ) + .unwrap(); + + let CollectorStep::Call(second) = collector.advance().unwrap() else { + panic!("the next page is asked for") + }; + assert_eq!(second.call.tool_name(), "use_figma"); + assert!( + second.call.arguments()["code"] + .as_str() + .unwrap() + .contains("{\"offset\":2}") + ); + collector + .accept( + &second.id, + fast_theme_page_result( + json!({"collections": [], "variables": [{"id": "v1", "name": "primary"}, {"id": "v2", "name": "text"}], "styles": [{"id": "s", "name": "body", "styleType": "TEXT"}], "usedVariableIds": ["v1", "v2"], "usedStyleIds": ["s"]}), + json!({"offset": 2, "nextOffset": 6, "complete": true, "totalItems": 6}), + ), + ) + .unwrap(); + + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("the last page completes the theme") + }; + assert_eq!(parts.stats.figma_tool_calls, 2); + assert_eq!(parts.stats.transport, "text"); + assert!(!parts.stats.fallback_used); + let resources = &parts.variables.as_ref().unwrap().raw; + assert_eq!(resources["collections"].as_array().unwrap().len(), 1); + // v1 was on both pages and is kept once. + assert_eq!( + resources["variables"] + .as_array() + .unwrap() + .iter() + .map(|variable| variable["id"].as_str().unwrap()) + .collect::>(), + ["v1", "v2"] + ); + assert_eq!(resources["styles"].as_array().unwrap().len(), 1); + assert_eq!(resources["localComplete"], true); + // The scan came in two shares, v1 on both, and is whole and unrepeated. + assert_eq!(resources["usedVariableIds"], json!(["v1", "v2"])); + assert_eq!(resources["usedStyleIds"], json!(["s"])); + assert_eq!(parts.stats.variable_count, 2); + assert_eq!(parts.stats.style_count, 1); + assert_eq!(parts.metadata["pageCount"], 2); +} \ No newline at end of file diff --git a/crates/devup-mcp/Cargo.toml b/crates/devup-mcp/Cargo.toml index fb9a6fd2..d498ce32 100644 --- a/crates/devup-mcp/Cargo.toml +++ b/crates/devup-mcp/Cargo.toml @@ -51,3 +51,4 @@ tokio = { workspace = true, features = ["test-util"] } # figma crate's transport tests use, so the tool surface is driven end to end. tokio-tungstenite.workspace = true futures-util.workspace = true +jsonschema = "0.59.0" diff --git a/crates/devup-mcp/src/asset_batches.rs b/crates/devup-mcp/src/asset_batches.rs index 52adbeac..ecc5baa5 100644 --- a/crates/devup-mcp/src/asset_batches.rs +++ b/crates/devup-mcp/src/asset_batches.rs @@ -42,7 +42,7 @@ fn unwrap_response(value: &Value) -> anyhow::Result { } anyhow::ensure!( batch_file(value).is_some(), - "Each batch must contain its export source.fileKey (or, from a Devup Bridge plugin that could not report one, source.fileName); pending jobs and bare manifests cannot establish source identity" + include_str!("server/text/asset_batches-unwrap_response-c1acefdb.md") ); anyhow::ensure!( value["assetSummary"].is_object(), @@ -215,7 +215,7 @@ pub fn merge(values: &[Value]) -> anyhow::Result { source[field] = json!(file); } Ok(json!({"status":if complete {"complete"} else {"partial"}, - "description":"Cumulative binary collection across the supplied batches only. Per-batch partial can mean assets were not requested in that batch; it is not an export failure. Projection quality is not merged. File writes are historical reports, not reverified files.", + "description":include_str!("server/text/asset_batches-merge-8a712496.md"), "scope":"union-of-supplied-batches", "inventoryComplete":incomplete_inventory_batches == 0, "incompleteInventoryBatchCount":incomplete_inventory_batches, "source":source, "scopeRootIds":roots,"batchCount":values.len(),"discovery":if incomplete {"incomplete"} else {"complete"}, diff --git a/crates/devup-mcp/src/server/artifacts.rs b/crates/devup-mcp/src/server/artifacts.rs index 2c0909e8..897175d1 100644 --- a/crates/devup-mcp/src/server/artifacts.rs +++ b/crates/devup-mcp/src/server/artifacts.rs @@ -1,1985 +1,5 @@ -use std::{ - cmp::Reverse, - collections::BTreeMap, - future::Future, - sync::{Arc, OnceLock}, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use devup_mcp_figma::{ - AssetSelection, CollectedPayload, CollectionRequest, CollectionScope, DevupError, ErrorCode, - ExploreReadOptions, ResourceScope, SearchReadOptions, SectionReadOptions, -}; -use rand::Rng; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use tokio::sync::{Mutex, OwnedMutexGuard, watch}; - -use super::delivery::{ProjectedOutput, RESOURCE_CHUNK_BYTES}; - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtifactRequestKey { - #[serde(default, skip_serializing_if = "Option::is_none")] - measurement_node_ids: Option>, - file_key: String, - node_id: Option, - branch_key: Option, - scope: CollectionScope, - resource_scope: ResourceScope, - include_context: bool, - metadata_only: bool, - variables_only: bool, - search: Option, - explore: Option, - section: Option, - asset_selections: Vec, - web_ready_images: bool, - reference_png: bool, -} - -impl ArtifactRequestKey { - pub fn from_collection(request: &CollectionRequest) -> Self { - let mut section = request.section.clone(); - if let Some(section) = &mut section { - section.frame_ids.sort(); - section.frame_ids.dedup(); - } - let mut asset_selections = request.asset_selections.clone(); - asset_selections - .sort_by_cached_key(|selection| serde_json::to_string(selection).unwrap_or_default()); - asset_selections.dedup(); - Self { - measurement_node_ids: None, - file_key: request.target.file_key.clone(), - node_id: request.target.node_id.clone(), - branch_key: request.target.branch_key.clone(), - scope: request.scope, - resource_scope: request.resource_scope, - include_context: request.include_context, - metadata_only: request.metadata_only, - variables_only: request.variables_only, - search: request.search.clone(), - explore: request.explore.clone(), - section, - asset_selections, - web_ready_images: request.web_ready_images, - reference_png: request.reference_png, - } - } - - fn digest(&self) -> String { - sha256_hex(&serde_json::to_vec(self).unwrap_or_default()) - } - - fn capabilities(&self) -> ArtifactCapabilities { - let kind = if self.measurement_node_ids.is_some() { - ArtifactKind::Measurement - } else if self.search.is_some() { - ArtifactKind::Search - } else if self.explore.is_some() { - ArtifactKind::Explore - } else if self - .section - .as_ref() - .is_some_and(|section| section.frame_ids.is_empty() && !section.all_screens) - { - ArtifactKind::SectionIndex - } else if self.variables_only { - ArtifactKind::ThemeOnly - } else { - ArtifactKind::Design - }; - ArtifactCapabilities { - kind, - collection_scope: self.scope, - resource_scope: self.resource_scope, - asset_capture_count: self.asset_selections.len(), - web_ready_images: self.web_ready_images, - asset_captures: self.asset_selections.clone(), - reference_png: self.reference_png, - section_selection: self.section.clone(), - } - } - - pub(super) fn measurement(target: devup_mcp_figma::FigmaTarget, ids: Vec) -> Self { - let mut request = CollectionRequest::new(target, CollectionScope::Node); - request.resource_scope = ResourceScope::None; - let mut key = Self::from_collection(&request); - key.measurement_node_ids = Some(ids); - key - } - - fn can_serve_explore(&self, requested: &Self) -> bool { - let (Some(cached_options), Some(requested_options)) = - (self.explore.as_ref(), requested.explore.as_ref()) - else { - return false; - }; - if cached_options.text_preview_limit != requested_options.text_preview_limit - || cached_options.projection_limit < requested_options.projection_limit - { - return false; - } - let mut cached_scope = self.clone(); - let mut requested_scope = requested.clone(); - cached_scope.node_id = None; - requested_scope.node_id = None; - cached_scope.explore = None; - requested_scope.explore = None; - cached_scope == requested_scope - } - - fn explore_projection_limit(&self) -> usize { - self.explore - .as_ref() - .map_or(usize::MAX, |options| options.projection_limit) - } - - fn explore_reuse_kind(&self, requested: &Self) -> Option { - self.can_serve_explore(requested).then(|| { - let same_node = self.node_id == requested.node_id; - let same_projection = - self.explore_projection_limit() == requested.explore_projection_limit(); - match (same_node, same_projection) { - (true, true) => CacheReuseKind::Exact, - (true, false) => CacheReuseKind::Superset, - (false, true) => CacheReuseKind::RelatedNode, - (false, false) => CacheReuseKind::RelatedNodeSuperset, - } - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum ArtifactKind { - Measurement, - Design, - ThemeOnly, - Search, - Explore, - SectionIndex, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum CacheReuseKind { - Miss, - Exact, - RelatedNode, - Superset, - RelatedNodeSuperset, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtifactCapabilities { - pub kind: ArtifactKind, - pub collection_scope: CollectionScope, - pub resource_scope: ResourceScope, - pub asset_capture_count: usize, - pub web_ready_images: bool, - pub reference_png: bool, - /// Last validated Section selection; projection callers restore this when no selection is supplied. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub section_selection: Option, - #[serde(skip)] - asset_captures: Vec, -} - -impl ArtifactCapabilities { - /// Validate exact asset ID, format and scale coverage before projecting a cached artifact. - pub fn validate_asset_captures(&self, requested: &[AssetSelection]) -> Result<(), DevupError> { - let missing = requested - .iter() - .filter(|capture| !self.asset_captures.contains(capture)) - .collect::>(); - if missing.is_empty() { - return Ok(()); - } - Err(DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - "This artifact was collected without the requested asset captures. Remove artifactId and call again with the original url and assetRequests.", - false, - json!({"missingAssetCaptures": missing, "assetCaptureCount": self.asset_capture_count}), - )) - } - - pub fn supports_asset_captures(&self, requested: &[AssetSelection]) -> bool { - requested - .iter() - .all(|capture| self.asset_captures.contains(capture)) - } -} - -#[derive(Debug, Clone, Copy)] -pub struct ArtifactLimits { - pub ttl: Duration, - pub max_entries: usize, - pub max_entry_bytes: usize, - pub max_total_bytes: usize, -} - -impl Default for ArtifactLimits { - fn default() -> Self { - Self { - ttl: Duration::from_secs(10 * 60), - max_entries: 8, - // One collection may fill the whole cache: a scope:file collection of a - // 5,543-node test file is 63.6 MB, and refusing it failed the export, - // while the entries it evicts cost only a re-collection. - max_entry_bytes: 384 * 1024 * 1024, - max_total_bytes: 384 * 1024 * 1024, - } - } -} - -pub trait ArtifactClock: Send + Sync { - fn now_epoch_seconds(&self) -> u64; -} - -#[derive(Debug)] -struct SystemClock; - -impl ArtifactClock for SystemClock { - fn now_epoch_seconds(&self) -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - } -} - -#[derive(Debug, Clone)] -pub struct ArtifactLookup { - pub artifact_id: String, - pub content_hash: String, - pub created_at_epoch_seconds: u64, - pub expires_at_epoch_seconds: u64, - pub age_seconds: u64, - pub remaining_ttl_seconds: u64, - pub size_bytes: usize, - pub cache_hit: bool, - pub reuse_kind: CacheReuseKind, - pub capabilities: ArtifactCapabilities, - pub payload: Arc, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtifactStoreStats { - pub entry_count: usize, - pub total_bytes: usize, - pub max_entries: usize, - pub max_total_bytes: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AttachedOutputManifest { - #[serde(skip_serializing_if = "Option::is_none")] - pub asset_metadata: Option, - pub artifact_id: String, - pub output_id: String, - pub name: String, - pub mime_type: String, - pub raw_bytes: usize, - pub sha256: String, - pub chunk_count: usize, - pub chunk_bytes: usize, - pub chunk_uris: Vec, - pub is_binary: bool, - pub manifest_uri: String, - pub expires_at_epoch_seconds: u64, -} - -#[derive(Debug)] -struct AttachedOutput { - manifest: AttachedOutputManifest, - bytes: Arc<[u8]>, - ranges: Vec<(usize, usize)>, - allocation_bytes: usize, -} - -#[derive(Debug)] -struct Entry { - key_digest: String, - request_key: ArtifactRequestKey, - selection_bytes: usize, - content_hash: String, - created_at: u64, - expires_at: u64, - size_bytes: usize, - last_access: u64, - capabilities: ArtifactCapabilities, - payload: Arc, - outputs: BTreeMap, - projections: BTreeMap>, -} - -type AcquisitionResult = Result; - -#[derive(Default)] -struct StoreState { - entries: BTreeMap, - key_index: BTreeMap, - in_flight: BTreeMap>>, - in_flight_keys: BTreeMap, - total_bytes: usize, - recovery: BTreeMap, - access_sequence: u64, -} - -#[derive(Debug, Clone, Copy)] -enum InFlightWait { - Exact, - Compatible(CacheReuseKind), -} - -#[derive(Clone)] -pub struct ArtifactStore { - state: Arc>, - clock: Arc, - limits: ArtifactLimits, -} - -pub struct OutputReservation { - state: Option>, - artifact_id: String, - projection_key: String, - staged: Vec, - manifests: Vec, - allocation: usize, - created: bool, - max_total_bytes: usize, -} - -impl OutputReservation { - pub fn manifests(&self) -> &[AttachedOutputManifest] { - &self.manifests - } - - pub fn commit(mut self) -> Vec { - let Some(mut state) = self.state.take() else { - return self.manifests; - }; - if !self.created { - return self.manifests; - } - while state.total_bytes.saturating_add(self.allocation) > self.max_total_bytes { - let lru_id = state - .entries - .iter() - .filter(|(id, _)| id.as_str() != self.artifact_id) - .min_by_key(|(_, entry)| entry.last_access) - .map(|(id, _)| id.clone()) - .expect("output reservation validated enough evictable capacity"); - remove_entry(&mut state, &lru_id); - } - state.access_sequence = state.access_sequence.saturating_add(1); - let access = state.access_sequence; - let entry = state - .entries - .get_mut(&self.artifact_id) - .expect("reserved artifact remains pinned by the store lock"); - entry.last_access = access; - entry.size_bytes = entry.size_bytes.saturating_add(self.allocation); - let ids = self - .staged - .iter() - .map(|output| output.manifest.output_id.clone()) - .collect::>(); - for output in self.staged.drain(..) { - entry - .outputs - .insert(output.manifest.output_id.clone(), output); - } - entry.projections.insert(self.projection_key, ids); - state.total_bytes = state.total_bytes.saturating_add(self.allocation); - self.manifests - } - - pub fn rollback(self) {} -} - -impl Default for ArtifactStore { - fn default() -> Self { - Self::with_limits(ArtifactLimits::default()) - } -} - -impl ArtifactStore { - pub fn with_limits(limits: ArtifactLimits) -> Self { - Self::with_clock(Arc::new(SystemClock), limits) - } - - pub fn with_clock(clock: Arc, limits: ArtifactLimits) -> Self { - Self { - state: Arc::new(Mutex::new(StoreState::default())), - clock, - limits, - } - } - - pub async fn get_or_acquire( - &self, - key: ArtifactRequestKey, - refresh: bool, - acquire: F, - ) -> Result - where - F: FnOnce() -> Fut, - Fut: Future>, - { - let key_digest = key.digest(); - let capabilities = key.capabilities(); - if refresh { - let payload = acquire().await?; - return self - .insert_with_digest(key, key_digest, capabilities, payload) - .await; - } - let mut acquire = Some(acquire); - loop { - let (owner, mut receiver, sender, wait_digest, wait_kind) = { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - if let Some(hit) = lookup_by_key(&mut state, &key_digest, now, true) { - return Ok(hit); - } - if let Some(receiver) = state.in_flight.get(&key_digest) { - ( - false, - receiver.clone(), - None, - key_digest.clone(), - InFlightWait::Exact, - ) - } else if let Some((compatible_digest, receiver, reuse_kind)) = state - .in_flight_keys - .iter() - // The receiver is taken here, from the same read of the - // same lock that picked the key, rather than looked up - // again below. `in_flight` and `in_flight_keys` are meant - // to hold the same digests — they are written together and - // erased together — but that is an agreement between three - // separate sites, one of which only runs when a waiter - // notices a cancelled owner. Reading the key without its - // receiver leaves somewhere for the two to disagree, and - // what a disagreement would have cost is the process: this - // is the shared store every collection goes through. A - // candidate that has no receiver simply is not a candidate. - .filter_map(|(digest, in_flight_key)| { - let receiver = state.in_flight.get(digest)?; - in_flight_key.explore_reuse_kind(&key).map(|reuse_kind| { - ( - digest.clone(), - receiver.clone(), - reuse_kind, - in_flight_key.explore_projection_limit(), - ) - }) - }) - .min_by_key(|(_, _, _, projection_limit)| *projection_limit) - .map(|(digest, receiver, reuse_kind, _)| (digest, receiver, reuse_kind)) - { - ( - false, - receiver, - None, - compatible_digest, - InFlightWait::Compatible(reuse_kind), - ) - } else { - let (sender, receiver) = watch::channel(None); - state.in_flight.insert(key_digest.clone(), receiver.clone()); - state.in_flight_keys.insert(key_digest.clone(), key.clone()); - ( - true, - receiver, - Some(sender), - key_digest.clone(), - InFlightWait::Exact, - ) - } - }; - - if !owner { - if receiver.wait_for(Option::is_some).await.is_err() { - let mut state = self.state.lock().await; - if state - .in_flight - .get(&wait_digest) - .is_some_and(|current| current.same_channel(&receiver)) - { - state.in_flight.remove(&wait_digest); - state.in_flight_keys.remove(&wait_digest); - } - continue; - } - let mut result = receiver - .borrow() - .clone() - .ok_or_else(acquisition_cancelled)?; - match (wait_kind, &mut result) { - (InFlightWait::Exact, Ok(hit)) => { - hit.cache_hit = true; - hit.reuse_kind = CacheReuseKind::Exact; - } - (InFlightWait::Compatible(reuse_kind), Ok(hit)) - if key.node_id.as_ref().is_none_or(|node_id| { - hit.payload.snapshot.nodes.contains_key(node_id) - }) => - { - let now = self.clock.now_epoch_seconds(); - hit.cache_hit = true; - hit.reuse_kind = reuse_kind; - hit.age_seconds = now.saturating_sub(hit.created_at_epoch_seconds); - hit.remaining_ttl_seconds = - hit.expires_at_epoch_seconds.saturating_sub(now); - } - (InFlightWait::Exact, Err(_)) => {} - (InFlightWait::Compatible(_), _) => continue, - } - return result; - } - - let acquire = acquire.take().ok_or_else(acquisition_cancelled)?; - let result = match acquire().await { - Ok(payload) => { - self.insert_with_digest( - key.clone(), - key_digest.clone(), - capabilities.clone(), - payload, - ) - .await - } - Err(error) => Err(error), - }; - { - let mut state = self.state.lock().await; - state.in_flight.remove(&key_digest); - state.in_flight_keys.remove(&key_digest); - } - if let Some(sender) = sender { - let _ = sender.send(Some(result.clone())); - } - return result; - } - } - - pub async fn insert( - &self, - key: ArtifactRequestKey, - payload: CollectedPayload, - ) -> Result { - self.insert_with_digest(key.clone(), key.digest(), key.capabilities(), payload) - .await - } - - /// Remember a selection after the caller has validated it against the Section candidates. - /// This updates projection state only: the original acquisition key and capture capabilities stay intact. - pub async fn remember_section_selection( - &self, - artifact_id: &str, - selection: SectionReadOptions, - ) -> Result { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - let entry = state - .entries - .get(artifact_id) - .ok_or_else(resource_expired)?; - if (entry.capabilities.section_selection.is_none() - && entry.capabilities.kind != ArtifactKind::SectionIndex) - || (selection.all_screens && !selection.frame_ids.is_empty()) - || (!selection.all_screens && selection.frame_ids.is_empty()) - || selection - .frame_ids - .iter() - .collect::>() - .len() - != selection.frame_ids.len() - { - return Err(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "A Section selection must contain distinct frameIds or allScreens:true.", - false, - )); - } - let selection_bytes = serde_json::to_vec(&selection) - .map_err(|error| { - DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - error.to_string(), - false, - ) - })? - .len(); - let previous_bytes = entry.selection_bytes; - let size_bytes = entry - .size_bytes - .saturating_sub(previous_bytes) - .saturating_add(selection_bytes); - let total_bytes = state - .total_bytes - .saturating_sub(previous_bytes) - .saturating_add(selection_bytes); - if size_bytes > self.limits.max_entry_bytes || total_bytes > self.limits.max_total_bytes { - return Err(DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "The Section selection exceeded the artifact memory limit.", - false, - )); - } - let entry = state - .entries - .get_mut(artifact_id) - .ok_or_else(resource_expired)?; - entry.capabilities.section_selection = Some(selection); - entry.selection_bytes = selection_bytes; - entry.size_bytes = size_bytes; - state.total_bytes = total_bytes; - touch_entry(&mut state, artifact_id, now, CacheReuseKind::Exact) - .ok_or_else(resource_expired) - } - - pub async fn get_for_export(&self, artifact_id: &str) -> Result { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - touch_entry(&mut state, artifact_id, now, CacheReuseKind::Exact).ok_or_else(|| { - DevupError::with_details( - ErrorCode::DevupFigmaHandoffExpired, - "The Figma artifact is missing or expired.", - true, - missing_artifact_details(&state, artifact_id), - ) - }).and_then(|artifact| { - if artifact.capabilities.kind == ArtifactKind::Measurement { - Err(DevupError::with_details(ErrorCode::DevupFigmaHandoffInvalid,"A measurement-only artifact cannot supply export outputs; acquire a design export by explicit URL.",false,json!({"capabilities":artifact.capabilities,"artifactId":artifact.artifact_id}))) - } else {Ok(artifact)} - }) - } - - pub async fn get(&self, artifact_id: &str) -> Option { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - touch_entry(&mut state, artifact_id, now, CacheReuseKind::Exact) - } - - pub async fn lookup(&self, key: &ArtifactRequestKey) -> Option { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - lookup_by_key(&mut state, &key.digest(), now, true) - } - - pub async fn lookup_related_explore(&self, key: &ArtifactRequestKey) -> Option { - let requested_node_id = key.node_id.as_deref()?; - key.explore.as_ref()?; - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - let (artifact_id, reuse_kind) = state - .entries - .iter() - .filter_map(|(artifact_id, entry)| { - entry - .request_key - .explore_reuse_kind(key) - .map(|reuse_kind| (artifact_id, entry, reuse_kind)) - }) - .filter(|(_, entry, _)| entry.payload.snapshot.nodes.contains_key(requested_node_id)) - .min_by_key(|(_, entry, _)| { - ( - entry.request_key.explore_projection_limit(), - Reverse(entry.last_access), - ) - }) - .map(|(artifact_id, _, reuse_kind)| (artifact_id.clone(), reuse_kind))?; - touch_entry(&mut state, &artifact_id, now, reuse_kind) - } - - pub async fn stats(&self) -> ArtifactStoreStats { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - ArtifactStoreStats { - entry_count: state.entries.len(), - total_bytes: state.total_bytes, - max_entries: self.limits.max_entries, - max_total_bytes: self.limits.max_total_bytes, - } - } - - pub async fn attach_outputs( - &self, - artifact_id: &str, - projection_key: &str, - outputs: Vec, - ) -> Result, DevupError> { - Ok(self - .reserve_outputs(artifact_id, projection_key, outputs) - .await? - .commit()) - } - - pub async fn reserve_outputs( - &self, - artifact_id: &str, - projection_key: &str, - outputs: Vec, - ) -> Result { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.clone().lock_owned().await; - self.prune_expired(&mut state, now); - if let Some(ids) = state - .entries - .get(artifact_id) - .and_then(|entry| entry.projections.get(projection_key)) - .cloned() - { - let entry = state - .entries - .get(artifact_id) - .ok_or_else(resource_expired)?; - let manifests = ids - .iter() - .map(|id| { - entry - .outputs - .get(id) - .map(|output| output.manifest.clone()) - .ok_or_else(resource_expired) - }) - .collect::, _>>()?; - return Ok(OutputReservation { - state: Some(state), - artifact_id: artifact_id.to_owned(), - projection_key: projection_key.to_owned(), - staged: Vec::new(), - manifests, - allocation: 0, - created: false, - max_total_bytes: self.limits.max_total_bytes, - }); - } - let expires_at = state - .entries - .get(artifact_id) - .map(|entry| entry.expires_at) - .ok_or_else(resource_expired)?; - let mut staged = Vec::with_capacity(outputs.len()); - let mut staged_ids = BTreeMap::new(); - for output in outputs { - if output.name.is_empty() - || !output - .name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - || output.mime_type.is_empty() - { - return Err(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "The resource output name or MIME type is invalid.", - false, - )); - } - let output_id = output.resource_id; - if !valid_output_id(&output_id) - || staged_ids.contains_key(&output_id) - || state - .entries - .get(artifact_id) - .is_some_and(|entry| entry.outputs.contains_key(&output_id)) - { - return Err(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "The resource output ID is duplicated or invalid.", - true, - )); - } - staged_ids.insert(output_id.clone(), ()); - let raw_bytes = output.bytes.len(); - let ranges = output_chunk_ranges(&output.bytes, output.is_binary)?; - let encoded_bytes = if output.is_binary { - raw_bytes.div_ceil(3).saturating_mul(4) - } else { - 0 - }; - let allocation_bytes = raw_bytes.saturating_add(encoded_bytes); - let manifest_uri = - format!("devup://artifact/{artifact_id}/outputs/{output_id}/manifest"); - staged.push(AttachedOutput { - manifest: AttachedOutputManifest { - asset_metadata: output.asset_metadata, - artifact_id: artifact_id.to_owned(), - output_id: output_id.clone(), - name: output.name, - mime_type: output.mime_type, - raw_bytes, - sha256: sha256_hex(&output.bytes), - chunk_uris: (0..ranges.len()) - .map(|index| { - format!( - "devup://artifact/{artifact_id}/outputs/{output_id}/chunks/{index}" - ) - }) - .collect(), - chunk_count: ranges.len(), - chunk_bytes: RESOURCE_CHUNK_BYTES, - is_binary: output.is_binary, - manifest_uri, - expires_at_epoch_seconds: expires_at, - }, - bytes: Arc::from(output.bytes), - ranges, - allocation_bytes, - }); - } - let allocation = staged - .iter() - .try_fold(0_usize, |sum, output| { - sum.checked_add(output.allocation_bytes) - }) - .ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "The resource allocation size exceeded the safe range.", - false, - ) - })?; - let entry_size = state - .entries - .get(artifact_id) - .map(|entry| entry.size_bytes) - .ok_or_else(resource_expired)?; - if entry_size.saturating_add(allocation) > self.limits.max_entry_bytes - || allocation > self.limits.max_total_bytes - { - return Err(DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "The resource output exceeded the artifact memory limit.", - false, - )); - } - let retained_bytes = state - .entries - .get(artifact_id) - .map(|entry| entry.size_bytes) - .ok_or_else(resource_expired)?; - if retained_bytes.saturating_add(allocation) > self.limits.max_total_bytes { - return Err(DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "The resource output exceeded the total memory limit.", - false, - )); - } - let manifests = staged - .iter() - .map(|output| output.manifest.clone()) - .collect::>(); - Ok(OutputReservation { - state: Some(state), - artifact_id: artifact_id.to_owned(), - projection_key: projection_key.to_owned(), - staged, - manifests, - allocation, - created: true, - max_total_bytes: self.limits.max_total_bytes, - }) - } - - pub async fn detach_projection(&self, artifact_id: &str, projection_key: &str) -> bool { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - let removed_allocation = { - let Some(entry) = state.entries.get_mut(artifact_id) else { - return false; - }; - let Some(ids) = entry.projections.remove(projection_key) else { - return false; - }; - let allocation = ids - .into_iter() - .filter_map(|id| entry.outputs.remove(&id)) - .map(|output| output.allocation_bytes) - .sum::(); - entry.size_bytes = entry.size_bytes.saturating_sub(allocation); - allocation - }; - state.total_bytes = state.total_bytes.saturating_sub(removed_allocation); - true - } - - pub(crate) async fn read_resource_output( - &self, - artifact_id: &str, - output_id: &str, - index: Option, - ) -> Result<(AttachedOutputManifest, Vec), DevupError> { - let mut state = self.state.lock().await; - let now = self.clock.now_epoch_seconds(); - let Some(entry) = state.entries.get(artifact_id) else { - let mut error = resource_missing("artifact"); - merge_details(&mut error, missing_artifact_details(&state, artifact_id)); - return Err(error); - }; - if entry.expires_at <= now { - return Err(DevupError::with_details( - ErrorCode::DevupFigmaHandoffExpired, - "The resource artifact has expired. Re-collect it from the original URL.", - true, - json!({"artifactState":"expired","recoveryArguments":recovery_arguments(entry)}), - )); - } - state.access_sequence = state.access_sequence.saturating_add(1); - let access = state.access_sequence; - let entry = state - .entries - .get_mut(artifact_id) - .ok_or_else(|| resource_missing("artifact"))?; - let remaining_ttl_seconds = entry.expires_at.saturating_sub(now); - let Some(output) = entry.outputs.get(output_id) else { - let mut error = resource_missing("output"); - merge_details( - &mut error, - json!({"artifactState":"live","remainingTtlSeconds":remaining_ttl_seconds}), - ); - return Err(error); - }; - let bytes = if let Some(index) = index { - let Some((start, end)) = output.ranges.get(index).copied() else { - let mut error = resource_missing("chunk"); - merge_details( - &mut error, - json!({"artifactState":"live","chunkIndex":index, - "chunkCount":output.ranges.len(),"manifestUri":output.manifest.manifest_uri}), - ); - return Err(error); - }; - output.bytes[start..end].to_vec() - } else { - Vec::new() - }; - let manifest = output.manifest.clone(); - entry.last_access = access; - Ok((manifest, bytes)) - } - - pub async fn read_output_chunk( - &self, - artifact_id: &str, - output_id: &str, - index: usize, - ) -> Option> { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - state.access_sequence = state.access_sequence.saturating_add(1); - let access = state.access_sequence; - let entry = state.entries.get_mut(artifact_id)?; - entry.last_access = access; - let output = entry.outputs.get(output_id)?; - let (start, end) = *output.ranges.get(index)?; - Some(output.bytes[start..end].to_vec()) - } - - pub async fn output_manifest( - &self, - artifact_id: &str, - output_id: &str, - ) -> Option { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - state.access_sequence = state.access_sequence.saturating_add(1); - let access = state.access_sequence; - let entry = state.entries.get_mut(artifact_id)?; - entry.last_access = access; - entry - .outputs - .get(output_id) - .map(|output| output.manifest.clone()) - } - - pub async fn output_manifests(&self) -> Vec { - let now = self.clock.now_epoch_seconds(); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - state - .entries - .values() - .flat_map(|entry| entry.outputs.values()) - .map(|output| output.manifest.clone()) - .collect() - } - - async fn insert_with_digest( - &self, - request_key: ArtifactRequestKey, - key_digest: String, - mut capabilities: ArtifactCapabilities, - payload: CollectedPayload, - ) -> Result { - if request_key.web_ready_images { - capabilities.asset_captures = payload - .assets - .iter() - .filter_map(|a| { - Some(AssetSelection { - usage_aspect_ratio: a.usage_aspect_ratio, - max_width: a.max_width, - srcset: a.srcset, - visible_scope_node_id: a.visible_scope_node_id.clone(), - asset_id: a.asset_id.clone(), - format: a.format?, - scale: a.scale?, - render_mode: a.render_mode, - quality: a.quality, - lossless: a.lossless, - }) - }) - .collect(); - capabilities.asset_capture_count = capabilities.asset_captures.len(); - } - if payload.metadata.get("sectionIndex").is_some() - && payload.metadata.get("selectedRootIds").is_none() - { - capabilities.kind = ArtifactKind::SectionIndex; - } - let bytes = serde_json::to_vec(&payload).map_err(|error| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - format!("Cannot serialize the Figma artifact: {error}"), - false, - ) - })?; - let selection_bytes = capabilities - .section_selection - .as_ref() - .map(serde_json::to_vec) - .transpose() - .map_err(|error| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - error.to_string(), - false, - ) - })? - .map_or(0, |bytes| bytes.len()); - let size_bytes = bytes.len().saturating_add(selection_bytes); - if size_bytes > self.limits.max_entry_bytes - || size_bytes > self.limits.max_total_bytes - || self.limits.max_entries == 0 - { - return Err(DevupError::with_details( - ErrorCode::DevupFigmaResponseTooLarge, - "The Figma artifact exceeded the memory cache limit.", - false, - json!({"artifactBytes": bytes.len()}), - )); - } - let now = self.clock.now_epoch_seconds(); - let content_hash = sha256_hex(&bytes); - let payload = Arc::new(payload); - let mut state = self.state.lock().await; - self.prune_expired(&mut state, now); - if let Some(previous_id) = state.key_index.remove(&key_digest) { - remove_entry(&mut state, &previous_id); - } - while state.entries.len() >= self.limits.max_entries - || state.total_bytes.saturating_add(size_bytes) > self.limits.max_total_bytes - { - let Some(lru_id) = state - .entries - .iter() - .min_by_key(|(_, entry)| entry.last_access) - .map(|(id, _)| id.clone()) - else { - break; - }; - remove_entry(&mut state, &lru_id); - } - state.access_sequence = state.access_sequence.saturating_add(1); - let last_access = state.access_sequence; - let artifact_id = unique_id(&state.entries); - let expires_at = now.saturating_add(self.limits.ttl.as_secs()); - state.total_bytes = state.total_bytes.saturating_add(size_bytes); - state - .key_index - .insert(key_digest.clone(), artifact_id.clone()); - state.entries.insert( - artifact_id.clone(), - Entry { - key_digest, - request_key, - selection_bytes, - content_hash: content_hash.clone(), - created_at: now, - expires_at, - size_bytes, - last_access, - capabilities: capabilities.clone(), - payload: payload.clone(), - outputs: BTreeMap::new(), - projections: BTreeMap::new(), - }, - ); - Ok(ArtifactLookup { - artifact_id, - content_hash, - created_at_epoch_seconds: now, - expires_at_epoch_seconds: expires_at, - age_seconds: 0, - remaining_ttl_seconds: self.limits.ttl.as_secs(), - size_bytes, - cache_hit: false, - reuse_kind: CacheReuseKind::Miss, - capabilities, - payload, - }) - } - - fn prune_expired(&self, state: &mut StoreState, now: u64) { - let ids = state - .entries - .iter() - .filter_map(|(id, entry)| (entry.expires_at <= now).then_some(id.clone())) - .collect::>(); - for id in ids { - remove_entry(state, &id); - if let Some(recovery) = state.recovery.get_mut(&id) { - recovery["artifactState"] = json!("expired"); - } - } - } -} - -fn lookup_by_key( - state: &mut StoreState, - key_digest: &str, - now: u64, - cache_hit: bool, -) -> Option { - let artifact_id = state.key_index.get(key_digest)?.clone(); - let reuse_kind = if cache_hit { - CacheReuseKind::Exact - } else { - CacheReuseKind::Miss - }; - touch_entry(state, &artifact_id, now, reuse_kind) -} - -fn touch_entry( - state: &mut StoreState, - artifact_id: &str, - now: u64, - reuse_kind: CacheReuseKind, -) -> Option { - state.access_sequence = state.access_sequence.saturating_add(1); - let last_access = state.access_sequence; - let entry = state.entries.get_mut(artifact_id)?; - entry.last_access = last_access; - Some(ArtifactLookup { - artifact_id: artifact_id.to_owned(), - content_hash: entry.content_hash.clone(), - created_at_epoch_seconds: entry.created_at, - expires_at_epoch_seconds: entry.expires_at, - age_seconds: now.saturating_sub(entry.created_at), - remaining_ttl_seconds: entry.expires_at.saturating_sub(now), - size_bytes: entry.size_bytes, - cache_hit: reuse_kind != CacheReuseKind::Miss, - reuse_kind, - capabilities: entry.capabilities.clone(), - payload: entry.payload.clone(), - }) -} - -/// Canonical URL and the last selection: enough to reacquire the same capture, -/// with no payload, credential or original private URL label retained. -fn recovery_arguments(entry: &Entry) -> serde_json::Value { - let key = &entry.request_key; - let url = devup_mcp_figma::FigmaTarget { - file_key: key.file_key.clone(), - node_id: None, - branch_key: key.branch_key.clone(), - } - .link(key.node_id.as_deref()); - if let Some(ids) = &key.measurement_node_ids { - return json!({"url":url,"nodeIds":ids}); - } - let mut args = json!({"url":url,"refresh":true}); - if let Some(selection) = &entry.capabilities.section_selection { - args["frameIds"] = json!(selection.frame_ids); - args["allScreens"] = json!(selection.all_screens); - } - args -} - -/// What this process can say about an id it does not hold: its tombstone if -/// it once held it, and otherwise whether the id was even issued here. -fn missing_artifact_details(state: &StoreState, artifact_id: &str) -> serde_json::Value { - if let Some(tombstone) = state.recovery.get(artifact_id) { - return tombstone.clone(); - } - if issued_by_another_process(artifact_id) { - json!({"artifactState":"issued-by-another-process"}) - } else { - json!({"artifactState":"unknown"}) - } -} - -fn merge_details(error: &mut DevupError, extra: serde_json::Value) { - if let (Some(details), serde_json::Value::Object(extra)) = - (error.details.as_object_mut(), extra) - { - details.extend(extra); - } -} - -fn remove_entry(state: &mut StoreState, artifact_id: &str) { - if let Some(entry) = state.entries.remove(artifact_id) { - let args = recovery_arguments(&entry); - if state.recovery.len() >= 64 { - let oldest = state - .recovery - .iter() - .min_by_key(|(_, v)| v["createdAt"].as_u64().unwrap_or(0)) - .map(|(id, _)| id.clone()); - if let Some(id) = oldest { - state.recovery.remove(&id); - } - } - state.recovery.insert(artifact_id.into(), json!({"artifactState":"evicted","recoveryArguments":args,"createdAt":entry.created_at})); - state.total_bytes = state.total_bytes.saturating_sub(entry.size_bytes); - if state.key_index.get(&entry.key_digest) == Some(&artifact_id.to_owned()) { - state.key_index.remove(&entry.key_digest); - } - } -} - -/// How many leading bytes of every artifactId name the process that issued it. -/// -/// Artifacts live only in the memory of the process that collected them, so an -/// id this store does not hold is either one it forgot or one another process -/// issued: the server that ran before a restart, or a second devup-mcp session. -/// Only the first is this process's to explain, and the stamp tells them apart -/// without writing anything to disk. The id keeps its 32-byte, 43-character -/// shape, so resource URIs do not change. -const PROCESS_TAG_BYTES: usize = 6; - -fn process_tag() -> &'static [u8; PROCESS_TAG_BYTES] { - static TAG: OnceLock<[u8; PROCESS_TAG_BYTES]> = OnceLock::new(); - TAG.get_or_init(|| { - let mut tag = [0_u8; PROCESS_TAG_BYTES]; - rand::rng().fill_bytes(&mut tag); - tag - }) -} - -/// True only for a well-formed id stamped by a different process. A malformed -/// id is not claimed to come from anywhere. -pub(crate) fn issued_by_another_process(artifact_id: &str) -> bool { - URL_SAFE_NO_PAD - .decode(artifact_id) - .ok() - .filter(|bytes| bytes.len() == 32) - .is_some_and(|bytes| bytes[..PROCESS_TAG_BYTES] != process_tag()[..]) -} - -fn unique_id(entries: &BTreeMap) -> String { - loop { - let mut bytes = [0_u8; 32]; - rand::rng().fill_bytes(&mut bytes); - bytes[..PROCESS_TAG_BYTES].copy_from_slice(process_tag()); - let id = URL_SAFE_NO_PAD.encode(bytes); - if !entries.contains_key(&id) { - return id; - } - } -} - -fn valid_output_id(value: &str) -> bool { - value.len() == 22 - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) -} - -fn output_chunk_ranges(bytes: &[u8], is_binary: bool) -> Result, DevupError> { - let text = (!is_binary) - .then(|| std::str::from_utf8(bytes)) - .transpose() - .map_err(|_| { - DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "A text resource output must be UTF-8.", - false, - ) - })?; - if bytes.is_empty() { - return Ok(vec![(0, 0)]); - } - let mut ranges = Vec::new(); - let mut start = 0; - while start < bytes.len() { - let mut end = start.saturating_add(RESOURCE_CHUNK_BYTES).min(bytes.len()); - if let Some(text) = text { - while end > start && !text.is_char_boundary(end) { - end -= 1; - } - if end == start { - return Err(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "Cannot compute the text resource chunk boundary.", - false, - )); - } - } - ranges.push((start, end)); - start = end; - } - Ok(ranges) -} - -fn sha256_hex(bytes: &[u8]) -> String { - Sha256::digest(bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - -fn acquisition_cancelled() -> DevupError { - DevupError::new( - ErrorCode::DevupFigmaDirectUnavailable, - "Collection of the same Figma artifact was cancelled before it completed.", - true, - ) -} - -fn resource_missing(kind: &str) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - format!("The resource {kind} was not found. It may have been removed from the cache."), - false, - json!({"reason": "not_found", "resourceKind": kind}), - ) -} - -fn resource_expired() -> DevupError { - DevupError::new( - ErrorCode::DevupFigmaHandoffExpired, - "The resource artifact is missing or expired.", - true, - ) -} - -#[cfg(test)] -pub(crate) mod w3_tests { - use super::*; - use devup_mcp_figma::{ - AssetFormat, CollectionStats, FigmaTarget, PayloadCompleteness, Snapshot, - }; - use std::sync::atomic::{AtomicU64, Ordering}; - - #[derive(Default)] - pub(crate) struct Clock(pub AtomicU64); - impl ArtifactClock for Clock { - fn now_epoch_seconds(&self) -> u64 { - self.0.load(Ordering::SeqCst) - } - } - pub(crate) fn request() -> CollectionRequest { - CollectionRequest::new( - FigmaTarget { - file_key: "fixture".into(), - node_id: Some("1:1".into()), - branch_key: None, - }, - CollectionScope::Node, - ) - } - pub(crate) fn payload() -> CollectedPayload { - CollectedPayload { - target: request().target, - scope: CollectionScope::Node, - metadata: json!({}), - snapshot: Snapshot { - file_key: "fixture".into(), - version: None, - roots: vec!["1:1".into()], - nodes: BTreeMap::new(), - diagnostics: vec![], - }, - variables: None, - styles: None, - completeness: PayloadCompleteness::ResolvedValuesOnly, - source_version: None, - stats: CollectionStats::default(), - assets: vec![], - reference_png: None, - failures: vec![], - } - } - - /// Settles the question W3 left open about `ArtifactRequestKey::digest`. - /// - /// It ends in `unwrap_or_default()`, so a serialisation failure would not - /// raise anything — it would hand back the digest of the *empty* byte - /// string, and every key that failed would land on that one entry. Two - /// unrelated collections would then read each other's design. - /// - /// It cannot fail today. The key is `String`, `Option`, `bool`, - /// `usize`, `u8`, three fieldless enums and `Vec`s of those, every one of - /// them `#[derive(Serialize)]`; `serde_json` fails on a non-string map - /// key, a hand-written `Serialize` that returns an error, or a writer that - /// errors, and the writer here is a `Vec`. There is no float, so not - /// even a non-finite one to argue about. So the fallback stays. - /// - /// What it stays subject to is a field added later that *can* fail — at - /// which point the collapse is silent. This fills every field and asserts - /// each one still reaches a digest of its own, none of them the empty one. - #[test] - fn w3_every_field_of_a_request_key_reaches_its_digest() { - let populated = || { - let mut request = request(); - request.resource_scope = ResourceScope::Used; - request.include_context = true; - request.metadata_only = true; - request.variables_only = true; - request.reference_png = true; - request.search = Some(SearchReadOptions { - query: "STORY-F-PROOFREAD".into(), - node_types: vec!["FRAME".into()], - match_kind: "normalized".into(), - limit: 20, - }); - request.explore = Some(ExploreReadOptions { - projection_limit: 50, - text_preview_limit: 10, - }); - request.section = Some(SectionReadOptions { - frame_ids: vec!["2:2".into()], - all_screens: false, - }); - request.asset_selections = vec![AssetSelection { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - render_mode: devup_mcp_figma::AssetRenderMode::Node, - quality: None, - lossless: None, - asset_id: "asset".into(), - format: AssetFormat::Png, - scale: 2.0, - }]; - request - }; - - let mut branch_key = populated(); - branch_key.target.branch_key = Some("branch".into()); - let mut file_key = populated(); - file_key.target.file_key = "other".into(); - let mut node_id = populated(); - node_id.target.node_id = Some("9:9".into()); - let mut scope = populated(); - scope.scope = CollectionScope::File; - let mut resource_scope = populated(); - resource_scope.resource_scope = ResourceScope::File; - let mut include_context = populated(); - include_context.include_context = false; - let mut metadata_only = populated(); - metadata_only.metadata_only = false; - let mut variables_only = populated(); - variables_only.variables_only = false; - let mut reference_png = populated(); - reference_png.reference_png = false; - let mut search = populated(); - search.search = None; - let mut explore = populated(); - explore.explore = None; - let mut section = populated(); - section.section = None; - let mut asset_format = populated(); - asset_format.asset_selections[0].format = AssetFormat::Svg; - let mut asset_scale = populated(); - asset_scale.asset_selections[0].scale = 3.0; - let mut asset_mode = populated(); - asset_mode.asset_selections[0].render_mode = devup_mcp_figma::AssetRenderMode::Fill; - let mut asset_auto_mode = populated(); - asset_auto_mode.asset_selections[0].render_mode = devup_mcp_figma::AssetRenderMode::Auto; - let mut asset_id = populated(); - asset_id.asset_selections[0].asset_id = "elsewhere".into(); - let mut quality = populated(); - quality.asset_selections[0].quality = Some(43); - let mut lossless = populated(); - lossless.asset_selections[0].lossless = Some(true); - let mut preset = populated(); - preset.web_ready_images = true; - - let mut max_width = populated(); - max_width.asset_selections[0].max_width = Some(240); - let mut srcset = populated(); - srcset.asset_selections[0].srcset = true; - let mut visible = populated(); - visible.asset_selections[0].render_mode = devup_mcp_figma::AssetRenderMode::Visible; - visible.asset_selections[0].visible_scope_node_id = Some("1:1".into()); - let mut original = populated(); - original.asset_selections[0].render_mode = devup_mcp_figma::AssetRenderMode::OriginalSource; - let mut fractional = populated(); - fractional.asset_selections[0].scale = 1.5; - let mut usage = populated(); - usage.asset_selections[0].usage_aspect_ratio = Some(1.25); - let empty = sha256_hex(&[]); - let digests = [ - max_width, - srcset, - visible, - original, - fractional, - usage, - quality, - lossless, - preset, - asset_auto_mode, - populated(), - branch_key, - file_key, - node_id, - scope, - resource_scope, - include_context, - metadata_only, - variables_only, - reference_png, - search, - explore, - section, - asset_format, - asset_scale, - asset_mode, - asset_id, - ] - .iter() - .map(|request| ArtifactRequestKey::from_collection(request).digest()) - .collect::>(); - - for digest in &digests { - assert_ne!( - digest, &empty, - "a key that serialised to nothing would share one cache entry with every other such key" - ); - } - let distinct = digests.iter().collect::>(); - assert_eq!( - distinct.len(), - digests.len(), - "each field has to reach the digest, or two different collections share an artifact" - ); - } - - #[tokio::test] - async fn w3_selection_is_available_after_artifact_id_lookup() { - let store = ArtifactStore::default(); - let mut request = request(); - request.section = Some(SectionReadOptions { - frame_ids: vec!["2:2".into(), "3:3".into()], - all_screens: false, - }); - let artifact = store - .insert(ArtifactRequestKey::from_collection(&request), payload()) - .await - .unwrap(); - let restored = store.get(&artifact.artifact_id).await.unwrap(); - let metadata = serde_json::to_value(&restored.capabilities).unwrap(); - assert_eq!( - metadata["sectionSelection"]["frameIds"], - json!(["2:2", "3:3"]) - ); - } - - #[tokio::test] - async fn w3_last_selection_survives_lookup_without_changing_acquisition_key() { - let store = ArtifactStore::default(); - let mut request = request(); - request.section = Some(SectionReadOptions { - frame_ids: vec![], - all_screens: false, - }); - let key = ArtifactRequestKey::from_collection(&request); - let artifact = store.insert(key.clone(), payload()).await.unwrap(); - store - .remember_section_selection( - &artifact.artifact_id, - SectionReadOptions { - frame_ids: vec!["3:3".into()], - all_screens: false, - }, - ) - .await - .unwrap(); - let restored = store.lookup(&key).await.unwrap(); - assert_eq!(restored.artifact_id, artifact.artifact_id); - assert_eq!( - serde_json::to_value(&restored.capabilities).unwrap()["sectionSelection"]["frameIds"], - json!(["3:3"]) - ); - store - .remember_section_selection( - &artifact.artifact_id, - SectionReadOptions { - frame_ids: vec![], - all_screens: true, - }, - ) - .await - .unwrap(); - let restored = store.get(&artifact.artifact_id).await.unwrap(); - assert_eq!( - serde_json::to_value(&restored.capabilities).unwrap()["sectionSelection"]["allScreens"], - true - ); - } - - #[tokio::test] - async fn w3_unselected_section_index_remembers_its_first_selection() { - let store = ArtifactStore::default(); - let mut payload = payload(); - payload.metadata = json!({"sectionIndex": {}}); - let artifact = store - .insert(ArtifactRequestKey::from_collection(&request()), payload) - .await - .unwrap(); - assert_eq!(artifact.capabilities.kind, ArtifactKind::SectionIndex); - let selection = SectionReadOptions { - frame_ids: vec!["2:2".into()], - all_screens: false, - }; - store - .remember_section_selection(&artifact.artifact_id, selection.clone()) - .await - .unwrap(); - assert_eq!( - store - .get(&artifact.artifact_id) - .await - .unwrap() - .capabilities - .section_selection, - Some(selection) - ); - } - - #[tokio::test] - async fn r8_expired_branch_url_preserves_target_and_selection() { - let clock = Arc::new(Clock::default()); - let store = ArtifactStore::with_clock( - clock.clone(), - ArtifactLimits { - ttl: Duration::from_secs(1), - ..Default::default() - }, - ); - let mut request = request(); - request.target.branch_key = Some("Branch123".into()); - request.section = Some(SectionReadOptions { - frame_ids: vec!["2:2".into()], - all_screens: false, - }); - let artifact = store - .insert(ArtifactRequestKey::from_collection(&request), payload()) - .await - .unwrap(); - clock.0.store(1, Ordering::SeqCst); - let error = store - .get_for_export(&artifact.artifact_id) - .await - .unwrap_err(); - let arguments = &error.details["recoveryArguments"]; - let recovered = - devup_mcp_figma::FigmaTarget::parse(arguments["url"].as_str().unwrap()).unwrap(); - assert_eq!(recovered, request.target); - assert_eq!(arguments["frameIds"], json!(["2:2"])); - } - - #[tokio::test] - async fn w3_expired_key_is_reacquired_at_ttl_boundary() { - let clock = Arc::new(Clock::default()); - let store = ArtifactStore::with_clock( - clock.clone(), - ArtifactLimits { - ttl: Duration::from_secs(10), - ..ArtifactLimits::default() - }, - ); - let key = ArtifactRequestKey::from_collection(&request()); - let first = store.insert(key.clone(), payload()).await.unwrap(); - clock.0.store(10, Ordering::SeqCst); - assert!(store.lookup(&key).await.is_none()); - let expired = store.get_for_export(&first.artifact_id).await.unwrap_err(); - assert_eq!(expired.details["artifactState"], "expired"); - assert!( - expired.details["recoveryArguments"]["url"] - .as_str() - .unwrap() - .contains("figma.com/design/") - ); - let unknown = ArtifactStore::default() - .get_for_export(&first.artifact_id) - .await - .unwrap_err(); - assert_eq!(unknown.details["artifactState"], "unknown"); - assert!(unknown.details.get("recoveryArguments").is_none()); - let second = store - .get_or_acquire(key, false, || async { Ok(payload()) }) - .await - .unwrap(); - assert!(!second.cache_hit); - assert_ne!(first.artifact_id, second.artifact_id); - } - - #[tokio::test] - async fn w3_invalid_or_oversized_selection_does_not_replace_previous_selection() { - let store = ArtifactStore::with_limits(ArtifactLimits { - max_entry_bytes: 2048, - ..ArtifactLimits::default() - }); - let mut request = request(); - request.section = Some(SectionReadOptions { - frame_ids: vec!["2:2".into()], - all_screens: false, - }); - let artifact = store - .insert(ArtifactRequestKey::from_collection(&request), payload()) - .await - .unwrap(); - let original_bytes = store.stats().await.total_bytes; - for selection in [ - SectionReadOptions { - frame_ids: vec![], - all_screens: false, - }, - SectionReadOptions { - frame_ids: vec!["3:3".into()], - all_screens: true, - }, - SectionReadOptions { - frame_ids: vec!["3:3".into(), "3:3".into()], - all_screens: false, - }, - SectionReadOptions { - frame_ids: vec!["x".repeat(4096)], - all_screens: false, - }, - ] { - assert!( - store - .remember_section_selection(&artifact.artifact_id, selection) - .await - .is_err() - ); - assert_eq!( - store - .get(&artifact.artifact_id) - .await - .unwrap() - .capabilities - .section_selection, - request.section - ); - assert_eq!(store.stats().await.total_bytes, original_bytes); - } - let updated = store - .remember_section_selection( - &artifact.artifact_id, - SectionReadOptions { - frame_ids: vec![], - all_screens: true, - }, - ) - .await - .unwrap(); - assert_eq!(updated.size_bytes, store.stats().await.total_bytes); - assert_eq!(updated.content_hash, artifact.content_hash); - assert_eq!( - updated.expires_at_epoch_seconds, - artifact.expires_at_epoch_seconds - ); - } - - #[test] - fn w3_asset_capture_validation_reports_only_missing_exact_variants() { - let mut request = request(); - let captured = AssetSelection { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - render_mode: devup_mcp_figma::AssetRenderMode::Node, - quality: None, - lossless: None, - asset_id: "2:2:node".into(), - format: AssetFormat::Svg, - scale: 1.0, - }; - request.asset_selections = vec![captured.clone()]; - let capabilities = ArtifactRequestKey::from_collection(&request).capabilities(); - assert!(capabilities.validate_asset_captures(&[]).is_ok()); - assert!(!capabilities.supports_asset_captures(&[AssetSelection { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - quality: Some(80), - ..captured.clone() - }])); - assert!(!capabilities.supports_asset_captures(&[AssetSelection { - lossless: Some(true), - ..captured.clone() - }])); - assert!( - capabilities - .validate_asset_captures(std::slice::from_ref(&captured)) - .is_ok() - ); - let missing = AssetSelection { - scale: 2.0, - ..captured.clone() - }; - let error = capabilities - .validate_asset_captures(&[captured, missing]) - .unwrap_err(); - assert_eq!( - error.details["missingAssetCaptures"], - json!([{"assetId":"2:2:node", "format":"svg", "scale":2.0,"renderMode":"node","srcset":false}]) - ); - assert!(!error.retryable); - } - - #[test] - fn artifact_node_capture_cannot_serve_original_fill_mode() { - let mut request = request(); - let node: AssetSelection = - serde_json::from_value(json!({"assetId":"sample:fills:0","format":"png","scale":1})) - .unwrap(); - request.asset_selections = vec![node.clone()]; - let capabilities = ArtifactRequestKey::from_collection(&request).capabilities(); - let fill = AssetSelection { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - render_mode: devup_mcp_figma::AssetRenderMode::Fill, - quality: None, - lossless: None, - ..node - }; - assert!(!capabilities.supports_asset_captures(std::slice::from_ref(&fill))); - let error = capabilities.validate_asset_captures(&[fill]).unwrap_err(); - assert_eq!( - error.details["missingAssetCaptures"][0]["renderMode"], - "fill" - ); - } - - #[test] - fn w3_missing_asset_capture_has_actionable_details() { - let capabilities = ArtifactRequestKey::from_collection(&request()).capabilities(); - let selection = AssetSelection { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - render_mode: devup_mcp_figma::AssetRenderMode::Node, - quality: None, - lossless: None, - asset_id: "2:2:node".into(), - format: AssetFormat::Svg, - scale: 1.0, - }; - // The storage-layer validator will be consumed by W1's projection validation. - let error = capabilities - .validate_asset_captures(&[selection]) - .unwrap_err(); - assert!( - error.message.contains("Remove artifactId") - && error.message.contains("original url") - && error.message.contains("assetRequests") - ); - assert_eq!( - error.details["missingAssetCaptures"][0]["assetId"], - "2:2:node" - ); - } - - #[tokio::test] - async fn p2_an_id_from_another_process_is_named_as_such_not_as_unknown() { - let store = ArtifactStore::default(); - let ours = store - .insert(ArtifactRequestKey::from_collection(&request()), payload()) - .await - .unwrap(); - assert!(!issued_by_another_process(&ours.artifact_id)); - assert_eq!(ours.artifact_id.len(), 43); - - let mut bytes = URL_SAFE_NO_PAD.decode(&ours.artifact_id).unwrap(); - bytes[0] ^= 0xff; - let foreign = URL_SAFE_NO_PAD.encode(bytes); - let error = store.get_for_export(&foreign).await.unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaHandoffExpired); - assert_eq!(error.details["artifactState"], "issued-by-another-process"); - // A different store in this same process is still this process. - let unknown = ArtifactStore::default() - .get_for_export(&ours.artifact_id) - .await - .unwrap_err(); - assert_eq!(unknown.details["artifactState"], "unknown"); - assert!(!issued_by_another_process("not-an-artifact-id")); - } - - #[tokio::test] - async fn p2_resource_misses_say_whether_the_artifact_is_still_alive() { - let store = ArtifactStore::default(); - let artifact = store - .insert(ArtifactRequestKey::from_collection(&request()), payload()) - .await - .unwrap(); - let manifests = store - .attach_outputs( - &artifact.artifact_id, - "projection", - vec![super::super::delivery::ProjectedOutput::text( - "tsx", - "text/plain", - b"hello".to_vec(), - )], - ) - .await - .unwrap(); - let output = store - .read_resource_output(&artifact.artifact_id, &"z".repeat(22), None) - .await - .unwrap_err(); - assert_eq!(output.details["resourceKind"], "output"); - assert_eq!(output.details["artifactState"], "live"); - let chunk = store - .read_resource_output(&artifact.artifact_id, &manifests[0].output_id, Some(7)) - .await - .unwrap_err(); - assert_eq!(chunk.details["resourceKind"], "chunk"); - assert_eq!(chunk.details["chunkCount"], 1); - assert_eq!(chunk.details["manifestUri"], manifests[0].manifest_uri); - } -} +// Source sections share this module to preserve privacy and test names. +include!("artifacts/store_and_lookup.rs"); +include!("artifacts/impl.rs"); +include!("artifacts/w3_tests.rs"); diff --git a/crates/devup-mcp/src/server/artifacts/impl.rs b/crates/devup-mcp/src/server/artifacts/impl.rs new file mode 100644 index 00000000..d515f163 --- /dev/null +++ b/crates/devup-mcp/src/server/artifacts/impl.rs @@ -0,0 +1,975 @@ + + +impl ArtifactStore { + pub fn with_limits(limits: ArtifactLimits) -> Self { + Self::with_clock(Arc::new(SystemClock), limits) + } + + pub fn with_clock(clock: Arc, limits: ArtifactLimits) -> Self { + Self { + state: Arc::new(Mutex::new(StoreState::default())), + clock, + limits, + } + } + + pub async fn get_or_acquire( + &self, + key: ArtifactRequestKey, + refresh: bool, + acquire: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let key_digest = key.digest(); + let capabilities = key.capabilities(); + if refresh { + let payload = acquire().await?; + return self + .insert_with_digest(key, key_digest, capabilities, payload) + .await; + } + let mut acquire = Some(acquire); + loop { + let (owner, mut receiver, sender, wait_digest, wait_kind) = { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + if let Some(hit) = lookup_by_key(&mut state, &key_digest, now, true) { + return Ok(hit); + } + if let Some(receiver) = state.in_flight.get(&key_digest) { + ( + false, + receiver.clone(), + None, + key_digest.clone(), + InFlightWait::Exact, + ) + } else if let Some((compatible_digest, receiver, reuse_kind)) = state + .in_flight_keys + .iter() + // The receiver is taken here, from the same read of the + // same lock that picked the key, rather than looked up + // again below. `in_flight` and `in_flight_keys` are meant + // to hold the same digests — they are written together and + // erased together — but that is an agreement between three + // separate sites, one of which only runs when a waiter + // notices a cancelled owner. Reading the key without its + // receiver leaves somewhere for the two to disagree, and + // what a disagreement would have cost is the process: this + // is the shared store every collection goes through. A + // candidate that has no receiver simply is not a candidate. + .filter_map(|(digest, in_flight_key)| { + let receiver = state.in_flight.get(digest)?; + in_flight_key.explore_reuse_kind(&key).map(|reuse_kind| { + ( + digest.clone(), + receiver.clone(), + reuse_kind, + in_flight_key.explore_projection_limit(), + ) + }) + }) + .min_by_key(|(_, _, _, projection_limit)| *projection_limit) + .map(|(digest, receiver, reuse_kind, _)| (digest, receiver, reuse_kind)) + { + ( + false, + receiver, + None, + compatible_digest, + InFlightWait::Compatible(reuse_kind), + ) + } else { + let (sender, receiver) = watch::channel(None); + state.in_flight.insert(key_digest.clone(), receiver.clone()); + state.in_flight_keys.insert(key_digest.clone(), key.clone()); + ( + true, + receiver, + Some(sender), + key_digest.clone(), + InFlightWait::Exact, + ) + } + }; + + if !owner { + if receiver.wait_for(Option::is_some).await.is_err() { + let mut state = self.state.lock().await; + if state + .in_flight + .get(&wait_digest) + .is_some_and(|current| current.same_channel(&receiver)) + { + state.in_flight.remove(&wait_digest); + state.in_flight_keys.remove(&wait_digest); + } + continue; + } + let mut result = receiver + .borrow() + .clone() + .ok_or_else(acquisition_cancelled)?; + match (wait_kind, &mut result) { + (InFlightWait::Exact, Ok(hit)) => { + hit.cache_hit = true; + hit.reuse_kind = CacheReuseKind::Exact; + } + (InFlightWait::Compatible(reuse_kind), Ok(hit)) + if key.node_id.as_ref().is_none_or(|node_id| { + hit.payload.snapshot.nodes.contains_key(node_id) + }) => + { + let now = self.clock.now_epoch_seconds(); + hit.cache_hit = true; + hit.reuse_kind = reuse_kind; + hit.age_seconds = now.saturating_sub(hit.created_at_epoch_seconds); + hit.remaining_ttl_seconds = + hit.expires_at_epoch_seconds.saturating_sub(now); + } + (InFlightWait::Exact, Err(_)) => {} + (InFlightWait::Compatible(_), _) => continue, + } + return result; + } + + let acquire = acquire.take().ok_or_else(acquisition_cancelled)?; + let result = match acquire().await { + Ok(payload) => { + self.insert_with_digest( + key.clone(), + key_digest.clone(), + capabilities.clone(), + payload, + ) + .await + } + Err(error) => Err(error), + }; + { + let mut state = self.state.lock().await; + state.in_flight.remove(&key_digest); + state.in_flight_keys.remove(&key_digest); + } + if let Some(sender) = sender { + let _ = sender.send(Some(result.clone())); + } + return result; + } + } + + pub async fn insert( + &self, + key: ArtifactRequestKey, + payload: CollectedPayload, + ) -> Result { + self.insert_with_digest(key.clone(), key.digest(), key.capabilities(), payload) + .await + } + + /// Remember a selection after the caller has validated it against the Section candidates. + /// This updates projection state only: the original acquisition key and capture capabilities stay intact. + pub async fn remember_section_selection( + &self, + artifact_id: &str, + selection: SectionReadOptions, + ) -> Result { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + let entry = state + .entries + .get(artifact_id) + .ok_or_else(resource_expired)?; + if (entry.capabilities.section_selection.is_none() + && entry.capabilities.kind != ArtifactKind::SectionIndex) + || (selection.all_screens && !selection.frame_ids.is_empty()) + || (!selection.all_screens && selection.frame_ids.is_empty()) + || selection + .frame_ids + .iter() + .collect::>() + .len() + != selection.frame_ids.len() + { + return Err(DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + "A Section selection must contain distinct frameIds or allScreens:true.", + false, + )); + } + let selection_bytes = serde_json::to_vec(&selection) + .map_err(|error| { + DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + error.to_string(), + false, + ) + })? + .len(); + let previous_bytes = entry.selection_bytes; + let size_bytes = entry + .size_bytes + .saturating_sub(previous_bytes) + .saturating_add(selection_bytes); + let total_bytes = state + .total_bytes + .saturating_sub(previous_bytes) + .saturating_add(selection_bytes); + if size_bytes > self.limits.max_entry_bytes || total_bytes > self.limits.max_total_bytes { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "The Section selection exceeded the artifact memory limit.", + false, + )); + } + let entry = state + .entries + .get_mut(artifact_id) + .ok_or_else(resource_expired)?; + entry.capabilities.section_selection = Some(selection); + entry.selection_bytes = selection_bytes; + entry.size_bytes = size_bytes; + state.total_bytes = total_bytes; + touch_entry(&mut state, artifact_id, now, CacheReuseKind::Exact) + .ok_or_else(resource_expired) + } + + pub async fn get_for_export(&self, artifact_id: &str) -> Result { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + touch_entry(&mut state, artifact_id, now, CacheReuseKind::Exact).ok_or_else(|| { + DevupError::with_details( + ErrorCode::DevupFigmaHandoffExpired, + "The Figma artifact is missing or expired.", + true, + missing_artifact_details(&state, artifact_id), + ) + }).and_then(|artifact| { + if artifact.capabilities.kind == ArtifactKind::Measurement { + Err(DevupError::with_details(ErrorCode::DevupFigmaHandoffInvalid,"A measurement-only artifact cannot supply export outputs; acquire a design export by explicit URL.",false,json!({"capabilities":artifact.capabilities,"artifactId":artifact.artifact_id}))) + } else {Ok(artifact)} + }) + } + + pub async fn get(&self, artifact_id: &str) -> Option { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + touch_entry(&mut state, artifact_id, now, CacheReuseKind::Exact) + } + + pub async fn lookup(&self, key: &ArtifactRequestKey) -> Option { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + lookup_by_key(&mut state, &key.digest(), now, true) + } + + pub async fn lookup_related_explore(&self, key: &ArtifactRequestKey) -> Option { + let requested_node_id = key.node_id.as_deref()?; + key.explore.as_ref()?; + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + let (artifact_id, reuse_kind) = state + .entries + .iter() + .filter_map(|(artifact_id, entry)| { + entry + .request_key + .explore_reuse_kind(key) + .map(|reuse_kind| (artifact_id, entry, reuse_kind)) + }) + .filter(|(_, entry, _)| entry.payload.snapshot.nodes.contains_key(requested_node_id)) + .min_by_key(|(_, entry, _)| { + ( + entry.request_key.explore_projection_limit(), + Reverse(entry.last_access), + ) + }) + .map(|(artifact_id, _, reuse_kind)| (artifact_id.clone(), reuse_kind))?; + touch_entry(&mut state, &artifact_id, now, reuse_kind) + } + + pub async fn stats(&self) -> ArtifactStoreStats { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + ArtifactStoreStats { + entry_count: state.entries.len(), + total_bytes: state.total_bytes, + max_entries: self.limits.max_entries, + max_total_bytes: self.limits.max_total_bytes, + } + } + + pub async fn attach_outputs( + &self, + artifact_id: &str, + projection_key: &str, + outputs: Vec, + ) -> Result, DevupError> { + Ok(self + .reserve_outputs(artifact_id, projection_key, outputs) + .await? + .commit()) + } + + pub async fn reserve_outputs( + &self, + artifact_id: &str, + projection_key: &str, + outputs: Vec, + ) -> Result { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.clone().lock_owned().await; + self.prune_expired(&mut state, now); + if let Some(ids) = state + .entries + .get(artifact_id) + .and_then(|entry| entry.projections.get(projection_key)) + .cloned() + { + let entry = state + .entries + .get(artifact_id) + .ok_or_else(resource_expired)?; + let manifests = ids + .iter() + .map(|id| { + entry + .outputs + .get(id) + .map(|output| output.manifest.clone()) + .ok_or_else(resource_expired) + }) + .collect::, _>>()?; + return Ok(OutputReservation { + state: Some(state), + artifact_id: artifact_id.to_owned(), + projection_key: projection_key.to_owned(), + staged: Vec::new(), + manifests, + allocation: 0, + created: false, + max_total_bytes: self.limits.max_total_bytes, + }); + } + let expires_at = state + .entries + .get(artifact_id) + .map(|entry| entry.expires_at) + .ok_or_else(resource_expired)?; + let mut staged = Vec::with_capacity(outputs.len()); + let mut staged_ids = BTreeMap::new(); + for output in outputs { + if output.name.is_empty() + || !output + .name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + || output.mime_type.is_empty() + { + return Err(DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + "The resource output name or MIME type is invalid.", + false, + )); + } + let output_id = output.resource_id; + if !valid_output_id(&output_id) + || staged_ids.contains_key(&output_id) + || state + .entries + .get(artifact_id) + .is_some_and(|entry| entry.outputs.contains_key(&output_id)) + { + return Err(DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + "The resource output ID is duplicated or invalid.", + true, + )); + } + staged_ids.insert(output_id.clone(), ()); + let raw_bytes = output.bytes.len(); + let ranges = output_chunk_ranges(&output.bytes, output.is_binary)?; + let encoded_bytes = if output.is_binary { + raw_bytes.div_ceil(3).saturating_mul(4) + } else { + 0 + }; + let allocation_bytes = raw_bytes.saturating_add(encoded_bytes); + let manifest_uri = + format!("devup://artifact/{artifact_id}/outputs/{output_id}/manifest"); + staged.push(AttachedOutput { + manifest: AttachedOutputManifest { + asset_metadata: output.asset_metadata, + artifact_id: artifact_id.to_owned(), + output_id: output_id.clone(), + name: output.name, + mime_type: output.mime_type, + raw_bytes, + sha256: sha256_hex(&output.bytes), + chunk_uris: (0..ranges.len()) + .map(|index| { + format!( + "devup://artifact/{artifact_id}/outputs/{output_id}/chunks/{index}" + ) + }) + .collect(), + chunk_count: ranges.len(), + chunk_bytes: RESOURCE_CHUNK_BYTES, + is_binary: output.is_binary, + manifest_uri, + expires_at_epoch_seconds: expires_at, + }, + bytes: Arc::from(output.bytes), + ranges, + allocation_bytes, + }); + } + let allocation = staged + .iter() + .try_fold(0_usize, |sum, output| { + sum.checked_add(output.allocation_bytes) + }) + .ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "The resource allocation size exceeded the safe range.", + false, + ) + })?; + let entry_size = state + .entries + .get(artifact_id) + .map(|entry| entry.size_bytes) + .ok_or_else(resource_expired)?; + if entry_size.saturating_add(allocation) > self.limits.max_entry_bytes + || allocation > self.limits.max_total_bytes + { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "The resource output exceeded the artifact memory limit.", + false, + )); + } + let retained_bytes = state + .entries + .get(artifact_id) + .map(|entry| entry.size_bytes) + .ok_or_else(resource_expired)?; + if retained_bytes.saturating_add(allocation) > self.limits.max_total_bytes { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "The resource output exceeded the total memory limit.", + false, + )); + } + let manifests = staged + .iter() + .map(|output| output.manifest.clone()) + .collect::>(); + Ok(OutputReservation { + state: Some(state), + artifact_id: artifact_id.to_owned(), + projection_key: projection_key.to_owned(), + staged, + manifests, + allocation, + created: true, + max_total_bytes: self.limits.max_total_bytes, + }) + } + + pub async fn detach_projection(&self, artifact_id: &str, projection_key: &str) -> bool { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + let removed_allocation = { + let Some(entry) = state.entries.get_mut(artifact_id) else { + return false; + }; + let Some(ids) = entry.projections.remove(projection_key) else { + return false; + }; + let allocation = ids + .into_iter() + .filter_map(|id| entry.outputs.remove(&id)) + .map(|output| output.allocation_bytes) + .sum::(); + entry.size_bytes = entry.size_bytes.saturating_sub(allocation); + allocation + }; + state.total_bytes = state.total_bytes.saturating_sub(removed_allocation); + true + } + + pub(crate) async fn read_resource_output( + &self, + artifact_id: &str, + output_id: &str, + index: Option, + ) -> Result<(AttachedOutputManifest, Vec), DevupError> { + let mut state = self.state.lock().await; + let now = self.clock.now_epoch_seconds(); + let Some(entry) = state.entries.get(artifact_id) else { + let mut error = resource_missing("artifact"); + merge_details(&mut error, missing_artifact_details(&state, artifact_id)); + return Err(error); + }; + if entry.expires_at <= now { + return Err(DevupError::with_details( + ErrorCode::DevupFigmaHandoffExpired, + "The resource artifact has expired. Re-collect it from the original URL.", + true, + json!({"artifactState":"expired","recoveryArguments":recovery_arguments(entry)}), + )); + } + state.access_sequence = state.access_sequence.saturating_add(1); + let access = state.access_sequence; + let entry = state + .entries + .get_mut(artifact_id) + .ok_or_else(|| resource_missing("artifact"))?; + let remaining_ttl_seconds = entry.expires_at.saturating_sub(now); + let Some(output) = entry.outputs.get(output_id) else { + let mut error = resource_missing("output"); + merge_details( + &mut error, + json!({"artifactState":"live","remainingTtlSeconds":remaining_ttl_seconds}), + ); + return Err(error); + }; + let bytes = if let Some(index) = index { + let Some((start, end)) = output.ranges.get(index).copied() else { + let mut error = resource_missing("chunk"); + merge_details( + &mut error, + json!({"artifactState":"live","chunkIndex":index, + "chunkCount":output.ranges.len(),"manifestUri":output.manifest.manifest_uri}), + ); + return Err(error); + }; + output.bytes[start..end].to_vec() + } else { + Vec::new() + }; + let manifest = output.manifest.clone(); + entry.last_access = access; + Ok((manifest, bytes)) + } + + pub async fn read_output_chunk( + &self, + artifact_id: &str, + output_id: &str, + index: usize, + ) -> Option> { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + state.access_sequence = state.access_sequence.saturating_add(1); + let access = state.access_sequence; + let entry = state.entries.get_mut(artifact_id)?; + entry.last_access = access; + let output = entry.outputs.get(output_id)?; + let (start, end) = *output.ranges.get(index)?; + Some(output.bytes[start..end].to_vec()) + } + + pub async fn output_manifest( + &self, + artifact_id: &str, + output_id: &str, + ) -> Option { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + state.access_sequence = state.access_sequence.saturating_add(1); + let access = state.access_sequence; + let entry = state.entries.get_mut(artifact_id)?; + entry.last_access = access; + entry + .outputs + .get(output_id) + .map(|output| output.manifest.clone()) + } + + pub async fn output_manifests(&self) -> Vec { + let now = self.clock.now_epoch_seconds(); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + state + .entries + .values() + .flat_map(|entry| entry.outputs.values()) + .map(|output| output.manifest.clone()) + .collect() + } + + async fn insert_with_digest( + &self, + request_key: ArtifactRequestKey, + key_digest: String, + mut capabilities: ArtifactCapabilities, + payload: CollectedPayload, + ) -> Result { + if request_key.web_ready_images { + capabilities.asset_captures = payload + .assets + .iter() + .filter_map(|a| { + Some(AssetSelection { + usage_aspect_ratio: a.usage_aspect_ratio, + max_width: a.max_width, + srcset: a.srcset, + visible_scope_node_id: a.visible_scope_node_id.clone(), + asset_id: a.asset_id.clone(), + format: a.format?, + scale: a.scale?, + render_mode: a.render_mode, + quality: a.quality, + lossless: a.lossless, + }) + }) + .collect(); + capabilities.asset_capture_count = capabilities.asset_captures.len(); + } + if payload.metadata.get("sectionIndex").is_some() + && payload.metadata.get("selectedRootIds").is_none() + { + capabilities.kind = ArtifactKind::SectionIndex; + } + let bytes = serde_json::to_vec(&payload).map_err(|error| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + format!("Cannot serialize the Figma artifact: {error}"), + false, + ) + })?; + let selection_bytes = capabilities + .section_selection + .as_ref() + .map(serde_json::to_vec) + .transpose() + .map_err(|error| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + error.to_string(), + false, + ) + })? + .map_or(0, |bytes| bytes.len()); + let size_bytes = bytes.len().saturating_add(selection_bytes); + if size_bytes > self.limits.max_entry_bytes + || size_bytes > self.limits.max_total_bytes + || self.limits.max_entries == 0 + { + return Err(DevupError::with_details( + ErrorCode::DevupFigmaResponseTooLarge, + "The Figma artifact exceeded the memory cache limit.", + false, + json!({"artifactBytes": bytes.len()}), + )); + } + let now = self.clock.now_epoch_seconds(); + let content_hash = sha256_hex(&bytes); + let payload = Arc::new(payload); + let mut state = self.state.lock().await; + self.prune_expired(&mut state, now); + if let Some(previous_id) = state.key_index.remove(&key_digest) { + remove_entry(&mut state, &previous_id); + } + while state.entries.len() >= self.limits.max_entries + || state.total_bytes.saturating_add(size_bytes) > self.limits.max_total_bytes + { + let Some(lru_id) = state + .entries + .iter() + .min_by_key(|(_, entry)| entry.last_access) + .map(|(id, _)| id.clone()) + else { + break; + }; + remove_entry(&mut state, &lru_id); + } + state.access_sequence = state.access_sequence.saturating_add(1); + let last_access = state.access_sequence; + let artifact_id = unique_id(&state.entries); + let expires_at = now.saturating_add(self.limits.ttl.as_secs()); + state.total_bytes = state.total_bytes.saturating_add(size_bytes); + state + .key_index + .insert(key_digest.clone(), artifact_id.clone()); + state.entries.insert( + artifact_id.clone(), + Entry { + key_digest, + request_key, + selection_bytes, + content_hash: content_hash.clone(), + created_at: now, + expires_at, + size_bytes, + last_access, + capabilities: capabilities.clone(), + payload: payload.clone(), + outputs: BTreeMap::new(), + projections: BTreeMap::new(), + }, + ); + Ok(ArtifactLookup { + artifact_id, + content_hash, + created_at_epoch_seconds: now, + expires_at_epoch_seconds: expires_at, + age_seconds: 0, + remaining_ttl_seconds: self.limits.ttl.as_secs(), + size_bytes, + cache_hit: false, + reuse_kind: CacheReuseKind::Miss, + capabilities, + payload, + }) + } + + fn prune_expired(&self, state: &mut StoreState, now: u64) { + let ids = state + .entries + .iter() + .filter_map(|(id, entry)| (entry.expires_at <= now).then_some(id.clone())) + .collect::>(); + for id in ids { + remove_entry(state, &id); + if let Some(recovery) = state.recovery.get_mut(&id) { + recovery["artifactState"] = json!("expired"); + } + } + } +} + +fn lookup_by_key( + state: &mut StoreState, + key_digest: &str, + now: u64, + cache_hit: bool, +) -> Option { + let artifact_id = state.key_index.get(key_digest)?.clone(); + let reuse_kind = if cache_hit { + CacheReuseKind::Exact + } else { + CacheReuseKind::Miss + }; + touch_entry(state, &artifact_id, now, reuse_kind) +} + +fn touch_entry( + state: &mut StoreState, + artifact_id: &str, + now: u64, + reuse_kind: CacheReuseKind, +) -> Option { + state.access_sequence = state.access_sequence.saturating_add(1); + let last_access = state.access_sequence; + let entry = state.entries.get_mut(artifact_id)?; + entry.last_access = last_access; + Some(ArtifactLookup { + artifact_id: artifact_id.to_owned(), + content_hash: entry.content_hash.clone(), + created_at_epoch_seconds: entry.created_at, + expires_at_epoch_seconds: entry.expires_at, + age_seconds: now.saturating_sub(entry.created_at), + remaining_ttl_seconds: entry.expires_at.saturating_sub(now), + size_bytes: entry.size_bytes, + cache_hit: reuse_kind != CacheReuseKind::Miss, + reuse_kind, + capabilities: entry.capabilities.clone(), + payload: entry.payload.clone(), + }) +} + +/// Canonical URL and the last selection: enough to reacquire the same capture, +/// with no payload, credential or original private URL label retained. +fn recovery_arguments(entry: &Entry) -> serde_json::Value { + let key = &entry.request_key; + let url = devup_mcp_figma::FigmaTarget { + file_key: key.file_key.clone(), + node_id: None, + branch_key: key.branch_key.clone(), + } + .link(key.node_id.as_deref()); + if let Some(ids) = &key.measurement_node_ids { + return json!({"url":url,"nodeIds":ids}); + } + let mut args = json!({"url":url,"refresh":true}); + if let Some(selection) = &entry.capabilities.section_selection { + args["frameIds"] = json!(selection.frame_ids); + args["allScreens"] = json!(selection.all_screens); + } + args +} + +/// What this process can say about an id it does not hold: its tombstone if +/// it once held it, and otherwise whether the id was even issued here. +fn missing_artifact_details(state: &StoreState, artifact_id: &str) -> serde_json::Value { + if let Some(tombstone) = state.recovery.get(artifact_id) { + return tombstone.clone(); + } + if issued_by_another_process(artifact_id) { + json!({"artifactState":"issued-by-another-process"}) + } else { + json!({"artifactState":"unknown"}) + } +} + +fn merge_details(error: &mut DevupError, extra: serde_json::Value) { + if let (Some(details), serde_json::Value::Object(extra)) = + (error.details.as_object_mut(), extra) + { + details.extend(extra); + } +} + +fn remove_entry(state: &mut StoreState, artifact_id: &str) { + if let Some(entry) = state.entries.remove(artifact_id) { + let args = recovery_arguments(&entry); + if state.recovery.len() >= 64 { + let oldest = state + .recovery + .iter() + .min_by_key(|(_, v)| v["createdAt"].as_u64().unwrap_or(0)) + .map(|(id, _)| id.clone()); + if let Some(id) = oldest { + state.recovery.remove(&id); + } + } + state.recovery.insert(artifact_id.into(), json!({"artifactState":"evicted","recoveryArguments":args,"createdAt":entry.created_at})); + state.total_bytes = state.total_bytes.saturating_sub(entry.size_bytes); + if state.key_index.get(&entry.key_digest) == Some(&artifact_id.to_owned()) { + state.key_index.remove(&entry.key_digest); + } + } +} + +/// How many leading bytes of every artifactId name the process that issued it. +/// +/// Artifacts live only in the memory of the process that collected them, so an +/// id this store does not hold is either one it forgot or one another process +/// issued: the server that ran before a restart, or a second devup-mcp session. +/// Only the first is this process's to explain, and the stamp tells them apart +/// without writing anything to disk. The id keeps its 32-byte, 43-character +/// shape, so resource URIs do not change. +const PROCESS_TAG_BYTES: usize = 6; + +fn process_tag() -> &'static [u8; PROCESS_TAG_BYTES] { + static TAG: OnceLock<[u8; PROCESS_TAG_BYTES]> = OnceLock::new(); + TAG.get_or_init(|| { + let mut tag = [0_u8; PROCESS_TAG_BYTES]; + rand::rng().fill_bytes(&mut tag); + tag + }) +} + +/// True only for a well-formed id stamped by a different process. A malformed +/// id is not claimed to come from anywhere. +pub(crate) fn issued_by_another_process(artifact_id: &str) -> bool { + URL_SAFE_NO_PAD + .decode(artifact_id) + .ok() + .filter(|bytes| bytes.len() == 32) + .is_some_and(|bytes| bytes[..PROCESS_TAG_BYTES] != process_tag()[..]) +} + +fn unique_id(entries: &BTreeMap) -> String { + loop { + let mut bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut bytes); + bytes[..PROCESS_TAG_BYTES].copy_from_slice(process_tag()); + let id = URL_SAFE_NO_PAD.encode(bytes); + if !entries.contains_key(&id) { + return id; + } + } +} + +fn valid_output_id(value: &str) -> bool { + value.len() == 22 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn output_chunk_ranges(bytes: &[u8], is_binary: bool) -> Result, DevupError> { + let text = (!is_binary) + .then(|| std::str::from_utf8(bytes)) + .transpose() + .map_err(|_| { + DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + "A text resource output must be UTF-8.", + false, + ) + })?; + if bytes.is_empty() { + return Ok(vec![(0, 0)]); + } + let mut ranges = Vec::new(); + let mut start = 0; + while start < bytes.len() { + let mut end = start.saturating_add(RESOURCE_CHUNK_BYTES).min(bytes.len()); + if let Some(text) = text { + while end > start && !text.is_char_boundary(end) { + end -= 1; + } + if end == start { + return Err(DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + "Cannot compute the text resource chunk boundary.", + false, + )); + } + } + ranges.push((start, end)); + start = end; + } + Ok(ranges) +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn acquisition_cancelled() -> DevupError { + DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "Collection of the same Figma artifact was cancelled before it completed.", + true, + ) +} + +fn resource_missing(kind: &str) -> DevupError { + DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + format!("The resource {kind} was not found. It may have been removed from the cache."), + false, + json!({"reason": "not_found", "resourceKind": kind}), + ) +} + +fn resource_expired() -> DevupError { + DevupError::new( + ErrorCode::DevupFigmaHandoffExpired, + "The resource artifact is missing or expired.", + true, + ) +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/artifacts/store_and_lookup.rs b/crates/devup-mcp/src/server/artifacts/store_and_lookup.rs new file mode 100644 index 00000000..7491506c --- /dev/null +++ b/crates/devup-mcp/src/server/artifacts/store_and_lookup.rs @@ -0,0 +1,412 @@ +use std::{ + cmp::Reverse, + collections::BTreeMap, + future::Future, + sync::{Arc, OnceLock}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use devup_mcp_figma::{ + AssetSelection, CollectedPayload, CollectionRequest, CollectionScope, DevupError, ErrorCode, + ExploreReadOptions, ResourceScope, SearchReadOptions, SectionReadOptions, +}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tokio::sync::{Mutex, OwnedMutexGuard, watch}; + +use super::delivery::{ProjectedOutput, RESOURCE_CHUNK_BYTES}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactRequestKey { + #[serde(default, skip_serializing_if = "Option::is_none")] + measurement_node_ids: Option>, + file_key: String, + node_id: Option, + branch_key: Option, + scope: CollectionScope, + resource_scope: ResourceScope, + include_context: bool, + metadata_only: bool, + variables_only: bool, + search: Option, + explore: Option, + section: Option, + asset_selections: Vec, + web_ready_images: bool, + reference_png: bool, +} + +impl ArtifactRequestKey { + pub fn from_collection(request: &CollectionRequest) -> Self { + let mut section = request.section.clone(); + if let Some(section) = &mut section { + section.frame_ids.sort(); + section.frame_ids.dedup(); + } + let mut asset_selections = request.asset_selections.clone(); + asset_selections + .sort_by_cached_key(|selection| serde_json::to_string(selection).unwrap_or_default()); + asset_selections.dedup(); + Self { + measurement_node_ids: None, + file_key: request.target.file_key.clone(), + node_id: request.target.node_id.clone(), + branch_key: request.target.branch_key.clone(), + scope: request.scope, + resource_scope: request.resource_scope, + include_context: request.include_context, + metadata_only: request.metadata_only, + variables_only: request.variables_only, + search: request.search.clone(), + explore: request.explore.clone(), + section, + asset_selections, + web_ready_images: request.web_ready_images, + reference_png: request.reference_png, + } + } + + fn digest(&self) -> String { + sha256_hex(&serde_json::to_vec(self).unwrap_or_default()) + } + + fn capabilities(&self) -> ArtifactCapabilities { + let kind = if self.measurement_node_ids.is_some() { + ArtifactKind::Measurement + } else if self.search.is_some() { + ArtifactKind::Search + } else if self.explore.is_some() { + ArtifactKind::Explore + } else if self + .section + .as_ref() + .is_some_and(|section| section.frame_ids.is_empty() && !section.all_screens) + { + ArtifactKind::SectionIndex + } else if self.variables_only { + ArtifactKind::ThemeOnly + } else { + ArtifactKind::Design + }; + ArtifactCapabilities { + kind, + collection_scope: self.scope, + resource_scope: self.resource_scope, + asset_capture_count: self.asset_selections.len(), + web_ready_images: self.web_ready_images, + asset_captures: self.asset_selections.clone(), + reference_png: self.reference_png, + section_selection: self.section.clone(), + } + } + + pub(super) fn measurement(target: devup_mcp_figma::FigmaTarget, ids: Vec) -> Self { + let mut request = CollectionRequest::new(target, CollectionScope::Node); + request.resource_scope = ResourceScope::None; + let mut key = Self::from_collection(&request); + key.measurement_node_ids = Some(ids); + key + } + + fn can_serve_explore(&self, requested: &Self) -> bool { + let (Some(cached_options), Some(requested_options)) = + (self.explore.as_ref(), requested.explore.as_ref()) + else { + return false; + }; + if cached_options.text_preview_limit != requested_options.text_preview_limit + || cached_options.projection_limit < requested_options.projection_limit + { + return false; + } + let mut cached_scope = self.clone(); + let mut requested_scope = requested.clone(); + cached_scope.node_id = None; + requested_scope.node_id = None; + cached_scope.explore = None; + requested_scope.explore = None; + cached_scope == requested_scope + } + + fn explore_projection_limit(&self) -> usize { + self.explore + .as_ref() + .map_or(usize::MAX, |options| options.projection_limit) + } + + fn explore_reuse_kind(&self, requested: &Self) -> Option { + self.can_serve_explore(requested).then(|| { + let same_node = self.node_id == requested.node_id; + let same_projection = + self.explore_projection_limit() == requested.explore_projection_limit(); + match (same_node, same_projection) { + (true, true) => CacheReuseKind::Exact, + (true, false) => CacheReuseKind::Superset, + (false, true) => CacheReuseKind::RelatedNode, + (false, false) => CacheReuseKind::RelatedNodeSuperset, + } + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ArtifactKind { + Measurement, + Design, + ThemeOnly, + Search, + Explore, + SectionIndex, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CacheReuseKind { + Miss, + Exact, + RelatedNode, + Superset, + RelatedNodeSuperset, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactCapabilities { + pub kind: ArtifactKind, + pub collection_scope: CollectionScope, + pub resource_scope: ResourceScope, + pub asset_capture_count: usize, + pub web_ready_images: bool, + pub reference_png: bool, + /// Last validated Section selection; projection callers restore this when no selection is supplied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub section_selection: Option, + #[serde(skip)] + asset_captures: Vec, +} + +impl ArtifactCapabilities { + /// Validate exact asset ID, format and scale coverage before projecting a cached artifact. + pub fn validate_asset_captures(&self, requested: &[AssetSelection]) -> Result<(), DevupError> { + let missing = requested + .iter() + .filter(|capture| !self.asset_captures.contains(capture)) + .collect::>(); + if missing.is_empty() { + return Ok(()); + } + Err(DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + "This artifact was collected without the requested asset captures. Remove artifactId and call again with the original url and assetRequests.", + false, + json!({"missingAssetCaptures": missing, "assetCaptureCount": self.asset_capture_count}), + )) + } + + pub fn supports_asset_captures(&self, requested: &[AssetSelection]) -> bool { + requested + .iter() + .all(|capture| self.asset_captures.contains(capture)) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ArtifactLimits { + pub ttl: Duration, + pub max_entries: usize, + pub max_entry_bytes: usize, + pub max_total_bytes: usize, +} + +impl Default for ArtifactLimits { + fn default() -> Self { + Self { + ttl: Duration::from_secs(10 * 60), + max_entries: 8, + // One collection may fill the whole cache: a scope:file collection of a + // 5,543-node test file is 63.6 MB, and refusing it failed the export, + // while the entries it evicts cost only a re-collection. + max_entry_bytes: 384 * 1024 * 1024, + max_total_bytes: 384 * 1024 * 1024, + } + } +} + +pub trait ArtifactClock: Send + Sync { + fn now_epoch_seconds(&self) -> u64; +} + +#[derive(Debug)] +struct SystemClock; + +impl ArtifactClock for SystemClock { + fn now_epoch_seconds(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } +} + +#[derive(Debug, Clone)] +pub struct ArtifactLookup { + pub artifact_id: String, + pub content_hash: String, + pub created_at_epoch_seconds: u64, + pub expires_at_epoch_seconds: u64, + pub age_seconds: u64, + pub remaining_ttl_seconds: u64, + pub size_bytes: usize, + pub cache_hit: bool, + pub reuse_kind: CacheReuseKind, + pub capabilities: ArtifactCapabilities, + pub payload: Arc, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactStoreStats { + pub entry_count: usize, + pub total_bytes: usize, + pub max_entries: usize, + pub max_total_bytes: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachedOutputManifest { + #[serde(skip_serializing_if = "Option::is_none")] + pub asset_metadata: Option, + pub artifact_id: String, + pub output_id: String, + pub name: String, + pub mime_type: String, + pub raw_bytes: usize, + pub sha256: String, + pub chunk_count: usize, + pub chunk_bytes: usize, + pub chunk_uris: Vec, + pub is_binary: bool, + pub manifest_uri: String, + pub expires_at_epoch_seconds: u64, +} + +#[derive(Debug)] +struct AttachedOutput { + manifest: AttachedOutputManifest, + bytes: Arc<[u8]>, + ranges: Vec<(usize, usize)>, + allocation_bytes: usize, +} + +#[derive(Debug)] +struct Entry { + key_digest: String, + request_key: ArtifactRequestKey, + selection_bytes: usize, + content_hash: String, + created_at: u64, + expires_at: u64, + size_bytes: usize, + last_access: u64, + capabilities: ArtifactCapabilities, + payload: Arc, + outputs: BTreeMap, + projections: BTreeMap>, +} + +type AcquisitionResult = Result; + +#[derive(Default)] +struct StoreState { + entries: BTreeMap, + key_index: BTreeMap, + in_flight: BTreeMap>>, + in_flight_keys: BTreeMap, + total_bytes: usize, + recovery: BTreeMap, + access_sequence: u64, +} + +#[derive(Debug, Clone, Copy)] +enum InFlightWait { + Exact, + Compatible(CacheReuseKind), +} + +#[derive(Clone)] +pub struct ArtifactStore { + state: Arc>, + clock: Arc, + limits: ArtifactLimits, +} + +pub struct OutputReservation { + state: Option>, + artifact_id: String, + projection_key: String, + staged: Vec, + manifests: Vec, + allocation: usize, + created: bool, + max_total_bytes: usize, +} + +impl OutputReservation { + pub fn manifests(&self) -> &[AttachedOutputManifest] { + &self.manifests + } + + pub fn commit(mut self) -> Vec { + let Some(mut state) = self.state.take() else { + return self.manifests; + }; + if !self.created { + return self.manifests; + } + while state.total_bytes.saturating_add(self.allocation) > self.max_total_bytes { + let lru_id = state + .entries + .iter() + .filter(|(id, _)| id.as_str() != self.artifact_id) + .min_by_key(|(_, entry)| entry.last_access) + .map(|(id, _)| id.clone()) + .expect("output reservation validated enough evictable capacity"); + remove_entry(&mut state, &lru_id); + } + state.access_sequence = state.access_sequence.saturating_add(1); + let access = state.access_sequence; + let entry = state + .entries + .get_mut(&self.artifact_id) + .expect("reserved artifact remains pinned by the store lock"); + entry.last_access = access; + entry.size_bytes = entry.size_bytes.saturating_add(self.allocation); + let ids = self + .staged + .iter() + .map(|output| output.manifest.output_id.clone()) + .collect::>(); + for output in self.staged.drain(..) { + entry + .outputs + .insert(output.manifest.output_id.clone(), output); + } + entry.projections.insert(self.projection_key, ids); + state.total_bytes = state.total_bytes.saturating_add(self.allocation); + self.manifests + } + + pub fn rollback(self) {} +} + +impl Default for ArtifactStore { + fn default() -> Self { + Self::with_limits(ArtifactLimits::default()) + } +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/artifacts/w3_tests.rs b/crates/devup-mcp/src/server/artifacts/w3_tests.rs new file mode 100644 index 00000000..c93310e1 --- /dev/null +++ b/crates/devup-mcp/src/server/artifacts/w3_tests.rs @@ -0,0 +1,600 @@ + + +#[cfg(test)] +pub(crate) mod w3_tests { + use super::*; + use devup_mcp_figma::{ + AssetFormat, CollectionStats, FigmaTarget, PayloadCompleteness, Snapshot, + }; + use std::sync::atomic::{AtomicU64, Ordering}; + + #[derive(Default)] + pub(crate) struct Clock(pub AtomicU64); + impl ArtifactClock for Clock { + fn now_epoch_seconds(&self) -> u64 { + self.0.load(Ordering::SeqCst) + } + } + pub(crate) fn request() -> CollectionRequest { + CollectionRequest::new( + FigmaTarget { + file_key: "fixture".into(), + node_id: Some("1:1".into()), + branch_key: None, + }, + CollectionScope::Node, + ) + } + pub(crate) fn payload() -> CollectedPayload { + CollectedPayload { + target: request().target, + scope: CollectionScope::Node, + metadata: json!({}), + snapshot: Snapshot { + file_key: "fixture".into(), + version: None, + roots: vec!["1:1".into()], + nodes: BTreeMap::new(), + diagnostics: vec![], + }, + variables: None, + styles: None, + completeness: PayloadCompleteness::ResolvedValuesOnly, + source_version: None, + stats: CollectionStats::default(), + assets: vec![], + reference_png: None, + failures: vec![], + } + } + + /// Settles the question W3 left open about `ArtifactRequestKey::digest`. + /// + /// It ends in `unwrap_or_default()`, so a serialisation failure would not + /// raise anything — it would hand back the digest of the *empty* byte + /// string, and every key that failed would land on that one entry. Two + /// unrelated collections would then read each other's design. + /// + /// It cannot fail today. The key is `String`, `Option`, `bool`, + /// `usize`, `u8`, three fieldless enums and `Vec`s of those, every one of + /// them `#[derive(Serialize)]`; `serde_json` fails on a non-string map + /// key, a hand-written `Serialize` that returns an error, or a writer that + /// errors, and the writer here is a `Vec`. There is no float, so not + /// even a non-finite one to argue about. So the fallback stays. + /// + /// What it stays subject to is a field added later that *can* fail — at + /// which point the collapse is silent. This fills every field and asserts + /// each one still reaches a digest of its own, none of them the empty one. + #[test] + fn w3_every_field_of_a_request_key_reaches_its_digest() { + let populated = || { + let mut request = request(); + request.resource_scope = ResourceScope::Used; + request.include_context = true; + request.metadata_only = true; + request.variables_only = true; + request.reference_png = true; + request.search = Some(SearchReadOptions { + query: "STORY-F-PROOFREAD".into(), + node_types: vec!["FRAME".into()], + match_kind: "normalized".into(), + limit: 20, + }); + request.explore = Some(ExploreReadOptions { + projection_limit: 50, + text_preview_limit: 10, + }); + request.section = Some(SectionReadOptions { + frame_ids: vec!["2:2".into()], + all_screens: false, + }); + request.asset_selections = vec![AssetSelection { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + render_mode: devup_mcp_figma::AssetRenderMode::Node, + quality: None, + lossless: None, + asset_id: "asset".into(), + format: AssetFormat::Png, + scale: 2.0, + }]; + request + }; + + let mut branch_key = populated(); + branch_key.target.branch_key = Some("branch".into()); + let mut file_key = populated(); + file_key.target.file_key = "other".into(); + let mut node_id = populated(); + node_id.target.node_id = Some("9:9".into()); + let mut scope = populated(); + scope.scope = CollectionScope::File; + let mut resource_scope = populated(); + resource_scope.resource_scope = ResourceScope::File; + let mut include_context = populated(); + include_context.include_context = false; + let mut metadata_only = populated(); + metadata_only.metadata_only = false; + let mut variables_only = populated(); + variables_only.variables_only = false; + let mut reference_png = populated(); + reference_png.reference_png = false; + let mut search = populated(); + search.search = None; + let mut explore = populated(); + explore.explore = None; + let mut section = populated(); + section.section = None; + let mut asset_format = populated(); + asset_format.asset_selections[0].format = AssetFormat::Svg; + let mut asset_scale = populated(); + asset_scale.asset_selections[0].scale = 3.0; + let mut asset_mode = populated(); + asset_mode.asset_selections[0].render_mode = devup_mcp_figma::AssetRenderMode::Fill; + let mut asset_auto_mode = populated(); + asset_auto_mode.asset_selections[0].render_mode = devup_mcp_figma::AssetRenderMode::Auto; + let mut asset_id = populated(); + asset_id.asset_selections[0].asset_id = "elsewhere".into(); + let mut quality = populated(); + quality.asset_selections[0].quality = Some(43); + let mut lossless = populated(); + lossless.asset_selections[0].lossless = Some(true); + let mut preset = populated(); + preset.web_ready_images = true; + + let mut max_width = populated(); + max_width.asset_selections[0].max_width = Some(240); + let mut srcset = populated(); + srcset.asset_selections[0].srcset = true; + let mut visible = populated(); + visible.asset_selections[0].render_mode = devup_mcp_figma::AssetRenderMode::Visible; + visible.asset_selections[0].visible_scope_node_id = Some("1:1".into()); + let mut original = populated(); + original.asset_selections[0].render_mode = devup_mcp_figma::AssetRenderMode::OriginalSource; + let mut fractional = populated(); + fractional.asset_selections[0].scale = 1.5; + let mut usage = populated(); + usage.asset_selections[0].usage_aspect_ratio = Some(1.25); + let empty = sha256_hex(&[]); + let digests = [ + max_width, + srcset, + visible, + original, + fractional, + usage, + quality, + lossless, + preset, + asset_auto_mode, + populated(), + branch_key, + file_key, + node_id, + scope, + resource_scope, + include_context, + metadata_only, + variables_only, + reference_png, + search, + explore, + section, + asset_format, + asset_scale, + asset_mode, + asset_id, + ] + .iter() + .map(|request| ArtifactRequestKey::from_collection(request).digest()) + .collect::>(); + + for digest in &digests { + assert_ne!( + digest, &empty, + "a key that serialised to nothing would share one cache entry with every other such key" + ); + } + let distinct = digests.iter().collect::>(); + assert_eq!( + distinct.len(), + digests.len(), + "each field has to reach the digest, or two different collections share an artifact" + ); + } + + #[tokio::test] + async fn w3_selection_is_available_after_artifact_id_lookup() { + let store = ArtifactStore::default(); + let mut request = request(); + request.section = Some(SectionReadOptions { + frame_ids: vec!["2:2".into(), "3:3".into()], + all_screens: false, + }); + let artifact = store + .insert(ArtifactRequestKey::from_collection(&request), payload()) + .await + .unwrap(); + let restored = store.get(&artifact.artifact_id).await.unwrap(); + let metadata = serde_json::to_value(&restored.capabilities).unwrap(); + assert_eq!( + metadata["sectionSelection"]["frameIds"], + json!(["2:2", "3:3"]) + ); + } + + #[tokio::test] + async fn w3_last_selection_survives_lookup_without_changing_acquisition_key() { + let store = ArtifactStore::default(); + let mut request = request(); + request.section = Some(SectionReadOptions { + frame_ids: vec![], + all_screens: false, + }); + let key = ArtifactRequestKey::from_collection(&request); + let artifact = store.insert(key.clone(), payload()).await.unwrap(); + store + .remember_section_selection( + &artifact.artifact_id, + SectionReadOptions { + frame_ids: vec!["3:3".into()], + all_screens: false, + }, + ) + .await + .unwrap(); + let restored = store.lookup(&key).await.unwrap(); + assert_eq!(restored.artifact_id, artifact.artifact_id); + assert_eq!( + serde_json::to_value(&restored.capabilities).unwrap()["sectionSelection"]["frameIds"], + json!(["3:3"]) + ); + store + .remember_section_selection( + &artifact.artifact_id, + SectionReadOptions { + frame_ids: vec![], + all_screens: true, + }, + ) + .await + .unwrap(); + let restored = store.get(&artifact.artifact_id).await.unwrap(); + assert_eq!( + serde_json::to_value(&restored.capabilities).unwrap()["sectionSelection"]["allScreens"], + true + ); + } + + #[tokio::test] + async fn w3_unselected_section_index_remembers_its_first_selection() { + let store = ArtifactStore::default(); + let mut payload = payload(); + payload.metadata = json!({"sectionIndex": {}}); + let artifact = store + .insert(ArtifactRequestKey::from_collection(&request()), payload) + .await + .unwrap(); + assert_eq!(artifact.capabilities.kind, ArtifactKind::SectionIndex); + let selection = SectionReadOptions { + frame_ids: vec!["2:2".into()], + all_screens: false, + }; + store + .remember_section_selection(&artifact.artifact_id, selection.clone()) + .await + .unwrap(); + assert_eq!( + store + .get(&artifact.artifact_id) + .await + .unwrap() + .capabilities + .section_selection, + Some(selection) + ); + } + + #[tokio::test] + async fn r8_expired_branch_url_preserves_target_and_selection() { + let clock = Arc::new(Clock::default()); + let store = ArtifactStore::with_clock( + clock.clone(), + ArtifactLimits { + ttl: Duration::from_secs(1), + ..Default::default() + }, + ); + let mut request = request(); + request.target.branch_key = Some("Branch123".into()); + request.section = Some(SectionReadOptions { + frame_ids: vec!["2:2".into()], + all_screens: false, + }); + let artifact = store + .insert(ArtifactRequestKey::from_collection(&request), payload()) + .await + .unwrap(); + clock.0.store(1, Ordering::SeqCst); + let error = store + .get_for_export(&artifact.artifact_id) + .await + .unwrap_err(); + let arguments = &error.details["recoveryArguments"]; + let recovered = + devup_mcp_figma::FigmaTarget::parse(arguments["url"].as_str().unwrap()).unwrap(); + assert_eq!(recovered, request.target); + assert_eq!(arguments["frameIds"], json!(["2:2"])); + } + + #[tokio::test] + async fn w3_expired_key_is_reacquired_at_ttl_boundary() { + let clock = Arc::new(Clock::default()); + let store = ArtifactStore::with_clock( + clock.clone(), + ArtifactLimits { + ttl: Duration::from_secs(10), + ..ArtifactLimits::default() + }, + ); + let key = ArtifactRequestKey::from_collection(&request()); + let first = store.insert(key.clone(), payload()).await.unwrap(); + clock.0.store(10, Ordering::SeqCst); + assert!(store.lookup(&key).await.is_none()); + let expired = store.get_for_export(&first.artifact_id).await.unwrap_err(); + assert_eq!(expired.details["artifactState"], "expired"); + assert!( + expired.details["recoveryArguments"]["url"] + .as_str() + .unwrap() + .contains("figma.com/design/") + ); + let unknown = ArtifactStore::default() + .get_for_export(&first.artifact_id) + .await + .unwrap_err(); + assert_eq!(unknown.details["artifactState"], "unknown"); + assert!(unknown.details.get("recoveryArguments").is_none()); + let second = store + .get_or_acquire(key, false, || async { Ok(payload()) }) + .await + .unwrap(); + assert!(!second.cache_hit); + assert_ne!(first.artifact_id, second.artifact_id); + } + + #[tokio::test] + async fn w3_invalid_or_oversized_selection_does_not_replace_previous_selection() { + let store = ArtifactStore::with_limits(ArtifactLimits { + max_entry_bytes: 2048, + ..ArtifactLimits::default() + }); + let mut request = request(); + request.section = Some(SectionReadOptions { + frame_ids: vec!["2:2".into()], + all_screens: false, + }); + let artifact = store + .insert(ArtifactRequestKey::from_collection(&request), payload()) + .await + .unwrap(); + let original_bytes = store.stats().await.total_bytes; + for selection in [ + SectionReadOptions { + frame_ids: vec![], + all_screens: false, + }, + SectionReadOptions { + frame_ids: vec!["3:3".into()], + all_screens: true, + }, + SectionReadOptions { + frame_ids: vec!["3:3".into(), "3:3".into()], + all_screens: false, + }, + SectionReadOptions { + frame_ids: vec!["x".repeat(4096)], + all_screens: false, + }, + ] { + assert!( + store + .remember_section_selection(&artifact.artifact_id, selection) + .await + .is_err() + ); + assert_eq!( + store + .get(&artifact.artifact_id) + .await + .unwrap() + .capabilities + .section_selection, + request.section + ); + assert_eq!(store.stats().await.total_bytes, original_bytes); + } + let updated = store + .remember_section_selection( + &artifact.artifact_id, + SectionReadOptions { + frame_ids: vec![], + all_screens: true, + }, + ) + .await + .unwrap(); + assert_eq!(updated.size_bytes, store.stats().await.total_bytes); + assert_eq!(updated.content_hash, artifact.content_hash); + assert_eq!( + updated.expires_at_epoch_seconds, + artifact.expires_at_epoch_seconds + ); + } + + #[test] + fn w3_asset_capture_validation_reports_only_missing_exact_variants() { + let mut request = request(); + let captured = AssetSelection { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + render_mode: devup_mcp_figma::AssetRenderMode::Node, + quality: None, + lossless: None, + asset_id: "2:2:node".into(), + format: AssetFormat::Svg, + scale: 1.0, + }; + request.asset_selections = vec![captured.clone()]; + let capabilities = ArtifactRequestKey::from_collection(&request).capabilities(); + assert!(capabilities.validate_asset_captures(&[]).is_ok()); + assert!(!capabilities.supports_asset_captures(&[AssetSelection { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + quality: Some(80), + ..captured.clone() + }])); + assert!(!capabilities.supports_asset_captures(&[AssetSelection { + lossless: Some(true), + ..captured.clone() + }])); + assert!( + capabilities + .validate_asset_captures(std::slice::from_ref(&captured)) + .is_ok() + ); + let missing = AssetSelection { + scale: 2.0, + ..captured.clone() + }; + let error = capabilities + .validate_asset_captures(&[captured, missing]) + .unwrap_err(); + assert_eq!( + error.details["missingAssetCaptures"], + json!([{"assetId":"2:2:node", "format":"svg", "scale":2.0,"renderMode":"node","srcset":false}]) + ); + assert!(!error.retryable); + } + + #[test] + fn artifact_node_capture_cannot_serve_original_fill_mode() { + let mut request = request(); + let node: AssetSelection = + serde_json::from_value(json!({"assetId":"sample:fills:0","format":"png","scale":1})) + .unwrap(); + request.asset_selections = vec![node.clone()]; + let capabilities = ArtifactRequestKey::from_collection(&request).capabilities(); + let fill = AssetSelection { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + render_mode: devup_mcp_figma::AssetRenderMode::Fill, + quality: None, + lossless: None, + ..node + }; + assert!(!capabilities.supports_asset_captures(std::slice::from_ref(&fill))); + let error = capabilities.validate_asset_captures(&[fill]).unwrap_err(); + assert_eq!( + error.details["missingAssetCaptures"][0]["renderMode"], + "fill" + ); + } + + #[test] + fn w3_missing_asset_capture_has_actionable_details() { + let capabilities = ArtifactRequestKey::from_collection(&request()).capabilities(); + let selection = AssetSelection { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + render_mode: devup_mcp_figma::AssetRenderMode::Node, + quality: None, + lossless: None, + asset_id: "2:2:node".into(), + format: AssetFormat::Svg, + scale: 1.0, + }; + // The storage-layer validator will be consumed by W1's projection validation. + let error = capabilities + .validate_asset_captures(&[selection]) + .unwrap_err(); + assert!( + error.message.contains("Remove artifactId") + && error.message.contains("original url") + && error.message.contains("assetRequests") + ); + assert_eq!( + error.details["missingAssetCaptures"][0]["assetId"], + "2:2:node" + ); + } + + #[tokio::test] + async fn p2_an_id_from_another_process_is_named_as_such_not_as_unknown() { + let store = ArtifactStore::default(); + let ours = store + .insert(ArtifactRequestKey::from_collection(&request()), payload()) + .await + .unwrap(); + assert!(!issued_by_another_process(&ours.artifact_id)); + assert_eq!(ours.artifact_id.len(), 43); + + let mut bytes = URL_SAFE_NO_PAD.decode(&ours.artifact_id).unwrap(); + bytes[0] ^= 0xff; + let foreign = URL_SAFE_NO_PAD.encode(bytes); + let error = store.get_for_export(&foreign).await.unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupFigmaHandoffExpired); + assert_eq!(error.details["artifactState"], "issued-by-another-process"); + // A different store in this same process is still this process. + let unknown = ArtifactStore::default() + .get_for_export(&ours.artifact_id) + .await + .unwrap_err(); + assert_eq!(unknown.details["artifactState"], "unknown"); + assert!(!issued_by_another_process("not-an-artifact-id")); + } + + #[tokio::test] + async fn p2_resource_misses_say_whether_the_artifact_is_still_alive() { + let store = ArtifactStore::default(); + let artifact = store + .insert(ArtifactRequestKey::from_collection(&request()), payload()) + .await + .unwrap(); + let manifests = store + .attach_outputs( + &artifact.artifact_id, + "projection", + vec![super::super::delivery::ProjectedOutput::text( + "tsx", + "text/plain", + b"hello".to_vec(), + )], + ) + .await + .unwrap(); + let output = store + .read_resource_output(&artifact.artifact_id, &"z".repeat(22), None) + .await + .unwrap_err(); + assert_eq!(output.details["resourceKind"], "output"); + assert_eq!(output.details["artifactState"], "live"); + let chunk = store + .read_resource_output(&artifact.artifact_id, &manifests[0].output_id, Some(7)) + .await + .unwrap_err(); + assert_eq!(chunk.details["resourceKind"], "chunk"); + assert_eq!(chunk.details["chunkCount"], 1); + assert_eq!(chunk.details["manifestUri"], manifests[0].manifest_uri); + } +} diff --git a/crates/devup-mcp/src/server/asset_jobs.rs b/crates/devup-mcp/src/server/asset_jobs.rs index c8cbda57..b4b546a1 100644 --- a/crates/devup-mcp/src/server/asset_jobs.rs +++ b/crates/devup-mcp/src/server/asset_jobs.rs @@ -145,7 +145,7 @@ impl AssetJobs { json!({"frameIds":frame_ids,"allScreens":all_screens,"frameCount":frames,"frameOutputUnits":units, "planningSecondsPerUnit":[5,20],"planningSeconds":units.map(|units|[units*5,units*20]), "isGuarantee":false,"initialReplyBudgetSeconds":1, - "note":"Planning estimate only. Paging and upstream throttling are measured per call; poll this job instead of waiting for the whole batch. Split frameIds into one-frame calls to isolate slow frames."}) + "note":include_str!("text/asset_jobs-acquire_AssetJob-1e1eb717.md")}) } _ => unreachable!(), }, @@ -508,7 +508,7 @@ impl AssetJob { } else { job["assetRows"] = json!("compact"); job["assetDetail"] = json!({ - "how": "Rows carry identity, status, bytes and file state only. Poll with responseFields to read full rows (exportDiagnostics, renderInfo); a completed job also carries them in its assetManifest.", + "how": include_str!("text/asset_jobs-response_with-526ef5eb.md"), "arguments": {"jobId": self.id, "jobAction": "status", "responseFields": ["exportJob.assets"]}, }); } diff --git a/crates/devup-mcp/src/server/audit_plan.rs b/crates/devup-mcp/src/server/audit_plan.rs index 3f34cc55..8705e28e 100644 --- a/crates/devup-mcp/src/server/audit_plan.rs +++ b/crates/devup-mcp/src/server/audit_plan.rs @@ -7,1766 +7,7 @@ //! //! A plan per URL, because a page comparison needs the viewport as tall as //! that URL's frame, and because the work goes URL by URL. -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; -use std::sync::Arc; -use devup_mcp_figma::{AssetFormat, AssetStatus, CollectedPayload, DevupError, ErrorCode}; -use serde_json::{Value, json}; - -use super::artifacts::ArtifactStore; -use super::output::OutputPolicy; -use super::runtime_audit::{ - self, ASSET_THRESHOLD, CheckInput, PAGE_THRESHOLD, PlanInput, SECTION_THRESHOLD, ScreenInput, - ViewportInput, -}; - -pub(super) const AUDIT_DIR: &str = ".devup/runtime-audit"; -const RUNNER_DIR: &str = ".devup/runtime-audit/runner"; -const DEFAULT_WIDTHS: [u32; 4] = [1440, 1100, 858, 390]; -const DEFAULT_HEIGHT: u32 = 900; -const MAX_VIEWPORT_SIDE: u32 = 4096; -const MAX_VIEWPORTS: usize = 8; -const MAX_CHECKS: usize = 200; -/// Most concrete URLs audited for one dynamic route. -const MAX_DYNAMIC_URLS: usize = 10; -/// Font CDNs a page commonly loads; anything else outside the app is -/// blocked and recorded, which leaves the run inconclusive. -const FONT_ORIGINS: [&str; 3] = [ - "https://fonts.googleapis.com", - "https://fonts.gstatic.com", - "https://cdn.jsdelivr.net", -]; -const NAV_WORDS: [&str; 5] = ["nav", "header", "gnb", "menu", "헤더"]; - -fn invalid(message: impl Into) -> DevupError { - DevupError::new(ErrorCode::DevupInvalidInput, message, false) -} - -/// Changed-pixel ratios from `.devup/screen-plan.config.json` -/// `visualThresholds`: `page`, `section`, `asset`, and `nodes` keyed by Figma -/// node id for one asset that needs its own. -#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(super) struct VisualThresholds { - #[serde(default)] - pub page: Option, - #[serde(default)] - pub section: Option, - #[serde(default)] - pub asset: Option, - #[serde(default)] - pub nodes: BTreeMap, -} - -impl VisualThresholds { - fn for_node(&self, node_id: &str, scope: &str) -> f64 { - self.nodes - .get(node_id) - .copied() - .or(match scope { - "page" => self.page, - "section" => self.section, - _ => self.asset, - }) - .unwrap_or(match scope { - "page" => PAGE_THRESHOLD, - "section" => SECTION_THRESHOLD, - _ => ASSET_THRESHOLD, - }) - } -} - -/// The export artifacts the agent holds, searched for each frame's snapshot, -/// its referencePng and the native renders of its sections and assets. -pub(super) struct Sources { - payloads: Vec<(String, Arc)>, -} - -impl Sources { - pub(super) async fn load(ids: &[String], store: &ArtifactStore) -> (Self, Vec) { - let mut payloads = Vec::new(); - let mut missing = Vec::new(); - for id in ids { - match store.get(id).await { - Some(found) => payloads.push((id.clone(), found.payload)), - None => missing.push(json!({"artifactId": id, - "issue": "missing or expired; export the frame again and pass the new artifactId"})), - } - } - (Self { payloads }, missing) - } - - #[cfg(test)] - pub(super) fn from_payloads(payloads: Vec<(String, CollectedPayload)>) -> Self { - Self { - payloads: payloads - .into_iter() - .map(|(id, payload)| (id, Arc::new(payload))) - .collect(), - } - } - - /// The artifact whose snapshot holds the frame, preferring the one - /// exported for that frame and carrying its referencePng. - fn frame(&self, node_id: &str) -> Option<(&str, &CollectedPayload)> { - self.payloads - .iter() - .filter(|(_, payload)| payload.snapshot.nodes.contains_key(node_id)) - .max_by_key(|(_, payload)| { - ( - payload.target.node_id.as_deref() == Some(node_id), - payload.reference_png.is_some(), - ) - }) - .map(|(id, payload)| (id.as_str(), payload.as_ref())) - } - - fn reference_png(&self, node_id: &str) -> Option<&str> { - self.payloads - .iter() - .find(|(_, payload)| { - payload.target.node_id.as_deref() == Some(node_id) - && payload.reference_png.is_some() - }) - .map(|(id, _)| id.as_str()) - } - - /// A native-node PNG of the node at scale 1, the only render an element - /// capture at deviceScaleFactor 1 can be compared with pixel for pixel. - fn native_render(&self, node_id: &str) -> Option<(&str, &str)> { - self.payloads.iter().find_map(|(id, payload)| { - payload - .assets - .iter() - .find(|asset| { - asset.node_id == node_id - && asset.status == AssetStatus::Exported - && asset.format == Some(AssetFormat::Png) - && asset.mime_type.as_deref() == Some("image/png") - && asset.scale == Some(1.0) - && asset - .render_info - .as_ref() - .is_some_and(|info| info["representation"] == "native-node") - }) - .map(|asset| (id.as_str(), asset.asset_id.as_str())) - }) - } - - /// Nodes the export found as images or icons. A render the agent asked - /// for (sourceKind node) says nothing about what the node is. - fn asset_nodes(&self) -> BTreeSet { - self.payloads - .iter() - .flat_map(|(_, payload)| { - payload - .assets - .iter() - .filter(|asset| asset.source_kind != "node") - .map(|asset| asset.node_id.clone()) - }) - .collect() - } -} - -#[derive(Clone, Copy)] -struct Bounds { - x: f64, - y: f64, - width: f64, - height: f64, -} - -fn bounds(payload: &CollectedPayload, node_id: &str) -> Option { - let view = payload.snapshot.nodes.get(node_id)?.typed_view(); - let bbox = view.value("absoluteBoundingBox")?; - let number = |key: &str| bbox.get(key).and_then(Value::as_f64); - Some(Bounds { - x: number("x")?, - y: number("y")?, - width: number("width")?, - height: number("height")?, - }) -} - -fn visible_children(payload: &CollectedPayload, node_id: &str) -> Vec { - payload - .snapshot - .nodes - .get(node_id) - .map(|node| { - node.typed_view() - .child_ids() - .filter(|id| { - payload - .snapshot - .nodes - .get(*id) - .is_some_and(|child| child.typed_view().bool("visible") != Some(false)) - }) - .map(str::to_owned) - .collect() - }) - .unwrap_or_default() -} - -fn name_of(payload: &CollectedPayload, node_id: &str) -> String { - payload - .snapshot - .nodes - .get(node_id) - .and_then(|node| node.typed_view().name()) - .unwrap_or(node_id) - .to_owned() -} - -fn node(id: &str) -> Value { - json!({"css": format!("[data-figma-node-id=\"{id}\"]")}) -} - -/// The element a frame drawn at `width` made from `id`: on a single-frame -/// page it carries the id itself, on a page merged from one frame per width -/// the id is in its `data-figma-node-ids` map under that width. -fn node_at(width: u32, id: &str) -> Value { - json!({"css": format!( - "[data-figma-node-id=\"{id}\"], [data-figma-node-ids~=\"{width}:{id}\"]" - )}) -} - -fn check(value: Value) -> CheckInput { - serde_json::from_value(value).expect("audit checks are built from the schema") -} - -/// One confirmed URL and what the plan knows about it. -pub(super) struct Target<'a> { - pub url: &'a str, - pub screen: String, - pub file: Option<&'a str>, - /// (width, frame node id, frame height) for each drawn width. - pub frames: Vec<(u32, String, Option)>, - /// The URL a view-transition check follows a link to. - pub next_url: Option<&'a str>, - /// Elements of a merged page a drawn width does not show: (width, the - /// element's id at its widest width). - pub hidden: Vec<(u32, String)>, -} - -/// The runtime-audit plan for one URL, the references it could not find, -/// and what it left out. -pub(super) struct UrlPlan { - pub plan: PlanInput, - pub missing: Vec, - pub notes: Vec, -} - -pub(super) fn url_plan( - target: &Target<'_>, - base_url: &str, - sources: &Sources, - thresholds: &VisualThresholds, -) -> Result { - let origin = url::Url::parse(base_url) - .map_err(|_| invalid(format!("baseUrl {base_url} is not a URL.")))? - .origin() - .ascii_serialization(); - let mut notes = Vec::new(); - let mut missing = Vec::new(); - let mut viewports = Vec::::new(); - for (width, node_id, height) in &target.frames { - let height = height.map(|height| height.round() as u32); - let height = match height { - Some(height) if (1..=MAX_VIEWPORT_SIDE).contains(&height) => height, - Some(height) => { - notes.push(format!( - "The {width}px frame {node_id} is {height}px tall, over the {MAX_VIEWPORT_SIDE}px a viewport can be; its page comparison stays inconclusive and its sections carry the visual check." - )); - DEFAULT_HEIGHT - } - None => DEFAULT_HEIGHT, - }; - if !viewports.iter().any(|viewport| viewport.width == *width) { - viewports.push(viewport(*width, height)); - } - } - for width in DEFAULT_WIDTHS { - if viewports.len() >= MAX_VIEWPORTS { - notes.push(format!( - "{width}px left out: a plan holds at most {MAX_VIEWPORTS} widths." - )); - continue; - } - if !viewports.iter().any(|viewport| viewport.width == width) { - viewports.push(viewport(width, DEFAULT_HEIGHT)); - } - } - viewports.sort_by_key(|viewport| std::cmp::Reverse(viewport.width)); - let widest = viewports.first().map_or(1440, |viewport| viewport.width); - let drawn = target - .frames - .iter() - .map(|(width, _, _)| *width) - .collect::>(); - - // Health and containment at every width. - let mut checks = vec![ - check(json!({"kind": "page-health", "label": "page health"})), - check(json!({"kind": "no-horizontal-overflow", "label": "no horizontal overflow"})), - check(json!({"kind": "no-clipped-text", "label": "no clipped text"})), - ]; - let mut layout = Vec::new(); - let mut parts = Vec::new(); - let mut frame_refs = Vec::new(); - let assets_everywhere = sources.asset_nodes(); - let mut nav_checked = false; - for (width, frame_id, _) in &target.frames { - let Some((artifact_id, payload)) = sources.frame(frame_id) else { - missing.push(json!({"kind": "frame", "nodeId": frame_id, "width": width, - "why": "No artifact passed holds this frame; section, asset and geometry checks for this width are missing.", - "call": {"tool": "devup_figma_export", "arguments": {"url": format!(""), "outputs": ["tsx", "referencePng"], "projectRoot": ""}}})); - frame_refs.push(json!({"width": width, "nodeId": frame_id})); - continue; - }; - let reference = sources.reference_png(frame_id); - if reference.is_none() { - missing.push(json!({"kind": "referencePng", "nodeId": frame_id, "width": width, - "why": format!("No referencePng for the frame drawn at {width}px, so its page comparison cannot run."), - "call": {"tool": "devup_figma_export", "arguments": {"url": format!(""), "outputs": ["referencePng"]}}})); - } - frame_refs.push( - json!({"width": width, "nodeId": frame_id, "artifactId": reference, - "threshold": thresholds.for_node(frame_id, "page")}), - ); - let _ = artifact_id; - let Some(frame_box) = bounds(payload, frame_id) else { - notes.push(format!( - "Frame {frame_id} has no absoluteBoundingBox; its geometry is not checked." - )); - continue; - }; - let sections = visible_children(payload, frame_id); - let mut renders_missing = Vec::new(); - for section in §ions { - let name = name_of(payload, section); - let is_asset = assets_everywhere.contains(section) - || payload.snapshot.nodes[section].typed_view().bool("isAsset") == Some(true); - if !nav_checked - && NAV_WORDS - .iter() - .any(|word| name.to_lowercase().contains(word)) - { - nav_checked = true; - checks.push(check( - json!({"kind": "contained", "label": "navigation not cut off", - "subject": {"css": "nav >> nth=0"}, "container": {"css": "body"}}), - )); - } - // Between the drawn widths only containment holds; at a drawn - // width the frame says exactly where the section sits. - if target.frames.len() == 1 { - layout.push(check( - json!({"kind": "contained", "label": format!("{name} inside the page"), - "subject": node(section), "container": node(frame_id), "nodeId": section}), - )); - } - if let Some(section_box) = bounds(payload, section) { - layout.push(bounds_check( - &name, - section, - frame_id, - frame_box, - section_box, - *width, - )); - if !is_asset { - for part in visible_children(payload, section) { - if let Some(part_box) = bounds(payload, &part) { - parts.push(bounds_check( - &name_of(payload, &part), - &part, - section, - section_box, - part_box, - *width, - )); - } - } - } - } - let scope = if is_asset { "asset" } else { "section" }; - match sources.native_render(section) { - Some((artifact, asset)) => layout.push(capture( - &name, - section, - scope, - artifact, - asset, - *width, - thresholds.for_node(section, scope), - )), - None => renders_missing.push(section.clone()), - } - } - // Assets below the sections, each against its own render and in - // its section. - let mut stack = sections - .iter() - .map(|section| (section.clone(), section.clone())) - .collect::>(); - let mut seen = BTreeSet::new(); - while let Some((id, section)) = stack.pop() { - if !seen.insert(id.clone()) { - continue; - } - for child in visible_children(payload, &id) { - let is_asset = assets_everywhere.contains(&child) - || payload.snapshot.nodes[&child].typed_view().bool("isAsset") == Some(true); - if !is_asset { - stack.push((child, section.clone())); - continue; - } - let name = name_of(payload, &child); - if let (Some(section_box), Some(asset_box)) = - (bounds(payload, §ion), bounds(payload, &child)) - { - layout.push(bounds_check( - &name, - &child, - §ion, - section_box, - asset_box, - *width, - )); - } - match sources.native_render(&child) { - Some((artifact, asset)) => layout.push(capture( - &name, - &child, - "asset", - artifact, - asset, - *width, - thresholds.for_node(&child, "asset"), - )), - None => renders_missing.push(child.clone()), - } - } - } - for batch in renders_missing.chunks(6) { - missing.push(json!({"kind": "nativeRender", "frame": frame_id, "width": width, "nodeIds": batch, - "why": "No native-node PNG at scale 1 for these sections or assets, so their visual comparison cannot run.", - "call": {"tool": "devup_figma_export", "arguments": {"url": format!(""), "outputs": ["tsx"], - "assetRequests": batch.iter().map(|id| json!({"nodeId": id, "renderMode": "node", "format": "png", "scale": 1})).collect::>()}}})); - } - } - let room = MAX_CHECKS.saturating_sub(checks.len() + layout.len() + 3); - if parts.len() > room { - notes.push(format!( - "{} card and child position checks left out: a screen holds at most {MAX_CHECKS} checks.", - parts.len() - room - )); - parts.truncate(room); - } - checks.extend(layout); - checks.extend(parts); - for (width, id) in &target.hidden { - checks.push(check( - json!({"kind": "hidden", "label": format!("{id} hidden at {width}"), - "subject": node(id), "nodeId": id, "widths": [width]}), - )); - } - checks.truncate(MAX_CHECKS - 3); - checks.push(check(json!({"kind": "interaction", "label": "hover on every link and button", "state": "hover", "widths": [widest]}))); - checks.push(check(json!({"kind": "interaction", "label": "focus on every link and button", "state": "focus", "widths": [widest]}))); - match target.next_url { - Some(next) => checks.push(check( - json!({"kind": "view-transition", "label": format!("view transition to {next}"), - "subject": {"css": format!("a[href=\"{next}\"] >> nth=0")}, "widths": [widest]}), - )), - None => notes.push("A single URL: no navigation to prove a view transition on.".to_owned()), - } - let first = target - .frames - .first() - .map(|(_, id, _)| id.clone()) - .unwrap_or_default(); - let screen = ScreenInput { - id: target.screen.clone(), - route_path: Some(target.url.to_owned()), - component_path: target.file.map(str::to_owned), - steps: Vec::new(), - expect: None, - checks, - figma: (!first.is_empty()).then(|| { - serde_json::from_value( - json!({"nodeId": first, "drawnWidths": drawn, "frames": frame_refs}), - ) - .expect("figma anchor from the schema") - }), - }; - let mut allowed = vec![origin]; - allowed.extend(FONT_ORIGINS.iter().map(|origin| (*origin).to_owned())); - let mut plan = PlanInput { - base_url: base_url.to_owned(), - allowed_origins: allowed, - viewports, - screens: vec![screen], - readiness: None, - fonts: fonts(sources, &target.frames), - locale: Some("en-US".to_owned()), - timezone: Some("UTC".to_owned()), - step_timeout_ms: None, - }; - runtime_audit::order_checks(&mut plan); - runtime_audit::validate_plan(&plan)?; - Ok(UrlPlan { - plan, - missing, - notes, - }) -} - -fn viewport(width: u32, height: u32) -> ViewportInput { - ViewportInput { - width, - height, - device_scale_factor: 1.0, - mobile: false, - touch: false, - } -} - -/// Size and position against the frame, exact: the executor allows only -/// half a pixel of rounding and takes no tolerance. -fn bounds_check( - name: &str, - subject: &str, - container: &str, - container_box: Bounds, - subject_box: Bounds, - width: u32, -) -> CheckInput { - let round = |value: f64| (value * 1000.0).round() / 1000.0; - check( - json!({"kind": "bounds", "label": format!("{name} size and position"), - "subject": node_at(width, subject), "container": node_at(width, container), "nodeId": subject, - "expected": {"x": round(subject_box.x - container_box.x), "y": round(subject_box.y - container_box.y), - "width": round(subject_box.width), "height": round(subject_box.height)}, - "widths": [width]}), - ) -} - -/// The font families the frames' text uses: they must be loaded before a -/// text box is measured, and a missing one is an environment error rather -/// than a layout failure. -fn fonts(sources: &Sources, frames: &[(u32, String, Option)]) -> Vec { - let mut families = BTreeSet::new(); - for (_, frame, _) in frames { - let Some((_, payload)) = sources.frame(frame) else { - continue; - }; - let mut stack = vec![frame.clone()]; - let mut seen = BTreeSet::new(); - while let Some(id) = stack.pop() { - if !seen.insert(id.clone()) { - continue; - } - let Some(node) = payload.snapshot.nodes.get(&id) else { - continue; - }; - let view = node.typed_view(); - if let Some(family) = view - .value("fontName") - .and_then(|font| font.get("family")) - .and_then(Value::as_str) - { - families.insert(family.to_owned()); - } - stack.extend(view.child_ids().map(str::to_owned)); - } - } - families.into_iter().take(20).collect() -} - -fn capture( - name: &str, - subject: &str, - scope: &str, - artifact: &str, - asset: &str, - width: u32, - threshold: f64, -) -> CheckInput { - check( - json!({"kind": "capture", "label": format!("{name} against its Figma render"), - "subject": node_at(width, subject), "nodeId": subject, "widths": [width], - "reference": {"scope": scope, "artifactId": artifact, "assetId": asset, "threshold": threshold}}), - ) -} - -pub(super) fn slug(url: &str) -> String { - let trimmed = url.trim_matches('/'); - if trimmed.is_empty() { - return "home".to_owned(); - } - trimmed - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c.to_ascii_lowercase() - } else { - '-' - } - }) - .collect() -} - -/// What auditPlan was asked for. -pub(super) struct AuditRequest<'a> { - pub root: &'a str, - pub base_url: &'a str, - pub base_urls: &'a BTreeMap, - /// Only these URLs; empty means every confirmed URL. - pub only: &'a [String], - pub artifact_ids: &'a [String], - pub thresholds: &'a VisualThresholds, -} - -/// Writes every URL's plan, the runner once, and returns what to run. -pub(super) async fn write( - plan: &Value, - request: &AuditRequest<'_>, - policy: &OutputPolicy, - store: &ArtifactStore, -) -> Result { - let AuditRequest { - root, - base_url, - base_urls, - only, - artifact_ids, - thresholds, - } = *request; - let (sources, expired) = Sources::load(artifact_ids, store).await; - let routes = plan["routes"] - .as_array() - .into_iter() - .flatten() - .filter(|route| route["implementation"] == "page" && route["url"].is_string()) - .collect::>(); - let records = super::export_record::records(Path::new(root)); - // Every route as the concrete URLs it is audited at: a dynamic route at - // the URLs its params, the site's links or its Figma frame give. - let mut concrete = Vec::<(usize, String, Option)>::new(); - let mut not_audited = Vec::new(); - for (index, route) in routes.iter().enumerate() { - let url = route["url"].as_str().unwrap_or_default(); - if !only.is_empty() && !only.iter().any(|wanted| wanted == url) { - continue; - } - if !url.contains('[') { - concrete.push((index, url.to_owned(), None)); - continue; - } - let resolved = dynamic_urls(root, route, &plan["audit"]["links"], &sources); - if resolved.urls.is_empty() { - not_audited.push(json!({"url": url, "why": resolved.why})); - continue; - } - for sample in resolved.urls { - concrete.push(( - index, - sample, - Some(json!({"pattern": url, "from": resolved.source, - "assumption": resolved.assumption})), - )); - } - } - let urls = concrete - .iter() - .map(|(_, url, _)| url.as_str()) - .collect::>(); - let several_apps = routes - .iter() - .map(|route| route["app"].as_str().unwrap_or_default()) - .collect::>() - .len() - > 1; - let heights = plan["screens"] - .as_array() - .into_iter() - .flatten() - .flat_map(|screen| screen["frames"].as_array().into_iter().flatten()) - .filter_map(|frame| { - Some(( - frame["nodeId"].as_str()?.to_owned(), - frame["height"].as_f64(), - )) - }) - .collect::>(); - let runner = Path::new(root).join(RUNNER_DIR); - let mut template = (!runner.join("run.mjs").exists()).then(|| runner.display().to_string()); - let template_state = if template.is_some() { - "written" - } else if std::fs::read(runner.join("run.mjs")).ok().as_deref() - == Some(include_bytes!("../../../../harness/runtime-audit/run.mjs").as_slice()) - { - "current" - } else { - "stale: delete .devup/runtime-audit/runner and run auditPlan again to get this server's runner" - }; - let mut written = Vec::new(); - for (index, url, dynamic) in &concrete { - let route = routes[*index]; - let url = url.as_str(); - let app = route["app"].as_str().unwrap_or_default(); - let screen = if several_apps && !app.is_empty() { - format!("{}-{}", slug(app), slug(url)) - } else { - slug(url) - }; - let frames = route["nodeIds"] - .as_array() - .into_iter() - .flatten() - .zip(route["drawnWidths"].as_array().into_iter().flatten()) - .filter_map(|(node, width)| { - let node = node.as_str()?.to_owned(); - let height = heights.get(&node).copied().flatten(); - Some((u32::try_from(width.as_u64()?).ok()?, node, height)) - }) - .collect::>(); - let position = urls.iter().position(|candidate| *candidate == url); - let next_url = position - .filter(|_| urls.len() > 1) - .map(|index| urls[(index + 1) % urls.len()]); - let hidden = hidden_elements(&records, &frames); - let target = Target { - url, - screen: screen.clone(), - file: route["file"].as_str(), - frames, - next_url, - hidden, - }; - let base = base_urls.get(app).map_or(base_url, String::as_str); - let built = url_plan(&target, base, &sources, thresholds)?; - let plan_path = Path::new(root) - .join(AUDIT_DIR) - .join(&screen) - .join("plan.json"); - let report_dir = Path::new(root).join(AUDIT_DIR).join(&screen).join("report"); - let counts = built.plan.screens[0].checks.iter().fold( - BTreeMap::::new(), - |mut counts, check| { - *counts.entry(check.kind.clone()).or_default() += 1; - counts - }, - ); - let viewports = built - .plan - .viewports - .iter() - .map(|viewport| json!({"width": viewport.width, "height": viewport.height})) - .collect::>(); - let created = runtime_audit::create_plan( - built.plan, - policy, - Some(&plan_path.to_string_lossy()), - template.take().as_deref(), - store, - ) - .await?; - written.push(json!({ - "url": url, - "screen": screen, - "planPath": created["writtenTo"], - "planId": created["planId"], - "reportDirectory": report_dir.display().to_string(), - "viewports": viewports, - "checks": counts, - "missingReferences": built.missing, - "notes": built.notes, - "dynamic": dynamic, - "run": ["node", "run.mjs", "--plan", plan_path.display().to_string(), "--out", report_dir.display().to_string()], - })); - } - let missing = written - .iter() - .flat_map(|plan| { - plan["missingReferences"] - .as_array() - .cloned() - .unwrap_or_default() - }) - .collect::>(); - let strip = production_strip(&routes, root, policy)?; - Ok(json!({ - "plans": written, - "notAudited": not_audited, - "productionStrip": strip, - "missingReferences": missing, - "expiredArtifacts": expired, - "runner": {"directory": runner.display().to_string(), "state": template_state, - "install": [["npm", "ci"], ["npx", "playwright", "install", "chromium"]], - "ifInstallFails": "Record it with devup_screen_plan action status, auditPlanPath and toolingUnavailable: the URL's audit stays inconclusive and the work goes on."}, - "nodeIdentity": "Sections, cards and assets are found by data-figma-node-id, which devup_figma_export writes into a project's TSX. A check whose element is missing reports missingNodes; re-export with projectRoot.", - "nextAction": "For each URL in order: export what missingReferences names and run auditPlan again, start the app, run the plan, then call devup_screen_plan action status with auditPlanPath and auditReportPath (report/report.json). Run each visualComparisons call, pass the results back as visualResults, fix every finding, and repeat until the URL is done.", - })) -} - -/// Elements of a merged page that a route's drawn widths do not show, from -/// the export records: each must be hidden at those widths. -fn hidden_elements(records: &[Value], frames: &[(u32, String, Option)]) -> Vec<(u32, String)> { - let drawn = frames - .iter() - .map(|(width, _, _)| *width) - .collect::>(); - let roots = frames - .iter() - .map(|(_, id, _)| id.as_str()) - .collect::>(); - let mut hidden = Vec::new(); - for record in records { - let elements = record["responsive"].as_array().cloned().unwrap_or_default(); - let ours = elements.iter().any(|element| { - element["ids"] - .as_array() - .into_iter() - .flatten() - .any(|id| id["nodeId"].as_str().is_some_and(|id| roots.contains(id))) - }); - if !ours { - continue; - } - for element in &elements { - let Some(widest) = element["ids"][0]["nodeId"].as_str() else { - continue; - }; - for width in element["hiddenAt"].as_array().into_iter().flatten() { - if let Some(width) = width.as_u64().and_then(|width| u32::try_from(width).ok()) - && drawn.contains(&width) - { - hidden.push((width, widest.to_owned())); - } - } - } - } - hidden -} - -/// The concrete URLs a dynamic route is audited at, and where they came from. -pub(super) struct Dynamic { - pub urls: Vec, - pub source: &'static str, - pub why: String, - pub assumption: Option, -} - -/// `[slug]` -> ("slug", false); `[...slug]` and `[[...slug]]` -> ("slug", true). -fn params(pattern: &str) -> Vec<(String, bool)> { - pattern - .split('/') - .filter(|segment| segment.starts_with('[') && segment.ends_with(']')) - .map(|segment| { - let inner = segment.trim_matches(|c| c == '[' || c == ']'); - match inner.strip_prefix("...") { - Some(name) => (name.to_owned(), true), - None => (inner.to_owned(), false), - } - }) - .collect() -} - -fn fill(pattern: &str, values: &BTreeMap) -> Option { - let mut out = Vec::new(); - for segment in pattern.split('/') { - if segment.starts_with('[') && segment.ends_with(']') { - let inner = segment.trim_matches(|c| c == '[' || c == ']'); - out.push(values.get(inner.trim_start_matches("...")).cloned()?); - } else { - out.push(segment.to_owned()); - } - } - Some(out.join("/")) -} - -/// Every literal value of `key` in `text`: `key: 'a'`, `"key": "a"`, or -/// `key: ['a', 'b']` (joined with `/` for a catch-all). -pub(super) fn values_of(text: &str, key: &str) -> Vec { - let mut found = Vec::new(); - let bytes = text.as_bytes(); - let mut from = 0; - while let Some(at) = text[from..].find(key) { - let start = from + at; - from = start + key.len(); - let before = start.checked_sub(1).map(|i| bytes[i] as char); - if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') { - continue; - } - let mut rest = text[from..].trim_start_matches(['"', '\'']).trim_start(); - let Some(after) = rest.strip_prefix(':') else { - continue; - }; - rest = after.trim_start(); - let quoted = |rest: &str| -> Option<(String, usize)> { - let quote = rest - .chars() - .next() - .filter(|c| matches!(c, '"' | '\'' | '`'))?; - let end = rest[1..].find(quote)?; - Some((rest[1..=end].to_owned(), end + 2)) - }; - if let Some((value, _)) = quoted(rest) { - found.push(value); - } else if let Some(list) = rest.strip_prefix('[') { - let end = list.find(']').unwrap_or(list.len()); - let parts = list[..end] - .split(',') - .filter_map(|part| quoted(part.trim()).map(|(value, _)| value)) - .collect::>(); - if !parts.is_empty() { - found.push(parts.join("/")); - } - } - } - found.dedup(); - found -} - -/// Local modules a page imports: `./x`, `../x` or `@/x`, with the names -/// imported from each. -fn local_imports(source: &str) -> Vec<(String, String)> { - let mut found = Vec::new(); - for line in source.lines() { - let line = line.trim(); - let Some(rest) = line.strip_prefix("import ") else { - continue; - }; - let Some((names, module)) = rest.rsplit_once(" from ") else { - continue; - }; - let module = module - .trim() - .trim_end_matches(';') - .trim_matches(['"', '\'']); - if module.starts_with("./") || module.starts_with("../") || module.starts_with("@/") { - found.push((names.to_owned(), module.to_owned())); - } - } - found -} - -fn read_module(base: &Path, module: &str) -> Option { - for suffix in [ - "", - ".ts", - ".tsx", - ".js", - ".mjs", - ".json", - "/index.ts", - "/index.js", - ] { - let path = base.join(format!("{module}{suffix}")); - if path.is_file() - && std::fs::metadata(&path).is_ok_and(|meta| meta.len() <= 512 * 1024) - && let Ok(text) = std::fs::read_to_string(&path) - { - return Some(text); - } - } - None -} - -/// `generateStaticParams` in the route's page: literal params in its body, -/// else the param's values in a local data file it reads. -pub(super) fn static_params( - root: &Path, - app_dir: &str, - page_file: &str, - pattern: &str, -) -> Vec { - let page = root.join(page_file); - let Ok(source) = std::fs::read_to_string(&page) else { - return Vec::new(); - }; - let Some(at) = source.find("generateStaticParams") else { - return Vec::new(); - }; - let body = &source[at..]; - let body = body[1..] - .find("\nexport ") - .map_or(body, |end| &body[..=end]); - let names = params(pattern); - let collect = |text: &str| { - let lists = names - .iter() - .map(|(name, _)| values_of(text, name)) - .collect::>(); - let count = lists.iter().map(Vec::len).min().unwrap_or(0); - (0..count) - .filter_map(|index| { - let values = names - .iter() - .zip(&lists) - .map(|((name, _), list)| (name.clone(), list[index].clone())) - .collect::>(); - fill(pattern, &values) - }) - .collect::>() - }; - let literal = collect(body); - if !literal.is_empty() { - return literal; - } - let page_dir = page.parent().unwrap_or(root); - for (imported, module) in local_imports(&source) { - let used = imported - .split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$')) - .filter(|name| !name.is_empty() && *name != "as") - .any(|name| body.contains(name)); - if !used { - continue; - } - let text = match module.strip_prefix("@/") { - Some(rest) => read_module(&root.join(app_dir).join("src"), rest) - .or_else(|| read_module(&root.join(app_dir), rest)), - None => read_module(page_dir, &module), - }; - if let Some(text) = text { - let from_data = collect(&text); - if !from_data.is_empty() { - return from_data; - } - } - } - Vec::new() -} - -/// The first text of a frame, depth first: a detail page's title. -fn first_text(sources: &Sources, frame: &str) -> Option { - let (_, payload) = sources.frame(frame)?; - let mut stack = vec![frame.to_owned()]; - while let Some(id) = stack.pop() { - let node = payload.snapshot.nodes.get(&id)?; - let view = node.typed_view(); - if let Some(text) = view - .string("characters") - .filter(|text| !text.trim().is_empty()) - { - return Some(text.to_owned()); - } - let mut children = view.child_ids().map(str::to_owned).collect::>(); - children.reverse(); - stack.extend(children); - } - None -} - -fn sample_segment(text: &str) -> String { - let words = text - .split(|c: char| !c.is_alphanumeric()) - .filter(|word| !word.is_empty()) - .take(6) - .map(str::to_lowercase) - .collect::>(); - if words.is_empty() { - "sample".to_owned() - } else { - words.join("-") - } -} - -/// A dynamic route's concrete URLs: its `generateStaticParams`, else the -/// links the audited pages hold into it, else a sample from its Figma detail -/// frame (an assumption). At most ten. -pub(super) fn dynamic_urls(root: &str, route: &Value, links: &Value, sources: &Sources) -> Dynamic { - let pattern = route["url"].as_str().unwrap_or_default(); - let app_dir = route["app"] - .as_str() - .filter(|app| !app.is_empty()) - .unwrap_or("."); - let cap = |mut urls: Vec| { - urls.sort(); - urls.dedup(); - urls.truncate(MAX_DYNAMIC_URLS); - urls - }; - if let Some(file) = route["file"].as_str() { - let found = static_params(Path::new(root), app_dir, file, pattern); - if !found.is_empty() { - return Dynamic { - urls: cap(found), - source: "generateStaticParams", - why: String::new(), - assumption: None, - }; - } - } - let linked = links - .as_object() - .into_iter() - .flatten() - .flat_map(|(_, hrefs)| hrefs.as_array().cloned().unwrap_or_default()) - .filter_map(|href| href.as_str().map(str::to_owned)) - .filter(|href| super::navigation::matches_route(pattern, href)) - .collect::>(); - if !linked.is_empty() { - return Dynamic { - urls: cap(linked), - source: "links on audited pages", - why: String::new(), - assumption: None, - }; - } - let frame = route["nodeIds"][0].as_str().unwrap_or_default(); - if let Some(text) = first_text(sources, frame) { - let segment = sample_segment(&text); - let values = params(pattern) - .into_iter() - .map(|(name, _)| (name, segment.clone())) - .collect::>(); - if let Some(url) = fill(pattern, &values) { - return Dynamic { - assumption: Some(format!( - "{pattern} audited at {url}: the sample comes from the detail frame {frame}'s title \"{text}\"; the app must serve it." - )), - urls: vec![url], - source: "Figma detail frame", - why: String::new(), - }; - } - } - Dynamic { - urls: Vec::new(), - source: "none", - why: "No generateStaticParams with literal or local-data params, no link to it on an audited page (run the audit once and auditPlan again), and no detail frame text to sample.".to_owned(), - assumption: None, - } -} - -const STRIP_PLUGIN: &[u8] = - include_bytes!("../../../../harness/runtime-audit/strip-figma-node-ids.mjs"); -const STRIP_FILE: &str = "vite-plugin-strip-figma-node-ids.mjs"; - -/// Puts the plugin that strips the audit attributes from production builds -/// next to each app's vite.config, once, and says whether the config uses -/// it yet. -fn production_strip( - routes: &[&Value], - root: &str, - policy: &OutputPolicy, -) -> Result, DevupError> { - let apps = routes - .iter() - .map(|route| { - route["app"] - .as_str() - .filter(|app| !app.is_empty()) - .unwrap_or(".") - .to_owned() - }) - .collect::>(); - let mut out = Vec::new(); - for app in apps { - let dir = Path::new(root).join(&app); - let file = dir.join(STRIP_FILE); - let written = if file.exists() { - false - } else { - let target = policy.resolve(&file.to_string_lossy())?; - let mut transaction = super::output::OutputTransaction::new(); - transaction.stage("stripPlugin", target, STRIP_PLUGIN)?; - transaction.commit()?; - true - }; - let config = [ - "vite.config.ts", - "vite.config.mts", - "vite.config.js", - "vite.config.mjs", - ] - .iter() - .map(|name| dir.join(name)) - .find(|path| path.is_file()); - let wired = config - .as_ref() - .and_then(|path| std::fs::read_to_string(path).ok()) - .is_some_and(|text| text.contains(STRIP_FILE)); - out.push(json!({ - "app": app, - "plugin": file.display().to_string(), - "written": written, - "viteConfig": config.map(|path| path.display().to_string()), - "wired": wired, - "add": format!("import stripFigmaNodeIds from './{STRIP_FILE}' // then plugins: [stripFigmaNodeIds(), vinext()]"), - "nextCli": "On the next CLI instead: compiler.reactRemoveProperties: process.env.DEVUP_AUDIT === '1' ? false : { properties: ['^data-figma-node-ids?$'] }", - "audit": "Audit against `bun run dev` or an audit build: DEVUP_AUDIT=1 bun run build.", - "verify": "After `bun run build`, `grep -rl data-figma-node-id dist` (or the app's build output) must find nothing.", - })); - } - Ok(out) -} - -/// One devup_visual_compare result for a comparison status returned. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct VisualResultInput { - /// The visualComparisons id. - pub id: String, - /// The comparison's verdict. - #[schemars(extend("enum" = ["pass", "fail", "inconclusive"]))] - pub verdict: String, - /// visual.changedRatio from the result. - #[serde(default)] - pub changed_ratio: Option, - /// The diffPath the result wrote. - #[serde(default)] - pub diff_path: Option, - /// Why it was inconclusive, when it was. - #[serde(default)] - pub reason: Option, -} - -/// The order a URL's failures are worked in: a page that does not render -/// first, then overflow, then layout, then pixels, then states. -fn class_rank(class: &str) -> usize { - [ - "runtime-error", - "overflow", - "layout-position", - "layout-size", - "asset-mismatch", - "section-visual", - "page-visual", - "interaction", - "view-transition", - ] - .iter() - .position(|candidate| *candidate == class) - .unwrap_or(9) -} - -/// Folds one audit run (and the visual results for it) into the plan's -/// finding records. A finding counts a failed run once per new evidence - -/// a new report for a check, a new screenshot for a comparison - so calling -/// status twice on one report does not burn an attempt. After -/// `max_fix_attempts` fix-and-rerun rounds that still fail it is -/// unresolved, with its evidence, and leaves the work queue. -pub(super) fn record_round( - plan: &mut Value, - audit: &Value, - report_sha: &str, - visual: &[VisualResultInput], - node_files: &BTreeMap, - max_fix_attempts: u32, -) -> Value { - let audited = audit["screenTable"] - .as_array() - .into_iter() - .flatten() - .filter_map(|row| { - Some(( - row["routePath"].as_str()?.to_owned(), - row["width"].as_u64()?, - )) - }) - .collect::>(); - let comparisons = audit["visualComparisons"] - .as_array() - .cloned() - .unwrap_or_default(); - let mut current = Vec::<(Value, String)>::new(); - for finding in audit["findings"].as_array().into_iter().flatten() { - current.push((finding.clone(), report_sha.to_owned())); - } - let mut compared = BTreeSet::new(); - let mut inconclusive = Vec::new(); - for result in visual { - let Some(comparison) = comparisons.iter().find(|c| c["id"] == result.id.as_str()) else { - inconclusive.push( - json!({"id": result.id, "why": "no comparison with this id in this audit run"}), - ); - continue; - }; - compared.insert(result.id.clone()); - match result.verdict.as_str() { - "fail" => { - let threshold = comparison["call"]["arguments"]["threshold"].as_f64(); - let reason = match (result.changed_ratio, threshold) { - (Some(ratio), Some(threshold)) => format!( - "{:.1}% of pixels differ from the Figma render (allowed {:.1}%)", - ratio * 100.0, - threshold * 100.0 - ), - _ => "differs from the Figma render".to_owned(), - }; - current.push(( - json!({ - "id": result.id, - "class": comparison["class"], - "screen": comparison["screen"], - "routePath": comparison["routePath"], - "width": comparison["viewport"]["width"], - "kind": "visual", - "label": comparison["label"], - "nodeId": comparison["nodeId"], - "codeFile": comparison["codeFile"], - "reason": reason, - "fixHint": comparison["ifItFails"]["fixHint"], - "evidence": {"screenshot": comparison["screenshot"]["path"], "diff": result.diff_path, - "reference": comparison["call"]["arguments"]["reference"]}, - }), - comparison["screenshot"]["sha256"] - .as_str() - .unwrap_or_default() - .to_owned(), - )); - } - "inconclusive" => inconclusive.push(json!({"id": result.id, "class": comparison["class"], - "why": result.reason.clone().unwrap_or_else(|| "the comparison was inconclusive".to_owned())})), - _ => {} - } - } - let pending = comparisons - .iter() - .filter(|comparison| { - !compared.contains(comparison["id"].as_str().unwrap_or_default()) - && comparison["blockers"].as_array().is_none_or(Vec::is_empty) - }) - .map(|comparison| comparison["id"].clone()) - .collect::>(); - // A converter bug is reported once and kept, whatever later runs show. - for (finding, _) in ¤t { - if finding["class"] == "converter-bug" - && let Some(node) = finding["nodeId"].as_str() - { - plan["audit"]["converterBugs"][node] = json!({ - "nodeId": node, "routePath": finding["routePath"], "width": finding["width"], - "reason": finding["reason"], "repro": finding["evidence"]["repro"], - "codeFile": finding["codeFile"], - }); - } - } - let records = &mut plan["audit"]["findings"]; - if !records.is_object() { - *records = json!({}); - } - let records = records.as_object_mut().expect("an object"); - let failing = current - .iter() - .filter_map(|(finding, _)| finding["id"].as_str().map(str::to_owned)) - .collect::>(); - for (mut finding, key) in current { - let id = finding["id"].as_str().unwrap_or_default().to_owned(); - if let Some((_, file)) = finding["nodeId"] - .as_str() - .and_then(|node| node_files.get(node)) - { - finding["codeFile"] = json!(file); - } - let record = records - .entry(id) - .or_insert_with(|| json!({"failedRuns": 0})); - if record["lastEvidence"] != key.as_str() { - record["failedRuns"] = json!(record["failedRuns"].as_u64().unwrap_or(0) + 1); - record["lastEvidence"] = json!(key); - } - let fix_attempts = record["failedRuns"].as_u64().unwrap_or(1).saturating_sub(1); - for field in [ - "class", - "screen", - "routePath", - "width", - "kind", - "label", - "nodeId", - "codeFile", - "reason", - "fixHint", - "evidence", - ] { - record[field] = finding[field].clone(); - } - record["fixAttempts"] = json!(fix_attempts); - // A deterministic result does not change by retrying: a converter - // bug leaves the fix loop at once and never spends attempts. - record["status"] = json!(if finding["class"] == "converter-bug" { - "converter-bug" - } else if fix_attempts >= u64::from(max_fix_attempts) { - "unresolved" - } else { - "open" - }); - } - // Anything this run measured again and did not see fail is fixed. A - // comparison is fixed only by a passing result for it. - let passed = visual - .iter() - .filter(|result| result.verdict == "pass") - .map(|result| result.id.as_str()) - .collect::>(); - for (id, record) in records.iter_mut() { - if failing.contains(id) || record["status"] == "resolved" { - continue; - } - let measured_again = if record["kind"] == "visual" { - passed.contains(id.as_str()) - } else { - record["routePath"] - .as_str() - .zip(record["width"].as_u64()) - .is_some_and(|(route, width)| audited.contains(&(route.to_owned(), width))) - }; - if measured_again { - record["status"] = json!("resolved"); - } - } - json!({"pendingComparisons": pending, "inconclusiveComparisons": inconclusive}) -} - -/// The open and unresolved findings in work order, most basic first. -pub(super) fn findings_by_status(plan: &Value, status: &str) -> Vec { - let mut list = plan["audit"]["findings"] - .as_object() - .into_iter() - .flatten() - .filter(|(_, record)| record["status"] == status) - .map(|(id, record)| { - let mut record = record.clone(); - record["id"] = json!(id); - record - }) - .collect::>(); - list.sort_by_key(|record| { - ( - record["routePath"].as_str().unwrap_or_default().to_owned(), - class_rank(record["class"].as_str().unwrap_or_default()), - std::cmp::Reverse(record["width"].as_u64().unwrap_or(0)), - ) - }); - list -} - -/// Per URL: what to do next, blocked items last. Nothing here waits on -/// the user; a blocked item waits on a pending decision taken at the end. -pub(super) fn work_queue( - plan: &Value, - rows: &[Value], - pending_comparisons: &[Value], -) -> Vec { - let open = findings_by_status(plan, "open"); - let decisions = plan["pendingDecisions"] - .as_array() - .cloned() - .unwrap_or_default(); - let audited_urls = plan["audit"]["plans"] - .as_array() - .into_iter() - .flatten() - .filter_map(|entry| entry["url"].as_str()) - .collect::>(); - rows.iter() - .map(|row| { - let url = row["url"].as_str().unwrap_or_default(); - let screen = audit_screen(plan, url); - let mut items = Vec::new(); - let mut blocked = Vec::new(); - for decision in &decisions { - if decision["status"] == "open" - && decision["about"].as_str().is_some_and(|about| about.contains(url)) - { - blocked.push(json!({"do": "blocked", "on": decision["id"], - "why": decision["question"], "meanwhile": "work the rest; it is taken with assume at the end"})); - } - } - if row["implemented"] != "yes" { - items.push(json!({"do": "build", "file": row["file"]})); - } - if let Some(reason) = plan["audit"]["inconclusive"][url].as_str() { - items.push(json!({"do": "audit inconclusive", "why": reason, - "how": "install the runner when it can be; the rest of the work goes on"})); - } else if !audited_urls.contains(url) { - items.push(json!({"do": "audit", "how": "devup_screen_plan action auditPlan, run the plan, then status"})); - } else if row["widths"] - .as_array() - .into_iter() - .flatten() - .any(|width| width["result"] == "not-run") - { - items.push(json!({"do": "run the audit", "why": "some widths have no audit result yet"})); - } - for finding in open.iter().filter(|finding| finding["routePath"] == url) { - items.push(json!({"do": "fix", "finding": finding["id"], "class": finding["class"], - "width": finding["width"], "codeFile": finding["codeFile"], "fixHint": finding["fixHint"], - "attempt": finding["fixAttempts"].as_u64().unwrap_or(0) + 1})); - } - let comparisons = pending_comparisons - .iter() - .filter_map(Value::as_str) - .filter(|id| screen.as_deref().is_some_and(|screen| id.starts_with(&format!("{screen}|")))) - .count(); - if comparisons > 0 { - items.push(json!({"do": "compare", "count": comparisons, - "how": "run each visualComparisons call and pass the results back as visualResults"})); - } - let done = items.is_empty(); - items.extend(blocked); - let reported = plan["audit"]["converterBugs"] - .as_object() - .into_iter() - .flatten() - .filter(|(_, bug)| bug["routePath"] == url) - .map(|(_, bug)| json!({"converterBug": bug["nodeId"], "repro": bug["repro"], - "note": "reported, not retried; a local override needs a comment naming the repro"})) - .collect::>(); - json!({"url": url, "items": items, "reported": reported, "done": done}) - }) - .collect() -} - -fn audit_screen(plan: &Value, url: &str) -> Option { - plan["audit"]["plans"] - .as_array()? - .iter() - .find(|entry| entry["url"] == url)?["screen"] - .as_str() - .map(str::to_owned) -} - -/// The report the job ends with: every URL at every width by health, -/// geometry, the three visual levels and interaction, with screenshots and -/// diffs, then the unresolved findings with their evidence and the pending -/// decisions taken. The plan's own lists (assumptions, design fixes, -/// exclusions, defaults) and the dependency line follow. -pub(super) fn final_report(plan: &Value, rows: &[Value], trailing: &str) -> String { - let cell = |value: &Value| match value { - Value::Null => "-".to_owned(), - Value::String(text) => text.replace('|', "\\|"), - other => other.to_string(), - }; - let mut out = String::from( - "| URL | width | health | geometry | drift / converter bugs | page | section | asset | interaction | screenshot | diffs |\n|---|---|---|---|---|---|---|---|---|---|---|\n", - ); - let records = plan["audit"]["findings"].as_object(); - for row in rows { - let url = row["url"].as_str().unwrap_or_default(); - for width in row["widths"].as_array().into_iter().flatten() { - let columns = &width["columns"]; - let diffs = records - .into_iter() - .flatten() - .filter(|(_, record)| { - record["routePath"] == url - && record["width"] == width["width"] - && record["status"] != "resolved" - }) - .filter_map(|(_, record)| record["evidence"]["diff"].as_str()) - .collect::>(); - let count = |class: &str| { - records - .into_iter() - .flatten() - .filter(|(_, record)| { - record["routePath"] == url - && record["width"] == width["width"] - && record["class"] == class - && record["status"] != "resolved" - }) - .count() - }; - out.push_str(&format!( - "| {url} | {} | {} | {} | {} / {} | {} | {} | {} | {} | {} | {} |\n", - width["width"], - cell(&columns["health"]), - cell(&columns["geometry"]), - count("code-drift"), - count("converter-bug"), - cell(&columns["page"]), - cell(&columns["section"]), - cell(&columns["asset"]), - cell(&columns["interaction"]), - width["screenshots"] - .as_array() - .and_then(|shots| shots.first()) - .map_or("-".to_owned(), cell), - if diffs.is_empty() { - "-".to_owned() - } else { - diffs.join(", ") - }, - )); - } - } - let unresolved = findings_by_status(plan, "unresolved"); - out.push_str("\nUnresolved findings (after the fix-attempt budget):\n"); - if unresolved.is_empty() { - out.push_str("- none\n"); - } - for finding in &unresolved { - out.push_str(&format!( - "- {} at {}px, {} {}: {} (code: {}; screenshot: {}; diff: {})\n", - cell(&finding["routePath"]), - finding["width"], - cell(&finding["class"]), - cell(&finding["nodeId"]), - cell(&finding["reason"]), - cell(&finding["codeFile"]), - cell(&finding["evidence"]["screenshot"]), - cell(&finding["evidence"]["diff"]), - )); - } - out.push_str("\nConverter bugs (filed, not retried):\n"); - let bugs = plan["audit"]["converterBugs"] - .as_object() - .cloned() - .unwrap_or_default(); - if bugs.is_empty() { - out.push_str("- none\n"); - } - for bug in bugs.values() { - out.push_str(&format!( - "- {} at {}px, {}: {} (repro: {}; code: {})\n", - cell(&bug["routePath"]), - bug["width"], - cell(&bug["nodeId"]), - cell(&bug["reason"]), - cell(&bug["repro"]), - cell(&bug["codeFile"]), - )); - } - out.push_str("\nPending decisions:\n"); - let decisions = plan["pendingDecisions"] - .as_array() - .cloned() - .unwrap_or_default(); - if decisions.is_empty() { - out.push_str("- none\n"); - } - for decision in &decisions { - out.push_str(&format!( - "- {}: {} -> {} ({})\n", - cell(&decision["id"]), - cell(&decision["question"]), - cell(decision.get("taken").unwrap_or(&decision["recommended"])), - match decision["status"].as_str() { - Some("taken") => "taken at the end", - Some("left-unbuilt") => "destructive: left unbuilt", - _ => "still open", - } - )); - } - out.push_str(trailing); - let dependencies = &plan["dependencies"]; - out.push_str(&if dependencies.is_object() { - format!( - "\nDependencies: `{}` bumped {} packages; pins: {}.\n", - cell(&dependencies["command"]), - dependencies["bumped"], - dependencies["pins"] - .as_array() - .filter(|pins| !pins.is_empty()) - .map_or("none".to_owned(), |pins| pins - .iter() - .map(|pin| format!( - "{}@{} ({})", - cell(&pin["package"]), - cell(&pin["version"]), - cell(&pin["reason"]) - )) - .collect::>() - .join(", ")) - ) - } else { - "\nDependencies: the update step has not been recorded.\n".to_owned() - }); - out -} - -#[cfg(test)] -#[path = "audit_plan_tests.rs"] -mod tests; - -/// Geometry is exact, so a box that fails at a drawn width is classified by -/// cause: the code drifted from what the export wrote, or it did not and the -/// converter is wrong. A converter bug gets its repro written under -/// `.devup/converter-bugs/`. -pub(super) fn classify_geometry( - audit: &mut Value, - project_root: &str, - sources: &[String], - policy: &OutputPolicy, -) { - let Some(findings) = audit["findings"].as_array_mut() else { - return; - }; - for finding in findings { - if !matches!(finding["kind"].as_str(), Some("bounds" | "dimensions")) { - continue; - } - let Some(node) = finding["nodeId"].as_str().map(str::to_owned) else { - continue; - }; - match super::export_record::geometry_cause(Path::new(project_root), &node, sources) { - super::export_record::Cause::Drift { - file, - missing, - added, - } => { - finding["class"] = json!("code-drift"); - finding["codeFile"] = json!(file); - finding["evidence"]["drift"] = json!({"missing": missing, "added": added}); - finding["fixHint"] = json!(format!( - "{} Missing or changed: {}. Added layout props: {}.", - runtime_audit::fix_hint("code-drift", "bounds", &Value::Null), - if missing.is_empty() { - "none".to_owned() - } else { - missing.join(", ") - }, - if added.is_empty() { - "none".to_owned() - } else { - added.join(", ") - }, - )); - } - super::export_record::Cause::ConverterBug { - file, - record, - exported, - } => { - let width = finding["width"].clone(); - match super::export_record::write_repro( - policy, - project_root, - &node, - &width, - finding, - &file, - &record, - &exported, - ) { - Ok((path, _)) => { - finding["class"] = json!("converter-bug"); - finding["codeFile"] = json!(file); - finding["evidence"]["repro"] = json!(path); - finding["fixHint"] = json!(runtime_audit::fix_hint( - "converter-bug", - "bounds", - &Value::Null - )); - } - Err(error) => { - finding["evidence"]["reproError"] = json!(error.message); - } - } - } - super::export_record::Cause::Unknown(why) => { - finding["evidence"]["cause"] = json!(why); - } - } - } -} +// Source sections share this module to preserve privacy and test names. +include!("audit_plan/planning_and_capture.rs"); +include!("audit_plan/static_params.rs"); diff --git a/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs new file mode 100644 index 00000000..66e72d47 --- /dev/null +++ b/crates/devup-mcp/src/server/audit_plan/planning_and_capture.rs @@ -0,0 +1,977 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::Arc; + +use devup_mcp_figma::{AssetFormat, AssetStatus, CollectedPayload, DevupError, ErrorCode}; +use serde_json::{Value, json}; + +use super::artifacts::ArtifactStore; +use super::output::OutputPolicy; +use super::runtime_audit::{ + self, ASSET_THRESHOLD, CheckInput, PAGE_THRESHOLD, PlanInput, SECTION_THRESHOLD, ScreenInput, + ViewportInput, +}; + +pub(super) const AUDIT_DIR: &str = ".devup/runtime-audit"; +const RUNNER_DIR: &str = ".devup/runtime-audit/runner"; +const DEFAULT_WIDTHS: [u32; 4] = [1440, 1100, 858, 390]; +const DEFAULT_HEIGHT: u32 = 900; +const MAX_VIEWPORT_SIDE: u32 = 4096; +const MAX_VIEWPORTS: usize = 8; +const MAX_CHECKS: usize = 200; +/// Most concrete URLs audited for one dynamic route. +const MAX_DYNAMIC_URLS: usize = 10; +/// Font CDNs a page commonly loads; anything else outside the app is +/// blocked and recorded, which leaves the run inconclusive. +const FONT_ORIGINS: [&str; 3] = [ + "https://fonts.googleapis.com", + "https://fonts.gstatic.com", + "https://cdn.jsdelivr.net", +]; +static NAV_WORDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/audit-nav-words.json")).expect("vendored nav words") +}); + +fn invalid(message: impl Into) -> DevupError { + DevupError::new(ErrorCode::DevupInvalidInput, message, false) +} + +/// Changed-pixel ratios from `.devup/screen-plan.config.json` +/// `visualThresholds`: `page`, `section`, `asset`, and `nodes` keyed by Figma +/// node id for one asset that needs its own. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct VisualThresholds { + #[serde(default)] + pub page: Option, + #[serde(default)] + pub section: Option, + #[serde(default)] + pub asset: Option, + #[serde(default)] + pub nodes: BTreeMap, +} + +impl VisualThresholds { + fn for_node(&self, node_id: &str, scope: &str) -> f64 { + self.nodes + .get(node_id) + .copied() + .or(match scope { + "page" => self.page, + "section" => self.section, + _ => self.asset, + }) + .unwrap_or(match scope { + "page" => PAGE_THRESHOLD, + "section" => SECTION_THRESHOLD, + _ => ASSET_THRESHOLD, + }) + } +} + +/// The export artifacts the agent holds, searched for each frame's snapshot, +/// its referencePng and the native renders of its sections and assets. +pub(super) struct Sources { + payloads: Vec<(String, Arc)>, +} + +impl Sources { + pub(super) async fn load(ids: &[String], store: &ArtifactStore) -> (Self, Vec) { + let mut payloads = Vec::new(); + let mut missing = Vec::new(); + for id in ids { + match store.get(id).await { + Some(found) => payloads.push((id.clone(), found.payload)), + None => missing.push(json!({"artifactId": id, + "issue": "missing or expired; export the frame again and pass the new artifactId"})), + } + } + (Self { payloads }, missing) + } + + #[cfg(test)] + pub(super) fn from_payloads(payloads: Vec<(String, CollectedPayload)>) -> Self { + Self { + payloads: payloads + .into_iter() + .map(|(id, payload)| (id, Arc::new(payload))) + .collect(), + } + } + + /// The artifact whose snapshot holds the frame, preferring the one + /// exported for that frame and carrying its referencePng. + fn frame(&self, node_id: &str) -> Option<(&str, &CollectedPayload)> { + self.payloads + .iter() + .filter(|(_, payload)| payload.snapshot.nodes.contains_key(node_id)) + .max_by_key(|(_, payload)| { + ( + payload.target.node_id.as_deref() == Some(node_id), + payload.reference_png.is_some(), + ) + }) + .map(|(id, payload)| (id.as_str(), payload.as_ref())) + } + + fn reference_png(&self, node_id: &str) -> Option<&str> { + self.payloads + .iter() + .find(|(_, payload)| { + payload.target.node_id.as_deref() == Some(node_id) + && payload.reference_png.is_some() + }) + .map(|(id, _)| id.as_str()) + } + + /// A native-node PNG of the node at scale 1, the only render an element + /// capture at deviceScaleFactor 1 can be compared with pixel for pixel. + fn native_render(&self, node_id: &str) -> Option<(&str, &str)> { + self.payloads.iter().find_map(|(id, payload)| { + payload + .assets + .iter() + .find(|asset| { + asset.node_id == node_id + && asset.status == AssetStatus::Exported + && asset.format == Some(AssetFormat::Png) + && asset.mime_type.as_deref() == Some("image/png") + && asset.scale == Some(1.0) + && asset + .render_info + .as_ref() + .is_some_and(|info| info["representation"] == "native-node") + }) + .map(|asset| (id.as_str(), asset.asset_id.as_str())) + }) + } + + /// Nodes the export found as images or icons. A render the agent asked + /// for (sourceKind node) says nothing about what the node is. + fn asset_nodes(&self) -> BTreeSet { + self.payloads + .iter() + .flat_map(|(_, payload)| { + payload + .assets + .iter() + .filter(|asset| asset.source_kind != "node") + .map(|asset| asset.node_id.clone()) + }) + .collect() + } +} + +#[derive(Clone, Copy)] +struct Bounds { + x: f64, + y: f64, + width: f64, + height: f64, +} + +fn bounds(payload: &CollectedPayload, node_id: &str) -> Option { + let view = payload.snapshot.nodes.get(node_id)?.typed_view(); + let bbox = view.value("absoluteBoundingBox")?; + let number = |key: &str| bbox.get(key).and_then(Value::as_f64); + Some(Bounds { + x: number("x")?, + y: number("y")?, + width: number("width")?, + height: number("height")?, + }) +} + +fn visible_children(payload: &CollectedPayload, node_id: &str) -> Vec { + payload + .snapshot + .nodes + .get(node_id) + .map(|node| { + node.typed_view() + .child_ids() + .filter(|id| { + payload + .snapshot + .nodes + .get(*id) + .is_some_and(|child| child.typed_view().bool("visible") != Some(false)) + }) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() +} + +fn name_of(payload: &CollectedPayload, node_id: &str) -> String { + payload + .snapshot + .nodes + .get(node_id) + .and_then(|node| node.typed_view().name()) + .unwrap_or(node_id) + .to_owned() +} + +fn node(id: &str) -> Value { + json!({"css": format!("[data-figma-node-id=\"{id}\"]")}) +} + +/// The element a frame drawn at `width` made from `id`: on a single-frame +/// page it carries the id itself, on a page merged from one frame per width +/// the id is in its `data-figma-node-ids` map under that width. +fn node_at(width: u32, id: &str) -> Value { + json!({"css": format!( + "[data-figma-node-id=\"{id}\"], [data-figma-node-ids~=\"{width}:{id}\"]" + )}) +} + +fn check(value: Value) -> CheckInput { + serde_json::from_value(value).expect("audit checks are built from the schema") +} + +/// One confirmed URL and what the plan knows about it. +pub(super) struct Target<'a> { + pub url: &'a str, + pub screen: String, + pub file: Option<&'a str>, + /// (width, frame node id, frame height) for each drawn width. + pub frames: Vec<(u32, String, Option)>, + /// The URL a view-transition check follows a link to. + pub next_url: Option<&'a str>, + /// Elements of a merged page a drawn width does not show: (width, the + /// element's id at its widest width). + pub hidden: Vec<(u32, String)>, +} + +/// The runtime-audit plan for one URL, the references it could not find, +/// and what it left out. +pub(super) struct UrlPlan { + pub plan: PlanInput, + pub missing: Vec, + pub notes: Vec, +} + +pub(super) fn url_plan( + target: &Target<'_>, + base_url: &str, + sources: &Sources, + thresholds: &VisualThresholds, +) -> Result { + let origin = url::Url::parse(base_url) + .map_err(|_| invalid(format!("baseUrl {base_url} is not a URL.")))? + .origin() + .ascii_serialization(); + let mut notes = Vec::new(); + let mut missing = Vec::new(); + let mut viewports = Vec::::new(); + for (width, node_id, height) in &target.frames { + let height = height.map(|height| height.round() as u32); + let height = match height { + Some(height) if (1..=MAX_VIEWPORT_SIDE).contains(&height) => height, + Some(height) => { + notes.push(format!( + include_str!("../text/audit_plan-url_plan-a1031a26.md"), + MAX_VIEWPORT_SIDE = MAX_VIEWPORT_SIDE, + height = height, + node_id = node_id, + width = width + )); + DEFAULT_HEIGHT + } + None => DEFAULT_HEIGHT, + }; + if !viewports.iter().any(|viewport| viewport.width == *width) { + viewports.push(viewport(*width, height)); + } + } + for width in DEFAULT_WIDTHS { + if viewports.len() >= MAX_VIEWPORTS { + notes.push(format!( + "{width}px left out: a plan holds at most {MAX_VIEWPORTS} widths." + )); + continue; + } + if !viewports.iter().any(|viewport| viewport.width == width) { + viewports.push(viewport(width, DEFAULT_HEIGHT)); + } + } + viewports.sort_by_key(|viewport| std::cmp::Reverse(viewport.width)); + let widest = viewports.first().map_or(1440, |viewport| viewport.width); + let drawn = target + .frames + .iter() + .map(|(width, _, _)| *width) + .collect::>(); + + // Health and containment at every width. + let mut checks = vec![ + check(json!({"kind": "page-health", "label": "page health"})), + check(json!({"kind": "no-horizontal-overflow", "label": "no horizontal overflow"})), + check(json!({"kind": "no-clipped-text", "label": "no clipped text"})), + ]; + let mut layout = Vec::new(); + let mut parts = Vec::new(); + let mut frame_refs = Vec::new(); + let assets_everywhere = sources.asset_nodes(); + let mut nav_checked = false; + for (width, frame_id, _) in &target.frames { + let Some((artifact_id, payload)) = sources.frame(frame_id) else { + missing.push(json!({"kind": "frame", "nodeId": frame_id, "width": width, + "why": "No artifact passed holds this frame; section, asset and geometry checks for this width are missing.", + "call": {"tool": "devup_figma_export", "arguments": {"url": format!(""), "outputs": ["tsx", "referencePng"], "projectRoot": ""}}})); + frame_refs.push(json!({"width": width, "nodeId": frame_id})); + continue; + }; + let reference = sources.reference_png(frame_id); + if reference.is_none() { + missing.push(json!({"kind": "referencePng", "nodeId": frame_id, "width": width, + "why": format!("No referencePng for the frame drawn at {width}px, so its page comparison cannot run."), + "call": {"tool": "devup_figma_export", "arguments": {"url": format!(""), "outputs": ["referencePng"]}}})); + } + frame_refs.push( + json!({"width": width, "nodeId": frame_id, "artifactId": reference, + "threshold": thresholds.for_node(frame_id, "page")}), + ); + let _ = artifact_id; + let Some(frame_box) = bounds(payload, frame_id) else { + notes.push(format!( + "Frame {frame_id} has no absoluteBoundingBox; its geometry is not checked." + )); + continue; + }; + let sections = visible_children(payload, frame_id); + let mut renders_missing = Vec::new(); + for section in §ions { + let name = name_of(payload, section); + let is_asset = assets_everywhere.contains(section) + || payload.snapshot.nodes[section].typed_view().bool("isAsset") == Some(true); + if !nav_checked + && NAV_WORDS + .iter() + .any(|word| name.to_lowercase().contains(word)) + { + nav_checked = true; + checks.push(check( + json!({"kind": "contained", "label": "navigation not cut off", + "subject": {"css": "nav >> nth=0"}, "container": {"css": "body"}}), + )); + } + // Between the drawn widths only containment holds; at a drawn + // width the frame says exactly where the section sits. + if target.frames.len() == 1 { + layout.push(check( + json!({"kind": "contained", "label": format!("{name} inside the page"), + "subject": node(section), "container": node(frame_id), "nodeId": section}), + )); + } + if let Some(section_box) = bounds(payload, section) { + layout.push(bounds_check( + &name, + section, + frame_id, + frame_box, + section_box, + *width, + )); + if !is_asset { + for part in visible_children(payload, section) { + if let Some(part_box) = bounds(payload, &part) { + parts.push(bounds_check( + &name_of(payload, &part), + &part, + section, + section_box, + part_box, + *width, + )); + } + } + } + } + let scope = if is_asset { "asset" } else { "section" }; + match sources.native_render(section) { + Some((artifact, asset)) => layout.push(capture( + &name, + section, + scope, + artifact, + asset, + *width, + thresholds.for_node(section, scope), + )), + None => renders_missing.push(section.clone()), + } + } + // Assets below the sections, each against its own render and in + // its section. + let mut stack = sections + .iter() + .map(|section| (section.clone(), section.clone())) + .collect::>(); + let mut seen = BTreeSet::new(); + while let Some((id, section)) = stack.pop() { + if !seen.insert(id.clone()) { + continue; + } + for child in visible_children(payload, &id) { + let is_asset = assets_everywhere.contains(&child) + || payload.snapshot.nodes[&child].typed_view().bool("isAsset") == Some(true); + if !is_asset { + stack.push((child, section.clone())); + continue; + } + let name = name_of(payload, &child); + if let (Some(section_box), Some(asset_box)) = + (bounds(payload, §ion), bounds(payload, &child)) + { + layout.push(bounds_check( + &name, + &child, + §ion, + section_box, + asset_box, + *width, + )); + } + match sources.native_render(&child) { + Some((artifact, asset)) => layout.push(capture( + &name, + &child, + "asset", + artifact, + asset, + *width, + thresholds.for_node(&child, "asset"), + )), + None => renders_missing.push(child.clone()), + } + } + } + for batch in renders_missing.chunks(6) { + missing.push(json!({"kind": "nativeRender", "frame": frame_id, "width": width, "nodeIds": batch, + "why": "No native-node PNG at scale 1 for these sections or assets, so their visual comparison cannot run.", + "call": {"tool": "devup_figma_export", "arguments": {"url": format!(""), "outputs": ["tsx"], + "assetRequests": batch.iter().map(|id| json!({"nodeId": id, "renderMode": "node", "format": "png", "scale": 1})).collect::>()}}})); + } + } + let room = MAX_CHECKS.saturating_sub(checks.len() + layout.len() + 3); + if parts.len() > room { + notes.push(format!( + "{} card and child position checks left out: a screen holds at most {MAX_CHECKS} checks.", + parts.len() - room + )); + parts.truncate(room); + } + checks.extend(layout); + checks.extend(parts); + for (width, id) in &target.hidden { + checks.push(check( + json!({"kind": "hidden", "label": format!("{id} hidden at {width}"), + "subject": node(id), "nodeId": id, "widths": [width]}), + )); + } + checks.truncate(MAX_CHECKS - 3); + checks.push(check(json!({"kind": "interaction", "label": "hover on every link and button", "state": "hover", "widths": [widest]}))); + checks.push(check(json!({"kind": "interaction", "label": "focus on every link and button", "state": "focus", "widths": [widest]}))); + match target.next_url { + Some(next) => checks.push(check( + json!({"kind": "view-transition", "label": format!("view transition to {next}"), + "subject": {"css": format!("a[href=\"{next}\"] >> nth=0")}, "widths": [widest]}), + )), + None => notes.push("A single URL: no navigation to prove a view transition on.".to_owned()), + } + let first = target + .frames + .first() + .map(|(_, id, _)| id.clone()) + .unwrap_or_default(); + let screen = ScreenInput { + id: target.screen.clone(), + route_path: Some(target.url.to_owned()), + component_path: target.file.map(str::to_owned), + steps: Vec::new(), + expect: None, + checks, + figma: (!first.is_empty()).then(|| { + serde_json::from_value( + json!({"nodeId": first, "drawnWidths": drawn, "frames": frame_refs}), + ) + .expect("figma anchor from the schema") + }), + }; + let mut allowed = vec![origin]; + allowed.extend(FONT_ORIGINS.iter().map(|origin| (*origin).to_owned())); + let mut plan = PlanInput { + base_url: base_url.to_owned(), + allowed_origins: allowed, + viewports, + screens: vec![screen], + readiness: None, + fonts: fonts(sources, &target.frames), + locale: Some("en-US".to_owned()), + timezone: Some("UTC".to_owned()), + step_timeout_ms: None, + }; + runtime_audit::order_checks(&mut plan); + runtime_audit::validate_plan(&plan)?; + Ok(UrlPlan { + plan, + missing, + notes, + }) +} + +fn viewport(width: u32, height: u32) -> ViewportInput { + ViewportInput { + width, + height, + device_scale_factor: 1.0, + mobile: false, + touch: false, + } +} + +/// Size and position against the frame, exact: the executor allows only +/// half a pixel of rounding and takes no tolerance. +fn bounds_check( + name: &str, + subject: &str, + container: &str, + container_box: Bounds, + subject_box: Bounds, + width: u32, +) -> CheckInput { + let round = |value: f64| (value * 1000.0).round() / 1000.0; + check( + json!({"kind": "bounds", "label": format!("{name} size and position"), + "subject": node_at(width, subject), "container": node_at(width, container), "nodeId": subject, + "expected": {"x": round(subject_box.x - container_box.x), "y": round(subject_box.y - container_box.y), + "width": round(subject_box.width), "height": round(subject_box.height)}, + "widths": [width]}), + ) +} + +/// The font families the frames' text uses: they must be loaded before a +/// text box is measured, and a missing one is an environment error rather +/// than a layout failure. +fn fonts(sources: &Sources, frames: &[(u32, String, Option)]) -> Vec { + let mut families = BTreeSet::new(); + for (_, frame, _) in frames { + let Some((_, payload)) = sources.frame(frame) else { + continue; + }; + let mut stack = vec![frame.clone()]; + let mut seen = BTreeSet::new(); + while let Some(id) = stack.pop() { + if !seen.insert(id.clone()) { + continue; + } + let Some(node) = payload.snapshot.nodes.get(&id) else { + continue; + }; + let view = node.typed_view(); + if let Some(family) = view + .value("fontName") + .and_then(|font| font.get("family")) + .and_then(Value::as_str) + { + families.insert(family.to_owned()); + } + stack.extend(view.child_ids().map(str::to_owned)); + } + } + families.into_iter().take(20).collect() +} + +fn capture( + name: &str, + subject: &str, + scope: &str, + artifact: &str, + asset: &str, + width: u32, + threshold: f64, +) -> CheckInput { + check( + json!({"kind": "capture", "label": format!("{name} against its Figma render"), + "subject": node_at(width, subject), "nodeId": subject, "widths": [width], + "reference": {"scope": scope, "artifactId": artifact, "assetId": asset, "threshold": threshold}}), + ) +} + +pub(super) fn slug(url: &str) -> String { + let trimmed = url.trim_matches('/'); + if trimmed.is_empty() { + return "home".to_owned(); + } + trimmed + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect() +} + +/// What auditPlan was asked for. +pub(super) struct AuditRequest<'a> { + pub root: &'a str, + pub base_url: &'a str, + pub base_urls: &'a BTreeMap, + /// Only these URLs; empty means every confirmed URL. + pub only: &'a [String], + pub artifact_ids: &'a [String], + pub thresholds: &'a VisualThresholds, +} + +/// Writes every URL's plan, the runner once, and returns what to run. +pub(super) async fn write( + plan: &Value, + request: &AuditRequest<'_>, + policy: &OutputPolicy, + store: &ArtifactStore, +) -> Result { + let AuditRequest { + root, + base_url, + base_urls, + only, + artifact_ids, + thresholds, + } = *request; + let (sources, expired) = Sources::load(artifact_ids, store).await; + let routes = plan["routes"] + .as_array() + .into_iter() + .flatten() + .filter(|route| route["implementation"] == "page" && route["url"].is_string()) + .collect::>(); + let records = super::export_record::records(Path::new(root)); + // Every route as the concrete URLs it is audited at: a dynamic route at + // the URLs its params, the site's links or its Figma frame give. + let mut concrete = Vec::<(usize, String, Option)>::new(); + let mut not_audited = Vec::new(); + for (index, route) in routes.iter().enumerate() { + let url = route["url"].as_str().unwrap_or_default(); + if !only.is_empty() && !only.iter().any(|wanted| wanted == url) { + continue; + } + if !url.contains('[') { + concrete.push((index, url.to_owned(), None)); + continue; + } + let resolved = dynamic_urls(root, route, &plan["audit"]["links"], &sources); + if resolved.urls.is_empty() { + not_audited.push(json!({"url": url, "why": resolved.why})); + continue; + } + for sample in resolved.urls { + concrete.push(( + index, + sample, + Some(json!({"pattern": url, "from": resolved.source, + "assumption": resolved.assumption})), + )); + } + } + let urls = concrete + .iter() + .map(|(_, url, _)| url.as_str()) + .collect::>(); + let several_apps = routes + .iter() + .map(|route| route["app"].as_str().unwrap_or_default()) + .collect::>() + .len() + > 1; + let heights = plan["screens"] + .as_array() + .into_iter() + .flatten() + .flat_map(|screen| screen["frames"].as_array().into_iter().flatten()) + .filter_map(|frame| { + Some(( + frame["nodeId"].as_str()?.to_owned(), + frame["height"].as_f64(), + )) + }) + .collect::>(); + let runner = Path::new(root).join(RUNNER_DIR); + let mut template = (!runner.join("run.mjs").exists()).then(|| runner.display().to_string()); + let template_state = if template.is_some() { + "written" + } else if std::fs::read(runner.join("run.mjs")).ok().as_deref() + == Some(include_bytes!("../../../../../harness/runtime-audit/run.mjs").as_slice()) + { + "current" + } else { + "stale: delete .devup/runtime-audit/runner and run auditPlan again to get this server's runner" + }; + let mut written = Vec::new(); + for (index, url, dynamic) in &concrete { + let route = routes[*index]; + let url = url.as_str(); + let app = route["app"].as_str().unwrap_or_default(); + let screen = if several_apps && !app.is_empty() { + format!("{}-{}", slug(app), slug(url)) + } else { + slug(url) + }; + let frames = route["nodeIds"] + .as_array() + .into_iter() + .flatten() + .zip(route["drawnWidths"].as_array().into_iter().flatten()) + .filter_map(|(node, width)| { + let node = node.as_str()?.to_owned(); + let height = heights.get(&node).copied().flatten(); + Some((u32::try_from(width.as_u64()?).ok()?, node, height)) + }) + .collect::>(); + let position = urls.iter().position(|candidate| *candidate == url); + let next_url = position + .filter(|_| urls.len() > 1) + .map(|index| urls[(index + 1) % urls.len()]); + let hidden = hidden_elements(&records, &frames); + let target = Target { + url, + screen: screen.clone(), + file: route["file"].as_str(), + frames, + next_url, + hidden, + }; + let base = base_urls.get(app).map_or(base_url, String::as_str); + let built = url_plan(&target, base, &sources, thresholds)?; + let plan_path = Path::new(root) + .join(AUDIT_DIR) + .join(&screen) + .join("plan.json"); + let report_dir = Path::new(root).join(AUDIT_DIR).join(&screen).join("report"); + let counts = built.plan.screens[0].checks.iter().fold( + BTreeMap::::new(), + |mut counts, check| { + *counts.entry(check.kind.clone()).or_default() += 1; + counts + }, + ); + let viewports = built + .plan + .viewports + .iter() + .map(|viewport| json!({"width": viewport.width, "height": viewport.height})) + .collect::>(); + let created = runtime_audit::create_plan( + built.plan, + policy, + Some(&plan_path.to_string_lossy()), + template.take().as_deref(), + store, + ) + .await?; + written.push(json!({ + "url": url, + "screen": screen, + "planPath": created["writtenTo"], + "planId": created["planId"], + "reportDirectory": report_dir.display().to_string(), + "viewports": viewports, + "checks": counts, + "missingReferences": built.missing, + "notes": built.notes, + "dynamic": dynamic, + "run": ["node", "run.mjs", "--plan", plan_path.display().to_string(), "--out", report_dir.display().to_string()], + })); + } + let missing = written + .iter() + .flat_map(|plan| { + plan["missingReferences"] + .as_array() + .cloned() + .unwrap_or_default() + }) + .collect::>(); + let strip = production_strip(&routes, root, policy)?; + Ok(json!({ + "plans": written, + "notAudited": not_audited, + "productionStrip": strip, + "missingReferences": missing, + "expiredArtifacts": expired, + "runner": {"directory": runner.display().to_string(), "state": template_state, + "install": [["npm", "ci"], ["npx", "playwright", "install", "chromium"]], + "ifInstallFails": "Record it with devup_screen_plan action status, auditPlanPath and toolingUnavailable: the URL's audit stays inconclusive and the work goes on."}, + "nodeIdentity": include_str!("../text/audit_plan-write-7f9aea99.md"), + "nextAction": include_str!("../text/audit_plan-write-925db929.md"), + })) +} + +/// Elements of a merged page that a route's drawn widths do not show, from +/// the export records: each must be hidden at those widths. +fn hidden_elements(records: &[Value], frames: &[(u32, String, Option)]) -> Vec<(u32, String)> { + let drawn = frames + .iter() + .map(|(width, _, _)| *width) + .collect::>(); + let roots = frames + .iter() + .map(|(_, id, _)| id.as_str()) + .collect::>(); + let mut hidden = Vec::new(); + for record in records { + let elements = record["responsive"].as_array().cloned().unwrap_or_default(); + let ours = elements.iter().any(|element| { + element["ids"] + .as_array() + .into_iter() + .flatten() + .any(|id| id["nodeId"].as_str().is_some_and(|id| roots.contains(id))) + }); + if !ours { + continue; + } + for element in &elements { + let Some(widest) = element["ids"][0]["nodeId"].as_str() else { + continue; + }; + for width in element["hiddenAt"].as_array().into_iter().flatten() { + if let Some(width) = width.as_u64().and_then(|width| u32::try_from(width).ok()) + && drawn.contains(&width) + { + hidden.push((width, widest.to_owned())); + } + } + } + } + hidden +} + +/// The concrete URLs a dynamic route is audited at, and where they came from. +pub(super) struct Dynamic { + pub urls: Vec, + pub source: &'static str, + pub why: String, + pub assumption: Option, +} + +/// `[slug]` -> ("slug", false); `[...slug]` and `[[...slug]]` -> ("slug", true). +fn params(pattern: &str) -> Vec<(String, bool)> { + pattern + .split('/') + .filter(|segment| segment.starts_with('[') && segment.ends_with(']')) + .map(|segment| { + let inner = segment.trim_matches(|c| c == '[' || c == ']'); + match inner.strip_prefix("...") { + Some(name) => (name.to_owned(), true), + None => (inner.to_owned(), false), + } + }) + .collect() +} + +fn fill(pattern: &str, values: &BTreeMap) -> Option { + let mut out = Vec::new(); + for segment in pattern.split('/') { + if segment.starts_with('[') && segment.ends_with(']') { + let inner = segment.trim_matches(|c| c == '[' || c == ']'); + out.push(values.get(inner.trim_start_matches("...")).cloned()?); + } else { + out.push(segment.to_owned()); + } + } + Some(out.join("/")) +} + +/// Every literal value of `key` in `text`: `key: 'a'`, `"key": "a"`, or +/// `key: ['a', 'b']` (joined with `/` for a catch-all). +pub(super) fn values_of(text: &str, key: &str) -> Vec { + let mut found = Vec::new(); + let bytes = text.as_bytes(); + let mut from = 0; + while let Some(at) = text[from..].find(key) { + let start = from + at; + from = start + key.len(); + let before = start.checked_sub(1).map(|i| bytes[i] as char); + if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$' || c == '.') { + continue; + } + let mut rest = text[from..].trim_start_matches(['"', '\'']).trim_start(); + let Some(after) = rest.strip_prefix(':') else { + continue; + }; + rest = after.trim_start(); + let quoted = |rest: &str| -> Option<(String, usize)> { + let quote = rest + .chars() + .next() + .filter(|c| matches!(c, '"' | '\'' | '`'))?; + let end = rest[1..].find(quote)?; + Some((rest[1..=end].to_owned(), end + 2)) + }; + if let Some((value, _)) = quoted(rest) { + found.push(value); + } else if let Some(list) = rest.strip_prefix('[') { + let end = list.find(']').unwrap_or(list.len()); + let parts = list[..end] + .split(',') + .filter_map(|part| quoted(part.trim()).map(|(value, _)| value)) + .collect::>(); + if !parts.is_empty() { + found.push(parts.join("/")); + } + } + } + found.dedup(); + found +} + +/// Local modules a page imports: `./x`, `../x` or `@/x`, with the names +/// imported from each. +fn local_imports(source: &str) -> Vec<(String, String)> { + let mut found = Vec::new(); + for line in source.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("import ") else { + continue; + }; + let Some((names, module)) = rest.rsplit_once(" from ") else { + continue; + }; + let module = module + .trim() + .trim_end_matches(';') + .trim_matches(['"', '\'']); + if module.starts_with("./") || module.starts_with("../") || module.starts_with("@/") { + found.push((names.to_owned(), module.to_owned())); + } + } + found +} + +fn read_module(base: &Path, module: &str) -> Option { + for suffix in [ + "", + ".ts", + ".tsx", + ".js", + ".mjs", + ".json", + "/index.ts", + "/index.js", + ] { + let path = base.join(format!("{module}{suffix}")); + if path.is_file() + && std::fs::metadata(&path).is_ok_and(|meta| meta.len() <= 512 * 1024) + && let Ok(text) = std::fs::read_to_string(&path) + { + return Some(text); + } + } + None +} diff --git a/crates/devup-mcp/src/server/audit_plan/static_params.rs b/crates/devup-mcp/src/server/audit_plan/static_params.rs new file mode 100644 index 00000000..d851c7d4 --- /dev/null +++ b/crates/devup-mcp/src/server/audit_plan/static_params.rs @@ -0,0 +1,786 @@ +static FAILURE_CLASSES: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/audit-failure-classes.json")) + .expect("vendored failure classes") +}); + +/// `generateStaticParams` in the route's page: literal params in its body, +/// else the param's values in a local data file it reads. +pub(super) fn static_params( + root: &Path, + app_dir: &str, + page_file: &str, + pattern: &str, +) -> Vec { + let page = root.join(page_file); + let Ok(source) = std::fs::read_to_string(&page) else { + return Vec::new(); + }; + let Some(at) = source.find("generateStaticParams") else { + return Vec::new(); + }; + let body = &source[at..]; + let body = body[1..] + .find("\nexport ") + .map_or(body, |end| &body[..=end]); + let names = params(pattern); + let collect = |text: &str| { + let lists = names + .iter() + .map(|(name, _)| values_of(text, name)) + .collect::>(); + let count = lists.iter().map(Vec::len).min().unwrap_or(0); + (0..count) + .filter_map(|index| { + let values = names + .iter() + .zip(&lists) + .map(|((name, _), list)| (name.clone(), list[index].clone())) + .collect::>(); + fill(pattern, &values) + }) + .collect::>() + }; + let literal = collect(body); + if !literal.is_empty() { + return literal; + } + let page_dir = page.parent().unwrap_or(root); + for (imported, module) in local_imports(&source) { + let used = imported + .split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$')) + .filter(|name| !name.is_empty() && *name != "as") + .any(|name| body.contains(name)); + if !used { + continue; + } + let text = match module.strip_prefix("@/") { + Some(rest) => read_module(&root.join(app_dir).join("src"), rest) + .or_else(|| read_module(&root.join(app_dir), rest)), + None => read_module(page_dir, &module), + }; + if let Some(text) = text { + let from_data = collect(&text); + if !from_data.is_empty() { + return from_data; + } + } + } + Vec::new() +} + +/// The first text of a frame, depth first: a detail page's title. +fn first_text(sources: &Sources, frame: &str) -> Option { + let (_, payload) = sources.frame(frame)?; + let mut stack = vec![frame.to_owned()]; + while let Some(id) = stack.pop() { + let node = payload.snapshot.nodes.get(&id)?; + let view = node.typed_view(); + if let Some(text) = view + .string("characters") + .filter(|text| !text.trim().is_empty()) + { + return Some(text.to_owned()); + } + let mut children = view.child_ids().map(str::to_owned).collect::>(); + children.reverse(); + stack.extend(children); + } + None +} + +fn sample_segment(text: &str) -> String { + let words = text + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .take(6) + .map(str::to_lowercase) + .collect::>(); + if words.is_empty() { + "sample".to_owned() + } else { + words.join("-") + } +} + +/// A dynamic route's concrete URLs: its `generateStaticParams`, else the +/// links the audited pages hold into it, else a sample from its Figma detail +/// frame (an assumption). At most ten. +pub(super) fn dynamic_urls(root: &str, route: &Value, links: &Value, sources: &Sources) -> Dynamic { + let pattern = route["url"].as_str().unwrap_or_default(); + let app_dir = route["app"] + .as_str() + .filter(|app| !app.is_empty()) + .unwrap_or("."); + let cap = |mut urls: Vec| { + urls.sort(); + urls.dedup(); + urls.truncate(MAX_DYNAMIC_URLS); + urls + }; + if let Some(file) = route["file"].as_str() { + let found = static_params(Path::new(root), app_dir, file, pattern); + if !found.is_empty() { + return Dynamic { + urls: cap(found), + source: "generateStaticParams", + why: String::new(), + assumption: None, + }; + } + } + let linked = links + .as_object() + .into_iter() + .flatten() + .flat_map(|(_, hrefs)| hrefs.as_array().cloned().unwrap_or_default()) + .filter_map(|href| href.as_str().map(str::to_owned)) + .filter(|href| super::navigation::matches_route(pattern, href)) + .collect::>(); + if !linked.is_empty() { + return Dynamic { + urls: cap(linked), + source: "links on audited pages", + why: String::new(), + assumption: None, + }; + } + let frame = route["nodeIds"][0].as_str().unwrap_or_default(); + if let Some(text) = first_text(sources, frame) { + let segment = sample_segment(&text); + let values = params(pattern) + .into_iter() + .map(|(name, _)| (name, segment.clone())) + .collect::>(); + if let Some(url) = fill(pattern, &values) { + return Dynamic { + assumption: Some(format!( + "{pattern} audited at {url}: the sample comes from the detail frame {frame}'s title \"{text}\"; the app must serve it." + )), + urls: vec![url], + source: "Figma detail frame", + why: String::new(), + }; + } + } + Dynamic { + urls: Vec::new(), + source: "none", + why: "No generateStaticParams with literal or local-data params, no link to it on an audited page (run the audit once and auditPlan again), and no detail frame text to sample.".to_owned(), + assumption: None, + } +} + +const STRIP_PLUGIN: &[u8] = + include_bytes!("../../../../../harness/runtime-audit/strip-figma-node-ids.mjs"); +const STRIP_FILE: &str = "vite-plugin-strip-figma-node-ids.mjs"; + +/// Puts the plugin that strips the audit attributes from production builds +/// next to each app's vite.config, once, and says whether the config uses +/// it yet. +fn production_strip( + routes: &[&Value], + root: &str, + policy: &OutputPolicy, +) -> Result, DevupError> { + let apps = routes + .iter() + .map(|route| { + route["app"] + .as_str() + .filter(|app| !app.is_empty()) + .unwrap_or(".") + .to_owned() + }) + .collect::>(); + let mut out = Vec::new(); + for app in apps { + let dir = Path::new(root).join(&app); + let file = dir.join(STRIP_FILE); + let written = if file.exists() { + false + } else { + let target = policy.resolve(&file.to_string_lossy())?; + let mut transaction = super::output::OutputTransaction::new(); + transaction.stage("stripPlugin", target, STRIP_PLUGIN)?; + transaction.commit()?; + true + }; + let config = [ + "vite.config.ts", + "vite.config.mts", + "vite.config.js", + "vite.config.mjs", + ] + .iter() + .map(|name| dir.join(name)) + .find(|path| path.is_file()); + let wired = config + .as_ref() + .and_then(|path| std::fs::read_to_string(path).ok()) + .is_some_and(|text| text.contains(STRIP_FILE)); + out.push(json!({ + "app": app, + "plugin": file.display().to_string(), + "written": written, + "viteConfig": config.map(|path| path.display().to_string()), + "wired": wired, + "add": format!("import stripFigmaNodeIds from './{STRIP_FILE}' // then plugins: [stripFigmaNodeIds(), vinext()]"), + "nextCli": "On the next CLI instead: compiler.reactRemoveProperties: process.env.DEVUP_AUDIT === '1' ? false : { properties: ['^data-figma-node-ids?$'] }", + "audit": "Audit against `bun run dev` or an audit build: DEVUP_AUDIT=1 bun run build.", + "verify": "After `bun run build`, `grep -rl data-figma-node-id dist` (or the app's build output) must find nothing.", + })); + } + Ok(out) +} + +/// One devup_visual_compare result for a comparison status returned. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct VisualResultInput { + /// The visualComparisons id. + pub id: String, + /// The comparison's verdict. + #[schemars(extend("enum" = ["pass", "fail", "inconclusive"]))] + pub verdict: String, + /// visual.changedRatio from the result. + #[serde(default)] + pub changed_ratio: Option, + /// The diffPath the result wrote. + #[serde(default)] + pub diff_path: Option, + /// Why it was inconclusive, when it was. + #[serde(default)] + pub reason: Option, +} + +/// The order a URL's failures are worked in: a page that does not render +/// first, then overflow, then layout, then pixels, then states. +fn class_rank(class: &str) -> usize { + FAILURE_CLASSES + .iter() + .position(|candidate| *candidate == class) + .unwrap_or(9) +} + +/// Folds one audit run (and the visual results for it) into the plan's +/// finding records. A finding counts a failed run once per new evidence - +/// a new report for a check, a new screenshot for a comparison - so calling +/// status twice on one report does not burn an attempt. After +/// `max_fix_attempts` fix-and-rerun rounds that still fail it is +/// unresolved, with its evidence, and leaves the work queue. +pub(super) fn record_round( + plan: &mut Value, + audit: &Value, + report_sha: &str, + visual: &[VisualResultInput], + node_files: &BTreeMap, + max_fix_attempts: u32, +) -> Value { + let audited = audit["screenTable"] + .as_array() + .into_iter() + .flatten() + .filter_map(|row| { + Some(( + row["routePath"].as_str()?.to_owned(), + row["width"].as_u64()?, + )) + }) + .collect::>(); + let comparisons = audit["visualComparisons"] + .as_array() + .cloned() + .unwrap_or_default(); + let mut current = Vec::<(Value, String)>::new(); + for finding in audit["findings"].as_array().into_iter().flatten() { + current.push((finding.clone(), report_sha.to_owned())); + } + let mut compared = BTreeSet::new(); + let mut inconclusive = Vec::new(); + for result in visual { + let Some(comparison) = comparisons.iter().find(|c| c["id"] == result.id.as_str()) else { + inconclusive.push( + json!({"id": result.id, "why": "no comparison with this id in this audit run"}), + ); + continue; + }; + compared.insert(result.id.clone()); + match result.verdict.as_str() { + "fail" => { + let threshold = comparison["call"]["arguments"]["threshold"].as_f64(); + let reason = match (result.changed_ratio, threshold) { + (Some(ratio), Some(threshold)) => format!( + "{:.1}% of pixels differ from the Figma render (allowed {:.1}%)", + ratio * 100.0, + threshold * 100.0 + ), + _ => "differs from the Figma render".to_owned(), + }; + current.push(( + json!({ + "id": result.id, + "class": comparison["class"], + "screen": comparison["screen"], + "routePath": comparison["routePath"], + "width": comparison["viewport"]["width"], + "kind": "visual", + "label": comparison["label"], + "nodeId": comparison["nodeId"], + "codeFile": comparison["codeFile"], + "reason": reason, + "fixHint": comparison["ifItFails"]["fixHint"], + "evidence": {"screenshot": comparison["screenshot"]["path"], "diff": result.diff_path, + "reference": comparison["call"]["arguments"]["reference"]}, + }), + comparison["screenshot"]["sha256"] + .as_str() + .unwrap_or_default() + .to_owned(), + )); + } + "inconclusive" => inconclusive.push(json!({"id": result.id, "class": comparison["class"], + "why": result.reason.clone().unwrap_or_else(|| "the comparison was inconclusive".to_owned())})), + _ => {} + } + } + let pending = comparisons + .iter() + .filter(|comparison| { + !compared.contains(comparison["id"].as_str().unwrap_or_default()) + && comparison["blockers"].as_array().is_none_or(Vec::is_empty) + }) + .map(|comparison| comparison["id"].clone()) + .collect::>(); + // A converter bug is reported once and kept, whatever later runs show. + for (finding, _) in ¤t { + if finding["class"] == "converter-bug" + && let Some(node) = finding["nodeId"].as_str() + { + plan["audit"]["converterBugs"][node] = json!({ + "nodeId": node, "routePath": finding["routePath"], "width": finding["width"], + "reason": finding["reason"], "repro": finding["evidence"]["repro"], + "codeFile": finding["codeFile"], + }); + } + } + let records = &mut plan["audit"]["findings"]; + if !records.is_object() { + *records = json!({}); + } + let records = records.as_object_mut().expect("an object"); + let failing = current + .iter() + .filter_map(|(finding, _)| finding["id"].as_str().map(str::to_owned)) + .collect::>(); + for (mut finding, key) in current { + let id = finding["id"].as_str().unwrap_or_default().to_owned(); + if let Some((_, file)) = finding["nodeId"] + .as_str() + .and_then(|node| node_files.get(node)) + { + finding["codeFile"] = json!(file); + } + let record = records + .entry(id) + .or_insert_with(|| json!({"failedRuns": 0})); + if record["lastEvidence"] != key.as_str() { + record["failedRuns"] = json!(record["failedRuns"].as_u64().unwrap_or(0) + 1); + record["lastEvidence"] = json!(key); + } + let fix_attempts = record["failedRuns"].as_u64().unwrap_or(1).saturating_sub(1); + for field in [ + "class", + "screen", + "routePath", + "width", + "kind", + "label", + "nodeId", + "codeFile", + "reason", + "fixHint", + "evidence", + ] { + record[field] = finding[field].clone(); + } + record["fixAttempts"] = json!(fix_attempts); + // A deterministic result does not change by retrying: a converter + // bug leaves the fix loop at once and never spends attempts. + record["status"] = json!(if finding["class"] == "converter-bug" { + "converter-bug" + } else if fix_attempts >= u64::from(max_fix_attempts) { + "unresolved" + } else { + "open" + }); + } + // Anything this run measured again and did not see fail is fixed. A + // comparison is fixed only by a passing result for it. + let passed = visual + .iter() + .filter(|result| result.verdict == "pass") + .map(|result| result.id.as_str()) + .collect::>(); + for (id, record) in records.iter_mut() { + if failing.contains(id) || record["status"] == "resolved" { + continue; + } + let measured_again = if record["kind"] == "visual" { + passed.contains(id.as_str()) + } else { + record["routePath"] + .as_str() + .zip(record["width"].as_u64()) + .is_some_and(|(route, width)| audited.contains(&(route.to_owned(), width))) + }; + if measured_again { + record["status"] = json!("resolved"); + } + } + json!({"pendingComparisons": pending, "inconclusiveComparisons": inconclusive}) +} + +/// The open and unresolved findings in work order, most basic first. +pub(super) fn findings_by_status(plan: &Value, status: &str) -> Vec { + let mut list = plan["audit"]["findings"] + .as_object() + .into_iter() + .flatten() + .filter(|(_, record)| record["status"] == status) + .map(|(id, record)| { + let mut record = record.clone(); + record["id"] = json!(id); + record + }) + .collect::>(); + list.sort_by_key(|record| { + ( + record["routePath"].as_str().unwrap_or_default().to_owned(), + class_rank(record["class"].as_str().unwrap_or_default()), + std::cmp::Reverse(record["width"].as_u64().unwrap_or(0)), + ) + }); + list +} + +/// Per URL: what to do next, blocked items last. Nothing here waits on +/// the user; a blocked item waits on a pending decision taken at the end. +pub(super) fn work_queue( + plan: &Value, + rows: &[Value], + pending_comparisons: &[Value], +) -> Vec { + let open = findings_by_status(plan, "open"); + let decisions = plan["pendingDecisions"] + .as_array() + .cloned() + .unwrap_or_default(); + let audited_urls = plan["audit"]["plans"] + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| entry["url"].as_str()) + .collect::>(); + rows.iter() + .map(|row| { + let url = row["url"].as_str().unwrap_or_default(); + let screen = audit_screen(plan, url); + let mut items = Vec::new(); + let mut blocked = Vec::new(); + for decision in &decisions { + if decision["status"] == "open" + && decision["about"].as_str().is_some_and(|about| about.contains(url)) + { + blocked.push(json!({"do": "blocked", "on": decision["id"], + "why": decision["question"], "meanwhile": "work the rest; it is taken with assume at the end"})); + } + } + if row["implemented"] != "yes" { + items.push(json!({"do": "build", "file": row["file"]})); + } + if let Some(reason) = plan["audit"]["inconclusive"][url].as_str() { + items.push(json!({"do": "audit inconclusive", "why": reason, + "how": "install the runner when it can be; the rest of the work goes on"})); + } else if !audited_urls.contains(url) { + items.push(json!({"do": "audit", "how": "devup_screen_plan action auditPlan, run the plan, then status"})); + } else if row["widths"] + .as_array() + .into_iter() + .flatten() + .any(|width| width["result"] == "not-run") + { + items.push(json!({"do": "run the audit", "why": "some widths have no audit result yet"})); + } + for finding in open.iter().filter(|finding| finding["routePath"] == url) { + items.push(json!({"do": "fix", "finding": finding["id"], "class": finding["class"], + "width": finding["width"], "codeFile": finding["codeFile"], "fixHint": finding["fixHint"], + "attempt": finding["fixAttempts"].as_u64().unwrap_or(0) + 1})); + } + let comparisons = pending_comparisons + .iter() + .filter_map(Value::as_str) + .filter(|id| screen.as_deref().is_some_and(|screen| id.starts_with(&format!("{screen}|")))) + .count(); + if comparisons > 0 { + items.push(json!({"do": "compare", "count": comparisons, + "how": "run each visualComparisons call and pass the results back as visualResults"})); + } + let done = items.is_empty(); + items.extend(blocked); + let reported = plan["audit"]["converterBugs"] + .as_object() + .into_iter() + .flatten() + .filter(|(_, bug)| bug["routePath"] == url) + .map(|(_, bug)| json!({"converterBug": bug["nodeId"], "repro": bug["repro"], + "note": "reported, not retried; a local override needs a comment naming the repro"})) + .collect::>(); + json!({"url": url, "items": items, "reported": reported, "done": done}) + }) + .collect() +} + +fn audit_screen(plan: &Value, url: &str) -> Option { + plan["audit"]["plans"] + .as_array()? + .iter() + .find(|entry| entry["url"] == url)?["screen"] + .as_str() + .map(str::to_owned) +} + +/// The report the job ends with: every URL at every width by health, +/// geometry, the three visual levels and interaction, with screenshots and +/// diffs, then the unresolved findings with their evidence and the pending +/// decisions taken. The plan's own lists (assumptions, design fixes, +/// exclusions, defaults) and the dependency line follow. +pub(super) fn final_report(plan: &Value, rows: &[Value], trailing: &str) -> String { + let cell = |value: &Value| match value { + Value::Null => "-".to_owned(), + Value::String(text) => text.replace('|', "\\|"), + other => other.to_string(), + }; + let mut out = String::from( + "| URL | width | health | geometry | drift / converter bugs | page | section | asset | interaction | screenshot | diffs |\n|---|---|---|---|---|---|---|---|---|---|---|\n", + ); + let records = plan["audit"]["findings"].as_object(); + for row in rows { + let url = row["url"].as_str().unwrap_or_default(); + for width in row["widths"].as_array().into_iter().flatten() { + let columns = &width["columns"]; + let diffs = records + .into_iter() + .flatten() + .filter(|(_, record)| { + record["routePath"] == url + && record["width"] == width["width"] + && record["status"] != "resolved" + }) + .filter_map(|(_, record)| record["evidence"]["diff"].as_str()) + .collect::>(); + let count = |class: &str| { + records + .into_iter() + .flatten() + .filter(|(_, record)| { + record["routePath"] == url + && record["width"] == width["width"] + && record["class"] == class + && record["status"] != "resolved" + }) + .count() + }; + out.push_str(&format!( + "| {url} | {} | {} | {} | {} / {} | {} | {} | {} | {} | {} | {} |\n", + width["width"], + cell(&columns["health"]), + cell(&columns["geometry"]), + count("code-drift"), + count("converter-bug"), + cell(&columns["page"]), + cell(&columns["section"]), + cell(&columns["asset"]), + cell(&columns["interaction"]), + width["screenshots"] + .as_array() + .and_then(|shots| shots.first()) + .map_or("-".to_owned(), cell), + if diffs.is_empty() { + "-".to_owned() + } else { + diffs.join(", ") + }, + )); + } + } + let unresolved = findings_by_status(plan, "unresolved"); + out.push_str("\nUnresolved findings (after the fix-attempt budget):\n"); + if unresolved.is_empty() { + out.push_str("- none\n"); + } + for finding in &unresolved { + out.push_str(&format!( + "- {} at {}px, {} {}: {} (code: {}; screenshot: {}; diff: {})\n", + cell(&finding["routePath"]), + finding["width"], + cell(&finding["class"]), + cell(&finding["nodeId"]), + cell(&finding["reason"]), + cell(&finding["codeFile"]), + cell(&finding["evidence"]["screenshot"]), + cell(&finding["evidence"]["diff"]), + )); + } + out.push_str("\nConverter bugs (filed, not retried):\n"); + let bugs = plan["audit"]["converterBugs"] + .as_object() + .cloned() + .unwrap_or_default(); + if bugs.is_empty() { + out.push_str("- none\n"); + } + for bug in bugs.values() { + out.push_str(&format!( + "- {} at {}px, {}: {} (repro: {}; code: {})\n", + cell(&bug["routePath"]), + bug["width"], + cell(&bug["nodeId"]), + cell(&bug["reason"]), + cell(&bug["repro"]), + cell(&bug["codeFile"]), + )); + } + out.push_str("\nPending decisions:\n"); + let decisions = plan["pendingDecisions"] + .as_array() + .cloned() + .unwrap_or_default(); + if decisions.is_empty() { + out.push_str("- none\n"); + } + for decision in &decisions { + out.push_str(&format!( + "- {}: {} -> {} ({})\n", + cell(&decision["id"]), + cell(&decision["question"]), + cell(decision.get("taken").unwrap_or(&decision["recommended"])), + match decision["status"].as_str() { + Some("taken") => "taken at the end", + Some("left-unbuilt") => "destructive: left unbuilt", + _ => "still open", + } + )); + } + out.push_str(trailing); + let dependencies = &plan["dependencies"]; + out.push_str(&if dependencies.is_object() { + format!( + "\nDependencies: `{}` bumped {} packages; pins: {}.\n", + cell(&dependencies["command"]), + dependencies["bumped"], + dependencies["pins"] + .as_array() + .filter(|pins| !pins.is_empty()) + .map_or("none".to_owned(), |pins| pins + .iter() + .map(|pin| format!( + "{}@{} ({})", + cell(&pin["package"]), + cell(&pin["version"]), + cell(&pin["reason"]) + )) + .collect::>() + .join(", ")) + ) + } else { + "\nDependencies: the update step has not been recorded.\n".to_owned() + }); + out +} + +#[cfg(test)] +#[path = "../audit_plan_tests.rs"] +mod tests; + +/// Geometry is exact, so a box that fails at a drawn width is classified by +/// cause: the code drifted from what the export wrote, or it did not and the +/// converter is wrong. A converter bug gets its repro written under +/// `.devup/converter-bugs/`. +pub(super) fn classify_geometry( + audit: &mut Value, + project_root: &str, + sources: &[String], + policy: &OutputPolicy, +) { + let Some(findings) = audit["findings"].as_array_mut() else { + return; + }; + for finding in findings { + if !matches!(finding["kind"].as_str(), Some("bounds" | "dimensions")) { + continue; + } + let Some(node) = finding["nodeId"].as_str().map(str::to_owned) else { + continue; + }; + match super::export_record::geometry_cause(Path::new(project_root), &node, sources) { + super::export_record::Cause::Drift { + file, + missing, + added, + } => { + finding["class"] = json!("code-drift"); + finding["codeFile"] = json!(file); + finding["evidence"]["drift"] = json!({"missing": missing, "added": added}); + finding["fixHint"] = json!(format!( + "{} Missing or changed: {}. Added layout props: {}.", + runtime_audit::fix_hint("code-drift", "bounds", &Value::Null), + if missing.is_empty() { + "none".to_owned() + } else { + missing.join(", ") + }, + if added.is_empty() { + "none".to_owned() + } else { + added.join(", ") + }, + )); + } + super::export_record::Cause::ConverterBug { + file, + record, + exported, + } => { + let width = finding["width"].clone(); + match super::export_record::write_repro( + policy, + project_root, + &node, + &width, + finding, + &file, + &record, + &exported, + ) { + Ok((path, _)) => { + finding["class"] = json!("converter-bug"); + finding["codeFile"] = json!(file); + finding["evidence"]["repro"] = json!(path); + finding["fixHint"] = json!(runtime_audit::fix_hint( + "converter-bug", + "bounds", + &Value::Null + )); + } + Err(error) => { + finding["evidence"]["reproError"] = json!(error.message); + } + } + } + super::export_record::Cause::Unknown(why) => { + finding["evidence"]["cause"] = json!(why); + } + } + } +} diff --git a/crates/devup-mcp/src/server/commands/opencode-devup.md b/crates/devup-mcp/src/server/commands/opencode-devup.md index 41247cf5..4c4be15d 100644 --- a/crates/devup-mcp/src/server/commands/opencode-devup.md +++ b/crates/devup-mcp/src/server/commands/opencode-devup.md @@ -1,16 +1,105 @@ --- -description: Build pages or a site from Figma with devup-mcp (screen plan, questions up front, states, metadata, audit, final table) +description: Plan and deliver DevFive projects, features, fixes, Figma pages and refactors with project intake, ERD models and supervisor work units --- Run the devup workflow for: $ARGUMENTS -The arguments are a Figma Section, frame or page link (optional while one -Devup Bridge plugin is attached) followed by notes for the plan. +The arguments are optional Figma links, Jira keys, spec URLs/files and a goal. +For Figma work, one attached Devup Bridge plugin supplies the selection when +a link is omitted. Follow the `devup` skill step by step - it is installed next to this command - and the devup-implementation-plan skill for its rules: +## Project intake comes first + +`/devup` is the entry point for new projects, existing features, bug fixes, +Figma page implementation and refactors. Figma screen planning is a branch +of the confirmed project plan; complete this section before the screen workflow below. + +Read sources before asking: `devup_project_context` for every supported scope, +Atlassian MCP `getJiraIssue` / `searchJiraIssuesUsingJql` for issues, epic children +and dependencies, referenced Confluence/spec documents, and Figma exploration +(screen names, field labels, forms, tables and detail screens). Pass each read as +`evidence: [{source, facts}]` to `devup_project_plan`. Facts use `goal`, `parts`, +`figma` (links), `screens` ([{name, fields}]), and `.screens`, `.states`, +`.permissions`, `.forms`, `.notifications`. Backend decisions use `erd`, `tenancy`, +`auth`, `lifecycle`, `indexes`, `storage`; delivery uses `jiraPolicy`, `branchPolicy`, +`prPolicy`. A sourced fact is a default with its provenance, not another question. +Sources are read by the runtime's connectors, never by treating a URL as its contents. + +1. `devup_project_plan intake` with projectRoot: always ask the four initial + questions with the runtime question tool (recommended option first): work type + (refactor may be a custom answer), optional Figma/Jira/spec sources, one-sentence + goal, and parts. Existing `apis/` recommends front + admin + API; + `apps/admin` recommends front + admin. Retain planId on every later call. +2. Read newly named sources. `draft` with all initial answers, source-labelled + evidence and `features: [{id,scope,screens,acceptance}]` or + `issues: [{key,scope,dependencies,acceptance}]` (epic children are issues too). + Each unresolved feature gets concrete screen/role, state, permission, + validation, notification/integration rounds. Put all remaining unknowns into + extraQuestions. No implementation starts while a question remains unanswered. +3. API in scope: inspect existing `vespertide.json` and `apis/*/models/*.json`. + Draft complete `entities` as vespertide model objects and `relations` with + from/to, cardinality oneToMany|manyToMany, column/joinTable and onDelete. + Signup/post Figma evidence seeds user/post entities and a user FK, but the + agent must expand every actual domain from the source evidence. Review the + ERD round, ownership/tenancy, auth with integer roles (0,10,50,100), status + enums, soft delete, audit timestamps, nullable fields, unique/composite keys, + filter/sort indexes, file/image storage and cascade/restrict/set_null actions. + Read https://github.com/dev-five-git/vespertide/blob/main/SKILL.md and schemas/. + Use snake_case, `$schema` on every model, inline PK/FK/unique/index constraints, + and table constraints only for CHECK. A manyToMany relation generates its join + table. modelsDir is relative to projectRoot; set it explicitly for multiple APIs. + Re-draft structured entities for requested corrections before confirming. +4. When Figma is present, run the existing screen-plan draft/rounds/confirm + workflow (routes, groups, exclusions, site facts) before project confirm. +5. Ask all delivery questions now: Jira none/comment/transition, branch, PR. + `devup_project_plan confirm` requires every applicable answer and writes source + models plus confirmed state in one transaction. Then in modelsWorkingDirectory + run the returned commands, in order: `vespertide diff`, `vespertide sql`, + `vespertide revision -m "confirmed project plan"`, `vespertide export --orm seaorm`. + The tool returns commands; the runtime executes and checks them. Never hand-write + migrations/ or src/models/. Build vespera CRUD and feature routes, openapi.json, + and the devup-api client; `devup_stack_diff` must show zero drift before done. +6. Latest-first updates, vinext, exact layout, all interaction/site defaults, + runtime/visual audits and the existing never-wait rules below still apply. + After confirm, use project-plan defer with id/reason/recommended/destructive; + continue independent work. At the end assume with no decisions resolves all + pending recommendations; destructive decisions remain unbuilt. Status includes + ERD Mermaid, decisions and the unit table; retain unresolved evidence in reports. + +## Supervisor execution + +`devup_project_plan split` splits work, not files. Jira input produces one unit +per issue/sub-task with its explicit dependencies (reject cycles/unknown IDs). +Without Jira, it produces shared layout/theme first, then each feature's +models -> API -> client -> front chain; front-only features depend on shared. +Review cross-feature dependencies before dispatch; the supervisor sequences +shared database work to avoid concurrent schema edits. Each unit has acceptance +criteria and build/lint/typecheck/tests/validate/runtime/visual/stack-diff checks. +`.devup/units/.md` contains scope, decisions, ERD and screen context, and checklist. + +Default `unitQuestions: "supervisor"` means workers never ask the user: questions +were settled across all units during intake, and supervisor ownership avoids +repeated questions and keeps decisions consistent. `unitQuestions: "worker"` +is an explicit alternative, still subject to the confirmed never-wait policy. +Load `orca-cli` and `orchestration` skills to create one worktree per unit, +dispatch the DAG in parallel where independent, collect worker_done, and rerun +each unit's checks yourself before merging. New decisions use orchestration +ask/reply to the supervisor: answer from recorded decisions, or defer with a +recommended option while the worker continues other work. Send failed checks +back to the same worker session. Update unit states with status unitUpdates +(pending -> running -> verifying -> merged, or unresolved); merged needs recorded +supervisor verification. Without Orca, execute the same units sequentially in one +session. Only under the recorded Jira/PR policy, comment results and PR link and +transition issues. Final report includes the unit table, DAG, ERD and unresolved +items; never claim completion from split or command generation alone. + + +For Figma work, continue with the following branch after project intake: + 1. `devup_figma_auth` status. 2. `devup_skills` status, then install what is missing, and load the skills. 3. `devup_project_context` with scope theme and scope routes. diff --git a/crates/devup-mcp/src/server/compact.rs b/crates/devup-mcp/src/server/compact.rs index f49070ac..70933a35 100644 --- a/crates/devup-mcp/src/server/compact.rs +++ b/crates/devup-mcp/src/server/compact.rs @@ -45,7 +45,7 @@ enum Step { fn invalid() -> DevupError { DevupError::new( ErrorCode::DevupInvalidInput, - "Selection accepts at most 16 paths of 256 bytes and 8 steps: dot fields, [index], [name=\"exact\"] or [id=\"exact\"]. No wildcards, code, recursion or expressions. responseLimit is 1..200; responseOffset is at most 100000.", + include_str!("text/compact-invalid-7f830631.md"), false, ) } diff --git a/crates/devup-mcp/src/server/delivery.rs b/crates/devup-mcp/src/server/delivery.rs index ccb36909..b83faacf 100644 --- a/crates/devup-mcp/src/server/delivery.rs +++ b/crates/devup-mcp/src/server/delivery.rs @@ -233,7 +233,7 @@ fn inline_size_error( "outputBytes":output_bytes,"serializedResponseBytes":wire_bytes, "exceededByBytes":measured.saturating_sub(MAX_INLINE_TOTAL_BYTES), "outputs":outputs.iter().map(|o| serde_json::json!({"name":o.name,"bytes":o.bytes.len(),"binary":o.is_binary})).collect::>(), - "measurement":"Output bytes are UTF-8 or binary payload bytes; serializedResponseBytes includes JSON escaping, base64 and duplicated MCP content/structuredContent. Per-output bytes are not additive wire sizes.", + "measurement":include_str!("text/delivery-inline_size_error-e8696ca0.md"), "recoveryState":"unrecoverable", "recoveryReason":"The delivery size check alone has no artifact or original projection arguments. The export boundary must supply a concrete resource-delivery call.", "nextAction":null @@ -286,9 +286,9 @@ pub(super) fn with_inline_recovery(mut error: DevupError, arguments: Value) -> D ); error.details["splitRequests"] = serde_json::json!(split); error.details["splitReason"] = serde_json::json!(if coupled_assets { - "No optional split is offered: asset requests, output paths and assetPublicRoot can determine generated asset URLs. Keep those coupled outputs together in the full resource recovery call." + include_str!("text/delivery-with_inline_recovery-01306837.md") } else { - "Optional smaller resource projections isolate outputs; sourceMap stays paired with a generated output. These are alternatives to the full recovery call, not guaranteed-to-fit inline calls." + include_str!("text/delivery-with_inline_recovery-e6ab7ccd.md") }); error } @@ -297,7 +297,7 @@ pub(crate) fn server_identity() -> Value { serde_json::json!({"version":env!("CARGO_PKG_VERSION"),"buildId":crate::build_id(), "commit":option_env!("DEVUP_MCP_GIT_COMMIT").filter(|s|!s.is_empty()), "displayVersion":format!("{}+{}", env!("CARGO_PKG_VERSION"), crate::build_id()), - "identityGuidance":"Identify deployments by commit/buildId, not version alone. If the expected commit/buildId differs, reconnect or restart the client MCP server connection after updating the binary.", + "identityGuidance":include_str!("text/delivery-server_identity-f8e03915.md"), // Cache-only. The lookup lives in a background task, so adding this to // the identity every response carries costs no network on the call path // and cannot delay a tool call. diff --git a/crates/devup-mcp/src/server/design_drift.rs b/crates/devup-mcp/src/server/design_drift.rs index 01a784aa..f78a3da5 100644 --- a/crates/devup-mcp/src/server/design_drift.rs +++ b/crates/devup-mcp/src/server/design_drift.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value, json}; pub(super) type Fingerprints = BTreeMap; const VERSION: &str = "v1:sha256:"; const KIND: &str = "devup-design-fingerprints"; -const MEANING: &str = "Changed means a field the converter reads changed; it does not mean the rendered screen changed and does not locate the change inside the node. Unchanged means captured converter inputs for that node are identical; it does not prove generated code on disk still matches, because a human may have edited it. Added and removed mean absence in the previous or current capture map, respectively; compare the same capture scope. These fingerprints do not hash generated code, external variable definitions, asset bytes, or pixels."; +const MEANING: &str = include_str!("text/design_drift-MEANING-dfdee151.md"); /// Pure comparison of validated maps from the same fingerprint version. fn compare(previous: &Fingerprints, current: &Fingerprints) -> Value { diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index f87d6d28..ad72c1a6 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -35,1241 +35,6 @@ //! 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::{ - AttachedFile, AuthStatus, BridgeIssue, BridgePathSnapshot, BridgePeer, BridgeRole, - ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, PortOwner, TokenState, -}; -use serde::Serialize; -use serde_json::{Value, json}; - -/// The path a read would take right now. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -enum ActivePath { - Bridge, - Direct, -} - -/// Stands in for the link an agent has to supply itself. Nothing reads it as a -/// link; `requiredArguments` says it must be replaced. -const FIGMA_LINK_PLACEHOLDER: &str = ""; - -/// What `fileKey: null` on an attached file means, said where it is read. -const KEYLESS_NOTE: &str = "This plugin could not report its file key (figma.fileKey is empty, as in Dev Mode), so no key is claimed for its file. It serves reads only while it is the only plugin attached; address it by omitting url, or with figma-bridge://current."; - -/// Backs `devup_figma_auth`'s `status`, `login` and `logout` answers, and is -/// the first half of `doctor`. -/// -/// `status` used to be the direct path's word alone. With a plugin attached -/// and serving it still answered `disconnected`, and an agent asked for one -/// screen concluded Figma was unreachable and asked its user to log in. Both -/// paths are reported now, and the verdict is `disconnected` only when neither -/// can serve. -pub fn connection_report( - status: AuthStatus, - direct: &DirectPathSnapshot, - bridge: Option<&BridgePathSnapshot>, -) -> Value { - // Usable means a read can be sent now: a plugin is visible and there is a - // way to it - this process holds the port, or reads through the one that - // does. A relay whose host went away sees no plugin until it reconnects. - let usable = bridge.filter(|bridge| bridge.available()); - let attached = usable.map_or(&[][..], |bridge| bridge.attached_files.as_slice()); - let direct_available = status == AuthStatus::Connected; - let active_path = if usable.is_some() { - Some(ActivePath::Bridge) - } else if direct_available { - Some(ActivePath::Direct) - } else { - None - }; - let bridge_blocked = bridge.is_some_and(|bridge| { - matches!( - bridge.role, - BridgeRole::Connecting | BridgeRole::Unavailable - ) - }); - json!({ - "connected": active_path.is_some(), - "status": if active_path.is_some() { "connected" } else { "disconnected" }, - "activePath": active_path, - "preferredPath": "bridge", - "paths": { - "bridge": bridge_path(bridge), - "direct": direct_path(status, direct, usable.is_some()), - }, - "nextAction": match active_path { - Some(ActivePath::Bridge) => bridge_next_action(attached), - Some(ActivePath::Direct) => json!({ - "tool": "devup_figma_export", - "arguments": { "url": FIGMA_LINK_PLACEHOLDER, "outputs": ["tsx"] }, - "requiredArguments": ["url"], - "note": if bridge_blocked { - "Only the metered direct path is open, so an export needs the frame's Figma link. The Devup Bridge cannot be used from this process right now; paths.bridge.reason says why and what would change it." - } else { - "Only the metered direct path is open, so an export needs the frame's Figma link. Running the Devup Bridge plugin on the file instead spends no allowance and makes url optional." - }, - }), - None => ways_to_open_a_path( - bridge, - false, - &json!({ "tool": "devup_figma_export", "arguments": { "outputs": ["tsx"] } }), - ), - }, - }) -} - -/// With plugins attached: the call to make, and whether it needs a url. -fn bridge_next_action(attached: &[AttachedFile]) -> Value { - let [file] = attached else { - return several_plugins( - attached, - &json!({ "tool": "devup_figma_export", "arguments": { "outputs": ["tsx"] } }), - ); - }; - let selection = match (&file.context.selection, file.context.single_selection()) { - (_, Some(node)) => format!( - "the node selected in Figma ({:?}, {})", - node.name, - node.node_type.as_deref().unwrap_or("node") - ), - (Some(_), None) => format!( - "the node selected in Figma - select exactly one frame first ({} selected now), or pass frameIds with that frame's node id", - file.context.selection_count.unwrap_or_default() - ), - (None, None) => { - "the node you name in frameIds - this plugin build does not report the Figma selection" - .to_owned() - } - }; - json!({ - "tool": "devup_figma_export", - "arguments": { "outputs": ["tsx"] }, - "note": format!( - "One Devup Bridge plugin is attached, so no url is needed: the export reads the file it has open and {selection}. devup_figma_search and devup_figma_explore take no url either." - ), - }) -} - -/// Several plugins attached: a call without url cannot say which file it -/// means, so each file is offered as `retry` with the link that names it. -pub(super) fn several_plugins(attached: &[AttachedFile], retry: &Value) -> Value { - let files = attached - .iter() - .map(|file| match &file.file_key { - Some(key) => { - let target = devup_mcp_figma::FigmaTarget { - file_key: key.clone(), - node_id: None, - branch_key: None, - }; - let node = file.context.single_selection().map(|node| node.id.as_str()); - let mut call = retry.clone(); - call["arguments"]["url"] = json!(target.link(node)); - call["fileName"] = json!(file.file_name); - call - } - None => json!({ "fileName": file.file_name, "note": KEYLESS_NOTE }), - }) - .collect::>(); - json!({ - "how": format!( - "{} Devup Bridge plugins are attached, so a call without url cannot say which file it means. Pass the url of the file you mean, or close the other plugin windows so one remains.", - attached.len() - ), - "options": files, - }) -} - -/// The ways to open a path to Figma, preferred first. -/// -/// Shared by `status` and by the refusal of a call made without a url, so the -/// two cannot recommend different things. `retry` is the call to make once a -/// path is open, without url; the direct option adds the url it then needs. -/// -/// The bridge option is whatever would open the bridge for *this* process. -/// Running the plugin is that only while a way to the plugin exists - this -/// process holds the port, or reads through the one that does. While the port -/// is changing hands it is waiting a moment, and while something else holds -/// the port it is the repair that names that holder. -pub(super) fn ways_to_open_a_path( - bridge: Option<&BridgePathSnapshot>, - direct_available: bool, - retry: &Value, -) -> Value { - let mut with_url = retry.clone(); - with_url["arguments"]["url"] = json!(FIGMA_LINK_PLACEHOLDER); - with_url["requiredArguments"] = json!(["url"]); - let mut options = Vec::new(); - if let Some(bridge) = bridge { - options.push(bridge_option(bridge, retry)); - } - options.push(if direct_available { - let mut call = with_url; - call["path"] = json!("direct"); - call - } else { - json!({ - "path": "direct", - "tool": "devup_figma_auth", - "arguments": { "action": "login" }, - "then": with_url, - }) - }); - json!({ - "how": match bridge.map(|bridge| bridge.role) { - Some(BridgeRole::Host | BridgeRole::Relay) => { - "Open a path to Figma: run the Devup Bridge plugin (preferred - no login), or use the metered direct path with the frame's Figma link." - } - Some(BridgeRole::Connecting) => { - "The Devup Bridge port is changing hands (see paths.bridge.reason): call status again in a few seconds, or use the metered direct path with the frame's Figma link." - } - Some(BridgeRole::Unavailable) => { - "This devup-mcp cannot use the Devup Bridge right now (see paths.bridge.reason). The first option is what would change that; the metered direct path needs the frame's Figma link." - } - None => { - "This devup-mcp is not listening for the Devup Bridge plugin (see paths.bridge.reason), so only the metered direct path can open here: it needs the frame's Figma link." - } - }, - "options": options, - }) -} - -const RUN_THE_PLUGIN: &str = "In the Figma desktop app, open the file and run Plugins -> Development -> Devup Bridge (imported once from plugin/manifest.json), keeping its window open. No login is needed and no Figma allowance is spent."; - -/// How long the plugin waits before it connects again after its socket closed -/// (`RETRY_MS` in plugin/src/ui.ts), said where a reader waits on it. -const PLUGIN_REATTACH: &str = "about 2 seconds"; - -fn status_again() -> Value { - json!({ "tool": "devup_figma_auth", "arguments": { "action": "status" } }) -} - -/// What would open the bridge for this process. -fn bridge_option(bridge: &BridgePathSnapshot, retry: &Value) -> Value { - let port = port_text(bridge); - match bridge.role { - BridgeRole::Host | BridgeRole::Relay if bridge.handover_from.is_some() => json!({ - "path": "bridge", - "action": format!("Wait a moment: the bridge port just changed hands, and the plugin window that was attached reconnects on its own within {PLUGIN_REATTACH}. If no plugin appears, run Devup Bridge again on the file."), - "then": status_again(), - }), - BridgeRole::Host | BridgeRole::Relay => json!({ - "path": "bridge", - "action": RUN_THE_PLUGIN, - "then": retry, - }), - BridgeRole::Connecting => json!({ - "path": "bridge", - "action": format!("Wait a few seconds: the devup-mcp that held port {port} went away, this process is taking the port over or reconnecting, and the plugin reconnects on its own within {PLUGIN_REATTACH} of the new holder appearing."), - "then": status_again(), - }), - BridgeRole::Unavailable => json!({ - "path": "bridge", - "action": repair(bridge), - "then": status_again(), - }), - } -} - -fn port_text(bridge: &BridgePathSnapshot) -> String { - bridge - .port - .map_or_else(|| "".to_owned(), |port| port.to_string()) -} - -/// How to find what holds the port, on each platform. -fn find_the_holder(port: &str) -> String { - format!( - "find it with `netstat -ano | findstr :{port}` on Windows or `lsof -nP -iTCP:{port} -sTCP:LISTEN` on macOS and Linux" - ) -} - -/// Names a devup-mcp the way a person finds it in a process list. -fn describe_peer(peer: &BridgePeer) -> String { - match &peer.build_id { - Some(build) => format!("pid {}, version {}, build {build}", peer.pid, peer.version), - None => format!("pid {}, version {}", peer.pid, peer.version), - } -} - -fn holder_text(bridge: &BridgePathSnapshot) -> String { - bridge - .host - .as_ref() - .map_or_else(String::new, |host| format!(" ({})", describe_peer(host))) -} - -/// The process the operating system says holds the port, the way a person -/// finds it: its pid, and the executable, whose path shows which MCP client -/// installed it. Without that, how to look it up. -fn owner_text(owner: Option<&PortOwner>, port: &str) -> String { - match owner { - Some(owner) => { - let what = match (&owner.executable, &owner.name) { - (Some(executable), _) => format!(", {executable}"), - (None, Some(name)) => format!(", {name}"), - (None, None) => String::new(), - }; - format!("pid {}{what}", owner.pid) - } - None => find_the_holder(port), - } -} - -/// The one step that would let this process use the bridge again. -fn repair(bridge: &BridgePathSnapshot) -> String { - let port = port_text(bridge); - let holder = holder_text(bridge); - match &bridge.issue { - Some(BridgeIssue::LegacyHost { owner }) => format!( - "The devup-mcp holding port {port} ({}) predates bridge sharing, so this process cannot read through it: restart (or update) the MCP client session that started it. Once it exits, this process takes the port over by itself and the plugin reconnects to it.", - owner_text(owner.as_ref(), &port) - ), - Some(BridgeIssue::ForeignProgram { owner, .. }) => format!( - "Stop the program holding port {port} ({}). The plugin's port is fixed by its manifest; once the port is free this process takes it over by itself.", - owner_text(owner.as_ref(), &port) - ), - Some(BridgeIssue::IncompatibleProtocol { .. }) => format!( - "The devup-mcp holding port {port}{holder} and this one are different releases that cannot relay to each other: restart the MCP client session whose devup-mcp is older so both run the same release." - ), - Some(BridgeIssue::AuthenticationFailed { .. }) => format!( - "Run every devup-mcp on this machine as the same user (they prove themselves to each other with a secret only that user can read), or restart the MCP client session holding port {port}{holder}." - ), - Some(BridgeIssue::SecretUnavailable { .. }) => format!( - "Make the per-user relay secret readable and writable for this user (see paths.bridge.reason), or restart the MCP client session holding port {port}{holder} so this process can take the port over." - ), - Some(BridgeIssue::BindFailed { .. }) | None => format!( - "Free port {port}, or change it in all three places the plugin's README names (the manifest's allowedDomains, src/code.ts and DEVUP_FIGMA_BRIDGE_PORT)." - ), - } -} - -/// What `credentialSource` counts, said in the response rather than only in -/// the README. -/// -/// Two different credentials reach the direct path and only one of them is -/// this field. `credentialSource` is the **client registration** credential — -/// the `client_id`/`client_secret` a pre-registered Figma client is injected -/// with. The **user's** OAuth access token, the thing `login` obtains, is -/// `tokenState`. They move independently, so `credentialSource: "none"` beside -/// `tokenState: "valid"` is an ordinary signed-in session that registered -/// dynamically — not a contradiction, and not a reason to log in again. -/// -/// Read as one word, though, it was: `doctor` answered `credentialSource: -/// "none"` and `reason: "A stored credential is present."` in the same object, -/// and a reader has no way to tell which of the two the tool means. A -/// diagnostic that misreports its own state cannot be used to diagnose -/// anything, so the field now carries what it counts next to the value. -const CREDENTIAL_SOURCE_NOTE: &str = "Where the OAuth *client registration* credential (client_id/client_secret) came from: cli-arg, env, credential-store, or none. This is not the user's access token — that is tokenState, and whether the direct path is usable right now is available. \"none\" only means no pre-registered client is injected, so login registers dynamically under registrationClientName; a signed-in session that registered that way reads credentialSource \"none\" with tokenState \"valid\", which is normal."; - -/// Builds the response for `devup_figma_auth {"action":"doctor"}`: the same -/// path-aware report `status` gives, plus the reference data behind it. -/// -/// `paths` reports what was actually measured (stored-credential presence, a -/// live local-TCP probe, the attached plugins), 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, - direct: DirectPathSnapshot, - bridge: Option, -) -> Value { - let mut report = connection_report(status, &direct, bridge.as_ref()); - report["preferredPathNote"] = json!( - "Two paths reach Figma and they are not equals. The bridge plugin reads through the Figma desktop app: no login, no OAuth, and it spends none of the Figma allowance the direct path is metered against — a single screen costs several reads, so the allowance goes quickly. Reach for the bridge first: it serves every read a collection makes, and direct is the fallback that re-reads what the plugin answers short - data Figma withholds while the file is out of sight, a reference PNG the plugin cannot draw as get_screenshot does, a read an older plugin does not know." - ); - report["clientSetup"] = client_setup(); - report -} - -/// The 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 free right now. -fn direct_path(status: AuthStatus, direct: &DirectPathSnapshot, bridge_available: bool) -> Value { - let direct_available = status == AuthStatus::Connected; - let mut reason = direct_reason( - direct_available, - direct.token_state, - direct.credential_source, - ); - if bridge_available { - reason.push_str( - " While a bridge plugin is attached it serves every read, and this path only re-reads \ - what the plugin answers short: data Figma withholds while the file is out of sight, \ - a reference PNG the plugin cannot draw as get_screenshot does, a read an older \ - plugin does not know.", - ); - } - json!({ - "available": direct_available, - "credentialSource": direct.credential_source, - "credentialSourceNote": CREDENTIAL_SOURCE_NOTE, - "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": reason - }) -} - -/// Reports the path that costs nothing, so `doctor` stops answering a -/// connection question with "log in" and nothing else. -/// -/// This module knew only about `direct`, so every reason it could give ended -/// at `devup_figma_auth login` — including for someone whose plugin was -/// attached and serving, and including when the real problem was that the -/// listener never bound. Naming only the metered path made the metered path -/// the only answer. -/// -/// The states are genuinely different repairs, so each says its own. -/// -/// Only one devup-mcp on a machine can hold the port the plugin knows, and -/// several run at once as a matter of course - one per MCP client session. -/// The one holding it is the `host`; the others are `relay`s that read through -/// it, and they see the same `attachedFiles`. A `relay` whose host just left -/// is `connecting` while it takes the port over or finds whoever did. And -/// `unavailable` names what holds the port when that cannot be read through: -/// a devup-mcp from before sharing, another program, or a devup-mcp that -/// cannot prove it runs for the same user - each with the step that would -/// change it, rather than the generic "not listening" that left a session with -/// no remedy but killing another session's process. -/// -/// `listening` keeps its meaning: this process holds the port. `available` is -/// the test for sending a read now: a plugin is visible and there is a way to -/// it. The bridge needs no credential of any kind, so there is nothing else -/// for it to be waiting on. -fn bridge_path(bridge: Option<&BridgePathSnapshot>) -> Value { - let Some(bridge) = bridge else { - return json!({ - "available": false, - "role": "off", - "listening": false, - "port": null, - "host": null, - "attachedFiles": [], - "reason": "This process is not using the bridge plugin: DEVUP_FIGMA_BRIDGE_PORT is off or 0, or is not a port number. Unset it, or set it to the port in the plugin's manifest, to use the bridge; this process can use the metered direct path only.", - }); - }; - let mut path = json!({ - "available": bridge.available(), - "role": role_name(bridge.role), - "listening": bridge.role == BridgeRole::Host, - "port": bridge.port, - "host": bridge.host.as_ref().map(|host| peer(host, bridge.role == BridgeRole::Host)), - "attachedFiles": bridge.attached_files.iter().map(attached_file).collect::>(), - "attachedFilesNote": "The files the attached plugins have open, with the page in view and what is selected on it - as the devup-mcp holding the bridge port sees them, so every devup-mcp on this machine shows the same list. fileKey is null for a plugin that could not report it (Figma fills figma.fileKey only for a manifest declaring enablePrivatePluginApi, which older builds lack); such a plugin serves reads only while it is the only one attached, because with two there is no way to tell which file is meant.", - "reason": bridge_reason(bridge), - }); - if let Some(issue) = &bridge.issue { - path["issue"] = json!(issue.code()); - } - match &bridge.issue { - // A devup-mcp from before sharing is still the port's devup-mcp, so it - // is the `host` - named by the operating system, since it cannot say - // its own version. - Some(BridgeIssue::LegacyHost { owner: Some(owner) }) => { - path["host"] = json!({ - "pid": owner.pid, - "version": null, - "buildId": null, - "name": owner.name, - "executable": owner.executable, - "thisProcess": false, - }); - } - // Something that is not a devup-mcp is no host; it is only in the way. - Some(BridgeIssue::ForeignProgram { - owner: Some(owner), .. - }) => { - path["holder"] = json!({ - "pid": owner.pid, - "name": owner.name, - "executable": owner.executable, - }); - } - _ => {} - } - if let Some(previous) = &bridge.handover_from { - path["handoverFrom"] = peer(previous, false); - } - path -} - -fn role_name(role: BridgeRole) -> &'static str { - match role { - BridgeRole::Host => "host", - BridgeRole::Relay => "relay", - BridgeRole::Connecting => "connecting", - BridgeRole::Unavailable => "unavailable", - } -} - -fn peer(peer: &BridgePeer, this_process: bool) -> Value { - json!({ - "pid": peer.pid, - "version": peer.version, - "buildId": peer.build_id, - "thisProcess": this_process, - }) -} - -const SERVED_WITH_ONE_PLUGIN: &str = "With exactly one attached, devup_figma_export, devup_figma_search and devup_figma_explore take no url: they read the file it has open, and export and explore start from the node selected in Figma."; - -fn bridge_reason(bridge: &BridgePathSnapshot) -> String { - let port = port_text(bridge); - let holder = holder_text(bridge); - let attached = !bridge.attached_files.is_empty(); - let left = bridge - .handover_from - .as_ref() - .map(|previous| format!(" (pid {})", previous.pid)) - .unwrap_or_default(); - match (bridge.role, &bridge.issue) { - (BridgeRole::Host, _) if attached => format!( - "A plugin is attached to this process, which holds the bridge port {port}. Reads for the files listed in attachedFiles are served through it, spending no Figma allowance and needing no login; other devup-mcp processes on this machine read through this one. {SERVED_WITH_ONE_PLUGIN}" - ), - (BridgeRole::Relay, _) if attached => format!( - "Another devup-mcp on this machine{holder} holds the bridge port {port} and the plugins attach to it; this process reads through it. Reads for the files listed in attachedFiles spend no Figma allowance and need no login. {SERVED_WITH_ONE_PLUGIN}" - ), - (BridgeRole::Host, _) if bridge.handover_from.is_some() => format!( - "This process took the bridge port {port} over moments ago from the devup-mcp that held it{left}, which exited. The plugin window that was attached reconnects on its own within {PLUGIN_REATTACH}; call status again shortly. If no plugin appears, run Devup Bridge again on the file." - ), - (BridgeRole::Relay, _) if bridge.handover_from.is_some() => format!( - "The devup-mcp that held the bridge port {port}{left} exited, and another{holder} took the port over; this process now reads through it. The plugin window that was attached reconnects on its own within {PLUGIN_REATTACH}; call status again shortly. If no plugin appears, run Devup Bridge again on the file." - ), - (BridgeRole::Host, _) => format!( - "The bridge is listening on 127.0.0.1:{port} but no plugin is attached, so every read falls through to the metered direct path. Open the target file in the Figma desktop app and run the Devup Bridge plugin (Plugins -> Development -> Import plugin from manifest... once, using plugin/manifest.json). The bridge works only while that plugin window stays open. If the indicator stays grey, the port in the plugin's manifest allowedDomains and the port here must match." - ), - (BridgeRole::Relay, _) => format!( - "Another devup-mcp on this machine{holder} holds the bridge port {port}, and this process reads through it, but no plugin is attached there, so every read falls through to the metered direct path. Open the target file in the Figma desktop app and run the Devup Bridge plugin (Plugins -> Development -> Import plugin from manifest... once, using plugin/manifest.json); it attaches to that process, and every devup-mcp on this machine can then read through it." - ), - (BridgeRole::Connecting, _) if bridge.handover_from.is_some() => format!( - "The devup-mcp that held the bridge port {port}{left} went away. This process is taking the port over, or reconnecting to whichever process did; the plugin reconnects on its own within {PLUGIN_REATTACH} of the new holder appearing. Nothing can be read through the bridge until then - call status again in a few seconds." - ), - (BridgeRole::Connecting, _) => format!( - "This process is still finding out which devup-mcp holds the bridge port {port}. Call status again in a moment." - ), - (BridgeRole::Unavailable, Some(BridgeIssue::LegacyHost { owner })) => format!( - "Port {port} is held by a devup-mcp built before the bridge could be shared ({}): it serves the plugin on /plugin but has no relay endpoint, so only that process can use the plugin and this one cannot read through it. To use the bridge here, restart (or update) the MCP client session that started it. This process keeps checking, and takes the port over by itself once that process exits.", - owner_text(owner.as_ref(), &port) - ), - (BridgeRole::Unavailable, Some(BridgeIssue::ForeignProgram { detail, owner })) => format!( - "Port {port} is held by a program that is not a devup-mcp ({}; {detail}), so the plugin cannot reach any devup-mcp on this machine. Stop that program. This process keeps checking, and takes the port over by itself once it is free.", - owner_text(owner.as_ref(), &port) - ), - (BridgeRole::Unavailable, Some(BridgeIssue::IncompatibleProtocol { theirs, ours })) => { - format!( - "Port {port} is held by a devup-mcp{holder} that speaks bridge relay protocol {}, and this one speaks {ours}. Rather than risk a wrong answer, this process does not read through it. Restart the MCP client session whose devup-mcp is older so both run the same release.", - theirs.map_or_else(|| "".to_owned(), |version| version.to_string()) - ) - } - (BridgeRole::Unavailable, Some(BridgeIssue::AuthenticationFailed { detail })) => format!( - "Port {port} is held by a process{holder} that could not be verified as this user's devup-mcp ({detail}). Relaying needs both processes to read the same secret, which only this user can read, so a devup-mcp run as another user - or sandboxed away from that secret - cannot share the bridge, and this process does not read through it." - ), - (BridgeRole::Unavailable, Some(BridgeIssue::SecretUnavailable { detail })) => format!( - "Another process{holder} holds the bridge port {port}, but this process could not read or create the per-user relay secret it proves itself with ({detail}), so it cannot read through it." - ), - (BridgeRole::Unavailable, Some(BridgeIssue::BindFailed { detail })) => format!( - "This process cannot open the bridge port {port} ({detail}), and nothing is listening on it." - ), - (BridgeRole::Unavailable, None) => { - format!("This process cannot use the bridge port {port} right now.") - } - } -} - -/// One attached plugin as the caller reads it. The key a read is routed by -/// stays internal: a plugin that could not report its file key has no key to -/// show, and the one standing in for it is not the file's. -fn attached_file(file: &AttachedFile) -> Value { - let mut entry = json!({ - "fileKey": file.file_key, - "fileName": file.file_name, - "currentPage": file.context.current_page, - "selection": file.context.selection, - "selectionCount": file.context.selection_count, - "zoom":file.context.zoom,"uiDevicePixelRatio":file.context.ui_device_pixel_ratio, - "contextFreshness":"last-delivered-event","observedAt":file.context.observed_at, - "lastKnownSelection":file.context.last_known_selection,"lastKnownPage":file.context.last_known_page, - "displayContextEvidenceLimit":"Zoom is Figma viewport zoom; DPR belongs to the plugin UI iframe and does not verify screenshot capture scale.", - }); - if let Some(in_view) = file.context.in_view { - entry["inView"] = json!(in_view); - if !in_view { - entry["inViewNote"] = json!( - "Figma is not showing this file. Once it has been out of view for a minute or two, Figma answers reads of data it has not loaded with \"Unable to establish connection to Figma\" after 10 seconds each. Bring the Figma window to the front and click this file's tab before a collection." - ); - } - } - if file.file_key.is_none() { - entry["fileKeyNote"] = json!(KEYLESS_NOTE); - } - if file.context.selection.is_none() { - entry["selectionNote"] = json!( - "This plugin build does not report its page or selection. Re-run Devup Bridge from this devup-mcp's plugin/manifest.json to have them reported, or name the node with frameIds." - ); - } - entry -} - -/// Says which of the two credentials is present, and never lets one of them -/// stand in for the other. -/// -/// This answered `"A stored credential is present."` the moment `available` -/// was true, reading nothing else. So a perfectly ordinary signed-in session -/// that had registered dynamically reported `credentialSource: "none"` and -/// that sentence inside the same object, and nothing in the object said the -/// two words meant different credentials. A reader has no way to tell which -/// one the tool means, and something whose whole purpose is to be believed -/// about the connection's state cannot afford to be ambiguous about it. -/// -/// Two clauses now, in the order a reader needs them. The first is the user's -/// access token — what `available`/`tokenState` measure, and what carries the -/// next step when there isn't one. The second is the client registration -/// credential — what `credentialSource` measures, and what decides whether -/// `login` registers dynamically or goes straight to the code flow. A -/// pre-registered client still just needs `login`, never `configure` or the -/// waitlist, so that guidance stays where it belongs. -fn direct_reason( - direct_available: bool, - token_state: TokenState, - credential_source: ClientCredentialSource, -) -> String { - let token = match (direct_available, token_state) { - (true, TokenState::Valid) => { - "An authorized access token is stored and unexpired, so the direct path is usable now." - } - (true, TokenState::Expired) => { - "An authorized access token is stored but has expired. It is refreshed on the next \ - call when a refresh token was stored with it, and otherwise needs devup_figma_auth \ - { action: \"login\" } again." - } - // `available` and `tokenState` are read by two separate calls on the - // auth backend, so they can disagree. Saying so is the useful answer; - // picking whichever one makes a tidier sentence is what produced the - // contradiction this function exists to stop. - (true, TokenState::Absent) => { - "The direct path reports itself usable while no access token is stored — these are \ - measured separately and have disagreed. Treat the path as unusable and re-run \ - devup_figma_auth { action: \"login\" }." - } - (false, TokenState::Valid) => { - "An unexpired access token is stored while the direct path reports itself unusable — \ - these are measured separately and have disagreed. Re-run devup_figma_auth \ - { action: \"status\" }, then devup_figma_auth { action: \"login\" } if it still \ - answers disconnected." - } - (false, TokenState::Expired) => { - "The stored access token has expired and was not refreshed. Run devup_figma_auth \ - { action: \"login\" } again." - } - (false, TokenState::Absent) => { - "No access token is stored. Run devup_figma_auth { action: \"login\" } to authorize \ - the direct path." - } - }; - let client = match credential_source { - ClientCredentialSource::None => { - "Separately, no pre-registered client registration credential is injected \ - (credentialSource \"none\"), so login falls back to Dynamic Client Registration \ - under the allowlisted client_name in 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 }, or \ - join the Figma MCP Catalog waitlist (https://www.figma.com/mcp-catalog/)." - } - ClientCredentialSource::CliArg - | ClientCredentialSource::Env - | ClientCredentialSource::CredentialStore => { - "Separately, a pre-registered client registration credential is in play (see \ - credentialSource), so login skips Dynamic Client Registration and goes straight to \ - the authorization code flow." - } - }; - format!("{token} {client}") -} - -fn client_setup() -> Value { - json!({ - "constraints": { - "registerEndpoint": "POST https://api.figma.com/v1/oauth/mcp/register", - "clientNameAllowlist": "Figma approves a registration request's client_name only against an exact-match allowlist (e.g. Codex and Claude Code get 200; OpenCode, opencode, Cursor, and VS Code get 403). A non-approved name returns 403 with a plain-text 'Forbidden' body instead of JSON, which also breaks OAuth error parsing in several clients. Registering a new client is only possible through the waitlist: https://www.figma.com/mcp-catalog/", - "redirectUri": "redirect_uri must use exactly the path /callback and the host 127.0.0.1 (200). A localhost host, or another path such as /mcp/oauth/callback, is rejected with 400.", - "callbackPortCaution": "If the OS or security software already occupies the local OAuth callback port, the browser looks like it redirected successfully, but that request goes to the other process and the client waits forever at 'Waiting for authorization...' with no error. Check first that no other process is using the callback port.", - "personalAccessToken": "A Figma PAT (figd_...) is not supported by the remote MCP through either Authorization: Bearer or X-Figma-Token." - }, - "codex": { - "primary": true, - "hint": "The intended host. devup-mcp registers under client_name Codex by default, so devup_figma_auth { action: \"login\" } completes from a Codex install with no extra flags and no client_id/client_secret. Add --figma-client-name only once your own client is admitted to the Figma MCP catalog.", - "installDevupMcp": { - "file": "~/.codex/config.toml", - "toml": "[mcp_servers.devup-mcp]\ncommand = \"devup-mcp\"\nargs = [\"--allow-write-root\", \"\"]", - "then": "Restart Codex, then call devup_figma_auth { action: \"login\" } once to store the token." - }, - "officialFigmaMcp": "codex mcp add figma --url https://mcp.figma.com/mcp" - }, - "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" - } - } - } - } - } - } - }) -} - -#[cfg(test)] -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(), - } - } - - /// `doctor` used to answer every connection question with "log in", - /// because `direct` was the only path it knew. It now reports the cheaper - /// one first, and distinguishes the three states that need three different - /// repairs: no listener, a listener nobody attached to, and a working - /// plugin. Only the middle one is fixed by running the plugin, and none of - /// them is fixed by logging in. - #[tokio::test] - async fn doctor_prefers_the_bridge_and_separates_its_three_states() { - let absent = doctor_report(AuthStatus::Disconnected, absent_direct_snapshot(), None).await; - assert_eq!(absent["preferredPath"], "bridge"); - assert_eq!(absent["paths"]["bridge"]["listening"], false); - assert_eq!(absent["paths"]["bridge"]["available"], false); - let reason = absent["paths"]["bridge"]["reason"].as_str().unwrap(); - assert!(reason.contains("DEVUP_FIGMA_BRIDGE_PORT"), "{reason}"); - assert!( - !reason.contains("devup_figma_auth"), - "a bridge problem is not repaired by logging in: {reason}" - ); - - let idle = doctor_report( - AuthStatus::Disconnected, - absent_direct_snapshot(), - Some(bridge_with(vec![])), - ) - .await; - assert_eq!(idle["paths"]["bridge"]["listening"], true); - assert_eq!(idle["paths"]["bridge"]["available"], false); - assert_eq!(idle["paths"]["bridge"]["port"], 1993); - assert!( - idle["paths"]["bridge"]["reason"] - .as_str() - .unwrap() - .contains("1993") - ); - - // Attached is the whole test: the bridge needs no credential, so a - // disconnected direct path takes nothing away from it - and the - // verdict follows the bridge rather than the direct path. - let attached = doctor_report( - AuthStatus::Disconnected, - absent_direct_snapshot(), - Some(bridge_with(vec![attached(Some("FileKey123"), Some(1))])), - ) - .await; - assert_eq!(attached["paths"]["bridge"]["available"], true); - assert_eq!( - attached["paths"]["bridge"]["attachedFiles"][0]["fileKey"], - "FileKey123" - ); - assert_eq!(attached["status"], "connected"); - assert_eq!(attached["activePath"], "bridge"); - } - - fn attached(file_key: Option<&str>, selected: Option) -> AttachedFile { - let node = |index: usize| devup_mcp_figma::NodeRef { - id: format!("1:{index}"), - name: format!("Frame {index}"), - node_type: Some("FRAME".to_owned()), - }; - AttachedFile { - target_key: file_key.map_or_else(|| "bridge:7".to_owned(), str::to_owned), - file_key: file_key.map(str::to_owned), - file_name: Some("Landing".to_owned()), - context: devup_mcp_figma::PluginContext { - current_page: Some(devup_mcp_figma::NodeRef { - id: "0:1".to_owned(), - name: "Page 1".to_owned(), - node_type: None, - }), - selection: selected.map(|count| (1..=count).map(node).collect()), - selection_count: selected, - in_view: Some(false), - ..Default::default() - }, - } - } - - fn this_process() -> BridgePeer { - BridgePeer { - pid: 4242, - version: "0.12.0".to_owned(), - build_id: Some("abc1234".to_owned()), - } - } - - /// This process holds the port. - fn bridge_with(attached_files: Vec) -> BridgePathSnapshot { - BridgePathSnapshot { - port: Some(1993), - attached_files, - role: BridgeRole::Host, - host: Some(this_process()), - issue: None, - handover_from: None, - } - } - - /// The states a process that did not get the port has to report as they - /// are: reading through the holder, finding out who took over, or unable - /// to use the bridge - and none of them may be answered with "log in" - /// ahead of the step that repairs the bridge. - #[test] - fn a_process_without_the_port_says_what_it_is_doing_about_it() { - let holder = BridgePeer { - pid: 32536, - ..this_process() - }; - let relay = connection_report( - AuthStatus::Disconnected, - &absent_direct_snapshot(), - Some(&BridgePathSnapshot { - role: BridgeRole::Relay, - host: Some(holder.clone()), - ..bridge_with(vec![attached(None, Some(1))]) - }), - ); - let bridge = &relay["paths"]["bridge"]; - assert_eq!(relay["activePath"], "bridge"); - assert_eq!(bridge["role"], "relay"); - assert_eq!(bridge["available"], true); - assert_eq!(bridge["listening"], false); - assert_eq!(bridge["host"]["pid"], 32536); - assert_eq!(bridge["host"]["buildId"], "abc1234"); - assert_eq!(bridge["host"]["thisProcess"], false); - assert!(!relay.to_string().contains("disconnected"), "{relay}"); - assert!(relay["nextAction"]["arguments"].get("url").is_none()); - - let handing_over = connection_report( - AuthStatus::Disconnected, - &absent_direct_snapshot(), - Some(&BridgePathSnapshot { - role: BridgeRole::Connecting, - host: None, - handover_from: Some(holder.clone()), - ..bridge_with(vec![]) - }), - ); - let bridge = &handing_over["paths"]["bridge"]; - assert_eq!(bridge["role"], "connecting"); - assert_eq!(bridge["available"], false); - assert_eq!(bridge["handoverFrom"]["pid"], 32536); - assert!(bridge["reason"].as_str().unwrap().contains("32536")); - let options = handing_over["nextAction"]["options"].as_array().unwrap(); - assert_eq!(options[0]["path"], "bridge"); - assert_eq!(options[0]["then"]["arguments"]["action"], "status"); - assert_eq!(options[1]["path"], "direct"); - - let legacy = connection_report( - AuthStatus::Disconnected, - &absent_direct_snapshot(), - Some(&BridgePathSnapshot { - role: BridgeRole::Unavailable, - host: None, - issue: Some(BridgeIssue::LegacyHost { owner: None }), - ..bridge_with(vec![]) - }), - ); - let bridge = &legacy["paths"]["bridge"]; - assert_eq!(bridge["issue"], "legacy-host"); - assert!(bridge["host"].is_null()); - assert!(bridge["reason"].as_str().unwrap().contains("restart")); - let options = legacy["nextAction"]["options"].as_array().unwrap(); - assert!( - options[0]["action"].as_str().unwrap().contains(":1993"), - "the repair says how to find the holder: {}", - options[0] - ); - } - - /// The three states `status` has to tell apart. `disconnected` is the - /// verdict only when neither path can serve, and each state names the - /// call that moves it forward. - #[test] - fn the_verdict_follows_whichever_path_can_serve() { - let valid = DirectPathSnapshot { - token_state: devup_mcp_figma::TokenState::Valid, - ..absent_direct_snapshot() - }; - - let bridge_only = connection_report( - AuthStatus::Disconnected, - &absent_direct_snapshot(), - Some(&bridge_with(vec![attached(None, Some(1))])), - ); - assert_eq!(bridge_only["connected"], true); - assert_eq!(bridge_only["status"], "connected"); - assert_eq!(bridge_only["activePath"], "bridge"); - assert_eq!(bridge_only["paths"]["bridge"]["available"], true); - assert_eq!(bridge_only["paths"]["direct"]["available"], false); - assert!( - !bridge_only.to_string().contains("disconnected"), - "nothing may say disconnected while the bridge serves: {bridge_only}" - ); - let file = &bridge_only["paths"]["bridge"]["attachedFiles"][0]; - assert!(file["fileKey"].is_null(), "no key is claimed: {file}"); - assert!( - file.get("targetKey").is_none(), - "the routing key stays internal" - ); - assert_eq!(file["currentPage"]["name"], "Page 1"); - assert_eq!(file["selection"][0]["id"], "1:1"); - assert_eq!(file["selection"][0]["type"], "FRAME"); - // Out of view, status says so before a collection meets Figma's refusals. - assert_eq!(file["inView"], false); - assert!( - file["inViewNote"] - .as_str() - .is_some_and(|note| note.contains("to the front")) - ); - // One plugin, one selected frame: the next call needs no url. - assert_eq!(bridge_only["nextAction"]["tool"], "devup_figma_export"); - assert!(bridge_only["nextAction"]["arguments"].get("url").is_none()); - - let direct_only = - connection_report(AuthStatus::Connected, &valid, Some(&bridge_with(vec![]))); - assert_eq!(direct_only["connected"], true); - assert_eq!(direct_only["activePath"], "direct"); - assert_eq!(direct_only["paths"]["bridge"]["available"], false); - assert_eq!( - direct_only["nextAction"]["requiredArguments"], - json!(["url"]) - ); - - let neither = connection_report( - AuthStatus::Disconnected, - &absent_direct_snapshot(), - Some(&bridge_with(vec![])), - ); - assert_eq!(neither["connected"], false); - assert_eq!(neither["status"], "disconnected"); - assert!(neither["activePath"].is_null()); - let options = neither["nextAction"]["options"].as_array().unwrap(); - assert_eq!(options[0]["path"], "bridge"); - assert_eq!(options[1]["path"], "direct"); - assert_eq!(options[1]["arguments"]["action"], "login"); - } - - /// With two plugins attached a call without url could mean either file, - /// so the next action names each one - with its own link where the plugin - /// reported a key, and without inventing one where it did not. - #[test] - fn several_plugins_are_offered_one_by_one() { - let report = connection_report( - AuthStatus::Disconnected, - &absent_direct_snapshot(), - Some(&bridge_with(vec![ - attached(Some("FileKey123"), Some(1)), - attached(None, Some(0)), - ])), - ); - let options = report["nextAction"]["options"].as_array().unwrap(); - assert_eq!( - options[0]["arguments"]["url"], - "https://www.figma.com/design/FileKey123/devup?node-id=1-1" - ); - assert!(options[1].get("arguments").is_none(), "{}", options[1]); - } - - #[tokio::test] - async fn doctor_report_reflects_measured_auth_status_without_changing_status_shape() { - let connected = doctor_report(AuthStatus::Connected, absent_direct_snapshot(), None).await; - assert_eq!(connected["status"], "connected"); - assert_eq!(connected["paths"]["direct"]["available"], true); - - let disconnected = - doctor_report(AuthStatus::Disconnected, absent_direct_snapshot(), None).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 client_setup_leads_with_codex_and_demotes_the_other_hosts() { - let report = doctor_report(AuthStatus::Disconnected, absent_direct_snapshot(), None).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 doctor_report_surfaces_the_registration_client_name_and_whether_it_is_default() { - let default_report = - doctor_report(AuthStatus::Disconnected, absent_direct_snapshot(), None).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() - }, - None, - ) - .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 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, None).await; - assert_eq!(report["paths"]["direct"]["credentialSource"], "cli-arg"); - assert_eq!(report["paths"]["direct"]["tokenState"], "expired"); - assert_eq!(report["paths"]["direct"]["callbackPort"]["port"], 19876); - assert_eq!(report["paths"]["direct"]["callbackPort"]["free"], false); - // Even with a client credential configured, the reason must not - // point back at the DCR-blocked/waitlist guidance meant for the - // "no credential at all" case. - assert!( - !report["paths"]["direct"]["reason"] - .as_str() - .unwrap() - .contains("waitlist") - ); - } - - /// The response has to be readable as one object without contradicting - /// itself. - /// - /// Measured `doctor` output said `credentialSource: "none"` and - /// `reason: "A stored credential is present."` about the same path, in the - /// same breath. Both sentences were true, of different credentials, and - /// the object said nothing about which was which — so the only tool for - /// diagnosing a connection could not be trusted about the connection. - /// - /// A signed-in session that registered dynamically is exactly that shape, - /// so it is the case pinned here: the reason must speak of the access - /// token it actually means, must not claim a stored *client* credential - /// that `credentialSource` denies, and the field has to carry what it - /// counts. - #[tokio::test] - async fn a_dynamically_registered_session_does_not_claim_a_stored_client_credential() { - let report = doctor_report( - AuthStatus::Connected, - DirectPathSnapshot { - credential_source: ClientCredentialSource::None, - token_state: devup_mcp_figma::TokenState::Valid, - ..absent_direct_snapshot() - }, - None, - ) - .await; - let direct = &report["paths"]["direct"]; - assert_eq!(direct["available"], true); - assert_eq!(direct["credentialSource"], "none"); - assert_eq!(direct["tokenState"], "valid"); - - let reason = direct["reason"].as_str().expect("a reason"); - assert!( - !reason.contains("A stored credential is present."), - "the sentence that read as a denial of credentialSource: {reason}" - ); - assert!( - reason.contains("access token is stored"), - "the reason has to name the token it means: {reason}" - ); - assert!( - reason.contains("no pre-registered client registration credential is injected"), - "and has to agree with credentialSource \"none\": {reason}" - ); - - let note = direct["credentialSourceNote"] - .as_str() - .expect("credentialSource has to say what it counts"); - assert!(note.contains("client_id/client_secret")); - assert!(note.contains("tokenState")); - } - - /// The other half of the same distinction: an injected client credential - /// and no token at all. `credentialSource` is not evidence of being - /// signed in, so the reason must still send the caller to `login` — and - /// must not send a caller who already has a client to `configure` or the - /// waitlist. - #[tokio::test] - async fn a_pre_registered_client_without_a_token_is_still_told_to_log_in() { - let report = doctor_report( - AuthStatus::Disconnected, - DirectPathSnapshot { - credential_source: ClientCredentialSource::CredentialStore, - token_state: devup_mcp_figma::TokenState::Absent, - ..absent_direct_snapshot() - }, - None, - ) - .await; - let direct = &report["paths"]["direct"]; - assert_eq!(direct["available"], false); - assert_eq!(direct["credentialSource"], "credential-store"); - - let reason = direct["reason"].as_str().expect("a reason"); - assert!(reason.contains("No access token is stored."), "{reason}"); - assert!(reason.contains("action: \"login\""), "{reason}"); - assert!( - reason.contains("pre-registered client registration credential is in play"), - "{reason}" - ); - assert!(!reason.contains("waitlist"), "{reason}"); - assert!(!reason.contains("configure"), "{reason}"); - } - - /// `available` and `tokenState` come from two separate calls on the auth - /// backend, so they can disagree. When they do, `doctor` has to report the - /// disagreement — smoothing it into one confident sentence is how the - /// original contradiction got written. - #[tokio::test] - async fn a_disagreement_between_availability_and_token_state_is_reported_as_one() { - for (status, token_state) in [ - (AuthStatus::Connected, devup_mcp_figma::TokenState::Absent), - (AuthStatus::Disconnected, devup_mcp_figma::TokenState::Valid), - ] { - let report = doctor_report( - status, - DirectPathSnapshot { - token_state, - ..absent_direct_snapshot() - }, - None, - ) - .await; - let reason = report["paths"]["direct"]["reason"] - .as_str() - .expect("a reason"); - assert!( - reason.contains("measured separately and have disagreed"), - "{status:?}/{token_state:?} must be reported, not smoothed over: {reason}" - ); - } - } - - /// The verified reference block is the part of this response that was - /// already right, and it stays whole: every constraint that cost a - /// measurement against the real registration endpoint is still published. - #[tokio::test] - async fn the_measured_client_setup_constraints_survive_the_reason_rewrite() { - let report = doctor_report(AuthStatus::Connected, absent_direct_snapshot(), None).await; - let constraints = &report["clientSetup"]["constraints"]; - for key in [ - "registerEndpoint", - "clientNameAllowlist", - "redirectUri", - "callbackPortCaution", - "personalAccessToken", - ] { - assert!( - constraints[key] - .as_str() - .is_some_and(|text| !text.is_empty()), - "clientSetup.constraints.{key} went missing" - ); - } - assert!( - constraints["redirectUri"] - .as_str() - .unwrap() - .contains("/callback") - ); - assert!( - constraints["callbackPortCaution"] - .as_str() - .unwrap() - .contains("Waiting for authorization") - ); - } - - /// `DirectPathSnapshot` structurally cannot carry a client secret (it - /// has no such field — see `oauth.rs`), so `doctor_report` cannot leak - /// one regardless of which credential source is reported. This test - /// pins that invariant at the JSON boundary: the only permitted - /// occurrence of the substring "secret" is the static `clientSetup` - /// reference text that documents *where* a secret goes (field names, - /// not values) — never a real value. - #[tokio::test] - async fn doctor_report_only_mentions_secret_as_a_field_name_never_a_value() { - let snapshot = DirectPathSnapshot { - credential_source: ClientCredentialSource::Env, - token_state: devup_mcp_figma::TokenState::Valid, - callback_port: Some(19876), - callback_port_free: Some(true), - client_name: DEFAULT_CLIENT_NAME.to_owned(), - }; - let report = doctor_report(AuthStatus::Connected, snapshot, None).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")); - } -} +// Source sections share this module to preserve privacy and test names. +include!("diagnostics/response_diagnostics.rs"); +include!("diagnostics/tests.rs"); diff --git a/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs b/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs new file mode 100644 index 00000000..61f44fa2 --- /dev/null +++ b/crates/devup-mcp/src/server/diagnostics/response_diagnostics.rs @@ -0,0 +1,746 @@ + +use devup_mcp_figma::{ + AttachedFile, AuthStatus, BridgeIssue, BridgePathSnapshot, BridgePeer, BridgeRole, + ClientCredentialSource, DEFAULT_CLIENT_NAME, DirectPathSnapshot, PortOwner, TokenState, +}; +use serde::Serialize; +use serde_json::{Value, json}; + +/// The path a read would take right now. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +enum ActivePath { + Bridge, + Direct, +} + +/// Stands in for the link an agent has to supply itself. Nothing reads it as a +/// link; `requiredArguments` says it must be replaced. +const FIGMA_LINK_PLACEHOLDER: &str = ""; + +/// What `fileKey: null` on an attached file means, said where it is read. +const KEYLESS_NOTE: &str = include_str!("../text/diagnostics-KEYLESS_NOTE-bcb2fb02.md"); + +/// Backs `devup_figma_auth`'s `status`, `login` and `logout` answers, and is +/// the first half of `doctor`. +/// +/// `status` used to be the direct path's word alone. With a plugin attached +/// and serving it still answered `disconnected`, and an agent asked for one +/// screen concluded Figma was unreachable and asked its user to log in. Both +/// paths are reported now, and the verdict is `disconnected` only when neither +/// can serve. +pub fn connection_report( + status: AuthStatus, + direct: &DirectPathSnapshot, + bridge: Option<&BridgePathSnapshot>, +) -> Value { + // Usable means a read can be sent now: a plugin is visible and there is a + // way to it - this process holds the port, or reads through the one that + // does. A relay whose host went away sees no plugin until it reconnects. + let usable = bridge.filter(|bridge| bridge.available()); + let attached = usable.map_or(&[][..], |bridge| bridge.attached_files.as_slice()); + let direct_available = status == AuthStatus::Connected; + let active_path = if usable.is_some() { + Some(ActivePath::Bridge) + } else if direct_available { + Some(ActivePath::Direct) + } else { + None + }; + let bridge_blocked = bridge.is_some_and(|bridge| { + matches!( + bridge.role, + BridgeRole::Connecting | BridgeRole::Unavailable + ) + }); + json!({ + "connected": active_path.is_some(), + "status": if active_path.is_some() { "connected" } else { "disconnected" }, + "activePath": active_path, + "preferredPath": "bridge", + "paths": { + "bridge": bridge_path(bridge), + "direct": direct_path(status, direct, usable.is_some()), + }, + "nextAction": match active_path { + Some(ActivePath::Bridge) => bridge_next_action(attached), + Some(ActivePath::Direct) => json!({ + "tool": "devup_figma_export", + "arguments": { "url": FIGMA_LINK_PLACEHOLDER, "outputs": ["tsx"] }, + "requiredArguments": ["url"], + "note": if bridge_blocked { + include_str!("../text/diagnostics-connection_report-d61f53ae.md") + } else { + "Only the metered direct path is open, so an export needs the frame's Figma link. Running the Devup Bridge plugin on the file instead spends no allowance and makes url optional." + }, + }), + None => ways_to_open_a_path( + bridge, + false, + &json!({ "tool": "devup_figma_export", "arguments": { "outputs": ["tsx"] } }), + ), + }, + }) +} + +/// With plugins attached: the call to make, and whether it needs a url. +fn bridge_next_action(attached: &[AttachedFile]) -> Value { + let [file] = attached else { + return several_plugins( + attached, + &json!({ "tool": "devup_figma_export", "arguments": { "outputs": ["tsx"] } }), + ); + }; + let selection = match (&file.context.selection, file.context.single_selection()) { + (_, Some(node)) => format!( + "the node selected in Figma ({:?}, {})", + node.name, + node.node_type.as_deref().unwrap_or("node") + ), + (Some(_), None) => format!( + "the node selected in Figma - select exactly one frame first ({} selected now), or pass frameIds with that frame's node id", + file.context.selection_count.unwrap_or_default() + ), + (None, None) => { + "the node you name in frameIds - this plugin build does not report the Figma selection" + .to_owned() + } + }; + json!({ + "tool": "devup_figma_export", + "arguments": { "outputs": ["tsx"] }, + "note": format!( + "One Devup Bridge plugin is attached, so no url is needed: the export reads the file it has open and {selection}. devup_figma_search and devup_figma_explore take no url either." + ), + }) +} + +/// Several plugins attached: a call without url cannot say which file it +/// means, so each file is offered as `retry` with the link that names it. +pub(super) fn several_plugins(attached: &[AttachedFile], retry: &Value) -> Value { + let files = attached + .iter() + .map(|file| match &file.file_key { + Some(key) => { + let target = devup_mcp_figma::FigmaTarget { + file_key: key.clone(), + node_id: None, + branch_key: None, + }; + let node = file.context.single_selection().map(|node| node.id.as_str()); + let mut call = retry.clone(); + call["arguments"]["url"] = json!(target.link(node)); + call["fileName"] = json!(file.file_name); + call + } + None => json!({ "fileName": file.file_name, "note": KEYLESS_NOTE }), + }) + .collect::>(); + json!({ + "how": format!( + "{} Devup Bridge plugins are attached, so a call without url cannot say which file it means. Pass the url of the file you mean, or close the other plugin windows so one remains.", + attached.len() + ), + "options": files, + }) +} + +/// The ways to open a path to Figma, preferred first. +/// +/// Shared by `status` and by the refusal of a call made without a url, so the +/// two cannot recommend different things. `retry` is the call to make once a +/// path is open, without url; the direct option adds the url it then needs. +/// +/// The bridge option is whatever would open the bridge for *this* process. +/// Running the plugin is that only while a way to the plugin exists - this +/// process holds the port, or reads through the one that does. While the port +/// is changing hands it is waiting a moment, and while something else holds +/// the port it is the repair that names that holder. +pub(super) fn ways_to_open_a_path( + bridge: Option<&BridgePathSnapshot>, + direct_available: bool, + retry: &Value, +) -> Value { + let mut with_url = retry.clone(); + with_url["arguments"]["url"] = json!(FIGMA_LINK_PLACEHOLDER); + with_url["requiredArguments"] = json!(["url"]); + let mut options = Vec::new(); + if let Some(bridge) = bridge { + options.push(bridge_option(bridge, retry)); + } + options.push(if direct_available { + let mut call = with_url; + call["path"] = json!("direct"); + call + } else { + json!({ + "path": "direct", + "tool": "devup_figma_auth", + "arguments": { "action": "login" }, + "then": with_url, + }) + }); + json!({ + "how": match bridge.map(|bridge| bridge.role) { + Some(BridgeRole::Host | BridgeRole::Relay) => { + "Open a path to Figma: run the Devup Bridge plugin (preferred - no login), or use the metered direct path with the frame's Figma link." + } + Some(BridgeRole::Connecting) => { + "The Devup Bridge port is changing hands (see paths.bridge.reason): call status again in a few seconds, or use the metered direct path with the frame's Figma link." + } + Some(BridgeRole::Unavailable) => { + "This devup-mcp cannot use the Devup Bridge right now (see paths.bridge.reason). The first option is what would change that; the metered direct path needs the frame's Figma link." + } + None => { + "This devup-mcp is not listening for the Devup Bridge plugin (see paths.bridge.reason), so only the metered direct path can open here: it needs the frame's Figma link." + } + }, + "options": options, + }) +} + +const RUN_THE_PLUGIN: &str = include_str!("../text/diagnostics-RUN_THE_PLUGIN-6f87e1f9.md"); + +/// How long the plugin waits before it connects again after its socket closed +/// (`RETRY_MS` in plugin/src/ui.ts), said where a reader waits on it. +const PLUGIN_REATTACH: &str = "about 2 seconds"; + +fn status_again() -> Value { + json!({ "tool": "devup_figma_auth", "arguments": { "action": "status" } }) +} + +/// What would open the bridge for this process. +fn bridge_option(bridge: &BridgePathSnapshot, retry: &Value) -> Value { + let port = port_text(bridge); + match bridge.role { + BridgeRole::Host | BridgeRole::Relay if bridge.handover_from.is_some() => json!({ + "path": "bridge", + "action": format!(include_str!("../text/diagnostics-bridge_option-1573849a.md"), PLUGIN_REATTACH = PLUGIN_REATTACH), + "then": status_again(), + }), + BridgeRole::Host | BridgeRole::Relay => json!({ + "path": "bridge", + "action": RUN_THE_PLUGIN, + "then": retry, + }), + BridgeRole::Connecting => json!({ + "path": "bridge", + "action": format!(include_str!("../text/diagnostics-bridge_option-c5748d60.md"), PLUGIN_REATTACH = PLUGIN_REATTACH, port = port), + "then": status_again(), + }), + BridgeRole::Unavailable => json!({ + "path": "bridge", + "action": repair(bridge), + "then": status_again(), + }), + } +} + +fn port_text(bridge: &BridgePathSnapshot) -> String { + bridge + .port + .map_or_else(|| "".to_owned(), |port| port.to_string()) +} + +/// How to find what holds the port, on each platform. +fn find_the_holder(port: &str) -> String { + format!( + "find it with `netstat -ano | findstr :{port}` on Windows or `lsof -nP -iTCP:{port} -sTCP:LISTEN` on macOS and Linux" + ) +} + +/// Names a devup-mcp the way a person finds it in a process list. +fn describe_peer(peer: &BridgePeer) -> String { + match &peer.build_id { + Some(build) => format!("pid {}, version {}, build {build}", peer.pid, peer.version), + None => format!("pid {}, version {}", peer.pid, peer.version), + } +} + +fn holder_text(bridge: &BridgePathSnapshot) -> String { + bridge + .host + .as_ref() + .map_or_else(String::new, |host| format!(" ({})", describe_peer(host))) +} + +/// The process the operating system says holds the port, the way a person +/// finds it: its pid, and the executable, whose path shows which MCP client +/// installed it. Without that, how to look it up. +fn owner_text(owner: Option<&PortOwner>, port: &str) -> String { + match owner { + Some(owner) => { + let what = match (&owner.executable, &owner.name) { + (Some(executable), _) => format!(", {executable}"), + (None, Some(name)) => format!(", {name}"), + (None, None) => String::new(), + }; + format!("pid {}{what}", owner.pid) + } + None => find_the_holder(port), + } +} + +/// The one step that would let this process use the bridge again. +fn repair(bridge: &BridgePathSnapshot) -> String { + let port = port_text(bridge); + let holder = holder_text(bridge); + match &bridge.issue { + Some(BridgeIssue::LegacyHost { owner }) => format!( + include_str!("../text/diagnostics-repair-05473b24.md"), + owner_text(owner.as_ref(), &port), + port = port + ), + Some(BridgeIssue::ForeignProgram { owner, .. }) => format!( + "Stop the program holding port {port} ({}). The plugin's port is fixed by its manifest; once the port is free this process takes it over by itself.", + owner_text(owner.as_ref(), &port) + ), + Some(BridgeIssue::IncompatibleProtocol { .. }) => { + format!( + include_str!("../text/diagnostics-repair-a70db245.md"), + holder = holder, + port = port + ) + } + Some(BridgeIssue::AuthenticationFailed { .. }) => { + format!( + include_str!("../text/diagnostics-repair-c2a44c5b.md"), + holder = holder, + port = port + ) + } + Some(BridgeIssue::SecretUnavailable { .. }) => { + format!( + include_str!("../text/diagnostics-repair-8e741ef8.md"), + holder = holder, + port = port + ) + } + Some(BridgeIssue::BindFailed { .. }) | None => format!( + "Free port {port}, or change it in all three places the plugin's README names (the manifest's allowedDomains, src/code.ts and DEVUP_FIGMA_BRIDGE_PORT)." + ), + } +} + +/// What `credentialSource` counts, said in the response rather than only in +/// the README. +/// +/// Two different credentials reach the direct path and only one of them is +/// this field. `credentialSource` is the **client registration** credential — +/// the `client_id`/`client_secret` a pre-registered Figma client is injected +/// with. The **user's** OAuth access token, the thing `login` obtains, is +/// `tokenState`. They move independently, so `credentialSource: "none"` beside +/// `tokenState: "valid"` is an ordinary signed-in session that registered +/// dynamically — not a contradiction, and not a reason to log in again. +/// +/// Read as one word, though, it was: `doctor` answered `credentialSource: +/// "none"` and `reason: "A stored credential is present."` in the same object, +/// and a reader has no way to tell which of the two the tool means. A +/// diagnostic that misreports its own state cannot be used to diagnose +/// anything, so the field now carries what it counts next to the value. +const CREDENTIAL_SOURCE_NOTE: &str = + include_str!("../text/diagnostics-CREDENTIAL_SOURCE_NOTE-f6bf233e.md"); + +/// Builds the response for `devup_figma_auth {"action":"doctor"}`: the same +/// path-aware report `status` gives, plus the reference data behind it. +/// +/// `paths` reports what was actually measured (stored-credential presence, a +/// live local-TCP probe, the attached plugins), 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, + direct: DirectPathSnapshot, + bridge: Option, +) -> Value { + let mut report = connection_report(status, &direct, bridge.as_ref()); + report["preferredPathNote"] = json!(include_str!("../text/diagnostics-doctor_report-787ad2ad.md")); + report["clientSetup"] = client_setup(); + report +} + +/// The 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 free right now. +fn direct_path(status: AuthStatus, direct: &DirectPathSnapshot, bridge_available: bool) -> Value { + let direct_available = status == AuthStatus::Connected; + let mut reason = direct_reason( + direct_available, + direct.token_state, + direct.credential_source, + ); + if bridge_available { + reason.push_str(include_str!("../text/diagnostics-direct_path-c63b8306.md")); + } + json!({ + "available": direct_available, + "credentialSource": direct.credential_source, + "credentialSourceNote": CREDENTIAL_SOURCE_NOTE, + "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": include_str!("../text/diagnostics-direct_path-6f63807d.md") + }, + "reason": reason + }) +} + +/// Reports the path that costs nothing, so `doctor` stops answering a +/// connection question with "log in" and nothing else. +/// +/// This module knew only about `direct`, so every reason it could give ended +/// at `devup_figma_auth login` — including for someone whose plugin was +/// attached and serving, and including when the real problem was that the +/// listener never bound. Naming only the metered path made the metered path +/// the only answer. +/// +/// The states are genuinely different repairs, so each says its own. +/// +/// Only one devup-mcp on a machine can hold the port the plugin knows, and +/// several run at once as a matter of course - one per MCP client session. +/// The one holding it is the `host`; the others are `relay`s that read through +/// it, and they see the same `attachedFiles`. A `relay` whose host just left +/// is `connecting` while it takes the port over or finds whoever did. And +/// `unavailable` names what holds the port when that cannot be read through: +/// a devup-mcp from before sharing, another program, or a devup-mcp that +/// cannot prove it runs for the same user - each with the step that would +/// change it, rather than the generic "not listening" that left a session with +/// no remedy but killing another session's process. +/// +/// `listening` keeps its meaning: this process holds the port. `available` is +/// the test for sending a read now: a plugin is visible and there is a way to +/// it. The bridge needs no credential of any kind, so there is nothing else +/// for it to be waiting on. +fn bridge_path(bridge: Option<&BridgePathSnapshot>) -> Value { + let Some(bridge) = bridge else { + return json!({ + "available": false, + "role": "off", + "listening": false, + "port": null, + "host": null, + "attachedFiles": [], + "reason": include_str!("../text/diagnostics-bridge_path-5051fedf.md"), + }); + }; + let mut path = json!({ + "available": bridge.available(), + "role": role_name(bridge.role), + "listening": bridge.role == BridgeRole::Host, + "port": bridge.port, + "host": bridge.host.as_ref().map(|host| peer(host, bridge.role == BridgeRole::Host)), + "attachedFiles": bridge.attached_files.iter().map(attached_file).collect::>(), + "attachedFilesNote": include_str!("../text/diagnostics-bridge_path-10545dbe.md"), + "reason": bridge_reason(bridge), + }); + if let Some(issue) = &bridge.issue { + path["issue"] = json!(issue.code()); + } + match &bridge.issue { + // A devup-mcp from before sharing is still the port's devup-mcp, so it + // is the `host` - named by the operating system, since it cannot say + // its own version. + Some(BridgeIssue::LegacyHost { owner: Some(owner) }) => { + path["host"] = json!({ + "pid": owner.pid, + "version": null, + "buildId": null, + "name": owner.name, + "executable": owner.executable, + "thisProcess": false, + }); + } + // Something that is not a devup-mcp is no host; it is only in the way. + Some(BridgeIssue::ForeignProgram { + owner: Some(owner), .. + }) => { + path["holder"] = json!({ + "pid": owner.pid, + "name": owner.name, + "executable": owner.executable, + }); + } + _ => {} + } + if let Some(previous) = &bridge.handover_from { + path["handoverFrom"] = peer(previous, false); + } + path +} + +fn role_name(role: BridgeRole) -> &'static str { + match role { + BridgeRole::Host => "host", + BridgeRole::Relay => "relay", + BridgeRole::Connecting => "connecting", + BridgeRole::Unavailable => "unavailable", + } +} + +fn peer(peer: &BridgePeer, this_process: bool) -> Value { + json!({ + "pid": peer.pid, + "version": peer.version, + "buildId": peer.build_id, + "thisProcess": this_process, + }) +} + +const SERVED_WITH_ONE_PLUGIN: &str = + include_str!("../text/diagnostics-SERVED_WITH_ONE_PLUGIN-a04b8617.md"); + +fn bridge_reason(bridge: &BridgePathSnapshot) -> String { + let port = port_text(bridge); + let holder = holder_text(bridge); + let attached = !bridge.attached_files.is_empty(); + let left = bridge + .handover_from + .as_ref() + .map(|previous| format!(" (pid {})", previous.pid)) + .unwrap_or_default(); + match (bridge.role, &bridge.issue) { + (BridgeRole::Host, _) if attached => { + format!( + include_str!("../text/diagnostics-bridge_reason-bfc91239.md"), + SERVED_WITH_ONE_PLUGIN = SERVED_WITH_ONE_PLUGIN, + port = port + ) + } + (BridgeRole::Relay, _) if attached => { + format!( + include_str!("../text/diagnostics-bridge_reason-2101bbfb.md"), + SERVED_WITH_ONE_PLUGIN = SERVED_WITH_ONE_PLUGIN, + holder = holder, + port = port + ) + } + (BridgeRole::Host, _) if bridge.handover_from.is_some() => { + format!( + include_str!("../text/diagnostics-bridge_reason-9a4710b8.md"), + PLUGIN_REATTACH = PLUGIN_REATTACH, + left = left, + port = port + ) + } + (BridgeRole::Relay, _) if bridge.handover_from.is_some() => { + format!( + include_str!("../text/diagnostics-bridge_reason-a9b48677.md"), + PLUGIN_REATTACH = PLUGIN_REATTACH, + holder = holder, + left = left, + port = port + ) + } + (BridgeRole::Host, _) => { + format!( + include_str!("../text/diagnostics-bridge_reason-6c5cdae3.md"), + port = port + ) + } + (BridgeRole::Relay, _) => { + format!( + include_str!("../text/diagnostics-bridge_reason-76cadd39.md"), + holder = holder, + port = port + ) + } + (BridgeRole::Connecting, _) if bridge.handover_from.is_some() => { + format!( + include_str!("../text/diagnostics-bridge_reason-88ec9725.md"), + PLUGIN_REATTACH = PLUGIN_REATTACH, + left = left, + port = port + ) + } + (BridgeRole::Connecting, _) => format!( + "This process is still finding out which devup-mcp holds the bridge port {port}. Call status again in a moment." + ), + (BridgeRole::Unavailable, Some(BridgeIssue::LegacyHost { owner })) => format!( + include_str!("../text/diagnostics-bridge_reason-c2bc4670.md"), + owner_text(owner.as_ref(), &port), + port = port + ), + (BridgeRole::Unavailable, Some(BridgeIssue::ForeignProgram { detail, owner })) => format!( + include_str!("../text/diagnostics-bridge_reason-d72562c5.md"), + owner_text(owner.as_ref(), &port), + detail = detail, + port = port + ), + (BridgeRole::Unavailable, Some(BridgeIssue::IncompatibleProtocol { theirs, ours })) => { + format!( + include_str!("../text/diagnostics-bridge_reason-8a3715df.md"), + theirs.map_or_else(|| "".to_owned(), |version| version.to_string()), + holder = holder, + ours = ours, + port = port + ) + } + (BridgeRole::Unavailable, Some(BridgeIssue::AuthenticationFailed { detail })) => { + format!( + include_str!("../text/diagnostics-bridge_reason-d82d1e27.md"), + detail = detail, + holder = holder, + port = port + ) + } + (BridgeRole::Unavailable, Some(BridgeIssue::SecretUnavailable { detail })) => { + format!( + include_str!("../text/diagnostics-bridge_reason-938d46a6.md"), + detail = detail, + holder = holder, + port = port + ) + } + (BridgeRole::Unavailable, Some(BridgeIssue::BindFailed { detail })) => format!( + "This process cannot open the bridge port {port} ({detail}), and nothing is listening on it." + ), + (BridgeRole::Unavailable, None) => { + format!("This process cannot use the bridge port {port} right now.") + } + } +} + +/// One attached plugin as the caller reads it. The key a read is routed by +/// stays internal: a plugin that could not report its file key has no key to +/// show, and the one standing in for it is not the file's. +fn attached_file(file: &AttachedFile) -> Value { + let mut entry = json!({ + "fileKey": file.file_key, + "fileName": file.file_name, + "currentPage": file.context.current_page, + "selection": file.context.selection, + "selectionCount": file.context.selection_count, + "zoom":file.context.zoom,"uiDevicePixelRatio":file.context.ui_device_pixel_ratio, + "contextFreshness":"last-delivered-event","observedAt":file.context.observed_at, + "lastKnownSelection":file.context.last_known_selection,"lastKnownPage":file.context.last_known_page, + "displayContextEvidenceLimit":"Zoom is Figma viewport zoom; DPR belongs to the plugin UI iframe and does not verify screenshot capture scale.", + }); + if let Some(in_view) = file.context.in_view { + entry["inView"] = json!(in_view); + if !in_view { + entry["inViewNote"] = json!(include_str!("../text/diagnostics-attached_file-eb5b83b1.md")); + } + } + if file.file_key.is_none() { + entry["fileKeyNote"] = json!(KEYLESS_NOTE); + } + if file.context.selection.is_none() { + entry["selectionNote"] = json!( + "This plugin build does not report its page or selection. Re-run Devup Bridge from this devup-mcp's plugin/manifest.json to have them reported, or name the node with frameIds." + ); + } + entry +} + +/// Says which of the two credentials is present, and never lets one of them +/// stand in for the other. +/// +/// This answered `"A stored credential is present."` the moment `available` +/// was true, reading nothing else. So a perfectly ordinary signed-in session +/// that had registered dynamically reported `credentialSource: "none"` and +/// that sentence inside the same object, and nothing in the object said the +/// two words meant different credentials. A reader has no way to tell which +/// one the tool means, and something whose whole purpose is to be believed +/// about the connection's state cannot afford to be ambiguous about it. +/// +/// Two clauses now, in the order a reader needs them. The first is the user's +/// access token — what `available`/`tokenState` measure, and what carries the +/// next step when there isn't one. The second is the client registration +/// credential — what `credentialSource` measures, and what decides whether +/// `login` registers dynamically or goes straight to the code flow. A +/// pre-registered client still just needs `login`, never `configure` or the +/// waitlist, so that guidance stays where it belongs. +fn direct_reason( + direct_available: bool, + token_state: TokenState, + credential_source: ClientCredentialSource, +) -> String { + let token = match (direct_available, token_state) { + (true, TokenState::Valid) => { + "An authorized access token is stored and unexpired, so the direct path is usable now." + } + (true, TokenState::Expired) => { + include_str!("../text/diagnostics-direct_reason-83c3453a.md") + } + // `available` and `tokenState` are read by two separate calls on the + // auth backend, so they can disagree. Saying so is the useful answer; + // picking whichever one makes a tidier sentence is what produced the + // contradiction this function exists to stop. + (true, TokenState::Absent) => { + include_str!("../text/diagnostics-direct_reason-6336653b.md") + } + (false, TokenState::Valid) => { + include_str!("../text/diagnostics-direct_reason-6f3f9489.md") + } + (false, TokenState::Expired) => { + "The stored access token has expired and was not refreshed. Run devup_figma_auth \ + { action: \"login\" } again." + } + (false, TokenState::Absent) => { + "No access token is stored. Run devup_figma_auth { action: \"login\" } to authorize \ + the direct path." + } + }; + let client = match credential_source { + ClientCredentialSource::None => { + include_str!("../text/diagnostics-direct_reason-3faeaac8.md") + } + ClientCredentialSource::CliArg + | ClientCredentialSource::Env + | ClientCredentialSource::CredentialStore => { + include_str!("../text/diagnostics-direct_reason-df4d5655.md") + } + }; + format!("{token} {client}") +} + +fn client_setup() -> Value { + json!({ + "constraints": { + "registerEndpoint": "POST https://api.figma.com/v1/oauth/mcp/register", + "clientNameAllowlist": include_str!("../text/diagnostics-client_setup-b61acd6f.md"), + "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": include_str!("../text/diagnostics-client_setup-ebbeb457.md"), + "personalAccessToken": "A Figma PAT (figd_...) is not supported by the remote MCP through either Authorization: Bearer or X-Figma-Token." + }, + "codex": { + "primary": true, + "hint": include_str!("../text/diagnostics-client_setup-4350fbba.md"), + "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": include_str!("../text/diagnostics-client_setup-a528a590.md"), + "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" + } + } + } + } + } + } + }) +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/diagnostics/tests.rs b/crates/devup-mcp/src/server/diagnostics/tests.rs new file mode 100644 index 00000000..08464e2e --- /dev/null +++ b/crates/devup-mcp/src/server/diagnostics/tests.rs @@ -0,0 +1,534 @@ + + +#[cfg(test)] +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(), + } + } + + /// `doctor` used to answer every connection question with "log in", + /// because `direct` was the only path it knew. It now reports the cheaper + /// one first, and distinguishes the three states that need three different + /// repairs: no listener, a listener nobody attached to, and a working + /// plugin. Only the middle one is fixed by running the plugin, and none of + /// them is fixed by logging in. + #[tokio::test] + async fn doctor_prefers_the_bridge_and_separates_its_three_states() { + let absent = doctor_report(AuthStatus::Disconnected, absent_direct_snapshot(), None).await; + assert_eq!(absent["preferredPath"], "bridge"); + assert_eq!(absent["paths"]["bridge"]["listening"], false); + assert_eq!(absent["paths"]["bridge"]["available"], false); + let reason = absent["paths"]["bridge"]["reason"].as_str().unwrap(); + assert!(reason.contains("DEVUP_FIGMA_BRIDGE_PORT"), "{reason}"); + assert!( + !reason.contains("devup_figma_auth"), + "a bridge problem is not repaired by logging in: {reason}" + ); + + let idle = doctor_report( + AuthStatus::Disconnected, + absent_direct_snapshot(), + Some(bridge_with(vec![])), + ) + .await; + assert_eq!(idle["paths"]["bridge"]["listening"], true); + assert_eq!(idle["paths"]["bridge"]["available"], false); + assert_eq!(idle["paths"]["bridge"]["port"], 1993); + assert!( + idle["paths"]["bridge"]["reason"] + .as_str() + .unwrap() + .contains("1993") + ); + + // Attached is the whole test: the bridge needs no credential, so a + // disconnected direct path takes nothing away from it - and the + // verdict follows the bridge rather than the direct path. + let attached = doctor_report( + AuthStatus::Disconnected, + absent_direct_snapshot(), + Some(bridge_with(vec![attached(Some("FileKey123"), Some(1))])), + ) + .await; + assert_eq!(attached["paths"]["bridge"]["available"], true); + assert_eq!( + attached["paths"]["bridge"]["attachedFiles"][0]["fileKey"], + "FileKey123" + ); + assert_eq!(attached["status"], "connected"); + assert_eq!(attached["activePath"], "bridge"); + } + + fn attached(file_key: Option<&str>, selected: Option) -> AttachedFile { + let node = |index: usize| devup_mcp_figma::NodeRef { + id: format!("1:{index}"), + name: format!("Frame {index}"), + node_type: Some("FRAME".to_owned()), + }; + AttachedFile { + target_key: file_key.map_or_else(|| "bridge:7".to_owned(), str::to_owned), + file_key: file_key.map(str::to_owned), + file_name: Some("Landing".to_owned()), + context: devup_mcp_figma::PluginContext { + current_page: Some(devup_mcp_figma::NodeRef { + id: "0:1".to_owned(), + name: "Page 1".to_owned(), + node_type: None, + }), + selection: selected.map(|count| (1..=count).map(node).collect()), + selection_count: selected, + in_view: Some(false), + ..Default::default() + }, + } + } + + fn this_process() -> BridgePeer { + BridgePeer { + pid: 4242, + version: "0.12.0".to_owned(), + build_id: Some("abc1234".to_owned()), + } + } + + /// This process holds the port. + fn bridge_with(attached_files: Vec) -> BridgePathSnapshot { + BridgePathSnapshot { + port: Some(1993), + attached_files, + role: BridgeRole::Host, + host: Some(this_process()), + issue: None, + handover_from: None, + } + } + + /// The states a process that did not get the port has to report as they + /// are: reading through the holder, finding out who took over, or unable + /// to use the bridge - and none of them may be answered with "log in" + /// ahead of the step that repairs the bridge. + #[test] + fn a_process_without_the_port_says_what_it_is_doing_about_it() { + let holder = BridgePeer { + pid: 32536, + ..this_process() + }; + let relay = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&BridgePathSnapshot { + role: BridgeRole::Relay, + host: Some(holder.clone()), + ..bridge_with(vec![attached(None, Some(1))]) + }), + ); + let bridge = &relay["paths"]["bridge"]; + assert_eq!(relay["activePath"], "bridge"); + assert_eq!(bridge["role"], "relay"); + assert_eq!(bridge["available"], true); + assert_eq!(bridge["listening"], false); + assert_eq!(bridge["host"]["pid"], 32536); + assert_eq!(bridge["host"]["buildId"], "abc1234"); + assert_eq!(bridge["host"]["thisProcess"], false); + assert!(!relay.to_string().contains("disconnected"), "{relay}"); + assert!(relay["nextAction"]["arguments"].get("url").is_none()); + + let handing_over = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&BridgePathSnapshot { + role: BridgeRole::Connecting, + host: None, + handover_from: Some(holder.clone()), + ..bridge_with(vec![]) + }), + ); + let bridge = &handing_over["paths"]["bridge"]; + assert_eq!(bridge["role"], "connecting"); + assert_eq!(bridge["available"], false); + assert_eq!(bridge["handoverFrom"]["pid"], 32536); + assert!(bridge["reason"].as_str().unwrap().contains("32536")); + let options = handing_over["nextAction"]["options"].as_array().unwrap(); + assert_eq!(options[0]["path"], "bridge"); + assert_eq!(options[0]["then"]["arguments"]["action"], "status"); + assert_eq!(options[1]["path"], "direct"); + + let legacy = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&BridgePathSnapshot { + role: BridgeRole::Unavailable, + host: None, + issue: Some(BridgeIssue::LegacyHost { owner: None }), + ..bridge_with(vec![]) + }), + ); + let bridge = &legacy["paths"]["bridge"]; + assert_eq!(bridge["issue"], "legacy-host"); + assert!(bridge["host"].is_null()); + assert!(bridge["reason"].as_str().unwrap().contains("restart")); + let options = legacy["nextAction"]["options"].as_array().unwrap(); + assert!( + options[0]["action"].as_str().unwrap().contains(":1993"), + "the repair says how to find the holder: {}", + options[0] + ); + } + + /// The three states `status` has to tell apart. `disconnected` is the + /// verdict only when neither path can serve, and each state names the + /// call that moves it forward. + #[test] + fn the_verdict_follows_whichever_path_can_serve() { + let valid = DirectPathSnapshot { + token_state: devup_mcp_figma::TokenState::Valid, + ..absent_direct_snapshot() + }; + + let bridge_only = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&bridge_with(vec![attached(None, Some(1))])), + ); + assert_eq!(bridge_only["connected"], true); + assert_eq!(bridge_only["status"], "connected"); + assert_eq!(bridge_only["activePath"], "bridge"); + assert_eq!(bridge_only["paths"]["bridge"]["available"], true); + assert_eq!(bridge_only["paths"]["direct"]["available"], false); + assert!( + !bridge_only.to_string().contains("disconnected"), + "nothing may say disconnected while the bridge serves: {bridge_only}" + ); + let file = &bridge_only["paths"]["bridge"]["attachedFiles"][0]; + assert!(file["fileKey"].is_null(), "no key is claimed: {file}"); + assert!( + file.get("targetKey").is_none(), + "the routing key stays internal" + ); + assert_eq!(file["currentPage"]["name"], "Page 1"); + assert_eq!(file["selection"][0]["id"], "1:1"); + assert_eq!(file["selection"][0]["type"], "FRAME"); + // Out of view, status says so before a collection meets Figma's refusals. + assert_eq!(file["inView"], false); + assert!( + file["inViewNote"] + .as_str() + .is_some_and(|note| note.contains("to the front")) + ); + // One plugin, one selected frame: the next call needs no url. + assert_eq!(bridge_only["nextAction"]["tool"], "devup_figma_export"); + assert!(bridge_only["nextAction"]["arguments"].get("url").is_none()); + + let direct_only = + connection_report(AuthStatus::Connected, &valid, Some(&bridge_with(vec![]))); + assert_eq!(direct_only["connected"], true); + assert_eq!(direct_only["activePath"], "direct"); + assert_eq!(direct_only["paths"]["bridge"]["available"], false); + assert_eq!( + direct_only["nextAction"]["requiredArguments"], + json!(["url"]) + ); + + let neither = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&bridge_with(vec![])), + ); + assert_eq!(neither["connected"], false); + assert_eq!(neither["status"], "disconnected"); + assert!(neither["activePath"].is_null()); + let options = neither["nextAction"]["options"].as_array().unwrap(); + assert_eq!(options[0]["path"], "bridge"); + assert_eq!(options[1]["path"], "direct"); + assert_eq!(options[1]["arguments"]["action"], "login"); + } + + /// With two plugins attached a call without url could mean either file, + /// so the next action names each one - with its own link where the plugin + /// reported a key, and without inventing one where it did not. + #[test] + fn several_plugins_are_offered_one_by_one() { + let report = connection_report( + AuthStatus::Disconnected, + &absent_direct_snapshot(), + Some(&bridge_with(vec![ + attached(Some("FileKey123"), Some(1)), + attached(None, Some(0)), + ])), + ); + let options = report["nextAction"]["options"].as_array().unwrap(); + assert_eq!( + options[0]["arguments"]["url"], + "https://www.figma.com/design/FileKey123/devup?node-id=1-1" + ); + assert!(options[1].get("arguments").is_none(), "{}", options[1]); + } + + #[tokio::test] + async fn doctor_report_reflects_measured_auth_status_without_changing_status_shape() { + let connected = doctor_report(AuthStatus::Connected, absent_direct_snapshot(), None).await; + assert_eq!(connected["status"], "connected"); + assert_eq!(connected["paths"]["direct"]["available"], true); + + let disconnected = + doctor_report(AuthStatus::Disconnected, absent_direct_snapshot(), None).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 client_setup_leads_with_codex_and_demotes_the_other_hosts() { + let report = doctor_report(AuthStatus::Disconnected, absent_direct_snapshot(), None).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 doctor_report_surfaces_the_registration_client_name_and_whether_it_is_default() { + let default_report = + doctor_report(AuthStatus::Disconnected, absent_direct_snapshot(), None).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() + }, + None, + ) + .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 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, None).await; + assert_eq!(report["paths"]["direct"]["credentialSource"], "cli-arg"); + assert_eq!(report["paths"]["direct"]["tokenState"], "expired"); + assert_eq!(report["paths"]["direct"]["callbackPort"]["port"], 19876); + assert_eq!(report["paths"]["direct"]["callbackPort"]["free"], false); + // Even with a client credential configured, the reason must not + // point back at the DCR-blocked/waitlist guidance meant for the + // "no credential at all" case. + assert!( + !report["paths"]["direct"]["reason"] + .as_str() + .unwrap() + .contains("waitlist") + ); + } + + /// The response has to be readable as one object without contradicting + /// itself. + /// + /// Measured `doctor` output said `credentialSource: "none"` and + /// `reason: "A stored credential is present."` about the same path, in the + /// same breath. Both sentences were true, of different credentials, and + /// the object said nothing about which was which — so the only tool for + /// diagnosing a connection could not be trusted about the connection. + /// + /// A signed-in session that registered dynamically is exactly that shape, + /// so it is the case pinned here: the reason must speak of the access + /// token it actually means, must not claim a stored *client* credential + /// that `credentialSource` denies, and the field has to carry what it + /// counts. + #[tokio::test] + async fn a_dynamically_registered_session_does_not_claim_a_stored_client_credential() { + let report = doctor_report( + AuthStatus::Connected, + DirectPathSnapshot { + credential_source: ClientCredentialSource::None, + token_state: devup_mcp_figma::TokenState::Valid, + ..absent_direct_snapshot() + }, + None, + ) + .await; + let direct = &report["paths"]["direct"]; + assert_eq!(direct["available"], true); + assert_eq!(direct["credentialSource"], "none"); + assert_eq!(direct["tokenState"], "valid"); + + let reason = direct["reason"].as_str().expect("a reason"); + assert!( + !reason.contains("A stored credential is present."), + "the sentence that read as a denial of credentialSource: {reason}" + ); + assert!( + reason.contains("access token is stored"), + "the reason has to name the token it means: {reason}" + ); + assert!( + reason.contains("no pre-registered client registration credential is injected"), + "and has to agree with credentialSource \"none\": {reason}" + ); + + let note = direct["credentialSourceNote"] + .as_str() + .expect("credentialSource has to say what it counts"); + assert!(note.contains("client_id/client_secret")); + assert!(note.contains("tokenState")); + } + + /// The other half of the same distinction: an injected client credential + /// and no token at all. `credentialSource` is not evidence of being + /// signed in, so the reason must still send the caller to `login` — and + /// must not send a caller who already has a client to `configure` or the + /// waitlist. + #[tokio::test] + async fn a_pre_registered_client_without_a_token_is_still_told_to_log_in() { + let report = doctor_report( + AuthStatus::Disconnected, + DirectPathSnapshot { + credential_source: ClientCredentialSource::CredentialStore, + token_state: devup_mcp_figma::TokenState::Absent, + ..absent_direct_snapshot() + }, + None, + ) + .await; + let direct = &report["paths"]["direct"]; + assert_eq!(direct["available"], false); + assert_eq!(direct["credentialSource"], "credential-store"); + + let reason = direct["reason"].as_str().expect("a reason"); + assert!(reason.contains("No access token is stored."), "{reason}"); + assert!(reason.contains("action: \"login\""), "{reason}"); + assert!( + reason.contains("pre-registered client registration credential is in play"), + "{reason}" + ); + assert!(!reason.contains("waitlist"), "{reason}"); + assert!(!reason.contains("configure"), "{reason}"); + } + + /// `available` and `tokenState` come from two separate calls on the auth + /// backend, so they can disagree. When they do, `doctor` has to report the + /// disagreement — smoothing it into one confident sentence is how the + /// original contradiction got written. + #[tokio::test] + async fn a_disagreement_between_availability_and_token_state_is_reported_as_one() { + for (status, token_state) in [ + (AuthStatus::Connected, devup_mcp_figma::TokenState::Absent), + (AuthStatus::Disconnected, devup_mcp_figma::TokenState::Valid), + ] { + let report = doctor_report( + status, + DirectPathSnapshot { + token_state, + ..absent_direct_snapshot() + }, + None, + ) + .await; + let reason = report["paths"]["direct"]["reason"] + .as_str() + .expect("a reason"); + assert!( + reason.contains("measured separately and have disagreed"), + "{status:?}/{token_state:?} must be reported, not smoothed over: {reason}" + ); + } + } + + /// The verified reference block is the part of this response that was + /// already right, and it stays whole: every constraint that cost a + /// measurement against the real registration endpoint is still published. + #[tokio::test] + async fn the_measured_client_setup_constraints_survive_the_reason_rewrite() { + let report = doctor_report(AuthStatus::Connected, absent_direct_snapshot(), None).await; + let constraints = &report["clientSetup"]["constraints"]; + for key in [ + "registerEndpoint", + "clientNameAllowlist", + "redirectUri", + "callbackPortCaution", + "personalAccessToken", + ] { + assert!( + constraints[key] + .as_str() + .is_some_and(|text| !text.is_empty()), + "clientSetup.constraints.{key} went missing" + ); + } + assert!( + constraints["redirectUri"] + .as_str() + .unwrap() + .contains("/callback") + ); + assert!( + constraints["callbackPortCaution"] + .as_str() + .unwrap() + .contains("Waiting for authorization") + ); + } + + /// `DirectPathSnapshot` structurally cannot carry a client secret (it + /// has no such field — see `oauth.rs`), so `doctor_report` cannot leak + /// one regardless of which credential source is reported. This test + /// pins that invariant at the JSON boundary: the only permitted + /// occurrence of the substring "secret" is the static `clientSetup` + /// reference text that documents *where* a secret goes (field names, + /// not values) — never a real value. + #[tokio::test] + async fn doctor_report_only_mentions_secret_as_a_field_name_never_a_value() { + let snapshot = DirectPathSnapshot { + credential_source: ClientCredentialSource::Env, + token_state: devup_mcp_figma::TokenState::Valid, + callback_port: Some(19876), + callback_port_free: Some(true), + client_name: DEFAULT_CLIENT_NAME.to_owned(), + }; + let report = doctor_report(AuthStatus::Connected, snapshot, None).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/export_record.rs b/crates/devup-mcp/src/server/export_record.rs index 9d0fae1e..c0ef5fc2 100644 --- a/crates/devup-mcp/src/server/export_record.rs +++ b/crates/devup-mcp/src/server/export_record.rs @@ -384,7 +384,7 @@ pub(super) fn write_repro( "codeFile": file, "page": finding["routePath"], "screenshot": finding["evidence"]["screenshot"], - "why": "The element's code still equals the export output, yet the browser box differs from the Figma box: the converter produced the wrong layout. Retrying does not change a deterministic result.", + "why": include_str!("text/export_record-write_repro-ab60ad0a.md"), "file": "Report it to devup-mcp with this file. A local override may finish the site, but it must carry a comment naming this file.", }); let path = Path::new(project_root) diff --git a/crates/devup-mcp/src/server/feature_trace.rs b/crates/devup-mcp/src/server/feature_trace.rs index a9631463..9ce4f3ea 100644 --- a/crates/devup-mcp/src/server/feature_trace.rs +++ b/crates/devup-mcp/src/server/feature_trace.rs @@ -1,1132 +1,5 @@ //! Explicit-anchor, read-only cross-layer evidence. No requirement interpretation. -use std::collections::{BTreeMap, BTreeSet}; -use std::io::Read; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use devup_mcp_devup_ui::{ - codegen::{CodegenOptions, generate_component}, - provenance::design_fingerprints, -}; -use devup_mcp_figma::{DevupError, ErrorCode}; -use oxc_allocator::Allocator; -use oxc_ast::ast::*; -use oxc_parser::Parser; -use oxc_span::{GetSpan, SourceType}; -use serde_json::{Value, json}; - -use super::{ - artifacts::ArtifactStore, project_context, project_root, stack_diff, tools::FeatureTraceInput, -}; - -const MAX_BYTES: usize = 65_536; -const MAX_SOURCE_BYTES: u64 = 2_097_152; -const METHODS: &[&str] = &[ - "get", "post", "put", "patch", "delete", "head", "options", "trace", -]; -const ACCEPTED: &str = "Supply at least one explicit anchor: routePath, figmaNodeId, artifactId, operationId, apiPath with method, componentPath, or tableName. Requirement prose is never interpreted as an anchor."; - -fn text(value: &Value) -> &str { - value.as_str().unwrap_or("") -} -fn array(value: &Value) -> &[Value] { - value.as_array().map(Vec::as_slice).unwrap_or(&[]) -} -fn supplied(value: &Option) -> bool { - value.as_deref().is_some_and(|s| !s.trim().is_empty()) -} -fn invalid(message: &str) -> DevupError { - DevupError::new(ErrorCode::DevupInvalidInput, message, false) -} -fn read(path: &Path) -> Result { - let mut source = String::new(); - std::fs::File::open(path) - .map_err(|e| e.to_string())? - .take(MAX_SOURCE_BYTES + 1) - .read_to_string(&mut source) - .map_err(|e| e.to_string())?; - if source.len() as u64 > MAX_SOURCE_BYTES { - return Err("maxSourceBytes exceeded; source was not parsed".into()); - } - Ok(source) -} -fn relative(root: &Path, file: &Path) -> String { - project_context::relative_display(root, file) -} -fn hop(kind: &str, from: Value, to: Value, evidence: Value, reason: Option<&str>) -> Value { - json!({"kind":kind,"from":from,"to":to,"status":if reason.is_some(){"UNVERIFIED"}else{"RESOLVED"}, - "confidence":if reason.is_some(){"low"}else{"medium"},"evidence":evidence,"reason":reason}) -} - -/// Apply the shared ownership patterns to concrete paths, including nested -/// authorities. Authored-looking files inside generated trees stay protected. -fn generated_path(path: &str, ownership: &Value) -> bool { - let wrapped = format!("/{path}"); - array(&ownership["generated"]).iter().any(|entry| { - let pattern = text(&entry["path"]); - if let Some(directory) = pattern.strip_suffix("/**") { - wrapped.contains(&format!("/{directory}/")) - } else if let Some((directory, suffix)) = pattern.split_once("/**/") { - wrapped.contains(&format!("/{directory}/")) - && path.ends_with(suffix.trim_start_matches('*')) - } else { - path == pattern || wrapped.ends_with(&format!("/{pattern}")) - } - }) -} -fn authored_artifact(path: &str, ownership: &Value) -> Value { - let generated = generated_path(path, ownership); - json!({"path":path,"ownership":if generated{"generated"}else{"human-authored"}, - "editTarget":if generated{None}else{Some(path)},"sourceOwnership":ownership}) -} - -struct Operation { - endpoint: Value, - operation: Value, - spec: Arc, - file: String, -} -struct Handler { - file: PathBuf, - source: String, - method: String, - path: String, -} - -fn operations(root: &Path, api: &Value, diagnostics: &mut Vec) -> Vec { - let mut result = vec![]; - for entry in array(&api["specs"]) { - let file = text(&entry["path"]); - let spec = match read(&root.join(file)) - .and_then(|s| serde_json::from_str::(&s).map_err(|e| e.to_string())) - { - Ok(spec) => spec, - Err(reason) => { - diagnostics.push(json!({"path":file,"reason":reason})); - continue; - } - }; - let spec = Arc::new(spec); - for endpoint in array(&entry["endpoints"]) { - let operation = &spec["paths"][text(&endpoint["path"])] - [text(&endpoint["method"]).to_ascii_lowercase()]; - result.push(Operation { - endpoint: endpoint.clone(), - operation: operation.clone(), - spec: spec.clone(), - file: file.into(), - }); - } - } - result -} -fn handlers(root: &Path, diagnostics: &mut Vec) -> Vec { - let (directories, excluded) = project_root::find_dirs_named(root, "routes", 5); - diagnostics.extend(excluded); - let mut result = vec![]; - let mut seen = BTreeSet::new(); - for directory in directories { - let (files, excluded) = project_root::find_matching_files( - &directory, - |p| p.extension().is_some_and(|s| s == "rs"), - 6, - ); - diagnostics.extend(excluded); - for file in files { - if !seen.insert(file.clone()) { - continue; - } - let source = match read(&file) { - Ok(s) => s, - Err(reason) => { - diagnostics.push(json!({"path":relative(root,&file),"reason":reason})); - continue; - } - }; - let prefix = stack_diff::route_url_prefix(file.strip_prefix(&directory).unwrap()); - for (method, path) in stack_diff::extract_vespera_route_attributes(&source) { - result.push(Handler { - file: file.clone(), - source: source.clone(), - method, - path: stack_diff::join_route_url(&prefix, path.as_deref()), - }); - } - } - } - result -} - -#[derive(Default)] -struct Design { - names: BTreeSet, - fields: BTreeSet, - states: BTreeSet, - diagnostics: Vec, - evidence: Value, -} -impl Design { - fn expression(&mut self, e: &Expression<'_>, source: &str) { - match e { - Expression::JSXElement(e) => self.element(e, source), - Expression::JSXFragment(e) => self.children(&e.children, source), - Expression::ParenthesizedExpression(e) => self.expression(&e.expression, source), - Expression::ArrowFunctionExpression(e) => match &e.body { - ArrowFunctionBody::FunctionBody(body) => self.statements(&body.statements, source), - other => { - if let Some(e) = other.as_expression() { - self.expression(e, source); - } - } - }, - Expression::FunctionExpression(e) => { - if let Some(body) = &e.body { - self.statements(&body.statements, source); - } - } - Expression::ConditionalExpression(e) => { - self.expression(&e.consequent, source); - self.expression(&e.alternate, source); - } - Expression::LogicalExpression(e) => { - self.expression(&e.left, source); - self.expression(&e.right, source); - } - _ => {} - } - } - fn declaration(&mut self, d: &Declaration<'_>, source: &str) { - match d { - Declaration::FunctionDeclaration(f) => { - if let Some(body) = &f.body { - self.statements(&body.statements, source); - } - } - Declaration::VariableDeclaration(v) => { - for d in &v.declarations { - if let Some(e) = &d.init { - self.expression(e, source); - } - } - } - _ => {} - } - } - fn statements(&mut self, statements: &[Statement<'_>], source: &str) { - for s in statements { - match s { - Statement::ExportDeclaration(e) => self.declaration(&e.declaration, source), - Statement::ExportDefaultDeclaration(e) => match &e.declaration { - ExportDefaultDeclarationKind::FunctionDeclaration(f) => { - if let Some(body) = &f.body { - self.statements(&body.statements, source); - } - } - other => { - if let Some(e) = other.as_expression() { - self.expression(e, source); - } - } - }, - Statement::FunctionDeclaration(f) => { - if let Some(body) = &f.body { - self.statements(&body.statements, source); - } - } - Statement::VariableDeclaration(v) => { - for d in &v.declarations { - if let Some(e) = &d.init { - self.expression(e, source); - } - } - } - Statement::ReturnStatement(s) => { - if let Some(e) = &s.argument { - self.expression(e, source); - } - } - Statement::ExpressionStatement(e) => self.expression(&e.expression, source), - Statement::BlockStatement(b) => self.statements(&b.body, source), - _ => {} - } - } - } - fn children(&mut self, children: &[JSXChild<'_>], source: &str) { - for child in children { - match child { - JSXChild::Element(e) => self.element(e, source), - JSXChild::Fragment(e) => self.children(&e.children, source), - JSXChild::ExpressionContainer(e) => { - if let Some(e) = e.expression.as_expression() { - self.expression(e, source); - } - } - _ => {} - } - } - } - fn element(&mut self, element: &JSXElement<'_>, source: &str) { - let opening = &element.opening_element; - let name = opening.name.span().source_text(source); - if name.chars().next().is_some_and(char::is_uppercase) { - self.names.insert(name.into()); - } - for attribute in &opening.attributes { - let JSXAttributeItem::Attribute(a) = attribute else { - self.diagnostics.push( - json!({"reason":"Spread JSX props may contain additional field bindings."}), - ); - continue; - }; - let key = a.name.span().source_text(source); - if key == "data-state" { - if let Some(JSXAttributeValue::StringLiteral(value)) = &a.value - && ["loading", "error", "empty", "validation", "authorization"] - .contains(&value.value.as_str()) - { - self.states.insert(value.value.to_string()); - } - continue; - } - if !matches!(key, "name" | "data-field") { - continue; - } - if let Some(JSXAttributeValue::StringLiteral(value)) = &a.value { - self.fields.insert(value.value.to_string()); - } else { - self.diagnostics.push( - json!({"reason":"Nonliteral field binding is unresolved.","attribute":key}), - ); - } - } - self.children(&element.children, source); - } -} -fn design_source(source: &str) -> Design { - let mut design = Design::default(); - if source.len() as u64 > MAX_SOURCE_BYTES { - design - .diagnostics - .push(json!({"reason":"maxSourceBytes exceeded"})); - return design; - } - let allocator = Allocator::default(); - let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); - if parsed.panicked || !parsed.diagnostics.is_empty() { - design - .diagnostics - .push(json!({"reason":"componentTsx could not be parsed; no bindings extracted."})); - return design; - } - design.statements(&parsed.program.body, source); - design -} -async fn design(input: &FeatureTraceInput, artifacts: &ArtifactStore) -> Design { - if supplied(&input.artifact_id) { - if let Some(artifact) = artifacts.get(input.artifact_id.as_deref().unwrap()).await { - if artifact.capabilities.kind == super::artifacts::ArtifactKind::Measurement { - return Design { - evidence: json!({"source":"measurement-only-artifact","artifactId":artifact.artifact_id,"state":"unverified","capabilities":artifact.capabilities}), - diagnostics: vec![ - json!({"reason":"Measurement receipts contain logical dimensions only; design structure, component bindings and code were not acquired. Export a design snapshot for feature tracing."}), - ], - ..Design::default() - }; - } - let node = input.figma_node_id.as_deref().or(artifact - .payload - .target - .node_id - .as_deref()); - if let Some(node) = node { - let interaction_candidates = serde_json::to_value(super::interactions::candidates( - &artifact.payload.snapshot, - &[node.to_owned()], - )) - .unwrap(); - match generate_component( - &artifact.payload.snapshot, - node, - &CodegenOptions::default().with_payload_tokens(&artifact.payload), - ) { - Ok(output) => { - let mut design = design_source(&output.tsx); - design.evidence = json!({"source":"cached-artifact","artifactId":artifact.artifact_id,"nodeId":node,"sourceMap":output.source_map,"designFingerprints":design_fingerprints(&artifact.payload.snapshot),"staleness":"unknown: cached snapshot has not been compared with live Figma"}); - design.evidence["interactiveCandidates"] = interaction_candidates; - return design; - } - Err(e) => { - return Design { - evidence: json!({"source":"cached-artifact","interactiveCandidates":interaction_candidates}), - diagnostics: vec![json!({"reason":e.to_string()})], - ..Design::default() - }; - } - } - } - return Design { - diagnostics: vec![ - json!({"reason":"Artifact does not identify a single node; supply figmaNodeId."}), - ], - ..Design::default() - }; - } - return Design { - diagnostics: vec![ - json!({"reason":"Artifact missing or expired; export the explicit Figma selection again."}), - ], - ..Design::default() - }; - } - if supplied(&input.figma_node_id) - && let Some(source) = &input.component_tsx - { - let mut design = design_source(source); - design.evidence = json!({"source":"caller-supplied-componentTsx","nodeId":input.figma_node_id,"provenance":"UNVERIFIED: caller associates this export with the node; no cached artifact validates that association"}); - // oxc identifies actual comments so string literals cannot spoof hints. - let allocator = Allocator::default(); - let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); - let hints = if parsed.diagnostics.is_empty() && !parsed.panicked { - parsed - .program - .comments - .iter() - .filter_map(|c| { - c.span - .source_text(source) - .strip_prefix("// @devup-interaction ") - }) - .filter_map(|s| serde_json::from_str::(s).ok()) - .filter(|v| v["nodeId"].is_string() && v["prototypeActions"].is_array()) - .collect::>() - } else { - vec![] - }; - design.evidence["interactiveCandidates"] = json!(hints); - return design; - } - Design { - diagnostics: vec![ - json!({"reason":"No design export available. A node id alone does not contain design data; supply artifactId or componentTsx."}), - ], - ..Design::default() - } -} - -fn schema_fields( - schema: &Value, - spec: &Value, - fields: &mut BTreeSet, - reasons: &mut Vec, - depth: usize, -) { - if depth > 16 { - reasons.push("Schema reference/depth cap maxSchemaDepth (16) reached".into()); - return; - } - if let Some(reference) = schema["$ref"].as_str() { - if let Some(target) = reference.strip_prefix('#').and_then(|p| spec.pointer(p)) { - schema_fields(target, spec, fields, reasons, depth + 1); - } else { - reasons.push(format!("Unresolved schema reference {reference}")); - } - return; - } - if let Some(props) = schema["properties"].as_object() { - fields.extend(props.keys().cloned()); - } - for part in array(&schema["allOf"]) { - schema_fields(part, spec, fields, reasons, depth + 1); - } - if schema.get("oneOf").is_some() || schema.get("anyOf").is_some() { - reasons.push("Union schema alternatives do not prove a single field set".into()); - } - if let Some(items) = schema.get("items") { - schema_fields(items, spec, fields, reasons, depth + 1); - } - if schema - .get("additionalProperties") - .is_some_and(|v| v != false) - { - reasons.push("Open-ended additionalProperties prevents proving missing fields".into()); - } -} -fn content_fields( - value: &Value, - spec: &Value, - fields: &mut BTreeSet, - reasons: &mut Vec, -) { - if let Some(reference) = value["$ref"].as_str() { - if let Some(target) = reference.strip_prefix('#').and_then(|p| spec.pointer(p)) { - if target.get("$ref").is_some() { - reasons.push("Chained request/response reference unresolved".into()); - } else { - content_fields(target, spec, fields, reasons); - } - } else { - reasons.push(format!("Unresolved request/response reference {reference}")); - } - return; - } - if let Some(content) = value["content"].as_object() { - if content.len() > 1 { - reasons.push("Multiple media types may have different field sets".into()); - } - for entry in content.values() { - schema_fields(&entry["schema"], spec, fields, reasons, 0); - } - } else { - reasons.push("No explicit content schema provided".into()); - } -} -fn comparison(fields: &BTreeSet, contract: &BTreeSet, reasons: &[String]) -> Value { - json!({"status":if fields.is_empty() || !reasons.is_empty(){"UNVERIFIED"}else{"RESOLVED"}, - "designFields":fields,"contractFields":contract,"designFieldsNotProvided":fields.difference(contract).collect::>(), - "contractFieldsUnused":contract.difference(fields).collect::>(),"reasons":reasons, - "scope":"Literal top-level name/data-field bindings versus declared schema fields; differences are candidates only when status is UNVERIFIED. This is not type compatibility or runtime serialization proof."}) -} -fn compare(design: &Design, operation: &Operation) -> Value { - let mut request = BTreeSet::new(); - let mut response = BTreeSet::new(); - let mut request_reasons = vec![]; - let mut response_reasons = vec![]; - content_fields( - &operation.operation["requestBody"], - &operation.spec, - &mut request, - &mut request_reasons, - ); - if let Some(responses) = operation.operation["responses"].as_object() { - let success = responses - .iter() - .filter(|(status, _)| status.starts_with('2')) - .collect::>(); - if success.len() != 1 { - response_reasons.push("No unique success response schema".into()); - } - for (_, value) in success { - content_fields(value, &operation.spec, &mut response, &mut response_reasons); - } - } else { - response_reasons.push("No response schema".into()); - } - if design.fields.is_empty() { - request_reasons.push( - "Design has no literal contract field bindings; visual labels are not field names" - .into(), - ); - response_reasons.push("Design has no literal contract field bindings".into()); - } - for d in &design.diagnostics { - request_reasons.push(text(&d["reason"]).into()); - response_reasons.push(text(&d["reason"]).into()); - } - json!({"operation":operation.endpoint,"specPath":operation.file,"designEvidence":design.evidence,"request":comparison(&design.fields,&request,&request_reasons),"response":comparison(&design.fields,&response,&response_reasons)}) -} - -fn bound(mut value: Value, max_items: usize) -> Value { - let mut omitted = BTreeMap::::new(); - fn trim(v: &mut Value, path: &str, limit: usize, omitted: &mut BTreeMap) { - match v { - Value::Array(a) => { - if a.len() > limit { - omitted.insert(path.into(), a.len() - limit); - a.truncate(limit); - } - for (i, v) in a.iter_mut().enumerate() { - trim(v, &format!("{path}/{i}"), limit, omitted); - } - } - Value::Object(o) => { - for (k, v) in o { - trim( - v, - &if path.is_empty() { - k.clone() - } else { - format!("{path}/{k}") - }, - limit, - omitted, - ); - } - } - _ => {} - } - } - trim(&mut value, "", max_items, &mut omitted); - let mut caps = vec![]; - if !omitted.is_empty() { - caps.push("maxItems"); - } - value["limits"] = json!({"maxItems":max_items,"maxBytes":MAX_BYTES,"maxSourceBytes":MAX_SOURCE_BYTES,"maxSchemaDepth":16,"byteEncoding":"UTF-8 compact JSON before MCP envelope and server identity"}); - value["truncation"] = json!({"truncated":!caps.is_empty(),"capsHit":caps,"omitted":omitted}); - // Remove the largest payload member until the full response, including cap - // metadata, fits. Every removal is named; never silently slice a hop's reason. - while value.to_string().len() > MAX_BYTES { - let key = value - .as_object() - .unwrap() - .iter() - .filter(|(k, _)| !matches!(k.as_str(), "truncation" | "limits" | "status" | "summary")) - .max_by_key(|(_, v)| v.to_string().len()) - .map(|(k, _)| k.clone()); - let Some(key) = key else { - break; - }; - let removed = value.as_object_mut().unwrap().remove(&key).unwrap(); - value["truncation"]["omitted"][&key] = json!({"reason":"maxBytes","items":removed.as_array().map(Vec::len),"bytes":removed.to_string().len()}); - value["truncation"]["truncated"] = json!(true); - let caps = value["truncation"]["capsHit"].as_array_mut().unwrap(); - if !caps.contains(&json!("maxBytes")) { - caps.push(json!("maxBytes")); - } - } - value -} - -pub(super) async fn run( - input: FeatureTraceInput, - artifacts: &ArtifactStore, -) -> Result { - let max_items = input.max_items.unwrap_or(100); - if !(1..=200).contains(&max_items) { - return Err(invalid("maxItems must be between 1 and 200.")); - } - if supplied(&input.api_path) != supplied(&input.method) { - return Err(invalid("apiPath and method must be supplied together.")); - } - if supplied(&input.method) - && !METHODS.contains( - &input - .method - .as_deref() - .unwrap() - .to_ascii_lowercase() - .as_str(), - ) - { - return Err(invalid("method must be an HTTP method.")); - } - if ![ - &input.route_path, - &input.figma_node_id, - &input.artifact_id, - &input.operation_id, - &input.api_path, - &input.component_path, - &input.table_name, - ] - .into_iter() - .any(supplied) - { - return Ok(json!({"status":"REFUSED","message":ACCEPTED})); - } - if input - .component_tsx - .as_ref() - .is_some_and(|s| s.len() as u64 > MAX_SOURCE_BYTES) - { - return Err(invalid("componentTsx exceeds maxSourceBytes (2097152).")); - } - let ui = project_context::run("ui", input.project_root.as_deref(), None).await?; - let api = project_context::run("api", input.project_root.as_deref(), None).await?; - let db = project_context::run("db", input.project_root.as_deref(), None).await?; - let root = PathBuf::from(text(&ui["projectRoot"])); - if root.as_os_str().is_empty() { - return Ok(bound( - json!({"status":"UNVERIFIED","message":"Project root could not be resolved","evidence":ui}), - max_items, - )); - } - let mut diagnostics = vec![]; - let operations = operations(&root, &api, &mut diagnostics); - let handlers = handlers(&root, &mut diagnostics); - let design = design(&input, artifacts).await; - let mut chain = vec![]; - let mut selected_files = BTreeSet::new(); - let mut selected_ops = BTreeSet::new(); - let routes = array(&ui["routes"]) - .iter() - .filter(|r| input.route_path.as_deref() == r["route"].as_str()) - .collect::>(); - for route in &routes { - selected_files.insert(text(&route["page"]).to_owned()); - chain.push(hop( - "screen-component", - route["route"].clone(), - route["page"].clone(), - json!({"declaration":"App Router page file","inventory":route}), - None, - )); - } - if supplied(&input.route_path) && routes.is_empty() { - chain.push(hop("screen-component",json!(input.route_path),Value::Null,json!({}),Some("No matching route in the bounded UI inventory; no API path inferred from the screen path."))); - } - for c in array(&ui["components"]) { - let direct = input.component_path.as_deref() == c["path"].as_str(); - let sites = array(&c["importedBy"]) - .iter() - .filter(|site| { - array(&site["routePages"]) - .iter() - .any(|p| routes.iter().any(|r| r["page"] == *p)) - }) - .collect::>(); - if direct || !sites.is_empty() { - selected_files.insert(text(&c["path"]).into()); - if !sites.is_empty() { - chain.push(hop("screen-component",json!(input.route_path),c["path"].clone(),json!({"importSites":sites,"meaning":"Static import reachability, not proof of rendering"}),None)); - } - } - } - // Anchor-first: an explicit componentPath is read directly. The capped UI - // inventory only enriches it (import sites, reuse ranking); it never gates it. - let mut anchor_status = Value::Null; - if let Some(requested) = input - .component_path - .as_deref() - .filter(|s| !s.trim().is_empty()) - { - let normalized = anchor_path(requested); - let in_inventory = selected_files.contains(&normalized); - match resolve_anchor(&root, &normalized) { - Ok(()) => { - selected_files.insert(normalized.clone()); - anchor_status = json!({"path":normalized,"read":"direct","inInventory":in_inventory, - "inventoryTruncated":ui["truncation"]["truncated"]}); - if !in_inventory { - chain.push(hop( - "screen-component", - json!(requested), - json!(normalized), - json!({"declaration":"Explicit componentPath anchor read directly from disk","inventoryNote":"Not present in the capped UI inventory; import sites and reuse ranking for this file are unavailable."}), - None, - )); - } - } - Err(reason) => { - anchor_status = json!({"path":normalized,"read":"failed","reason":reason}); - chain.push(hop( - "screen-component", - json!(requested), - Value::Null, - json!({}), - Some(&format!( - "Explicit componentPath could not be read: {reason}" - )), - )); - } - } - } - for (i, op) in operations.iter().enumerate() { - let id = supplied(&input.operation_id) - && input.operation_id.as_deref() == op.endpoint["operationId"].as_str(); - let path = supplied(&input.api_path) - && input.api_path.as_deref() == op.endpoint["path"].as_str() - && input - .method - .as_deref() - .is_some_and(|m| m.eq_ignore_ascii_case(text(&op.endpoint["method"]))); - if id || path { - selected_ops.insert(i); - } - } - if (supplied(&input.operation_id) || supplied(&input.api_path)) && selected_ops.is_empty() { - chain.push(hop("component-api",json!({"operationId":input.operation_id,"apiPath":input.api_path,"method":input.method}),Value::Null,json!({}),Some("Explicit operation anchor was not found in project OpenAPI endpoints."))); - } - let mut navigation_sources = vec![]; - for file in &selected_files { - let source = match read(&root.join(file)) { - Ok(s) => s, - Err(reason) => { - chain.push(hop( - "component-api", - json!(file), - Value::Null, - json!({}), - Some(&reason), - )); - continue; - } - }; - navigation_sources.push((file.clone(), source.clone())); - let (calls, configs) = stack_diff::parse::client_references(&source); - if calls.is_empty() && configs.is_empty() { - chain.push(hop("component-api",json!(file),Value::Null,json!({}),Some("No supported literal API reference in this file; helpers, wrappers and dynamic calls are not resolved."))); - } - for (call_site, identifier) in calls { - let matches = operations - .iter() - .enumerate() - .filter(|(_, op)| { - op.endpoint["operationId"] == identifier - || (identifier.starts_with('/') - && stack_diff::client_path_key(&identifier) - == stack_diff::client_path_key(text(&op.endpoint["path"]))) - }) - .collect::>(); - if matches.len() == 1 { - let (i, op) = matches[0]; - selected_ops.insert(i); - chain.push(hop("component-api",json!(file),op.endpoint.clone(),json!({"callSite":call_site,"identifier":identifier,"specPath":op.file,"parser":"stack_diff::client_references","limitation":"Parser retains identifier but not all call methods; no runtime or method compatibility proof"}),None)); - } else { - chain.push(hop("component-api",json!(file),json!(identifier),json!({"callSite":call_site,"candidateCount":matches.len()}),Some("API identifier is absent or ambiguous across methods/spec authorities; no operation guessed."))); - } - } - for (call_site, name) in configs { - let matches = operations - .iter() - .enumerate() - .filter(|(_, op)| { - array(&op.operation["tags"]).iter().any(|tag| { - ["one", "create", "edit", "fix"] - .iter() - .any(|kind| *tag == format!("devup:{name}:{kind}")) - }) - }) - .collect::>(); - if matches.is_empty() { - chain.push(hop( - "component-api", - json!(file), - json!(name), - json!({"callSite":call_site}), - Some("CRUD configuration has no matching devup:NAME:one/create/edit/fix tags."), - )); - } - for (i, op) in matches { - selected_ops.insert(i); - chain.push(hop("component-api",json!(file),op.endpoint.clone(),json!({"callSite":call_site,"config":name,"tags":op.operation["tags"],"specPath":op.file,"meaning":"CRUD configuration tag membership; no particular action inferred"}),None)); - } - } - } - // A table anchor can travel backwards only through explicit local references. - if let Some(table) = input.table_name.as_deref().filter(|s| !s.trim().is_empty()) { - for (i, op) in operations.iter().enumerate() { - if handlers.iter().any(|h| { - handler_matches(h, op, &root) - && array(&db["tables"]) - .iter() - .filter(|t| t["table"] == table) - .any(|t| { - array(&t["columns"]).iter().any(|c| { - stack_diff::parse::route_mapping(&h.source) - .mentions(table, text(&c["name"])) - }) - }) - }) { - selected_ops.insert(i); - } - } - } - for &i in &selected_ops { - let op = &operations[i]; - let matches = handlers - .iter() - .filter(|h| handler_matches(h, op, &root)) - .collect::>(); - if matches.len() != 1 { - chain.push(hop("api-handler",op.endpoint.clone(),Value::Null,json!({"specPath":op.file,"candidateCount":matches.len()}),Some("No unique local handler in the spec authority; merged prefixes, macro expansion and reexports are not resolved."))); - continue; - } - let h = matches[0]; - let file = relative(&root, &h.file); - chain.push(hop("api-handler",op.endpoint.clone(),json!(file),json!({"attributeMethod":h.method,"fileConventionPath":h.path,"specPath":op.file,"parser":"stack_diff route attribute and path comparison","limitation":"File convention is heuristic; merged-app runtime registration is not proven"}),None)); - let mapping = stack_diff::parse::route_mapping(&h.source); - let mut found = false; - for t in array(&db["tables"]) { - let table = text(&t["table"]); - // Keep model authority local to the spec/handler project. - if Path::new(text(&t["path"])).parent().and_then(Path::parent) - != Path::new(&op.file).parent() - { - continue; - } - let columns = array(&t["columns"]) - .iter() - .filter(|c| mapping.mentions(table, text(&c["name"]))) - .map(|c| c["name"].clone()) - .collect::>(); - if columns.is_empty() { - continue; - } - found = true; - let reason=(handlers.iter().filter(|other|other.file==h.file).count()>1).then_some("Multiple handlers share this file; file-level schema references cannot be attributed to this handler."); - chain.push(hop("handler-model",json!(file),json!(table),json!({"modelPath":t["path"],"parser":"stack_diff::route_mapping","meaning":"Explicit file-level model/schema_type pick/omit references; serialization and type alias usage not proven"}),reason)); - chain.push(hop("model-columns",json!(table),json!(columns),json!({"modelPath":t["path"],"routeFile":file,"columns":columns,"mappingUnresolved":mapping.unresolved}),reason)); - } - if !found { - chain.push(hop("handler-model",json!(file),Value::Null,json!({}),Some("No explicit local model/column mapping resolved; substring guesses are not used."))); - } - } - if let Some(table) = input.table_name.as_deref().filter(|s| !s.trim().is_empty()) { - let tables = array(&db["tables"]) - .iter() - .filter(|t| t["table"] == table) - .collect::>(); - if tables.len() == 1 { - chain.push(hop("model-columns",json!(table),tables[0]["columns"].clone(),json!({"modelPath":tables[0]["path"],"declaration":"Vespertide model JSON columns"}),None)); - } else { - chain.push(hop( - "model-columns", - json!(table), - Value::Null, - json!({"candidateCount":tables.len()}), - Some("Table anchor is absent or ambiguous in the bounded model inventory."), - )); - } - } - if supplied(&input.figma_node_id) || supplied(&input.artifact_id) { - chain.push(hop("design-component",json!({"figmaNodeId":input.figma_node_id,"artifactId":input.artifact_id}),json!(design.names),design.evidence.clone(),Some("Design composition names identify reuse candidates, not an established design-to-code binding."))); - } - for kind in [ - "screen-component", - "component-api", - "api-handler", - "handler-model", - "model-columns", - ] { - if !chain.iter().any(|h| h["kind"] == kind) { - chain.push(hop( - kind, - Value::Null, - Value::Null, - json!({}), - Some("No evidence joins this hop to the supplied anchors; no anchor was inferred."), - )); - } - } - let mut reuse = vec![]; - for c in array(&ui["components"]) { - let mut reasons = vec![]; - let mut score = 0; - for name in array(&c["exports"]).iter().chain(array(&c["localNames"])) { - if design.names.contains(text(name)) { - reasons.push(format!( - "Export/local name {} exactly matches a design JSX reference", - text(name) - )); - score += 100; - } - } - for prop in array(&c["props"]) { - if design.fields.contains(text(&prop["name"])) { - reasons.push(format!( - "Declared prop {} matches a literal design field binding", - text(&prop["name"]) - )); - score += 10; - } - } - if selected_files.contains(text(&c["path"])) { - reasons.push("Already belongs to the explicitly selected component/route slice".into()); - score += 20; - } - if score > 0 { - reuse.push(json!({"component":c,"score":score,"matchReasons":reasons,"confidence":"medium","limitation":"Name/prop compatibility is syntactic; visual equivalence and runtime behavior are unverified"})); - } - } - reuse.sort_by(|a, b| { - b["score"] - .as_u64() - .cmp(&a["score"].as_u64()) - .then_with(|| text(&a["component"]["path"]).cmp(text(&b["component"]["path"]))) - }); - for (i, c) in reuse.iter_mut().enumerate() { - c["rank"] = json!(i + 1); - } - let comparisons = if (supplied(&input.figma_node_id) || supplied(&input.artifact_id)) - && (supplied(&input.operation_id) || supplied(&input.api_path)) - { - selected_ops - .iter() - .filter(|&&i| { - (supplied(&input.operation_id) - && input.operation_id.as_deref() - == operations[i].endpoint["operationId"].as_str()) - || (supplied(&input.api_path) - && input.api_path.as_deref() == operations[i].endpoint["path"].as_str() - && input.method.as_deref().is_some_and(|m| { - m.eq_ignore_ascii_case(text(&operations[i].endpoint["method"])) - })) - }) - .map(|&i| compare(&design, &operations[i])) - .collect::>() - } else { - vec![] - }; - let mut ownership = vec![]; - for layer in [ - "db-entity", - "entity-route", - "route-openapi", - "openapi-client", - ] { - let mut result = json!({"selfCheck":{}}); - stack_diff::attach_source_ownership(&mut result, layer); - ownership.push(result["selfCheck"]["sourceOwnership"].clone()); - } - let mut traced_artifacts = BTreeMap::new(); - for file in &selected_files { - traced_artifacts.insert(file.clone(),json!({"path":file,"ownership":"unverified","editTarget":null, - "reason":"UI inventory identifies source declarations but does not certify that a local file is human-authored. Check generated-code provenance before editing.","sourceOwnership":ownership[3]})); - } - for &i in &selected_ops { - let operation = &operations[i]; - let matches = handlers - .iter() - .filter(|h| handler_matches(h, operation, &root)) - .collect::>(); - let edit_target = (matches.len() == 1) - .then(|| relative(&root, &matches[0].file)) - .filter(|path| !generated_path(path, &ownership[2])); - traced_artifacts.insert(operation.file.clone(),json!({"path":operation.file,"ownership":"generated","editTarget":edit_target,"sourceOwnership":ownership[2]})); - for handler in matches { - let file = relative(&root, &handler.file); - traced_artifacts.insert(file.clone(), authored_artifact(&file, &ownership[1])); - } - } - for hop in &chain { - if let Some(path) = hop["evidence"]["modelPath"].as_str() { - traced_artifacts.insert(path.into(), authored_artifact(path, &ownership[0])); - } - } - let mut implemented = BTreeMap::>::new(); - for file in &selected_files { - if let Ok(source) = read(&root.join(file)) { - for state in design_source(&source).states { - implemented.entry(state).or_default().push(json!({"path":file,"declaration":"literal data-state JSX attribute; runtime reachability remains unverified"})); - } - } - } - for route in &routes { - let page = Path::new(text(&route["page"])); - let owner = if route["appliesTo"] == "." { - PathBuf::new() - } else { - PathBuf::from(text(&route["appliesTo"])) - }; - let app = if page.starts_with(owner.join("src/app")) { - owner.join("src/app") - } else { - owner.join("app") - }; - for component in array(&ui["components"]) { - let boundary = Path::new(text(&component["path"])); - let state = boundary.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - if matches!(state, "loading" | "error") - && boundary.starts_with(&app) - && boundary.parent().is_some_and(|p| page.starts_with(p)) - && component["appliesTo"] == route["appliesTo"] - { - implemented.entry(state.into()).or_default().push(json!({"path":component["path"],"declaration":"App Router boundary component in selected page ancestry; runtime appearance unverified"})); - } - } - } - let states=["loading","error","empty","validation","authorization"].iter().map(|state|json!({"state":state, - "specified":if design.states.contains(*state){"present"}else{"unknown"}, - "implemented":if implemented.contains_key(*state){"present"}else{"unknown"}, - "specifiedEvidence":if design.states.contains(*state){json!({"attribute":"data-state","value":state,"source":design.evidence})}else{Value::Null}, - "implementedEvidence":implemented.get(*state), - "reason":"Presence records explicit state declarations or App Router boundary components only. Missing evidence is unknown, never assumed absent; runtime coverage requires tests."})).collect::>(); - let acceptance=input.acceptance_criteria.iter().map(|criterion|json!({"criterion":criterion,"status":"UNVERIFIED","reason":"Caller acceptance text is preserved without semantic interpretation; supply tests or inspect the linked evidence."})).collect::>(); - let summary = summarize( - &chain, - &anchor_status, - ui["truncation"]["truncated"] == true, - ); - let mut navigation_graph = super::navigation::graph( - &design.evidence["interactiveCandidates"], - &navigation_sources, - &input.navigation_targets, - ); - navigation_graph["sourceRoute"] = json!(input.route_path); - if let Some(route) = &input.route_path - && let Some(edges) = navigation_graph["codeEdges"].as_array_mut() - { - for edge in edges { - edge["sourceFile"] = edge["from"].clone(); - edge["from"] = json!(route); - } - } - Ok(bound( - json!({"status":"OK","summary":summary,"projectRoot":root,"anchors":{"routePath":input.route_path,"figmaNodeId":input.figma_node_id,"artifactId":input.artifact_id,"operationId":input.operation_id,"apiPath":input.api_path,"method":input.method,"componentPath":input.component_path,"tableName":input.table_name},"requirement":input.requirement,"acceptanceMatrix":acceptance, - "chain":chain,"artifacts":traced_artifacts.into_values().collect::>(),"sourceOwnership":ownership,"reuseCandidates":reuse,"designContract":comparisons,"requiredStates":states, - "navigationGraph":navigation_graph, - "designEvidence":design.evidence,"designDiagnostics":design.diagnostics,"diagnostics":diagnostics, - "inventoryEvidence":{"uiTruncation":ui["truncation"],"uiLimits":ui["limits"],"uiDiagnostics":ui["diagnostics"],"uiExcludedPaths":ui["excludedPaths"],"unparsedFiles":ui["unparsedFiles"],"api":{"found":api["found"],"excludedPaths":api["excludedPaths"],"authorityNote":api["authorityNote"],"issues":array(&api["specs"]).iter().filter(|v|v.get("parseError").is_some()||v.get("readError").is_some()).collect::>()}, - "db":{"found":db["found"],"excludedPaths":db["excludedPaths"],"authorityNote":db["authorityNote"],"issues":array(&db["tables"]).iter().filter(|v|v.get("parseError").is_some()||v.get("readError").is_some()).collect::>()} -}, - "limitations":["Read-only static evidence, never runtime execution proof. Shared parser findings retain low/medium confidence.","Missing results do not prove absence: scan depth, UI inventory caps, unreadable files, dynamic imports/calls, wrappers and macro expansion can hide links.","Source ownership comes unchanged from stack_diff; edit authored models/routes/frontend and regenerate generated artifacts."]}), - max_items, - )) -} -/// Normalise a caller-supplied component path to the inventory's `a/b/c.tsx` form. -fn anchor_path(path: &str) -> String { - let unified = path.trim().replace('\\', "/"); - unified.trim_start_matches("./").to_owned() -} -/// An anchor must be a regular file strictly inside the project root. -fn resolve_anchor(root: &Path, relative_path: &str) -> Result<(), String> { - let candidate = Path::new(relative_path); - if candidate.is_absolute() - || candidate - .components() - .any(|c| matches!(c, std::path::Component::ParentDir)) - { - return Err("path must be relative to the project root and may not contain '..'".into()); - } - let full = root.join(candidate); - let canonical = full.canonicalize().map_err(|e| format!("{e}"))?; - let canonical_root = root.canonicalize().map_err(|e| format!("{e}"))?; - if !canonical.starts_with(&canonical_root) { - return Err("path resolves outside the project root".into()); - } - if !canonical.is_file() { - return Err("path is not a regular file".into()); - } - Ok(()) -} - -/// Summary-first digest: what is resolved, what is not, and what a human must -/// still check. Everything else in the response is detail behind it. -fn summarize(chain: &[Value], anchor: &Value, truncated_inventory: bool) -> Value { - let unresolved = chain - .iter() - .filter(|h| h["status"] == "UNVERIFIED") - .map(|h| json!({"kind":h["kind"],"from":h["from"],"reason":h["reason"]})) - .collect::>(); - let resolved = chain.iter().filter(|h| h["status"] == "RESOLVED").count(); - let mut required = vec![]; - if anchor["read"] == "failed" { - required.push(json!( - "Fix componentPath: the explicit anchor could not be read." - )); - } - let mut recommended = vec![]; - if truncated_inventory { - recommended.push(json!("UI inventory was capped; import sites and reuse ranking may be incomplete. Anchor files were still read directly.")); - } - if !unresolved.is_empty() { - recommended.push(json!( - "Verify each UNVERIFIED hop manually; absence of a link is not proof of absence." - )); - } - json!({"hopsResolved":resolved,"hopsUnverified":unresolved.len(),"anchor":anchor, - "required":required,"recommended":recommended,"unverified":unresolved}) -} -fn handler_matches(handler: &Handler, operation: &Operation, root: &Path) -> bool { - let authority = root.join(&operation.file).parent().unwrap().to_path_buf(); - handler.file.starts_with(authority.join("src/routes")) - && stack_diff::route_comparison_key(&handler.method, &handler.path) - == stack_diff::route_comparison_key( - text(&operation.endpoint["method"]), - text(&operation.endpoint["path"]), - ) -} - -#[cfg(test)] -#[path = "feature_trace_tests.rs"] -mod tests; +// Source sections share this module to preserve privacy and test names. +include!("feature_trace/types_and_helpers.rs"); +include!("feature_trace/run.rs"); diff --git a/crates/devup-mcp/src/server/feature_trace/run.rs b/crates/devup-mcp/src/server/feature_trace/run.rs new file mode 100644 index 00000000..5a7de0e6 --- /dev/null +++ b/crates/devup-mcp/src/server/feature_trace/run.rs @@ -0,0 +1,546 @@ + + +pub(super) async fn run( + input: FeatureTraceInput, + artifacts: &ArtifactStore, +) -> Result { + let max_items = input.max_items.unwrap_or(100); + if !(1..=200).contains(&max_items) { + return Err(invalid("maxItems must be between 1 and 200.")); + } + if supplied(&input.api_path) != supplied(&input.method) { + return Err(invalid("apiPath and method must be supplied together.")); + } + if supplied(&input.method) + && !METHODS.contains( + &input + .method + .as_deref() + .unwrap() + .to_ascii_lowercase() + .as_str(), + ) + { + return Err(invalid("method must be an HTTP method.")); + } + if ![ + &input.route_path, + &input.figma_node_id, + &input.artifact_id, + &input.operation_id, + &input.api_path, + &input.component_path, + &input.table_name, + ] + .into_iter() + .any(supplied) + { + return Ok(json!({"status":"REFUSED","message":ACCEPTED})); + } + if input + .component_tsx + .as_ref() + .is_some_and(|s| s.len() as u64 > MAX_SOURCE_BYTES) + { + return Err(invalid("componentTsx exceeds maxSourceBytes (2097152).")); + } + let ui = project_context::run("ui", input.project_root.as_deref(), None).await?; + let api = project_context::run("api", input.project_root.as_deref(), None).await?; + let db = project_context::run("db", input.project_root.as_deref(), None).await?; + let root = PathBuf::from(text(&ui["projectRoot"])); + if root.as_os_str().is_empty() { + return Ok(bound( + json!({"status":"UNVERIFIED","message":"Project root could not be resolved","evidence":ui}), + max_items, + )); + } + let mut diagnostics = vec![]; + let operations = operations(&root, &api, &mut diagnostics); + let handlers = handlers(&root, &mut diagnostics); + let design = design(&input, artifacts).await; + let mut chain = vec![]; + let mut selected_files = BTreeSet::new(); + let mut selected_ops = BTreeSet::new(); + let routes = array(&ui["routes"]) + .iter() + .filter(|r| input.route_path.as_deref() == r["route"].as_str()) + .collect::>(); + for route in &routes { + selected_files.insert(text(&route["page"]).to_owned()); + chain.push(hop( + "screen-component", + route["route"].clone(), + route["page"].clone(), + json!({"declaration":"App Router page file","inventory":route}), + None, + )); + } + if supplied(&input.route_path) && routes.is_empty() { + chain.push(hop("screen-component",json!(input.route_path),Value::Null,json!({}),Some("No matching route in the bounded UI inventory; no API path inferred from the screen path."))); + } + for c in array(&ui["components"]) { + let direct = input.component_path.as_deref() == c["path"].as_str(); + let sites = array(&c["importedBy"]) + .iter() + .filter(|site| { + array(&site["routePages"]) + .iter() + .any(|p| routes.iter().any(|r| r["page"] == *p)) + }) + .collect::>(); + if direct || !sites.is_empty() { + selected_files.insert(text(&c["path"]).into()); + if !sites.is_empty() { + chain.push(hop("screen-component",json!(input.route_path),c["path"].clone(),json!({"importSites":sites,"meaning":"Static import reachability, not proof of rendering"}),None)); + } + } + } + // Anchor-first: an explicit componentPath is read directly. The capped UI + // inventory only enriches it (import sites, reuse ranking); it never gates it. + let mut anchor_status = Value::Null; + if let Some(requested) = input + .component_path + .as_deref() + .filter(|s| !s.trim().is_empty()) + { + let normalized = anchor_path(requested); + let in_inventory = selected_files.contains(&normalized); + match resolve_anchor(&root, &normalized) { + Ok(()) => { + selected_files.insert(normalized.clone()); + anchor_status = json!({"path":normalized,"read":"direct","inInventory":in_inventory, + "inventoryTruncated":ui["truncation"]["truncated"]}); + if !in_inventory { + chain.push(hop( + "screen-component", + json!(requested), + json!(normalized), + json!({"declaration":"Explicit componentPath anchor read directly from disk","inventoryNote":"Not present in the capped UI inventory; import sites and reuse ranking for this file are unavailable."}), + None, + )); + } + } + Err(reason) => { + anchor_status = json!({"path":normalized,"read":"failed","reason":reason}); + chain.push(hop( + "screen-component", + json!(requested), + Value::Null, + json!({}), + Some(&format!( + "Explicit componentPath could not be read: {reason}" + )), + )); + } + } + } + for (i, op) in operations.iter().enumerate() { + let id = supplied(&input.operation_id) + && input.operation_id.as_deref() == op.endpoint["operationId"].as_str(); + let path = supplied(&input.api_path) + && input.api_path.as_deref() == op.endpoint["path"].as_str() + && input + .method + .as_deref() + .is_some_and(|m| m.eq_ignore_ascii_case(text(&op.endpoint["method"]))); + if id || path { + selected_ops.insert(i); + } + } + if (supplied(&input.operation_id) || supplied(&input.api_path)) && selected_ops.is_empty() { + chain.push(hop("component-api",json!({"operationId":input.operation_id,"apiPath":input.api_path,"method":input.method}),Value::Null,json!({}),Some("Explicit operation anchor was not found in project OpenAPI endpoints."))); + } + let mut navigation_sources = vec![]; + for file in &selected_files { + let source = match read(&root.join(file)) { + Ok(s) => s, + Err(reason) => { + chain.push(hop( + "component-api", + json!(file), + Value::Null, + json!({}), + Some(&reason), + )); + continue; + } + }; + navigation_sources.push((file.clone(), source.clone())); + let (calls, configs) = stack_diff::parse::client_references(&source); + if calls.is_empty() && configs.is_empty() { + chain.push(hop("component-api",json!(file),Value::Null,json!({}),Some("No supported literal API reference in this file; helpers, wrappers and dynamic calls are not resolved."))); + } + for (call_site, identifier) in calls { + let matches = operations + .iter() + .enumerate() + .filter(|(_, op)| { + op.endpoint["operationId"] == identifier + || (identifier.starts_with('/') + && stack_diff::client_path_key(&identifier) + == stack_diff::client_path_key(text(&op.endpoint["path"]))) + }) + .collect::>(); + if matches.len() == 1 { + let (i, op) = matches[0]; + selected_ops.insert(i); + chain.push(hop("component-api",json!(file),op.endpoint.clone(),json!({"callSite":call_site,"identifier":identifier,"specPath":op.file,"parser":"stack_diff::client_references","limitation":"Parser retains identifier but not all call methods; no runtime or method compatibility proof"}),None)); + } else { + chain.push(hop("component-api",json!(file),json!(identifier),json!({"callSite":call_site,"candidateCount":matches.len()}),Some("API identifier is absent or ambiguous across methods/spec authorities; no operation guessed."))); + } + } + for (call_site, name) in configs { + let matches = operations + .iter() + .enumerate() + .filter(|(_, op)| { + array(&op.operation["tags"]).iter().any(|tag| { + ["one", "create", "edit", "fix"] + .iter() + .any(|kind| *tag == format!("devup:{name}:{kind}")) + }) + }) + .collect::>(); + if matches.is_empty() { + chain.push(hop( + "component-api", + json!(file), + json!(name), + json!({"callSite":call_site}), + Some("CRUD configuration has no matching devup:NAME:one/create/edit/fix tags."), + )); + } + for (i, op) in matches { + selected_ops.insert(i); + chain.push(hop("component-api",json!(file),op.endpoint.clone(),json!({"callSite":call_site,"config":name,"tags":op.operation["tags"],"specPath":op.file,"meaning":"CRUD configuration tag membership; no particular action inferred"}),None)); + } + } + } + // A table anchor can travel backwards only through explicit local references. + if let Some(table) = input.table_name.as_deref().filter(|s| !s.trim().is_empty()) { + for (i, op) in operations.iter().enumerate() { + if handlers.iter().any(|h| { + handler_matches(h, op, &root) + && array(&db["tables"]) + .iter() + .filter(|t| t["table"] == table) + .any(|t| { + array(&t["columns"]).iter().any(|c| { + stack_diff::parse::route_mapping(&h.source) + .mentions(table, text(&c["name"])) + }) + }) + }) { + selected_ops.insert(i); + } + } + } + for &i in &selected_ops { + let op = &operations[i]; + let matches = handlers + .iter() + .filter(|h| handler_matches(h, op, &root)) + .collect::>(); + if matches.len() != 1 { + chain.push(hop("api-handler",op.endpoint.clone(),Value::Null,json!({"specPath":op.file,"candidateCount":matches.len()}),Some("No unique local handler in the spec authority; merged prefixes, macro expansion and reexports are not resolved."))); + continue; + } + let h = matches[0]; + let file = relative(&root, &h.file); + chain.push(hop("api-handler",op.endpoint.clone(),json!(file),json!({"attributeMethod":h.method,"fileConventionPath":h.path,"specPath":op.file,"parser":"stack_diff route attribute and path comparison","limitation":"File convention is heuristic; merged-app runtime registration is not proven"}),None)); + let mapping = stack_diff::parse::route_mapping(&h.source); + let mut found = false; + for t in array(&db["tables"]) { + let table = text(&t["table"]); + // Keep model authority local to the spec/handler project. + if Path::new(text(&t["path"])).parent().and_then(Path::parent) + != Path::new(&op.file).parent() + { + continue; + } + let columns = array(&t["columns"]) + .iter() + .filter(|c| mapping.mentions(table, text(&c["name"]))) + .map(|c| c["name"].clone()) + .collect::>(); + if columns.is_empty() { + continue; + } + found = true; + let reason=(handlers.iter().filter(|other|other.file==h.file).count()>1).then_some("Multiple handlers share this file; file-level schema references cannot be attributed to this handler."); + chain.push(hop("handler-model",json!(file),json!(table),json!({"modelPath":t["path"],"parser":"stack_diff::route_mapping","meaning":"Explicit file-level model/schema_type pick/omit references; serialization and type alias usage not proven"}),reason)); + chain.push(hop("model-columns",json!(table),json!(columns),json!({"modelPath":t["path"],"routeFile":file,"columns":columns,"mappingUnresolved":mapping.unresolved}),reason)); + } + if !found { + chain.push(hop("handler-model",json!(file),Value::Null,json!({}),Some("No explicit local model/column mapping resolved; substring guesses are not used."))); + } + } + if let Some(table) = input.table_name.as_deref().filter(|s| !s.trim().is_empty()) { + let tables = array(&db["tables"]) + .iter() + .filter(|t| t["table"] == table) + .collect::>(); + if tables.len() == 1 { + chain.push(hop("model-columns",json!(table),tables[0]["columns"].clone(),json!({"modelPath":tables[0]["path"],"declaration":"Vespertide model JSON columns"}),None)); + } else { + chain.push(hop( + "model-columns", + json!(table), + Value::Null, + json!({"candidateCount":tables.len()}), + Some("Table anchor is absent or ambiguous in the bounded model inventory."), + )); + } + } + if supplied(&input.figma_node_id) || supplied(&input.artifact_id) { + chain.push(hop("design-component",json!({"figmaNodeId":input.figma_node_id,"artifactId":input.artifact_id}),json!(design.names),design.evidence.clone(),Some("Design composition names identify reuse candidates, not an established design-to-code binding."))); + } + for kind in [ + "screen-component", + "component-api", + "api-handler", + "handler-model", + "model-columns", + ] { + if !chain.iter().any(|h| h["kind"] == kind) { + chain.push(hop( + kind, + Value::Null, + Value::Null, + json!({}), + Some("No evidence joins this hop to the supplied anchors; no anchor was inferred."), + )); + } + } + let mut reuse = vec![]; + for c in array(&ui["components"]) { + let mut reasons = vec![]; + let mut score = 0; + for name in array(&c["exports"]).iter().chain(array(&c["localNames"])) { + if design.names.contains(text(name)) { + reasons.push(format!( + "Export/local name {} exactly matches a design JSX reference", + text(name) + )); + score += 100; + } + } + for prop in array(&c["props"]) { + if design.fields.contains(text(&prop["name"])) { + reasons.push(format!( + "Declared prop {} matches a literal design field binding", + text(&prop["name"]) + )); + score += 10; + } + } + if selected_files.contains(text(&c["path"])) { + reasons.push("Already belongs to the explicitly selected component/route slice".into()); + score += 20; + } + if score > 0 { + reuse.push(json!({"component":c,"score":score,"matchReasons":reasons,"confidence":"medium","limitation":"Name/prop compatibility is syntactic; visual equivalence and runtime behavior are unverified"})); + } + } + reuse.sort_by(|a, b| { + b["score"] + .as_u64() + .cmp(&a["score"].as_u64()) + .then_with(|| text(&a["component"]["path"]).cmp(text(&b["component"]["path"]))) + }); + for (i, c) in reuse.iter_mut().enumerate() { + c["rank"] = json!(i + 1); + } + let comparisons = if (supplied(&input.figma_node_id) || supplied(&input.artifact_id)) + && (supplied(&input.operation_id) || supplied(&input.api_path)) + { + selected_ops + .iter() + .filter(|&&i| { + (supplied(&input.operation_id) + && input.operation_id.as_deref() + == operations[i].endpoint["operationId"].as_str()) + || (supplied(&input.api_path) + && input.api_path.as_deref() == operations[i].endpoint["path"].as_str() + && input.method.as_deref().is_some_and(|m| { + m.eq_ignore_ascii_case(text(&operations[i].endpoint["method"])) + })) + }) + .map(|&i| compare(&design, &operations[i])) + .collect::>() + } else { + vec![] + }; + let mut ownership = vec![]; + for layer in [ + "db-entity", + "entity-route", + "route-openapi", + "openapi-client", + ] { + let mut result = json!({"selfCheck":{}}); + stack_diff::attach_source_ownership(&mut result, layer); + ownership.push(result["selfCheck"]["sourceOwnership"].clone()); + } + let mut traced_artifacts = BTreeMap::new(); + for file in &selected_files { + traced_artifacts.insert(file.clone(),json!({"path":file,"ownership":"unverified","editTarget":null, + "reason":"UI inventory identifies source declarations but does not certify that a local file is human-authored. Check generated-code provenance before editing.","sourceOwnership":ownership[3]})); + } + for &i in &selected_ops { + let operation = &operations[i]; + let matches = handlers + .iter() + .filter(|h| handler_matches(h, operation, &root)) + .collect::>(); + let edit_target = (matches.len() == 1) + .then(|| relative(&root, &matches[0].file)) + .filter(|path| !generated_path(path, &ownership[2])); + traced_artifacts.insert(operation.file.clone(),json!({"path":operation.file,"ownership":"generated","editTarget":edit_target,"sourceOwnership":ownership[2]})); + for handler in matches { + let file = relative(&root, &handler.file); + traced_artifacts.insert(file.clone(), authored_artifact(&file, &ownership[1])); + } + } + for hop in &chain { + if let Some(path) = hop["evidence"]["modelPath"].as_str() { + traced_artifacts.insert(path.into(), authored_artifact(path, &ownership[0])); + } + } + let mut implemented = BTreeMap::>::new(); + for file in &selected_files { + if let Ok(source) = read(&root.join(file)) { + for state in design_source(&source).states { + implemented.entry(state).or_default().push(json!({"path":file,"declaration":"literal data-state JSX attribute; runtime reachability remains unverified"})); + } + } + } + for route in &routes { + let page = Path::new(text(&route["page"])); + let owner = if route["appliesTo"] == "." { + PathBuf::new() + } else { + PathBuf::from(text(&route["appliesTo"])) + }; + let app = if page.starts_with(owner.join("src/app")) { + owner.join("src/app") + } else { + owner.join("app") + }; + for component in array(&ui["components"]) { + let boundary = Path::new(text(&component["path"])); + let state = boundary.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + if matches!(state, "loading" | "error") + && boundary.starts_with(&app) + && boundary.parent().is_some_and(|p| page.starts_with(p)) + && component["appliesTo"] == route["appliesTo"] + { + implemented.entry(state.into()).or_default().push(json!({"path":component["path"],"declaration":"App Router boundary component in selected page ancestry; runtime appearance unverified"})); + } + } + } + let states=["loading","error","empty","validation","authorization"].iter().map(|state|json!({"state":state, + "specified":if design.states.contains(*state){"present"}else{"unknown"}, + "implemented":if implemented.contains_key(*state){"present"}else{"unknown"}, + "specifiedEvidence":if design.states.contains(*state){json!({"attribute":"data-state","value":state,"source":design.evidence})}else{Value::Null}, + "implementedEvidence":implemented.get(*state), + "reason":"Presence records explicit state declarations or App Router boundary components only. Missing evidence is unknown, never assumed absent; runtime coverage requires tests."})).collect::>(); + let acceptance=input.acceptance_criteria.iter().map(|criterion|json!({"criterion":criterion,"status":"UNVERIFIED","reason":"Caller acceptance text is preserved without semantic interpretation; supply tests or inspect the linked evidence."})).collect::>(); + let summary = summarize( + &chain, + &anchor_status, + ui["truncation"]["truncated"] == true, + ); + let mut navigation_graph = super::navigation::graph( + &design.evidence["interactiveCandidates"], + &navigation_sources, + &input.navigation_targets, + ); + navigation_graph["sourceRoute"] = json!(input.route_path); + if let Some(route) = &input.route_path + && let Some(edges) = navigation_graph["codeEdges"].as_array_mut() + { + for edge in edges { + edge["sourceFile"] = edge["from"].clone(); + edge["from"] = json!(route); + } + } + Ok(bound( + json!({"status":"OK","summary":summary,"projectRoot":root,"anchors":{"routePath":input.route_path,"figmaNodeId":input.figma_node_id,"artifactId":input.artifact_id,"operationId":input.operation_id,"apiPath":input.api_path,"method":input.method,"componentPath":input.component_path,"tableName":input.table_name},"requirement":input.requirement,"acceptanceMatrix":acceptance, + "chain":chain,"artifacts":traced_artifacts.into_values().collect::>(),"sourceOwnership":ownership,"reuseCandidates":reuse,"designContract":comparisons,"requiredStates":states, + "navigationGraph":navigation_graph, + "designEvidence":design.evidence,"designDiagnostics":design.diagnostics,"diagnostics":diagnostics, + "inventoryEvidence":{"uiTruncation":ui["truncation"],"uiLimits":ui["limits"],"uiDiagnostics":ui["diagnostics"],"uiExcludedPaths":ui["excludedPaths"],"unparsedFiles":ui["unparsedFiles"],"api":{"found":api["found"],"excludedPaths":api["excludedPaths"],"authorityNote":api["authorityNote"],"issues":array(&api["specs"]).iter().filter(|v|v.get("parseError").is_some()||v.get("readError").is_some()).collect::>()}, + "db":{"found":db["found"],"excludedPaths":db["excludedPaths"],"authorityNote":db["authorityNote"],"issues":array(&db["tables"]).iter().filter(|v|v.get("parseError").is_some()||v.get("readError").is_some()).collect::>()} +}, + "limitations":["Read-only static evidence, never runtime execution proof. Shared parser findings retain low/medium confidence.","Missing results do not prove absence: scan depth, UI inventory caps, unreadable files, dynamic imports/calls, wrappers and macro expansion can hide links.","Source ownership comes unchanged from stack_diff; edit authored models/routes/frontend and regenerate generated artifacts."]}), + max_items, + )) +} +/// Normalise a caller-supplied component path to the inventory's `a/b/c.tsx` form. +fn anchor_path(path: &str) -> String { + let unified = path.trim().replace('\\', "/"); + unified.trim_start_matches("./").to_owned() +} +/// An anchor must be a regular file strictly inside the project root. +fn resolve_anchor(root: &Path, relative_path: &str) -> Result<(), String> { + let candidate = Path::new(relative_path); + if candidate.is_absolute() + || candidate + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err("path must be relative to the project root and may not contain '..'".into()); + } + let full = root.join(candidate); + let canonical = full.canonicalize().map_err(|e| format!("{e}"))?; + let canonical_root = root.canonicalize().map_err(|e| format!("{e}"))?; + if !canonical.starts_with(&canonical_root) { + return Err("path resolves outside the project root".into()); + } + if !canonical.is_file() { + return Err("path is not a regular file".into()); + } + Ok(()) +} + +/// Summary-first digest: what is resolved, what is not, and what a human must +/// still check. Everything else in the response is detail behind it. +fn summarize(chain: &[Value], anchor: &Value, truncated_inventory: bool) -> Value { + let unresolved = chain + .iter() + .filter(|h| h["status"] == "UNVERIFIED") + .map(|h| json!({"kind":h["kind"],"from":h["from"],"reason":h["reason"]})) + .collect::>(); + let resolved = chain.iter().filter(|h| h["status"] == "RESOLVED").count(); + let mut required = vec![]; + if anchor["read"] == "failed" { + required.push(json!( + "Fix componentPath: the explicit anchor could not be read." + )); + } + let mut recommended = vec![]; + if truncated_inventory { + recommended.push(json!("UI inventory was capped; import sites and reuse ranking may be incomplete. Anchor files were still read directly.")); + } + if !unresolved.is_empty() { + recommended.push(json!( + "Verify each UNVERIFIED hop manually; absence of a link is not proof of absence." + )); + } + json!({"hopsResolved":resolved,"hopsUnverified":unresolved.len(),"anchor":anchor, + "required":required,"recommended":recommended,"unverified":unresolved}) +} +fn handler_matches(handler: &Handler, operation: &Operation, root: &Path) -> bool { + let authority = root.join(&operation.file).parent().unwrap().to_path_buf(); + handler.file.starts_with(authority.join("src/routes")) + && stack_diff::route_comparison_key(&handler.method, &handler.path) + == stack_diff::route_comparison_key( + text(&operation.endpoint["method"]), + text(&operation.endpoint["path"]), + ) +} + +#[cfg(test)] +#[path = "../feature_trace_tests.rs"] +mod tests; diff --git a/crates/devup-mcp/src/server/feature_trace/types_and_helpers.rs b/crates/devup-mcp/src/server/feature_trace/types_and_helpers.rs new file mode 100644 index 00000000..fae9db2c --- /dev/null +++ b/crates/devup-mcp/src/server/feature_trace/types_and_helpers.rs @@ -0,0 +1,586 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use devup_mcp_devup_ui::{ + codegen::{CodegenOptions, generate_component}, + provenance::design_fingerprints, +}; +use devup_mcp_figma::{DevupError, ErrorCode}; +use oxc_allocator::Allocator; +use oxc_ast::ast::*; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; +use serde_json::{Value, json}; + +use super::{ + artifacts::ArtifactStore, project_context, project_root, stack_diff, tools::FeatureTraceInput, +}; + +const MAX_BYTES: usize = 65_536; +const MAX_SOURCE_BYTES: u64 = 2_097_152; +const METHODS: &[&str] = &[ + "get", "post", "put", "patch", "delete", "head", "options", "trace", +]; +const ACCEPTED: &str = include_str!("../text/feature_trace-ACCEPTED-59546b50.md"); + +fn text(value: &Value) -> &str { + value.as_str().unwrap_or("") +} +fn array(value: &Value) -> &[Value] { + value.as_array().map(Vec::as_slice).unwrap_or(&[]) +} +fn supplied(value: &Option) -> bool { + value.as_deref().is_some_and(|s| !s.trim().is_empty()) +} +fn invalid(message: &str) -> DevupError { + DevupError::new(ErrorCode::DevupInvalidInput, message, false) +} +fn read(path: &Path) -> Result { + let mut source = String::new(); + std::fs::File::open(path) + .map_err(|e| e.to_string())? + .take(MAX_SOURCE_BYTES + 1) + .read_to_string(&mut source) + .map_err(|e| e.to_string())?; + if source.len() as u64 > MAX_SOURCE_BYTES { + return Err("maxSourceBytes exceeded; source was not parsed".into()); + } + Ok(source) +} +fn relative(root: &Path, file: &Path) -> String { + project_context::relative_display(root, file) +} +fn hop(kind: &str, from: Value, to: Value, evidence: Value, reason: Option<&str>) -> Value { + json!({"kind":kind,"from":from,"to":to,"status":if reason.is_some(){"UNVERIFIED"}else{"RESOLVED"}, + "confidence":if reason.is_some(){"low"}else{"medium"},"evidence":evidence,"reason":reason}) +} + +/// Apply the shared ownership patterns to concrete paths, including nested +/// authorities. Authored-looking files inside generated trees stay protected. +fn generated_path(path: &str, ownership: &Value) -> bool { + let wrapped = format!("/{path}"); + array(&ownership["generated"]).iter().any(|entry| { + let pattern = text(&entry["path"]); + if let Some(directory) = pattern.strip_suffix("/**") { + wrapped.contains(&format!("/{directory}/")) + } else if let Some((directory, suffix)) = pattern.split_once("/**/") { + wrapped.contains(&format!("/{directory}/")) + && path.ends_with(suffix.trim_start_matches('*')) + } else { + path == pattern || wrapped.ends_with(&format!("/{pattern}")) + } + }) +} +fn authored_artifact(path: &str, ownership: &Value) -> Value { + let generated = generated_path(path, ownership); + json!({"path":path,"ownership":if generated{"generated"}else{"human-authored"}, + "editTarget":if generated{None}else{Some(path)},"sourceOwnership":ownership}) +} + +struct Operation { + endpoint: Value, + operation: Value, + spec: Arc, + file: String, +} +struct Handler { + file: PathBuf, + source: String, + method: String, + path: String, +} + +fn operations(root: &Path, api: &Value, diagnostics: &mut Vec) -> Vec { + let mut result = vec![]; + for entry in array(&api["specs"]) { + let file = text(&entry["path"]); + let spec = match read(&root.join(file)) + .and_then(|s| serde_json::from_str::(&s).map_err(|e| e.to_string())) + { + Ok(spec) => spec, + Err(reason) => { + diagnostics.push(json!({"path":file,"reason":reason})); + continue; + } + }; + let spec = Arc::new(spec); + for endpoint in array(&entry["endpoints"]) { + let operation = &spec["paths"][text(&endpoint["path"])] + [text(&endpoint["method"]).to_ascii_lowercase()]; + result.push(Operation { + endpoint: endpoint.clone(), + operation: operation.clone(), + spec: spec.clone(), + file: file.into(), + }); + } + } + result +} +fn handlers(root: &Path, diagnostics: &mut Vec) -> Vec { + let (directories, excluded) = project_root::find_dirs_named(root, "routes", 5); + diagnostics.extend(excluded); + let mut result = vec![]; + let mut seen = BTreeSet::new(); + for directory in directories { + let (files, excluded) = project_root::find_matching_files( + &directory, + |p| p.extension().is_some_and(|s| s == "rs"), + 6, + ); + diagnostics.extend(excluded); + for file in files { + if !seen.insert(file.clone()) { + continue; + } + let source = match read(&file) { + Ok(s) => s, + Err(reason) => { + diagnostics.push(json!({"path":relative(root,&file),"reason":reason})); + continue; + } + }; + let prefix = stack_diff::route_url_prefix(file.strip_prefix(&directory).unwrap()); + for (method, path) in stack_diff::extract_vespera_route_attributes(&source) { + result.push(Handler { + file: file.clone(), + source: source.clone(), + method, + path: stack_diff::join_route_url(&prefix, path.as_deref()), + }); + } + } + } + result +} + +#[derive(Default)] +struct Design { + names: BTreeSet, + fields: BTreeSet, + states: BTreeSet, + diagnostics: Vec, + evidence: Value, +} +impl Design { + fn expression(&mut self, e: &Expression<'_>, source: &str) { + match e { + Expression::JSXElement(e) => self.element(e, source), + Expression::JSXFragment(e) => self.children(&e.children, source), + Expression::ParenthesizedExpression(e) => self.expression(&e.expression, source), + Expression::ArrowFunctionExpression(e) => match &e.body { + ArrowFunctionBody::FunctionBody(body) => self.statements(&body.statements, source), + other => { + if let Some(e) = other.as_expression() { + self.expression(e, source); + } + } + }, + Expression::FunctionExpression(e) => { + if let Some(body) = &e.body { + self.statements(&body.statements, source); + } + } + Expression::ConditionalExpression(e) => { + self.expression(&e.consequent, source); + self.expression(&e.alternate, source); + } + Expression::LogicalExpression(e) => { + self.expression(&e.left, source); + self.expression(&e.right, source); + } + _ => {} + } + } + fn declaration(&mut self, d: &Declaration<'_>, source: &str) { + match d { + Declaration::FunctionDeclaration(f) => { + if let Some(body) = &f.body { + self.statements(&body.statements, source); + } + } + Declaration::VariableDeclaration(v) => { + for d in &v.declarations { + if let Some(e) = &d.init { + self.expression(e, source); + } + } + } + _ => {} + } + } + fn statements(&mut self, statements: &[Statement<'_>], source: &str) { + for s in statements { + match s { + Statement::ExportDeclaration(e) => self.declaration(&e.declaration, source), + Statement::ExportDefaultDeclaration(e) => match &e.declaration { + ExportDefaultDeclarationKind::FunctionDeclaration(f) => { + if let Some(body) = &f.body { + self.statements(&body.statements, source); + } + } + other => { + if let Some(e) = other.as_expression() { + self.expression(e, source); + } + } + }, + Statement::FunctionDeclaration(f) => { + if let Some(body) = &f.body { + self.statements(&body.statements, source); + } + } + Statement::VariableDeclaration(v) => { + for d in &v.declarations { + if let Some(e) = &d.init { + self.expression(e, source); + } + } + } + Statement::ReturnStatement(s) => { + if let Some(e) = &s.argument { + self.expression(e, source); + } + } + Statement::ExpressionStatement(e) => self.expression(&e.expression, source), + Statement::BlockStatement(b) => self.statements(&b.body, source), + _ => {} + } + } + } + fn children(&mut self, children: &[JSXChild<'_>], source: &str) { + for child in children { + match child { + JSXChild::Element(e) => self.element(e, source), + JSXChild::Fragment(e) => self.children(&e.children, source), + JSXChild::ExpressionContainer(e) => { + if let Some(e) = e.expression.as_expression() { + self.expression(e, source); + } + } + _ => {} + } + } + } + fn element(&mut self, element: &JSXElement<'_>, source: &str) { + let opening = &element.opening_element; + let name = opening.name.span().source_text(source); + if name.chars().next().is_some_and(char::is_uppercase) { + self.names.insert(name.into()); + } + for attribute in &opening.attributes { + let JSXAttributeItem::Attribute(a) = attribute else { + self.diagnostics.push( + json!({"reason":"Spread JSX props may contain additional field bindings."}), + ); + continue; + }; + let key = a.name.span().source_text(source); + if key == "data-state" { + if let Some(JSXAttributeValue::StringLiteral(value)) = &a.value + && ["loading", "error", "empty", "validation", "authorization"] + .contains(&value.value.as_str()) + { + self.states.insert(value.value.to_string()); + } + continue; + } + if !matches!(key, "name" | "data-field") { + continue; + } + if let Some(JSXAttributeValue::StringLiteral(value)) = &a.value { + self.fields.insert(value.value.to_string()); + } else { + self.diagnostics.push( + json!({"reason":"Nonliteral field binding is unresolved.","attribute":key}), + ); + } + } + self.children(&element.children, source); + } +} +fn design_source(source: &str) -> Design { + let mut design = Design::default(); + if source.len() as u64 > MAX_SOURCE_BYTES { + design + .diagnostics + .push(json!({"reason":"maxSourceBytes exceeded"})); + return design; + } + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + design + .diagnostics + .push(json!({"reason":"componentTsx could not be parsed; no bindings extracted."})); + return design; + } + design.statements(&parsed.program.body, source); + design +} +async fn design(input: &FeatureTraceInput, artifacts: &ArtifactStore) -> Design { + if supplied(&input.artifact_id) { + if let Some(artifact) = artifacts.get(input.artifact_id.as_deref().unwrap()).await { + if artifact.capabilities.kind == super::artifacts::ArtifactKind::Measurement { + return Design { + evidence: json!({"source":"measurement-only-artifact","artifactId":artifact.artifact_id,"state":"unverified","capabilities":artifact.capabilities}), + diagnostics: vec![ + json!({"reason":"Measurement receipts contain logical dimensions only; design structure, component bindings and code were not acquired. Export a design snapshot for feature tracing."}), + ], + ..Design::default() + }; + } + let node = input.figma_node_id.as_deref().or(artifact + .payload + .target + .node_id + .as_deref()); + if let Some(node) = node { + let interaction_candidates = serde_json::to_value(super::interactions::candidates( + &artifact.payload.snapshot, + &[node.to_owned()], + )) + .unwrap(); + match generate_component( + &artifact.payload.snapshot, + node, + &CodegenOptions::default().with_payload_tokens(&artifact.payload), + ) { + Ok(output) => { + let mut design = design_source(&output.tsx); + design.evidence = json!({"source":"cached-artifact","artifactId":artifact.artifact_id,"nodeId":node,"sourceMap":output.source_map,"designFingerprints":design_fingerprints(&artifact.payload.snapshot),"staleness":"unknown: cached snapshot has not been compared with live Figma"}); + design.evidence["interactiveCandidates"] = interaction_candidates; + return design; + } + Err(e) => { + return Design { + evidence: json!({"source":"cached-artifact","interactiveCandidates":interaction_candidates}), + diagnostics: vec![json!({"reason":e.to_string()})], + ..Design::default() + }; + } + } + } + return Design { + diagnostics: vec![ + json!({"reason":"Artifact does not identify a single node; supply figmaNodeId."}), + ], + ..Design::default() + }; + } + return Design { + diagnostics: vec![ + json!({"reason":"Artifact missing or expired; export the explicit Figma selection again."}), + ], + ..Design::default() + }; + } + if supplied(&input.figma_node_id) + && let Some(source) = &input.component_tsx + { + let mut design = design_source(source); + design.evidence = json!({"source":"caller-supplied-componentTsx","nodeId":input.figma_node_id,"provenance":"UNVERIFIED: caller associates this export with the node; no cached artifact validates that association"}); + // oxc identifies actual comments so string literals cannot spoof hints. + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let hints = if parsed.diagnostics.is_empty() && !parsed.panicked { + parsed + .program + .comments + .iter() + .filter_map(|c| { + c.span + .source_text(source) + .strip_prefix("// @devup-interaction ") + }) + .filter_map(|s| serde_json::from_str::(s).ok()) + .filter(|v| v["nodeId"].is_string() && v["prototypeActions"].is_array()) + .collect::>() + } else { + vec![] + }; + design.evidence["interactiveCandidates"] = json!(hints); + return design; + } + Design { + diagnostics: vec![ + json!({"reason":"No design export available. A node id alone does not contain design data; supply artifactId or componentTsx."}), + ], + ..Design::default() + } +} + +fn schema_fields( + schema: &Value, + spec: &Value, + fields: &mut BTreeSet, + reasons: &mut Vec, + depth: usize, +) { + if depth > 16 { + reasons.push("Schema reference/depth cap maxSchemaDepth (16) reached".into()); + return; + } + if let Some(reference) = schema["$ref"].as_str() { + if let Some(target) = reference.strip_prefix('#').and_then(|p| spec.pointer(p)) { + schema_fields(target, spec, fields, reasons, depth + 1); + } else { + reasons.push(format!("Unresolved schema reference {reference}")); + } + return; + } + if let Some(props) = schema["properties"].as_object() { + fields.extend(props.keys().cloned()); + } + for part in array(&schema["allOf"]) { + schema_fields(part, spec, fields, reasons, depth + 1); + } + if schema.get("oneOf").is_some() || schema.get("anyOf").is_some() { + reasons.push("Union schema alternatives do not prove a single field set".into()); + } + if let Some(items) = schema.get("items") { + schema_fields(items, spec, fields, reasons, depth + 1); + } + if schema + .get("additionalProperties") + .is_some_and(|v| v != false) + { + reasons.push("Open-ended additionalProperties prevents proving missing fields".into()); + } +} +fn content_fields( + value: &Value, + spec: &Value, + fields: &mut BTreeSet, + reasons: &mut Vec, +) { + if let Some(reference) = value["$ref"].as_str() { + if let Some(target) = reference.strip_prefix('#').and_then(|p| spec.pointer(p)) { + if target.get("$ref").is_some() { + reasons.push("Chained request/response reference unresolved".into()); + } else { + content_fields(target, spec, fields, reasons); + } + } else { + reasons.push(format!("Unresolved request/response reference {reference}")); + } + return; + } + if let Some(content) = value["content"].as_object() { + if content.len() > 1 { + reasons.push("Multiple media types may have different field sets".into()); + } + for entry in content.values() { + schema_fields(&entry["schema"], spec, fields, reasons, 0); + } + } else { + reasons.push("No explicit content schema provided".into()); + } +} +fn comparison(fields: &BTreeSet, contract: &BTreeSet, reasons: &[String]) -> Value { + json!({"status":if fields.is_empty() || !reasons.is_empty(){"UNVERIFIED"}else{"RESOLVED"}, + "designFields":fields,"contractFields":contract,"designFieldsNotProvided":fields.difference(contract).collect::>(), + "contractFieldsUnused":contract.difference(fields).collect::>(),"reasons":reasons, + "scope":include_str!("../text/feature_trace-comparison-60526fe1.md")}) +} +fn compare(design: &Design, operation: &Operation) -> Value { + let mut request = BTreeSet::new(); + let mut response = BTreeSet::new(); + let mut request_reasons = vec![]; + let mut response_reasons = vec![]; + content_fields( + &operation.operation["requestBody"], + &operation.spec, + &mut request, + &mut request_reasons, + ); + if let Some(responses) = operation.operation["responses"].as_object() { + let success = responses + .iter() + .filter(|(status, _)| status.starts_with('2')) + .collect::>(); + if success.len() != 1 { + response_reasons.push("No unique success response schema".into()); + } + for (_, value) in success { + content_fields(value, &operation.spec, &mut response, &mut response_reasons); + } + } else { + response_reasons.push("No response schema".into()); + } + if design.fields.is_empty() { + request_reasons.push( + "Design has no literal contract field bindings; visual labels are not field names" + .into(), + ); + response_reasons.push("Design has no literal contract field bindings".into()); + } + for d in &design.diagnostics { + request_reasons.push(text(&d["reason"]).into()); + response_reasons.push(text(&d["reason"]).into()); + } + json!({"operation":operation.endpoint,"specPath":operation.file,"designEvidence":design.evidence,"request":comparison(&design.fields,&request,&request_reasons),"response":comparison(&design.fields,&response,&response_reasons)}) +} + +fn bound(mut value: Value, max_items: usize) -> Value { + let mut omitted = BTreeMap::::new(); + fn trim(v: &mut Value, path: &str, limit: usize, omitted: &mut BTreeMap) { + match v { + Value::Array(a) => { + if a.len() > limit { + omitted.insert(path.into(), a.len() - limit); + a.truncate(limit); + } + for (i, v) in a.iter_mut().enumerate() { + trim(v, &format!("{path}/{i}"), limit, omitted); + } + } + Value::Object(o) => { + for (k, v) in o { + trim( + v, + &if path.is_empty() { + k.clone() + } else { + format!("{path}/{k}") + }, + limit, + omitted, + ); + } + } + _ => {} + } + } + trim(&mut value, "", max_items, &mut omitted); + let mut caps = vec![]; + if !omitted.is_empty() { + caps.push("maxItems"); + } + value["limits"] = json!({"maxItems":max_items,"maxBytes":MAX_BYTES,"maxSourceBytes":MAX_SOURCE_BYTES,"maxSchemaDepth":16,"byteEncoding":"UTF-8 compact JSON before MCP envelope and server identity"}); + value["truncation"] = json!({"truncated":!caps.is_empty(),"capsHit":caps,"omitted":omitted}); + // Remove the largest payload member until the full response, including cap + // metadata, fits. Every removal is named; never silently slice a hop's reason. + while value.to_string().len() > MAX_BYTES { + let key = value + .as_object() + .unwrap() + .iter() + .filter(|(k, _)| !matches!(k.as_str(), "truncation" | "limits" | "status" | "summary")) + .max_by_key(|(_, v)| v.to_string().len()) + .map(|(k, _)| k.clone()); + let Some(key) = key else { + break; + }; + let removed = value.as_object_mut().unwrap().remove(&key).unwrap(); + value["truncation"]["omitted"][&key] = json!({"reason":"maxBytes","items":removed.as_array().map(Vec::len),"bytes":removed.to_string().len()}); + value["truncation"]["truncated"] = json!(true); + let caps = value["truncation"]["capsHit"].as_array_mut().unwrap(); + if !caps.contains(&json!("maxBytes")) { + caps.push(json!("maxBytes")); + } + } + value +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/framework.rs b/crates/devup-mcp/src/server/framework.rs index 3bdac353..71253be0 100644 --- a/crates/devup-mcp/src/server/framework.rs +++ b/crates/devup-mcp/src/server/framework.rs @@ -177,7 +177,7 @@ pub(super) fn report(root: &Path) -> Value { json!({ "framework": if kinds.len() == 1 { json!(kinds.iter().next()) } else { Value::Null }, "apps": apps.iter().map(App::json).collect::>(), - "note": "vinext runs Next.js App Router on Vite: an app with a vinext dependency, vinext scripts or a vite.config importing vinext is an App Router app even with no next dependency and no next.config.", + "note": include_str!("text/framework-report-6fa8f9df.md"), }) } diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index c841cf57..62a5b79d 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -26,248 +26,12 @@ pub const GUIDE_MIME_TYPE: &str = "text/markdown"; /// /// The original rule numbering is preserved in [`GUIDE`] - it skipped 9 - so a /// reader comparing the two can see nothing was dropped in the move. -pub const INSTRUCTIONS: &str = concat!( - "Build identity: identify deployments by server.commit/buildId, not version alone. ", - "Reconnect the MCP server if the expected build differs. ", - "server.updateAvailable reports whether a newer release exists; state \"unknown\" means the check is disabled, ", - "not yet run, or could not reach the network, and it never blocks a call.\n", - "1. devup-mcp is the primary source for turning a Figma design into code. Do not replace it with another source.\n", - "1a. Connection order: bridge -> direct. Call devup_figma_auth status before asking anyone to log in: the Devup ", - "Bridge plugin needs no login, and with one attached devup_figma_export takes no url.\n", - "2. When the goal is implementation, call devup_figma_export first and take tsx. That is the deliverable; ", - "deliverable.isFinal describes export readiness. TSX has no wired links/events: resolve interactiveCandidates, ", - "run devup_ui_validate and report wired/tested counts before functional completion.\n", - "2b. The tsx is devup-ui code. Call devup_skills before writing it: it reports the conventions this ", - "workspace lacks and installs the ones it carries.\n", - "2c. Building pages or a site from Figma (a Section, several frames): follow skill ", - "devup-implementation-plan even without /devup - devup_screen_plan first, all questions up front, then never wait; audit every width (rule 16).\n", - "3. Request only outputs you will read. Before your second call, resources/read \"devup://guide/usage\" ", - "for output sizing, verification boundaries, SECTION batching, assets and delivery." -); +pub const INSTRUCTIONS: &str = include_str!("text/guide-INSTRUCTIONS-49bd7ec6.md"); /// The rules that moved out of `instructions`, verbatim in meaning. This is a /// relocation, not a rewrite: every numbered rule that was published before is /// published here under the same number. -pub const GUIDE: &str = r#"# Using devup-mcp - -These are the rules that used to be pushed to every client in `initialize`. -They are published here instead so a caller that never converts a Figma design -never carries them. Rule numbers match the original list, which skipped 9. - -## 1a. Connection order: bridge -> direct - -Two paths reach Figma and they are not equals. The Devup Bridge plugin, run in -the Figma desktop app on the file, needs no login and spends none of Figma's -allowance. The direct path is OAuth against Figma's MCP and is metered. Use the -bridge first: it serves every read a collection makes, and direct only re-reads -what the plugin answers short - data Figma withholds while the file is out of -sight, a reference PNG the plugin cannot draw as get_screenshot does, a read an -older plugin does not know. `source.directFallbacks` counts those re-reads. - -- Call `devup_figma_auth` with `action: "status"` before asking anyone to log - in. It reports both paths: `connected`, `activePath` (`bridge`, `direct` or - null), `paths.bridge.attachedFiles` with each plugin's file name, current - page and selection, and a `nextAction`. `status` reads `disconnected` only - when neither path can serve. Do not ask for a login while `activePath` is - `bridge`. -- With exactly one plugin attached, `devup_figma_export`, - `devup_figma_search` and `devup_figma_explore` take no `url`. The export - reads the file that plugin has open and the node selected in Figma; - `frameIds` without `url` name frames in that file - one id is that frame, - several are screens of the selected Section. Explore anchors on the - selection, and search reads the whole file. -- When a link is still needed, use `figma-bridge://current`, with - `?node-id=1-2` to name a node. Never invent a Figma file key to fill `url`: - a plugin that cannot report its key answers any key while it is alone, so an - invented one is served and then mistaken for the file's. -- With no plugin attached, or several, a call without `url` is refused with a - `nextAction` that lists the ways forward: run the plugin, log in and pass a - link, or pick one of the attached files. -- An answer the plugin served says so: `source.kind` is `bridge`, with - `bridgePort`, `fileName`, `pageName` and `bridgeReads`. `source.fileKey` is - the key the plugin reported, or `null` with `fileKeyReason` when it could - not report one. - -## 2a. Ask for an output only when you will read it - -Measured on the Korean WQUW-120 modal, semantic sourceMap is about 5.4x TSX -(48,832 versus 9,099 UTF-8 bytes; 12.6% smaller than its former offset map). -Sizes vary by screen; earlier rawPayload/rawSnapshot measurements were about -7x/2x TSX, so requesting them by default spends most of the response on bytes -nothing reads. - -sourceMap records nodeId, original property, generatedProperty and resolution, -plus optional variableId (variable token), styleId (style token) and assetId -(asset reference), with no character/byte offsets. Each entry's elementIndex, -which joins it to `devup_ui_validate` violations, adds 11-14% to the map -(measured on the ten WQUW-151 screens). - -Resolution labels carry different weight. `raw-fallback` means a raw-value -mapping, not necessarily an inaccurate value. `verified-explicit-dimension` -checks that emitted pixels equal the source dimension, while -`verified-layout-sizing` checks sizing intent against emitted CSS. `exact` -verifies that field-to-property mapping, not rendered pixel equivalence. - -Use the `generatedSource` diagnostics for node code excerpts. `rawSnapshot` and -`rawPayload` are for banking a capture as an offline fixture, and both require -`debug: true`. - -`componentTsx` is the same screen with instances left as `` references, -and `responsiveTsx` appears on its own whenever the capture carries more than -one width. - -## 3. Screenshots and visual reasoning verify; they do not author - -`get_design_context`, screenshots and visual reasoning are verification aids -only. Do not overwrite devup-mcp output with what a picture looked like. - -## 4. Do not hand-interpret the design - -Do not read a node tree yourself to write devup-ui code, and do not infer -layout from coordinates. - -## 5. A failed call is a fact to report - -If a devup-mcp call fails, record it explicitly. Do not silently route around -it. - -## 6. Never guess a UI value - -Do not guess color, spacing, radius or typography. If you could not obtain a -value, stop and report that. - -## 7. A SECTION link is two steps, not one subtree - -Do not implement a Section link as one whole subtree. Read the -`selection_required` candidates and continue with bounded per-screen `frameIds` -batches from `nextAction`. `allScreens` is valid only when the complete list -fits the advertised frame and output budget. - -## 8. The generated component name is a starting point - -The name comes from the Figma layer name and is not a contract. Rename it to -fit the codebase, and rename any name that is meaningless or not a valid -identifier. - -## 10. An asset path in the output is a placeholder - -A `maskImage` or `Image` src is 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. - -`maskImage` + `bg` is emitted only for an icon painted in one flat colour; a -multi-colour icon (gradient, several colours, an image) stays ``. -Never turn an `` icon into a mask or recolour it by hand: a mask keeps -only the shape and fills it with one colour. `devup_ui_validate` with -`projectRoot` warns `multicolor-mask` when one does. - -## 11. A referenced asset must actually exist - -A fixed asset such as an icon must 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. - -Image paints default to `renderMode:"original-source"`: acquire the complete source, -apply crop, blur, rounding and blend in CSS, and inspect `effectsBaked` and -`effectsAppliedBy`. Original scale never upscales the source. `node` explicitly -renders that node at scale 2 by default; fractional 1.5 is supported and 1.5–3 is -recommended. Low-resolution requests remain accepted with explicit warnings. -`visible` exports a selected acquired ancestor subtree, including that scope's -clips/masks/rounding. Supply `visibleScopeNodeId`; outside-scope composition stays -unknown. `nodeId` selects any acquired in-scope node, including parent instances -that asset discovery did not classify. Keep usable SVG vectors as SVG. -Legacy `fill` retains matching PNG/JPG bytes at scale 1 and no resizing; explicit -WebP fill conversion is labelled transcoded. Full-source codec conversions are -also labelled: a JPEG-to-PNG conversion does not preserve original byte identity. - -For web images request `format:"webp"`, integer `quality:0..100` (default 85), -with alpha preserved and optional lossless encoding. Optional `maxWidth` caps -original-source WebP width without upscaling. `srcset:true` emits a base image and -an available higher-resolution `@2x.webp` derivative; descriptors report actual -encoded density, including caps. Read sourcePixelSize, outputSize, nodeBox, -source/output effective scales, savings against source and a real PNG baseline. -Conversion is bounded to 8192 per side, 16777216 pixels, 128 MiB decode allocation -and 1 MiB inflated compressed PNG metadata. Animated/unsupported conversion is -refused rather than silently flattened; metadata is not preserved. - -`preset:"web-ready-images"` acquires every in-scope IMAGE paint as full-source -WebP, quality 85, capped at display width ×2 when known, with CSS effects. It adds -assetManifest/imageAudit and `asset-css-map.json`, schedules sorted batches of at -most six and retains hidden, missing-source, failed and limit dispositions. -Use assetPublicRoot for written `.webp` files and mapped code, or resources for -reusable bytes. Custom assetRequests remain separate. Poll exportJob; resume -paused work or cancel with jobAction. Polls return compact asset rows; add -`responseFields` (e.g. `exportJob.assets`) to a poll for full rows. `assetJob` -only references `exportJob`. Accepted results/checkpoints live in this -server process for the reported retention period. Scheduling is bounded to4096 -sources and128 MiB retained asset bytes; remaining IDs carry explicit failures -and can be retried in smaller scopes. Read completeness and failures before use. - -## 12. Prefer resource delivery for assets and large outputs - -With `delivery: "resource"`, devup-mcp returns `devup://artifact/...` resource -links to read on demand instead of inlining bytes into every response. - -## 13. Exact means exact at the drawn width - -`quality.projection` grades the mapping at the width the frame was drawn at, -not at any other viewport. Before widening a generated screen, read -`widthBehavior`: a finding marked `consumer-policy-required` is a question the -design does not answer. Choose an anchor from its `candidates` and pass it in -`widthPolicy` (`{"root": "fluid", "anchors": {"": "right"}}`) rather -than re-anchoring the generated code by hand. How the screen renders at other -widths is checked in the consumer project: `devup_runtime_audit` writes the -plan and verifies the report, and never runs the browser itself. Its ingest -returns a ready `devup_visual_compare` call for each screenshot taken at a -drawn width. - -## 14. Interactions by role, restrained - -Hover plus a transition is not a finished interaction. Each interactive -element gets its role's recipe - button, link, card, nav item, icon button, -input, disclosure - with pressed, focus, current-page, expanded, disabled and -loading states where they apply, and view transitions between pages - always -on, and proved by the runtime audit's view-transition check. Restraint is a rule, not a taste: at -most two properties per state, 150-250ms, existing tokens only, scale 0.97 to -1.05, nothing infinite, everything off under `_motionReduce`. The table is in -the devup-implementation-plan skill; `devup_screen_plan` records it in -`defaults` and `devup_ui_validate` reports both too little and too much. - -## 15. The latest stack, and vinext - -DevFive builds on Next App Router run by vinext on Vite, in bun workspaces, -on the latest versions. An app with a `vinext` dependency, `vinext` scripts or -a `vite.config` importing vinext is an App Router app with no `next` -dependency; `devup_project_context` reports `frameworks` per app. Before -implementing, the workflow runs `bunx @dependency-check-updates/cli -d -u --rm` -at the repository root, then `bun install`, build, lint and typecheck, and -checks current docs before using a library API - API knowledge from memory is -assumed stale. A new project starts from `dev-five-git/service-template`; one -on the `next` CLI is asked once whether to migrate. - -## 16. Visual verification of everything, full autonomy - -Visually verify every screen, section and asset at every drawn width. -`devup_screen_plan` `auditPlan` writes one runtime-audit plan per URL: page -health, overflow and clipped text at every width; the page, each section and -each asset against its Figma render, and the size and position of each -section, card and asset, at the drawn widths; hover, focus and a view -transition, ordered by code. Layout is exact; a mismatch is drift or a -converter bug: geometry allows only half a pixel of rounding, and a -mismatch is `code-drift` when the code no longer writes what the export -recorded in `.devup/exports/`, or `converter-bug` when it does - reported -with a repro in `.devup/converter-bugs/`, never retried or tuned silently. -Merged pages are found per width through `data-figma-node-ids`, dynamic -routes at concrete URLs, and production builds strip the ids unless -`DEVUP_AUDIT=1`. `status` classifies each failure (runtime-error, -overflow, layout-position, layout-size, asset-mismatch, section-visual, -page-visual, interaction, view-transition) with its node, file and fix hint. -Never stop to wait; reorder the work: a decision after confirm goes to -`defer` and is taken with `assume` at the end. Done = zero fails, or listed as -unresolved with evidence after `maxFixAttempts`. -"#; +pub const GUIDE: &str = include_str!("text/guide-GUIDE-c682b0c5.md"); #[cfg(test)] mod tests { @@ -305,6 +69,7 @@ mod tests { /// is exactly the one that writes pages straight away. #[test] fn instructions_are_far_smaller_than_the_guidance_they_point_at() { + super::super::source_limits::check(); assert!( INSTRUCTIONS.len() < 1_500, "instructions grew back to {} bytes; the per-session cost is the thing being fixed", diff --git a/crates/devup-mcp/src/server/handlers/devup_feature_trace.rs b/crates/devup-mcp/src/server/handlers/devup_feature_trace.rs new file mode 100644 index 00000000..966a7015 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_feature_trace.rs @@ -0,0 +1,18 @@ +use super::*; + +#[tool_router(router = devup_feature_trace_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-feature-trace.md")] + pub(super) async fn devup_feature_trace( + &self, + Parameters(input): Parameters, + ) -> Result { + let result = feature_trace::run(input, &self.artifacts) + .await + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_figma_auth.rs b/crates/devup-mcp/src/server/handlers/devup_figma_auth.rs new file mode 100644 index 00000000..b139827d --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_figma_auth.rs @@ -0,0 +1,78 @@ +use super::*; + +#[tool_router(router = devup_figma_auth_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-figma-auth.md")] + pub(super) async fn devup_figma_auth( + &self, + Parameters(input): Parameters, + ) -> Result { + match input.action.as_str() { + "status" => {} + "doctor" => { + let status = self.services.auth.status().await.map_err(to_mcp_error)?; + let direct = self + .services + .auth + .direct_path_snapshot() + .await + .map_err(to_mcp_error)?; + let bridge = self.services.upstream.bridge_path_snapshot().await; + return Ok(tool_result( + diagnostics::doctor_report(status, direct, bridge).await, + )); + } + "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" }))); + } + "login" => { + // Warned rather than refused: the bridge serves every read, + // but the direct path re-reads what the plugin answers short, + // and whoever asks may want that. But reaching for the metered + // path while the free one is attached is the mistake this + // answer exists to catch, so it says so. + let bridge_attached = self + .services + .upstream + .bridge_path_snapshot() + .await + .is_some_and(|bridge| bridge.available()); + self.services.auth.login().await.map_err(to_mcp_error)?; + let mut report = self.connection_report().await.map_err(to_mcp_error)?; + if bridge_attached { + report["warning"] = + json!(include_str!("../text/mod-devup_figma_auth-444c97f4.md")); + } + return Ok(tool_result(report)); + } + "logout" => { + self.services.auth.logout().await.map_err(to_mcp_error)?; + } + _ => { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupAuthRequired, + "action must be status, login, logout, configure, or doctor.", + false, + ))); + } + } + Ok(tool_result( + self.connection_report().await.map_err(to_mcp_error)?, + )) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_figma_explore.rs b/crates/devup-mcp/src/server/handlers/devup_figma_explore.rs new file mode 100644 index 00000000..de5d1834 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_figma_explore.rs @@ -0,0 +1,18 @@ +use super::*; + +#[tool_router(router = devup_figma_explore_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-figma-explore.md")] + pub(super) async fn devup_figma_explore( + &self, + Parameters(input): Parameters, + ) -> Result { + self.explore(input) + .await + .map(tool_result) + .map_err(to_mcp_error) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_figma_export.rs b/crates/devup-mcp/src/server/handlers/devup_figma_export.rs new file mode 100644 index 00000000..4ccb4686 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_figma_export.rs @@ -0,0 +1,610 @@ +use super::*; + +#[tool_router(router = devup_figma_export_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = interactions::output_schema() + )] + #[doc = include_str!("../text/devup-figma-export.md")] + pub(super) async fn devup_figma_export( + &self, + Parameters(workflow): Parameters, + ) -> Result { + let mut input = workflow.input; + if input.preset.as_deref() == Some("web-ready-images") { + for output in ["assetManifest", "imageAudit"] { + if !input.outputs.iter().any(|o| o == output) { + input.outputs.push(output.into()); + } + } + } + // Before anything is validated or collected: a page write without a + // confirmed screen plan is answered with the plan to make, and a + // pageScaffold route comes from the plan when one covers the frame. + if workflow.job_id.is_none() + && let Some(project_root) = input.project_root.clone() + { + let mut frames = input.frame_ids.clone(); + if frames.is_empty() { + let node = match (&input.url, &input.artifact_id) { + (Some(url), _) => FigmaTarget::parse(url).ok().and_then(|t| t.node_id), + (None, Some(id)) => self + .artifacts + .get(id) + .await + .and_then(|artifact| artifact.payload.target.node_id.clone()), + _ => None, + }; + frames.extend(node); + } + let request = screen_plan::ExportRequest { + project_root: &project_root, + url: input.url.as_deref(), + frames, + all_screens: input.all_screens, + scaffold_write: input.page_scaffold.as_ref().is_some_and(|s| s.write), + scaffold_route: input.page_scaffold.as_ref().map(|s| s.route.as_str()), + output_paths: input + .output_paths + .iter() + .filter(|(key, _)| !key.ends_with("pageScaffold")) + .map(|(_, path)| path.as_str()) + .collect(), + }; + match screen_plan::export_gate(&request).map_err(to_mcp_error)? { + screen_plan::Gate::Open => {} + screen_plan::Gate::Blocked(response) => return Ok(tool_result(response)), + screen_plan::Gate::Route(route) => { + if let Some(scaffold) = input.page_scaffold.as_mut() { + scaffold.route = route; + } + } + } + } + projection::page_scaffold::validate(input.page_scaffold.as_ref(), &input.outputs) + .map_err(to_mcp_error)?; + validation::validate_asset_budget(&input.asset_requests).map_err(to_mcp_error)?; + validation::validate_asset_render_outputs(&input.asset_requests, &input.outputs) + .map_err(to_mcp_error)?; + if input.page_scaffold.is_some() { + validation::validate_asset_render_outputs( + &input.asset_requests, + &["pageScaffold".into()], + ) + .map_err(to_mcp_error)?; + } + if let Some(id) = workflow.job_id.as_deref() { + if input.url.is_some() + || input.artifact_id.is_some() + || !input.asset_requests.is_empty() + || !input.output_paths.is_empty() + || workflow.asset_public_root.is_some() + || input.page_scaffold.is_some() + || input.refresh + || !input.frame_ids.is_empty() + || input.all_screens + || input.width_policy.is_some() + || input.preset.is_some() + { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + "jobId status/resume cannot change the original request or output paths.", + false, + ))); + } + let job = self.asset_jobs.get(id).map_err(to_mcp_error)?; + match workflow.job_action.as_deref().unwrap_or("status") { + "status" => {} + "resume" => job.resume(), + "cancel" => job.cancel(), + _ => { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + "jobAction must be status, resume or cancel.", + false, + ))); + } + } + // A collection of any size answers `in_progress` first, so this + // poll — not the call that started it — is what hands the caller + // their TSX. Checking it only on the synchronous returns left + // every real export unchecked. + let project_root = input.project_root.clone(); + // responseFields on a poll selects from this reply; without them + // the reply carries compact per-asset rows. + let selection = &input.response_selection; + selection.validate().map_err(to_mcp_error)?; + let result = job + .wait_briefly(selection.active()) + .await + .map_err(to_mcp_error)?; + let result = with_project_checks( + result, + project_root.as_deref(), + &skills::Lookup::new( + project_root.as_deref(), + self.output_policy.primary_root(), + self.client_name(), + ), + ); + if selection.active() { + return compact::pick(&result, selection) + .map(tool_result) + .map_err(to_mcp_error); + } + return Ok(tool_result(result)); + } + if workflow.job_action.is_some() { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + "jobAction requires jobId.", + false, + ))); + } + let web_ready_images = match input.preset.as_deref() { + None => false, + Some("web-ready-images") => true, + Some(_) => { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + "preset must be web-ready-images.", + false, + ))); + } + }; + if web_ready_images && !input.asset_requests.is_empty() { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + "web-ready-images cannot override custom assetRequests. Export the preset and custom requests separately; no requested work was started.", + false, + ))); + } + if web_ready_images + && !input + .outputs + .iter() + .any(|o| o == "assetManifest" || o == "pageScaffold") + { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + "web-ready-images requires outputs including assetManifest or pageScaffold for reusable assets and completeness.", + false, + ))); + } + let asset_public_root = + validation::validate_public_root(workflow.asset_public_root.as_deref()) + .map_err(to_mcp_error)?; + projection::theme_export::validate( + &input.devup_json_mode, + &input.outputs, + input.project_root.as_deref(), + ) + .map_err(to_mcp_error)?; + if asset_public_root.is_some() + && !web_ready_images + && input.page_scaffold.is_none() + && !input + .asset_requests + .iter() + .any(|asset| asset.output_path.is_some()) + { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + "assetPublicRoot requires an assetRequests outputPath in this call; saved output mappings are not part of an artifact.", + false, + ))); + } + validation::validate_export_budget(&input.frame_ids, &input.outputs) + .map_err(to_mcp_error)?; + for path in input + .output_paths + .iter() + .filter(|(key, _)| !key.ends_with("pageScaffold")) + .map(|(_, path)| path) + .chain( + input + .asset_requests + .iter() + .filter_map(|asset| asset.output_path.as_ref()), + ) + { + let target = self.output_policy.resolve(path).map_err(to_mcp_error)?; + if let Some(root) = &asset_public_root + && input + .asset_requests + .iter() + .any(|asset| asset.output_path.as_ref() == Some(path)) + { + validation::public_asset_url(root, target.display_path()).map_err(to_mcp_error)?; + } + } + validate_outputs(&input.outputs, input.debug).map_err(to_mcp_error)?; + input.response_selection.validate().map_err(to_mcp_error)?; + if let Some(hints) = &input.screenshot_observations { + hints.validate().map_err(to_mcp_error)?; + } + design_drift::validate_request( + &input.outputs, + input.previous_design_fingerprints.as_deref(), + ) + .map_err(to_mcp_error)?; + if !input.asset_requests.is_empty() + && !input + .outputs + .iter() + .any(|output| matches!(output.as_str(), "assetManifest" | "pageScaffold")) + { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "Using assetRequests requires assetManifest in outputs.", + false, + ))); + } + let reference_png_requested = input.outputs.iter().any(|output| output == "referencePng"); + if reference_png_requested && (!input.frame_ids.is_empty() || input.all_screens) { + let target = if let Some(url) = &input.url { + FigmaTarget::parse(url).ok() + } else if let Some(id) = &input.artifact_id { + self.artifacts + .get(id) + .await + .map(|a| a.payload.target.clone()) + } else { + None + }; + let url = target + .as_ref() + .zip(input.frame_ids.first()) + .map(|(target, id)| target.link(Some(id))); + let mut arguments = json!(input); + for key in ["artifactId", "frameIds", "allScreens", "url"] { + arguments.as_object_mut().unwrap().remove(key); + } + arguments["scope"] = json!("node"); + if let Some(url) = &url { + arguments["url"] = json!(url); + } + return Err(to_mcp_error(DevupError::with_details( + ErrorCode::DevupSnapshotUnsupported, + "referencePng can only be collected for a single Figma link target.", + false, + json!({"recoveryState":if url.is_some() {"available"} else {"manual-fix-required"}, + "nextAction":{"tool":"devup_figma_export","arguments":arguments, + "requiredArguments":if url.is_some() {Vec::<&str>::new()} else {vec!["url"]}, + "omitArguments":["artifactId","frameIds","allScreens"], + "how":include_str!("../text/mod-devup_figma_export-1fe13d5b.md")}}), + ))); + } + let root_layout = parse_root_layout(&input.root_layout).map_err(to_mcp_error)?; + let width_policy = + validation::parse_width_policy(input.width_policy.as_ref()).map_err(to_mcp_error)?; + parse_scope(&input.scope).map_err(to_mcp_error)?; + let delivery = input + .delivery + .parse::() + .map_err(to_mcp_error)?; + + if let Some(artifact_id) = input.artifact_id.as_deref() { + if input.url.is_some() || input.refresh { + return Err(to_mcp_error(DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + "artifactId cannot be used together with url or refresh.", + false, + json!({"stage":"preflight","reason":"invalid-argument-combination", + "distinctFrom":"DEVUP_FIGMA_HANDOFF_EXPIRED means the artifactId itself is gone; this means the call mixed incompatible arguments.", + "nextAction":{"tool":"devup_figma_export", + "how":"Send either artifactId (reuse, no Figma call) or url (+ refresh:true to recollect), never both."}}), + ))); + } + let artifact = self + .artifacts + .get_for_export(artifact_id) + .await + .map_err(to_mcp_error)?; + if web_ready_images && !artifact.capabilities.web_ready_images { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + "This artifact did not acquire web-ready-images. Repeat with the original url and preset to capture the complete image plan.", + false, + ))); + } + let (asset_selections, asset_output_paths) = + parse_asset_requests(&input.asset_requests).map_err(to_mcp_error)?; + if artifact.capabilities.kind == ArtifactKind::SectionIndex + && (!input.frame_ids.is_empty() || input.all_screens) + { + let index = section_index_from_payload(&artifact.payload).ok_or_else(|| { + to_mcp_error(DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + "The Section index artifact payload is invalid.", + false, + )) + })?; + let collection_scope = + parse_collection_scope(&input.scope).map_err(to_mcp_error)?; + if collection_scope != CollectionScope::Node { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "The Section Frame collection scope must be node.", + false, + ))); + } + // A Section index already knows every candidate's name and + // width, which is all a breakpoint family is decided on. Ask + // here, where the answer costs nothing: the same refusal from + // projection arrives only after the whole collection has run. + if !input.frame_ids.is_empty() + && input.outputs.iter().any(|output| output == "responsiveTsx") + { + let selected = input + .frame_ids + .iter() + .filter_map(|id| { + index + .candidates + .iter() + .find(|candidate| candidate.node_id == *id) + }) + .map(|candidate| (candidate.name.as_str(), candidate.bounds.width)) + .collect::>(); + // Only when every selected frame was found in the index. + // A frame the index does not describe leaves the question + // open, and an open question is collected, not refused. + if selected.len() == input.frame_ids.len() + && !devup_mcp_devup_ui::codegen::responsive::breakpoint_family_is_possible( + &selected, + ) + { + let mut arguments = json!(input); + arguments["outputs"] = json!(["tsx"]); + arguments["frameIds"] = json!([input.frame_ids[0]]); + return Err(to_mcp_error(DevupError::with_details( + ErrorCode::DevupInvalidInput, + "responsiveTsx needs the selected frames to be one screen drawn at several widths, and these are not. Nothing was collected.", + false, + json!({ + "output": "responsiveTsx", + "selectedFrames": selected + .iter() + .map(|(name, width)| json!({"name": name, "width": width})) + .collect::>(), + "recoveryState": "available", + "nextAction": { + "tool": "devup_figma_export", + "arguments": arguments, + "why": "Each selected frame still converts on its own.", + "how": "Request tsx one frame at a time, repeating for each frameId you selected.", + }, + }), + ))); + } + } + let mut request = + CollectionRequest::new(artifact.payload.target.clone(), collection_scope); + request.resource_scope = ResourceScope::Used; + request.asset_selections = asset_selections.clone(); + request.web_ready_images = web_ready_images; + request.reference_png = reference_png_requested; + request.section = Some(SectionReadOptions { + frame_ids: input.frame_ids.clone(), + all_screens: input.all_screens, + }); + request.cached_section_index = Some(index); + let result = self + .start_operation( + PendingOperation::Export { + outputs: input.outputs, + component_name: input.component_name, + include_diagnostics: input.include_diagnostics, + root_layout, + asset_names_per_node: input.asset_names_per_node, + scope: input.scope, + theme_options: projection::theme_export::ThemeExportOptions { + mode: input.devup_json_mode, + project_root: input.project_root.clone(), + }, + strict: input.strict, + output_paths: input.output_paths, + page_scaffold: input.page_scaffold, + previous_design_fingerprints: input.previous_design_fingerprints, + frame_ids: input.frame_ids, + all_screens: input.all_screens, + asset_captures: asset_selections, + web_ready_images, + asset_output_paths, + asset_public_root: asset_public_root.clone(), + fields: input.fields.clone(), + response_selection: input.response_selection.clone(), + delivery, + width_policy: width_policy.clone(), + }, + request, + false, + ) + .await + .map_err(to_mcp_error)?; + return Ok(tool_result(with_project_checks( + result, + input.project_root.as_deref(), + &skills::Lookup::new( + input.project_root.as_deref(), + self.output_policy.primary_root(), + self.client_name(), + ), + ))); + } + validate_artifact_projection( + &artifact, + &input.outputs, + &input.scope, + &asset_selections, + ) + .map_err(to_mcp_error)?; + let result = complete_operation( + PendingOperation::Export { + outputs: input.outputs, + component_name: input.component_name, + include_diagnostics: input.include_diagnostics, + root_layout, + asset_names_per_node: input.asset_names_per_node, + scope: input.scope, + theme_options: projection::theme_export::ThemeExportOptions { + mode: input.devup_json_mode, + project_root: input.project_root.clone(), + }, + strict: input.strict, + output_paths: input.output_paths, + page_scaffold: input.page_scaffold, + previous_design_fingerprints: input.previous_design_fingerprints, + frame_ids: input.frame_ids, + all_screens: input.all_screens, + asset_captures: asset_selections, + web_ready_images, + asset_output_paths, + asset_public_root: asset_public_root.clone(), + fields: input.fields.clone(), + response_selection: input.response_selection.clone(), + delivery, + width_policy: width_policy.clone(), + }, + &artifact.payload, + "artifact", + &artifact, + &self.output_policy, + &self.artifacts, + ) + .await + .map_err(to_mcp_error)?; + return Ok(tool_result(with_project_checks( + result, + input.project_root.as_deref(), + &skills::Lookup::new( + input.project_root.as_deref(), + self.output_policy.primary_root(), + self.client_name(), + ), + ))); + } + + let collection_scope = parse_collection_scope(&input.scope).map_err(to_mcp_error)?; + let mut target = parse_link(input.url.as_deref()).map_err(to_mcp_error)?; + let mut frame_ids = input.frame_ids; + // With no link, frameIds naming one frame is that frame - exactly what + // its own link would say. Several are screens of the selected Section. + if target.file_key == BRIDGE_CURRENT_KEY + && target.node_id.is_none() + && frame_ids.len() == 1 + && !input.all_screens + { + target.node_id = frame_ids.pop(); + } + let wants_tsx = input.outputs.iter().any(|output| output == "tsx"); + let unnamed = if !frame_ids.is_empty() || input.all_screens { + Unnamed::SelectedSection + } else if collection_scope == CollectionScope::File && !wants_tsx { + Unnamed::SelectionOrFile + } else { + Unnamed::Selection + }; + let mut retry = + json!({ "tool": "devup_figma_export", "arguments": { "outputs": input.outputs } }); + if let Some(hints) = &input.screenshot_observations { + retry["arguments"]["screenshotObservations"] = json!(hints); + } + if !frame_ids.is_empty() { + retry["arguments"]["frameIds"] = json!(frame_ids); + } + if input.all_screens { + retry["arguments"]["allScreens"] = json!(true); + } + let target = self + .bind_to_bridge(target, unnamed, &retry) + .await + .map_err(to_mcp_error)?; + if wants_tsx && target.node_id.is_none() { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "A TSX export link requires a node-id.", + false, + ))); + } + let mut request = CollectionRequest::new(target, collection_scope); + let (asset_selections, asset_output_paths) = + parse_asset_requests(&input.asset_requests).map_err(to_mcp_error)?; + request.asset_selections = asset_selections.clone(); + request.web_ready_images = web_ready_images; + request.reference_png = reference_png_requested; + request.resource_scope = if collection_scope == CollectionScope::File { + ResourceScope::File + } else { + ResourceScope::Used + }; + request.variables_only = collection_scope == CollectionScope::File + && input.outputs.iter().all(|output| output == "devupJson") + && request.asset_selections.is_empty(); + if !frame_ids.is_empty() || input.all_screens { + request.section = Some(SectionReadOptions { + frame_ids: frame_ids.clone(), + all_screens: input.all_screens, + }); + } + let operation = PendingOperation::Export { + outputs: input.outputs, + component_name: input.component_name, + include_diagnostics: input.include_diagnostics, + root_layout, + asset_names_per_node: input.asset_names_per_node, + scope: input.scope, + theme_options: projection::theme_export::ThemeExportOptions { + mode: input.devup_json_mode, + project_root: input.project_root.clone(), + }, + strict: input.strict, + output_paths: input.output_paths, + page_scaffold: input.page_scaffold, + previous_design_fingerprints: input.previous_design_fingerprints, + frame_ids, + all_screens: input.all_screens, + asset_captures: asset_selections, + web_ready_images, + asset_output_paths, + asset_public_root: asset_public_root.clone(), + fields: input.fields.clone(), + response_selection: input.response_selection.clone(), + delivery, + width_policy: width_policy.clone(), + }; + // A frame link plus frameIds is a precise request; refusing it because + // the plugin wants the parent Section only makes the caller repeat + // the ancestor lookup the plugin already did. Retry once, there. + let retry_with = |error: &DevupError| section_retry(error, &operation, &request); + let result = match self + .start_operation(operation.clone(), request.clone(), input.refresh) + .await + { + Err(error) => match retry_with(&error) { + Some((operation, request, resolved)) => self + .start_operation(operation, request, input.refresh) + .await + .map(|mut value| { + if let Some(object) = value.as_object_mut() { + object.insert("autoResolvedSection".into(), resolved); + } + value + }), + None => Err(error), + }, + ok => ok, + } + .map_err(to_mcp_error)?; + Ok(tool_result(with_project_checks( + result, + input.project_root.as_deref(), + &skills::Lookup::new( + input.project_root.as_deref(), + self.output_policy.primary_root(), + self.client_name(), + ), + ))) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_figma_measure.rs b/crates/devup-mcp/src/server/handlers/devup_figma_measure.rs new file mode 100644 index 00000000..57c073b0 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_figma_measure.rs @@ -0,0 +1,17 @@ +use super::*; + +#[tool_router(router = devup_figma_measure_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema())] + #[doc = include_str!("../text/devup-figma-measure.md")] + pub(super) async fn devup_figma_measure( + &self, + Parameters(input): Parameters, + ) -> Result { + measurement::run(self, input) + .await + .map(tool_result) + .map_err(to_mcp_error) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_figma_search.rs b/crates/devup-mcp/src/server/handlers/devup_figma_search.rs new file mode 100644 index 00000000..6bff8160 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_figma_search.rs @@ -0,0 +1,73 @@ +use super::*; + +#[tool_router(router = devup_figma_search_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-figma-search.md")] + pub(super) async fn devup_figma_search( + &self, + Parameters(input): Parameters, + ) -> Result { + // Refused here rather than after collecting, the way the explore tool + // already refuses one: the projection below asks for the ceiling when + // the search is scoped, and would otherwise swallow the rejection that + // an out-of-range limit is owed. + if !(1..=MATCH_CEILING).contains(&input.limit) { + return Err(to_mcp_error(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "The search limit must be between 1 and 100 inclusive.", + false, + ))); + } + let target = self + .bind_to_bridge( + parse_link(input.url.as_deref()).map_err(to_mcp_error)?, + Unnamed::File, + &json!({ "tool": "devup_figma_search", "arguments": { "query": input.query } }), + ) + .await + .map_err(to_mcp_error)?; + let scope = SearchScope { + node_id: target.node_id.clone(), + limit: input.limit, + file_url: target.link(None), + }; + // Scope the upstream read itself to the linked subtree. Keep the ranked + // projection above the caller's limit so ancestry filtering happens + // before the response is cut. + let collection_scope = if scope.node_id.is_some() { + CollectionScope::Node + } else { + CollectionScope::File + }; + let projected_limit = if scope.node_id.is_some() { + MATCH_CEILING + } else { + input.limit + }; + let mut request = CollectionRequest::new(target, collection_scope); + request.search = Some(SearchReadOptions { + query: input.query.clone(), + node_types: input.node_types.clone(), + match_kind: input.match_kind.clone(), + limit: projected_limit, + }); + let result = self + .start_operation_scoped( + PendingOperation::Search { + query: input.query, + node_types: input.node_types, + match_kind: input.match_kind, + limit: projected_limit, + }, + request, + false, + Some(&scope), + ) + .await + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_project_context.rs b/crates/devup-mcp/src/server/handlers/devup_project_context.rs new file mode 100644 index 00000000..4ed3ae26 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_project_context.rs @@ -0,0 +1,30 @@ +use super::*; + +#[tool_router(router = devup_project_context_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-project-context.md")] + pub(super) 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(with_context_skill_gap( + result, + &input.scope, + &skills::Lookup::new( + input.project_root.as_deref(), + self.output_policy.primary_root(), + self.client_name(), + ), + ))) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_project_plan.rs b/crates/devup-mcp/src/server/handlers/devup_project_plan.rs new file mode 100644 index 00000000..fc39d7bf --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_project_plan.rs @@ -0,0 +1,18 @@ +use super::*; + +#[tool_router(router = devup_project_plan_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-project-plan.md")] + pub(super) async fn devup_project_plan( + &self, + Parameters(input): Parameters, + ) -> Result { + let result = project_plan::run(&input, &self.output_policy) + .await + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_runtime_audit.rs b/crates/devup-mcp/src/server/handlers/devup_runtime_audit.rs new file mode 100644 index 00000000..dd841fc0 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_runtime_audit.rs @@ -0,0 +1,52 @@ +use super::*; + +#[tool_router(router = devup_runtime_audit_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-runtime-audit.md")] + pub(super) async fn devup_runtime_audit( + &self, + Parameters(input): Parameters, + ) -> Result { + let invalid = |message: &str| { + to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + message, + false, + )) + }; + let result = match input.action.as_str() { + "plan" => { + let plan = input + .plan + .ok_or_else(|| invalid("action plan needs plan."))?; + runtime_audit::create_plan( + plan, + &self.output_policy, + input.output_path.as_deref(), + input.template_path.as_deref(), + &self.artifacts, + ) + .await + } + "ingest" => { + let (Some(plan_path), Some(report_path)) = (input.plan_path, input.report_path) + else { + return Err(invalid("action ingest needs planPath and reportPath.")); + }; + runtime_audit::ingest_with_artifacts( + &self.output_policy, + &plan_path, + &report_path, + &self.artifacts, + ) + .await + } + _ => return Err(invalid("action must be plan or ingest.")), + } + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_screen_plan.rs b/crates/devup-mcp/src/server/handlers/devup_screen_plan.rs new file mode 100644 index 00000000..99106c12 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_screen_plan.rs @@ -0,0 +1,45 @@ +use super::*; + +#[tool_router(router = devup_screen_plan_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-screen-plan.md")] + pub(super) async fn devup_screen_plan( + &self, + Parameters(input): Parameters, + ) -> Result { + let result = match input.action.as_str() { + "draft" => { + let (screens, source) = self.plan_screens(&input).await.map_err(to_mcp_error)?; + let mut assets = input.assets.clone(); + assets.extend( + source["assetCandidates"] + .as_array() + .into_iter() + .flatten() + .filter_map(|asset| serde_json::from_value(asset.clone()).ok()), + ); + screen_plan::draft_with(&input, &screens, &assets, source, &self.output_policy) + .await + } + "confirm" => screen_plan::confirm(&input, &self.output_policy), + "assume" => screen_plan::assume(&input, &self.output_policy), + "status" => screen_plan::status(&input, &self.output_policy, &self.artifacts).await, + "auditPlan" => { + screen_plan::audit_plan(&input, &self.output_policy, &self.artifacts).await + } + "defer" => screen_plan::defer(&input, &self.output_policy), + other => Err(DevupError::new( + ErrorCode::DevupInvalidInput, + format!( + "action must be draft, confirm, status, assume, auditPlan or defer, not {other}." + ), + false, + )), + } + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_skills.rs b/crates/devup-mcp/src/server/handlers/devup_skills.rs new file mode 100644 index 00000000..88005f98 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_skills.rs @@ -0,0 +1,38 @@ +use super::*; + +#[tool_router(router = devup_skills_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-skills.md")] + pub(super) async fn devup_skills( + &self, + Parameters(input): Parameters, + ) -> Result { + let lookup = skills::Lookup::new( + input.project_root.as_deref(), + self.output_policy.primary_root(), + self.client_name(), + ); + match input.action.as_str() { + "status" => Ok(tool_result(skills::report(&lookup))), + "install" => { + let outcome = skills::install(&self.output_policy, &lookup, &input.names) + .await + .map_err(to_mcp_error)?; + // The state after the write, from the same reader `status` + // uses. An install that reports what it meant to do rather than + // what is now on disk is the report that cannot be trusted. + let mut result = outcome; + result["state"] = skills::report(&lookup); + Ok(tool_result(result)) + } + other => Err(to_mcp_error(DevupError::new( + ErrorCode::DevupInvalidInput, + format!("action must be status or install, not {other}."), + false, + ))), + } + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_stack_diff.rs b/crates/devup-mcp/src/server/handlers/devup_stack_diff.rs new file mode 100644 index 00000000..938cf09a --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_stack_diff.rs @@ -0,0 +1,18 @@ +use super::*; + +#[tool_router(router = devup_stack_diff_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-stack-diff.md")] + pub(super) 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)) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs new file mode 100644 index 00000000..2d897df5 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs @@ -0,0 +1,204 @@ +use super::*; + +#[tool_router(router = devup_ui_validate_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-ui-validate.md")] + pub(super) async fn devup_ui_validate( + &self, + Parameters(input): Parameters, + ) -> Result { + use devup_mcp_devup_ui::ui_validate::{bundle_theme, check_bundle, validate_bundle}; + let invalid = |message: String| ErrorData::invalid_params(message, None); + // Neither source supplied. This used to validate the empty string and + // answer `ok: true, okReason: "clean"` — which reads as "this code + // passed" about code that was never sent. There is no verdict to give + // here, so the call is refused instead of being given a passing one. + if input.files.as_ref().is_none_or(|files| files.is_empty()) && input.tsx.trim().is_empty() + { + return Err(invalid( + "Supply either tsx or files; neither was provided, so there is nothing to validate." + .into(), + )); + } + let theme_lookup = if let Some(files) = &input.files { + check_bundle(files).map_err(invalid)?; + if !input.tsx.is_empty() { + return Err(invalid("Supply either tsx or files, not both".into())); + } + let theme = bundle_theme(files).map_err(invalid)?; + let guardrail = theme.is_none().then(|| json!({ + "message": "No devup.json supplied in files; token checks skipped. Bundle validation never reads the filesystem." + })); + project_context::ThemeLookup { theme, guardrail } + } else { + project_context::theme_for_validation(input.project_root.as_deref()) + .map_err(to_mcp_error)? + }; + let mut report = if let Some(files) = &input.files { + validate_bundle(files, theme_lookup.theme.as_ref(), input.strict).map_err(invalid)? + } else { + devup_mcp_devup_ui::ui_validate::validate_devup_ui_tsx( + &input.tsx, + theme_lookup.theme.as_ref(), + input.strict, + ) + }; + // Site completeness, for code checked against a project: feedback on + // every interactive element (bundle mode checks it per file), and an + // icon for the app. + if let Some(root) = input.project_root.as_deref() { + if input.files.is_none() { + report.violations.extend( + devup_mcp_devup_ui::ui_validate::site::interaction_findings( + input.source_name.as_deref().unwrap_or("input.tsx"), + &input.tsx, + ), + ); + } + report + .violations + .extend(screen_plan::missing_app_icon(root)); + if input.strict + && report.violations.iter().any(|finding| { + finding.severity == devup_mcp_devup_ui::ui_validate::Severity::Warning + }) + { + report.ok = false; + } + } + if input.asset_policies.len() > 4096 { + return Err(invalid("assetPolicies exceeds 4096 entries.".into())); + } + let sources = input + .files + .as_ref() + .map(|files| { + files + .iter() + .map(|file| (file.path.as_str(), file.content.as_str())) + .collect::>() + }) + .unwrap_or_else(|| vec![("", input.tsx.as_str())]); + for &(name, tsx) in &sources { + for diagnostic in + devup_mcp_devup_ui::codegen::validate_asset_effects(tsx, &input.asset_policies) + { + let mut context = diagnostic + .details + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + if !name.is_empty() { + context.insert("sourceName".into(), json!(name)); + } + report.violations.push(devup_mcp_devup_ui::ui_validate::Violation {rule:"double-effect",severity:devup_mcp_devup_ui::ui_validate::Severity::Warning,byte_range:[0,0],context,message:diagnostic.message,suggestion:Some("Remove the repeated CSS effect, or acquire original-source and apply that effect in CSS.".into())}); + if input.strict { + report.ok = false; + } + } + } + // A mask flattens its SVG to one colour, which only the file shows. + let masks = mask_guard::violations(&sources, input.project_root.as_deref()); + if input.strict && !masks.is_empty() { + report.ok = false; + } + report.violations.extend(masks); + // These keys are assembled here rather than serialized from + // `UiValidation`, so anything the struct's own documentation + // explains reaches nobody unless it is answered here too. Three + // things in this response cannot be read off it: + // + // `ok: true` next to ten violations looks like a contradiction + // until you know severity decides it and count does not, so the + // severity split is reported and `okReason` names the case. + // + // `checkedTokens: 4` next to `availableTokenCount: 81` reads as + // "4 of 81 checked". It is not: one counts references the code + // makes, the other definitions the theme holds. `tokens` says + // which is which in the key names, where a reader cannot miss it. + // + // And with no theme the token check is skipped rather than passed, + // which a bare `checkedTokens` cannot distinguish. + use devup_mcp_devup_ui::ui_validate::Severity; + let count_severity = |severity| { + report + .violations + .iter() + .filter(|finding| finding.severity == severity) + .count() + }; + let errors = count_severity(Severity::Error); + let warnings = count_severity(Severity::Warning); + let infos = count_severity(Severity::Info); + // `clean` has to mean "checked, and nothing was wrong". With no theme + // resolved the unknown-token check never ran, so code that references + // tokens is unverified rather than clean. This is the monorepo case + // that answered `clean` for a `$bg` reference while no devup.json had + // been read at all. + let token_check_skipped = theme_lookup.theme.is_none() && report.checked_tokens > 0; + let ok_reason = match (errors, warnings, infos, input.strict) { + (1.., _, _, _) => "error-violations", + (_, 1.., _, true) => "strict-warnings", + (_, 1.., 1.., _) => "warnings-and-info", + (_, 1.., _, _) => "warnings-only", + (_, _, 1.., _) => "info-only", + _ if token_check_skipped => "clean-unverified-tokens", + _ => "clean", + }; + let mut result = json!({ + "ok": report.ok, + "okReason": ok_reason, + "strict": input.strict, + "violations": report.violations, + "violationCounts": { "error": errors, "warning": warnings, "info": infos }, + "themeNotes": report.theme_notes, + "checkedTokens": report.checked_tokens, + "availableTokenCount": report.available_token_count, + "tokens": { + "referencedByTsx": report.checked_tokens, + "definedByTheme": report.available_token_count, + "unknownTokenCheckRan": theme_lookup.theme.is_some(), + }, + "themeAvailable": theme_lookup.theme.is_some(), + "themeGuardrail": theme_lookup.guardrail, + }); + if let Some(name) = &input.source_name { + for finding in result["violations"].as_array_mut().unwrap() { + finding["sourceName"] = json!(name); + } + } + attach_layout_and_source_links(&mut result, &input); + navigation::attach_validation(&mut result, &input); + if !report.ok { + result.as_object_mut().unwrap().extend( + validation_guidance::guidance(&input, &report, theme_lookup.theme.as_ref()) + .as_object() + .unwrap() + .clone(), + ); + } + // A violation is a located, proven gap in devup-ui knowledge, which + // makes this the one moment where naming the skill is a measurement + // rather than a nudge. Raised only when the skill is actually absent: + // telling a caller who already has it to install it is the noise that + // teaches them to skip the field. + let lookup = skills::Lookup::new( + input.project_root.as_deref(), + self.output_policy.primary_root(), + self.client_name(), + ); + if !report.violations.is_empty() + && lookup.installed_paths("devup-ui").is_empty() + && let Some(skill) = skills::find_by_name("devup-ui") + { + result["skillGap"] = json!({ + "skill": "devup-ui", + "why": include_str!("../text/mod-devup_ui_validate-48eac31a.md"), + "install": skill.install_action(&lookup), + }); + } + Ok(tool_result(result)) + } +} diff --git a/crates/devup-mcp/src/server/handlers/devup_visual_compare.rs b/crates/devup-mcp/src/server/handlers/devup_visual_compare.rs new file mode 100644 index 00000000..c5220f0d --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_visual_compare.rs @@ -0,0 +1,18 @@ +use super::*; + +#[tool_router(router = devup_visual_compare_router, vis = "pub(super)")] +impl DevupServer { + #[tool( + output_schema = permissive_object_output_schema() + )] + #[doc = include_str!("../text/devup-visual-compare.md")] + pub(super) async fn devup_visual_compare( + &self, + Parameters(input): Parameters, + ) -> Result { + let result = visual_compare::compare(input, &self.output_policy, &self.artifacts) + .await + .map_err(to_mcp_error)?; + Ok(tool_result(result)) + } +} diff --git a/crates/devup-mcp/src/server/interactions.rs b/crates/devup-mcp/src/server/interactions.rs index 0333ebea..7b86ef41 100644 --- a/crates/devup-mcp/src/server/interactions.rs +++ b/crates/devup-mcp/src/server/interactions.rs @@ -623,7 +623,7 @@ pub(super) fn attach(result: &mut Value, snapshot: &Snapshot) { issues.as_array_mut().unwrap().push(json!({"code":"DEVUP_CODEGEN_INTERACTION_UNMAPPED","nodeId":node_ids.first(),"screenId":screen_id, "property":"reactions","severity":"warning","fidelityImpact":"none", "message":"Interaction candidate has static markup only; href/events and accessible control semantics require implementation.", - "details":{"classification":"interaction-unmapped","candidateCount":node_ids.len(),"nodeIds":node_ids,"nextAction":"Resolve every interactiveCandidate against real routes, record IDs and accessible controls; validate and test the wiring. Ask about unresolved targets together; never invent a destination."}})); + "details":{"classification":"interaction-unmapped","candidateCount":node_ids.len(),"nodeIds":node_ids,"nextAction":include_str!("text/interactions-attach-cd63f312.md")}})); } if result["deliverable"].is_object() { result["deliverable"]["isFinal"] = json!(false); @@ -681,7 +681,7 @@ pub(super) fn attach(result: &mut Value, snapshot: &Snapshot) { } } result["ambiguityQuestions"] = json!(questions); - result["completionEvidence"] = json!({"behavior":"unverified","visual":"unverified","required":["Report candidate, wired and tested counts with unresolved reasons.","Run devup_ui_validate with real route inventory and inspect source/rendered href coverage.","Render at each implemented viewport with project fonts/assets loaded and capture a screenshot; call devup_visual_compare against referencePng. Report skipped comparisons and remaining viewports explicitly."], + result["completionEvidence"] = json!({"behavior":"unverified","visual":"unverified","required":["Report candidate, wired and tested counts with unresolved reasons.","Run devup_ui_validate with real route inventory and inspect source/rendered href coverage.",include_str!("text/interactions-attach-1e4189e6.md")], "visualCompare":{"tool":"devup_visual_compare","automaticRun":false,"reason":"Requires actual browser-rendered screenshot and matching Figma reference; source analysis is not pixel evidence."}}); if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { for frame in frames { diff --git a/crates/devup-mcp/src/server/measurement.rs b/crates/devup-mcp/src/server/measurement.rs index 654110e4..4f4c34c8 100644 --- a/crates/devup-mcp/src/server/measurement.rs +++ b/crates/devup-mcp/src/server/measurement.rs @@ -98,7 +98,7 @@ pub(super) fn calibrate(result: &Value, input: &super::tools::MeasureCalibration "nodeId":input.node_id,"logicalWidth":width,"logicalHeight":height, "scaleX":x.is_finite().then_some(x),"scaleY":y.is_finite().then_some(y), "uniformScale":consistent.then_some((x+y)/2.0),"aspectConsistent":consistent, - "evidenceLimit":"Screen pixels are caller-declared. No screenshot, canvas mapping, crop or browser viewport was inspected; UI DPR and zoom do not prove capture scale. Compare DOM CSS logical dimensions to these Figma logical dimensions."}) + "evidenceLimit":include_str!("text/measurement-calibrate-b4d6cfac.md")}) } pub(super) async fn run( diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index a8dc66e4..db0144e5 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -1,3661 +1,5 @@ -pub mod artifacts; -mod asset_jobs; -mod audit_plan; -mod call_cache; -mod compact; -pub mod delivery; -mod design_drift; -mod diagnostics; -mod export_record; -mod feature_trace; -#[cfg(test)] -mod feedback_interactions_tests; -mod field_select; -mod framework; -mod guide; -mod interactions; -mod mask_guard; -mod measurement; -mod navigation; -pub mod operation; -pub mod output; -mod pacing; -mod project_context; -mod project_root; -mod projection; -mod quality; -mod release_check; -pub mod resources; -mod result_contract; -mod review; -mod runtime_audit; -mod screen_plan; -mod screenshot_observations; -mod section_checklist; -pub mod self_update; -mod skills; -mod stack_diff; -mod tools; -mod validation; -mod validation_guidance; -mod verdict_scope; -mod visual_compare; - -use std::sync::Arc; - -use async_trait::async_trait; -use rmcp::{ - ErrorData, RoleServer, ServerHandler, - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{ - CallToolResult, ErrorCode as McpErrorCode, Implementation, JsonObject, ServerCapabilities, - ServerInfo, - }, - service::RequestContext, - tool, tool_handler, tool_router, -}; -use serde_json::{Value, json}; - -use devup_mcp_devup_ui::theme::ThemeScope; -use devup_mcp_figma::{ - AuthStatus, BRIDGE_CURRENT_KEY, BridgeFigmaClient, BridgeOptions, BridgeRole, BridgeServer, - ClientCredentialSource, ClientCredentials, CollectedParts, CollectedPayload, CollectionRequest, - CollectionScope, CollectorSession, CollectorStep, CredentialStore, DEFAULT_CLIENT_NAME, - DevupError, DirectPathSnapshot, ErrorCode, ExploreCandidate, ExploreKind, ExploreNode, - ExploreReadOptions, FallbackUpstream, FigmaTarget, FigmaUpstream, KeyringClientCredentialStore, - KeyringCredentialStore, OAuthManager, ReadToolCall, RemoteFigmaClient, ResourceScope, - SearchReadOptions, SecretString, SectionCandidate, SectionIndex, SectionReadOptions, Snapshot, - SystemBrowser, TokenState, UpstreamResult, -}; - -use artifacts::{ArtifactKind, ArtifactRequestKey, ArtifactStore}; -use call_cache::CallCache; -use delivery::{DeliveryMode, tool_result}; -use operation::PendingOperation; -use output::OutputPolicy; -use pacing::CallPacer; -use projection::complete_operation; -use validation::{ - parse_asset_requests, parse_collection_scope, parse_root_layout, validate_artifact_projection, - validate_outputs, -}; - -pub use tools::{ - AuthInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, FigmaSearchInput, - ProjectContextInput, SkillsInput, StackDiffInput, UiValidateInput, -}; - -/// Additive workflow options; existing input defaults remain in tools.rs. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct FigmaExportWorkflowInput { - #[serde(flatten)] - pub input: FigmaExportInput, - /// Opt in to rewriting exported asset references. Existing absolute local - /// directory served at URL /. Only exported files below it are mapped; - /// URL path segments are percent-encoded. Omit to keep placeholder paths. - #[serde(default)] - pub asset_public_root: Option, - - /// Poll a server-local asset job. Omit url/artifactId/assetRequests when polling. - #[serde(default)] - pub job_id: Option, - /// status (default) or resume a paused asset job without repeating accepted reads. - #[serde(default)] - #[schemars(extend("enum" = ["status", "resume", "cancel"]))] - pub job_action: Option, -} - -const FIGMA_ENDPOINT: &str = "https://mcp.figma.com/mcp"; - -/// The most matches `devup_figma_search` and screen candidates -/// `devup_figma_explore` rank before the caller's `limit` cuts the answer. -/// -/// Both tools rank first and cut afterwards, so the ranked list has to be -/// built past `limit` or the cut decides the answer. In search it decided it -/// wrongly: five frames share the name `Loading` across the file, ranking put -/// the two outside the linked Section first, and a `limit` of five removed -/// both of the frames the caller had actually pointed at. This is also the -/// ceiling both tools already validate `limit` against, so nothing above it -/// could have been asked for. -const MATCH_CEILING: usize = 100; - -/// The byte-and-node budget every `devup_figma_explore` collection is given, -/// which is the highest the collection script accepts. It is a constant on -/// purpose - see the comment where it is used. -const EXPLORE_PROJECTION_LIMIT: usize = 400; - -#[async_trait] -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, - )) - } -} - -/// The system browser, with the URL said aloud first. -/// -/// Two logins in a row timed out waiting for a callback that never came, and -/// nothing said whether the browser had opened at all. `webbrowser::open` -/// reports failure to launch but not a launch into a window nobody is -/// looking at, and the URL it was handed was kept nowhere. So it is logged -/// before the browser is asked: if the tab does not appear, the URL is in -/// stderr to be opened by hand. Nothing in it is a secret — the verifier -/// stays in the process, and the challenge, state and client_id are what -/// the address bar shows anyway. -struct SpokenBrowser; - -impl devup_mcp_figma::BrowserOpener for SpokenBrowser { - fn open(&self, authorization_url: &str) -> Result<(), DevupError> { - // stderr is where this binary's traces go; stdout is MCP frames only. - eprintln!( - "devup-mcp: opening the browser for Figma authorization. \ - If no tab appears, open this URL by hand:\n{authorization_url}" - ); - SystemBrowser.open(authorization_url) - } -} - -#[async_trait] -impl DevupAuth for OAuthManager { - async fn status(&self) -> Result { - OAuthManager::status(self).await - } - - async fn login(&self) -> Result { - OAuthManager::login(self, &SpokenBrowser).await?; - Ok(AuthStatus::Connected) - } - - async fn logout(&self) -> Result { - 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)] -pub struct Services { - auth: Arc, - upstream: Arc, - /// Shared, so that concurrent collections meter against one ceiling rather - /// than one each and together exceed it. - pacer: Arc, - /// Off unless a directory was named, in which case each read that succeeds - /// is kept so a later attempt need not pay for it again. - call_cache: Arc, -} - -impl Services { - pub fn new(auth: Arc, upstream: Arc) -> Self { - Self { - auth, - upstream, - pacer: Arc::new(CallPacer::from_env()), - call_cache: Arc::new(CallCache::from_env()), - } - } - - /// Names the directory to bank calls in, rather than reading it from the - /// environment. A caller running two collections against one bank needs to - /// say which bank without setting a variable the whole process shares. - pub fn with_call_cache_dir( - auth: Arc, - upstream: Arc, - directory: Option, - ) -> Self { - Self { - auth, - upstream, - pacer: Arc::new(CallPacer::from_env()), - call_cache: Arc::new(CallCache::new( - directory, - std::time::Duration::from_secs(48 * 60 * 60), - )), - } - } - - fn production( - figma_direct: crate::FigmaDirectConfig, - bridge: Bridge, - ) -> Result { - // To check what happens without a Figma login while the person stays - // logged in: the login and the client credential both kept in memory, - // empty at start, so the operating system's store is neither read nor - // written. - if std::env::var("DEVUP_FIGMA_CREDENTIAL_STORE").as_deref() == Ok("memory") { - return Self::assemble( - devup_mcp_figma::MemoryCredentialStore::default(), - Arc::new(devup_mcp_figma::MemoryClientCredentialStore::default()), - figma_direct, - bridge, - ); - } - Self::assemble( - KeyringCredentialStore, - Arc::new(KeyringClientCredentialStore), - figma_direct, - bridge, - ) - } - - fn assemble( - store: S, - client_store: Arc, - figma_direct: crate::FigmaDirectConfig, - bridge: Bridge, - ) -> Result { - let mut oauth = OAuthManager::with_endpoint(FIGMA_ENDPOINT, store)? - .with_client_credential_store(client_store); - 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); - } - // Three minutes is enough when the browser opens itself and the person - // is already looking at it. It is not enough when the URL has to be - // carried to them by hand — read from a log, pasted into a chat, opened - // a few minutes later — which is how a login came back "timed out" - // after being approved: the approval arrived at a listener that had - // already closed. Whoever is carrying the URL sets this. - if let Some(seconds) = std::env::var("DEVUP_FIGMA_CALLBACK_TIMEOUT_SECONDS") - .ok() - .and_then(|value| value.trim().parse::().ok()) - .filter(|seconds| *seconds > 0) - { - oauth = oauth.with_callback_timeout(std::time::Duration::from_secs(seconds)); - } - 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 remote = RemoteFigmaClient::new(oauth.clone()); - // A script read goes to the bridge when a plugin is reachable - through - // this process's own port, or through the devup-mcp that holds it. With - // no plugin attached every call falls straight through to the remote - // path, so opening the door costs nothing when nobody walks through. - let bridge = match bridge { - Bridge::FromEnvironment => BridgeServer::from_env_with(BridgeOptions { - build_id: Some(crate::build_id().to_owned()), - secret_path: None, - }), - Bridge::Off => None, - }; - let pacer = Arc::new(CallPacer::from_env()); - let upstream: Arc = match bridge { - Some(bridge) => Arc::new( - FallbackUpstream::new( - BridgeFigmaClient::new(bridge.state()).with_port(bridge.port()), - remote, - ) - .with_meter(pacer.clone()), - ), - None => Arc::new(remote), - }; - let mut services = Self::new(Arc::new(oauth), upstream); - services.pacer = pacer; - Ok(services) - } -} - -#[derive(Clone)] -pub struct DevupServer { - tool_router: ToolRouter, - services: Services, - artifacts: ArtifactStore, - output_policy: OutputPolicy, - asset_jobs: asset_jobs::AssetJobs, - /// The MCP client's own name, learned at `initialize` and kept because - /// the tool methods never see the request context. - /// - /// `Arc` so every clone of the server reads the one that was filled in: - /// a per-clone copy would be empty in whichever clone the handshake did - /// not run through, and the skill roots would silently go back to - /// guessing. - client_name: std::sync::Arc>, -} - -impl DevupServer { - /// Which agent runtime is on the other end, for choosing skill roots. - /// - /// `Unknown` before the handshake or from a client that sends no name, - /// and that case installs every convention rather than picking one. - fn client_name(&self) -> Option<&str> { - self.client_name.get().map(String::as_str) - } - - pub fn new(services: Services) -> Self { - Self::with_output_roots( - services, - vec![std::env::current_dir().expect("devup-mcp current directory")], - ) - .expect("devup-mcp output root") - } - - pub fn with_output_roots( - services: Services, - roots: Vec, - ) -> Result { - Ok(Self { - tool_router: Self::tool_router(), - asset_jobs: asset_jobs::AssetJobs::default(), - services, - artifacts: ArtifactStore::default(), - output_policy: OutputPolicy::from_roots(roots)?, - client_name: std::sync::Arc::new(std::sync::OnceLock::new()), - }) - } - - pub fn production_with_config( - roots: Vec, - figma_direct: crate::FigmaDirectConfig, - ) -> Result { - Self::production_with_bridge(roots, figma_direct, Bridge::FromEnvironment) - } - - /// The production stack, with the Devup Bridge opened as `bridge` says. - pub fn production_with_bridge( - roots: Vec, - figma_direct: crate::FigmaDirectConfig, - bridge: Bridge, - ) -> Result { - Self::with_output_roots(Services::production(figma_direct, bridge)?, roots) - } - - /// The production stack for use inside another process - a test, an - /// embedder - so the Devup Bridge stays shut: the plugin's port belongs - /// to the devup-mcp sessions the machine is running. - pub fn production_with_output_roots( - roots: Vec, - ) -> Result { - Self::production_with_bridge(roots, crate::FigmaDirectConfig::default(), Bridge::Off) - } -} - -/// Whether a production server opens the Devup Bridge. -/// -/// Opening it binds the plugin's port, or reads through the devup-mcp that -/// holds it - which is what a serving process should do, and exactly what a -/// process that only checks its own configuration (`--self-check`) or a test -/// building the stack in-process should not: port 1993 belongs to whatever -/// sessions the machine runs. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Bridge { - /// `DEVUP_FIGMA_BRIDGE_PORT` decides, as in a serving process. - FromEnvironment, - Off, -} - -impl DevupServer { - async fn start_operation( - &self, - operation: PendingOperation, - request: CollectionRequest, - refresh: bool, - ) -> Result { - if matches!(operation, PendingOperation::Export { .. }) { - return self.start_asset_job(operation, request, refresh).await; - } - self.start_operation_scoped(operation, request, refresh, None) - .await - } - - /// As [`Self::start_operation`], plus the one shaping step that cannot be - /// done by the tool handler on its own: narrowing search matches to the - /// node the caller linked. Deciding whether a match sits inside that node - /// needs the collected snapshot's parent chain, and this is the last place - /// that still holds the snapshot. - async fn start_operation_scoped( - &self, - operation: PendingOperation, - request: CollectionRequest, - refresh: bool, - scope: Option<&SearchScope>, - ) -> Result { - self.start_operation_scoped_tracked(operation, request, refresh, scope, None) - .await - } - - async fn start_operation_scoped_tracked( - &self, - operation: PendingOperation, - request: CollectionRequest, - refresh: bool, - scope: Option<&SearchScope>, - job: Option<&asset_jobs::AssetJob>, - ) -> Result { - let artifact_key = ArtifactRequestKey::from_collection(&request); - if !refresh && let Some(artifact) = self.artifacts.lookup(&artifact_key).await { - if let Some(job) = job { - job.captured(&artifact.payload.assets); - } - let response = complete_operation( - operation, - &artifact.payload, - "artifact", - &artifact, - &self.output_policy, - &self.artifacts, - ) - .await?; - return Ok(apply_search_scope( - response, - &artifact.payload.snapshot, - artifact.capabilities.collection_scope, - scope, - )); - } - if !refresh - && let Some(artifact) = self.artifacts.lookup_related_explore(&artifact_key).await - { - if let Some(job) = job { - job.captured(&artifact.payload.assets); - } - let response = complete_operation( - operation, - &artifact.payload, - "artifact", - &artifact, - &self.output_policy, - &self.artifacts, - ) - .await?; - return Ok(apply_search_scope( - response, - &artifact.payload.snapshot, - artifact.capabilities.collection_scope, - scope, - )); - } - // The bridge is asked first, because it is the path that spends no - // allowance and needs no login at all. Demanding the token before - // looking at it inverted that: someone who had attached the plugin - // precisely to stay off the metered path was told to go and authorize - // the metered path first, and the collection never started — on a file - // every read of which the plugin was sitting there ready to serve. - // - // A login is required only when no plugin is holding *this* file open. - // A read inside the collection that the bridge cannot serve still - // refuses on its own, where the reason is specific to that read, - // rather than here where it would condemn the whole export. - if !self - .services - .upstream - .serves_without_credentials(&request.target.file_key) - .await - { - let auth_status = self.services.auth.status().await?; - if auth_status == AuthStatus::Disconnected { - return Err(DevupError::with_details( - ErrorCode::DevupAuthRequired, - "No Figma path is open for this file. Preferred: run the Devup Bridge plugin on \ - this file in the Figma desktop app — it needs no login and spends no Figma \ - allowance. Otherwise authorize the metered direct connection with \ - devup_figma_auth login.", - false, - json!({ - "source": "direct", - "bridgeServesFile": false, - "preferredPath": "bridge", - "fileKey": request.target.file_key, - }), - )); - } - } - - // Each tracked job owns its collector. Artifact single-flight would - // make another output-path job wait on a paused collector it cannot resume. - let acquisition = if job.is_some() { - let payload = CollectedPayload::try_from( - self.run_direct(request.clone(), &operation, job).await?, - )?; - // Only deduplicate publication once this collector has finished. - // A ready payload cannot strand another job behind a paused read, - // and the winning artifact's resource URIs remain valid. - self.artifacts - .get_or_acquire(artifact_key.clone(), refresh, || async { Ok(payload) }) - .await - } else { - self.artifacts - .get_or_acquire(artifact_key.clone(), refresh, || async { - CollectedPayload::try_from( - self.run_direct(request.clone(), &operation, None).await?, - ) - }) - .await - }; - match acquisition { - Ok(artifact) => { - if let Some(job) = job { - job.captured(&artifact.payload.assets); - } - let response = complete_operation( - operation, - &artifact.payload, - "direct", - &artifact, - &self.output_policy, - &self.artifacts, - ) - .await?; - Ok(apply_search_scope( - response, - &artifact.payload.snapshot, - artifact.capabilities.collection_scope, - scope, - )) - } - 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, - attempt_timeout: Option, - ) -> Result { - const ATTEMPTS: u32 = 3; - const LONGEST_WAIT: u64 = 90; - - // A call already banked costs no allowance, so it is answered ahead of - // the pacer rather than queued behind it. This is what lets a capture - // too large for one day's allowance finish across several: the reads - // an earlier attempt paid for are replayed, and only what is still - // missing is spent on. - if let Some(raw) = self.services.call_cache.get(&call) { - return Ok(UpstreamResult { raw }); - } - - // Only a read bound for the metered path is paced. One the Devup Bridge - // plugin answers spends none of Figma's allowance, and holding it to - // that pace made a second export on the same server sit out most of a - // minute for an allowance it was not using. - let metered = self.services.upstream.is_metered(&call).await; - let mut attempt = 1; - loop { - // Before the call, not after the refusal: a collection that paces - // itself under the ceiling rarely has to be waited out at all. - if metered { - self.services.pacer.acquire().await; - } - // Rate-limit pacing is deliberate waiting, not a stalled upstream - // read. Bound each actual attempt without cancelling the retry policy. - let read = self.services.upstream.call_read_tool(call.clone()); - let result = if let Some(timeout) = attempt_timeout { - tokio::time::timeout(timeout, read).await.unwrap_or_else(|_| Err(DevupError::with_details( - ErrorCode::DevupFigmaDirectUnavailable, - "Export job upstream read timed out; accepted reads are retained. Resume the job to retry the pending read.", true, - json!({"stage":"upstream-read","timeoutSeconds":timeout.as_secs()})))) - } else { - read.await - }; - let error = match result { - Ok(result) => { - // Only an answer is banked. A refusal arrives as a - // *successful* MCP call — `isError` in the body, or the - // sentence alone — and banking one meant every later - // attempt replayed it at once, from disk, without reaching - // Figma: twenty-eight refusals overnight and a fresh token - // refused, all one cached refusal. The classification - // that decides retries decides this too, so the two cannot - // disagree. - if operation::upstream_error(&result.raw).is_none() { - self.services.call_cache.put(&call, &result.raw); - } - return Ok(result); - } - Err(error) => error, - }; - if error.code != ErrorCode::DevupFigmaRateLimited { - return Err(error); - } - // The ceiling was reached at a rate the pacer thought was safe, so - // its picture is what is wrong. Correct it before anything else — - // including before giving up, because whatever runs next inherits - // the same window and would otherwise walk into the same refusal. - self.services.pacer.penalise(); - if 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, - operation: &PendingOperation, - job: Option<&asset_jobs::AssetJob>, - ) -> Result { - let budget = match operation { - PendingOperation::Export { - outputs, - all_screens: true, - .. - } => Some(outputs), - _ => None, - }; - if let (Some(outputs), Some(index)) = (budget, &request.cached_section_index) { - validation::validate_export_budget(&index.select(&[], true)?, outputs)?; - } - let target = request.target.clone(); - // 리소스를 몇 개씩 묶어 물을지는 이 수집을 실어 나를 전송이 정한다. - // 공식 MCP 는 응답을 자르므로 8개씩 끊지만, 자르지 않는 전송은 한 번에 - // 다 물어도 되고 그만큼 왕복이 사라진다. - let mut request = request; - request.batch_budget = self.services.upstream.batch_budget(); - let mut collector = CollectorSession::new(request); - loop { - if let Some(job) = job { - job.progress(&collector); - } - match collector.advance()? { - CollectorStep::Call(planned) => { - let call_id = planned.id.clone(); - let asset_read = planned.call.is_asset_read(); - let section_index_call = matches!( - &planned.call, - ReadToolCall::Snapshot { - script: devup_mcp_figma::BuiltinScript::SectionIndex, - .. - } - ); - let upstream_result = if let Some(job) = job { - job.call(self, planned.call).await - } else { - self.call_waiting_out_a_spent_allowance(planned.call, None) - .await - }; - match upstream_result { - // 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(mut error) => { - if !error.details.is_object() { - error.details = json!({"upstreamDetails":error.details}); - } - // A key only the bridge routes by is not the - // file's, so it is not reported as one. - error.details["fileKey"] = if target.is_bridge_only() { - Value::Null - } else { - json!(target.file_key) - }; - if error.details.get("nodeId").is_none() { - error.details["nodeId"] = json!(planned.expected_node_id); - } - error.details["stage"] = json!(if section_index_call { - "section-index" - } else { - "plugin-execution" - }); - if !collector.reject(&call_id, &error)? { - return Err(error); - } - } - None => { - if section_index_call && let Some(outputs) = budget { - let chunk = - devup_mcp_figma::snapshot_chunk_from_result(&result)?; - let snapshot = devup_mcp_figma::merge_chunks(vec![chunk])?; - let index = - devup_mcp_figma::build_section_index(&snapshot, &target)?; - validation::validate_export_budget( - &index.select(&[], true)?, - outputs, - )?; - } - if asset_read { - static ENCODERS: std::sync::OnceLock< - std::sync::Arc, - > = std::sync::OnceLock::new(); - let permit = ENCODERS - .get_or_init(|| { - std::sync::Arc::new(tokio::sync::Semaphore::new(2)) - }) - .clone() - .acquire_owned() - .await - .map_err(|_| { - DevupError::new( - ErrorCode::DevupInvalidInput, - "Asset postprocessing is unavailable.", - false, - ) - })?; - let (returned, accepted) = tokio::task::spawn_blocking(move || { - let _permit = permit; - let accepted = collector.accept(&call_id, result); - (collector, accepted) - }).await.map_err(|_| DevupError::new(ErrorCode::DevupInvalidInput, "Asset postprocessing worker failed; repeat the request.", false))?; - collector = returned; - accepted?; - } else { - collector.accept(&call_id, result)?; - } - } - }, - Err(error) if collector.reject(&call_id, &error)? => continue, - Err(error) => return Err(error), - } - } - CollectorStep::AwaitingResults => continue, - CollectorStep::Complete(parts) => return Ok(*parts), - } - } - } - - /// Both paths to Figma as they stand right now. - /// `devup_figma_explore` without the tool envelope, so `devup_screen_plan` - /// lists screens through the same read rather than a second one. - async fn explore(&self, input: FigmaExploreInput) -> Result { - if let Some(hints) = &input.screenshot_observations { - hints.validate()?; - } - let target = self - .bind_to_bridge( - parse_link(input.url.as_deref())?, - Unnamed::Selection, - &json!({ "tool": "devup_figma_explore", "arguments": { "limit": input.limit,"screenshotObservations":input.screenshot_observations } }), - ) - .await - ?; - target.node_id.as_ref().ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "Exploring neighboring Figma screens requires a node-id.", - false, - ) - })?; - if !(1..=MATCH_CEILING).contains(&input.limit) { - return Err(DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "The explore limit must be between 1 and 100 inclusive.", - false, - )); - } - let requested_target = target.clone(); - let mut request = CollectionRequest::new(target, CollectionScope::Node); - request.resource_scope = ResourceScope::None; - request.explore = Some(ExploreReadOptions { - // Fixed, not derived from `limit`. Derived, it made `limit` two - // things at once - how many screens come back, and how much of the - // page the collection script is allowed to walk - and the second - // one is why limit 33 could answer with fewer screens than limit - // 30. Asking for the script's own ceiling every time also means - // one anchor's projection can be reused for a neighbour's, since - // every explore of a file now reads the same amount. - projection_limit: EXPLORE_PROJECTION_LIMIT, - text_preview_limit: if input.include_text_preview - || input.screenshot_observations.is_some() - { - 160 - } else { - 0 - }, - }); - let result = self - .start_operation( - PendingOperation::Explore { - // The projection ranks up to the ceiling and `limit` cuts - // the ranked list afterwards, in `apply_explore_limit`, so - // that what was found and what was returned are both known - // and can be reported apart. - limit: MATCH_CEILING, - target: requested_target, - }, - request, - input.refresh, - ) - .await?; - let mut result = result; - screenshot_observations::rank(&mut result, input.screenshot_observations.as_ref()); - Ok(apply_explore_limit(result, input.limit)) - } - - /// The screens `devup_screen_plan draft` plans: the caller's list from an - /// earlier explore, or one explore of the linked Section or selection. - async fn plan_screens( - &self, - input: &screen_plan::ScreenPlanInput, - ) -> Result<(Vec, Value), DevupError> { - if !input.screens.is_empty() { - return Ok(( - input.screens.clone(), - json!({"from": "caller", "url": input.url, - "nodeId": input.url.as_deref().and_then(|url| FigmaTarget::parse(url).ok()).and_then(|target| target.node_id)}), - )); - } - let explored = self - .explore(FigmaExploreInput { - screenshot_observations: None, - url: input.url.clone(), - limit: MATCH_CEILING, - include_text_preview: false, - refresh: input.refresh, - }) - .await?; - let candidates = explored["candidates"] - .as_array() - .cloned() - .unwrap_or_default(); - let screens_only = candidates - .iter() - .any(|candidate| candidate["node"]["kind"] == "screen"); - let screens = candidates - .iter() - .map(|candidate| &candidate["node"]) - .filter(|node| !screens_only || node["kind"] == "screen") - .filter_map(screen_plan::screen_from_explore) - .collect(); - // Nodes beside the screens - a logo, a frame named og - are the - // favicon and share image candidates the site questions offer. - let assets = candidates - .iter() - .map(|candidate| &candidate["node"]) - .filter(|node| screens_only && node["kind"] != "screen") - .filter_map(|node| { - Some(json!({"nodeId": node["nodeId"].as_str()?, "name": node["name"].as_str()?})) - }) - .collect::>(); - Ok(( - screens, - json!({ - "assetCandidates": assets, - "from": "devup_figma_explore", - "url": input.url, - "nodeId": explored["anchor"]["nodeId"], - "anchorName": explored["anchor"]["name"], - "anchorBreadcrumb": explored["anchor"]["breadcrumb"], - "targetKind": explored["targetKind"], - "fileKey": explored["source"]["fileKey"], - "truncation": explored["truncation"], - }), - )) - } - - async fn connection_report(&self) -> Result { - let status = self.services.auth.status().await?; - let direct = self.services.auth.direct_path_snapshot().await?; - let bridge = self.services.upstream.bridge_path_snapshot().await; - Ok(diagnostics::connection_report( - status, - &direct, - bridge.as_ref(), - )) - } - - /// Binds a call made without url, or with `figma-bridge://current`, to the - /// one attached Devup Bridge plugin: its file, and the node selected in - /// Figma when the call names none. - /// - /// A call without url used to be refused outright as "url or artifactId - /// is required" while a plugin sat attached and ready, and the one agent - /// that met it invented `/design/bridge/bridge` to get past the field. - /// With no plugin, or several, there is no one file the call can mean, - /// and the refusal says what would make one - `retry` is the call to make - /// then. - async fn bind_to_bridge( - &self, - mut target: FigmaTarget, - unnamed: Unnamed, - retry: &Value, - ) -> Result { - if target.file_key != BRIDGE_CURRENT_KEY { - return Ok(target); - } - let bridge = self.services.upstream.bridge_path_snapshot().await; - let attached = bridge - .as_ref() - .map_or(&[][..], |bridge| bridge.attached_files.as_slice()); - let mut file = match attached { - [file] => file.clone(), - [] => { - let direct_available = self.services.auth.status().await? == AuthStatus::Connected; - return Err(DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - "No url was given and no Devup Bridge plugin is attached, so there is no open \ - file to read. Run the Devup Bridge plugin on the file in the Figma desktop \ - app (preferred: no login), or pass the frame's Figma link as url.", - false, - json!({ - "stage": "target-resolution", - "bridge": { - "listening": bridge - .as_ref() - .is_some_and(|bridge| bridge.role == BridgeRole::Host), - "role": bridge.as_ref().map(|bridge| match bridge.role { - BridgeRole::Host => "host", - BridgeRole::Relay => "relay", - BridgeRole::Connecting => "connecting", - BridgeRole::Unavailable => "unavailable", - }), - "issue": bridge - .as_ref() - .and_then(|bridge| bridge.issue.as_ref()) - .map(|issue| issue.code()), - "port": bridge.as_ref().and_then(|bridge| bridge.port), - "attachedFiles": [], - }, - "directAvailable": direct_available, - "nextAction": diagnostics::ways_to_open_a_path( - bridge.as_ref(), - direct_available, - retry, - ), - }), - )); - } - several => { - return Err(DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - "No url was given and several Devup Bridge plugins are attached, so which file \ - is meant is ambiguous.", - false, - json!({ - "stage": "target-resolution", - "nextAction": diagnostics::several_plugins(several, retry), - }), - )); - } - }; - target.file_key.clone_from(&file.target_key); - if target.node_id.is_some() || unnamed == Unnamed::File { - return Ok(target); - } - let last_delivered = file.context.clone(); - let refreshed = tokio::time::timeout( - std::time::Duration::from_secs(3), - self.services - .upstream - .refresh_bridge_context(&file.target_key), - ) - .await; - match refreshed { - Ok(Ok(context)) => file.context = context, - _ => { - let mut unnamed_file = file.clone(); - unnamed_file.context.selection = None; - let mut error = selection_refusal(&file, - "Current Bridge selection could not be refreshed. Last-known context is diagnostic only; no node was exported.".into(),name_the_node(&unnamed_file,retry)); - error.details["selectionFreshness"] = json!({"state":"refresh-failed","current":null,"lastKnown":last_delivered, - "nextStep":"Reload an up-to-date Bridge plugin or pass an explicit node link; the last-known node is never selected automatically."}); - return Err(error); - } - } - let context = &file.context; - match (context.single_selection(), unnamed) { - (Some(node), Unnamed::SelectedSection) - if node - .node_type - .as_deref() - .is_some_and(|node_type| node_type != "SECTION") => - { - let mut call = retry.clone(); - if let Some(first) = retry["arguments"]["frameIds"].get(0) { - call["arguments"]["frameIds"] = json!([first]); - } - call["how"] = json!( - "Select the Section in Figma and repeat the call, or export one frame per call: a single frameId and no url, once for each frame." - ); - Err(selection_refusal( - &file, - format!( - "frameIds and allScreens without url choose screens inside the Section \ - selected in Figma, and the selection is a {}.", - node.node_type.as_deref().unwrap_or("node") - ), - call, - )) - } - (Some(node), _) => { - target.node_id = Some(node.id.clone()); - Ok(target) - } - (None, Unnamed::SelectionOrFile) - if context.selection.as_ref().is_some_and(Vec::is_empty) => - { - Ok(target) - } - (None, _) => { - let mut error = selection_refusal( - &file, - match &context.selection { - None => { - "No url or node was given, and the attached Devup Bridge plugin does \ - not report the Figma selection - it predates this devup-mcp. Re-run \ - Devup Bridge from this devup-mcp's plugin/manifest.json, or name the \ - node." - .to_owned() - } - Some(selection) if selection.is_empty() => { - "No url or node was given and nothing is selected in Figma. Select the \ - node in Figma, or name it." - .to_owned() - } - Some(selection) => format!( - "No url or node was given and {} nodes are selected in Figma. Select \ - exactly one, or name the one you mean.", - context.selection_count.unwrap_or(selection.len()) - ), - }, - name_the_node(&file, retry), - ); - error.details["selectionFreshness"] = - json!({"state":"current","current":file.context,"lastKnown":last_delivered}); - if context.selection.as_ref().is_some_and(Vec::is_empty) - && let Some(page) = &context.current_page - { - let observations = retry["arguments"] - .get("screenshotObservations") - .filter(|v| !v.is_null()) - .and_then(|v| { - serde_json::from_value::< - screenshot_observations::ScreenshotObservations, - >(v.clone()) - .ok() - }); - let anchor = FigmaTarget { - node_id: Some(page.id.clone()), - ..target.clone() - }; - error.details["exploreSuggestion"] = json!({"tool":"devup_figma_explore","arguments":{"url":anchor.link(Some(&page.id)),"limit":10}, - "basis":"current-page-context","screenshotMatch":"unverified","evidenceLimit":"No screenshot was inspected. Candidates use actual Figma page structure and geometry only."}); - if let Some(hints) = &observations { - error.details["exploreSuggestion"]["arguments"]["screenshotObservations"] = - json!(hints); - error.details["exploreSuggestion"]["basis"] = - json!("caller-observations-and-current-page-evidence"); - } - let call = ReadToolCall::explore_snapshot( - &target.file_key, - &page.id, - ExploreReadOptions { - projection_limit: EXPLORE_PROJECTION_LIMIT, - text_preview_limit: if observations.is_some() { 160 } else { 0 }, - }, - ); - if let Ok(Ok(read)) = tokio::time::timeout( - std::time::Duration::from_secs(3), - self.services.upstream.call_read_tool(call), - ) - .await - && let Ok(chunk) = devup_mcp_figma::snapshot_chunk_from_result(&read) - && let Ok(snapshot) = devup_mcp_figma::merge_chunks(vec![chunk]) - && let Ok(candidates) = devup_mcp_figma::explore_snapshot( - &snapshot, - &anchor, - &devup_mcp_figma::ExploreOptions { - limit: MATCH_CEILING, - }, - ) - { - error.details["exploreSuggestion"]["candidates"] = - json!(candidates.candidates); - error.details["exploreSuggestion"]["projectionTruncated"] = - json!(candidates.truncated); - screenshot_observations::rank( - &mut error.details["exploreSuggestion"], - observations.as_ref(), - ); - let found = error.details["exploreSuggestion"]["candidates"] - .as_array() - .map_or(0, Vec::len); - error.details["exploreSuggestion"]["foundCandidates"] = json!(found); - error.details["exploreSuggestion"]["candidatesTruncated"] = - json!(found > 10); - if let Some(candidates) = - error.details["exploreSuggestion"]["candidates"].as_array_mut() - { - candidates.truncate(10); - } - } - } - Err(error) - } - } - } -} - -/// The call that names the node a selection could not: the first selected -/// one when anything is selected, a placeholder to replace otherwise. -fn name_the_node(file: &devup_mcp_figma::AttachedFile, retry: &Value) -> Value { - let node = file - .context - .selection - .as_deref() - .and_then(<[_]>::first) - .map(|node| node.id.as_str()); - let mut call = retry.clone(); - call["arguments"]["url"] = - json!(FigmaTarget::bridge_current().link(Some(node.unwrap_or("")))); - if node.is_none() { - call["requiredArguments"] = json!(["url"]); - } - call["how"] = json!( - "Select one node in Figma and repeat the call, or name the node with figma-bridge://current?node-id= as url." - ); - call -} - -/// Which node a call bound to the bridge means when it names none. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Unnamed { - /// No node: the whole file, as a search reads it. - File, - /// The one node selected in Figma: an export's target, explore's anchor. - Selection, - /// The selected node when exactly one is selected, else the whole file: - /// a file-scope export that needs no node. - SelectionOrFile, - /// The selected Section, whose screens frameIds or allScreens choose. - SelectedSection, -} - -/// The refusal for a selection that does not name the node a call needs, -/// carrying the call that would. -fn selection_refusal( - file: &devup_mcp_figma::AttachedFile, - message: String, - call: Value, -) -> DevupError { - DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - message, - false, - json!({ - "stage": "target-resolution", - "file": { - "fileName": file.file_name, - "currentPage": file.context.current_page, - "selection": file.context.selection, - "selectionCount": file.context.selection_count, - "lastKnownSelection":file.context.last_known_selection, - "lastKnownPage":file.context.last_known_page, - "observedAt":file.context.observed_at, - }, - "nextAction": call, - }), - ) -} - -/// Every `devup_figma_*` tool response is a JSON object whose exact shape -/// varies per operation, `status`, and `delivery` mode (see README). Rather -/// than pin a schema per branch that would need constant re-syncing, this -/// declares only what the MCP spec requires of `outputSchema` — root type -/// `object` (SEP-2106) — with no constraint on properties. -/// -/// This replaces `schema_for_output::()`, whose root -/// schema had no `type` field at all: schemars maps `Value` to the boolean -/// JSON Schema `true`, and `into_root_schema_for` normalizes that to the -/// empty object `{}` before `outputSchema` strips `title`/`description`, -/// leaving a schema that satisfies "is an object" as a JSON value but not -/// the MCP-mandated `"type": "object"` marker. -fn permissive_object_output_schema() -> Arc { - let mut schema = JsonObject::new(); - schema.insert("type".to_owned(), json!("object")); - Arc::new(schema) -} - -/// What `devup_figma_search` was pointed at, and how much of it to answer -/// with. `node_id` is read from the URL rather than from a parameter of its -/// own: a caller who links a node has already said where to look, and a -/// search that ignores that says nothing about having ignored it. -struct SearchScope { - node_id: Option, - limit: usize, - /// The same file with no node linked - the call that widens the search. - file_url: String, -} - -/// Whether `node_id` is `ancestor_id` itself or sits beneath it, following the -/// parent chain the collection recorded. -/// -/// The search projection keeps each match's ancestors up to its page, so the -/// chain of anything that matched is present even when the ancestors did not -/// match themselves. A node with no recorded chain to `ancestor_id` is out of -/// scope, which is the answer wanted when the linked node holds nothing. -fn is_within(snapshot: &Snapshot, node_id: &str, ancestor_id: &str) -> bool { - if node_id == ancestor_id { - return true; - } - let parent_of = |id: &str| { - snapshot - .nodes - .get(id) - .and_then(|node| node.typed_view().string("parentId")) - }; - let mut seen = std::collections::BTreeSet::new(); - let mut current = parent_of(node_id); - while let Some(parent) = current { - if parent == ancestor_id { - return true; - } - if !seen.insert(parent.to_owned()) { - return false; - } - current = parent_of(parent); - } - false -} - -/// Cuts the ranked match list down to what the caller linked and says so. -/// -/// Report the stored artifact capability for both fresh and cached reads. -fn apply_search_scope( - mut response: Value, - snapshot: &Snapshot, - collection_scope: CollectionScope, - scope: Option<&SearchScope>, -) -> Value { - let Some(scope) = scope else { - return response; - }; - let Some(Value::Array(matches)) = response.get_mut("matches").map(Value::take) else { - return response; - }; - let scanned = matches.len(); - let node_id = match scope.node_id.as_deref() { - None => { - let mut kept = matches; - kept.truncate(scope.limit); - response["count"] = json!(kept.len()); - response["matches"] = Value::Array(kept); - response["scope"] = json!({ - "kind": "file", - "nodeId": Value::Null, - "collectionScope": collection_scope, - "scannedMatches": scanned, - "note": "The URL carried no node-id, so the whole file was searched. \ - Put the node you care about in the URL to search only inside it.", - }); - return response; - } - Some(node_id) => node_id, - }; - - let (mut inside, outside): (Vec, Vec) = matches.into_iter().partition(|entry| { - entry["nodeId"] - .as_str() - .is_some_and(|id| is_within(snapshot, id, node_id)) - }); - let found = inside.len(); - inside.truncate(scope.limit); - let mut scope_report = json!({ - "kind": "node", - "nodeId": node_id, - "collectionScope": collection_scope, - "scannedMatches": scanned, - "matchedInScope": found, - "returned": inside.len(), - "excludedOutOfScope": outside.len(), - "candidatesTruncated": found > inside.len(), - "note": "The URL carried a node-id, so only matches inside that node are \ - returned. Remove the node-id to search elsewhere in the file.", - }); - if inside.is_empty() { - scope_report["nextAction"] = json!({ - "reason": "Nothing inside the linked node matched. Search the whole file \ - with the node-id removed to look beyond this scope.", - "example": { - "url": scope.file_url, - "note": "The same call with the node-id removed from the URL.", - }, - }); - if !outside.is_empty() { - let mut elsewhere = outside; - elsewhere.truncate(scope.limit); - response["outOfScopeMatches"] = Value::Array(elsewhere); - } - } - response["count"] = json!(inside.len()); - response["matches"] = Value::Array(inside); - response["scope"] = scope_report; - response -} - -/// Cuts the ranked candidate list to `limit` and reports the two truncations -/// apart. -/// -/// `limit` is applied here rather than inside the projection so that it stays -/// a property of the answer alone: the projection is always asked for the same -/// ceiling, so no choice of `limit` can change how much of the design was read. -/// `truncated` arrives meaning "the snapshot itself was incomplete" and is -/// widened back to "something was cut" only after the two are recorded apart. -fn apply_explore_limit(mut response: Value, limit: usize) -> Value { - let Some(Value::Array(mut candidates)) = response.get_mut("candidates").map(Value::take) else { - return response; - }; - let projection_truncated = response["truncated"].as_bool().unwrap_or(false); - let found = candidates.len(); - candidates.truncate(limit); - let returned = candidates.len(); - let candidates_truncated = found > returned; - let at_ceiling = found >= MATCH_CEILING; - - let reason = match (projection_truncated, candidates_truncated) { - (false, false) => { - "Nothing was cut: every screen candidate found is in this answer.".to_owned() - } - (false, true) => format!( - "{found} screen candidates were found and {returned} returned, because limit is \ - {limit}. Nothing is missing from the design - raise limit to see the rest." - ), - (true, false) => "The Figma projection this was read from is itself incomplete - the \ - collection script reached its byte ceiling and left nodes out - so \ - screens may be missing that no limit would bring back. Explore a \ - narrower anchor. Text previews use a separate budget and do not \ - change candidate count, IDs, or projection truncation." - .to_owned(), - (true, true) => format!( - "Two cuts: limit {limit} returned {returned} of {found} candidates, and the Figma \ - projection was itself incomplete, so further screens may exist that no limit \ - would bring back. Raise limit to see collected candidates, or explore a narrower \ - anchor for missing screens. Text previews use a separate budget and do not \ - change candidate count, IDs, or projection truncation." - ), - }; - let mut truncation = json!({ - "projection": projection_truncated, - "candidates": candidates_truncated, - "returned": returned, - "found": found, - "atCeiling": at_ceiling, - "reason": reason, - }); - if candidates_truncated { - truncation["nextAction"] = json!({ - "limit": found.max(returned).min(MATCH_CEILING), - "note": if at_ceiling { - "This is the highest limit there is; a Section holding more screens than that \ - has to be exported in parts." - } else { - "The ranked order is stable, so a larger limit extends this list rather than \ - reshuffling it." - }, - }); - } - response["candidates"] = Value::Array(candidates); - response["count"] = json!(returned); - // There is no cursor to hand back: the projection is rebuilt per call and - // has no position to resume from. `truncation` says which lever moves it. - response["truncated"] = json!(projection_truncated || candidates_truncated); - response["truncation"] = truncation; - response -} - -#[tool_router] -impl DevupServer { - #[tool(description = "Read only width, height, padding, gap, layoutMode and alignment for 1..100 explicit Figma nodeIds, including instance descendants. Uses one fixed node-list script: no tree walk, resources, assets, screenshot or code generation. Dimensions are Figma logical units for comparison with DOM CSS logical dimensions. Optional calibration uses caller-declared screen pixels and known node dimensions; zoom and plugin UI devicePixelRatio are diagnostic and never prove capture scale.", output_schema = permissive_object_output_schema())] - async fn devup_figma_measure( - &self, - Parameters(input): Parameters, - ) -> Result { - measurement::run(self, input) - .await - .map(tool_result) - .map_err(to_mcp_error) - } - - #[tool( - description = "Report which agent skills the code devup-mcp emits depends on and whether this workspace has them, then install the ones devup-mcp carries (action: status | install). \ - The TSX devup_figma_export returns is devup-ui code, and an agent that has never seen devup-ui does not know its components are compile-time placeholders, that $token means devup.json, or that a style prop takes a responsive array - it guesses, and this server cannot see the guesses. \ - Call status before writing or editing that code. Anything reported missing is a gap you can close in one step. \ - install fetches current documents for embedded skills, falling back to the binary on failure, and writes own skills from the binary into the workspace skill root (.claude/skills, .opencode/skill or .agents/skills - an existing one is preferred), reporting source and any fallback reason (DEVUP_MCP_SKILLS_OFFLINE=1 disables fetching). Load them afterwards the way your runtime loads a project skill; an installed skill keeps applying to later sessions, which reading a document once does not. \ - External skills are reported, never written: devup-mcp hands over its publisher's install command and does not run it.", - output_schema = permissive_object_output_schema() - )] - async fn devup_skills( - &self, - Parameters(input): Parameters, - ) -> Result { - let lookup = skills::Lookup::new( - input.project_root.as_deref(), - self.output_policy.primary_root(), - self.client_name(), - ); - match input.action.as_str() { - "status" => Ok(tool_result(skills::report(&lookup))), - "install" => { - let outcome = skills::install(&self.output_policy, &lookup, &input.names) - .await - .map_err(to_mcp_error)?; - // The state after the write, from the same reader `status` - // uses. An install that reports what it meant to do rather than - // what is now on disk is the report that cannot be trusted. - let mut result = outcome; - result["state"] = skills::report(&lookup); - Ok(tool_result(result)) - } - other => Err(to_mcp_error(DevupError::new( - ErrorCode::DevupInvalidInput, - format!("action must be status or install, not {other}."), - false, - ))), - } - } - - #[tool( - description = "Report how devup-mcp reaches Figma, and manage the direct path's OAuth (action: status | login | logout | configure | doctor). \ - Two paths reach Figma: the Devup Bridge plugin running in the Figma desktop app is preferred and needs no login, and OAuth - the metered direct path - is the fallback. \ - Call status before asking anyone to log in: it reports both paths, the files attached plugins have open with their current page and selection, and the next call to make. `connected` is false, and `status` reads disconnected, only when neither path can serve. \ - With exactly one plugin attached, devup_figma_export, devup_figma_search and devup_figma_explore take no url. \ - login is needed only when no plugin is attached; with one attached it serves every read, and login only lets what the plugin answers short be read again on the metered direct path (data Figma withholds while the file is out of sight, a referencePng it cannot draw as get_screenshot does, a read an older plugin does not know). configure injects a pre-registered client credential to skip Dynamic Client Registration; doctor adds client setup reference data to status.", - output_schema = permissive_object_output_schema() - )] - async fn devup_figma_auth( - &self, - Parameters(input): Parameters, - ) -> Result { - match input.action.as_str() { - "status" => {} - "doctor" => { - let status = self.services.auth.status().await.map_err(to_mcp_error)?; - let direct = self - .services - .auth - .direct_path_snapshot() - .await - .map_err(to_mcp_error)?; - let bridge = self.services.upstream.bridge_path_snapshot().await; - return Ok(tool_result( - diagnostics::doctor_report(status, direct, bridge).await, - )); - } - "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" }))); - } - "login" => { - // Warned rather than refused: the bridge serves every read, - // but the direct path re-reads what the plugin answers short, - // and whoever asks may want that. But reaching for the metered - // path while the free one is attached is the mistake this - // answer exists to catch, so it says so. - let bridge_attached = self - .services - .upstream - .bridge_path_snapshot() - .await - .is_some_and(|bridge| bridge.available()); - self.services.auth.login().await.map_err(to_mcp_error)?; - let mut report = self.connection_report().await.map_err(to_mcp_error)?; - if bridge_attached { - report["warning"] = json!( - "A bridge is attached and serves every read; login only lets what the plugin answers short - data Figma withholds while the file is out of sight, a reference PNG it cannot draw as get_screenshot does, a read an older plugin does not know - be read again on the metered direct path." - ); - } - return Ok(tool_result(report)); - } - "logout" => { - self.services.auth.logout().await.map_err(to_mcp_error)?; - } - _ => { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupAuthRequired, - "action must be status, login, logout, configure, or doctor.", - false, - ))); - } - } - Ok(tool_result( - self.connection_report().await.map_err(to_mcp_error)?, - )) - } - - #[tool( - description = "Search Figma pages, sections, frames, and components by name to locate the target before devup_figma_export. \ - A node-id in the URL scopes the search to that node and everything under it; a URL without one searches the whole file. \ - url is optional while exactly one Devup Bridge plugin is attached: omitted, the whole file that plugin has open is searched, and figma-bridge://current?node-id= scopes it. \ - The answer says which of the two it did under `scope`, and `limit` is the number of matches returned.", - output_schema = permissive_object_output_schema() - )] - async fn devup_figma_search( - &self, - Parameters(input): Parameters, - ) -> Result { - // Refused here rather than after collecting, the way the explore tool - // already refuses one: the projection below asks for the ceiling when - // the search is scoped, and would otherwise swallow the rejection that - // an out-of-range limit is owed. - if !(1..=MATCH_CEILING).contains(&input.limit) { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaResponseTooLarge, - "The search limit must be between 1 and 100 inclusive.", - false, - ))); - } - let target = self - .bind_to_bridge( - parse_link(input.url.as_deref()).map_err(to_mcp_error)?, - Unnamed::File, - &json!({ "tool": "devup_figma_search", "arguments": { "query": input.query } }), - ) - .await - .map_err(to_mcp_error)?; - let scope = SearchScope { - node_id: target.node_id.clone(), - limit: input.limit, - file_url: target.link(None), - }; - // Scope the upstream read itself to the linked subtree. Keep the ranked - // projection above the caller's limit so ancestry filtering happens - // before the response is cut. - let collection_scope = if scope.node_id.is_some() { - CollectionScope::Node - } else { - CollectionScope::File - }; - let projected_limit = if scope.node_id.is_some() { - MATCH_CEILING - } else { - input.limit - }; - let mut request = CollectionRequest::new(target, collection_scope); - request.search = Some(SearchReadOptions { - query: input.query.clone(), - node_types: input.node_types.clone(), - match_kind: input.match_kind.clone(), - limit: projected_limit, - }); - let result = self - .start_operation_scoped( - PendingOperation::Search { - query: input.query, - node_types: input.node_types, - match_kind: input.match_kind, - limit: projected_limit, - }, - request, - false, - Some(&scope), - ) - .await - .map_err(to_mcp_error)?; - Ok(tool_result(result)) - } - - #[tool( - description = "Explore screen candidates spatially related to a linked Figma node to locate the right screen before devup_figma_export. Optional screenshotObservations (bounded labels/aspectRatio declared by the caller) rank real candidates using captured text and geometry; no screenshot pixels or visual matching are verified, and observations never auto-export a target. \ - `limit` is the number of candidates returned and nothing else - it never changes how much of the design is read, so raising it can only lengthen the answer. \ - `truncation` reports the two cuts apart: `candidates` means limit cut the list and raising it returns the rest, `projection` means the collected snapshot was itself incomplete and no limit will bring those screens back. \ - `includeTextPreview` uses a separate budget; turning it on or off does not change candidate count, IDs, or `truncation.projection`. \ - url is optional while exactly one Devup Bridge plugin is attached: omitted, the anchor is the node selected in Figma in the file that plugin has open.", - output_schema = permissive_object_output_schema() - )] - async fn devup_figma_explore( - &self, - Parameters(input): Parameters, - ) -> Result { - self.explore(input) - .await - .map(tool_result) - .map_err(to_mcp_error) - } - - #[tool( - description = "Export a small Figma selection. outputs:[outline] returns a bounded captured tree with logical layout metrics and responseOffset/responseLimit pagination. responseFields or path use a validated field/index/exact-name-or-id grammar (e.g. nodes[name=\"Cards\"].layout), retaining node identity and linking full omitted evidence as resources. For only explicit node dimensions, prefer devup_figma_measure, which bypasses snapshot collection and code generation; the Figma-to-code entry point. url is optional while exactly one Devup Bridge plugin is attached (devup_figma_auth status shows what is attached): omitted, or given as figma-bridge://current, the export reads the file that plugin has open and the node selected in Figma; frameIds without url name frames in it - one id is that frame, several are screens of the selected Section. Never invent a Figma file key to fill url. Asset requests: recommend 1–3 per call, maximum 6; split larger assetRequests before calling. All fresh exports return exportJob (assetJob compatibility alias) within a one-second initial wait when collection is still running, with per-call frame/root IDs, pagination and elapsed time. Slow asset calls also retain per-asset progress. Poll with jobId; use jobAction=resume when paused. Polls return compact asset rows (status, bytes, sha256, fileState); responseFields on a poll selects from its reply, e.g. exportJob.assets for full rows; assetJob only references exportJob. Generated code uses maskImage only for single-colour icons; multi-colour icons stay . Never convert an icon to a mask or recolour it by hand. Jobs retain accepted reads/bytes across client timeouts for 30 minutes in this server process, not across restart. Identical arguments recover a lost job reply. Completed results are retained for 5 minutes. Recommend 1–3 frames per call; allow at most 6 frames and 12 frame-times-output units. Budget roughly 5–20 seconds per frame-output unit (15–60 seconds per frame for three outputs) as a planning heuristic, not a guarantee: paging, complexity and throttling can exceed it and clients commonly time out at 300 seconds. Oversized selections are refused before screen collection; split frameIds into one-frame calls when isolating latency, or poll jobId. Partial per-frame projection failures retain successful frame outputs. projectionIssues always explains reported approximations, unclassified layout loss and missing generated-property provenance, even without includeDiagnostics. mappingComplete=false identifies mapping gaps; mapping-incomplete is not value loss and cannot be exact. projectionEvidence includes source fields and calculations for generated attributes. outputPathResults lists supported keys and diagnostics; frame file keys are frame::, while outputPaths reports actual committed writes. Resource responses offer nextAction.tool/arguments for same-artifact body comparison and sizeEstimate with explicit unmeasured wire overhead; coupled asset/path arguments remain together. quality.assets grades binary collection; assetSummary.description explains collection state. Merge saved batch responses offline with devup-mcp --merge-asset-batches batch1.json batch2.json for cumulative collection, unrequested, failed and conflict counts. SECTION links use two stages: receive selection_required, then run nextAction.example to export a selected screen. Asked to build pages or a site from Figma, follow the devup-implementation-plan workflow (/devup) even if not asked: it plans the URLs, updates dependencies to the latest, and adds interaction states, transitions and metadata; App Router apps may run on vinext with no next dependency. Building pages: with projectRoot, a page write (pageScaffold write, outputPaths under app/, or several frames with outputPaths) needs a confirmed devup_screen_plan and otherwise answers plan_required; pageScaffold then takes its route from the plan. Ask the plan's questions up front only; after confirm never ask the user, record assumptions instead. Excluded and design-fix frames are refused (skill devup-implementation-plan). \ - The `tsx` this returns is devup-ui code, not plain React: its components are compile-time placeholders, `$token` names an entry in the project's devup.json, and a style prop takes a responsive array. Call devup_skills before you write or edit it - it reports which of those conventions this workspace is missing and installs them where your runtime loads skills from. An agent that skips this does not know it is guessing, and this server cannot see the guesses; the gap is repeated as `skillGap` on the response that carries the code. \ - This output is the design, already read. Do not rewrite it from a screenshot or from `get_design_context` - pictures and visual reasoning verify, they do not author. Do not read the node tree and write devup-ui by hand, and do not infer layout from coordinates. Never guess a colour, spacing, radius or typography value: if a value did not come back, say so and stop, because an invented one is indistinguishable from a real one in the code and is the single failure this server exists to prevent. A failed call is a fact to report, not something to route around. \ - Ask only for what you will read: `tsx` is the deliverable, and the response always carries `status`, `quality`, `cache.artifactId`, `collection` and `source` beside it. \ - `outputs` defaults to `[\"tsx\"]`. Add `devupJson` only when the project has no `devup.json` yet, or when you are introducing tokens it does not define - if it already has one, that file is what the code must match, and `devup_project_context` is what reads it. \ - If you already know the node ids you want - a brief named them, or an earlier call did - pass them straight to `frameIds` and skip `devup_figma_explore`; exploring to rediscover ids you are already holding spends a Figma call for nothing. Explore is for when a Section link is all you have. \ - Each output adds its own keys and nothing else - tsx adds `tsx`; componentTsx adds `componentTsx`; responsiveTsx adds `responsiveTsx`, `responsiveSlots` and, where a width asked for something one tree cannot say, `responsiveUnrepresented`; devupJson adds `devupJson`, `themeCounts`, `themeCompleteness`, `conflicts` and `unresolvedVariables`; sourceMap and assetManifest each add the key they name; referencePng adds `referencePng` and, when the Devup Bridge plugin's picture may hold layers get_screenshot leaves out, `referencePngNote` naming them. \ - `rawSnapshot` and `rawPayload` are the collected design in raw form and need `debug: true`. Use them for one question only - a screen looks wrong and you must decide whether the generator is at fault or the design says so - never to implement, since the tsx already carries what they carry. \ - `fidelity` and `completenessReport` appear only when the result is not exact or complete, or when includeDiagnostics is set. \ - tsx expands every instance into primitives while componentTsx keeps them as references, so requesting both gives the same screen twice and the difference between them is each component's body. responsiveTsx merges every width the capture carries into one module whose differing values are devup-ui responsive arrays, and is produced whenever there is more than one width. \ - `assetManifest` lists the assets and their ids and writes nothing. To get files, call again with `assetRequests`, giving each entry an `outputPath` under an allowed write root - and make that call with the original `url` rather than `artifactId`, because an acquisition made without asset capture cannot serve asset requests. By default manifest `path` and TSX references remain placeholders. Opt in with `assetPublicRoot`, an existing absolute local directory served at URL /, together with assetRequests.outputPath in this call (saved output mappings are not part of artifacts): files below that root map to percent-encoded relative URLs in manifest and all TSX outputs. Identical exported bytes share one canonical path/outputPath; inspect the returned paths. assetNamesPerNode remains true by default; matching names alone never trigger deduplication. \ - Reuse a previous acquisition with `artifactId` from `cache` to project further outputs without calling Figma again. Omitted frameIds/allScreens reuse the stored Section selection; explicit selection overrides it. Debug outputs can use resource delivery in auto mode: read the linked resources or follow nextAction to reproject inline. sourceMap is a node/field/generatedProperty/resolution map without offsets; source.generatedOutput identifies the annotated output. Even when reusing artifactId, sourceMap requires tsx, componentTsx or devupJson in outputs in the same call. Example: {\"artifactId\":\"\",\"outputs\":[\"tsx\",\"rawSnapshot\",\"sourceMap\"],\"debug\":true}", - output_schema = interactions::output_schema() - )] - async fn devup_figma_export( - &self, - Parameters(workflow): Parameters, - ) -> Result { - let mut input = workflow.input; - if input.preset.as_deref() == Some("web-ready-images") { - for output in ["assetManifest", "imageAudit"] { - if !input.outputs.iter().any(|o| o == output) { - input.outputs.push(output.into()); - } - } - } - // Before anything is validated or collected: a page write without a - // confirmed screen plan is answered with the plan to make, and a - // pageScaffold route comes from the plan when one covers the frame. - if workflow.job_id.is_none() - && let Some(project_root) = input.project_root.clone() - { - let mut frames = input.frame_ids.clone(); - if frames.is_empty() { - let node = match (&input.url, &input.artifact_id) { - (Some(url), _) => FigmaTarget::parse(url).ok().and_then(|t| t.node_id), - (None, Some(id)) => self - .artifacts - .get(id) - .await - .and_then(|artifact| artifact.payload.target.node_id.clone()), - _ => None, - }; - frames.extend(node); - } - let request = screen_plan::ExportRequest { - project_root: &project_root, - url: input.url.as_deref(), - frames, - all_screens: input.all_screens, - scaffold_write: input.page_scaffold.as_ref().is_some_and(|s| s.write), - scaffold_route: input.page_scaffold.as_ref().map(|s| s.route.as_str()), - output_paths: input - .output_paths - .iter() - .filter(|(key, _)| !key.ends_with("pageScaffold")) - .map(|(_, path)| path.as_str()) - .collect(), - }; - match screen_plan::export_gate(&request).map_err(to_mcp_error)? { - screen_plan::Gate::Open => {} - screen_plan::Gate::Blocked(response) => return Ok(tool_result(response)), - screen_plan::Gate::Route(route) => { - if let Some(scaffold) = input.page_scaffold.as_mut() { - scaffold.route = route; - } - } - } - } - projection::page_scaffold::validate(input.page_scaffold.as_ref(), &input.outputs) - .map_err(to_mcp_error)?; - validation::validate_asset_budget(&input.asset_requests).map_err(to_mcp_error)?; - validation::validate_asset_render_outputs(&input.asset_requests, &input.outputs) - .map_err(to_mcp_error)?; - if input.page_scaffold.is_some() { - validation::validate_asset_render_outputs( - &input.asset_requests, - &["pageScaffold".into()], - ) - .map_err(to_mcp_error)?; - } - if let Some(id) = workflow.job_id.as_deref() { - if input.url.is_some() - || input.artifact_id.is_some() - || !input.asset_requests.is_empty() - || !input.output_paths.is_empty() - || workflow.asset_public_root.is_some() - || input.page_scaffold.is_some() - || input.refresh - || !input.frame_ids.is_empty() - || input.all_screens - || input.width_policy.is_some() - || input.preset.is_some() - { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupInvalidInput, - "jobId status/resume cannot change the original request or output paths.", - false, - ))); - } - let job = self.asset_jobs.get(id).map_err(to_mcp_error)?; - match workflow.job_action.as_deref().unwrap_or("status") { - "status" => {} - "resume" => job.resume(), - "cancel" => job.cancel(), - _ => { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupInvalidInput, - "jobAction must be status, resume or cancel.", - false, - ))); - } - } - // A collection of any size answers `in_progress` first, so this - // poll — not the call that started it — is what hands the caller - // their TSX. Checking it only on the synchronous returns left - // every real export unchecked. - let project_root = input.project_root.clone(); - // responseFields on a poll selects from this reply; without them - // the reply carries compact per-asset rows. - let selection = &input.response_selection; - selection.validate().map_err(to_mcp_error)?; - let result = job - .wait_briefly(selection.active()) - .await - .map_err(to_mcp_error)?; - let result = with_project_checks( - result, - project_root.as_deref(), - &skills::Lookup::new( - project_root.as_deref(), - self.output_policy.primary_root(), - self.client_name(), - ), - ); - if selection.active() { - return compact::pick(&result, selection) - .map(tool_result) - .map_err(to_mcp_error); - } - return Ok(tool_result(result)); - } - if workflow.job_action.is_some() { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupInvalidInput, - "jobAction requires jobId.", - false, - ))); - } - let web_ready_images = match input.preset.as_deref() { - None => false, - Some("web-ready-images") => true, - Some(_) => { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupInvalidInput, - "preset must be web-ready-images.", - false, - ))); - } - }; - if web_ready_images && !input.asset_requests.is_empty() { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupInvalidInput, - "web-ready-images cannot override custom assetRequests. Export the preset and custom requests separately; no requested work was started.", - false, - ))); - } - if web_ready_images - && !input - .outputs - .iter() - .any(|o| o == "assetManifest" || o == "pageScaffold") - { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupInvalidInput, - "web-ready-images requires outputs including assetManifest or pageScaffold for reusable assets and completeness.", - false, - ))); - } - let asset_public_root = - validation::validate_public_root(workflow.asset_public_root.as_deref()) - .map_err(to_mcp_error)?; - projection::theme_export::validate( - &input.devup_json_mode, - &input.outputs, - input.project_root.as_deref(), - ) - .map_err(to_mcp_error)?; - if asset_public_root.is_some() - && !web_ready_images - && input.page_scaffold.is_none() - && !input - .asset_requests - .iter() - .any(|asset| asset.output_path.is_some()) - { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupInvalidInput, - "assetPublicRoot requires an assetRequests outputPath in this call; saved output mappings are not part of an artifact.", - false, - ))); - } - validation::validate_export_budget(&input.frame_ids, &input.outputs) - .map_err(to_mcp_error)?; - for path in input - .output_paths - .iter() - .filter(|(key, _)| !key.ends_with("pageScaffold")) - .map(|(_, path)| path) - .chain( - input - .asset_requests - .iter() - .filter_map(|asset| asset.output_path.as_ref()), - ) - { - let target = self.output_policy.resolve(path).map_err(to_mcp_error)?; - if let Some(root) = &asset_public_root - && input - .asset_requests - .iter() - .any(|asset| asset.output_path.as_ref() == Some(path)) - { - validation::public_asset_url(root, target.display_path()).map_err(to_mcp_error)?; - } - } - validate_outputs(&input.outputs, input.debug).map_err(to_mcp_error)?; - input.response_selection.validate().map_err(to_mcp_error)?; - if let Some(hints) = &input.screenshot_observations { - hints.validate().map_err(to_mcp_error)?; - } - design_drift::validate_request( - &input.outputs, - input.previous_design_fingerprints.as_deref(), - ) - .map_err(to_mcp_error)?; - if !input.asset_requests.is_empty() - && !input - .outputs - .iter() - .any(|output| matches!(output.as_str(), "assetManifest" | "pageScaffold")) - { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "Using assetRequests requires assetManifest in outputs.", - false, - ))); - } - let reference_png_requested = input.outputs.iter().any(|output| output == "referencePng"); - if reference_png_requested && (!input.frame_ids.is_empty() || input.all_screens) { - let target = if let Some(url) = &input.url { - FigmaTarget::parse(url).ok() - } else if let Some(id) = &input.artifact_id { - self.artifacts - .get(id) - .await - .map(|a| a.payload.target.clone()) - } else { - None - }; - let url = target - .as_ref() - .zip(input.frame_ids.first()) - .map(|(target, id)| target.link(Some(id))); - let mut arguments = json!(input); - for key in ["artifactId", "frameIds", "allScreens", "url"] { - arguments.as_object_mut().unwrap().remove(key); - } - arguments["scope"] = json!("node"); - if let Some(url) = &url { - arguments["url"] = json!(url); - } - return Err(to_mcp_error(DevupError::with_details( - ErrorCode::DevupSnapshotUnsupported, - "referencePng can only be collected for a single Figma link target.", - false, - json!({"recoveryState":if url.is_some() {"available"} else {"manual-fix-required"}, - "nextAction":{"tool":"devup_figma_export","arguments":arguments, - "requiredArguments":if url.is_some() {Vec::<&str>::new()} else {vec!["url"]}, - "omitArguments":["artifactId","frameIds","allScreens"], - "how":"Request each frame by its direct canonicalUrl with outputs including referencePng and scope=node. For multiple selected frames, repeat once per direct frame URL. If the cached file URL is unavailable, copy the frame link from Figma."}}), - ))); - } - let root_layout = parse_root_layout(&input.root_layout).map_err(to_mcp_error)?; - let width_policy = - validation::parse_width_policy(input.width_policy.as_ref()).map_err(to_mcp_error)?; - parse_scope(&input.scope).map_err(to_mcp_error)?; - let delivery = input - .delivery - .parse::() - .map_err(to_mcp_error)?; - - if let Some(artifact_id) = input.artifact_id.as_deref() { - if input.url.is_some() || input.refresh { - return Err(to_mcp_error(DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - "artifactId cannot be used together with url or refresh.", - false, - json!({"stage":"preflight","reason":"invalid-argument-combination", - "distinctFrom":"DEVUP_FIGMA_HANDOFF_EXPIRED means the artifactId itself is gone; this means the call mixed incompatible arguments.", - "nextAction":{"tool":"devup_figma_export", - "how":"Send either artifactId (reuse, no Figma call) or url (+ refresh:true to recollect), never both."}}), - ))); - } - let artifact = self - .artifacts - .get_for_export(artifact_id) - .await - .map_err(to_mcp_error)?; - if web_ready_images && !artifact.capabilities.web_ready_images { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "This artifact did not acquire web-ready-images. Repeat with the original url and preset to capture the complete image plan.", - false, - ))); - } - let (asset_selections, asset_output_paths) = - parse_asset_requests(&input.asset_requests).map_err(to_mcp_error)?; - if artifact.capabilities.kind == ArtifactKind::SectionIndex - && (!input.frame_ids.is_empty() || input.all_screens) - { - let index = section_index_from_payload(&artifact.payload).ok_or_else(|| { - to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "The Section index artifact payload is invalid.", - false, - )) - })?; - let collection_scope = - parse_collection_scope(&input.scope).map_err(to_mcp_error)?; - if collection_scope != CollectionScope::Node { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "The Section Frame collection scope must be node.", - false, - ))); - } - // A Section index already knows every candidate's name and - // width, which is all a breakpoint family is decided on. Ask - // here, where the answer costs nothing: the same refusal from - // projection arrives only after the whole collection has run. - if !input.frame_ids.is_empty() - && input.outputs.iter().any(|output| output == "responsiveTsx") - { - let selected = input - .frame_ids - .iter() - .filter_map(|id| { - index - .candidates - .iter() - .find(|candidate| candidate.node_id == *id) - }) - .map(|candidate| (candidate.name.as_str(), candidate.bounds.width)) - .collect::>(); - // Only when every selected frame was found in the index. - // A frame the index does not describe leaves the question - // open, and an open question is collected, not refused. - if selected.len() == input.frame_ids.len() - && !devup_mcp_devup_ui::codegen::responsive::breakpoint_family_is_possible( - &selected, - ) - { - let mut arguments = json!(input); - arguments["outputs"] = json!(["tsx"]); - arguments["frameIds"] = json!([input.frame_ids[0]]); - return Err(to_mcp_error(DevupError::with_details( - ErrorCode::DevupInvalidInput, - "responsiveTsx needs the selected frames to be one screen drawn at several widths, and these are not. Nothing was collected.", - false, - json!({ - "output": "responsiveTsx", - "selectedFrames": selected - .iter() - .map(|(name, width)| json!({"name": name, "width": width})) - .collect::>(), - "recoveryState": "available", - "nextAction": { - "tool": "devup_figma_export", - "arguments": arguments, - "why": "Each selected frame still converts on its own.", - "how": "Request tsx one frame at a time, repeating for each frameId you selected.", - }, - }), - ))); - } - } - let mut request = - CollectionRequest::new(artifact.payload.target.clone(), collection_scope); - request.resource_scope = ResourceScope::Used; - request.asset_selections = asset_selections.clone(); - request.web_ready_images = web_ready_images; - request.reference_png = reference_png_requested; - request.section = Some(SectionReadOptions { - frame_ids: input.frame_ids.clone(), - all_screens: input.all_screens, - }); - request.cached_section_index = Some(index); - let result = self - .start_operation( - PendingOperation::Export { - outputs: input.outputs, - component_name: input.component_name, - include_diagnostics: input.include_diagnostics, - root_layout, - asset_names_per_node: input.asset_names_per_node, - scope: input.scope, - theme_options: projection::theme_export::ThemeExportOptions { - mode: input.devup_json_mode, - project_root: input.project_root.clone(), - }, - strict: input.strict, - output_paths: input.output_paths, - page_scaffold: input.page_scaffold, - previous_design_fingerprints: input.previous_design_fingerprints, - frame_ids: input.frame_ids, - all_screens: input.all_screens, - asset_captures: asset_selections, - web_ready_images, - asset_output_paths, - asset_public_root: asset_public_root.clone(), - fields: input.fields.clone(), - response_selection: input.response_selection.clone(), - delivery, - width_policy: width_policy.clone(), - }, - request, - false, - ) - .await - .map_err(to_mcp_error)?; - return Ok(tool_result(with_project_checks( - result, - input.project_root.as_deref(), - &skills::Lookup::new( - input.project_root.as_deref(), - self.output_policy.primary_root(), - self.client_name(), - ), - ))); - } - validate_artifact_projection( - &artifact, - &input.outputs, - &input.scope, - &asset_selections, - ) - .map_err(to_mcp_error)?; - let result = complete_operation( - PendingOperation::Export { - outputs: input.outputs, - component_name: input.component_name, - include_diagnostics: input.include_diagnostics, - root_layout, - asset_names_per_node: input.asset_names_per_node, - scope: input.scope, - theme_options: projection::theme_export::ThemeExportOptions { - mode: input.devup_json_mode, - project_root: input.project_root.clone(), - }, - strict: input.strict, - output_paths: input.output_paths, - page_scaffold: input.page_scaffold, - previous_design_fingerprints: input.previous_design_fingerprints, - frame_ids: input.frame_ids, - all_screens: input.all_screens, - asset_captures: asset_selections, - web_ready_images, - asset_output_paths, - asset_public_root: asset_public_root.clone(), - fields: input.fields.clone(), - response_selection: input.response_selection.clone(), - delivery, - width_policy: width_policy.clone(), - }, - &artifact.payload, - "artifact", - &artifact, - &self.output_policy, - &self.artifacts, - ) - .await - .map_err(to_mcp_error)?; - return Ok(tool_result(with_project_checks( - result, - input.project_root.as_deref(), - &skills::Lookup::new( - input.project_root.as_deref(), - self.output_policy.primary_root(), - self.client_name(), - ), - ))); - } - - let collection_scope = parse_collection_scope(&input.scope).map_err(to_mcp_error)?; - let mut target = parse_link(input.url.as_deref()).map_err(to_mcp_error)?; - let mut frame_ids = input.frame_ids; - // With no link, frameIds naming one frame is that frame - exactly what - // its own link would say. Several are screens of the selected Section. - if target.file_key == BRIDGE_CURRENT_KEY - && target.node_id.is_none() - && frame_ids.len() == 1 - && !input.all_screens - { - target.node_id = frame_ids.pop(); - } - let wants_tsx = input.outputs.iter().any(|output| output == "tsx"); - let unnamed = if !frame_ids.is_empty() || input.all_screens { - Unnamed::SelectedSection - } else if collection_scope == CollectionScope::File && !wants_tsx { - Unnamed::SelectionOrFile - } else { - Unnamed::Selection - }; - let mut retry = - json!({ "tool": "devup_figma_export", "arguments": { "outputs": input.outputs } }); - if let Some(hints) = &input.screenshot_observations { - retry["arguments"]["screenshotObservations"] = json!(hints); - } - if !frame_ids.is_empty() { - retry["arguments"]["frameIds"] = json!(frame_ids); - } - if input.all_screens { - retry["arguments"]["allScreens"] = json!(true); - } - let target = self - .bind_to_bridge(target, unnamed, &retry) - .await - .map_err(to_mcp_error)?; - if wants_tsx && target.node_id.is_none() { - return Err(to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "A TSX export link requires a node-id.", - false, - ))); - } - let mut request = CollectionRequest::new(target, collection_scope); - let (asset_selections, asset_output_paths) = - parse_asset_requests(&input.asset_requests).map_err(to_mcp_error)?; - request.asset_selections = asset_selections.clone(); - request.web_ready_images = web_ready_images; - request.reference_png = reference_png_requested; - request.resource_scope = if collection_scope == CollectionScope::File { - ResourceScope::File - } else { - ResourceScope::Used - }; - request.variables_only = collection_scope == CollectionScope::File - && input.outputs.iter().all(|output| output == "devupJson") - && request.asset_selections.is_empty(); - if !frame_ids.is_empty() || input.all_screens { - request.section = Some(SectionReadOptions { - frame_ids: frame_ids.clone(), - all_screens: input.all_screens, - }); - } - let operation = PendingOperation::Export { - outputs: input.outputs, - component_name: input.component_name, - include_diagnostics: input.include_diagnostics, - root_layout, - asset_names_per_node: input.asset_names_per_node, - scope: input.scope, - theme_options: projection::theme_export::ThemeExportOptions { - mode: input.devup_json_mode, - project_root: input.project_root.clone(), - }, - strict: input.strict, - output_paths: input.output_paths, - page_scaffold: input.page_scaffold, - previous_design_fingerprints: input.previous_design_fingerprints, - frame_ids, - all_screens: input.all_screens, - asset_captures: asset_selections, - web_ready_images, - asset_output_paths, - asset_public_root: asset_public_root.clone(), - fields: input.fields.clone(), - response_selection: input.response_selection.clone(), - delivery, - width_policy: width_policy.clone(), - }; - // A frame link plus frameIds is a precise request; refusing it because - // the plugin wants the parent Section only makes the caller repeat - // the ancestor lookup the plugin already did. Retry once, there. - let retry_with = |error: &DevupError| section_retry(error, &operation, &request); - let result = match self - .start_operation(operation.clone(), request.clone(), input.refresh) - .await - { - Err(error) => match retry_with(&error) { - Some((operation, request, resolved)) => self - .start_operation(operation, request, input.refresh) - .await - .map(|mut value| { - if let Some(object) = value.as_object_mut() { - object.insert("autoResolvedSection".into(), resolved); - } - value - }), - None => Err(error), - }, - ok => ok, - } - .map_err(to_mcp_error)?; - Ok(tool_result(with_project_checks( - result, - input.project_root.as_deref(), - &skills::Lookup::new( - input.project_root.as_deref(), - self.output_policy.primary_root(), - self.client_name(), - ), - ))) - } - - #[tool( - description = "Read a project's real devup.json theme tokens, openapi.json endpoints/schemas, Vespertide models/*.json tables/columns, or UI component/props/import/route reuse evidence (scope: theme | api | db | ui | routes | all) — read-only, no session cache, never guesses. UI/routes are opt-in and excluded from all. Routes inventories Next App Router pages with package authority and scan limits; ui and routes add `frameworks`, which app runs on vinext (a vinext dependency or scripts, or a vite.config importing vinext - no next dependency needed) or next, across bun workspaces. Identify deployments by server.commit/buildId, not version alone; server.displayVersion is a readable version+buildId.", - 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(with_context_skill_gap( - result, - &input.scope, - &skills::Lookup::new( - input.project_root.as_deref(), - self.output_policy.primary_root(), - self.client_name(), - ), - ))) - } - - #[tool( - description = "Validate DevupUI TSX against a project's real devup.json: unknown $token references, hardcoded colors/lengths, unknown props on Box/Flex/Text/Center/Grid/Image, and non-static values inside css()/globalCss()/keyframes() calls. \ - `ok` is decided by severity, not by count: errors fail, and `strict: true` also fails warnings. Hardcoded values with exact matching tokens are warnings with token advice; unmatched values are info without token advice and never fail strict mode. `okReason` distinguishes clean, clean-unverified-tokens, info-only, warnings-only, warnings-and-info, strict-warnings, and error-violations; `clean-unverified-tokens` means no violation was found but no devup.json was resolved, so the $token references this code makes were never checked. Supplying neither tsx nor files is rejected rather than answered `clean`. Structural rules (file-placement, one-component-per-file, client-boundary, inline-style, react-query-states) run only in `files` bundle mode; framework-required default exports (page, layout, loading, error, not-found, template, and the metadata conventions) are exempt from file-placement. With projectRoot, a maskImage whose public SVG paints more than one colour warns `multicolor-mask` (use ). `themeNotes` summarizes each encountered empty token category once. \ - Site completeness: an interactive element (as=a/button, Link, button, onClick) with no _hover or _focusVisible is a warning (interactive-states), one with no focus style is a warning (focus-style), too much motion - over 400ms, scale above 1.05, infinite - is a warning (too-much-motion), an icon-only button without aria-label is a warning (icon-button-label); a button with no _active, a _hover with no transition, motion with no _motionReduce and a nav link with no aria-current are info (button-active, hover-transition, motion-reduce, nav-current); a page or layout with no metadata export is a warning (page-metadata, files mode), and with projectRoot an app with no icon or favicon is a warning (app-icon). Navigation rules also report unmapped action candidates/accessibility warnings, missing static App Router href errors when projectRoot inventory is complete, and literal active-state info. Dynamic hrefs/custom routing and incomplete bundles remain unverified. Optional renderedHtml reports captured anchor counts separately from source coverage; neither proves runtime behavior. `checkedTokens` counts references and `availableTokenCount` counts definitions; they are not a ratio.", - output_schema = permissive_object_output_schema() - )] - async fn devup_ui_validate( - &self, - Parameters(input): Parameters, - ) -> Result { - use devup_mcp_devup_ui::ui_validate::{bundle_theme, check_bundle, validate_bundle}; - let invalid = |message: String| ErrorData::invalid_params(message, None); - // Neither source supplied. This used to validate the empty string and - // answer `ok: true, okReason: "clean"` — which reads as "this code - // passed" about code that was never sent. There is no verdict to give - // here, so the call is refused instead of being given a passing one. - if input.files.as_ref().is_none_or(|files| files.is_empty()) && input.tsx.trim().is_empty() - { - return Err(invalid( - "Supply either tsx or files; neither was provided, so there is nothing to validate." - .into(), - )); - } - let theme_lookup = if let Some(files) = &input.files { - check_bundle(files).map_err(invalid)?; - if !input.tsx.is_empty() { - return Err(invalid("Supply either tsx or files, not both".into())); - } - let theme = bundle_theme(files).map_err(invalid)?; - let guardrail = theme.is_none().then(|| json!({ - "message": "No devup.json supplied in files; token checks skipped. Bundle validation never reads the filesystem." - })); - project_context::ThemeLookup { theme, guardrail } - } else { - project_context::theme_for_validation(input.project_root.as_deref()) - .map_err(to_mcp_error)? - }; - let mut report = if let Some(files) = &input.files { - validate_bundle(files, theme_lookup.theme.as_ref(), input.strict).map_err(invalid)? - } else { - devup_mcp_devup_ui::ui_validate::validate_devup_ui_tsx( - &input.tsx, - theme_lookup.theme.as_ref(), - input.strict, - ) - }; - // Site completeness, for code checked against a project: feedback on - // every interactive element (bundle mode checks it per file), and an - // icon for the app. - if let Some(root) = input.project_root.as_deref() { - if input.files.is_none() { - report.violations.extend( - devup_mcp_devup_ui::ui_validate::site::interaction_findings( - input.source_name.as_deref().unwrap_or("input.tsx"), - &input.tsx, - ), - ); - } - report - .violations - .extend(screen_plan::missing_app_icon(root)); - if input.strict - && report.violations.iter().any(|finding| { - finding.severity == devup_mcp_devup_ui::ui_validate::Severity::Warning - }) - { - report.ok = false; - } - } - if input.asset_policies.len() > 4096 { - return Err(invalid("assetPolicies exceeds 4096 entries.".into())); - } - let sources = input - .files - .as_ref() - .map(|files| { - files - .iter() - .map(|file| (file.path.as_str(), file.content.as_str())) - .collect::>() - }) - .unwrap_or_else(|| vec![("", input.tsx.as_str())]); - for &(name, tsx) in &sources { - for diagnostic in - devup_mcp_devup_ui::codegen::validate_asset_effects(tsx, &input.asset_policies) - { - let mut context = diagnostic - .details - .and_then(|v| v.as_object().cloned()) - .unwrap_or_default(); - if !name.is_empty() { - context.insert("sourceName".into(), json!(name)); - } - report.violations.push(devup_mcp_devup_ui::ui_validate::Violation {rule:"double-effect",severity:devup_mcp_devup_ui::ui_validate::Severity::Warning,byte_range:[0,0],context,message:diagnostic.message,suggestion:Some("Remove the repeated CSS effect, or acquire original-source and apply that effect in CSS.".into())}); - if input.strict { - report.ok = false; - } - } - } - // A mask flattens its SVG to one colour, which only the file shows. - let masks = mask_guard::violations(&sources, input.project_root.as_deref()); - if input.strict && !masks.is_empty() { - report.ok = false; - } - report.violations.extend(masks); - // These keys are assembled here rather than serialized from - // `UiValidation`, so anything the struct's own documentation - // explains reaches nobody unless it is answered here too. Three - // things in this response cannot be read off it: - // - // `ok: true` next to ten violations looks like a contradiction - // until you know severity decides it and count does not, so the - // severity split is reported and `okReason` names the case. - // - // `checkedTokens: 4` next to `availableTokenCount: 81` reads as - // "4 of 81 checked". It is not: one counts references the code - // makes, the other definitions the theme holds. `tokens` says - // which is which in the key names, where a reader cannot miss it. - // - // And with no theme the token check is skipped rather than passed, - // which a bare `checkedTokens` cannot distinguish. - use devup_mcp_devup_ui::ui_validate::Severity; - let count_severity = |severity| { - report - .violations - .iter() - .filter(|finding| finding.severity == severity) - .count() - }; - let errors = count_severity(Severity::Error); - let warnings = count_severity(Severity::Warning); - let infos = count_severity(Severity::Info); - // `clean` has to mean "checked, and nothing was wrong". With no theme - // resolved the unknown-token check never ran, so code that references - // tokens is unverified rather than clean. This is the monorepo case - // that answered `clean` for a `$bg` reference while no devup.json had - // been read at all. - let token_check_skipped = theme_lookup.theme.is_none() && report.checked_tokens > 0; - let ok_reason = match (errors, warnings, infos, input.strict) { - (1.., _, _, _) => "error-violations", - (_, 1.., _, true) => "strict-warnings", - (_, 1.., 1.., _) => "warnings-and-info", - (_, 1.., _, _) => "warnings-only", - (_, _, 1.., _) => "info-only", - _ if token_check_skipped => "clean-unverified-tokens", - _ => "clean", - }; - let mut result = json!({ - "ok": report.ok, - "okReason": ok_reason, - "strict": input.strict, - "violations": report.violations, - "violationCounts": { "error": errors, "warning": warnings, "info": infos }, - "themeNotes": report.theme_notes, - "checkedTokens": report.checked_tokens, - "availableTokenCount": report.available_token_count, - "tokens": { - "referencedByTsx": report.checked_tokens, - "definedByTheme": report.available_token_count, - "unknownTokenCheckRan": theme_lookup.theme.is_some(), - }, - "themeAvailable": theme_lookup.theme.is_some(), - "themeGuardrail": theme_lookup.guardrail, - }); - if let Some(name) = &input.source_name { - for finding in result["violations"].as_array_mut().unwrap() { - finding["sourceName"] = json!(name); - } - } - attach_layout_and_source_links(&mut result, &input); - navigation::attach_validation(&mut result, &input); - if !report.ok { - result.as_object_mut().unwrap().extend( - validation_guidance::guidance(&input, &report, theme_lookup.theme.as_ref()) - .as_object() - .unwrap() - .clone(), - ); - } - // A violation is a located, proven gap in devup-ui knowledge, which - // makes this the one moment where naming the skill is a measurement - // rather than a nudge. Raised only when the skill is actually absent: - // telling a caller who already has it to install it is the noise that - // teaches them to skip the field. - let lookup = skills::Lookup::new( - input.project_root.as_deref(), - self.output_policy.primary_root(), - self.client_name(), - ); - if !report.violations.is_empty() - && lookup.installed_paths("devup-ui").is_empty() - && let Some(skill) = skills::find_by_name("devup-ui") - { - result["skillGap"] = json!({ - "skill": "devup-ui", - "why": "This code broke devup-ui rules, and the devup-ui skill is not installed in \ - this workspace. Installing it puts the rules in front of you while you \ - write, instead of after this tool has already refused the result.", - "install": skill.install_action(&lookup), - }); - } - Ok(tool_result(result)) - } - - #[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)) - } - - #[tool( - description = "Compare actual PNG with one reference PNG path, cached artifactId referencePng, or {artifactId,assetId} captured native-node PNG asset. Asset comparisons validate retained reference bytes and report asset pixel scope without requiring a browser environment; raw-fill and unknown captures cannot be references. Paths must be allowlisted. Never renders or runs commands. Default threshold is 0.005. Page comparisons require a valid environment manifest for a conclusive verdict. Optional diff PNG uses auto|inline|resource delivery.", - output_schema = permissive_object_output_schema() - )] - async fn devup_visual_compare( - &self, - Parameters(input): Parameters, - ) -> Result { - let result = visual_compare::compare(input, &self.output_policy, &self.artifacts) - .await - .map_err(to_mcp_error)?; - Ok(tool_result(result)) - } - - #[tool( - description = "Read-only cross-layer feature slice and acceptance matrix from explicit anchors: routePath, figmaNodeId/artifactId, operationId or apiPath+method, componentPath, tableName. Refuses anchorless prose. Returns evidence-backed or UNVERIFIED hops, generated-source ownership, ranked UI reuse, literal design binding versus request/response fields, required-state coverage, and named truncation caps. Optional requirement/acceptanceCriteria are echoed without semantic interpretation. componentTsx is a caller-declared Figma export; artifactId uses the cached snapshot. Static parsing never proves runtime behavior.", - output_schema = permissive_object_output_schema() - )] - async fn devup_feature_trace( - &self, - Parameters(input): Parameters, - ) -> Result { - let result = feature_trace::run(input, &self.artifacts) - .await - .map_err(to_mcp_error)?; - Ok(tool_result(result)) - } - - #[tool( - description = "Plan, then verify, a browser run of generated screens at several viewport widths that the consumer project executes (action: plan | ingest). This server never launches a browser or runs a command. \ - Optional templatePath transactionally writes the fixed Playwright runner and package files alongside outputPath. Dimension checks bind explicit Figma artifactId/nodeId references to CSS logical DOM sizes at viewports (default 1440/1100/858/390); width tables disclose missing references and report tolerance exceedances only, never multiplying zoom or DPR. plan validates a declarative plan - baseUrl and allowedOrigins, viewports, screens by routePath or by steps that find elements by role and accessible name, testId or text, and geometry checks (no-horizontal-overflow, contained, aligned, spans, visible, hidden, and bounds - size and position against the Figma frame, exact up to 0.5px of rounding) plus page-health, no-clipped-text, interaction, capture and view-transition (follow a link and confirm a view transition ran) - gives it a content id, and writes it under an allowed root with outputPath. Unknown fields, shell strings, other origins and URLs carrying credentials are refused. \ - The consumer runs harness/runtime-audit against it in fresh isolated browser contexts. ingest reads the plan and the report.json it wrote, rejects a report not bound to that plan by id and file hash, recomputes every count from the items, verifies each screenshot's SHA-256, length and decoded width, and separates what it verified from what the executor only declared. A missing item is not-run, never a pass; a viewport with an environment error makes the verdict inconclusive. A visual original exists only at the widths a screen's Figma frame was drawn at.", - output_schema = permissive_object_output_schema() - )] - async fn devup_runtime_audit( - &self, - Parameters(input): Parameters, - ) -> Result { - let invalid = |message: &str| { - to_mcp_error(DevupError::new( - ErrorCode::DevupInvalidInput, - message, - false, - )) - }; - let result = match input.action.as_str() { - "plan" => { - let plan = input - .plan - .ok_or_else(|| invalid("action plan needs plan."))?; - runtime_audit::create_plan( - plan, - &self.output_policy, - input.output_path.as_deref(), - input.template_path.as_deref(), - &self.artifacts, - ) - .await - } - "ingest" => { - let (Some(plan_path), Some(report_path)) = (input.plan_path, input.report_path) - else { - return Err(invalid("action ingest needs planPath and reportPath.")); - }; - runtime_audit::ingest_with_artifacts( - &self.output_policy, - &plan_path, - &report_path, - &self.artifacts, - ) - .await - } - _ => return Err(invalid("action must be plan or ingest.")), - } - .map_err(to_mcp_error)?; - Ok(tool_result(result)) - } - #[tool( - description = "Plan which Figma screen becomes which URL before any page is written, confirm it up front, then report it URL by width (action: draft | confirm | status | assume | auditPlan | defer). draft reads the linked Section or frame (or the Bridge selection, or `screens` from an earlier devup_figma_explore) and proposes a route only from evidence: an existing page built from the frame, a nav link label bound to an existing href, a navigationTargets entry, or .devup/screen-plan.config.json (modalKeywords, routeAliases, widthGrouping). A route inferred from a layer name is routeEvidence name-match and is put to the user; with no evidence it is UNVERIFIED and asked. Only frames under the link are candidates, never the rest of the file; frames in trash, archive, draft, copy or hidden areas are excluded and listed (excludeKeywords/includeKeywords in the config). Frames sharing a name in one section are asked about per group; the same name in different sections, a screen split across sections, a frame outside any section and duplicates each get an explicit question with a \"the Figma structure is wrong\" option whose frames are reported, never built. It writes .devup/screen-plan.json and .md and returns `questionRounds`: questions already shaped for your question tool (id, header <= 30 chars, question, options[{label, description}] recommended first, multiple, custom answers allowed), at most maxQuestionsPerRound (default 4) per round. Ask everything now: a question tool with no cap (`question` in OpenCode) takes the flat `questions` list in one call; one with a cap (`AskUserQuestion` in Claude Code) takes the rounds in consecutive calls with no other work in between, skipping a question whose `when` was not met; never ask in prose and never implement before confirm. confirm takes every applicable answer keyed by question id and refuses the call, naming the missing ids. After confirm, never ask the user: take the recommended or most conservative option and record it with assume. status compares the confirmed plan with the routes on disk and an optional devup_runtime_audit plan/report: per URL, implemented yes/no and per-width pass, fail with reason, or not-run - a width without an audit item is never pass - plus every assumption. draft also lists the stack defaults - Next App Router on vinext, a new project from dev-five-git/service-template, and the dependency update `bunx @dependency-check-updates/cli -d -u --rm` before implementing - and asks once whether a next-CLI app migrates to vinext; with several workspace apps it picks the app by meaning (the public app unless the frames clearly say admin, per screen in a mixed file) and asks only when no app looks like the public site; then the site defaults it fills in without asking (an interaction recipe per element role, restrained; one shared transition; reduced motion; view transitions always on (React's ViewTransition; skipped only when the project config opts out), verified by a runtime-audit view-transition check; metadata, lang, alt text, not-found) and asks only what needs a file or a fact: favicon and share image (from existing files or Figma logo/og nodes, never generated), site name and URL when the project does not say, analytics when the project shows it, and scroll reveal; `extraQuestions` adds the agent's own questions to the same rounds so nothing is asked after confirm; .devup/screen-plan.config.json may pre-answer them (favicon, ogImage, siteName, siteUrl, motion). status adds site completeness: favicon, metadata per URL, share image, hover/focus coverage and reduced motion as filled, asked-and-answered or missing. auditPlan writes one devup_runtime_audit plan per confirmed URL from the export artifacts: page health, overflow and clipped text at every width; the page, each section and each asset against its Figma render (devup_visual_compare calls returned by status) and the size and position of each section, card and asset at the drawn widths, exactly (half a pixel of rounding, no tolerance); merged pages found per width through data-figma-node-ids; dynamic routes at concrete URLs from generateStaticParams, the site's links or the detail frame; hover and focus; a view-transition check placed last; a Vite plugin beside each app's vite.config that strips the ids from production builds unless DEVUP_AUDIT=1; missing referencePngs and renders are listed with the export call that makes them. status then classifies every failure (runtime-error, overflow, code-drift or converter-bug for geometry - compared with the export record each export writes to .devup/exports, a converter bug written as a repro to .devup/converter-bugs and never retried - layout-position, layout-size, asset-mismatch, section-visual, page-visual, interaction, view-transition) with its Figma node, code file and fix hint, folds visualResults in, marks a finding unresolved with its evidence after maxFixAttempts (config, default 3), and returns a workQueue per URL and the finalReport. defer records a decision met after confirm as pending with its recommended option so the work never waits; assume takes it at the end, and a destructive one stays unbuilt. Done = zero fails, or listed as unresolved with evidence. The devup-implementation-plan skill (devup_skills) carries the whole workflow; /devup starts it.", - output_schema = permissive_object_output_schema() - )] - async fn devup_screen_plan( - &self, - Parameters(input): Parameters, - ) -> Result { - let result = match input.action.as_str() { - "draft" => { - let (screens, source) = self.plan_screens(&input).await.map_err(to_mcp_error)?; - let mut assets = input.assets.clone(); - assets.extend( - source["assetCandidates"] - .as_array() - .into_iter() - .flatten() - .filter_map(|asset| serde_json::from_value(asset.clone()).ok()), - ); - screen_plan::draft_with(&input, &screens, &assets, source, &self.output_policy) - .await - } - "confirm" => screen_plan::confirm(&input, &self.output_policy), - "assume" => screen_plan::assume(&input, &self.output_policy), - "status" => screen_plan::status(&input, &self.output_policy, &self.artifacts).await, - "auditPlan" => { - screen_plan::audit_plan(&input, &self.output_policy, &self.artifacts).await - } - "defer" => screen_plan::defer(&input, &self.output_policy), - other => Err(DevupError::new( - ErrorCode::DevupInvalidInput, - format!( - "action must be draft, confirm, status, assume, auditPlan or defer, not {other}." - ), - false, - )), - } - .map_err(to_mcp_error)?; - Ok(tool_result(result)) - } -} - -/// Adds what `devup_ui_validate` checked beyond its rules, kept apart from -/// `violations` so `ok`, `okReason` and `strict` mean what they meant: the -/// static layout risks, the export node behind each violation when the caller -/// passed the export's `sourceMap`, and which part was static and which was -/// never rendered. -fn attach_layout_and_source_links(result: &mut Value, input: &UiValidateInput) { - let sources: Vec<(Option<&str>, &str)> = match &input.files { - Some(files) => files - .iter() - .filter(|file| { - [".tsx", ".jsx"] - .iter() - .any(|extension| file.path.ends_with(extension)) - }) - .map(|file| (Some(file.path.as_str()), file.content.as_str())) - .collect(), - None => vec![(None, input.tsx.as_str())], - }; - let mut risks = Vec::new(); - let mut unmeasured = 0; - let mut layout = Value::Null; - for (path, source) in sources { - layout = devup_mcp_devup_ui::ui_layout::layout_risks(source, input.reference_width); - unmeasured += layout["groupsWithoutReferenceWidth"].as_u64().unwrap_or(0); - for mut risk in layout["risks"].as_array().cloned().unwrap_or_default() { - if let Some(path) = path { - risk["path"] = json!(path); - } - risks.push(risk); - } - } - result["layoutRisks"] = json!(risks); - let mut rules = devup_mcp_devup_ui::ui_validate::rule_names() - .into_iter() - .collect::>(); - if input.project_root.is_some() { - rules.push(mask_guard::RULE); - } - if input.files.is_some() { - rules.extend([ - "file-placement", - "one-component-per-file", - "client-boundary", - "inline-style", - "react-query-states", - ]); - } - result["verificationScope"] = json!({ - "static": { - "rules": rules, - "unknownTokenCheckRan": result["tokens"]["unknownTokenCheckRan"], - "layout": layout["checked"], - "referenceWidth": input.reference_width, - "groupsWithoutReferenceWidth": unmeasured, - }, - "runtime": { - "state": "not-performed", - "unverified": layout["runtimeUnverified"], - "note": "Nothing was rendered. devup_runtime_audit plans a consumer-side browser run whose report this server can ingest.", - }, - }); - let Some(source_map) = &input.source_map else { - return; - }; - if input.files.is_some() { - result["sourceMapLink"] = json!({"state": "unavailable", - "reason": "A sourceMap describes the one TSX it was exported with, and files numbers the elements of each file from 0, so a violation cannot be tied to it. Pass the sourceMap with tsx."}); - return; - } - let entries = source_map - .entries - .iter() - .chain(&source_map.tsx) - .collect::>(); - if entries.is_empty() { - result["sourceMapLink"] = json!({"state": "unavailable", - "reason": "The sourceMap holds no entries: pass the sourceMap devup_figma_export returned with this tsx, whose entries are under tsx (a screen) or entries (a Section frame)."}); - return; - } - if !entries.iter().any(|entry| entry.element_index.is_some()) { - result["sourceMapLink"] = json!({"state": "unavailable", - "reason": "No sourceMap entry carries elementIndex; it was produced by a build before elementIndex existed. Export again to link violations."}); - return; - } - let mut linked = 0; - for violation in result["violations"].as_array_mut().into_iter().flatten() { - let (Some(element), Some(attribute)) = ( - violation["elementIndex"].as_u64(), - violation["attribute"].as_str().map(str::to_owned), - ) else { - continue; - }; - let matches = entries - .iter() - .filter(|entry| entry.element_index == usize::try_from(element).ok()) - .filter(|entry| { - entry - .generated_property - .as_deref() - .is_some_and(|generated| { - generated == attribute || generated.starts_with(&format!("{attribute}=")) - }) - }) - .take(3) - .map(|entry| { - json!({"nodeId": entry.node_id, "property": entry.property, - "generatedProperty": entry.generated_property, "resolution": entry.resolution}) - }) - .collect::>(); - if !matches.is_empty() { - linked += 1; - violation["source"] = json!(matches); - } - } - let unlinked = result["violations"].as_array().map_or(0, Vec::len) - linked; - result["sourceMapLink"] = json!({"state": "linked", "linkedViolations": linked, - "unlinkedViolations": unlinked, "joinedOn": "elementIndex + attribute name"}); - if unlinked > 0 { - result["sourceMapLink"]["unlinkedReason"] = json!( - "No sourceMap entry generated the attribute these sit on, or they sit on no attribute. The export names each attribute its sourceMap does not map in projectionIssues (DEVUP_CODEGEN_PROPERTY_UNMAPPED) or projectionEvidence, at the same elementIndex." - ); - } -} - -/// The target a tool's url names. No url is the file the attached Devup Bridge -/// plugin has open, which [`DevupServer::bind_to_bridge`] then resolves. -fn parse_link(url: Option<&str>) -> Result { - url.map_or_else(|| Ok(FigmaTarget::bridge_current()), FigmaTarget::parse) -} - -fn section_index_from_payload(payload: &CollectedPayload) -> Option { - serde_json::from_value(payload.metadata.get("sectionIndex")?.clone()).ok() -} - -/// The skills the code in this response is written in, named when this runtime -/// would not load them. -/// -/// On the export rather than only on `devup_ui_validate`, because the agent -/// that has never seen devup-ui does not call the validator either: the gap -/// was only ever shown to callers who had already written the code and then -/// chosen to check it. This is the response every implementing agent reads, -/// and it arrives before the code is written rather than after it is refused. -/// -/// The same sentence is in `devup_figma_export`'s own tool description, -/// because that is the only channel that reaches an agent which never sees the -/// server's `instructions` - a subagent handed tool schemas alone, or Codex, -/// where `instructions` becomes one namespace blurb rather than prompt text. -const CODE_SKILLS: [&str; 2] = ["devup-ui", "devfive-frontend"]; - -fn with_skill_gap(mut result: Value, lookup: &skills::Lookup) -> Value { - if let Some(gap) = skill_gap(CODE_SKILLS.into_iter(), lookup, CODE_GAP_WHY) { - result["skillGap"] = gap; - } - result -} - -const CODE_GAP_WHY: &str = "This response carries devup-ui code, and the conventions it is written in are not what \ - this runtime would load. Without them the code is written from guesses - invented \ - $tokens, an authored CSS file, components treated as runtime React - and this server can \ - neither see nor correct that."; - -/// The gap between the skills a response's subject needs and what this runtime -/// would actually load, or `None` when there is none. -/// -/// Missing and outdated are reported apart because they need different words -/// from the caller. One is a skill the agent has never seen. The other is one -/// it is reading right now, and being taught rules this build no longer emits - -/// which reads exactly like a correctly installed skill until someone compares -/// the bytes. -fn skill_gap<'a>( - names: impl Iterator, - lookup: &skills::Lookup, - why: &str, -) -> Option { - let mut missing = Vec::new(); - let mut outdated = Vec::new(); - for skill in names.filter_map(skills::find_by_name) { - let entry = json!({ - "skill": skill.record.name, - "whyYouNeedIt": skill.record.used_for, - "install": skill.install_action(lookup), - }); - match skill.installed_freshness(lookup) { - None => missing.push(entry), - Some(state) if state.should_refresh() => { - let mut entry = entry; - entry["revision"] = json!(state.as_str()); - outdated.push(entry); - } - Some(_) => {} - } - } - if missing.is_empty() && outdated.is_empty() { - return None; - } - // The same install writes /devup, the one word that starts the whole - // workflow; say so where the agent is already being told to install. - let command = skills::command_state(lookup); - Some(json!({ - "missing": missing, - "outdated": outdated, - "command": if command["installed"] == true { Value::Null } else { json!({ - "invocation": command["invocation"], - "supported": command["supported"], - "how": command["how"], - }) }, - "why": why, - "how": "Call devup_skills with action \"install\" and this same projectRoot, then load \ - the skills the way your runtime loads a project skill. Before writing the code, \ - not after. The same call rewrites an outdated copy devup-mcp wrote and leaves a \ - document it did not write alone.", - "runtime": lookup.runtime.as_str(), - "workspace": lookup.project.display().to_string(), - })) -} - -/// Names the conventions for the files this scope just read, when this runtime -/// would not load them. -/// -/// The export gap covers the code devup-mcp writes; this covers the files it -/// reads. `devup.json` is devup-ui's theme format, the `openapi.json` under -/// scope api is generated by vespera routes, and the `models/*.json` under -/// scope db are vespertide schemas - so an agent about to edit any of them -/// needs the same rules, and had no way to learn it needed them. -/// -/// Gated on the scope having found something. A project with no `openapi.json` -/// has no use for the vespera skill, and naming it there is the noise that -/// teaches a reader to skip the field. -fn with_context_skill_gap(mut result: Value, scope: &str, lookup: &skills::Lookup) -> Value { - let found = |key: &str| { - // `all` nests one object per scope; a single scope answers at the top - // level. Both say `found`. - result - .get(key) - .unwrap_or(&result) - .get("found") - .and_then(Value::as_bool) - .unwrap_or(false) - }; - let mut names: Vec<&str> = Vec::new(); - if matches!(scope, "theme" | "all") && found("theme") { - names.push("devup-ui"); - } - if matches!(scope, "api" | "all") && found("api") { - names.push("vespera"); - } - if matches!(scope, "db" | "all") && found("db") { - names.push("vespertide"); - } - if scope == "ui" && found("ui") { - names.extend(CODE_SKILLS); - } - if let Some(gap) = skill_gap( - names.into_iter(), - lookup, - "These are the conventions for the files this scope just read, and they are not what \ - this runtime would load. Without them an agent edits a devup.json, an openapi.json or \ - a Vespertide model by guessing at its shape.", - ) { - result["skillGap"] = gap; - } - result -} - -/// Says whether the TSX just generated fits the project it is going into. -/// -/// Tokens are named after the Figma variables and text styles the screen -/// uses. For a project with no `devup.json` that is the right answer, and it -/// is what the `devupJson` output is for. For a project that already has one, -/// the names are its own and the export had no way to know them: a Braillify -/// Studio login screen came back referencing `$background`, `$innerBg`, -/// `$kakaoLogo`, `$title`, `$textSub`, `$borderLight` and the typography -/// `mainSubText`, `titleSmMed`, `bodyMed`, and that project defines none of -/// them. `devup_ui_validate` refused the same code against the same root, so -/// the export was handing back TSX this server's own validator rejects, and -/// saying nothing about it. -/// -/// Reported rather than rewritten. Which token was meant is the caller's -/// decision — `$background` is one edit from `$bg` and also from any other -/// name a project happens to use — and silently substituting a colour is a -/// worse failure than naming the mismatch. -/// -/// Without a root the theme half is skipped and nothing is read from disk, but -/// the skill half still runs: see [`with_skill_gap`] for why it cannot be -/// gated on an argument the unaware caller is the one least likely to send. -fn with_project_checks( - mut result: Value, - project_root: Option<&str>, - lookup: &skills::Lookup, -) -> Value { - if result.get("interactionCoverage").is_some() - && let Some(root) = project_root - { - project_context::attach_export_context(&mut result, root); - } - review::attach(&mut result); - const OUTPUTS: [&str; 3] = ["tsx", "componentTsx", "responsiveTsx"]; - let mut generated: Vec<(String, String)> = OUTPUTS - .into_iter() - .filter_map(|key| { - result - .get(key) - .and_then(Value::as_str) - .map(|tsx| (key.to_owned(), tsx.to_owned())) - }) - .collect(); - // A Section export answers under `frames`, one entry per selected frame, - // and nothing at the top level. Reading only the top level checked the - // single-node `url` case and silently skipped every `frameIds` call — - // which is every Section workflow, and so every screen of the Braillify - // Studio handover this exists for. - if let Some(frames) = result.get("frames").and_then(Value::as_array) { - for frame in frames { - let node = frame - .get("nodeId") - .and_then(Value::as_str) - .unwrap_or("frame") - .to_owned(); - for key in OUTPUTS { - if let Some(tsx) = frame.get(key).and_then(Value::as_str) { - generated.push((format!("{node}:{key}"), tsx.to_owned())); - } - } - } - } - // Before the theme half, and before its `project_root` gate: a response - // that handed back code has to name the conventions that code is written - // in whether or not the caller told us where the project is. - let produced_code = !generated.is_empty() || result.get("deliverable").is_some(); - if produced_code { - result = with_skill_gap(result, lookup); - } - let Some(project_root) = project_root else { - return result; - }; - if generated.is_empty() { - // Resource delivery hands the code back as a link, so there is no - // source here to check. Saying so is the point: an absent verdict - // reads exactly like a clean one, and this export is neither. - if result.get("resources").is_some_and(|r| !r.is_null()) { - result["projectThemeValidation"] = json!({ - "state": "not-checked", - "reason": "The outputs were delivered as resources, so no source was present to check.", - "note": "Request delivery=inline to have the generated tokens checked against this project.", - }); - } - return result; - } - let lookup = match project_context::theme_for_validation(Some(project_root)) { - Ok(lookup) => lookup, - Err(error) => { - result["projectThemeValidation"] = json!({ - "state": "unavailable", - "reason": error.message, - "note": "The generated tokens could not be checked against this project.", - }); - return result; - } - }; - let mut outputs = serde_json::Map::new(); - for (key, tsx) in generated { - let report = devup_mcp_devup_ui::ui_validate::validate_devup_ui_tsx( - &tsx, - lookup.theme.as_ref(), - false, - ); - // Only the findings a caller has to act on. The hardcoded-value - // information is already in `fidelity`, and repeating twenty of them - // here would bury the handful that stop the code compiling into a - // theme. - let unknown = report - .violations - .iter() - .filter(|violation| violation.rule == "unknown-token") - .map(|violation| { - json!({ - "message": violation.message, - "suggestion": violation.suggestion, - }) - }) - .collect::>(); - outputs.insert( - key.to_owned(), - json!({ - "ok": report.ok, - "unknownTokenCount": unknown.len(), - "unknownTokens": unknown, - }), - ); - } - let any_unknown = outputs - .values() - .any(|output| output["unknownTokenCount"].as_u64().unwrap_or(0) > 0); - result["projectThemeValidation"] = json!({ - "state": if lookup.theme.is_some() { "checked" } else { "no-theme" }, - "themeAvailable": lookup.theme.is_some(), - "guardrail": lookup.guardrail, - "outputs": outputs, - "note": if any_unknown { - "The generated tokens are the Figma names. This project defines different ones; map each unknown token to the project's own before using this TSX, or add the missing tokens to devup.json." - } else { - "Every token the generated TSX references exists in this project's devup.json." - }, - }); - result -} - -fn section_candidate_as_explore(candidate: &SectionCandidate) -> ExploreCandidate { - ExploreCandidate { - node: ExploreNode { - node_id: candidate.node_id.clone(), - name: candidate.name.clone(), - node_type: candidate.node_type.clone(), - bounds: candidate.bounds, - child_count: candidate.direct_child_count, - text_preview: candidate.text_preview.clone(), - text_preview_state: candidate.text_preview_state.clone(), - parent_id: candidate.parent_id.clone(), - kind: ExploreKind::Screen, - visible: candidate.visible, - breadcrumb: candidate.breadcrumb.clone(), - page_child_index: None, - }, - canonical_url: candidate.canonical_url.clone(), - score: 900, - selection_reasons: candidate.selection_reasons.clone(), - } -} - -fn format_epoch_rfc3339(epoch_seconds: u64) -> String { - let days = (epoch_seconds / 86_400) as i64; - let seconds = epoch_seconds % 86_400; - let (year, month, day) = civil_from_days(days); - let hour = seconds / 3_600; - let minute = (seconds % 3_600) / 60; - let second = seconds % 60; - format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") -} - -fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { - let days = days_since_epoch + 719_468; - let era = if days >= 0 { days } else { days - 146_096 } / 146_097; - let day_of_era = days - era * 146_097; - let year_of_era = - (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; - let mut year = year_of_era + era * 400; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let month_prime = (5 * day_of_year + 2) / 153; - let day = day_of_year - (153 * month_prime + 2) / 5 + 1; - let month = month_prime + if month_prime < 10 { 3 } else { -9 }; - year += i64::from(month <= 2); - (year, month, day) -} - -fn parse_scope(scope: &str) -> Result { - match scope { - "node" => Ok(ThemeScope::Node), - "page" => Ok(ThemeScope::Page), - "file" => Ok(ThemeScope::File), - _ => Err(DevupError::new( - ErrorCode::DevupThemeConflict, - "scope must be node, page, or file.", - false, - )), - } -} - -fn with_error_identity(mut error: ErrorData) -> ErrorData { - let mut data = error.data.take().unwrap_or_else(|| json!({})); - if !data.is_object() { - data = json!({"details":data}); - } - data["server"] = delivery::server_identity(); - error.data = Some(data); - error -} - -/// The one automatic recovery for `DEVUP_SECTION_REQUIRED`: re-aim the same -/// request at the ancestor Section the plugin reported, keeping the requested -/// frame as the selection. Returns `None` when the plugin named no Section. -fn section_retry( - error: &DevupError, - operation: &PendingOperation, - request: &CollectionRequest, -) -> Option<(PendingOperation, CollectionRequest, Value)> { - if error.details["pluginCode"] != "DEVUP_SECTION_REQUIRED" { - return None; - } - let section_id = error.details["sectionId"].as_str()?; - let requested = request.target.node_id.clone()?; - let PendingOperation::Export { - frame_ids, - all_screens, - .. - } = operation - else { - return None; - }; - let mut operation = operation.clone(); - let mut request = request.clone(); - let selection = if frame_ids.is_empty() && !all_screens { - vec![requested.clone()] - } else { - frame_ids.clone() - }; - if let PendingOperation::Export { frame_ids, .. } = &mut operation { - frame_ids.clone_from(&selection); - } - request.target.node_id = Some(section_id.to_owned()); - request.section = Some(SectionReadOptions { - frame_ids: selection.clone(), - all_screens: *all_screens, - }); - Some(( - operation, - request, - json!({"requestedNodeId":requested,"sectionId":section_id,"frameIds":selection, - "note":"The url named a node inside a SECTION; the export was re-run against that SECTION with the node selected."}), - )) -} - -/// Maps a [`DevupError`] onto the JSON-RPC error the caller actually sees. -/// -/// The protocol code is not decoration here: the caller is usually an agent -/// choosing between "fix the arguments and call again" and "stop and report", -/// and every error used to arrive as INTERNAL_ERROR, which says the second -/// thing about both. A mistake in the call itself is INVALID_PARAMS, so that -/// decision can be made without parsing the message. `data` keeps carrying -/// the exact `code` and `retryable`, unchanged. -fn to_mcp_error(mut error: DevupError) -> ErrorData { - if error.code == ErrorCode::DevupFigmaHandoffExpired - && error.details.get("exportJob").is_none() - && error.details.get("assetJob").is_none() - { - if !error.details.is_object() { - error.details = json!({"upstreamDetails":error.details}); - } - if error.details.get("stage").is_none() { - error.details["stage"] = json!("artifact-lookup"); - } - if error.details.get("artifactState").is_none() { - error.details["artifactState"] = json!("unknown"); - } - let recovery = error.details.get("recoveryArguments").cloned(); - error.details["recoveryState"] = json!(if recovery.is_some() { - "available" - } else { - "unrecoverable" - }); - let another_process = error.details["artifactState"] == "issued-by-another-process"; - if recovery.is_none() { - error.details["recoveryReason"] = json!(if another_process { - "This artifactId was issued by a different devup-mcp process: the server restarted, or another session's server collected it. Artifacts are held only in the memory of the process that collected them and are never shared, so this process cannot serve or recover it." - } else { - "Original URL/selection cannot be recovered from this server. The ID may be unknown, evicted beyond metadata retention, or lost on restart; the cause cannot be determined." - }); - } - error.details["recovery"] = json!({ - "mode": "recollect", - "cause": match error.details["artifactState"].as_str() { - Some("expired") => "artifact-expired", - Some("evicted") => "artifact-evicted", - Some("issued-by-another-process") => "issued-by-another-process", - _ => "artifact-unknown", - }, - "reprojectable": false, - "requiresOriginalUrl": recovery.is_none(), - }); - error.details["nextAction"] = json!({"tool":"devup_figma_export", - "arguments":{"refresh":true},"requiredArguments":["url"],"omitArguments":["artifactId"], - "how":"Use the original Figma SECTION URL and frameIds (or allScreens) with refresh:true to collect a new artifact. Then reuse the new cache.artifactId; do not send the expired artifactId with url or refresh."}); - if let Some(arguments) = recovery { - error.details["nextAction"]["arguments"] = arguments; - } - } - if error.details["recoveryArguments"]["nodeIds"].is_array() { - error.details["nextAction"] = json!({"tool":"devup_figma_measure","arguments":error.details["recoveryArguments"], - "how":"Repeat the explicit measured node list; a measurement receipt never recovers by exporting the current selection."}); - } - let mcp_code = if validation::is_caller_mistake(&error) { - McpErrorCode::INVALID_PARAMS - } else { - McpErrorCode::INTERNAL_ERROR - }; - ErrorData::new( - mcp_code, - error.message, - Some( - json!({ "code": error.code, "retryable": error.retryable, "details": error.details, "server":delivery::server_identity() }), - ), - ) -} - -fn structured_tool_error(error: ErrorData) -> rmcp::model::CallToolResult { - let mut data = error.data.unwrap_or_else(|| json!({})); - if !data.is_object() { - data = json!({"details":data}); - } - data.as_object_mut().unwrap().remove("server"); - if data.get("code").is_none() { - data["code"] = json!("DEVUP_INVALID_INPUT"); - } - if data.get("retryable").is_none() { - data["retryable"] = json!(false); - } - if data.get("details").is_none() { - data["details"] = json!({}); - } - data["message"] = json!(error.message); - data["rpcCode"] = json!(error.code); - let mut result = tool_result(json!({"error":data})); - result.is_error = Some(true); - result -} - -#[tool_handler(router = self.tool_router)] -impl ServerHandler for DevupServer { - async fn call_tool( - &self, - request: rmcp::model::CallToolRequestParams, - context: RequestContext, - ) -> Result { - // Read here rather than by overriding `initialize`, because the peer - // already recorded it there and this is the first place the value is - // needed. `set` after the first call is a no-op, so one session keeps - // one answer. - if self.client_name.get().is_none() - && let Some(peer) = context.peer.peer_info() - { - let _ = self.client_name.set(peer.client_info.name.clone()); - } - let call = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); - let mut response = match self.tool_router.call(call).await { - Ok(response) => response, - Err(error) if error.data.as_ref().and_then(|d| d.get("code")).is_some() => { - return Ok(structured_tool_error(error).into()); - } - Err(error) => return Err(with_error_identity(error)), - }; - if let rmcp::model::CallToolResponse::Complete(result) = &mut response - && result.is_error == Some(true) - { - // Router argument decoding failures arrive as content-only tool - // errors. Give them the same error envelope as execution errors. - if result - .structured_content - .as_ref() - .and_then(|v| v.get("error")) - .is_none() - { - *result = structured_tool_error(ErrorData::invalid_params( - "Invalid tool arguments.", - Some(json!({"details":{"content":result.content}})), - )); - } - } - Ok(response) - } - - fn get_info(&self) -> ServerInfo { - // Initialize is the first moment a real session exists, which makes it - // the right place to start the release lookup: server construction is - // not, because `self_check()` constructs a server on a thread with no - // Tokio runtime and `--self-check` must stay network-free. `spawn` is - // idempotent and returns immediately when disabled or when there is no - // runtime, so this costs nothing here. - release_check::spawn(); - ServerInfo::new( - ServerCapabilities::builder() - .enable_resources() - .enable_tools() - .build(), - ) - .with_server_info(Implementation::new("devup-mcp", env!("CARGO_PKG_VERSION"))) - .with_instructions(guide::INSTRUCTIONS) - } - - async fn list_resources( - &self, - request: Option, - _context: RequestContext, - ) -> Result { - resources::list_output_resources( - &self.artifacts, - request - .as_ref() - .and_then(|request| request.cursor.as_deref()), - ) - .await - .map_err(to_mcp_error) - } - - async fn list_resource_templates( - &self, - _request: Option, - _context: RequestContext, - ) -> Result { - Ok(resources::resource_templates()) - } - - async fn read_resource( - &self, - request: rmcp::model::ReadResourceRequestParams, - _context: RequestContext, - ) -> Result { - resources::read_output_resource(&self.artifacts, &request.uri) - .await - .map(Into::into) - .map_err(|error| with_error_identity(resource_read_error(error))) - } -} - -/// A failed resource read, still `resource not found` on the wire, carrying -/// which part was missing and whether the artifact behind it is alive: a live -/// artifact can project the output again, a gone one has to be recollected. -fn resource_read_error(error: DevupError) -> ErrorData { - let details = &error.details; - let live = details["artifactState"] == "live"; - let (mode, cause) = match details["resourceKind"].as_str() { - Some("chunk") => ("reread-manifest", "chunk-out-of-range"), - Some("output") => ("reproject", "output-not-attached"), - None if error.code == ErrorCode::DevupFigmaHandoffInvalid => ("fix-uri", "invalid-uri"), - _ => ( - "recollect", - match details["artifactState"].as_str() { - Some("expired") => "artifact-expired", - Some("evicted") => "artifact-evicted", - Some("issued-by-another-process") => "issued-by-another-process", - _ => "artifact-unknown", - }, - ), - }; - let recovery = json!({ - "mode": mode, - "cause": cause, - "reprojectable": live, - "requiresOriginalUrl": !live && details.get("recoveryArguments").is_none(), - "recoveryArguments": details.get("recoveryArguments"), - }); - ErrorData::resource_not_found( - "resource not found", - Some(json!({ - "code": error.code, - "retryable": error.retryable, - "message": error.message, - "details": error.details, - "recovery": recovery, - })), - ) -} - -#[cfg(test)] -#[path = "export_skill_gap_tests.rs"] -mod export_skill_gap_tests; - -#[cfg(test)] -mod p3_error_tests { - use super::*; - - #[test] - fn p3_expired_artifact_is_state_failure_with_original_message() { - let error = to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaHandoffExpired, - "The Figma artifact is missing or expired.", - true, - )); - assert_eq!(error.code, McpErrorCode::INTERNAL_ERROR); - assert_eq!(error.message, "The Figma artifact is missing or expired."); - } - - #[test] - fn p3_output_root_violation_is_invalid_params() { - let policy = OutputPolicy::from_roots(vec![std::env::current_dir().unwrap()]).unwrap(); - let error = policy.resolve("../outside.svg").err().unwrap(); - assert_eq!(to_mcp_error(error).code, McpErrorCode::INVALID_PARAMS); - } -} - -#[cfg(test)] -mod r7_error_tests { - use super::*; - #[test] - fn r7_errors_have_the_same_build_identity_as_success() { - let success = serde_json::to_value(tool_result(json!({"status":"complete"}))).unwrap(); - let success: Value = - serde_json::from_str(success["content"][0]["text"].as_str().unwrap()).unwrap(); - for code in [ - ErrorCode::DevupInvalidInput, - ErrorCode::DevupSnapshotUnsupported, - ErrorCode::DevupFigmaHandoffExpired, - ] { - let error = to_mcp_error(DevupError::new(code, "failure", false)); - assert_eq!(error.data.as_ref().unwrap()["server"], success["server"]); - } - } - #[test] - fn r7_plugin_expiry_has_recovery_and_keeps_its_stage() { - let raw = json!({"isError":true,"content":[{"type":"text","text":"DEVUP_FIGMA_HANDOFF_EXPIRED"}]}); - let error = to_mcp_error(operation::upstream_error(&raw).unwrap()); - let data = error.data.unwrap(); - assert_eq!(data["code"], "DEVUP_FIGMA_HANDOFF_EXPIRED"); - assert_eq!(data["details"]["stage"], "plugin-execution"); - assert_eq!(data["details"]["nextAction"]["arguments"]["refresh"], true); - } - - #[test] - fn r7_expired_artifact_explains_refresh_arguments() { - let error = to_mcp_error(DevupError::new( - ErrorCode::DevupFigmaHandoffExpired, - "expired", - true, - )); - let d = &error.data.as_ref().unwrap()["details"]; - assert_eq!(d["stage"], "artifact-lookup"); - assert_eq!(d["nextAction"]["tool"], "devup_figma_export"); - assert_eq!(d["nextAction"]["arguments"]["refresh"], true); - assert!( - d["nextAction"]["omitArguments"] - .as_array() - .unwrap() - .contains(&json!("artifactId")) - ); - } -} - -#[cfg(test)] -mod r8_recovery_tests { - use super::*; - #[test] - fn r8_expired_artifact_returns_known_url_or_explicit_unrecoverable_state() { - for known in [true, false] { - let details = if known { - json!({"artifactState":"expired","recoveryArguments":{"url":"https://www.figma.com/design/test?node-id=1-2","frameIds":["2:1"],"refresh":true}}) - } else { - json!({}) - }; - let e = to_mcp_error(DevupError::with_details( - ErrorCode::DevupFigmaHandoffExpired, - "expired", - true, - details, - )); - let d = &e.data.unwrap()["details"]; - if known { - assert_eq!( - d["nextAction"]["arguments"]["url"], - "https://www.figma.com/design/test?node-id=1-2" - ); - } else { - assert_eq!(d["artifactState"], "unknown"); - assert_eq!(d["recoveryState"], "unrecoverable"); - } - } - } -} - -#[cfg(test)] -mod p2_recovery_tests { - use super::*; - - #[test] - fn an_id_from_another_process_says_so_and_cannot_be_reprojected() { - let error = to_mcp_error(DevupError::with_details( - ErrorCode::DevupFigmaHandoffExpired, - "The Figma artifact is missing or expired.", - true, - json!({"artifactState":"issued-by-another-process"}), - )); - let details = &error.data.unwrap()["details"]; - assert_eq!(details["recovery"]["cause"], "issued-by-another-process"); - assert_eq!(details["recovery"]["reprojectable"], false); - assert_eq!(details["recoveryState"], "unrecoverable"); - assert!( - details["recoveryReason"] - .as_str() - .unwrap() - .contains("different devup-mcp process") - ); - } - - #[test] - fn resource_misses_distinguish_reproject_reread_and_recollect() { - let read = |details: Value, code| { - let mut error = DevupError::new(code, "missing", false); - error.details = details; - resource_read_error(error).data.unwrap()["recovery"].clone() - }; - let output = read( - json!({"resourceKind":"output","artifactState":"live"}), - ErrorCode::DevupFigmaHandoffInvalid, - ); - assert_eq!(output["mode"], "reproject"); - assert_eq!(output["reprojectable"], true); - let chunk = read( - json!({"resourceKind":"chunk","artifactState":"live","chunkCount":2}), - ErrorCode::DevupFigmaHandoffInvalid, - ); - assert_eq!(chunk["mode"], "reread-manifest"); - let expired = read( - json!({"artifactState":"expired","recoveryArguments":{"url":"u","refresh":true}}), - ErrorCode::DevupFigmaHandoffExpired, - ); - assert_eq!(expired["mode"], "recollect"); - assert_eq!(expired["requiresOriginalUrl"], false); - let foreign = read( - json!({"resourceKind":"artifact","artifactState":"issued-by-another-process"}), - ErrorCode::DevupFigmaHandoffInvalid, - ); - assert_eq!(foreign["cause"], "issued-by-another-process"); - assert_eq!(foreign["requiresOriginalUrl"], true); - let invalid = read(json!(null), ErrorCode::DevupFigmaHandoffInvalid); - assert_eq!(invalid["mode"], "fix-uri"); - } -} - -#[cfg(test)] -mod section_retry_tests { - use super::*; - use devup_mcp_devup_ui::codegen::RootLayout; - - fn export(frame_ids: Vec) -> PendingOperation { - PendingOperation::Export { - outputs: vec!["tsx".into()], - previous_design_fingerprints: None, - component_name: None, - include_diagnostics: false, - root_layout: RootLayout::Standalone, - asset_names_per_node: true, - scope: "node".into(), - theme_options: Default::default(), - strict: false, - output_paths: Default::default(), - page_scaffold: None, - frame_ids, - all_screens: false, - asset_captures: vec![], - web_ready_images: false, - asset_output_paths: Default::default(), - asset_public_root: None, - fields: vec![], - response_selection: Default::default(), - delivery: DeliveryMode::Auto, - width_policy: None, - } - } - - fn target() -> FigmaTarget { - FigmaTarget { - file_key: "abc".into(), - node_id: Some("1:2".into()), - branch_key: None, - } - } - - #[test] - fn frame_link_is_retried_against_its_section_with_the_frame_selected() { - let error = DevupError::with_details( - ErrorCode::DevupSnapshotUnsupported, - "DEVUP_SECTION_REQUIRED", - false, - json!({"pluginCode":"DEVUP_SECTION_REQUIRED","sectionId":"9:9","nodeId":"1:2"}), - ); - let request = CollectionRequest::new(target(), CollectionScope::Node); - let (operation, request, note) = section_retry(&error, &export(vec![]), &request).unwrap(); - assert_eq!(request.target.node_id.as_deref(), Some("9:9")); - assert_eq!(request.section.unwrap().frame_ids, vec!["1:2".to_owned()]); - assert!( - matches!(operation, PendingOperation::Export { frame_ids, .. } if frame_ids == ["1:2"]) - ); - assert_eq!(note["sectionId"], "9:9"); - } - - #[test] - fn no_section_or_other_codes_are_not_retried() { - let request = CollectionRequest::new(target(), CollectionScope::Node); - for details in [ - json!({"pluginCode":"DEVUP_SECTION_REQUIRED","sectionId":null}), - json!({"pluginCode":"DEVUP_NODE_NOT_FOUND","sectionId":"9:9"}), - ] { - let error = - DevupError::with_details(ErrorCode::DevupSnapshotUnsupported, "x", false, details); - assert!(section_retry(&error, &export(vec![]), &request).is_none()); - } - } -} +// Source sections share this module to preserve privacy and test names. +include!("mod_parts/state_and_services.rs"); +include!("mod_parts/acquisition.rs"); +include!("mod_parts/exploration_and_responses.rs"); +include!("mod_parts/p2_recovery_tests.rs"); diff --git a/crates/devup-mcp/src/server/mod_parts/acquisition.rs b/crates/devup-mcp/src/server/mod_parts/acquisition.rs new file mode 100644 index 00000000..5601c2c8 --- /dev/null +++ b/crates/devup-mcp/src/server/mod_parts/acquisition.rs @@ -0,0 +1,957 @@ + + +impl DevupServer { + async fn start_operation( + &self, + operation: PendingOperation, + request: CollectionRequest, + refresh: bool, + ) -> Result { + if matches!(operation, PendingOperation::Export { .. }) { + return self.start_asset_job(operation, request, refresh).await; + } + self.start_operation_scoped(operation, request, refresh, None) + .await + } + + /// As [`Self::start_operation`], plus the one shaping step that cannot be + /// done by the tool handler on its own: narrowing search matches to the + /// node the caller linked. Deciding whether a match sits inside that node + /// needs the collected snapshot's parent chain, and this is the last place + /// that still holds the snapshot. + async fn start_operation_scoped( + &self, + operation: PendingOperation, + request: CollectionRequest, + refresh: bool, + scope: Option<&SearchScope>, + ) -> Result { + self.start_operation_scoped_tracked(operation, request, refresh, scope, None) + .await + } + + async fn start_operation_scoped_tracked( + &self, + operation: PendingOperation, + request: CollectionRequest, + refresh: bool, + scope: Option<&SearchScope>, + job: Option<&asset_jobs::AssetJob>, + ) -> Result { + let artifact_key = ArtifactRequestKey::from_collection(&request); + if !refresh && let Some(artifact) = self.artifacts.lookup(&artifact_key).await { + if let Some(job) = job { + job.captured(&artifact.payload.assets); + } + let response = complete_operation( + operation, + &artifact.payload, + "artifact", + &artifact, + &self.output_policy, + &self.artifacts, + ) + .await?; + return Ok(apply_search_scope( + response, + &artifact.payload.snapshot, + artifact.capabilities.collection_scope, + scope, + )); + } + if !refresh + && let Some(artifact) = self.artifacts.lookup_related_explore(&artifact_key).await + { + if let Some(job) = job { + job.captured(&artifact.payload.assets); + } + let response = complete_operation( + operation, + &artifact.payload, + "artifact", + &artifact, + &self.output_policy, + &self.artifacts, + ) + .await?; + return Ok(apply_search_scope( + response, + &artifact.payload.snapshot, + artifact.capabilities.collection_scope, + scope, + )); + } + // The bridge is asked first, because it is the path that spends no + // allowance and needs no login at all. Demanding the token before + // looking at it inverted that: someone who had attached the plugin + // precisely to stay off the metered path was told to go and authorize + // the metered path first, and the collection never started — on a file + // every read of which the plugin was sitting there ready to serve. + // + // A login is required only when no plugin is holding *this* file open. + // A read inside the collection that the bridge cannot serve still + // refuses on its own, where the reason is specific to that read, + // rather than here where it would condemn the whole export. + if !self + .services + .upstream + .serves_without_credentials(&request.target.file_key) + .await + { + let auth_status = self.services.auth.status().await?; + if auth_status == AuthStatus::Disconnected { + return Err(DevupError::with_details( + ErrorCode::DevupAuthRequired, + include_str!("../text/mod-start_operation_scoped_tracked-ec551baf.md"), + false, + json!({ + "source": "direct", + "bridgeServesFile": false, + "preferredPath": "bridge", + "fileKey": request.target.file_key, + }), + )); + } + } + + // Each tracked job owns its collector. Artifact single-flight would + // make another output-path job wait on a paused collector it cannot resume. + let acquisition = if job.is_some() { + let payload = CollectedPayload::try_from( + self.run_direct(request.clone(), &operation, job).await?, + )?; + // Only deduplicate publication once this collector has finished. + // A ready payload cannot strand another job behind a paused read, + // and the winning artifact's resource URIs remain valid. + self.artifacts + .get_or_acquire(artifact_key.clone(), refresh, || async { Ok(payload) }) + .await + } else { + self.artifacts + .get_or_acquire(artifact_key.clone(), refresh, || async { + CollectedPayload::try_from( + self.run_direct(request.clone(), &operation, None).await?, + ) + }) + .await + }; + match acquisition { + Ok(artifact) => { + if let Some(job) = job { + job.captured(&artifact.payload.assets); + } + let response = complete_operation( + operation, + &artifact.payload, + "direct", + &artifact, + &self.output_policy, + &self.artifacts, + ) + .await?; + Ok(apply_search_scope( + response, + &artifact.payload.snapshot, + artifact.capabilities.collection_scope, + scope, + )) + } + 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, + attempt_timeout: Option, + ) -> Result { + const ATTEMPTS: u32 = 3; + const LONGEST_WAIT: u64 = 90; + + // A call already banked costs no allowance, so it is answered ahead of + // the pacer rather than queued behind it. This is what lets a capture + // too large for one day's allowance finish across several: the reads + // an earlier attempt paid for are replayed, and only what is still + // missing is spent on. + if let Some(raw) = self.services.call_cache.get(&call) { + return Ok(UpstreamResult { raw }); + } + + // Only a read bound for the metered path is paced. One the Devup Bridge + // plugin answers spends none of Figma's allowance, and holding it to + // that pace made a second export on the same server sit out most of a + // minute for an allowance it was not using. + let metered = self.services.upstream.is_metered(&call).await; + let mut attempt = 1; + loop { + // Before the call, not after the refusal: a collection that paces + // itself under the ceiling rarely has to be waited out at all. + if metered { + self.services.pacer.acquire().await; + } + // Rate-limit pacing is deliberate waiting, not a stalled upstream + // read. Bound each actual attempt without cancelling the retry policy. + let read = self.services.upstream.call_read_tool(call.clone()); + let result = if let Some(timeout) = attempt_timeout { + tokio::time::timeout(timeout, read).await.unwrap_or_else(|_| Err(DevupError::with_details( + ErrorCode::DevupFigmaDirectUnavailable, + "Export job upstream read timed out; accepted reads are retained. Resume the job to retry the pending read.", true, + json!({"stage":"upstream-read","timeoutSeconds":timeout.as_secs()})))) + } else { + read.await + }; + let error = match result { + Ok(result) => { + // Only an answer is banked. A refusal arrives as a + // *successful* MCP call — `isError` in the body, or the + // sentence alone — and banking one meant every later + // attempt replayed it at once, from disk, without reaching + // Figma: twenty-eight refusals overnight and a fresh token + // refused, all one cached refusal. The classification + // that decides retries decides this too, so the two cannot + // disagree. + if operation::upstream_error(&result.raw).is_none() { + self.services.call_cache.put(&call, &result.raw); + } + return Ok(result); + } + Err(error) => error, + }; + if error.code != ErrorCode::DevupFigmaRateLimited { + return Err(error); + } + // The ceiling was reached at a rate the pacer thought was safe, so + // its picture is what is wrong. Correct it before anything else — + // including before giving up, because whatever runs next inherits + // the same window and would otherwise walk into the same refusal. + self.services.pacer.penalise(); + if 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, + operation: &PendingOperation, + job: Option<&asset_jobs::AssetJob>, + ) -> Result { + let budget = match operation { + PendingOperation::Export { + outputs, + all_screens: true, + .. + } => Some(outputs), + _ => None, + }; + if let (Some(outputs), Some(index)) = (budget, &request.cached_section_index) { + validation::validate_export_budget(&index.select(&[], true)?, outputs)?; + } + let target = request.target.clone(); + // 리소스를 몇 개씩 묶어 물을지는 이 수집을 실어 나를 전송이 정한다. + // 공식 MCP 는 응답을 자르므로 8개씩 끊지만, 자르지 않는 전송은 한 번에 + // 다 물어도 되고 그만큼 왕복이 사라진다. + let mut request = request; + request.batch_budget = self.services.upstream.batch_budget(); + let mut collector = CollectorSession::new(request); + loop { + if let Some(job) = job { + job.progress(&collector); + } + match collector.advance()? { + CollectorStep::Call(planned) => { + let call_id = planned.id.clone(); + let asset_read = planned.call.is_asset_read(); + let section_index_call = matches!( + &planned.call, + ReadToolCall::Snapshot { + script: devup_mcp_figma::BuiltinScript::SectionIndex, + .. + } + ); + let upstream_result = if let Some(job) = job { + job.call(self, planned.call).await + } else { + self.call_waiting_out_a_spent_allowance(planned.call, None) + .await + }; + match upstream_result { + // 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(mut error) => { + if !error.details.is_object() { + error.details = json!({"upstreamDetails":error.details}); + } + // A key only the bridge routes by is not the + // file's, so it is not reported as one. + error.details["fileKey"] = if target.is_bridge_only() { + Value::Null + } else { + json!(target.file_key) + }; + if error.details.get("nodeId").is_none() { + error.details["nodeId"] = json!(planned.expected_node_id); + } + error.details["stage"] = json!(if section_index_call { + "section-index" + } else { + "plugin-execution" + }); + if !collector.reject(&call_id, &error)? { + return Err(error); + } + } + None => { + if section_index_call && let Some(outputs) = budget { + let chunk = + devup_mcp_figma::snapshot_chunk_from_result(&result)?; + let snapshot = devup_mcp_figma::merge_chunks(vec![chunk])?; + let index = + devup_mcp_figma::build_section_index(&snapshot, &target)?; + validation::validate_export_budget( + &index.select(&[], true)?, + outputs, + )?; + } + if asset_read { + static ENCODERS: std::sync::OnceLock< + std::sync::Arc, + > = std::sync::OnceLock::new(); + let permit = ENCODERS + .get_or_init(|| { + std::sync::Arc::new(tokio::sync::Semaphore::new(2)) + }) + .clone() + .acquire_owned() + .await + .map_err(|_| { + DevupError::new( + ErrorCode::DevupInvalidInput, + "Asset postprocessing is unavailable.", + false, + ) + })?; + let (returned, accepted) = tokio::task::spawn_blocking(move || { + let _permit = permit; + let accepted = collector.accept(&call_id, result); + (collector, accepted) + }).await.map_err(|_| DevupError::new(ErrorCode::DevupInvalidInput, "Asset postprocessing worker failed; repeat the request.", false))?; + collector = returned; + accepted?; + } else { + collector.accept(&call_id, result)?; + } + } + }, + Err(error) if collector.reject(&call_id, &error)? => continue, + Err(error) => return Err(error), + } + } + CollectorStep::AwaitingResults => continue, + CollectorStep::Complete(parts) => return Ok(*parts), + } + } + } + + /// Both paths to Figma as they stand right now. + /// `devup_figma_explore` without the tool envelope, so `devup_screen_plan` + /// lists screens through the same read rather than a second one. + async fn explore(&self, input: FigmaExploreInput) -> Result { + if let Some(hints) = &input.screenshot_observations { + hints.validate()?; + } + let target = self + .bind_to_bridge( + parse_link(input.url.as_deref())?, + Unnamed::Selection, + &json!({ "tool": "devup_figma_explore", "arguments": { "limit": input.limit,"screenshotObservations":input.screenshot_observations } }), + ) + .await + ?; + target.node_id.as_ref().ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "Exploring neighboring Figma screens requires a node-id.", + false, + ) + })?; + if !(1..=MATCH_CEILING).contains(&input.limit) { + return Err(DevupError::new( + ErrorCode::DevupFigmaResponseTooLarge, + "The explore limit must be between 1 and 100 inclusive.", + false, + )); + } + let requested_target = target.clone(); + let mut request = CollectionRequest::new(target, CollectionScope::Node); + request.resource_scope = ResourceScope::None; + request.explore = Some(ExploreReadOptions { + // Fixed, not derived from `limit`. Derived, it made `limit` two + // things at once - how many screens come back, and how much of the + // page the collection script is allowed to walk - and the second + // one is why limit 33 could answer with fewer screens than limit + // 30. Asking for the script's own ceiling every time also means + // one anchor's projection can be reused for a neighbour's, since + // every explore of a file now reads the same amount. + projection_limit: EXPLORE_PROJECTION_LIMIT, + text_preview_limit: if input.include_text_preview + || input.screenshot_observations.is_some() + { + 160 + } else { + 0 + }, + }); + let result = self + .start_operation( + PendingOperation::Explore { + // The projection ranks up to the ceiling and `limit` cuts + // the ranked list afterwards, in `apply_explore_limit`, so + // that what was found and what was returned are both known + // and can be reported apart. + limit: MATCH_CEILING, + target: requested_target, + }, + request, + input.refresh, + ) + .await?; + let mut result = result; + screenshot_observations::rank(&mut result, input.screenshot_observations.as_ref()); + Ok(apply_explore_limit(result, input.limit)) + } + + /// The screens `devup_screen_plan draft` plans: the caller's list from an + /// earlier explore, or one explore of the linked Section or selection. + async fn plan_screens( + &self, + input: &screen_plan::ScreenPlanInput, + ) -> Result<(Vec, Value), DevupError> { + if !input.screens.is_empty() { + return Ok(( + input.screens.clone(), + json!({"from": "caller", "url": input.url, + "nodeId": input.url.as_deref().and_then(|url| FigmaTarget::parse(url).ok()).and_then(|target| target.node_id)}), + )); + } + let explored = self + .explore(FigmaExploreInput { + screenshot_observations: None, + url: input.url.clone(), + limit: MATCH_CEILING, + include_text_preview: false, + refresh: input.refresh, + }) + .await?; + let candidates = explored["candidates"] + .as_array() + .cloned() + .unwrap_or_default(); + let screens_only = candidates + .iter() + .any(|candidate| candidate["node"]["kind"] == "screen"); + let screens = candidates + .iter() + .map(|candidate| &candidate["node"]) + .filter(|node| !screens_only || node["kind"] == "screen") + .filter_map(screen_plan::screen_from_explore) + .collect(); + // Nodes beside the screens - a logo, a frame named og - are the + // favicon and share image candidates the site questions offer. + let assets = candidates + .iter() + .map(|candidate| &candidate["node"]) + .filter(|node| screens_only && node["kind"] != "screen") + .filter_map(|node| { + Some(json!({"nodeId": node["nodeId"].as_str()?, "name": node["name"].as_str()?})) + }) + .collect::>(); + Ok(( + screens, + json!({ + "assetCandidates": assets, + "from": "devup_figma_explore", + "url": input.url, + "nodeId": explored["anchor"]["nodeId"], + "anchorName": explored["anchor"]["name"], + "anchorBreadcrumb": explored["anchor"]["breadcrumb"], + "targetKind": explored["targetKind"], + "fileKey": explored["source"]["fileKey"], + "truncation": explored["truncation"], + }), + )) + } + + async fn connection_report(&self) -> Result { + let status = self.services.auth.status().await?; + let direct = self.services.auth.direct_path_snapshot().await?; + let bridge = self.services.upstream.bridge_path_snapshot().await; + Ok(diagnostics::connection_report( + status, + &direct, + bridge.as_ref(), + )) + } + + /// Binds a call made without url, or with `figma-bridge://current`, to the + /// one attached Devup Bridge plugin: its file, and the node selected in + /// Figma when the call names none. + /// + /// A call without url used to be refused outright as "url or artifactId + /// is required" while a plugin sat attached and ready, and the one agent + /// that met it invented `/design/bridge/bridge` to get past the field. + /// With no plugin, or several, there is no one file the call can mean, + /// and the refusal says what would make one - `retry` is the call to make + /// then. + async fn bind_to_bridge( + &self, + mut target: FigmaTarget, + unnamed: Unnamed, + retry: &Value, + ) -> Result { + if target.file_key != BRIDGE_CURRENT_KEY { + return Ok(target); + } + let bridge = self.services.upstream.bridge_path_snapshot().await; + let attached = bridge + .as_ref() + .map_or(&[][..], |bridge| bridge.attached_files.as_slice()); + let mut file = match attached { + [file] => file.clone(), + [] => { + let direct_available = self.services.auth.status().await? == AuthStatus::Connected; + return Err(DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + include_str!("../text/mod-bind_to_bridge-3d006854.md"), + false, + json!({ + "stage": "target-resolution", + "bridge": { + "listening": bridge + .as_ref() + .is_some_and(|bridge| bridge.role == BridgeRole::Host), + "role": bridge.as_ref().map(|bridge| match bridge.role { + BridgeRole::Host => "host", + BridgeRole::Relay => "relay", + BridgeRole::Connecting => "connecting", + BridgeRole::Unavailable => "unavailable", + }), + "issue": bridge + .as_ref() + .and_then(|bridge| bridge.issue.as_ref()) + .map(|issue| issue.code()), + "port": bridge.as_ref().and_then(|bridge| bridge.port), + "attachedFiles": [], + }, + "directAvailable": direct_available, + "nextAction": diagnostics::ways_to_open_a_path( + bridge.as_ref(), + direct_available, + retry, + ), + }), + )); + } + several => { + return Err(DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + "No url was given and several Devup Bridge plugins are attached, so which file \ + is meant is ambiguous.", + false, + json!({ + "stage": "target-resolution", + "nextAction": diagnostics::several_plugins(several, retry), + }), + )); + } + }; + target.file_key.clone_from(&file.target_key); + if target.node_id.is_some() || unnamed == Unnamed::File { + return Ok(target); + } + let last_delivered = file.context.clone(); + let refreshed = tokio::time::timeout( + std::time::Duration::from_secs(3), + self.services + .upstream + .refresh_bridge_context(&file.target_key), + ) + .await; + match refreshed { + Ok(Ok(context)) => file.context = context, + _ => { + let mut unnamed_file = file.clone(); + unnamed_file.context.selection = None; + let mut error = selection_refusal(&file, + "Current Bridge selection could not be refreshed. Last-known context is diagnostic only; no node was exported.".into(),name_the_node(&unnamed_file,retry)); + error.details["selectionFreshness"] = json!({"state":"refresh-failed","current":null,"lastKnown":last_delivered, + "nextStep":"Reload an up-to-date Bridge plugin or pass an explicit node link; the last-known node is never selected automatically."}); + return Err(error); + } + } + let context = &file.context; + match (context.single_selection(), unnamed) { + (Some(node), Unnamed::SelectedSection) + if node + .node_type + .as_deref() + .is_some_and(|node_type| node_type != "SECTION") => + { + let mut call = retry.clone(); + if let Some(first) = retry["arguments"]["frameIds"].get(0) { + call["arguments"]["frameIds"] = json!([first]); + } + call["how"] = json!( + "Select the Section in Figma and repeat the call, or export one frame per call: a single frameId and no url, once for each frame." + ); + Err(selection_refusal( + &file, + format!( + "frameIds and allScreens without url choose screens inside the Section \ + selected in Figma, and the selection is a {}.", + node.node_type.as_deref().unwrap_or("node") + ), + call, + )) + } + (Some(node), _) => { + target.node_id = Some(node.id.clone()); + Ok(target) + } + (None, Unnamed::SelectionOrFile) + if context.selection.as_ref().is_some_and(Vec::is_empty) => + { + Ok(target) + } + (None, _) => { + let mut error = selection_refusal( + &file, + match &context.selection { + None => include_str!("../text/mod-bind_to_bridge-6dea64b3.md").to_owned(), + Some(selection) if selection.is_empty() => { + "No url or node was given and nothing is selected in Figma. Select the \ + node in Figma, or name it." + .to_owned() + } + Some(selection) => format!( + "No url or node was given and {} nodes are selected in Figma. Select \ + exactly one, or name the one you mean.", + context.selection_count.unwrap_or(selection.len()) + ), + }, + name_the_node(&file, retry), + ); + error.details["selectionFreshness"] = + json!({"state":"current","current":file.context,"lastKnown":last_delivered}); + if context.selection.as_ref().is_some_and(Vec::is_empty) + && let Some(page) = &context.current_page + { + let observations = retry["arguments"] + .get("screenshotObservations") + .filter(|v| !v.is_null()) + .and_then(|v| { + serde_json::from_value::< + screenshot_observations::ScreenshotObservations, + >(v.clone()) + .ok() + }); + let anchor = FigmaTarget { + node_id: Some(page.id.clone()), + ..target.clone() + }; + error.details["exploreSuggestion"] = json!({"tool":"devup_figma_explore","arguments":{"url":anchor.link(Some(&page.id)),"limit":10}, + "basis":"current-page-context","screenshotMatch":"unverified","evidenceLimit":"No screenshot was inspected. Candidates use actual Figma page structure and geometry only."}); + if let Some(hints) = &observations { + error.details["exploreSuggestion"]["arguments"]["screenshotObservations"] = + json!(hints); + error.details["exploreSuggestion"]["basis"] = + json!("caller-observations-and-current-page-evidence"); + } + let call = ReadToolCall::explore_snapshot( + &target.file_key, + &page.id, + ExploreReadOptions { + projection_limit: EXPLORE_PROJECTION_LIMIT, + text_preview_limit: if observations.is_some() { 160 } else { 0 }, + }, + ); + if let Ok(Ok(read)) = tokio::time::timeout( + std::time::Duration::from_secs(3), + self.services.upstream.call_read_tool(call), + ) + .await + && let Ok(chunk) = devup_mcp_figma::snapshot_chunk_from_result(&read) + && let Ok(snapshot) = devup_mcp_figma::merge_chunks(vec![chunk]) + && let Ok(candidates) = devup_mcp_figma::explore_snapshot( + &snapshot, + &anchor, + &devup_mcp_figma::ExploreOptions { + limit: MATCH_CEILING, + }, + ) + { + error.details["exploreSuggestion"]["candidates"] = + json!(candidates.candidates); + error.details["exploreSuggestion"]["projectionTruncated"] = + json!(candidates.truncated); + screenshot_observations::rank( + &mut error.details["exploreSuggestion"], + observations.as_ref(), + ); + let found = error.details["exploreSuggestion"]["candidates"] + .as_array() + .map_or(0, Vec::len); + error.details["exploreSuggestion"]["foundCandidates"] = json!(found); + error.details["exploreSuggestion"]["candidatesTruncated"] = + json!(found > 10); + if let Some(candidates) = + error.details["exploreSuggestion"]["candidates"].as_array_mut() + { + candidates.truncate(10); + } + } + } + Err(error) + } + } + } +} + +/// The call that names the node a selection could not: the first selected +/// one when anything is selected, a placeholder to replace otherwise. +fn name_the_node(file: &devup_mcp_figma::AttachedFile, retry: &Value) -> Value { + let node = file + .context + .selection + .as_deref() + .and_then(<[_]>::first) + .map(|node| node.id.as_str()); + let mut call = retry.clone(); + call["arguments"]["url"] = + json!(FigmaTarget::bridge_current().link(Some(node.unwrap_or("")))); + if node.is_none() { + call["requiredArguments"] = json!(["url"]); + } + call["how"] = json!( + "Select one node in Figma and repeat the call, or name the node with figma-bridge://current?node-id= as url." + ); + call +} + +/// Which node a call bound to the bridge means when it names none. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Unnamed { + /// No node: the whole file, as a search reads it. + File, + /// The one node selected in Figma: an export's target, explore's anchor. + Selection, + /// The selected node when exactly one is selected, else the whole file: + /// a file-scope export that needs no node. + SelectionOrFile, + /// The selected Section, whose screens frameIds or allScreens choose. + SelectedSection, +} + +/// The refusal for a selection that does not name the node a call needs, +/// carrying the call that would. +fn selection_refusal( + file: &devup_mcp_figma::AttachedFile, + message: String, + call: Value, +) -> DevupError { + DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + message, + false, + json!({ + "stage": "target-resolution", + "file": { + "fileName": file.file_name, + "currentPage": file.context.current_page, + "selection": file.context.selection, + "selectionCount": file.context.selection_count, + "lastKnownSelection":file.context.last_known_selection, + "lastKnownPage":file.context.last_known_page, + "observedAt":file.context.observed_at, + }, + "nextAction": call, + }), + ) +} + +/// Every `devup_figma_*` tool response is a JSON object whose exact shape +/// varies per operation, `status`, and `delivery` mode (see README). Rather +/// than pin a schema per branch that would need constant re-syncing, this +/// declares only what the MCP spec requires of `outputSchema` — root type +/// `object` (SEP-2106) — with no constraint on properties. +/// +/// This replaces `schema_for_output::()`, whose root +/// schema had no `type` field at all: schemars maps `Value` to the boolean +/// JSON Schema `true`, and `into_root_schema_for` normalizes that to the +/// empty object `{}` before `outputSchema` strips `title`/`description`, +/// leaving a schema that satisfies "is an object" as a JSON value but not +/// the MCP-mandated `"type": "object"` marker. +fn permissive_object_output_schema() -> Arc { + let mut schema = JsonObject::new(); + schema.insert("type".to_owned(), json!("object")); + Arc::new(schema) +} + +/// What `devup_figma_search` was pointed at, and how much of it to answer +/// with. `node_id` is read from the URL rather than from a parameter of its +/// own: a caller who links a node has already said where to look, and a +/// search that ignores that says nothing about having ignored it. +struct SearchScope { + node_id: Option, + limit: usize, + /// The same file with no node linked - the call that widens the search. + file_url: String, +} + +/// Whether `node_id` is `ancestor_id` itself or sits beneath it, following the +/// parent chain the collection recorded. +/// +/// The search projection keeps each match's ancestors up to its page, so the +/// chain of anything that matched is present even when the ancestors did not +/// match themselves. A node with no recorded chain to `ancestor_id` is out of +/// scope, which is the answer wanted when the linked node holds nothing. +fn is_within(snapshot: &Snapshot, node_id: &str, ancestor_id: &str) -> bool { + if node_id == ancestor_id { + return true; + } + let parent_of = |id: &str| { + snapshot + .nodes + .get(id) + .and_then(|node| node.typed_view().string("parentId")) + }; + let mut seen = std::collections::BTreeSet::new(); + let mut current = parent_of(node_id); + while let Some(parent) = current { + if parent == ancestor_id { + return true; + } + if !seen.insert(parent.to_owned()) { + return false; + } + current = parent_of(parent); + } + false +} + +/// Cuts the ranked match list down to what the caller linked and says so. +/// +/// Report the stored artifact capability for both fresh and cached reads. +fn apply_search_scope( + mut response: Value, + snapshot: &Snapshot, + collection_scope: CollectionScope, + scope: Option<&SearchScope>, +) -> Value { + let Some(scope) = scope else { + return response; + }; + let Some(Value::Array(matches)) = response.get_mut("matches").map(Value::take) else { + return response; + }; + let scanned = matches.len(); + let node_id = match scope.node_id.as_deref() { + None => { + let mut kept = matches; + kept.truncate(scope.limit); + response["count"] = json!(kept.len()); + response["matches"] = Value::Array(kept); + response["scope"] = json!({ + "kind": "file", + "nodeId": Value::Null, + "collectionScope": collection_scope, + "scannedMatches": scanned, + "note": "The URL carried no node-id, so the whole file was searched. \ + Put the node you care about in the URL to search only inside it.", + }); + return response; + } + Some(node_id) => node_id, + }; + + let (mut inside, outside): (Vec, Vec) = matches.into_iter().partition(|entry| { + entry["nodeId"] + .as_str() + .is_some_and(|id| is_within(snapshot, id, node_id)) + }); + let found = inside.len(); + inside.truncate(scope.limit); + let mut scope_report = json!({ + "kind": "node", + "nodeId": node_id, + "collectionScope": collection_scope, + "scannedMatches": scanned, + "matchedInScope": found, + "returned": inside.len(), + "excludedOutOfScope": outside.len(), + "candidatesTruncated": found > inside.len(), + "note": "The URL carried a node-id, so only matches inside that node are \ + returned. Remove the node-id to search elsewhere in the file.", + }); + if inside.is_empty() { + scope_report["nextAction"] = json!({ + "reason": "Nothing inside the linked node matched. Search the whole file \ + with the node-id removed to look beyond this scope.", + "example": { + "url": scope.file_url, + "note": "The same call with the node-id removed from the URL.", + }, + }); + if !outside.is_empty() { + let mut elsewhere = outside; + elsewhere.truncate(scope.limit); + response["outOfScopeMatches"] = Value::Array(elsewhere); + } + } + response["count"] = json!(inside.len()); + response["matches"] = Value::Array(inside); + response["scope"] = scope_report; + response +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs b/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs new file mode 100644 index 00000000..010bc870 --- /dev/null +++ b/crates/devup-mcp/src/server/mod_parts/exploration_and_responses.rs @@ -0,0 +1,961 @@ + + +/// Cuts the ranked candidate list to `limit` and reports the two truncations +/// apart. +/// +/// `limit` is applied here rather than inside the projection so that it stays +/// a property of the answer alone: the projection is always asked for the same +/// ceiling, so no choice of `limit` can change how much of the design was read. +/// `truncated` arrives meaning "the snapshot itself was incomplete" and is +/// widened back to "something was cut" only after the two are recorded apart. +fn apply_explore_limit(mut response: Value, limit: usize) -> Value { + let Some(Value::Array(mut candidates)) = response.get_mut("candidates").map(Value::take) else { + return response; + }; + let projection_truncated = response["truncated"].as_bool().unwrap_or(false); + let found = candidates.len(); + candidates.truncate(limit); + let returned = candidates.len(); + let candidates_truncated = found > returned; + let at_ceiling = found >= MATCH_CEILING; + + let reason = match (projection_truncated, candidates_truncated) { + (false, false) => { + "Nothing was cut: every screen candidate found is in this answer.".to_owned() + } + (false, true) => format!( + "{found} screen candidates were found and {returned} returned, because limit is \ + {limit}. Nothing is missing from the design - raise limit to see the rest." + ), + (true, false) => include_str!("../text/mod-apply_explore_limit-f9b94793.md").to_owned(), + (true, true) => format!( + include_str!("../text/mod-apply_explore_limit-7dacda8e.md"), + found = found, + limit = limit, + returned = returned + ), + }; + let mut truncation = json!({ + "projection": projection_truncated, + "candidates": candidates_truncated, + "returned": returned, + "found": found, + "atCeiling": at_ceiling, + "reason": reason, + }); + if candidates_truncated { + truncation["nextAction"] = json!({ + "limit": found.max(returned).min(MATCH_CEILING), + "note": if at_ceiling { + "This is the highest limit there is; a Section holding more screens than that \ + has to be exported in parts." + } else { + "The ranked order is stable, so a larger limit extends this list rather than \ + reshuffling it." + }, + }); + } + response["candidates"] = Value::Array(candidates); + response["count"] = json!(returned); + // There is no cursor to hand back: the projection is rebuilt per call and + // has no position to resume from. `truncation` says which lever moves it. + response["truncated"] = json!(projection_truncated || candidates_truncated); + response["truncation"] = truncation; + response +} + +#[path = "../handlers/devup_figma_measure.rs"] +mod devup_figma_measure_handler; +#[path = "../handlers/devup_skills.rs"] +mod devup_skills_handler; +#[path = "../handlers/devup_figma_auth.rs"] +mod devup_figma_auth_handler; +#[path = "../handlers/devup_figma_search.rs"] +mod devup_figma_search_handler; +#[path = "../handlers/devup_figma_explore.rs"] +mod devup_figma_explore_handler; +#[path = "../handlers/devup_figma_export.rs"] +mod devup_figma_export_handler; +#[path = "../handlers/devup_project_context.rs"] +mod devup_project_context_handler; +#[path = "../handlers/devup_ui_validate.rs"] +mod devup_ui_validate_handler; +#[path = "../handlers/devup_stack_diff.rs"] +mod devup_stack_diff_handler; +#[path = "../handlers/devup_visual_compare.rs"] +mod devup_visual_compare_handler; +#[path = "../handlers/devup_feature_trace.rs"] +mod devup_feature_trace_handler; +#[path = "../handlers/devup_runtime_audit.rs"] +mod devup_runtime_audit_handler; +#[path = "../handlers/devup_project_plan.rs"] +mod devup_project_plan_handler; +#[path = "../handlers/devup_screen_plan.rs"] +mod devup_screen_plan_handler; + +impl DevupServer { + fn tool_router() -> ToolRouter { + Self::devup_figma_measure_router() + + Self::devup_skills_router() + + Self::devup_figma_auth_router() + + Self::devup_figma_search_router() + + Self::devup_figma_explore_router() + + Self::devup_figma_export_router() + + Self::devup_project_context_router() + + Self::devup_ui_validate_router() + + Self::devup_stack_diff_router() + + Self::devup_visual_compare_router() + + Self::devup_feature_trace_router() + + Self::devup_runtime_audit_router() + + Self::devup_project_plan_router() + + Self::devup_screen_plan_router() + } +} + +/// Adds what `devup_ui_validate` checked beyond its rules, kept apart from +/// `violations` so `ok`, `okReason` and `strict` mean what they meant: the +/// static layout risks, the export node behind each violation when the caller +/// passed the export's `sourceMap`, and which part was static and which was +/// never rendered. +fn attach_layout_and_source_links(result: &mut Value, input: &UiValidateInput) { + let sources: Vec<(Option<&str>, &str)> = match &input.files { + Some(files) => files + .iter() + .filter(|file| { + [".tsx", ".jsx"] + .iter() + .any(|extension| file.path.ends_with(extension)) + }) + .map(|file| (Some(file.path.as_str()), file.content.as_str())) + .collect(), + None => vec![(None, input.tsx.as_str())], + }; + let mut risks = Vec::new(); + let mut unmeasured = 0; + let mut layout = Value::Null; + for (path, source) in sources { + layout = devup_mcp_devup_ui::ui_layout::layout_risks(source, input.reference_width); + unmeasured += layout["groupsWithoutReferenceWidth"].as_u64().unwrap_or(0); + for mut risk in layout["risks"].as_array().cloned().unwrap_or_default() { + if let Some(path) = path { + risk["path"] = json!(path); + } + risks.push(risk); + } + } + result["layoutRisks"] = json!(risks); + let mut rules = devup_mcp_devup_ui::ui_validate::rule_names() + .into_iter() + .collect::>(); + if input.project_root.is_some() { + rules.push(mask_guard::RULE); + } + if input.files.is_some() { + rules.extend([ + "file-placement", + "one-component-per-file", + "client-boundary", + "inline-style", + "react-query-states", + ]); + } + result["verificationScope"] = json!({ + "static": { + "rules": rules, + "unknownTokenCheckRan": result["tokens"]["unknownTokenCheckRan"], + "layout": layout["checked"], + "referenceWidth": input.reference_width, + "groupsWithoutReferenceWidth": unmeasured, + }, + "runtime": { + "state": "not-performed", + "unverified": layout["runtimeUnverified"], + "note": "Nothing was rendered. devup_runtime_audit plans a consumer-side browser run whose report this server can ingest.", + }, + }); + let Some(source_map) = &input.source_map else { + return; + }; + if input.files.is_some() { + result["sourceMapLink"] = json!({"state": "unavailable", + "reason": "A sourceMap describes the one TSX it was exported with, and files numbers the elements of each file from 0, so a violation cannot be tied to it. Pass the sourceMap with tsx."}); + return; + } + let entries = source_map + .entries + .iter() + .chain(&source_map.tsx) + .collect::>(); + if entries.is_empty() { + result["sourceMapLink"] = json!({"state": "unavailable", + "reason": "The sourceMap holds no entries: pass the sourceMap devup_figma_export returned with this tsx, whose entries are under tsx (a screen) or entries (a Section frame)."}); + return; + } + if !entries.iter().any(|entry| entry.element_index.is_some()) { + result["sourceMapLink"] = json!({"state": "unavailable", + "reason": "No sourceMap entry carries elementIndex; it was produced by a build before elementIndex existed. Export again to link violations."}); + return; + } + let mut linked = 0; + for violation in result["violations"].as_array_mut().into_iter().flatten() { + let (Some(element), Some(attribute)) = ( + violation["elementIndex"].as_u64(), + violation["attribute"].as_str().map(str::to_owned), + ) else { + continue; + }; + let matches = entries + .iter() + .filter(|entry| entry.element_index == usize::try_from(element).ok()) + .filter(|entry| { + entry + .generated_property + .as_deref() + .is_some_and(|generated| { + generated == attribute || generated.starts_with(&format!("{attribute}=")) + }) + }) + .take(3) + .map(|entry| { + json!({"nodeId": entry.node_id, "property": entry.property, + "generatedProperty": entry.generated_property, "resolution": entry.resolution}) + }) + .collect::>(); + if !matches.is_empty() { + linked += 1; + violation["source"] = json!(matches); + } + } + let unlinked = result["violations"].as_array().map_or(0, Vec::len) - linked; + result["sourceMapLink"] = json!({"state": "linked", "linkedViolations": linked, + "unlinkedViolations": unlinked, "joinedOn": "elementIndex + attribute name"}); + if unlinked > 0 { + result["sourceMapLink"]["unlinkedReason"] = json!(include_str!( + "../text/mod-attach_layout_and_source_links-a7298619.md" + )); + } +} + +/// The target a tool's url names. No url is the file the attached Devup Bridge +/// plugin has open, which [`DevupServer::bind_to_bridge`] then resolves. +fn parse_link(url: Option<&str>) -> Result { + url.map_or_else(|| Ok(FigmaTarget::bridge_current()), FigmaTarget::parse) +} + +fn section_index_from_payload(payload: &CollectedPayload) -> Option { + serde_json::from_value(payload.metadata.get("sectionIndex")?.clone()).ok() +} + +/// The skills the code in this response is written in, named when this runtime +/// would not load them. +/// +/// On the export rather than only on `devup_ui_validate`, because the agent +/// that has never seen devup-ui does not call the validator either: the gap +/// was only ever shown to callers who had already written the code and then +/// chosen to check it. This is the response every implementing agent reads, +/// and it arrives before the code is written rather than after it is refused. +/// +/// The same sentence is in `devup_figma_export`'s own tool description, +/// because that is the only channel that reaches an agent which never sees the +/// server's `instructions` - a subagent handed tool schemas alone, or Codex, +/// where `instructions` becomes one namespace blurb rather than prompt text. +const CODE_SKILLS: [&str; 2] = ["devup-ui", "devfive-frontend"]; + +fn with_skill_gap(mut result: Value, lookup: &skills::Lookup) -> Value { + if let Some(gap) = skill_gap(CODE_SKILLS.into_iter(), lookup, CODE_GAP_WHY) { + result["skillGap"] = gap; + } + result +} + +const CODE_GAP_WHY: &str = include_str!("../text/mod-CODE_GAP_WHY-6866def7.md"); + +/// The gap between the skills a response's subject needs and what this runtime +/// would actually load, or `None` when there is none. +/// +/// Missing and outdated are reported apart because they need different words +/// from the caller. One is a skill the agent has never seen. The other is one +/// it is reading right now, and being taught rules this build no longer emits - +/// which reads exactly like a correctly installed skill until someone compares +/// the bytes. +fn skill_gap<'a>( + names: impl Iterator, + lookup: &skills::Lookup, + why: &str, +) -> Option { + let mut missing = Vec::new(); + let mut outdated = Vec::new(); + for skill in names.filter_map(skills::find_by_name) { + let entry = json!({ + "skill": skill.record.name, + "whyYouNeedIt": skill.record.used_for, + "install": skill.install_action(lookup), + }); + match skill.installed_freshness(lookup) { + None => missing.push(entry), + Some(state) if state.should_refresh() => { + let mut entry = entry; + entry["revision"] = json!(state.as_str()); + outdated.push(entry); + } + Some(_) => {} + } + } + if missing.is_empty() && outdated.is_empty() { + return None; + } + // The same install writes /devup, the one word that starts the whole + // workflow; say so where the agent is already being told to install. + let command = skills::command_state(lookup); + Some(json!({ + "missing": missing, + "outdated": outdated, + "command": if command["installed"] == true { Value::Null } else { json!({ + "invocation": command["invocation"], + "supported": command["supported"], + "how": command["how"], + }) }, + "why": why, + "how": include_str!("../text/mod-skill_gap-bd8a6ae8.md"), + "runtime": lookup.runtime.as_str(), + "workspace": lookup.project.display().to_string(), + })) +} + +/// Names the conventions for the files this scope just read, when this runtime +/// would not load them. +/// +/// The export gap covers the code devup-mcp writes; this covers the files it +/// reads. `devup.json` is devup-ui's theme format, the `openapi.json` under +/// scope api is generated by vespera routes, and the `models/*.json` under +/// scope db are vespertide schemas - so an agent about to edit any of them +/// needs the same rules, and had no way to learn it needed them. +/// +/// Gated on the scope having found something. A project with no `openapi.json` +/// has no use for the vespera skill, and naming it there is the noise that +/// teaches a reader to skip the field. +fn with_context_skill_gap(mut result: Value, scope: &str, lookup: &skills::Lookup) -> Value { + let found = |key: &str| { + // `all` nests one object per scope; a single scope answers at the top + // level. Both say `found`. + result + .get(key) + .unwrap_or(&result) + .get("found") + .and_then(Value::as_bool) + .unwrap_or(false) + }; + let mut names: Vec<&str> = Vec::new(); + if matches!(scope, "theme" | "all") && found("theme") { + names.push("devup-ui"); + } + if matches!(scope, "api" | "all") && found("api") { + names.push("vespera"); + } + if matches!(scope, "db" | "all") && found("db") { + names.push("vespertide"); + } + if scope == "ui" && found("ui") { + names.extend(CODE_SKILLS); + } + if let Some(gap) = skill_gap( + names.into_iter(), + lookup, + include_str!("../text/mod-with_context_skill_gap-fc39c774.md"), + ) { + result["skillGap"] = gap; + } + result +} + +/// Says whether the TSX just generated fits the project it is going into. +/// +/// Tokens are named after the Figma variables and text styles the screen +/// uses. For a project with no `devup.json` that is the right answer, and it +/// is what the `devupJson` output is for. For a project that already has one, +/// the names are its own and the export had no way to know them: a Braillify +/// Studio login screen came back referencing `$background`, `$innerBg`, +/// `$kakaoLogo`, `$title`, `$textSub`, `$borderLight` and the typography +/// `mainSubText`, `titleSmMed`, `bodyMed`, and that project defines none of +/// them. `devup_ui_validate` refused the same code against the same root, so +/// the export was handing back TSX this server's own validator rejects, and +/// saying nothing about it. +/// +/// Reported rather than rewritten. Which token was meant is the caller's +/// decision — `$background` is one edit from `$bg` and also from any other +/// name a project happens to use — and silently substituting a colour is a +/// worse failure than naming the mismatch. +/// +/// Without a root the theme half is skipped and nothing is read from disk, but +/// the skill half still runs: see [`with_skill_gap`] for why it cannot be +/// gated on an argument the unaware caller is the one least likely to send. +fn with_project_checks( + mut result: Value, + project_root: Option<&str>, + lookup: &skills::Lookup, +) -> Value { + if result.get("interactionCoverage").is_some() + && let Some(root) = project_root + { + project_context::attach_export_context(&mut result, root); + } + review::attach(&mut result); + const OUTPUTS: [&str; 3] = ["tsx", "componentTsx", "responsiveTsx"]; + let mut generated: Vec<(String, String)> = OUTPUTS + .into_iter() + .filter_map(|key| { + result + .get(key) + .and_then(Value::as_str) + .map(|tsx| (key.to_owned(), tsx.to_owned())) + }) + .collect(); + // A Section export answers under `frames`, one entry per selected frame, + // and nothing at the top level. Reading only the top level checked the + // single-node `url` case and silently skipped every `frameIds` call — + // which is every Section workflow, and so every screen of the Braillify + // Studio handover this exists for. + if let Some(frames) = result.get("frames").and_then(Value::as_array) { + for frame in frames { + let node = frame + .get("nodeId") + .and_then(Value::as_str) + .unwrap_or("frame") + .to_owned(); + for key in OUTPUTS { + if let Some(tsx) = frame.get(key).and_then(Value::as_str) { + generated.push((format!("{node}:{key}"), tsx.to_owned())); + } + } + } + } + // Before the theme half, and before its `project_root` gate: a response + // that handed back code has to name the conventions that code is written + // in whether or not the caller told us where the project is. + let produced_code = !generated.is_empty() || result.get("deliverable").is_some(); + if produced_code { + result = with_skill_gap(result, lookup); + } + let Some(project_root) = project_root else { + return result; + }; + if generated.is_empty() { + // Resource delivery hands the code back as a link, so there is no + // source here to check. Saying so is the point: an absent verdict + // reads exactly like a clean one, and this export is neither. + if result.get("resources").is_some_and(|r| !r.is_null()) { + result["projectThemeValidation"] = json!({ + "state": "not-checked", + "reason": "The outputs were delivered as resources, so no source was present to check.", + "note": "Request delivery=inline to have the generated tokens checked against this project.", + }); + } + return result; + } + let lookup = match project_context::theme_for_validation(Some(project_root)) { + Ok(lookup) => lookup, + Err(error) => { + result["projectThemeValidation"] = json!({ + "state": "unavailable", + "reason": error.message, + "note": "The generated tokens could not be checked against this project.", + }); + return result; + } + }; + let mut outputs = serde_json::Map::new(); + for (key, tsx) in generated { + let report = devup_mcp_devup_ui::ui_validate::validate_devup_ui_tsx( + &tsx, + lookup.theme.as_ref(), + false, + ); + // Only the findings a caller has to act on. The hardcoded-value + // information is already in `fidelity`, and repeating twenty of them + // here would bury the handful that stop the code compiling into a + // theme. + let unknown = report + .violations + .iter() + .filter(|violation| violation.rule == "unknown-token") + .map(|violation| { + json!({ + "message": violation.message, + "suggestion": violation.suggestion, + }) + }) + .collect::>(); + outputs.insert( + key.to_owned(), + json!({ + "ok": report.ok, + "unknownTokenCount": unknown.len(), + "unknownTokens": unknown, + }), + ); + } + let any_unknown = outputs + .values() + .any(|output| output["unknownTokenCount"].as_u64().unwrap_or(0) > 0); + result["projectThemeValidation"] = json!({ + "state": if lookup.theme.is_some() { "checked" } else { "no-theme" }, + "themeAvailable": lookup.theme.is_some(), + "guardrail": lookup.guardrail, + "outputs": outputs, + "note": if any_unknown { + include_str!("../text/mod-with_project_checks-2c4cf346.md") + } else { + "Every token the generated TSX references exists in this project's devup.json." + }, + }); + result +} + +fn section_candidate_as_explore(candidate: &SectionCandidate) -> ExploreCandidate { + ExploreCandidate { + node: ExploreNode { + node_id: candidate.node_id.clone(), + name: candidate.name.clone(), + node_type: candidate.node_type.clone(), + bounds: candidate.bounds, + child_count: candidate.direct_child_count, + text_preview: candidate.text_preview.clone(), + text_preview_state: candidate.text_preview_state.clone(), + parent_id: candidate.parent_id.clone(), + kind: ExploreKind::Screen, + visible: candidate.visible, + breadcrumb: candidate.breadcrumb.clone(), + page_child_index: None, + }, + canonical_url: candidate.canonical_url.clone(), + score: 900, + selection_reasons: candidate.selection_reasons.clone(), + } +} + +fn format_epoch_rfc3339(epoch_seconds: u64) -> String { + let days = (epoch_seconds / 86_400) as i64; + let seconds = epoch_seconds % 86_400; + let (year, month, day) = civil_from_days(days); + let hour = seconds / 3_600; + let minute = (seconds % 3_600) / 60; + let second = seconds % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) { + let days = days_since_epoch + 719_468; + let era = if days >= 0 { days } else { days - 146_096 } / 146_097; + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + (year, month, day) +} + +fn parse_scope(scope: &str) -> Result { + match scope { + "node" => Ok(ThemeScope::Node), + "page" => Ok(ThemeScope::Page), + "file" => Ok(ThemeScope::File), + _ => Err(DevupError::new( + ErrorCode::DevupThemeConflict, + "scope must be node, page, or file.", + false, + )), + } +} + +fn with_error_identity(mut error: ErrorData) -> ErrorData { + let mut data = error.data.take().unwrap_or_else(|| json!({})); + if !data.is_object() { + data = json!({"details":data}); + } + data["server"] = delivery::server_identity(); + error.data = Some(data); + error +} + +/// The one automatic recovery for `DEVUP_SECTION_REQUIRED`: re-aim the same +/// request at the ancestor Section the plugin reported, keeping the requested +/// frame as the selection. Returns `None` when the plugin named no Section. +fn section_retry( + error: &DevupError, + operation: &PendingOperation, + request: &CollectionRequest, +) -> Option<(PendingOperation, CollectionRequest, Value)> { + if error.details["pluginCode"] != "DEVUP_SECTION_REQUIRED" { + return None; + } + let section_id = error.details["sectionId"].as_str()?; + let requested = request.target.node_id.clone()?; + let PendingOperation::Export { + frame_ids, + all_screens, + .. + } = operation + else { + return None; + }; + let mut operation = operation.clone(); + let mut request = request.clone(); + let selection = if frame_ids.is_empty() && !all_screens { + vec![requested.clone()] + } else { + frame_ids.clone() + }; + if let PendingOperation::Export { frame_ids, .. } = &mut operation { + frame_ids.clone_from(&selection); + } + request.target.node_id = Some(section_id.to_owned()); + request.section = Some(SectionReadOptions { + frame_ids: selection.clone(), + all_screens: *all_screens, + }); + Some(( + operation, + request, + json!({"requestedNodeId":requested,"sectionId":section_id,"frameIds":selection, + "note":"The url named a node inside a SECTION; the export was re-run against that SECTION with the node selected."}), + )) +} + +/// Maps a [`DevupError`] onto the JSON-RPC error the caller actually sees. +/// +/// The protocol code is not decoration here: the caller is usually an agent +/// choosing between "fix the arguments and call again" and "stop and report", +/// and every error used to arrive as INTERNAL_ERROR, which says the second +/// thing about both. A mistake in the call itself is INVALID_PARAMS, so that +/// decision can be made without parsing the message. `data` keeps carrying +/// the exact `code` and `retryable`, unchanged. +fn to_mcp_error(mut error: DevupError) -> ErrorData { + if error.code == ErrorCode::DevupFigmaHandoffExpired + && error.details.get("exportJob").is_none() + && error.details.get("assetJob").is_none() + { + if !error.details.is_object() { + error.details = json!({"upstreamDetails":error.details}); + } + if error.details.get("stage").is_none() { + error.details["stage"] = json!("artifact-lookup"); + } + if error.details.get("artifactState").is_none() { + error.details["artifactState"] = json!("unknown"); + } + let recovery = error.details.get("recoveryArguments").cloned(); + error.details["recoveryState"] = json!(if recovery.is_some() { + "available" + } else { + "unrecoverable" + }); + let another_process = error.details["artifactState"] == "issued-by-another-process"; + if recovery.is_none() { + error.details["recoveryReason"] = json!(if another_process { + include_str!("../text/mod-to_mcp_error-784239dd.md") + } else { + "Original URL/selection cannot be recovered from this server. The ID may be unknown, evicted beyond metadata retention, or lost on restart; the cause cannot be determined." + }); + } + error.details["recovery"] = json!({ + "mode": "recollect", + "cause": match error.details["artifactState"].as_str() { + Some("expired") => "artifact-expired", + Some("evicted") => "artifact-evicted", + Some("issued-by-another-process") => "issued-by-another-process", + _ => "artifact-unknown", + }, + "reprojectable": false, + "requiresOriginalUrl": recovery.is_none(), + }); + error.details["nextAction"] = json!({"tool":"devup_figma_export", + "arguments":{"refresh":true},"requiredArguments":["url"],"omitArguments":["artifactId"], + "how":include_str!("../text/mod-to_mcp_error-20941350.md")}); + if let Some(arguments) = recovery { + error.details["nextAction"]["arguments"] = arguments; + } + } + if error.details["recoveryArguments"]["nodeIds"].is_array() { + error.details["nextAction"] = json!({"tool":"devup_figma_measure","arguments":error.details["recoveryArguments"], + "how":"Repeat the explicit measured node list; a measurement receipt never recovers by exporting the current selection."}); + } + let mcp_code = if validation::is_caller_mistake(&error) { + McpErrorCode::INVALID_PARAMS + } else { + McpErrorCode::INTERNAL_ERROR + }; + ErrorData::new( + mcp_code, + error.message, + Some( + json!({ "code": error.code, "retryable": error.retryable, "details": error.details, "server":delivery::server_identity() }), + ), + ) +} + +fn structured_tool_error(error: ErrorData) -> rmcp::model::CallToolResult { + let mut data = error.data.unwrap_or_else(|| json!({})); + if !data.is_object() { + data = json!({"details":data}); + } + data.as_object_mut().unwrap().remove("server"); + if data.get("code").is_none() { + data["code"] = json!("DEVUP_INVALID_INPUT"); + } + if data.get("retryable").is_none() { + data["retryable"] = json!(false); + } + if data.get("details").is_none() { + data["details"] = json!({}); + } + data["message"] = json!(error.message); + data["rpcCode"] = json!(error.code); + let mut result = tool_result(json!({"error":data})); + result.is_error = Some(true); + result +} + +#[tool_handler(router = self.tool_router)] +impl ServerHandler for DevupServer { + async fn call_tool( + &self, + request: rmcp::model::CallToolRequestParams, + context: RequestContext, + ) -> Result { + // Read here rather than by overriding `initialize`, because the peer + // already recorded it there and this is the first place the value is + // needed. `set` after the first call is a no-op, so one session keeps + // one answer. + if self.client_name.get().is_none() + && let Some(peer) = context.peer.peer_info() + { + let _ = self.client_name.set(peer.client_info.name.clone()); + } + let call = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + let mut response = match self.tool_router.call(call).await { + Ok(response) => response, + Err(error) if error.data.as_ref().and_then(|d| d.get("code")).is_some() => { + return Ok(structured_tool_error(error).into()); + } + Err(error) => return Err(with_error_identity(error)), + }; + if let rmcp::model::CallToolResponse::Complete(result) = &mut response + && result.is_error == Some(true) + { + // Router argument decoding failures arrive as content-only tool + // errors. Give them the same error envelope as execution errors. + if result + .structured_content + .as_ref() + .and_then(|v| v.get("error")) + .is_none() + { + *result = structured_tool_error(ErrorData::invalid_params( + "Invalid tool arguments.", + Some(json!({"details":{"content":result.content}})), + )); + } + } + Ok(response) + } + + fn get_info(&self) -> ServerInfo { + // Initialize is the first moment a real session exists, which makes it + // the right place to start the release lookup: server construction is + // not, because `self_check()` constructs a server on a thread with no + // Tokio runtime and `--self-check` must stay network-free. `spawn` is + // idempotent and returns immediately when disabled or when there is no + // runtime, so this costs nothing here. + release_check::spawn(); + ServerInfo::new( + ServerCapabilities::builder() + .enable_resources() + .enable_tools() + .build(), + ) + .with_server_info(Implementation::new("devup-mcp", env!("CARGO_PKG_VERSION"))) + .with_instructions(guide::INSTRUCTIONS) + } + + async fn list_resources( + &self, + request: Option, + _context: RequestContext, + ) -> Result { + resources::list_output_resources( + &self.artifacts, + request + .as_ref() + .and_then(|request| request.cursor.as_deref()), + ) + .await + .map_err(to_mcp_error) + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(resources::resource_templates()) + } + + async fn read_resource( + &self, + request: rmcp::model::ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + resources::read_output_resource(&self.artifacts, &request.uri) + .await + .map(Into::into) + .map_err(|error| with_error_identity(resource_read_error(error))) + } +} + +/// A failed resource read, still `resource not found` on the wire, carrying +/// which part was missing and whether the artifact behind it is alive: a live +/// artifact can project the output again, a gone one has to be recollected. +fn resource_read_error(error: DevupError) -> ErrorData { + let details = &error.details; + let live = details["artifactState"] == "live"; + let (mode, cause) = match details["resourceKind"].as_str() { + Some("chunk") => ("reread-manifest", "chunk-out-of-range"), + Some("output") => ("reproject", "output-not-attached"), + None if error.code == ErrorCode::DevupFigmaHandoffInvalid => ("fix-uri", "invalid-uri"), + _ => ( + "recollect", + match details["artifactState"].as_str() { + Some("expired") => "artifact-expired", + Some("evicted") => "artifact-evicted", + Some("issued-by-another-process") => "issued-by-another-process", + _ => "artifact-unknown", + }, + ), + }; + let recovery = json!({ + "mode": mode, + "cause": cause, + "reprojectable": live, + "requiresOriginalUrl": !live && details.get("recoveryArguments").is_none(), + "recoveryArguments": details.get("recoveryArguments"), + }); + ErrorData::resource_not_found( + "resource not found", + Some(json!({ + "code": error.code, + "retryable": error.retryable, + "message": error.message, + "details": error.details, + "recovery": recovery, + })), + ) +} + +#[cfg(test)] +#[path = "../export_skill_gap_tests.rs"] +mod export_skill_gap_tests; + +#[cfg(test)] +mod p3_error_tests { + use super::*; + + #[test] + fn p3_expired_artifact_is_state_failure_with_original_message() { + let error = to_mcp_error(DevupError::new( + ErrorCode::DevupFigmaHandoffExpired, + "The Figma artifact is missing or expired.", + true, + )); + assert_eq!(error.code, McpErrorCode::INTERNAL_ERROR); + assert_eq!(error.message, "The Figma artifact is missing or expired."); + } + + #[test] + fn p3_output_root_violation_is_invalid_params() { + let policy = OutputPolicy::from_roots(vec![std::env::current_dir().unwrap()]).unwrap(); + let error = policy.resolve("../outside.svg").err().unwrap(); + assert_eq!(to_mcp_error(error).code, McpErrorCode::INVALID_PARAMS); + } +} + +#[cfg(test)] +mod r7_error_tests { + use super::*; + #[test] + fn r7_errors_have_the_same_build_identity_as_success() { + let success = serde_json::to_value(tool_result(json!({"status":"complete"}))).unwrap(); + let success: Value = + serde_json::from_str(success["content"][0]["text"].as_str().unwrap()).unwrap(); + for code in [ + ErrorCode::DevupInvalidInput, + ErrorCode::DevupSnapshotUnsupported, + ErrorCode::DevupFigmaHandoffExpired, + ] { + let error = to_mcp_error(DevupError::new(code, "failure", false)); + assert_eq!(error.data.as_ref().unwrap()["server"], success["server"]); + } + } + #[test] + fn r7_plugin_expiry_has_recovery_and_keeps_its_stage() { + let raw = json!({"isError":true,"content":[{"type":"text","text":"DEVUP_FIGMA_HANDOFF_EXPIRED"}]}); + let error = to_mcp_error(operation::upstream_error(&raw).unwrap()); + let data = error.data.unwrap(); + assert_eq!(data["code"], "DEVUP_FIGMA_HANDOFF_EXPIRED"); + assert_eq!(data["details"]["stage"], "plugin-execution"); + assert_eq!(data["details"]["nextAction"]["arguments"]["refresh"], true); + } + + #[test] + fn r7_expired_artifact_explains_refresh_arguments() { + let error = to_mcp_error(DevupError::new( + ErrorCode::DevupFigmaHandoffExpired, + "expired", + true, + )); + let d = &error.data.as_ref().unwrap()["details"]; + assert_eq!(d["stage"], "artifact-lookup"); + assert_eq!(d["nextAction"]["tool"], "devup_figma_export"); + assert_eq!(d["nextAction"]["arguments"]["refresh"], true); + assert!( + d["nextAction"]["omitArguments"] + .as_array() + .unwrap() + .contains(&json!("artifactId")) + ); + } +} + +#[cfg(test)] +mod r8_recovery_tests { + use super::*; + #[test] + fn r8_expired_artifact_returns_known_url_or_explicit_unrecoverable_state() { + for known in [true, false] { + let details = if known { + json!({"artifactState":"expired","recoveryArguments":{"url":"https://www.figma.com/design/test?node-id=1-2","frameIds":["2:1"],"refresh":true}}) + } else { + json!({}) + }; + let e = to_mcp_error(DevupError::with_details( + ErrorCode::DevupFigmaHandoffExpired, + "expired", + true, + details, + )); + let d = &e.data.unwrap()["details"]; + if known { + assert_eq!( + d["nextAction"]["arguments"]["url"], + "https://www.figma.com/design/test?node-id=1-2" + ); + } else { + assert_eq!(d["artifactState"], "unknown"); + assert_eq!(d["recoveryState"], "unrecoverable"); + } + } + } +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/mod_parts/p2_recovery_tests.rs b/crates/devup-mcp/src/server/mod_parts/p2_recovery_tests.rs new file mode 100644 index 00000000..294f18ad --- /dev/null +++ b/crates/devup-mcp/src/server/mod_parts/p2_recovery_tests.rs @@ -0,0 +1,131 @@ + + +#[cfg(test)] +mod p2_recovery_tests { + use super::*; + + #[test] + fn an_id_from_another_process_says_so_and_cannot_be_reprojected() { + let error = to_mcp_error(DevupError::with_details( + ErrorCode::DevupFigmaHandoffExpired, + "The Figma artifact is missing or expired.", + true, + json!({"artifactState":"issued-by-another-process"}), + )); + let details = &error.data.unwrap()["details"]; + assert_eq!(details["recovery"]["cause"], "issued-by-another-process"); + assert_eq!(details["recovery"]["reprojectable"], false); + assert_eq!(details["recoveryState"], "unrecoverable"); + assert!( + details["recoveryReason"] + .as_str() + .unwrap() + .contains("different devup-mcp process") + ); + } + + #[test] + fn resource_misses_distinguish_reproject_reread_and_recollect() { + let read = |details: Value, code| { + let mut error = DevupError::new(code, "missing", false); + error.details = details; + resource_read_error(error).data.unwrap()["recovery"].clone() + }; + let output = read( + json!({"resourceKind":"output","artifactState":"live"}), + ErrorCode::DevupFigmaHandoffInvalid, + ); + assert_eq!(output["mode"], "reproject"); + assert_eq!(output["reprojectable"], true); + let chunk = read( + json!({"resourceKind":"chunk","artifactState":"live","chunkCount":2}), + ErrorCode::DevupFigmaHandoffInvalid, + ); + assert_eq!(chunk["mode"], "reread-manifest"); + let expired = read( + json!({"artifactState":"expired","recoveryArguments":{"url":"u","refresh":true}}), + ErrorCode::DevupFigmaHandoffExpired, + ); + assert_eq!(expired["mode"], "recollect"); + assert_eq!(expired["requiresOriginalUrl"], false); + let foreign = read( + json!({"resourceKind":"artifact","artifactState":"issued-by-another-process"}), + ErrorCode::DevupFigmaHandoffInvalid, + ); + assert_eq!(foreign["cause"], "issued-by-another-process"); + assert_eq!(foreign["requiresOriginalUrl"], true); + let invalid = read(json!(null), ErrorCode::DevupFigmaHandoffInvalid); + assert_eq!(invalid["mode"], "fix-uri"); + } +} + +#[cfg(test)] +mod section_retry_tests { + use super::*; + use devup_mcp_devup_ui::codegen::RootLayout; + + fn export(frame_ids: Vec) -> PendingOperation { + PendingOperation::Export { + outputs: vec!["tsx".into()], + previous_design_fingerprints: None, + component_name: None, + include_diagnostics: false, + root_layout: RootLayout::Standalone, + asset_names_per_node: true, + scope: "node".into(), + theme_options: Default::default(), + strict: false, + output_paths: Default::default(), + page_scaffold: None, + frame_ids, + all_screens: false, + asset_captures: vec![], + web_ready_images: false, + asset_output_paths: Default::default(), + asset_public_root: None, + fields: vec![], + response_selection: Default::default(), + delivery: DeliveryMode::Auto, + width_policy: None, + } + } + + fn target() -> FigmaTarget { + FigmaTarget { + file_key: "abc".into(), + node_id: Some("1:2".into()), + branch_key: None, + } + } + + #[test] + fn frame_link_is_retried_against_its_section_with_the_frame_selected() { + let error = DevupError::with_details( + ErrorCode::DevupSnapshotUnsupported, + "DEVUP_SECTION_REQUIRED", + false, + json!({"pluginCode":"DEVUP_SECTION_REQUIRED","sectionId":"9:9","nodeId":"1:2"}), + ); + let request = CollectionRequest::new(target(), CollectionScope::Node); + let (operation, request, note) = section_retry(&error, &export(vec![]), &request).unwrap(); + assert_eq!(request.target.node_id.as_deref(), Some("9:9")); + assert_eq!(request.section.unwrap().frame_ids, vec!["1:2".to_owned()]); + assert!( + matches!(operation, PendingOperation::Export { frame_ids, .. } if frame_ids == ["1:2"]) + ); + assert_eq!(note["sectionId"], "9:9"); + } + + #[test] + fn no_section_or_other_codes_are_not_retried() { + let request = CollectionRequest::new(target(), CollectionScope::Node); + for details in [ + json!({"pluginCode":"DEVUP_SECTION_REQUIRED","sectionId":null}), + json!({"pluginCode":"DEVUP_NODE_NOT_FOUND","sectionId":"9:9"}), + ] { + let error = + DevupError::with_details(ErrorCode::DevupSnapshotUnsupported, "x", false, details); + assert!(section_retry(&error, &export(vec![]), &request).is_none()); + } + } +} diff --git a/crates/devup-mcp/src/server/mod_parts/state_and_services.rs b/crates/devup-mcp/src/server/mod_parts/state_and_services.rs new file mode 100644 index 00000000..47a466f4 --- /dev/null +++ b/crates/devup-mcp/src/server/mod_parts/state_and_services.rs @@ -0,0 +1,484 @@ +#[path = "../artifacts.rs"] +pub mod artifacts; +#[path = "../asset_jobs.rs"] +mod asset_jobs; +#[path = "../audit_plan.rs"] +mod audit_plan; +#[path = "../call_cache.rs"] +mod call_cache; +#[path = "../compact.rs"] +mod compact; +#[path = "../delivery.rs"] +pub mod delivery; +#[path = "../design_drift.rs"] +mod design_drift; +#[path = "../diagnostics.rs"] +mod diagnostics; +#[path = "../export_record.rs"] +mod export_record; +#[path = "../feature_trace.rs"] +mod feature_trace; +#[cfg(test)] +#[path = "../feedback_interactions_tests.rs"] +mod feedback_interactions_tests; +#[path = "../field_select.rs"] +mod field_select; +#[path = "../framework.rs"] +mod framework; +#[path = "../guide.rs"] +mod guide; +#[path = "../interactions.rs"] +mod interactions; +#[path = "../mask_guard.rs"] +mod mask_guard; +#[path = "../measurement.rs"] +mod measurement; +#[path = "../navigation.rs"] +mod navigation; +#[path = "../operation.rs"] +pub mod operation; +#[path = "../output.rs"] +pub mod output; +#[path = "../pacing.rs"] +mod pacing; +#[path = "../project_context.rs"] +mod project_context; +#[path = "../project_plan/mod.rs"] +mod project_plan; +#[path = "../project_root.rs"] +mod project_root; +#[path = "../projection.rs"] +mod projection; +#[path = "../quality.rs"] +mod quality; +#[path = "../release_check.rs"] +mod release_check; +#[path = "../resources.rs"] +pub mod resources; +#[path = "../result_contract.rs"] +mod result_contract; +#[path = "../review.rs"] +mod review; +#[path = "../runtime_audit.rs"] +mod runtime_audit; +#[path = "../screen_plan.rs"] +mod screen_plan; +#[path = "../screenshot_observations.rs"] +mod screenshot_observations; +#[path = "../section_checklist.rs"] +mod section_checklist; +#[path = "../self_update.rs"] +pub mod self_update; +#[path = "../skills.rs"] +mod skills; +#[cfg(test)] +#[path = "../source_limits.rs"] +mod source_limits; +#[path = "../stack_diff.rs"] +mod stack_diff; +#[path = "../tools.rs"] +mod tools; +#[path = "../validation.rs"] +mod validation; +#[path = "../validation_guidance.rs"] +mod validation_guidance; +#[path = "../verdict_scope.rs"] +mod verdict_scope; +#[path = "../visual_compare.rs"] +mod visual_compare; + +use std::sync::Arc; + +use async_trait::async_trait; +use rmcp::{ + ErrorData, RoleServer, ServerHandler, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ + CallToolResult, ErrorCode as McpErrorCode, Implementation, JsonObject, ServerCapabilities, + ServerInfo, + }, + service::RequestContext, + tool, tool_handler, tool_router, +}; +use serde_json::{Value, json}; + +use devup_mcp_devup_ui::theme::ThemeScope; +use devup_mcp_figma::{ + AuthStatus, BRIDGE_CURRENT_KEY, BridgeFigmaClient, BridgeOptions, BridgeRole, BridgeServer, + ClientCredentialSource, ClientCredentials, CollectedParts, CollectedPayload, CollectionRequest, + CollectionScope, CollectorSession, CollectorStep, CredentialStore, DEFAULT_CLIENT_NAME, + DevupError, DirectPathSnapshot, ErrorCode, ExploreCandidate, ExploreKind, ExploreNode, + ExploreReadOptions, FallbackUpstream, FigmaTarget, FigmaUpstream, KeyringClientCredentialStore, + KeyringCredentialStore, OAuthManager, ReadToolCall, RemoteFigmaClient, ResourceScope, + SearchReadOptions, SecretString, SectionCandidate, SectionIndex, SectionReadOptions, Snapshot, + SystemBrowser, TokenState, UpstreamResult, +}; + +use artifacts::{ArtifactKind, ArtifactRequestKey, ArtifactStore}; +use call_cache::CallCache; +use delivery::{DeliveryMode, tool_result}; +use operation::PendingOperation; +use output::OutputPolicy; +use pacing::CallPacer; +use projection::complete_operation; +use validation::{ + parse_asset_requests, parse_collection_scope, parse_root_layout, validate_artifact_projection, + validate_outputs, +}; + +pub use tools::{ + AuthInput, FigmaAssetRequestInput, FigmaExploreInput, FigmaExportInput, FigmaSearchInput, + ProjectContextInput, SkillsInput, StackDiffInput, UiValidateInput, +}; + +/// Additive workflow options; existing input defaults remain in tools.rs. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct FigmaExportWorkflowInput { + #[serde(flatten)] + pub input: FigmaExportInput, + /// Opt in to rewriting exported asset references. Existing absolute local + /// directory served at URL /. Only exported files below it are mapped; + /// URL path segments are percent-encoded. Omit to keep placeholder paths. + #[serde(default)] + pub asset_public_root: Option, + + /// Poll a server-local asset job. Omit url/artifactId/assetRequests when polling. + #[serde(default)] + pub job_id: Option, + /// status (default) or resume a paused asset job without repeating accepted reads. + #[serde(default)] + #[schemars(extend("enum" = ["status", "resume", "cancel"]))] + pub job_action: Option, +} + +const FIGMA_ENDPOINT: &str = "https://mcp.figma.com/mcp"; + +/// The most matches `devup_figma_search` and screen candidates +/// `devup_figma_explore` rank before the caller's `limit` cuts the answer. +/// +/// Both tools rank first and cut afterwards, so the ranked list has to be +/// built past `limit` or the cut decides the answer. In search it decided it +/// wrongly: five frames share the name `Loading` across the file, ranking put +/// the two outside the linked Section first, and a `limit` of five removed +/// both of the frames the caller had actually pointed at. This is also the +/// ceiling both tools already validate `limit` against, so nothing above it +/// could have been asked for. +const MATCH_CEILING: usize = 100; + +/// The byte-and-node budget every `devup_figma_explore` collection is given, +/// which is the highest the collection script accepts. It is a constant on +/// purpose - see the comment where it is used. +const EXPLORE_PROJECTION_LIMIT: usize = 400; + +#[async_trait] +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, + )) + } +} + +/// The system browser, with the URL said aloud first. +/// +/// Two logins in a row timed out waiting for a callback that never came, and +/// nothing said whether the browser had opened at all. `webbrowser::open` +/// reports failure to launch but not a launch into a window nobody is +/// looking at, and the URL it was handed was kept nowhere. So it is logged +/// before the browser is asked: if the tab does not appear, the URL is in +/// stderr to be opened by hand. Nothing in it is a secret — the verifier +/// stays in the process, and the challenge, state and client_id are what +/// the address bar shows anyway. +struct SpokenBrowser; + +impl devup_mcp_figma::BrowserOpener for SpokenBrowser { + fn open(&self, authorization_url: &str) -> Result<(), DevupError> { + // stderr is where this binary's traces go; stdout is MCP frames only. + eprintln!( + "devup-mcp: opening the browser for Figma authorization. \ + If no tab appears, open this URL by hand:\n{authorization_url}" + ); + SystemBrowser.open(authorization_url) + } +} + +#[async_trait] +impl DevupAuth for OAuthManager { + async fn status(&self) -> Result { + OAuthManager::status(self).await + } + + async fn login(&self) -> Result { + OAuthManager::login(self, &SpokenBrowser).await?; + Ok(AuthStatus::Connected) + } + + async fn logout(&self) -> Result { + 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)] +pub struct Services { + auth: Arc, + upstream: Arc, + /// Shared, so that concurrent collections meter against one ceiling rather + /// than one each and together exceed it. + pacer: Arc, + /// Off unless a directory was named, in which case each read that succeeds + /// is kept so a later attempt need not pay for it again. + call_cache: Arc, +} + +impl Services { + pub fn new(auth: Arc, upstream: Arc) -> Self { + Self { + auth, + upstream, + pacer: Arc::new(CallPacer::from_env()), + call_cache: Arc::new(CallCache::from_env()), + } + } + + /// Names the directory to bank calls in, rather than reading it from the + /// environment. A caller running two collections against one bank needs to + /// say which bank without setting a variable the whole process shares. + pub fn with_call_cache_dir( + auth: Arc, + upstream: Arc, + directory: Option, + ) -> Self { + Self { + auth, + upstream, + pacer: Arc::new(CallPacer::from_env()), + call_cache: Arc::new(CallCache::new( + directory, + std::time::Duration::from_secs(48 * 60 * 60), + )), + } + } + + fn production( + figma_direct: crate::FigmaDirectConfig, + bridge: Bridge, + ) -> Result { + // To check what happens without a Figma login while the person stays + // logged in: the login and the client credential both kept in memory, + // empty at start, so the operating system's store is neither read nor + // written. + if std::env::var("DEVUP_FIGMA_CREDENTIAL_STORE").as_deref() == Ok("memory") { + return Self::assemble( + devup_mcp_figma::MemoryCredentialStore::default(), + Arc::new(devup_mcp_figma::MemoryClientCredentialStore::default()), + figma_direct, + bridge, + ); + } + Self::assemble( + KeyringCredentialStore, + Arc::new(KeyringClientCredentialStore), + figma_direct, + bridge, + ) + } + + fn assemble( + store: S, + client_store: Arc, + figma_direct: crate::FigmaDirectConfig, + bridge: Bridge, + ) -> Result { + let mut oauth = OAuthManager::with_endpoint(FIGMA_ENDPOINT, store)? + .with_client_credential_store(client_store); + 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); + } + // Three minutes is enough when the browser opens itself and the person + // is already looking at it. It is not enough when the URL has to be + // carried to them by hand — read from a log, pasted into a chat, opened + // a few minutes later — which is how a login came back "timed out" + // after being approved: the approval arrived at a listener that had + // already closed. Whoever is carrying the URL sets this. + if let Some(seconds) = std::env::var("DEVUP_FIGMA_CALLBACK_TIMEOUT_SECONDS") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|seconds| *seconds > 0) + { + oauth = oauth.with_callback_timeout(std::time::Duration::from_secs(seconds)); + } + 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 remote = RemoteFigmaClient::new(oauth.clone()); + // A script read goes to the bridge when a plugin is reachable - through + // this process's own port, or through the devup-mcp that holds it. With + // no plugin attached every call falls straight through to the remote + // path, so opening the door costs nothing when nobody walks through. + let bridge = match bridge { + Bridge::FromEnvironment => BridgeServer::from_env_with(BridgeOptions { + build_id: Some(crate::build_id().to_owned()), + secret_path: None, + }), + Bridge::Off => None, + }; + let pacer = Arc::new(CallPacer::from_env()); + let upstream: Arc = match bridge { + Some(bridge) => Arc::new( + FallbackUpstream::new( + BridgeFigmaClient::new(bridge.state()).with_port(bridge.port()), + remote, + ) + .with_meter(pacer.clone()), + ), + None => Arc::new(remote), + }; + let mut services = Self::new(Arc::new(oauth), upstream); + services.pacer = pacer; + Ok(services) + } +} + +#[derive(Clone)] +pub struct DevupServer { + tool_router: ToolRouter, + services: Services, + artifacts: ArtifactStore, + output_policy: OutputPolicy, + asset_jobs: asset_jobs::AssetJobs, + /// The MCP client's own name, learned at `initialize` and kept because + /// the tool methods never see the request context. + /// + /// `Arc` so every clone of the server reads the one that was filled in: + /// a per-clone copy would be empty in whichever clone the handshake did + /// not run through, and the skill roots would silently go back to + /// guessing. + client_name: std::sync::Arc>, +} + +impl DevupServer { + /// Which agent runtime is on the other end, for choosing skill roots. + /// + /// `Unknown` before the handshake or from a client that sends no name, + /// and that case installs every convention rather than picking one. + fn client_name(&self) -> Option<&str> { + self.client_name.get().map(String::as_str) + } + + pub fn new(services: Services) -> Self { + Self::with_output_roots( + services, + vec![std::env::current_dir().expect("devup-mcp current directory")], + ) + .expect("devup-mcp output root") + } + + pub fn with_output_roots( + services: Services, + roots: Vec, + ) -> Result { + Ok(Self { + tool_router: Self::tool_router(), + asset_jobs: asset_jobs::AssetJobs::default(), + services, + artifacts: ArtifactStore::default(), + output_policy: OutputPolicy::from_roots(roots)?, + client_name: std::sync::Arc::new(std::sync::OnceLock::new()), + }) + } + + pub fn production_with_config( + roots: Vec, + figma_direct: crate::FigmaDirectConfig, + ) -> Result { + Self::production_with_bridge(roots, figma_direct, Bridge::FromEnvironment) + } + + /// The production stack, with the Devup Bridge opened as `bridge` says. + pub fn production_with_bridge( + roots: Vec, + figma_direct: crate::FigmaDirectConfig, + bridge: Bridge, + ) -> Result { + Self::with_output_roots(Services::production(figma_direct, bridge)?, roots) + } + + /// The production stack for use inside another process - a test, an + /// embedder - so the Devup Bridge stays shut: the plugin's port belongs + /// to the devup-mcp sessions the machine is running. + pub fn production_with_output_roots( + roots: Vec, + ) -> Result { + Self::production_with_bridge(roots, crate::FigmaDirectConfig::default(), Bridge::Off) + } +} + +/// Whether a production server opens the Devup Bridge. +/// +/// Opening it binds the plugin's port, or reads through the devup-mcp that +/// holds it - which is what a serving process should do, and exactly what a +/// process that only checks its own configuration (`--self-check`) or a test +/// building the stack in-process should not: port 1993 belongs to whatever +/// sessions the machine runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bridge { + /// `DEVUP_FIGMA_BRIDGE_PORT` decides, as in a serving process. + FromEnvironment, + Off, +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/navigation.rs b/crates/devup-mcp/src/server/navigation.rs index 08d6f589..4e56bd76 100644 --- a/crates/devup-mcp/src/server/navigation.rs +++ b/crates/devup-mcp/src/server/navigation.rs @@ -348,7 +348,7 @@ pub(super) fn report(source: &str, inventory: &Value, rendered_html: Option<&str finding( "fixed-navigation-marker", "info", - "Navigation candidate has a constant underline/highlight; it may be decorative. Confirm its purpose and, if it denotes the active menu, derive it from route/state (usePathname for GNB). Active state and runtime behavior remain unverified.", + include_str!("text/navigation-report-928037dc.md"), ); } if let Some(href) = element["href"] diff --git a/crates/devup-mcp/src/server/operation.rs b/crates/devup-mcp/src/server/operation.rs index a45245b7..9d6de4cc 100644 --- a/crates/devup-mcp/src/server/operation.rs +++ b/crates/devup-mcp/src/server/operation.rs @@ -142,7 +142,7 @@ pub(crate) fn upstream_error(value: &Value) -> Option { // 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.", + "recovery": include_str!("text/operation-upstream_error-3ae5dbed.md"), "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 @@ -209,7 +209,7 @@ pub(crate) fn upstream_error(value: &Value) -> Option { } else if message.contains("DEVUP_SECTION_REQUIRED") { details = json!({"pluginCode":"DEVUP_SECTION_REQUIRED","stage":"section-index","sectionId":null, "nextAction":{"tool":"devup_figma_export","requiredArguments":["url"], - "how":"The url must point to a SECTION, while frameIds selects frames inside it. Open the requested frame's containing SECTION in Figma and copy its link. This legacy response did not include its ancestor SECTION ID."}}); + "how":include_str!("text/operation-upstream_error-e5dda23e.md")}}); } if let Some(code) = &code { if details["pluginCode"].is_null() { @@ -264,7 +264,7 @@ fn plugin_next_action(code: &str) -> Option { match code { "DEVUP_NODE_NOT_FOUND" => Some(json!({ "tool": "devup_figma_search", - "how": "The node-id in this url does not exist in this file. Figma links can outlive the node they pointed at, and a link copied from a comment or a requirement often names a node that has since been renamed or deleted. Search this file by name for the screen you meant and use the canonicalUrl the search returns.", + "how": include_str!("text/operation-plugin_next_action-021029b8.md"), "requiredArguments": ["url", "query"] })), "DEVUP_PAGE_NOT_FOUND" => Some(json!({ @@ -278,14 +278,14 @@ fn plugin_next_action(code: &str) -> Option { "doNot": "Do not raise limit to recover this; limit bounds the answer, not the collection." })), "DEVUP_BRIDGE_UNKNOWN_SCRIPT" => Some(json!({ - "how": "The Devup Bridge plugin running in Figma was built before this devup-mcp and does not know this read. Update the checkout it was imported from, or import plugin/manifest.json from a current one (Plugins -> Development -> Import plugin from manifest...), run the plugin again on the file, then retry. Until then this read goes to the metered direct path only when the plugin reported the file's Figma key and devup_figma_auth login has been done." + "how": include_str!("text/operation-plugin_next_action-847e92e7.md") })), "DEVUP_REFERENCE_PNG_TOO_LARGE" => Some(json!({ - "how": "This node's PNG is larger than the 8 MiB one Devup Bridge answer carries. The direct path takes up to 16 MiB once the file's Figma key is known: run devup_figma_auth login, and if this export had no Figma link, repeat it with the file's own link (Share -> Copy link). Otherwise export a smaller frame." + "how": include_str!("text/operation-plugin_next_action-d622713f.md") })), "DEVUP_SECTION_INDEX_TOO_LARGE" => Some(json!({ "tool": "devup_figma_search", - "how": "This Section's candidate list does not fit the plugin transport. The list is never truncated to fit, because dropping a candidate would hide a screen the caller can select; text previews are already surrendered first. Narrow the target instead: search this Section by name for the screen you want, or open a nested Section and export from there.", + "how": include_str!("text/operation-plugin_next_action-8366c796.md"), "doNot": "Do not retry the same Section URL. The ceiling is a property of this Section's size, so the same request produces the same refusal.", "requiredArguments": ["url", "query"] })), diff --git a/crates/devup-mcp/src/server/project_context.rs b/crates/devup-mcp/src/server/project_context.rs index 24ec7d0e..45e477ff 100644 --- a/crates/devup-mcp/src/server/project_context.rs +++ b/crates/devup-mcp/src/server/project_context.rs @@ -8,1155 +8,6 @@ //! 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}; - -#[cfg(test)] -#[path = "project_context_ui_tests.rs"] -mod ui_tests; - -#[path = "project_context_ui.rs"] -mod ui; - -#[path = "project_context_export.rs"] -mod export; -pub(super) use export::attach as attach_export_context; -pub(super) use export::export_context; - -pub(super) fn routes_for_validation(project_root: Option<&str>) -> Value { - project_root.map(export_context).map(|v|v["ui"].clone()) - .unwrap_or_else(||json!({"found":false,"reason":"No explicit projectRoot supplied; route validation skipped."})) -} - -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, -}; - -/// `(authority, appliesTo)` for a file the scan kept: whether it sits at -/// the project root or belongs to one package inside it, and the directory -/// it governs. A monorepo has no project-wide `devup.json`, and saying -/// which directory each one covers is what lets a caller pick correctly. -fn file_authority(root: &Path, file: &Path) -> (&'static str, String) { - let directory = file.parent().unwrap_or(root); - if directory == root { - ("project-root", ".".to_owned()) - } else { - ("package-local", relative_display(root, directory)) - } -} - -/// Adds `excludedPaths` (what the scan dropped and why) and, when more -/// than one file survived, `authorityNote` (that none of them governs the -/// whole project). Both appear only when they have something to say, so an -/// unambiguous single-file project's response is unchanged. -fn attach_scan_notes(response: &mut Value, excluded: Vec, kept: usize, filename: &str) { - let Some(object) = response.as_object_mut() else { - return; - }; - if !excluded.is_empty() { - object.insert("excludedPaths".to_owned(), Value::Array(excluded)); - } - if kept > 1 { - object.insert( - "authorityNote".to_owned(), - json!(format!( - "This project has {kept} {filename} files and none of them governs all of it. Each applies to the directory in its own appliesTo; use the one whose appliesTo contains the file being written." - )), - ); - } -} - -/// The `{"found": false, ...}` envelope, plus the nested checkouts that -/// were dropped. "Nothing found" and "nothing found that this project -/// owns" are different answers and the caller has to be able to tell them -/// apart — otherwise a project whose only `devup.json` lives in -/// `.worktrees/` reads as a project with no theme at all. -fn not_found_with_exclusions(message: &str, searched: Vec, excluded: Vec) -> Value { - let message = if excluded.is_empty() { - message.to_owned() - } else { - format!( - "{message} ({} file(s) were found only inside nested checkouts and skipped — a nested checkout is another branch's copy of this project, not its current source.)", - excluded.len() - ) - }; - let mut response = not_found_response(message, searched); - if !excluded.is_empty() - && let Some(object) = response.as_object_mut() - { - object.insert("excludedPaths".to_owned(), Value::Array(excluded)); - } - 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, -} - -/// Several `devup.json` files and none of them at the project root. Names -/// each one and the `projectRoot` value that selects it, because the -/// caller's next move is to pass one of them. -fn ambiguous_theme_guardrail(root: &Path, candidates: &[PathBuf], excluded: Vec) -> Value { - let choices = candidates - .iter() - .map(|file| { - let (authority, applies_to) = file_authority(root, file); - json!({ - "devupJson": display_path(file), - "projectRoot": display_path(file.parent().unwrap_or(root)), - "authority": authority, - "appliesTo": applies_to, - }) - }) - .collect::>(); - let mut guardrail = guardrail_object( - format!( - "This project has {} devup.json files and none of them is at the project root, so none of them governs all of it. The unknown-token check was skipped rather than run against an arbitrarily chosen one. Call again with projectRoot set to the package whose code is being validated; candidateProjectRoots lists the value that selects each theme.", - candidates.len() - ), - candidates.iter().map(|file| display_path(file)).collect(), - ); - if let Some(object) = guardrail.as_object_mut() { - object.insert("candidateProjectRoots".to_owned(), Value::Array(choices)); - if !excluded.is_empty() { - object.insert("excludedPaths".to_owned(), Value::Array(excluded)); - } - } - guardrail -} - -/// Resolves the theme `devup_ui_validate` should check `$token` references -/// against: the project root's own `devup.json` if present, otherwise the -/// single `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"); - // Nested checkouts are dropped before "the first one found" is taken. - // `find_files_named` sorts, and `.worktrees` sorts ahead of `apps`, so - // on a project with a git worktree this used to validate every `$token` - // against a *stale branch's* theme without saying so. - let (file, excluded) = if root_level.is_file() { - (Some(root_level), Vec::new()) - } else { - let (candidates, excluded) = find_files_named(&root, "devup.json", 4); - // More than one theme and none at the project root: nothing here - // governs the whole project, so there is no "the" theme to check - // against. Taking the first sorted one is how a monorepo root - // validated `apps/front` code against `apps/admin`'s theme and - // called every real token `unknown-token` — an *error* verdict that - // then suggested the other app's token as the fix. Skipping the - // check and naming the choices is the honest answer. - if candidates.len() > 1 { - return Ok(ThemeLookup { - theme: None, - guardrail: Some(ambiguous_theme_guardrail(&root, &candidates, excluded)), - }); - } - (candidates.into_iter().next(), excluded) - }; - let Some(file) = file else { - let message = if excluded.is_empty() { - "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.".to_owned() - } else { - format!( - "The only devup.json files under this project ({}) are inside nested checkouts — another branch's copy, not this project's current source — so none was used. $token references cannot be verified, so the unknown-token check is skipped. Do not guess and use tokens that do not exist.", - excluded.len() - ) - }; - return Ok(ThemeLookup { - theme: None, - guardrail: Some(guardrail_object( - message, - 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", "ui", "routes", "all"].contains(&scope) { - return Err(DevupError::new( - ErrorCode::DevupInvalidInput, - "scope must be theme, api, db, ui, routes, or all (ui/routes are opt-in).", - 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 { - let response = - not_found_response(PROJECT_ROOT_NOT_FOUND_MESSAGE, vec![display_path(&start)]); - return Ok(if matches!(scope, "theme" | "all") { - attach_theme_semantics(response) - } else { - response - }); - }; - - match scope { - "theme" => Ok(theme_scope(&root, filter)), - "api" => Ok(api_scope(&root, filter)), - "db" => Ok(db_scope(&root, filter)), - "ui" => { - let mut inventory = ui::run(&root, filter); - inventory["frameworks"] = super::framework::report(&root); - Ok(inventory) - } - "routes" => { - let mut inventory = ui::run(&root, filter); - inventory["frameworks"] = super::framework::report(&root); - inventory.as_object_mut().unwrap().remove("components"); - inventory["scope"] = json!("routes"); - Ok(inventory) - } - "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, excluded) = find_files_named(root, "devup.json", 4); - files.sort(); - files.dedup(); - if files.is_empty() { - return attach_theme_semantics(not_found_with_exclusions( - "No devup.json found. Do not write code by guessing color, typography, length, or shadow token names.", - vec![display_path(&root.join("devup.json"))], - excluded, - )); - } - let mut projects = Vec::new(); - for file in &files { - let relative = relative_display(root, file); - let (authority, applies_to) = file_authority(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::>(); - let catalog = theme.token_catalog(); - let matched_count = catalog.keys().filter(|name| matches_filter(name)).count(); - let mut categories = Map::new(); - for (name, original, returned) in [ - ( - "colors", - serde_json::to_value(&theme.colors).unwrap(), - json!(colors), - ), - ( - "length", - serde_json::to_value(&theme.length).unwrap(), - json!(length), - ), - ( - "shadow", - serde_json::to_value(&theme.shadow).unwrap(), - json!(shadow), - ), - ( - "typography", - serde_json::to_value(&theme.typography).unwrap(), - json!(typography), - ), - ] { - let names = |value: &Value| -> std::collections::BTreeSet { - if name == "typography" { - value - .as_object() - .into_iter() - .flat_map(|m| m.keys().cloned()) - .collect() - } else { - value - .as_object() - .into_iter() - .flat_map(|m| m.values()) - .filter_map(Value::as_object) - .flat_map(|m| m.keys().cloned()) - .collect() - } - }; - let total = names(&original).len(); - let matched = names(&returned).len(); - categories.insert(name.into(), json!({"totalTokenCount":total,"matchedTokenCount":matched, - "state":if total == 0 {"absent-in-source"} else if matched == 0 {"filtered-out"} else {"included"}})); - } - projects.push(json!({ - "path": relative, - "authority": authority, - "appliesTo": applies_to, - "modes": modes, - "tokenCount": theme.token_count(), - "totalTokenCount": theme.token_count(), - "matchedTokenCount": matched_count, - "tokenCountScope":"Distinct token names across all categories and modes in this file before filtering; tokenCount is a compatibility alias of totalTokenCount. matchedTokenCount uses the same deduplication after filtering. Category counts deduplicate names within each category.", - "categories": categories, - "colors": colors, - "typography": typography, - "length": length, - "shadow": shadow, - })); - } - let mut response = json!({ - "found": true, - "scope": "theme", - "projectRoot": display_path(root), - "files": projects, - }); - attach_scan_notes(&mut response, excluded, files.len(), "devup.json"); - attach_theme_semantics(response) -} - -fn attach_theme_semantics(mut response: Value) -> Value { - let Some(object) = response.as_object_mut() else { - return response; - }; - object.insert( - "modeResolution".to_owned(), - json!({ - "order": "active mode -> selected default mode", - "defaultModeSelection": ["default", "light", "first mode in lexical order"], - "note": "A token absent from a mode resolves through this chain. Equal values across modes are a design choice, not a defect." - }), - ); - object.insert( - "responsiveArrayOrder".to_owned(), - json!(["mobile", "sm", "tablet", "lg", "PC"]), - ); - object.insert( - "propConventions".to_owned(), - json!({ - "colorAndLength": "$token", - "typography": "bare token name, no $ prefix" - }), - ); - response -} - -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, excluded) = find_files_named(root, "openapi.json", 4); - if files.is_empty() { - return not_found_with_exclusions( - "No openapi.json found. Do not write code by guessing API endpoint or schema names.", - vec![format!("{} (up to depth 4)", display_path(root))], - excluded, - ); - } - let mut specs = Vec::new(); - for file in &files { - let relative = relative_display(root, file); - let (authority, applies_to) = file_authority(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, - authority, - &applies_to, - &parsed, - filter, - )); - } - let mut response = json!({ - "found": true, - "scope": "api", - "projectRoot": display_path(root), - "specs": specs, - }); - attach_scan_notes(&mut response, excluded, files.len(), "openapi.json"); - response -} - -fn project_openapi_spec( - relative_path: &str, - authority: &str, - applies_to: &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, - "authority": authority, - "appliesTo": applies_to, - "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, excluded) = 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_with_exclusions( - "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) - )], - excluded, - ); - } - 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, - })); - } - let mut response = json!({ - "found": true, - "scope": "db", - "projectRoot": display_path(root), - "tables": tables, - }); - attach_scan_notes(&mut response, excluded, model_dirs.len(), "models/"); - response -} - -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), - } -} - -pub(super) 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" - ); - assert_eq!( - result["modeResolution"]["order"], - "active mode -> selected default mode" - ); - assert_eq!( - result["modeResolution"]["defaultModeSelection"], - json!(["default", "light", "first mode in lexical order"]) - ); - assert_eq!( - result["responsiveArrayOrder"], - json!(["mobile", "sm", "tablet", "lg", "PC"]) - ); - assert_eq!(result["propConventions"]["colorAndLength"], "$token"); - assert_eq!( - result["propConventions"]["typography"], - "bare token name, no $ prefix" - ); - } - - #[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"); - assert!(result.get("modeResolution").is_some()); - } - - #[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); - } - - /// Writes the `.worktrees//` shape `git worktree add` produces: - /// a second copy of the project, carrying its own `.git` (a *file* for - /// a worktree, which is why the check uses `exists()` and not - /// `is_dir()`). - fn write_nested_worktree(root: &Path, relative_file: &str, contents: &str) { - let checkout = root.join(".worktrees").join("revert-some-branch"); - std::fs::create_dir_all(&checkout).unwrap(); - std::fs::write(checkout.join(".git"), "gitdir: ../../.git/worktrees/x").unwrap(); - let file = checkout.join(relative_file); - std::fs::create_dir_all(file.parent().unwrap()).unwrap(); - std::fs::write(file, contents).unwrap(); - } - - #[tokio::test] - async fn theme_scope_skips_devup_json_inside_a_nested_worktree() { - let temp = ScopedTempDir::new("theme-worktree"); - std::fs::write(temp.path().join("package.json"), "{}").unwrap(); - let real = temp.path().join("apps").join("front"); - std::fs::create_dir_all(&real).unwrap(); - std::fs::write( - real.join("devup.json"), - r##"{ "theme": { "colors": { "default": { "current": "#111111" } } } }"##, - ) - .unwrap(); - write_nested_worktree( - temp.path(), - "apps/front/devup.json", - r##"{ "theme": { "colors": { "default": { "stale": "#222222" } } } }"##, - ); - - // Both copies are accounted for by the shared scan, but only the - // current project's copy can be used as an authoritative input. - let (found, excluded) = find_files_named(temp.path(), "devup.json", 4); - assert_eq!(found, vec![real.join("devup.json")]); - assert_eq!( - excluded, - vec![json!({ - "path": ".worktrees/revert-some-branch/apps/front/devup.json", - "reason": "nested-checkout-directory", - })] - ); - - let result = run("theme", Some(&temp.path().to_string_lossy()), None) - .await - .unwrap(); - let files = result["files"].as_array().unwrap(); - assert_eq!(files.len(), 1, "{result}"); - assert_eq!(files[0]["path"], "apps/front/devup.json"); - assert_eq!(files[0]["appliesTo"], "apps/front"); - assert_eq!(files[0]["authority"], "package-local"); - assert!( - files[0]["colors"]["default"].get("stale").is_none(), - "a stale branch's token must never be reported: {result}" - ); - let excluded = result["excludedPaths"].as_array().unwrap(); - assert_eq!(excluded.len(), 1); - assert!( - excluded[0]["path"] - .as_str() - .unwrap() - .contains(".worktrees/"), - "{result}" - ); - } - - #[tokio::test] - async fn api_and_db_scopes_skip_nested_worktrees_too() { - let temp = ScopedTempDir::new("api-db-worktree"); - std::fs::write(temp.path().join("package.json"), "{}").unwrap(); - let api = temp.path().join("apis").join("api"); - std::fs::create_dir_all(api.join("models")).unwrap(); - std::fs::write( - api.join("openapi.json"), - r##"{ "paths": { "/current": { "get": { "operationId": "current" } } } }"##, - ) - .unwrap(); - std::fs::write( - api.join("models").join("user.json"), - r##"{ "name": "user", "columns": [ { "name": "id", "type": "uuid" } ] }"##, - ) - .unwrap(); - write_nested_worktree( - temp.path(), - "apis/api/openapi.json", - r##"{ "paths": { "/stale": { "get": { "operationId": "stale" } } } }"##, - ); - write_nested_worktree( - temp.path(), - "apis/api/models/ghost.json", - r##"{ "name": "ghost", "columns": [ { "name": "id", "type": "uuid" } ] }"##, - ); - - let api_result = run("api", Some(&temp.path().to_string_lossy()), None) - .await - .unwrap(); - let specs = api_result["specs"].as_array().unwrap(); - assert_eq!(specs.len(), 1, "{api_result}"); - assert_eq!(specs[0]["path"], "apis/api/openapi.json"); - assert_eq!(specs[0]["endpoints"][0]["operationId"], "current"); - assert_eq!(api_result["excludedPaths"].as_array().unwrap().len(), 1); - - let db_result = run("db", Some(&temp.path().to_string_lossy()), None) - .await - .unwrap(); - let tables = db_result["tables"].as_array().unwrap(); - assert_eq!(tables.len(), 1, "{db_result}"); - assert_eq!(tables[0]["table"], "user"); - assert_eq!(db_result["excludedPaths"].as_array().unwrap().len(), 1); - } - - #[tokio::test] - async fn theme_for_validation_never_picks_a_stale_branch_over_the_real_one() { - // `find_files_named` sorts, and `.worktrees` sorts ahead of `apps`, - // so "the first devup.json found" used to be the stale branch's. - let temp = ScopedTempDir::new("validation-worktree"); - std::fs::write(temp.path().join("package.json"), "{}").unwrap(); - let real = temp.path().join("apps").join("front"); - std::fs::create_dir_all(&real).unwrap(); - std::fs::write( - real.join("devup.json"), - r##"{ "theme": { "colors": { "default": { "current": "#111111" } } } }"##, - ) - .unwrap(); - write_nested_worktree( - temp.path(), - "apps/front/devup.json", - r##"{ "theme": { "colors": { "default": { "stale": "#222222" } } } }"##, - ); - - let lookup = theme_for_validation(Some(&temp.path().to_string_lossy())).unwrap(); - let theme = lookup.theme.expect("the project's own devup.json"); - assert!(theme.contains_token("current")); - assert!( - !theme.contains_token("stale"), - "validated against a nested checkout's theme" - ); - } - - #[tokio::test] - async fn a_theme_that_exists_only_in_a_nested_checkout_says_so() { - let temp = ScopedTempDir::new("only-worktree-theme"); - std::fs::write(temp.path().join("package.json"), "{}").unwrap(); - write_nested_worktree( - temp.path(), - "apps/front/devup.json", - r##"{ "theme": { "colors": { "default": { "stale": "#222222" } } } }"##, - ); - - let result = run("theme", Some(&temp.path().to_string_lossy()), None) - .await - .unwrap(); - assert_eq!(result["found"], false); - assert!( - result["guardrail"]["message"] - .as_str() - .unwrap() - .contains("nested checkout"), - "{result}" - ); - assert_eq!(result["excludedPaths"].as_array().unwrap().len(), 1); - } - - #[tokio::test] - async fn nested_only_theme_reports_each_checkout_kind_without_parsing_it() { - for (directory, git_marker, reason) in [ - (".worktrees/stale", None, "nested-checkout-directory"), - (".worktree/stale", None, "nested-checkout-directory"), - (".git-worktrees/stale", None, "nested-checkout-directory"), - ("copies/repo", Some(true), "nested-git-checkout"), - ("copies/linked", Some(false), "nested-git-checkout"), - ] { - let temp = ScopedTempDir::new("nested-only-kinds"); - std::fs::write(temp.path().join("package.json"), "{}").unwrap(); - let checkout = temp.path().join(directory); - std::fs::create_dir_all(&checkout).unwrap(); - match git_marker { - Some(true) => std::fs::create_dir(checkout.join(".git")).unwrap(), - Some(false) => { - std::fs::write(checkout.join(".git"), "gitdir: elsewhere").unwrap(); - } - None => {} - } - // Excluded files must be reported without reading/parsing their contents. - std::fs::write(checkout.join("devup.json"), "not valid JSON").unwrap(); - let root = temp.path().to_string_lossy(); - let result = run("theme", Some(&root), None).await.unwrap(); - assert_eq!(result["found"], false, "{result}"); - assert_eq!( - result["excludedPaths"], - json!([{ "path": format!("{directory}/devup.json"), "reason": reason }]), - "{result}" - ); - let lookup = theme_for_validation(Some(&root)).unwrap(); - assert!(lookup.theme.is_none()); - assert!( - lookup.guardrail.unwrap()["message"] - .as_str() - .unwrap() - .contains("nested checkout") - ); - } - } - - #[tokio::test] - async fn one_theme_needs_no_authority_note_and_several_do() { - let temp = ScopedTempDir::new("authority-note"); - std::fs::write(temp.path().join("package.json"), "{}").unwrap(); - let front = temp.path().join("apps").join("front"); - std::fs::create_dir_all(&front).unwrap(); - std::fs::write(front.join("devup.json"), r##"{"theme":{}}"##).unwrap(); - - let single = run("theme", Some(&temp.path().to_string_lossy()), None) - .await - .unwrap(); - assert!(single.get("authorityNote").is_none(), "{single}"); - assert!(single.get("excludedPaths").is_none(), "{single}"); - - let admin = temp.path().join("apps").join("admin"); - std::fs::create_dir_all(&admin).unwrap(); - std::fs::write(admin.join("devup.json"), r##"{"theme":{}}"##).unwrap(); - let several = run("theme", Some(&temp.path().to_string_lossy()), None) - .await - .unwrap(); - assert!( - several["authorityNote"] - .as_str() - .unwrap() - .contains("appliesTo"), - "{several}" - ); - } - - #[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()); - } -} - -#[cfg(test)] -mod r17_tests { - use super::*; - #[tokio::test] - async fn r17_context_distinguishes_total_matches_and_empty_categories() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/ground-truth-project"); - let all = run("theme", root.to_str(), None).await.unwrap(); - let filtered = run("theme", root.to_str(), Some("NO_MATCH")).await.unwrap(); - let f = &filtered["files"][0]; - assert_eq!(f["totalTokenCount"], all["files"][0]["tokenCount"]); - assert_eq!(f["matchedTokenCount"], 0); - assert_eq!(f["categories"]["colors"]["state"], "filtered-out"); - let schema = - serde_json::to_value(schemars::schema_for!(super::super::ProjectContextInput)).unwrap(); - let description = schema["properties"]["filter"]["description"] - .as_str() - .unwrap_or(""); - assert!( - description.contains("case-sensitive") - && description.contains("substring") - && description.contains("not a regular expression") - ); - } -} +// Source sections share this module to preserve privacy and test names. +include!("project_context/context_and_inventory.rs"); +include!("project_context/tests.rs"); diff --git a/crates/devup-mcp/src/server/project_context/context_and_inventory.rs b/crates/devup-mcp/src/server/project_context/context_and_inventory.rs new file mode 100644 index 00000000..59493f45 --- /dev/null +++ b/crates/devup-mcp/src/server/project_context/context_and_inventory.rs @@ -0,0 +1,724 @@ + +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}; + +#[cfg(test)] +#[path = "../project_context_ui_tests.rs"] +mod ui_tests; + +#[path = "../project_context_ui.rs"] +mod ui; + +#[path = "../project_context_export.rs"] +mod export; +pub(super) use export::attach as attach_export_context; +pub(super) use export::export_context; + +pub(super) fn routes_for_validation(project_root: Option<&str>) -> Value { + project_root.map(export_context).map(|v|v["ui"].clone()) + .unwrap_or_else(||json!({"found":false,"reason":"No explicit projectRoot supplied; route validation skipped."})) +} + +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, +}; + +/// `(authority, appliesTo)` for a file the scan kept: whether it sits at +/// the project root or belongs to one package inside it, and the directory +/// it governs. A monorepo has no project-wide `devup.json`, and saying +/// which directory each one covers is what lets a caller pick correctly. +fn file_authority(root: &Path, file: &Path) -> (&'static str, String) { + let directory = file.parent().unwrap_or(root); + if directory == root { + ("project-root", ".".to_owned()) + } else { + ("package-local", relative_display(root, directory)) + } +} + +/// Adds `excludedPaths` (what the scan dropped and why) and, when more +/// than one file survived, `authorityNote` (that none of them governs the +/// whole project). Both appear only when they have something to say, so an +/// unambiguous single-file project's response is unchanged. +fn attach_scan_notes(response: &mut Value, excluded: Vec, kept: usize, filename: &str) { + let Some(object) = response.as_object_mut() else { + return; + }; + if !excluded.is_empty() { + object.insert("excludedPaths".to_owned(), Value::Array(excluded)); + } + if kept > 1 { + object.insert( + "authorityNote".to_owned(), + json!(format!( + include_str!("../text/project_context-attach_scan_notes-4b303bac.md"), + filename = filename, + kept = kept + )), + ); + } +} + +/// The `{"found": false, ...}` envelope, plus the nested checkouts that +/// were dropped. "Nothing found" and "nothing found that this project +/// owns" are different answers and the caller has to be able to tell them +/// apart — otherwise a project whose only `devup.json` lives in +/// `.worktrees/` reads as a project with no theme at all. +fn not_found_with_exclusions(message: &str, searched: Vec, excluded: Vec) -> Value { + let message = if excluded.is_empty() { + message.to_owned() + } else { + format!( + "{message} ({} file(s) were found only inside nested checkouts and skipped — a nested checkout is another branch's copy of this project, not its current source.)", + excluded.len() + ) + }; + let mut response = not_found_response(message, searched); + if !excluded.is_empty() + && let Some(object) = response.as_object_mut() + { + object.insert("excludedPaths".to_owned(), Value::Array(excluded)); + } + 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, +} + +/// Several `devup.json` files and none of them at the project root. Names +/// each one and the `projectRoot` value that selects it, because the +/// caller's next move is to pass one of them. +fn ambiguous_theme_guardrail(root: &Path, candidates: &[PathBuf], excluded: Vec) -> Value { + let choices = candidates + .iter() + .map(|file| { + let (authority, applies_to) = file_authority(root, file); + json!({ + "devupJson": display_path(file), + "projectRoot": display_path(file.parent().unwrap_or(root)), + "authority": authority, + "appliesTo": applies_to, + }) + }) + .collect::>(); + let mut guardrail = guardrail_object( + format!( + include_str!("../text/project_context-ambiguous_theme_guardrail-b51035aa.md"), + candidates.len() + ), + candidates.iter().map(|file| display_path(file)).collect(), + ); + if let Some(object) = guardrail.as_object_mut() { + object.insert("candidateProjectRoots".to_owned(), Value::Array(choices)); + if !excluded.is_empty() { + object.insert("excludedPaths".to_owned(), Value::Array(excluded)); + } + } + guardrail +} + +/// Resolves the theme `devup_ui_validate` should check `$token` references +/// against: the project root's own `devup.json` if present, otherwise the +/// single `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"); + // Nested checkouts are dropped before "the first one found" is taken. + // `find_files_named` sorts, and `.worktrees` sorts ahead of `apps`, so + // on a project with a git worktree this used to validate every `$token` + // against a *stale branch's* theme without saying so. + let (file, excluded) = if root_level.is_file() { + (Some(root_level), Vec::new()) + } else { + let (candidates, excluded) = find_files_named(&root, "devup.json", 4); + // More than one theme and none at the project root: nothing here + // governs the whole project, so there is no "the" theme to check + // against. Taking the first sorted one is how a monorepo root + // validated `apps/front` code against `apps/admin`'s theme and + // called every real token `unknown-token` — an *error* verdict that + // then suggested the other app's token as the fix. Skipping the + // check and naming the choices is the honest answer. + if candidates.len() > 1 { + return Ok(ThemeLookup { + theme: None, + guardrail: Some(ambiguous_theme_guardrail(&root, &candidates, excluded)), + }); + } + (candidates.into_iter().next(), excluded) + }; + let Some(file) = file else { + let message = if excluded.is_empty() { + "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.".to_owned() + } else { + format!( + include_str!("../text/project_context-theme_for_validation-ba1b05bd.md"), + excluded.len() + ) + }; + return Ok(ThemeLookup { + theme: None, + guardrail: Some(guardrail_object( + message, + 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", "ui", "routes", "all"].contains(&scope) { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "scope must be theme, api, db, ui, routes, or all (ui/routes are opt-in).", + 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 { + let response = + not_found_response(PROJECT_ROOT_NOT_FOUND_MESSAGE, vec![display_path(&start)]); + return Ok(if matches!(scope, "theme" | "all") { + attach_theme_semantics(response) + } else { + response + }); + }; + + match scope { + "theme" => Ok(theme_scope(&root, filter)), + "api" => Ok(api_scope(&root, filter)), + "db" => Ok(db_scope(&root, filter)), + "ui" => { + let mut inventory = ui::run(&root, filter); + inventory["frameworks"] = super::framework::report(&root); + Ok(inventory) + } + "routes" => { + let mut inventory = ui::run(&root, filter); + inventory["frameworks"] = super::framework::report(&root); + inventory.as_object_mut().unwrap().remove("components"); + inventory["scope"] = json!("routes"); + Ok(inventory) + } + "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, excluded) = find_files_named(root, "devup.json", 4); + files.sort(); + files.dedup(); + if files.is_empty() { + return attach_theme_semantics(not_found_with_exclusions( + "No devup.json found. Do not write code by guessing color, typography, length, or shadow token names.", + vec![display_path(&root.join("devup.json"))], + excluded, + )); + } + let mut projects = Vec::new(); + for file in &files { + let relative = relative_display(root, file); + let (authority, applies_to) = file_authority(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::>(); + let catalog = theme.token_catalog(); + let matched_count = catalog.keys().filter(|name| matches_filter(name)).count(); + let mut categories = Map::new(); + for (name, original, returned) in [ + ( + "colors", + serde_json::to_value(&theme.colors).unwrap(), + json!(colors), + ), + ( + "length", + serde_json::to_value(&theme.length).unwrap(), + json!(length), + ), + ( + "shadow", + serde_json::to_value(&theme.shadow).unwrap(), + json!(shadow), + ), + ( + "typography", + serde_json::to_value(&theme.typography).unwrap(), + json!(typography), + ), + ] { + let names = |value: &Value| -> std::collections::BTreeSet { + if name == "typography" { + value + .as_object() + .into_iter() + .flat_map(|m| m.keys().cloned()) + .collect() + } else { + value + .as_object() + .into_iter() + .flat_map(|m| m.values()) + .filter_map(Value::as_object) + .flat_map(|m| m.keys().cloned()) + .collect() + } + }; + let total = names(&original).len(); + let matched = names(&returned).len(); + categories.insert(name.into(), json!({"totalTokenCount":total,"matchedTokenCount":matched, + "state":if total == 0 {"absent-in-source"} else if matched == 0 {"filtered-out"} else {"included"}})); + } + projects.push(json!({ + "path": relative, + "authority": authority, + "appliesTo": applies_to, + "modes": modes, + "tokenCount": theme.token_count(), + "totalTokenCount": theme.token_count(), + "matchedTokenCount": matched_count, + "tokenCountScope":include_str!("../text/project_context-theme_scope-c1299f17.md"), + "categories": categories, + "colors": colors, + "typography": typography, + "length": length, + "shadow": shadow, + })); + } + let mut response = json!({ + "found": true, + "scope": "theme", + "projectRoot": display_path(root), + "files": projects, + }); + attach_scan_notes(&mut response, excluded, files.len(), "devup.json"); + attach_theme_semantics(response) +} + +fn attach_theme_semantics(mut response: Value) -> Value { + let Some(object) = response.as_object_mut() else { + return response; + }; + object.insert( + "modeResolution".to_owned(), + json!({ + "order": "active mode -> selected default mode", + "defaultModeSelection": ["default", "light", "first mode in lexical order"], + "note": "A token absent from a mode resolves through this chain. Equal values across modes are a design choice, not a defect." + }), + ); + object.insert( + "responsiveArrayOrder".to_owned(), + json!(["mobile", "sm", "tablet", "lg", "PC"]), + ); + object.insert( + "propConventions".to_owned(), + json!({ + "colorAndLength": "$token", + "typography": "bare token name, no $ prefix" + }), + ); + response +} + +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, excluded) = find_files_named(root, "openapi.json", 4); + if files.is_empty() { + return not_found_with_exclusions( + "No openapi.json found. Do not write code by guessing API endpoint or schema names.", + vec![format!("{} (up to depth 4)", display_path(root))], + excluded, + ); + } + let mut specs = Vec::new(); + for file in &files { + let relative = relative_display(root, file); + let (authority, applies_to) = file_authority(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, + authority, + &applies_to, + &parsed, + filter, + )); + } + let mut response = json!({ + "found": true, + "scope": "api", + "projectRoot": display_path(root), + "specs": specs, + }); + attach_scan_notes(&mut response, excluded, files.len(), "openapi.json"); + response +} + +fn project_openapi_spec( + relative_path: &str, + authority: &str, + applies_to: &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, + "authority": authority, + "appliesTo": applies_to, + "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, excluded) = 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_with_exclusions( + "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) + )], + excluded, + ); + } + 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, + })); + } + let mut response = json!({ + "found": true, + "scope": "db", + "projectRoot": display_path(root), + "tables": tables, + }); + attach_scan_notes(&mut response, excluded, model_dirs.len(), "models/"); + response +} + +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), + } +} + +pub(super) 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)) +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/project_context/tests.rs b/crates/devup-mcp/src/server/project_context/tests.rs new file mode 100644 index 00000000..c510c596 --- /dev/null +++ b/crates/devup-mcp/src/server/project_context/tests.rs @@ -0,0 +1,432 @@ + + +#[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" + ); + assert_eq!( + result["modeResolution"]["order"], + "active mode -> selected default mode" + ); + assert_eq!( + result["modeResolution"]["defaultModeSelection"], + json!(["default", "light", "first mode in lexical order"]) + ); + assert_eq!( + result["responsiveArrayOrder"], + json!(["mobile", "sm", "tablet", "lg", "PC"]) + ); + assert_eq!(result["propConventions"]["colorAndLength"], "$token"); + assert_eq!( + result["propConventions"]["typography"], + "bare token name, no $ prefix" + ); + } + + #[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"); + assert!(result.get("modeResolution").is_some()); + } + + #[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); + } + + /// Writes the `.worktrees//` shape `git worktree add` produces: + /// a second copy of the project, carrying its own `.git` (a *file* for + /// a worktree, which is why the check uses `exists()` and not + /// `is_dir()`). + fn write_nested_worktree(root: &Path, relative_file: &str, contents: &str) { + let checkout = root.join(".worktrees").join("revert-some-branch"); + std::fs::create_dir_all(&checkout).unwrap(); + std::fs::write(checkout.join(".git"), "gitdir: ../../.git/worktrees/x").unwrap(); + let file = checkout.join(relative_file); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(file, contents).unwrap(); + } + + #[tokio::test] + async fn theme_scope_skips_devup_json_inside_a_nested_worktree() { + let temp = ScopedTempDir::new("theme-worktree"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let real = temp.path().join("apps").join("front"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write( + real.join("devup.json"), + r##"{ "theme": { "colors": { "default": { "current": "#111111" } } } }"##, + ) + .unwrap(); + write_nested_worktree( + temp.path(), + "apps/front/devup.json", + r##"{ "theme": { "colors": { "default": { "stale": "#222222" } } } }"##, + ); + + // Both copies are accounted for by the shared scan, but only the + // current project's copy can be used as an authoritative input. + let (found, excluded) = find_files_named(temp.path(), "devup.json", 4); + assert_eq!(found, vec![real.join("devup.json")]); + assert_eq!( + excluded, + vec![json!({ + "path": ".worktrees/revert-some-branch/apps/front/devup.json", + "reason": "nested-checkout-directory", + })] + ); + + let result = run("theme", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + let files = result["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{result}"); + assert_eq!(files[0]["path"], "apps/front/devup.json"); + assert_eq!(files[0]["appliesTo"], "apps/front"); + assert_eq!(files[0]["authority"], "package-local"); + assert!( + files[0]["colors"]["default"].get("stale").is_none(), + "a stale branch's token must never be reported: {result}" + ); + let excluded = result["excludedPaths"].as_array().unwrap(); + assert_eq!(excluded.len(), 1); + assert!( + excluded[0]["path"] + .as_str() + .unwrap() + .contains(".worktrees/"), + "{result}" + ); + } + + #[tokio::test] + async fn api_and_db_scopes_skip_nested_worktrees_too() { + let temp = ScopedTempDir::new("api-db-worktree"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let api = temp.path().join("apis").join("api"); + std::fs::create_dir_all(api.join("models")).unwrap(); + std::fs::write( + api.join("openapi.json"), + r##"{ "paths": { "/current": { "get": { "operationId": "current" } } } }"##, + ) + .unwrap(); + std::fs::write( + api.join("models").join("user.json"), + r##"{ "name": "user", "columns": [ { "name": "id", "type": "uuid" } ] }"##, + ) + .unwrap(); + write_nested_worktree( + temp.path(), + "apis/api/openapi.json", + r##"{ "paths": { "/stale": { "get": { "operationId": "stale" } } } }"##, + ); + write_nested_worktree( + temp.path(), + "apis/api/models/ghost.json", + r##"{ "name": "ghost", "columns": [ { "name": "id", "type": "uuid" } ] }"##, + ); + + let api_result = run("api", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + let specs = api_result["specs"].as_array().unwrap(); + assert_eq!(specs.len(), 1, "{api_result}"); + assert_eq!(specs[0]["path"], "apis/api/openapi.json"); + assert_eq!(specs[0]["endpoints"][0]["operationId"], "current"); + assert_eq!(api_result["excludedPaths"].as_array().unwrap().len(), 1); + + let db_result = run("db", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + let tables = db_result["tables"].as_array().unwrap(); + assert_eq!(tables.len(), 1, "{db_result}"); + assert_eq!(tables[0]["table"], "user"); + assert_eq!(db_result["excludedPaths"].as_array().unwrap().len(), 1); + } + + #[tokio::test] + async fn theme_for_validation_never_picks_a_stale_branch_over_the_real_one() { + // `find_files_named` sorts, and `.worktrees` sorts ahead of `apps`, + // so "the first devup.json found" used to be the stale branch's. + let temp = ScopedTempDir::new("validation-worktree"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let real = temp.path().join("apps").join("front"); + std::fs::create_dir_all(&real).unwrap(); + std::fs::write( + real.join("devup.json"), + r##"{ "theme": { "colors": { "default": { "current": "#111111" } } } }"##, + ) + .unwrap(); + write_nested_worktree( + temp.path(), + "apps/front/devup.json", + r##"{ "theme": { "colors": { "default": { "stale": "#222222" } } } }"##, + ); + + let lookup = theme_for_validation(Some(&temp.path().to_string_lossy())).unwrap(); + let theme = lookup.theme.expect("the project's own devup.json"); + assert!(theme.contains_token("current")); + assert!( + !theme.contains_token("stale"), + "validated against a nested checkout's theme" + ); + } + + #[tokio::test] + async fn a_theme_that_exists_only_in_a_nested_checkout_says_so() { + let temp = ScopedTempDir::new("only-worktree-theme"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + write_nested_worktree( + temp.path(), + "apps/front/devup.json", + r##"{ "theme": { "colors": { "default": { "stale": "#222222" } } } }"##, + ); + + let result = run("theme", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert_eq!(result["found"], false); + assert!( + result["guardrail"]["message"] + .as_str() + .unwrap() + .contains("nested checkout"), + "{result}" + ); + assert_eq!(result["excludedPaths"].as_array().unwrap().len(), 1); + } + + #[tokio::test] + async fn nested_only_theme_reports_each_checkout_kind_without_parsing_it() { + for (directory, git_marker, reason) in [ + (".worktrees/stale", None, "nested-checkout-directory"), + (".worktree/stale", None, "nested-checkout-directory"), + (".git-worktrees/stale", None, "nested-checkout-directory"), + ("copies/repo", Some(true), "nested-git-checkout"), + ("copies/linked", Some(false), "nested-git-checkout"), + ] { + let temp = ScopedTempDir::new("nested-only-kinds"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let checkout = temp.path().join(directory); + std::fs::create_dir_all(&checkout).unwrap(); + match git_marker { + Some(true) => std::fs::create_dir(checkout.join(".git")).unwrap(), + Some(false) => { + std::fs::write(checkout.join(".git"), "gitdir: elsewhere").unwrap(); + } + None => {} + } + // Excluded files must be reported without reading/parsing their contents. + std::fs::write(checkout.join("devup.json"), "not valid JSON").unwrap(); + let root = temp.path().to_string_lossy(); + let result = run("theme", Some(&root), None).await.unwrap(); + assert_eq!(result["found"], false, "{result}"); + assert_eq!( + result["excludedPaths"], + json!([{ "path": format!("{directory}/devup.json"), "reason": reason }]), + "{result}" + ); + let lookup = theme_for_validation(Some(&root)).unwrap(); + assert!(lookup.theme.is_none()); + assert!( + lookup.guardrail.unwrap()["message"] + .as_str() + .unwrap() + .contains("nested checkout") + ); + } + } + + #[tokio::test] + async fn one_theme_needs_no_authority_note_and_several_do() { + let temp = ScopedTempDir::new("authority-note"); + std::fs::write(temp.path().join("package.json"), "{}").unwrap(); + let front = temp.path().join("apps").join("front"); + std::fs::create_dir_all(&front).unwrap(); + std::fs::write(front.join("devup.json"), r##"{"theme":{}}"##).unwrap(); + + let single = run("theme", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert!(single.get("authorityNote").is_none(), "{single}"); + assert!(single.get("excludedPaths").is_none(), "{single}"); + + let admin = temp.path().join("apps").join("admin"); + std::fs::create_dir_all(&admin).unwrap(); + std::fs::write(admin.join("devup.json"), r##"{"theme":{}}"##).unwrap(); + let several = run("theme", Some(&temp.path().to_string_lossy()), None) + .await + .unwrap(); + assert!( + several["authorityNote"] + .as_str() + .unwrap() + .contains("appliesTo"), + "{several}" + ); + } + + #[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()); + } +} + +#[cfg(test)] +mod r17_tests { + use super::*; + #[tokio::test] + async fn r17_context_distinguishes_total_matches_and_empty_categories() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/ground-truth-project"); + let all = run("theme", root.to_str(), None).await.unwrap(); + let filtered = run("theme", root.to_str(), Some("NO_MATCH")).await.unwrap(); + let f = &filtered["files"][0]; + assert_eq!(f["totalTokenCount"], all["files"][0]["tokenCount"]); + assert_eq!(f["matchedTokenCount"], 0); + assert_eq!(f["categories"]["colors"]["state"], "filtered-out"); + let schema = + serde_json::to_value(schemars::schema_for!(super::super::ProjectContextInput)).unwrap(); + let description = schema["properties"]["filter"]["description"] + .as_str() + .unwrap_or(""); + assert!( + description.contains("case-sensitive") + && description.contains("substring") + && description.contains("not a regular expression") + ); + } +} diff --git a/crates/devup-mcp/src/server/project_context_export.rs b/crates/devup-mcp/src/server/project_context_export.rs index a7bb8694..66d5b516 100644 --- a/crates/devup-mcp/src/server/project_context_export.rs +++ b/crates/devup-mcp/src/server/project_context_export.rs @@ -110,7 +110,7 @@ pub(crate) fn export_context(project_root: &str) -> Value { let key_count = keys.len(); json!({"ui":ui,"envKeyNames":keys.into_iter().take(200).collect::>(), "envContext":{"valuesIncluded":false,"filesScanned":files.len().min(32),"truncated":files.len()>32 || key_count>200,"keyNamesOmitted":key_count.saturating_sub(200),"maxFiles":32,"maxFileBytes":65_536,"maxKeyNames":200,"excludedPaths":excluded,"diagnostics":diagnostics}, - "reuseGuidance":"Review existing components and import sites before creating new Header/Footer/widgets; compare props and design variants. Environment key presence does not prove provider setup or valid values."}) + "reuseGuidance":include_str!("text/project_context_export-export_context-12e0ec03.md")}) } /// Added context is bounded independently and respects remaining inline space. diff --git a/crates/devup-mcp/src/server/project_context_ui.rs b/crates/devup-mcp/src/server/project_context_ui.rs index 39f8e8e4..df306dde 100644 --- a/crates/devup-mcp/src/server/project_context_ui.rs +++ b/crates/devup-mcp/src/server/project_context_ui.rs @@ -548,7 +548,7 @@ pub(super) fn run(root: &Path, filter: Option<&str>) -> Value { "truncation":{"truncated":false,"capsHit":[],"omitted":{"components":0,"routes":0,"diagnostics":0,"excludedPaths":0}}, "evidenceNotes":["Syntactic evidence, not a TypeScript type check: uppercase exported functions, component factories, classes and reexports are candidates. Inspect unresolved props before reuse.", "client records the file's use client directive, not transitive client-bundle membership.", - "importedBy includes static import/reexport sites resolved through named barrels; routes describe static module reachability, not proof a component renders. Dynamic imports, runtime conditions and package export maps are not evaluated.", + include_str!("text/project_context_ui-run-9d95e12b.md"), "Unparsed files may contain additional components; totalComponents counts parsed candidates only. Check diagnostics and truncation before concluding no reusable component exists."] }); if owners.len() > 1 { diff --git a/crates/devup-mcp/src/server/project_plan/erd.rs b/crates/devup-mcp/src/server/project_plan/erd.rs new file mode 100644 index 00000000..4dbe7f5d --- /dev/null +++ b/crates/devup-mcp/src/server/project_plan/erd.rs @@ -0,0 +1,349 @@ +use super::*; + +const SCHEMA: &str = "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json"; +pub(super) fn api(plan: &Value) -> bool { + text(&plan["facts"]["parts"]) + .to_ascii_lowercase() + .contains("api") +} +fn ident(s: &str) -> bool { + !s.is_empty() + && s.as_bytes()[0].is_ascii_lowercase() + && s.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') +} +fn col(name: &str, typ: Value) -> Value { + json!({"name":name,"type":typ,"nullable":false}) +} +fn entity(name: &str, mut columns: Vec) -> Value { + let mut id = col("id", json!("integer")); + id["primary_key"] = json!({"auto_increment":true}); + columns.insert(0, id); + json!({"$schema":SCHEMA,"name":name,"columns":columns}) +} +fn inferred(plan: &Value) -> Vec { + // Figma field labels are evidence, not authoritative database semantics. + // Every inferred column is reviewed in the ERD round before writing. + let mut result = Vec::new(); + let screens = array(&plan["facts"]["screens"]); + let names = screens + .iter() + .map(|s| text(&s["name"]).to_lowercase()) + .collect::>() + .join(" "); + if names.contains("signup") || names.contains("sign up") { + let mut email = col("email", json!("text")); + email["unique"] = json!(true); + let mut columns = vec![ + email, + col( + "role", + json!({"kind":"enum","name":"user_role","values":[{"name":"guest","value":0},{"name":"user","value":10},{"name":"moderator","value":50},{"name":"admin","value":100}]}), + ), + ]; + for screen in screens + .iter() + .filter(|s| text(&s["name"]).to_lowercase().contains("sign")) + { + for field in array(&screen["fields"]) { + let label = field + .as_str() + .or_else(|| field["name"].as_str()) + .unwrap_or_default() + .to_lowercase() + .replace(' ', "_"); + if ident(&label) + && !["email", "role", "id", "password"].contains(&label.as_str()) + && !columns.iter().any(|c| c["name"] == label) + { + columns.push(col(&label, json!("text"))); + } + } + } + result.push(entity("user", columns)); + } + if names.contains("post") { + let mut columns = vec![col("title", json!("text")), col("body", json!("text"))]; + if result.iter().any(|e| e["name"] == "user") { + columns.push(fk("user_id", "user", "restrict")); + } + result.push(entity("post", columns)); + } + result +} +fn fk(column: &str, table: &str, on_delete: &str) -> Value { + json!({"name":column,"type":"integer","nullable":on_delete == "set_null","index":true, + "foreign_key":{"ref_table":table,"ref_columns":["id"],"on_delete":on_delete}}) +} +fn scan_models(root: &Path, result: &mut Vec) -> Result<(), DevupError> { + let Ok(files) = std::fs::read_dir(root) else { + return Ok(()); + }; + for file in files { + let path = file.map_err(|e| invalid(e.to_string()))?.path(); + if path.extension().is_some_and(|e| e == "json") { + let meta = std::fs::symlink_metadata(&path).map_err(|e| invalid(e.to_string()))?; + if meta.file_type().is_symlink() || meta.len() > 512 * 1024 { + return Err(invalid("Unsafe or oversized existing model")); + } + let model = + serde_json::from_slice(&std::fs::read(&path).map_err(|e| invalid(e.to_string()))?) + .map_err(|e| invalid(format!("{}: {e}", path.display())))?; + result.push(model); + } + } + Ok(()) +} +pub(super) fn draft(input: &ProjectPlanInput, plan: &mut Value) -> Result<(), DevupError> { + if !api(plan) { + return Ok(()); + } + let root = Path::new(&input.project_root); + let mut dir = input + .models_dir + .clone() + .or_else(|| plan["modelsDir"].as_str().map(str::to_owned)) + .unwrap_or_else(|| "models".into()); + if input.models_dir.is_none() && plan["modelsDir"].is_null() { + let config = root.join("vespertide.json"); + if config.is_file() { + let conf: Value = + serde_json::from_slice(&std::fs::read(config).map_err(|e| invalid(e.to_string()))?) + .map_err(|e| invalid(e.to_string()))?; + if let Some(d) = conf["modelsDir"].as_str() { + dir = d.into(); + } + } else if let Ok(entries) = std::fs::read_dir(root.join("apis")) { + let dirs = entries + .filter_map(Result::ok) + .filter(|e| e.path().join("vespertide.json").is_file()) + .collect::>(); + if dirs.len() == 1 { + let conf: Value = serde_json::from_slice( + &std::fs::read(dirs[0].path().join("vespertide.json")) + .map_err(|e| invalid(e.to_string()))?, + ) + .map_err(|e| invalid(e.to_string()))?; + dir = format!( + "apis/{}/{}", + dirs[0].file_name().to_string_lossy(), + conf["modelsDir"].as_str().unwrap_or("models") + ); + plan["modelsWorkingDirectory"] = + json!(format!("apis/{}", dirs[0].file_name().to_string_lossy())); + } else if dirs.len() > 1 { + return Err(invalid("Several APIs exist; set modelsDir explicitly")); + } + } + } + if Path::new(&dir).is_absolute() + || !Path::new(&dir) + .components() + .all(|c| matches!(c, std::path::Component::Normal(_))) + || dir + .split(['/', '\\']) + .any(|p| ["migrations", "src"].contains(&p)) + { + return Err(invalid( + "modelsDir must be a relative source-model directory, outside migrations/ and src/", + )); + } + plan["modelsDir"] = json!(dir); + if plan["modelsWorkingDirectory"].is_null() { + plan["modelsWorkingDirectory"] = json!("."); + } + let mut entities = if !input.entities.is_empty() { + input.entities.clone() + } else { + array(&plan["entities"]).to_vec() + }; + if entities.is_empty() { + scan_models(&root.join(&dir), &mut entities)?; + } + if entities.is_empty() { + entities = inferred(plan); + } + let relations: Vec = if !input.relations.is_empty() { + input.relations.clone() + } else { + serde_json::from_value(plan["relations"].clone()).unwrap_or_default() + }; + for r in &relations { + if !ident(&r.from) + || !ident(&r.to) + || ![ + "cascade", + "restrict", + "set_null", + "set_default", + "no_action", + ] + .contains(&r.on_delete.as_str()) + { + return Err(invalid("Invalid relation identifier or delete action")); + } + if !entities.iter().any(|e| e["name"] == r.from) + || !entities.iter().any(|e| e["name"] == r.to) + { + return Err(invalid("Relation endpoints must exist")); + } + match r.cardinality.as_str() { + "manyToMany" => { + let name = r + .join_table + .clone() + .unwrap_or_else(|| format!("{}_{}", r.from, r.to)); + if entities.iter().any(|e| e["name"] == name) { + continue; + } + let columns = [&r.from, &r.to] + .iter() + .map(|table| { + let mut c = fk(&format!("{table}_id"), table, &r.on_delete); + c["primary_key"] = json!(true); + c + }) + .collect::>(); + entities.push(json!({"$schema":SCHEMA,"name":name,"columns":columns})); + } + "oneToMany" => { + let name = r.column.clone().unwrap_or_else(|| format!("{}_id", r.from)); + let columns = entities.iter_mut().find(|e| e["name"] == r.to).unwrap()["columns"] + .as_array_mut() + .ok_or_else(|| invalid("Entity needs columns"))?; + let reference = fk(&name, &r.from, &r.on_delete); + if let Some(c) = columns.iter_mut().find(|c| c["name"] == name) { + c["foreign_key"] = reference["foreign_key"].clone(); + c["index"] = json!(true); + } else { + columns.push(reference); + } + } + _ => return Err(invalid("cardinality must be oneToMany or manyToMany")), + } + } + for e in &mut entities { + e["$schema"] = json!(SCHEMA); + } + validate(&entities)?; + plan["entities"] = json!(entities); + plan["relations"] = json!(relations); + plan["backendCommands"] = json!([ + "vespertide diff", + "vespertide sql", + "vespertide revision -m \"confirmed project plan\"", + "vespertide export --orm seaorm" + ]); + Ok(()) +} +pub(super) fn validate(entities: &[Value]) -> Result<(), DevupError> { + let mut names = std::collections::BTreeSet::new(); + for e in entities { + let name = text(&e["name"]); + if !ident(name) || !names.insert(name) || array(&e["columns"]).is_empty() { + return Err(invalid("Entities need unique snake_case names and columns")); + } + let mut columns = std::collections::BTreeSet::new(); + for c in array(&e["columns"]) { + if !ident(text(&c["name"])) + || !columns.insert(text(&c["name"])) + || !c["nullable"].is_boolean() + || !(c["type"].is_string() || c["type"].is_object()) + { + return Err(invalid( + "Columns need unique snake_case names, type and nullable", + )); + } + if !c["foreign_key"].is_null() && c["index"].is_null() { + return Err(invalid("Foreign keys require inline indexes")); + } + if c["name"] == "role" { + let values = array(&c["type"]["values"]); + if c["type"]["kind"] != "enum" + || values.is_empty() + || values.iter().any(|v| !v["value"].is_i64()) + || values.windows(2).any(|w| { + w[1]["value"].as_i64().unwrap() - w[0]["value"].as_i64().unwrap() < 2 + }) + { + return Err(invalid("Roles require gapped integer enums")); + } + } + } + if array(&e["constraints"]) + .iter() + .any(|c| c["type"] != "check") + { + return Err(invalid("Only CHECK constraints belong at table level")); + } + } + for e in entities { + for c in array(&e["columns"]) { + let fk = &c["foreign_key"]; + if let Some(table) = fk["ref_table"].as_str() { + let target = entities + .iter() + .find(|e| e["name"] == table) + .ok_or_else(|| invalid("Foreign key references missing entity"))?; + for col in array(&fk["ref_columns"]) { + if !array(&target["columns"]).iter().any(|c| c["name"] == *col) { + return Err(invalid("Foreign key references missing column")); + } + } + if fk["on_delete"] == "set_null" && c["nullable"] != true { + return Err(invalid("set_null requires nullable column")); + } + } + } + } + Ok(()) +} +pub(super) fn write_models( + tx: &mut OutputTransaction, + policy: &OutputPolicy, + root: &str, + plan: &Value, +) -> Result<(), DevupError> { + if !api(plan) { + return Ok(()); + } + let entities = array(&plan["entities"]); + if entities.is_empty() { + return Err(invalid("API plan requires a complete ERD")); + } + validate(entities)?; + for e in entities { + let file = format!("{}/{}.json", text(&plan["modelsDir"]), text(&e["name"])); + let bytes = serde_json::to_vec_pretty(e).map_err(|e| invalid(e.to_string()))?; + stage(tx, policy, root, &file, &bytes)?; + } + Ok(()) +} +pub(super) fn mermaid(entities: &[Value]) -> String { + let mut out = "erDiagram\n".to_owned(); + for e in entities { + out.push_str(&format!(" {} {{\n", text(&e["name"]))); + for c in array(&e["columns"]) { + out.push_str(&format!( + " {} {}\n", + c["type"].as_str().or_else(|| c["type"]["kind"].as_str()).unwrap_or("unknown"), + text(&c["name"]) + )); + } + out.push_str(" }\n"); + for c in array(&e["columns"]) { + let fk = &c["foreign_key"]; + let table = fk["ref_table"] + .as_str() + .or_else(|| fk.as_str().and_then(|s| s.split('.').next())); + if let Some(table) = table { + out.push_str(&format!( + " {table} ||--o{{ {} : {}\n", + text(&e["name"]), + text(&c["name"]) + )); + } + } + } + out +} diff --git a/crates/devup-mcp/src/server/project_plan/mod.rs b/crates/devup-mcp/src/server/project_plan/mod.rs new file mode 100644 index 00000000..4b6daf6c --- /dev/null +++ b/crates/devup-mcp/src/server/project_plan/mod.rs @@ -0,0 +1,338 @@ +//! Project intake and supervisor state. Source reads are supplied by the runtime; +//! local evidence is collected before generating any unresolved questions. +mod erd; +mod rounds; +#[cfg(test)] +mod tests; +mod units; + +use super::{ + output::{OutputPolicy, OutputTransaction}, + screen_plan, +}; +use devup_mcp_figma::DevupError; +use schemars::JsonSchema; +use screen_plan::{array, invalid, now, sha256, text}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{collections::BTreeMap, path::Path}; + +const FILE: &str = ".devup/project-plan.json"; +const KIND: &str = "devup-project-plan"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ProjectPlanInput { + #[schemars(extend("enum" = ["intake", "draft", "confirm", "status", "assume", "defer", "split"]))] + pub action: String, + pub project_root: String, + pub plan_id: Option, + /// Facts read from Jira issues/epic children, Figma exploration, spec documents, + /// and devup_project_context all scopes. Every fact includes its source. + #[serde(default)] + pub evidence: Vec, + #[serde(default)] + #[schemars(extend("additionalProperties" = json!({})))] + pub answers: BTreeMap, + #[serde(default)] + pub features: Vec, + /// Explicit ERD models in vespertide wire format; confirm may refine the draft. + #[serde(default)] + #[schemars(extend("items" = json!({})))] + pub entities: Vec, + #[serde(default)] + pub relations: Vec, + #[serde(default)] + pub issues: Vec, + #[serde(default)] + pub extra_questions: Vec, + pub unit_questions: Option, + pub models_dir: Option, + #[serde(default)] + pub decisions: Vec, + #[serde(default)] + pub unit_updates: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct Evidence { + pub source: String, + #[schemars(extend("additionalProperties" = json!({})))] + pub facts: BTreeMap, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct Feature { + pub id: String, + pub scope: String, + #[serde(default)] + pub screens: Vec, + #[serde(default)] + pub acceptance: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct Issue { + pub key: String, + pub scope: String, + #[serde(default)] + pub dependencies: Vec, + #[serde(default)] + pub acceptance: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct Relation { + pub from: String, + pub to: String, + /// oneToMany or manyToMany. from owns the referenced id, to owns the FK. + pub cardinality: String, + pub column: Option, + pub join_table: Option, + #[serde(default = "restrict")] + pub on_delete: String, +} +fn restrict() -> String { + "restrict".into() +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct Decision { + pub id: String, + pub reason: String, + pub recommended: String, + #[serde(default)] + pub destructive: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct UnitUpdate { + pub id: String, + pub state: String, + #[serde(default)] + pub verification: Vec, +} + +fn read(root: &str) -> Result { + let path = Path::new(root).join(FILE); + let metadata = + std::fs::metadata(&path).map_err(|e| invalid(format!("Read project plan: {e}")))?; + if metadata.len() > 2 * 1024 * 1024 { + return Err(invalid("Project plan exceeds 2 MiB")); + } + let plan: Value = + serde_json::from_slice(&std::fs::read(path).map_err(|e| invalid(e.to_string()))?) + .map_err(|e| invalid(e.to_string()))?; + if plan["kind"] != KIND || plan["schemaVersion"] != 1 { + return Err(invalid("Invalid project plan")); + } + Ok(plan) +} +fn stage( + tx: &mut OutputTransaction, + policy: &OutputPolicy, + root: &str, + path: &str, + bytes: &[u8], +) -> Result<(), DevupError> { + tx.stage( + path, + policy.resolve(&Path::new(root).join(path).to_string_lossy())?, + bytes, + ) +} +fn write( + tx: &mut OutputTransaction, + policy: &OutputPolicy, + root: &str, + plan: &Value, +) -> Result<(), DevupError> { + let bytes = serde_json::to_vec_pretty(plan).map_err(|e| invalid(e.to_string()))?; + if bytes.len() > 2 * 1024 * 1024 { + return Err(invalid("Project plan exceeds 2 MiB")); + } + stage(tx, policy, root, FILE, &bytes) +} +fn confirmed(plan: &Value) -> Result<(), DevupError> { + if plan["state"] != "confirmed" { + return Err(invalid( + "Confirm project plan before implementation or split", + )); + } + Ok(()) +} +fn response(plan: Value) -> Value { + let questions = array(&plan["questions"]) + .iter() + .filter(|q| plan["answers"][text(&q["id"])].is_null()) + .cloned() + .collect::>(); + json!({"planId":plan["planId"], "state":plan["state"], + "questions":screen_plan::ask_shape(&questions), + "questionRounds":screen_plan::question_rounds(&questions, 4), + "instruction": if plan["state"] == "confirmed" { include_str!("../text/mod-response-22d8f704.md") } else { screen_plan::ASK_INSTRUCTION }, + "erdMermaid":erd::mermaid(array(&plan["entities"])), + "unitDag":units::dag(array(&plan["units"])), + "finalReport":{"units":plan["units"], "decisions":plan["decisions"], "completion":"unverified until supervisor reruns all verification and stack diff reports no drift"}, + "plan":plan}) +} + +pub(super) async fn run( + input: &ProjectPlanInput, + policy: &OutputPolicy, +) -> Result { + if input.project_root.trim().is_empty() { + return Err(invalid("projectRoot is required")); + } + // Apply the same root boundary to reads and writes, even for status. + policy.resolve(&Path::new(&input.project_root).join(FILE).to_string_lossy())?; + let mut tx = OutputTransaction::new(); + if input.action == "intake" { + if Path::new(&input.project_root).join(FILE).exists() { + return Err(invalid( + "Project plan exists; use draft/status instead of replacing decisions", + )); + } + let facts = rounds::facts(input)?; + let id = sha256(format!("{}{}", input.project_root, now()).as_bytes()); + let mut plan = json!({"kind":KIND,"schemaVersion":1,"planId":id,"state":"intake", + "facts":facts,"evidence":input.evidence,"answers":{},"decisions":[],"units":[], + "questions":rounds::intake(&facts),"entities":[],"relations":[],"features":[],"issues":[], + "unitQuestions":"supervisor"}); + rounds::answers(&mut plan, &input.answers)?; + write(&mut tx, policy, &input.project_root, &plan)?; + tx.commit()?; + return Ok(response(plan)); + } + let mut plan = read(&input.project_root)?; + if input.plan_id.as_deref() != plan["planId"].as_str() { + return Err(invalid("planId is missing or stale")); + } + match input.action.as_str() { + "draft" => { + if plan["state"] == "confirmed" { + return Err(invalid("Confirmed plans cannot be redrafted")); + } + rounds::answers(&mut plan, &input.answers)?; + rounds::require_answers(&plan)?; + for ev in &input.evidence { + if ev.source.trim().is_empty() { + return Err(invalid("Evidence needs a source")); + } + for (key, value) in &ev.facts { + if !["parts", "goal", "workType"].contains(&key.as_str()) + || plan["answers"][key].is_null() + { + plan["facts"][key] = value.clone(); + } + } + } + plan["evidence"] + .as_array_mut() + .unwrap() + .extend(input.evidence.iter().map(|e| json!(e))); + if !input.features.is_empty() { + plan["features"] = json!(input.features); + } + if !input.issues.is_empty() { + plan["issues"] = json!(input.issues); + } + if let Some(mode) = &input.unit_questions { + if !["supervisor", "worker"].contains(&mode.as_str()) { + return Err(invalid("unitQuestions must be supervisor or worker")); + } + plan["unitQuestions"] = json!(mode); + } + erd::draft(input, &mut plan)?; + plan["questions"] = json!(rounds::detail(&plan, &input.extra_questions)?); + plan["state"] = json!("draft"); + } + "confirm" => { + if plan["state"] != "draft" { + return Err(invalid("Draft the complete plan before confirm")); + } + rounds::answers(&mut plan, &input.answers)?; + rounds::require_answers(&plan)?; + if plan["answers"] + .as_object() + .unwrap() + .values() + .any(|v| v == "Revise ERD before confirming") + { + return Err(invalid( + "Redraft the structured ERD and approve it before confirm", + )); + } + erd::draft(input, &mut plan)?; + // Figma remains its own confirmation gate and is retained in every brief. + if let Some(screen) = screen_plan::read_plan(&input.project_root)? { + if screen["state"] != "confirmed" && screen["status"] != "confirmed" { + return Err(invalid("Confirm the Figma screen plan first")); + } + plan["screenPlan"] = screen; + } else if !array(&plan["facts"]["figma"]).is_empty() { + return Err(invalid( + "Figma sources require a confirmed devup_screen_plan", + )); + } + erd::write_models(&mut tx, policy, &input.project_root, &plan)?; + plan["state"] = json!("confirmed"); + plan["confirmedAt"] = json!(now()); + } + "split" => { + confirmed(&plan)?; + units::split(input, &mut plan, &mut tx, policy)?; + } + "defer" | "assume" => { + confirmed(&plan)?; + if input.action == "assume" && input.decisions.is_empty() { + for d in plan["decisions"].as_array_mut().unwrap() { + if d["state"] == "pending" { + d["state"] = json!(if d["destructive"] == true { + "unbuilt" + } else { + "assumed" + }); + } + } + } + for d in &input.decisions { + if d.id.trim().is_empty() + || d.reason.trim().is_empty() + || d.recommended.trim().is_empty() + { + return Err(invalid("Decisions need id, reason and recommended option")); + } + let decisions = plan["decisions"].as_array_mut().unwrap(); + decisions.retain(|v| v["id"] != d.id); + let mut value = json!(d); + value["state"] = json!(if input.action == "defer" { + "pending" + } else if d.destructive { + "unbuilt" + } else { + "assumed" + }); + decisions.push(value); + } + } + "status" => { + units::update(input, &mut plan)?; + } + _ => { + return Err(invalid( + "action must be intake, draft, confirm, status, assume, defer or split", + )); + } + } + write(&mut tx, policy, &input.project_root, &plan)?; + tx.commit()?; + Ok(response(plan)) +} diff --git a/crates/devup-mcp/src/server/project_plan/rounds.rs b/crates/devup-mcp/src/server/project_plan/rounds.rs new file mode 100644 index 00000000..21f96c0b --- /dev/null +++ b/crates/devup-mcp/src/server/project_plan/rounds.rs @@ -0,0 +1,175 @@ +use super::*; +use screen_plan::{choice, question}; + +pub(super) fn facts(input: &ProjectPlanInput) -> Result { + let root = Path::new(&input.project_root); + let mut facts = json!({}); + if root.join("apis").is_dir() { + facts["parts"] = json!("front + admin + API"); + facts["partsEvidence"] = json!("apis/ exists"); + } else if root.join("apps/admin").is_dir() { + facts["parts"] = json!("front + admin"); + facts["partsEvidence"] = json!("apps/admin exists"); + } + for ev in &input.evidence { + if ev.source.trim().is_empty() { + return Err(invalid("Evidence requires a source")); + } + for (k, v) in &ev.facts { + facts[k] = v.clone(); + } + } + Ok(facts) +} +fn ask(id: &str, prompt: String, options: &[&str]) -> Value { + question( + id.into(), + screen_plan::header("", id), + prompt, + options + .iter() + .map(|label| { + choice( + *label, + format!("Record {label} for this project; custom answers accepted"), + json!(label), + ) + }) + .collect(), + "project", + ) +} +pub(super) fn intake(facts: &Value) -> Vec { + let parts = text(&facts["parts"]); + vec![ + ask("workType", "What kind of work is this? Choose new project, existing feature, bug fix, Figma page implementation, or refactor (custom answer).".into(), &["new project", "feature on existing project", "bug fix", "Figma page implementation"]), + ask("sources", format!("Which optional sources should be read? Figma links, Jira issues/epics, Confluence URLs or files. Already read evidence: {facts}"), &["Use recorded sources", "No additional sources"]), + ask("goal", format!("What should be built in one sentence? Source default: {}", text(&facts["goal"])), &[if facts["goal"].is_string() {text(&facts["goal"])} else {"Describe the goal"}, "Use source scope"]), + ask("parts", format!("Which parts are in scope? Default evidence: {}", facts["partsEvidence"]), &[if parts.is_empty() {"front only"} else {parts}, "front + admin", "front + admin + API"]), + ] +} +pub(super) fn answers( + plan: &mut Value, + answers: &BTreeMap, +) -> Result<(), DevupError> { + for (id, value) in answers { + if !array(&plan["questions"]).iter().any(|q| q["id"] == *id) { + return Err(invalid(format!("Unknown question {id}"))); + } + if value.as_str().is_none_or(|s| s.trim().is_empty()) { + return Err(invalid(format!("Answer {id} needs nonempty text"))); + } + let answer = screen_plan::strip_recommended(value.as_str().unwrap()); + if id == "goal" + && ["Describe the goal", "Use source scope"].contains(&answer) + && !plan["facts"]["goal"].is_string() + { + return Err(invalid("Provide a concrete one-sentence goal")); + } + plan["answers"][id] = json!(answer); + if ["parts", "goal", "workType"].contains(&id.as_str()) + && (id != "goal" || answer != "Use source scope") + { + plan["facts"][id] = json!(answer); + } + } + Ok(()) +} +pub(super) fn require_answers(plan: &Value) -> Result<(), DevupError> { + let missing = array(&plan["questions"]) + .iter() + .filter(|q| plan["answers"][text(&q["id"])].is_null()) + .map(|q| text(&q["id"])) + .collect::>(); + if missing.is_empty() { + Ok(()) + } else { + Err(invalid(format!( + "Answer all intake rounds first: {}", + missing.join(", ") + ))) + } +} +pub(super) fn detail( + plan: &Value, + extra: &[screen_plan::ExtraQuestionInput], +) -> Result, DevupError> { + let mut questions = vec![]; + if array(&plan["features"]).is_empty() && array(&plan["issues"]).is_empty() { + return Err(invalid( + "draft needs features or Jira issues read from sources", + )); + } + for feature in array(&plan["features"]) + .iter() + .chain(array(&plan["issues"])) + { + let id = feature["id"] + .as_str() + .or_else(|| feature["key"].as_str()) + .unwrap_or("feature"); + for (topic, prompt) in [ + ("screens", "screens/routes and user roles"), + ("states", "empty, loading and error states"), + ("permissions", "permissions and ownership"), + ("forms", "forms and field validation"), + ( + "notifications", + "notifications and external integrations (payment, mail, maps, login providers)", + ), + ] { + let key = format!("{id}.{topic}"); + if plan["facts"][&key].is_null() { + questions.push(ask(&key, format!("For {} ({id}), specify {prompt}. Screens: {}. Record a concrete decision or explicitly exclude the capability.", text(&feature["scope"]), feature["screens"]), &["Use recorded feature acceptance criteria", "Exclude this capability"])); + } + } + } + if erd::api(plan) { + for (key, topic) in [ + ( + "erd", + "Review every entity, column, required/nullable field, relation and N:M join table", + ), + ("tenancy", "Set ownership and multi-tenancy boundaries"), + ( + "auth", + "Confirm user/auth model, providers and gapped integer roles", + ), + ( + "lifecycle", + "Confirm status enums, soft delete, audit timestamps and deletion actions", + ), + ( + "indexes", + "Confirm unique/composite keys and indexes for each listed filter/sort", + ), + ("storage", "Set file/image storage and nullable metadata"), + ] { + if plan["facts"][key].is_null() { + questions.push(ask(key, format!("{topic}. Draft ERD: {}. Supply changes as entities/relations with confirm; a prose correction requires redraft.", plan["entities"]), &["Approve recorded ERD", "Revise ERD before confirming"])); + } + } + } + for (key, options) in [ + ("jiraPolicy", vec!["none", "comment", "transition"]), + ( + "branchPolicy", + vec!["Use current branch", "Create feature branch"], + ), + ("prPolicy", vec!["No PR", "Create draft PR"]), + ] { + if plan["facts"][key].is_null() { + questions.push(ask( + key, + format!("Delivery: choose {key} for {}", plan["facts"]["goal"]), + &options, + )); + } + } + questions.extend(screen_plan::extra_questions(extra)?); + let mut seen = std::collections::BTreeSet::new(); + if questions.iter().any(|q| !seen.insert(text(&q["id"]))) { + return Err(invalid("Question ids must be unique")); + } + Ok(questions) +} diff --git a/crates/devup-mcp/src/server/project_plan/tests.rs b/crates/devup-mcp/src/server/project_plan/tests.rs new file mode 100644 index 00000000..437b5419 --- /dev/null +++ b/crates/devup-mcp/src/server/project_plan/tests.rs @@ -0,0 +1,236 @@ +struct TestDir(std::path::PathBuf); +impl TestDir { + fn new() -> Self { + let p = std::env::temp_dir().join(format!( + "devup-project-plan-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn path(&self) -> &std::path::Path { + &self.0 + } +} +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +use super::*; +fn input(root: &Path, action: &str) -> ProjectPlanInput { + serde_json::from_value(json!({"action":action,"projectRoot":root})).unwrap() +} +async fn drafted(root: &Path, api: bool) -> (ProjectPlanInput, OutputPolicy, Value) { + let policy = OutputPolicy::from_roots(vec![root.to_path_buf()]).unwrap(); + let mut i = input(root, "intake"); + i.evidence = vec![serde_json::from_str(include_str!("../../../tests/fixtures/project-plan/figma.json")).unwrap()]; + let r = run(&i, &policy).await.unwrap(); + i.plan_id = r["planId"].as_str().map(str::to_owned); + i.action = "draft".into(); + i.answers = BTreeMap::from([ + ("workType".into(), json!("new project")), + ("sources".into(), json!("Use recorded sources")), + ("goal".into(), json!("Use source scope")), + ( + "parts".into(), + json!(if api { + "front + admin + API" + } else { + "front only" + }), + ), + ]); + i.features = vec![Feature { + id: "posts".into(), + scope: "Post authoring".into(), + screens: vec!["Post list".into()], + acceptance: vec!["Authenticated authors publish posts".into()], + }]; + i.evidence.push(serde_json::from_value(json!({"source":"repo defaults","facts":{ + "goal":"Source defaults cannot replace the selected goal", + "parts": if api {"front only"} else {"front + admin + API"} + }})).unwrap()); + let r = run(&i, &policy).await.unwrap(); + assert_eq!(r["plan"]["facts"]["goal"], "Build a post service"); + assert_eq!(r["plan"]["facts"]["parts"], if api {"front + admin + API"} else {"front only"}); + i.action = "confirm".into(); + i.evidence.clear(); + i.features.clear(); + i.answers = array(&r["plan"]["questions"]) + .iter() + .map(|q| (text(&q["id"]).into(), q["options"][0]["label"].clone())) + .collect(); + (i, policy, r) +} +#[tokio::test] +async fn figma_erd_confirm_writes_schema_valid_models() { + let dir = TestDir::new(); + let (mut i, policy, draft) = drafted(dir.path(), true).await; + assert!( + draft["erdMermaid"] + .as_str() + .unwrap() + .contains("user ||--o{ post") + ); + i.entities = array(&draft["plan"]["entities"]).to_vec(); + i.entities[0]["columns"].as_array_mut().unwrap().iter_mut() + .find(|c| c["name"] == "display_name").unwrap()["type"] = json!({"kind":"varchar", "length":100}); + let r = run(&i, &policy).await.unwrap(); + assert_eq!(r["state"], "confirmed"); + assert!(r["erdMermaid"].as_str().unwrap().contains("varchar display_name")); + let schema: Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/vespertide/model.schema.json" + )) + .unwrap(); + let validator = jsonschema::validator_for(&schema).unwrap(); + for name in ["user", "post"] { + let model: Value = serde_json::from_slice( + &std::fs::read(dir.path().join(format!("models/{name}.json"))).unwrap(), + ) + .unwrap(); + if let Some(error) = validator.iter_errors(&model).next() { + panic!("{error}"); + } + if name == "post" { + let sample: Value = serde_json::from_str(include_str!("../../../tests/fixtures/project-plan/post.json")).unwrap(); + assert_eq!(model, sample); + } + assert!(model["$schema"].is_string()); + } + assert!( + r["plan"]["entities"][0]["columns"] + .as_array() + .unwrap() + .iter() + .any(|c| c["name"] == "display_name") + ); + let source = dir.path().join("large/models"); + std::fs::create_dir_all(&source).unwrap(); + for index in 0..103 { + let name = format!("model_{index}"); + std::fs::write(source.join(format!("{name}.json")), serde_json::to_vec(&json!({ + "name":name,"columns":[{"name":"id","type":"integer","nullable":false,"primary_key":true}] + })).unwrap()).unwrap(); + } + let mut input = input(&dir.path().join("large"), "draft"); + input.models_dir = Some("models".into()); + let mut plan = json!({"facts":{"parts":"API"},"entities":[],"relations":[]}); + erd::draft(&input, &mut plan).unwrap(); + assert_eq!(array(&plan["entities"]).len(), 103); + +} +#[tokio::test] +async fn many_to_many_join_table_and_gapped_roles_validate() { + let dir = TestDir::new(); + let (mut i, policy, r) = drafted(dir.path(), true).await; + i.entities = array(&r["plan"]["entities"]).to_vec(); + i.relations.push(Relation { + from: "user".into(), + to: "post".into(), + cardinality: "manyToMany".into(), + column: None, + join_table: Some("user_post".into()), + on_delete: "cascade".into(), + }); + let r = run(&i, &policy).await.unwrap(); + let entities = array(&r["plan"]["entities"]); + let schema: Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/vespertide/model.schema.json" + )) + .unwrap(); + let validator = jsonschema::validator_for(&schema).unwrap(); + for e in entities { + assert!(validator.is_valid(e), "{e}"); + } + let join = entities.iter().find(|e| e["name"] == "user_post").unwrap(); + assert!( + array(&join["columns"]) + .iter() + .all(|c| c["primary_key"] == true && c["index"] == true) + ); + let mut bad = entities.to_vec(); + bad[0]["columns"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|c| c["name"] == "role") + .unwrap()["type"]["values"][1]["value"] = json!(1); + assert!(erd::validate(&bad).is_err()); +} +#[tokio::test] +async fn split_three_issues_preserves_dag_and_briefs() { + let dir = TestDir::new(); + let (i, policy, _) = drafted(dir.path(), false).await; + run(&i, &policy).await.unwrap(); + let mut plan = read(&i.project_root).unwrap(); + plan["screenPlan"] = json!({"status":"confirmed", "questions":[{"question":"Do not ask this?"}], + "questionRounds":[{"question":"Do not repeat this?"}], "answers":{"route":"/posts"}}); + plan["issues"] = json!([ + {"key":"DEV-1","scope":"Models","dependencies":[],"acceptance":["schema passes"]}, + {"key":"DEV-2","scope":"API","dependencies":["DEV-1"],"acceptance":["CRUD"]}, + {"key":"DEV-3","scope":"Pages","dependencies":["DEV-2"],"acceptance":["visual audit"]}]); + let mut tx = OutputTransaction::new(); + write(&mut tx, &policy, &i.project_root, &plan).unwrap(); + tx.commit().unwrap(); + let mut i = i; + i.action = "split".into(); + i.answers.clear(); + let r = run(&i, &policy).await.unwrap(); + assert_eq!(array(&r["plan"]["units"]).len(), 3); + assert_eq!(r["plan"]["units"][2]["dependencies"], json!(["DEV-2"])); + let brief = std::fs::read_to_string(dir.path().join(".devup/units/DEV-3.md")).unwrap(); + for required in [ + "Build a post service", + "Workers never ask", + "ERD slice", + "Screen-plan slice", + "devup_stack_diff", + ] { + assert!(brief.contains(required)); + } + assert!(!brief.contains('?')); +} +#[tokio::test] +async fn feature_split_and_decisions_and_state_gates() { + let dir = TestDir::new(); + let (mut i, policy, _) = drafted(dir.path(), true).await; + let mut incomplete = i.clone(); + incomplete.answers.clear(); + assert!(run(&incomplete, &policy).await.is_err()); + run(&i, &policy).await.unwrap(); + i.action = "split".into(); + i.answers.clear(); + let r = run(&i, &policy).await.unwrap(); + assert_eq!(array(&r["plan"]["units"]).len(), 5); + assert_eq!( + r["plan"]["units"][4]["dependencies"], + json!(["posts-client"]) + ); + i.action = "status".into(); + i.unit_updates.push(UnitUpdate { + id: "posts-api".into(), + state: "running".into(), + verification: vec![], + }); + assert!(run(&i, &policy).await.is_err()); + i.unit_updates.clear(); + i.action = "defer".into(); + i.decisions.push(Decision { + id: "delete".into(), + reason: "Destructive cleanup".into(), + recommended: "Keep data".into(), + destructive: true, + }); + run(&i, &policy).await.unwrap(); + i.action = "assume".into(); + i.decisions.clear(); + let r = run(&i, &policy).await.unwrap(); + assert_eq!(r["plan"]["decisions"][0]["state"], "unbuilt"); + i.action = "draft".into(); + assert!(run(&i, &policy).await.is_err()); + i.action = "status".into(); + i.plan_id = Some("stale".into()); + assert!(run(&i, &policy).await.is_err()); +} diff --git a/crates/devup-mcp/src/server/project_plan/units.rs b/crates/devup-mcp/src/server/project_plan/units.rs new file mode 100644 index 00000000..3f966e89 --- /dev/null +++ b/crates/devup-mcp/src/server/project_plan/units.rs @@ -0,0 +1,177 @@ +use super::*; + +fn unit( + id: String, + scope: String, + dependencies: Vec, + acceptance: Value, + layer: &str, +) -> Value { + json!({"id":id,"scope":scope,"dependencies":dependencies,"acceptance":acceptance, + "layer":layer,"state":"pending","verification":[], + "checklist":["build", "lint", "typecheck", "tests", "devup_ui_validate", "runtime/visual audit", "devup_stack_diff: zero drift"]}) +} +fn safe_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 80 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} +fn validate(units: &[Value]) -> Result<(), DevupError> { + let mut ids = std::collections::BTreeSet::new(); + for u in units { + if !safe_id(text(&u["id"])) || !ids.insert(text(&u["id"])) { + return Err(invalid("Work unit ids must be unique safe filenames")); + } + } + let mut done = std::collections::BTreeSet::new(); + loop { + let before = done.len(); + for u in units { + if array(&u["dependencies"]) + .iter() + .all(|d| done.contains(text(d))) + { + done.insert(text(&u["id"])); + } + } + if done.len() == units.len() { + return Ok(()); + } + if before == done.len() { + return Err(invalid("Unit dependencies contain a cycle or unknown id")); + } + } +} +pub(super) fn split( + input: &ProjectPlanInput, + plan: &mut Value, + tx: &mut OutputTransaction, + policy: &OutputPolicy, +) -> Result<(), DevupError> { + if !array(&plan["units"]).is_empty() { + return Err(invalid( + "Plan already split; status updates preserve worker sessions", + )); + } + let mut units = Vec::new(); + let issues: Vec = + serde_json::from_value(plan["issues"].clone()).map_err(|e| invalid(e.to_string()))?; + if !issues.is_empty() { + for issue in issues { + units.push(unit( + issue.key, + issue.scope, + issue.dependencies, + json!(issue.acceptance), + "issue", + )); + } + } else { + units.push(unit( + "shared".into(), + "Shared layout, theme and tokens".into(), + vec![], + json!(["Shared foundations verified"]), + "shared", + )); + for feature in array(&plan["features"]) { + let id = text(&feature["id"]); + let mut previous = "shared".to_owned(); + let layers = if erd::api(plan) { + vec!["models", "api", "client", "front"] + } else { + vec!["front"] + }; + for layer in layers { + let uid = format!("{id}-{layer}"); + units.push(unit( + uid.clone(), + format!("{}: {layer}", text(&feature["scope"])), + vec![previous], + feature["acceptance"].clone(), + layer, + )); + previous = uid; + } + } + } + validate(&units)?; + let mut screen_context = plan["screenPlan"].clone(); + if let Some(context) = screen_context.as_object_mut() { + for key in ["questions", "questionRounds", "instruction", "askInstruction"] { + context.remove(key); + } + } + for u in &units { + let brief = format!( + include_str!("../text/units-split-bf50d8a5.md"), + text(&u["id"]), + text(&u["scope"]), + u["dependencies"], + text(&plan["unitQuestions"]), + json!({"answers":plan["answers"],"facts":plan["facts"],"decisions":plan["decisions"],"backendCommands":plan["backendCommands"],"modelsWorkingDirectory":plan["modelsWorkingDirectory"]}), + erd::mermaid(array(&plan["entities"])), + screen_context, + json!({"acceptance":u["acceptance"],"checklist":u["checklist"]}) + ); + stage( + tx, + policy, + &input.project_root, + &format!(".devup/units/{}.md", text(&u["id"])), + brief.as_bytes(), + )?; + } + plan["units"] = json!(units); + Ok(()) +} +pub(super) fn update(input: &ProjectPlanInput, plan: &mut Value) -> Result<(), DevupError> { + for update in &input.unit_updates { + confirmed(plan)?; + let current = array(&plan["units"]) + .iter() + .find(|u| u["id"] == update.id) + .ok_or_else(|| invalid("Unknown unit"))?; + let old = text(¤t["state"]); + let valid = match (old, update.state.as_str()) { + (a, b) if a == b => true, + ("pending", "running") => array(¤t["dependencies"]).iter().all(|d| { + array(&plan["units"]) + .iter() + .any(|u| u["id"] == *d && u["state"] == "merged") + }), + ("running", "verifying" | "unresolved") + | ("verifying", "running" | "unresolved") + | ("unresolved", "running") => true, + ("verifying", "merged") => !update.verification.is_empty(), + _ => false, + }; + if !valid { + return Err(invalid( + "Invalid unit state transition or missing supervisor verification/dependencies", + )); + } + let u = plan["units"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|u| u["id"] == update.id) + .unwrap(); + u["state"] = json!(update.state); + u["verification"] = json!(update.verification); + } + Ok(()) +} +pub(super) fn dag(units: &[Value]) -> String { + let mut out = "flowchart TD\n".to_owned(); + for u in units { + let id = text(&u["id"]); + out.push_str(&format!(" {id}[{id}]\n")); + for d in array(&u["dependencies"]) { + out.push_str(&format!(" {} --> {id}\n", text(d))); + } + } + out +} diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index ef3bbd07..f8344b03 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -1,8573 +1,20 @@ -#[path = "projection/asset_policy.rs"] -mod asset_policy; -#[path = "page_scaffold.rs"] -pub(crate) mod page_scaffold; -#[path = "projection/responsive_groups.rs"] -mod responsive_groups; -#[path = "projection/theme_export.rs"] -pub(crate) mod theme_export; - -use std::collections::BTreeMap; -use std::fmt::Write as _; - -use super::result_contract::{ - attach_review_and_deliverable, failure_issue, output_result, scope_output, -}; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use devup_mcp_devup_ui::{ - codegen::{ - CodegenOptions, generate_component, normalize_component_name, responsive::merge_breakpoints, - }, - theme::{generate_devup_json, variable_snapshot_from_result}, -}; -use devup_mcp_figma::{ - AssetManifest, AssetStatus, CollectedPayload, CollectionStats, DevupError, Diagnostic, - DiagnosticSeverity, ErrorCode, ExploreOptions, SearchOptions, TargetKind, classify_target, - explore_snapshot, search_snapshot, -}; -use serde_json::{Map, Value, json}; -use sha2::{Digest, Sha256}; - -use super::{ - artifacts::{ArtifactLookup, ArtifactStore, OutputReservation}, - delivery::{DeliveryMode, ProjectedOutput, choose_delivery_for_result}, - format_epoch_rfc3339, - operation::PendingOperation, - output::{OutputPolicy, OutputTransaction}, - parse_scope, - quality::{ - AcquisitionQuality, OutputQuality, ProjectionQuality, acquisition_quality, assets_quality, - projection_quality, theme_quality, - }, - section_candidate_as_explore, section_index_from_payload, -}; - -/// Reconcile only exports backed by verified bytes. Preserve an entry per node, -/// but make identical content use one file and one code reference. -fn reconcile_asset_paths( - manifest: &mut AssetManifest, - output_paths: &mut BTreeMap, - public_root: Option<&std::path::Path>, - policy: &OutputPolicy, -) -> Result, DevupError> { - // This verifies decoded length/hash as well as MIME before any write. - projected_asset_outputs(manifest)?; - let mut groups = BTreeMap::<_, Vec>::new(); - for (index, asset) in manifest.assets.iter().enumerate() { - if asset.status != AssetStatus::Exported { - continue; - } - let bytes = STANDARD - .decode(asset.data_base64.as_deref().unwrap_or_default()) - .map_err(|_| { - DevupError::new( - ErrorCode::DevupCodegenFailed, - "Invalid exported asset base64.", - false, - ) - })?; - let key = ( - sha256_hex(&bytes), - asset.format.map(|format| format.extension()), - asset.mime_type.clone(), - asset - .variants - .iter() - .map(|v| (v.sha256.clone(), v.width, v.height)) - .collect::>(), - ); - groups.entry(key).or_default().push(index); - } - let mut replacements = BTreeMap::new(); - for members in groups.values() { - // Canonical code names must survive a later code-only projection. - // Select the placeholder independently from the requested file target. - let canonical_path = members - .iter() - .find_map(|&index| manifest.assets[index].path.clone()); - let output_path = members - .iter() - .find_map(|&index| output_paths.get(&manifest.assets[index].asset_id).cloned()); - let canonical_path = canonical_path.map(|p| { - if manifest.assets[members[0]].format == Some(devup_mcp_figma::AssetFormat::Webp) { - p.rsplit_once('.') - .map_or_else(|| format!("{p}.webp"), |(stem, _)| format!("{stem}.webp")) - } else { - p - } - }); - let path = if let (Some(root), Some(output)) = (public_root, &output_path) { - Some(super::validation::public_asset_url( - root, - policy.resolve(output)?.display_path(), - )?) - } else { - canonical_path - }; - for &index in members { - let asset = &mut manifest.assets[index]; - if let Some(output) = &output_path { - output_paths.insert(asset.asset_id.clone(), output.clone()); - } - let old_path = std::mem::replace(&mut asset.path, path.clone()); - if let (Some(old), Some(new)) = (&old_path, &path) - && !asset.variants.is_empty() - { - replacements.insert( - asset_policy::variant_path(old), - asset_policy::variant_path(new), - ); - } - if let (Some(old), Some(new)) = (old_path, path.as_ref()) - && old != *new - && let Some(previous) = replacements.insert(old, new.clone()) - && previous != *new - { - return Err(DevupError::new( - ErrorCode::DevupInvalidInput, - "One placeholder refers to different exported assets. Use assetNamesPerNode:true.", - false, - )); - } - } - } - Ok(replacements) -} - -fn rewrite_asset_references(code: &str, paths: &BTreeMap) -> String { - // One pass prevents a replacement from becoming another replacement's input. - let mut result = String::with_capacity(code.len()); - let mut cursor = 0; - while cursor < code.len() { - let rest = &code[cursor..]; - let matched = paths.iter().find(|(old, _)| { - rest.starts_with(old.as_str()) - && (cursor == 0 - || matches!( - code.as_bytes()[cursor - 1], - b'"' | b'\'' | b'(' | b' ' | b',' - )) - && rest - .as_bytes() - .get(old.len()) - .is_none_or(|next| matches!(next, b'"' | b'\'' | b')' | b' ' | b',')) - }); - if let Some((old, new)) = matched { - result.push_str(new); - cursor += old.len(); - } else { - let character = rest.chars().next().expect("nonempty remainder"); - result.push(character); - cursor += character.len_utf8(); - } - } - result -} - -fn rewrite_result_asset_references(value: &mut Value, paths: &BTreeMap) { - match value { - Value::Object(object) => { - for (key, value) in object { - if matches!( - key.as_str(), - "tsx" - | "componentTsx" - | "responsiveTsx" - | "generatedProperty" - | "generatedSource" - | "srcSet" - ) { - match value { - Value::String(code) => { - *code = if key == "srcSet" { - rewrite_asset_references(code, paths) - } else { - rewrite_asset_references(&with_host_safe_asset_paths(code), paths) - } - } - Value::Array(items) => { - for item in items { - if let Value::String(code) = item { - *code = rewrite_asset_references( - &with_host_safe_asset_paths(code), - paths, - ); - } else { - rewrite_result_asset_references(item, paths); - } - } - } - _ => rewrite_result_asset_references(value, paths), - } - } else if matches!(key.as_str(), "assetPath" | "path") { - if let Value::String(path) = value - && let Some(replacement) = paths.get(path) - { - *path = replacement.clone(); - } - } else if !matches!(key.as_str(), "rawSnapshot" | "rawPayload" | "originalValue") { - rewrite_result_asset_references(value, paths); - } - } - } - Value::Array(values) => { - for value in values { - rewrite_result_asset_references(value, paths); - } - } - _ => {} - } -} - -pub(super) fn projected_outputs_from_result( - result: &Map, -) -> Result, DevupError> { - let mut outputs = Vec::new(); - for field in ["tsx", "componentTsx", "responsiveTsx"] { - if let Some(tsx) = result.get(field).and_then(Value::as_str) { - outputs.push(ProjectedOutput::text( - field, - "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", - "application/json", - devup_json.as_bytes().to_vec(), - )); - } - if let Some(reference) = result.get("referencePng") { - let data = reference - .get("dataBase64") - .and_then(Value::as_str) - .ok_or_else(|| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "The referencePng resource has no base64 data.", - false, - ) - })?; - let bytes = STANDARD.decode(data.as_bytes()).map_err(|_| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "The referencePng resource base64 is invalid.", - false, - ) - })?; - outputs.push(ProjectedOutput::binary("reference.png", "image/png", bytes)); - } - for (field, name) in [ - ("rawSnapshot", "raw-snapshot.json"), - ("rawPayload", "raw-payload.json"), - ("sourceMap", "source-map.json"), - ("designFingerprints", "design-fingerprints.json"), - ("designChanges", "design-changes.json"), - ("assetManifest", "asset-manifest.json"), - ("imageFillAudit", "image-fill-audit.json"), - ("imageAudit", "image-audit.json"), - ("assetCssMap", "asset-css-map.json"), - ("webReadyImages", "web-ready-images.json"), - ("pageScaffold", "page-scaffold.json"), - ("outline", "outline.json"), - ("selected", "selected.json"), - ] { - if let Some(value) = result.get(field) { - if field == "imageFillAudit" && value["assets"].as_array().is_none_or(Vec::is_empty) { - continue; - } - outputs.push(ProjectedOutput::text( - name, - "application/json", - encode_projected_json(value)?, - )); - } - } - if let Some(frames) = result.get("frames").and_then(Value::as_array) { - for (index, frame) in frames.iter().enumerate() { - if let Some(scaffold) = frame.get("pageScaffold") { - outputs.push(ProjectedOutput::text( - format!("frame-{}.page-scaffold.json", index + 1), - "application/json", - encode_projected_json(scaffold)?, - )); - } - for field in ["tsx", "componentTsx"] { - if let Some(tsx) = frame.get(field).and_then(Value::as_str) { - let name = if field == "tsx" { - format!("frame-{}.tsx", index + 1) - } else { - format!("frame-{}.component.tsx", index + 1) - }; - outputs.push(ProjectedOutput::text( - name, - "text/typescript", - tsx.as_bytes().to_vec(), - )); - } - } - if let Some(source_map) = frame.get("sourceMap") { - outputs.push(ProjectedOutput::text( - format!("frame-{}.source-map.json", index + 1), - "application/json", - encode_projected_json(source_map)?, - )); - } - } - } - Ok(outputs) -} - -fn encode_projected_json(value: &Value) -> Result, DevupError> { - serde_json::to_vec(value).map_err(|error| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - format!("Cannot serialize the resource output to JSON: {error}"), - false, - ) - }) -} - -pub(super) struct DeliveryAttachment { - reservation: OutputReservation, -} - -/// Keep complete diagnostic evidence readable without duplicating it into a -/// multi-megabyte tool envelope. Only resource delivery changes these details; -/// diagnostic identities/counts/severities and verdicts remain inline. -fn resource_diagnostic_details( - result: &mut Value, - outputs: &mut Vec, - artifact_id: &str, -) -> Result<(), DevupError> { - const FIELDS: [&str; 3] = ["diagnostics", "projectionIssues", "projectionEvidence"]; - const BULKY: [&str; 5] = [ - "verdictScope", - "projectionReview", - "placementContracts", - "outputResults", - "widthBehavior", - ]; - let mut full = json!({"response":{},"frames":[]}); - for field in FIELDS.into_iter().chain(BULKY) { - if let Some(value) = result.get(field) { - full["response"][field] = value.clone(); - } - } - if let Some(frames) = result["frames"].as_array() { - for frame in frames { - let mut record = json!({"nodeId":frame["nodeId"]}); - for field in FIELDS.into_iter().chain(BULKY) { - if let Some(value) = frame.get(field) { - record[field] = value.clone(); - } - } - full["frames"].as_array_mut().unwrap().push(record); - } - } - let bytes = encode_projected_json(&full)?; - if bytes.len() <= super::delivery::MAX_INLINE_OUTPUT_BYTES / 2 { - return Ok(()); - } - let output = ProjectedOutput::text("projection-details.json", "application/json", bytes); - let uri = output.manifest_uri(artifact_id); - fn preview( - value: &Value, - depth: usize, - path: &str, - omissions: &mut Map, - ) -> Value { - let (original, returned) = match value { - Value::Array(v) => (v.len(), if depth > 0 { v.len().min(8) } else { 0 }), - Value::Object(v) => (v.len(), if depth > 0 { v.len().min(32) } else { 0 }), - Value::String(v) => (v.chars().count(), v.chars().count().min(320)), - _ => (0, 0), - }; - if original > returned { - omissions.insert(path.into(), json!({"originalCount":original,"returnedCount":returned,"omittedCount":original-returned})); - } - match value { - Value::String(s) => json!(s.chars().take(320).collect::()), - Value::Array(items) if depth > 0 => json!( - items - .iter() - .take(8) - .enumerate() - .map(|(i, v)| preview(v, depth - 1, &format!("{path}/{i}"), omissions)) - .collect::>() - ), - Value::Object(items) if depth > 0 => Value::Object( - items - .iter() - .take(32) - .map(|(k, v)| { - ( - k.clone(), - preview( - v, - depth - 1, - &format!("{path}/{}", k.replace('~', "~0").replace('/', "~1")), - omissions, - ), - ) - }) - .collect(), - ), - Value::Array(items) => json!({"state":"resource-details","entryCount":items.len()}), - Value::Object(items) => json!({"state":"resource-details","entryCount":items.len()}), - _ => value.clone(), - } - } - fn summarize(value: &mut Value, prefix: &str) { - for field in FIELDS { - if let Some(items) = value[field].as_array_mut() { - for (index, item) in items.iter_mut().enumerate() { - let Some(details) = item.get_mut("details").filter(|d| d.is_object()) else { - continue; - }; - let mut summary = Map::new(); - // Preserve small source values (notably FILL/HUG) and - // contracts; only bulky detail values need a resource. - for (key, value) in details.as_object().unwrap() { - if value.to_string().len() <= 512 - || (key == "originalValue" && value.to_string().len() <= 1024) - { - summary.insert(key.clone(), value.clone()); - } - } - for key in [ - "classification", - "nextAction", - "output", - "nodeIds", - "candidateCount", - "state", - "propertyMappingVerified", - ] { - if let Some(value) = details.get(key) { - summary.insert(key.into(), value.clone()); - } - } - if Value::Object(summary.clone()) == *details { - continue; - } - // Share the long URI once at the response root rather - // than repeating it for every diagnostic in both MCP forms. - summary.insert("fullDetailsResource".into(), json!({"uriPath":"/metadataDelivery/uri","jsonPointer":format!("{prefix}/{field}/{index}/details")})); - *details = Value::Object(summary); - } - } - } - for field in BULKY { - if let Some(original) = value - .get(field) - .filter(|v| v.to_string().len() > 16_384) - .cloned() - { - let mut omissions = Map::new(); - value[field] = preview(&original, 3, "", &mut omissions); - value["metadataPreviewFields"][field] = json!({"truncated":true,"uriPath":"/metadataDelivery/uri","jsonPointer":format!("{prefix}/{field}"),"originalBytes":original.to_string().len(),"omissions":omissions,"maxArrayEntries":8,"maxObjectEntries":32,"maxStringChars":320,"maxDepth":3}); - } - } - } - summarize(result, "/response"); - if let Some(frames) = result["frames"].as_array_mut() { - for (index, frame) in frames.iter_mut().enumerate() { - summarize(frame, &format!("/frames/{index}")); - } - } - result["metadataDelivery"] = json!({"mode":"resource-details","uri":uri,"name":"projection-details.json","fields":FIELDS.into_iter().chain(BULKY).collect::>(),"requiresFullDetailRead":true, - "note":"Diagnostic identities, counts, severity and verdicts remain inline; complete original details are in this resource. Read it before accepting projection evidence or final completeness."}); - outputs.push(output); - Ok(()) -} - -pub(super) async fn apply_delivery( - result: &mut Value, - mode: DeliveryMode, - artifact_store: &ArtifactStore, - artifact: &ArtifactLookup, - mut outputs: Vec, - reprojection_arguments: Option<&Value>, -) -> Result, DevupError> { - if outputs.is_empty() || choose_delivery_for_result(mode, result, &outputs)?.inline { - return Ok(None); - } - let frames = result["frames"].as_array(); - let selected_frame = frames.and_then(|frames| { - frames.iter().enumerate().find(|(_, frame)| { - frame.get("pageScaffold").is_some() - || ["tsx", "componentTsx"] - .iter() - .any(|field| frame.get(*field).is_some_and(Value::is_string)) - }) - }); - let frame = selected_frame.map(|(_, frame)| frame); - let selected = [ - "tsx", - "componentTsx", - "devupJson", - "rawSnapshot", - "rawPayload", - "responsiveTsx", - "assetManifest", - "imageAudit", - "pageScaffold", - "referencePng", - "designFingerprints", - "designChanges", - ] - .into_iter() - .find_map(|field| { - frame - .and_then(|f| f.get(field)) - .or_else(|| result.get(field)) - .map(|body| (field, body)) - }); - if let Some((field, body)) = selected { - let binary = field == "referencePng"; - let bytes = if binary { - outputs - .iter() - .find(|o| o.name == "reference.png") - .map_or(0, |o| o.bytes.len()) - } else { - body.as_str() - .map_or_else(|| serde_json::to_vec(body).unwrap().len(), str::len) - }; - let escaped_bytes = if binary { - bytes.div_ceil(3) * 4 - } else { - serde_json::to_vec(body).unwrap().len() - }; - let coupled = reprojection_arguments.is_some_and(|a| { - a.get("pageScaffold").is_some() - || a["preset"] == "web-ready-images" - || a["assetRequests"].as_array().is_some_and(|a| !a.is_empty()) - || (matches!( - a["devupJsonMode"].as_str(), - Some("used-only" | "missing-only") - ) && a["outputs"] - .as_array() - .is_some_and(|outputs| outputs.iter().any(|output| output == "devupJson"))) - }); - let mut args = reprojection_arguments.cloned().unwrap_or_else(|| json!({})); - if !coupled { - args["artifactId"] = json!(artifact.artifact_id); - args["outputs"] = json!([field]); - if let Some(frame) = frame { - args["frameIds"] = json!([frame["nodeId"]]); - args["allScreens"] = json!(false); - if frames.is_some_and(|frames| frames.len() > 1) - && let Some(name) = args["componentName"].as_str() - { - args["componentName"] = - json!(format!("{name}{}", selected_frame.unwrap().0 + 1)); - } - } - args["debug"] = json!(true); - args["delivery"] = json!( - if escaped_bytes * 2 > super::delivery::MAX_INLINE_TOTAL_BYTES { - "resource" - } else { - "inline" - } - ); - args.as_object_mut().unwrap().remove("outputPaths"); - args.as_object_mut().unwrap().remove("assetRequests"); - } - if args.get("pageScaffold").is_some() { - args["pageScaffold"]["write"] = json!(false); - } - if matches!( - args["devupJsonMode"].as_str(), - Some("used-only" | "missing-only") - ) && args.get("pageScaffold").is_none() - && !args["assetRequests"] - .as_array() - .is_some_and(|requests| !requests.is_empty()) - { - args.as_object_mut().unwrap().remove("outputPaths"); - } - let example = json!({"tool":"devup_figma_export","arguments":args}); - result["nextAction"] = json!({"tool":"devup_figma_export","arguments":args,"example":example, - "how":if coupled { "Read the linked resources. Preserve coupled code/theme outputs and asset requests/paths in this resource reprojection; splitting them can change token selection or generated URLs." } - else if field == "responsiveTsx" { "Read the linked resource or reproject this responsive output with the same breakpoint selection. A responsive merge requires multiple source screens." } - else { "Read the linked resources, or compare the same artifact using this single-screen, single-output reprojection. No new Figma acquisition is required. Inline errors retain R12 resource recovery arguments." }, - "sizeEstimate":{"outputBytes":bytes,"minimumWireBytes":escaped_bytes * 2, - "limitBytes":super::delivery::MAX_INLINE_TOTAL_BYTES,"isMeasuredReprojection":false, - "inlineFit":if escaped_bytes * 2 > super::delivery::MAX_INLINE_TOTAL_BYTES {"exceeds-limit"} else {"unknown"}, - "binary":binary, - "basis":"Existing output UTF-8/binary bytes; minimumWireBytes doubles its JSON encoding (base64 for PNG). This lower bound excludes metadata/diagnostics. The new projection's full MCP response has not been measured and can still exceed the limit."}}); - } - resource_diagnostic_details(result, &mut outputs, &artifact.artifact_id)?; - let projection_key = projection_key(&outputs); - materialize_asset_resource_references(result, &mut outputs, &artifact.artifact_id)?; - let reservation = artifact_store - .reserve_outputs(&artifact.artifact_id, &projection_key, outputs) - .await?; - let manifests = reservation.manifests().to_vec(); - if let Some(details) = manifests.iter().find(|m| m.name == "compact-details.json") { - result["selectionEvidence"]["uri"] = json!(details.manifest_uri); - } - if let Some(old_uri) = result["metadataDelivery"]["uri"] - .as_str() - .map(str::to_owned) - && let Some(manifest) = manifests - .iter() - .find(|m| m.name == "projection-details.json") - && old_uri != manifest.manifest_uri - { - fn rebind(value: &mut Value, old: &str, new: &str) { - match value { - Value::String(s) if s == old => *s = new.to_owned(), - Value::Array(items) => items.iter_mut().for_each(|v| rebind(v, old, new)), - Value::Object(items) => items.values_mut().for_each(|v| rebind(v, old, new)), - _ => {} - } - } - rebind(result, &old_uri, &manifest.manifest_uri); - } - let result = result.as_object_mut().ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "The resource delivery result is not a JSON object.", - false, - ) - })?; - for (field, name) in [ - ("imageFillAudit", "image-fill-audit.json"), - ("imageAudit", "image-audit.json"), - ("assetCssMap", "asset-css-map.json"), - ] { - if let Some(audit) = result.get_mut(field).and_then(Value::as_object_mut) { - audit.remove("assets"); - if let Some(manifest) = manifests.iter().find(|m| m.name == name) { - audit.insert("detailsResource".into(), json!({"uri":manifest.manifest_uri,"name":manifest.name,"contentHash":manifest.sha256,"requiresFullDetailRead":true})); - } - } - } - if let Some(plan) = result - .get_mut("webReadyImages") - .and_then(Value::as_object_mut) - { - plan.remove("plan"); - if let Some(manifest) = manifests.iter().find(|m| m.name == "web-ready-images.json") { - plan.insert("detailsResource".into(), json!({"uri":manifest.manifest_uri,"name":manifest.name,"contentHash":manifest.sha256,"requiresFullDetailRead":true})); - } - } - for field in [ - "tsx", - "componentTsx", - "responsiveTsx", - "devupJson", - "rawSnapshot", - "rawPayload", - "sourceMap", - "designFingerprints", - "designChanges", - "assetManifest", - "pageScaffold", - "referencePng", - "outline", - ] { - if field == "outline" - && result - .get("deliverable") - .is_some_and(|d| d["kind"] == "outline-data") - { - continue; - } - result.remove(field); - } - if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { - for frame in frames { - if let Some(frame) = frame.as_object_mut() { - frame.remove("tsx"); - frame.remove("componentTsx"); - frame.remove("sourceMap"); - frame.remove("pageScaffold"); - } - } - } - if let Some(deliverable) = result - .get_mut("deliverable") - .filter(|d| d["kind"] != "selected-data" && d["kind"] != "outline-data") - { - deliverable["note"] = json!(if deliverable["isFinal"] == true { - "The final TSX deliverables are in resources. Read the linked resources to implement them." - } else { - "The available TSX outputs are in resources. Review projectionIssues and outputResults before using them; these outputs are not final." - }); - } - result.insert( - "resources".to_owned(), - Value::Array( - manifests - .into_iter() - .map(|manifest| { - json!({ - "type": "resource_link", - "uri": manifest.manifest_uri, - "name": manifest.name, - "mimeType": manifest.mime_type, - "size": manifest.raw_bytes, - "contentHash": manifest.sha256, - "assetMetadata": manifest.asset_metadata, - "expiresAt": format_epoch_rfc3339(manifest.expires_at_epoch_seconds) - }) - }) - .collect(), - ), - ); - // Resource promotion must also bound the final metadata envelope. - if let Err(error) = - choose_delivery_for_result(DeliveryMode::Inline, &Value::Object(result.clone()), &[]) - { - reservation.rollback(); - return Err(error); - } - Ok(Some(DeliveryAttachment { reservation })) -} - -pub(super) async fn rollback_delivery( - _artifact_store: &ArtifactStore, - _artifact: &ArtifactLookup, - attachment: Option, -) { - if let Some(attachment) = attachment { - attachment.reservation.rollback(); - } -} - -pub(super) fn commit_delivery(attachment: Option) { - if let Some(attachment) = attachment { - attachment.reservation.commit(); - } -} - -fn materialize_asset_resource_references( - result: &mut Value, - outputs: &mut [ProjectedOutput], - artifact_id: &str, -) -> Result<(), DevupError> { - let Some(assets) = result - .get_mut("assetManifest") - .and_then(|manifest| manifest.get_mut("assets")) - .and_then(Value::as_array_mut) - else { - return Ok(()); - }; - for output in outputs.iter().filter(|output| output.asset_id.is_some()) { - let asset_id = output.asset_id.as_deref().unwrap_or_default(); - if !assets - .iter() - .any(|asset| asset["assetId"].as_str() == Some(asset_id)) - { - return Err(DevupError::new( - ErrorCode::DevupCodegenFailed, - "No manifest entry matches this asset resource.", - false, - )); - } - let format = assets - .iter() - .find(|asset| asset["assetId"].as_str() == Some(asset_id)) - .expect("asset checked above")["format"] - .clone(); - let hash = sha256_hex(&output.bytes); - for asset in assets.iter_mut() { - for variant in asset - .get_mut("variants") - .and_then(Value::as_array_mut) - .into_iter() - .flatten() - { - if variant["sha256"].as_str() == Some(hash.as_str()) - && variant["mimeType"] == output.mime_type - && variant["byteLength"].as_u64() == Some(output.bytes.len() as u64) - { - let variant = variant.as_object_mut().expect("variant object"); - variant.remove("dataBase64"); - variant.insert("resource".into(),json!({"uri":output.manifest_uri(artifact_id),"mimeType":output.mime_type,"byteLength":output.bytes.len(),"sha256":hash})); - } - } - } - for asset in assets.iter_mut().filter(|asset| { - asset["status"] == "exported" - && asset["sha256"].as_str() == Some(hash.as_str()) - && asset["mimeType"] == output.mime_type - && asset["format"] == format - }) { - let asset = asset.as_object_mut().expect("manifest asset object"); - asset.remove("dataBase64"); - asset.insert( - "resource".to_owned(), - json!({ - "uri":output.manifest_uri(artifact_id),"mimeType":output.mime_type, - "byteLength":output.bytes.len(),"sha256":hash,"assetMetadata":output.asset_metadata - }), - ); - } - } - if let Some(manifest_output) = outputs - .iter_mut() - .find(|output| output.name == "asset-manifest.json") - { - manifest_output.bytes = - encode_projected_json(result.get("assetManifest").ok_or_else(|| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "The asset manifest resource is missing.", - false, - ) - })?)?; - } - Ok(()) -} - -fn projected_asset_outputs(manifest: &AssetManifest) -> Result, DevupError> { - let mut outputs = Vec::new(); - let mut seen = std::collections::BTreeSet::new(); - let invalid = - |message: &str| DevupError::new(ErrorCode::DevupSnapshotUnsupported, message, false); - for (index, asset) in manifest - .assets - .iter() - .enumerate() - .filter(|(_, a)| a.status == AssetStatus::Exported) - { - let mut renditions = vec![( - asset.data_base64.as_deref(), - asset.byte_length, - asset.sha256.as_deref(), - asset.mime_type.as_deref(), - None, - )]; - renditions.extend(asset.variants.iter().map(|v| { - ( - v.data_base64.as_deref(), - Some(v.byte_length), - Some(v.sha256.as_str()), - Some(v.mime_type.as_str()), - Some(v), - ) - })); - for (data, length, hash, mime, variant) in renditions { - let data = - data.ok_or_else(|| invalid("The exported asset binary is not in the artifact."))?; - if data.len() > devup_mcp_figma::MAX_ASSET_BYTES.div_ceil(3) * 4 { - return Err(invalid("Asset base64 exceeds its encoded budget.")); - } - let bytes = STANDARD - .decode(data) - .map_err(|_| invalid("The exported asset binary base64 is invalid."))?; - let mime = mime.ok_or_else(|| invalid("The exported asset has no MIME type."))?; - if length != Some(bytes.len()) || hash != Some(sha256_hex(&bytes).as_str()) { - return Err(invalid("The exported asset length or hash does not match.")); - } - if let Some(variant) = variant { - if mime != "image/webp" || !variant.density.is_finite() || variant.density <= 1.0 { - return Err(invalid("Invalid srcset derivative identity.")); - } - let reader = image::ImageReader::new(std::io::Cursor::new(&bytes)) - .with_guessed_format() - .map_err(|_| invalid("Invalid variant codec."))?; - if reader.into_dimensions().ok() != Some((variant.width, variant.height)) { - return Err(invalid("Variant encoded dimensions do not match.")); - } - } - let extension = if variant.is_some() { - "webp" - } else { - asset - .format - .map_or("bin", devup_mcp_figma::AssetFormat::extension) - }; - if !seen.insert((sha256_hex(&bytes), extension.to_owned(), mime.to_owned())) { - continue; - } - let mut output = ProjectedOutput::asset( - format!( - "asset-{}{}.{extension}", - index + 1, - if variant.is_some() { "-2x" } else { "" } - ), - mime, - bytes, - &asset.asset_id, - ); - output.asset_metadata = Some( - json!({"assets":manifest.assets.iter().filter(|other|other.status==AssetStatus::Exported&&(other.sha256.as_deref()==hash || other.variants.iter().any(|v|Some(v.sha256.as_str())==hash))).map(|other|json!({"assetId":other.asset_id,"nodeId":other.node_id,"renderMode":other.render_mode,"format":other.format,"scale":other.scale,"quality":other.quality,"lossless":other.lossless,"maxWidth":other.max_width,"srcset":other.srcset,"visibleScopeNodeId":other.visible_scope_node_id,"renderInfo":other.render_info,"suggestedFilename":other.suggested_filename})).collect::>(),"density":variant.map(|v|v.density)}), - ); - outputs.push(output); - } - } - Ok(outputs) -} - -/// Bound generated filenames, retaining a hash when transliteration would lose -/// identity. The generated folders are short; user-chosen output roots remain -/// subject to OutputPolicy and the host filesystem's own path limits. -fn host_safe_file_name(name: &str) -> String { - let (stem, extension) = name.rsplit_once('.').unwrap_or((name, "")); - let mut slug = stem - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '@') { - c - } else { - '-' - } - }) - .collect::(); - slug = slug.trim_matches('-').to_owned(); - let upper = slug.to_ascii_uppercase(); - let reserved = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") - || (upper.len() == 4 - && (upper.starts_with("COM") || upper.starts_with("LPT")) - && matches!(upper.as_bytes()[3], b'1'..=b'9')); - let needs_hash = - !name.is_ascii() || slug != stem || slug.len() > 120 || slug.is_empty() || reserved; - if slug.is_empty() || reserved { - slug.insert_str(0, "asset-"); - } - if needs_hash { - slug.truncate(100); - } - if needs_hash { - slug.push('-'); - slug.push_str(&sha256_hex(name.as_bytes())[..12]); - } - let extension = extension - .chars() - .filter(char::is_ascii_alphanumeric) - .take(10) - .collect::(); - if !extension.is_empty() { - slug.push('.'); - slug.push_str(&extension); - } - slug -} - -/// `/icons/ic:round-arrow-left.svg` as `/icons/ic-round-arrow-left-b041d24e668d.svg`. Only -/// the file name is touched; the folder the code looks in is left alone. -fn host_safe_asset_path(path: &str) -> String { - for prefix in ["/icons/", "/images/"] { - if let Some(name) = path.strip_prefix(prefix) { - return format!("{prefix}{}", host_safe_file_name(name)); - } - } - match path.rsplit_once('/') { - Some((directory, name)) => format!("{directory}/{}", host_safe_file_name(name)), - None => host_safe_file_name(path), - } -} - -/// Every asset the generated code points at, renamed the way its bytes will -/// be written. The code holds each path inside a string or a `url(...)`, so a -/// name runs to the first quote or closing parenthesis - spaces and dots -/// belong to it, as they do in `Frame 269.png`. -fn with_host_safe_asset_paths(code: &str) -> String { - let mut safe = String::with_capacity(code.len()); - let mut rest = code; - loop { - let Some((at, prefix)) = ["/icons/", "/images/"] - .into_iter() - .filter_map(|prefix| rest.find(prefix).map(|at| (at, prefix))) - .min_by_key(|(at, _)| *at) - else { - safe.push_str(rest); - return safe; - }; - let after = at + prefix.len(); - safe.push_str(&rest[..after]); - let mut end = rest[after..] - .find(['"', '\'', ')']) - .map_or(rest.len(), |offset| after + offset); - // srcSet contains several URLs followed by numeric density/width - // descriptors. Keep each URL separate while retaining spaces inside - // ordinary quoted layer filenames. - let candidate = &rest[after..end]; - let first = candidate - .split_once(',') - .map_or(candidate, |(first, _)| first) - .trim_end(); - if let Some((filename, descriptor)) = first.rsplit_once(char::is_whitespace) - && descriptor - .strip_suffix('x') - .or_else(|| descriptor.strip_suffix('w')) - .and_then(|n| n.parse::().ok()) - .is_some_and(|n| n.is_finite() && n > 0.0) - { - end = after + filename.trim_end().len(); - } - safe.push_str(&host_safe_file_name(&rest[after..end])); - rest = &rest[end..]; - } -} - -fn sha256_hex(bytes: &[u8]) -> String { - Sha256::digest(bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - -/// Compare the generated text, removing only known asset references in codegen's -/// src and CSS URL forms. Literal text, layout, imports and component names stay -/// significant. Separate segments avoid collisions with a placeholder string. -fn generated_structure(code: &str, paths: &[String]) -> Vec { - let mut ranges = Vec::new(); - for attribute in ["src", "bg", "backgroundImage", "maskImage"] { - let opening = format!("{attribute}=\""); - for (at, _) in code.match_indices(&opening) { - // An attribute name must start at a word boundary, not inside an - // unrelated prop. Codegen quotes literal text containing quotes. - if !code[..at] - .chars() - .next_back() - .is_some_and(char::is_whitespace) - { - continue; - } - let start = at + opening.len(); - let Some(end) = code[start..].find('"') else { - continue; - }; - let value = &code[start..start + end]; - for path in paths { - if attribute == "src" { - if value == path { - ranges.push((start, start + path.len())); - } - continue; - } - // Preserve all paint layers and positions while recognizing - // every image, including URLs after gradients or other images. - for (prefix, suffix) in [("url(", ")"), ("url('", "')")] { - let reference = format!("{prefix}{path}{suffix}"); - for (offset, _) in value.match_indices(&reference) { - let asset_start = start + offset + prefix.len(); - ranges.push((asset_start, asset_start + path.len())); - } - } - } - } - } - ranges.sort_unstable(); - ranges.dedup(); - let mut segments = Vec::new(); - let mut previous = 0; - for (start, end) in ranges { - if start < previous { - continue; - } - segments.push(code[previous..start].to_owned()); - previous = end; - } - segments.push(code[previous..].to_owned()); - segments -} - -fn annotate_identical_frames(frames: &mut [Value], payload: &CollectedPayload, per_node: bool) { - let paths = devup_mcp_figma::discover_asset_manifest(&payload.snapshot) - .assets - .iter() - .filter_map(|asset| { - match asset - .field - .strip_prefix("fills/") - .and_then(|n| n.parse::().ok()) - { - Some(index) => devup_mcp_devup_ui::codegen::image_fill_path( - &payload.snapshot, - &asset.node_id, - index, - per_node, - ), - None => devup_mcp_devup_ui::codegen::asset_path( - &payload.snapshot, - &asset.node_id, - per_node, - ), - } - }) - .map(|path| host_safe_asset_path(&path)) - .collect::>(); - let mut seen = BTreeMap::new(); - for frame in frames { - // Fidelity warnings may make a frame partial without changing the - // generated text. Compare exactly the outputs that exist; the key and - // comparedOutputs prevent missing outputs from implying equivalence. - let generated = ["tsx", "componentTsx"] - .into_iter() - .filter_map(|field| frame[field].as_str().map(|code| (field, code.to_owned()))) - .collect::>(); - if generated.is_empty() { - continue; - } - let key = generated - .iter() - .map(|(field, code)| (*field, generated_structure(code, &paths))) - .collect::>(); - if let Some((node_id, original)) = seen.get(&key) { - frame["structurallyIdenticalTo"] = json!(node_id); - frame["differsOnly"] = if original == &generated { - json!([]) - } else { - json!(["assetReferences"]) - }; - frame["comparedOutputs"] = json!( - generated - .iter() - .map(|(field, _)| *field) - .collect::>() - ); - } else { - seen.insert(key, (frame["nodeId"].clone(), generated)); - } - } -} - -/// This describes collection, even when the caller did not request a manifest. -/// Missing evidence is never reported as proof that the design has no assets. -fn asset_summary( - payload: &CollectedPayload, - artifact: &ArtifactLookup, - roots: &[String], - manifest_requested: bool, -) -> Value { - let mut snapshot = payload.snapshot.clone(); - snapshot.roots = roots.to_vec(); - let mut reachable = std::collections::BTreeSet::new(); - let mut pending = roots.to_vec(); - while let Some(id) = pending.pop() { - if !reachable.insert(id.clone()) { - continue; - } - if let Some(node) = snapshot.nodes.get(&id) { - pending.extend(node.typed_view().child_ids().map(str::to_owned)); - } - } - snapshot.nodes.retain(|id, _| reachable.contains(id)); - let mut assets = asset_policy::discovery( - &snapshot, - &payload.assets, - artifact.capabilities.web_ready_images, - ) - .assets; - for captured in payload - .assets - .iter() - .filter(|asset| reachable.contains(&asset.node_id)) - { - if let Some(existing) = assets - .iter_mut() - .find(|asset| asset.asset_id == captured.asset_id) - { - *existing = captured.clone(); - } else { - assets.push(captured.clone()); - } - } - let index_only = artifact.capabilities.kind == super::artifacts::ArtifactKind::SectionIndex; - let incomplete = index_only - || snapshot.audit().state != devup_mcp_figma::CompletenessState::Complete - || snapshot - .nodes - .values() - .any(|node| node.typed_view().bool("projectionTruncated") == Some(true)); - let collected = assets - .iter() - .filter(|asset| asset.status == AssetStatus::Exported) - .count(); - let unavailable = assets.iter().filter(|asset| asset.status != AssetStatus::Exported).map(|asset| { - let reason = match asset.error_code.as_deref() { - Some("DEVUP_ASSET_NODE_HIDDEN") => "hidden-node", - Some(_) => "export-failed", - None if artifact.capabilities.asset_capture_count == 0 => "not-requested", - None => "capture-not-in-artifact", - }; - let mut entry = json!({"assetId":asset.asset_id,"nodeId":asset.node_id,"reason":reason,"errorCode":asset.error_code}); - if let Some(detail) = &asset.error_detail { - entry["errorDetail"] = json!(detail); - } - entry - }).collect::>(); - let excluded = assets - .iter() - .filter(|asset| asset.error_code.as_deref() == Some("DEVUP_ASSET_NODE_HIDDEN")) - .count(); - let status = if assets.is_empty() { - if incomplete { "unknown" } else { "none" } - } else if collected == 0 { - "not-collected" - } else if collected < assets.len() - excluded || incomplete { - "partial" - } else { - "collected" - }; - let description = match status { - "none" => "Complete discovery found no assets.", - "unknown" => "Incomplete discovery cannot establish whether assets exist.", - "not-collected" => { - "Assets were discovered but no binary bytes were collected; unavailable explains unrequested captures, hidden exclusions, or export failures." - } - "partial" => { - "Only some deliverable asset bytes were collected, or discovery is incomplete. capture-not-in-artifact means not collected in this batch, not an export failure. Merge saved batch responses with devup-mcp --merge-asset-batches batch1.json batch2.json." - } - "collected" => "All discovered non-hidden deliverable assets have collected binary bytes.", - _ => unreachable!(), - }; - json!({"status":status,"description":description,"excludedCount":excluded,"scopeRootIds":roots, - "discovery":if incomplete { "incomplete" } else { "complete" }, - "discoveryReason":if index_only { Some("index-only") } else if incomplete { Some("snapshot-incomplete") } else { None }, - "discoveredCount":assets.len(),"collectedCount":collected, - "manifestIncluded":manifest_requested,"unavailable":unavailable, - "batchMerge":{"command":"devup-mcp --merge-asset-batches batch1.json batch2.json","input":"Saved complete export responses, MCP response envelopes, or poll records containing response","scope":"union-of-supplied-batches"}}) -} - -/// Binary collection quality, independent of whether a manifest was delivered. -fn asset_collection_quality(summary: &Value) -> super::quality::AssetsQuality { - use super::quality::AssetsQuality; - match summary["status"].as_str() { - Some("collected") => AssetsQuality::Complete, - Some("partial") => AssetsQuality::Partial, - Some("not-collected") - if summary["unavailable"].as_array().is_some_and(|items| { - items.iter().any(|item| { - matches!( - item["reason"].as_str(), - Some("export-failed" | "capture-not-in-artifact") - ) - }) - }) => - { - AssetsQuality::Partial - } - _ => AssetsQuality::NotRequested, - } -} - -fn projection_key(outputs: &[ProjectedOutput]) -> String { - let mut hasher = Sha256::new(); - for output in outputs { - hasher.update(output.name.as_bytes()); - hasher.update([0]); - hasher.update(output.mime_type.as_bytes()); - hasher.update([u8::from(output.is_binary)]); - hasher.update((output.bytes.len() as u64).to_le_bytes()); - hasher.update(&output.bytes); - } - let digest = hasher.finalize(); - let mut encoded = String::with_capacity(digest.len() * 2); - for byte in digest { - let _ = write!(&mut encoded, "{byte:02x}"); - } - encoded -} - -pub(super) fn artifact_metadata(artifact: &ArtifactLookup) -> Value { - let origin_collection = &artifact.payload.stats; - json!({ - "artifactId": artifact.artifact_id, - "contentHash": artifact.content_hash, - "cacheHit": artifact.cache_hit, - "reuseKind": artifact.reuse_kind, - "ageSeconds": artifact.age_seconds, - "remainingTtlSeconds": artifact.remaining_ttl_seconds, - "avoidedFigmaToolCalls": if artifact.cache_hit { origin_collection.figma_tool_calls } else { 0 }, - "originCollection": origin_collection, - "capabilities": artifact.capabilities, - "reuse": reuse_summary(&artifact.capabilities), - "sizeBytes": artifact.size_bytes, - "acquiredAt": format_epoch_rfc3339(artifact.created_at_epoch_seconds), - "expiresAt": format_epoch_rfc3339(artifact.expires_at_epoch_seconds) - }) -} - -/// Which widths the generated code is checked at, and how: the mapping and the -/// width-change report are static, and nothing here was rendered. Said apart -/// so an exact grade at the drawn width is not read as a claim about others. -fn attach_verification_scope(value: &mut Value, responsive_widths: &[f64]) { - let mut widths = value["widthBehavior"]["designWidth"] - .as_f64() - .into_iter() - .chain( - value["frames"] - .as_array() - .into_iter() - .flatten() - .filter_map(|frame| frame["widthBehavior"]["designWidth"].as_f64()), - ) - .chain(responsive_widths.iter().copied()) - .collect::>(); - widths.sort_by(f64::total_cmp); - widths.dedup(); - if widths.is_empty() { - return; - } - let mut scope = json!({ - "collectedWidths": widths, - "staticallyChecked": { - "mapping": "Each generated property against its source field, at the collected widths (quality.projection).", - "widthChange": "Anchors and sizing intent when a container is not its drawn width (widthBehavior); read from constraints and geometry, not rendered.", - }, - "rendered": { - "state": "not-performed", - "note": "Nothing was rendered. devup_visual_compare compares a consumer-rendered PNG at a drawn width; devup_runtime_audit plans and ingests a consumer-side browser run at any widths.", - }, - }); - if !responsive_widths.is_empty() { - scope["betweenCollectedWidths"] = json!( - "responsiveTsx switches values at breakpoints; widths between and beyond the collected ones were never drawn." - ); - } - value["verificationScope"] = scope; -} - -/// Which further outputs this artifactId can project without calling Figma, -/// and which need the original url again. Each output keeps its own -/// preconditions (responsiveTsx still needs several widths); this only says -/// whether the capture holds what it reads. -fn reuse_summary(capabilities: &super::artifacts::ArtifactCapabilities) -> Value { - use super::artifacts::ArtifactKind; - use devup_mcp_figma::ResourceScope; - let mut with_artifact = Vec::new(); - let mut needs_url = Vec::new(); - match capabilities.kind { - ArtifactKind::Design => { - with_artifact.extend([ - "outline", - "tsx", - "componentTsx", - "responsiveTsx", - "sourceMap", - "assetManifest", - "designFingerprints", - "designChanges", - "pageScaffold", - "rawSnapshot", - "rawPayload", - ]); - if capabilities.resource_scope == ResourceScope::None { - needs_url.push("devupJson"); - } else { - with_artifact.push("devupJson"); - } - if capabilities.resource_scope != ResourceScope::File { - needs_url.push("devupJson:scope=file"); - } - if capabilities.reference_png { - with_artifact.push("referencePng"); - } else { - needs_url.push("referencePng"); - } - needs_url.push("assetRequests-not-yet-captured"); - } - ArtifactKind::ThemeOnly => { - with_artifact.push("devupJson"); - needs_url.push("design-outputs"); - } - ArtifactKind::SectionIndex => {} - ArtifactKind::Measurement | ArtifactKind::Search | ArtifactKind::Explore => { - needs_url.push("export-outputs") - } - } - let mut reuse = json!({ - "withArtifactId": with_artifact, - "needsUrl": needs_url, - "processBound": "Held in this devup-mcp process's memory until expiresAt; a restarted or different process cannot serve this artifactId.", - }); - if capabilities.kind == ArtifactKind::SectionIndex { - reuse["selection"] = json!( - "frameIds or allScreens with this artifactId collects the selected screens; nothing is projected from the index itself." - ); - } - reuse -} - -/// Adds `completenessReport` only when it has something to say. -/// -/// `quality.acquisition` already grades the capture, and on a clean one the -/// report underneath it is six empty arrays and a row of counters that agree -/// with the grade. Measured on one export it was 360 of the 1,911 bytes every -/// response carried before this, none of it actionable. It is attached -/// whenever the capture is not clean, and whenever the caller asked for -/// diagnostics and therefore wants the detail regardless. -/// Adds `fidelity` whenever the report has something `quality` does not say. -/// -/// Keyed on the report itself, not on `quality.projection`. That was the -/// first attempt and it hid a real defect: `projection_quality` is computed -/// from diagnostics alone, so a coverage shortfall that raises no diagnostic -/// leaves the grade reading `exact` and took the report away with it. -/// Measured on a real 50-node screen, `variables` was 19 of 20 - a `$primary` -/// binding frozen into an exported SVG - while `quality.projection` said -/// `exact`, so the response went from showing the shortfall to hiding it. -/// -/// `strict_compatible` is the same predicate `strict: true` refuses on, which -/// keeps the two from disagreeing about whether anything was lost. On the ten -/// real captured screens it is true - nothing sent - for every screen with no -/// shortfall and no approximation, which is what this is for. -fn attach_fidelity( - response: &mut Value, - report: &devup_mcp_devup_ui::provenance::FidelityReport, - include_diagnostics: bool, -) { - if include_diagnostics || !report.strict_compatible() { - response["fidelity"] = json!(report); - } -} - -fn attach_completeness_report( - response: &mut Value, - quality: OutputQuality, - report: &devup_mcp_figma::PayloadCompletenessReport, - include_diagnostics: bool, -) { - let clean = matches!( - quality.acquisition, - AcquisitionQuality::Complete | AcquisitionQuality::ExpectedProjection - ); - if include_diagnostics || !clean { - response["completenessReport"] = json!(report); - } -} - -fn unavailable_output(payload: &CollectedPayload, output: &str, reason: &str) -> DevupError { - DevupError::with_details( - ErrorCode::DevupInvalidInput, - format!("{output} cannot be produced. {reason}"), - false, - json!({"output":output,"nodeId":payload.target.node_id,"snapshotRootIds":payload.snapshot.roots, - "collectedTargetIds":payload.snapshot.roots}), - ) -} - -fn projection_failure( - payload: &CollectedPayload, - node_id: &str, - output: &str, - error: &DevupError, -) -> Value { - let mut details = error.details.as_object().cloned().unwrap_or_default(); - details.insert("nodeId".into(), json!(node_id)); - details.insert("snapshotRootIds".into(), json!(payload.snapshot.roots)); - details.insert("collectedTargetIds".into(), json!(payload.snapshot.roots)); - json!({"nodeId":node_id,"output":output,"errorCode":error.code,"message":error.message, - "retryable":error.retryable,"details":details}) -} - -/// Marks the page root, its top-level children (the sections), their -/// children and every asset with `data-figma-node-id`, so the runtime audit captures a section -/// or an asset by the node it came from. Only for exports into a project -/// (`projectRoot`); `.devup/screen-plan.config.json` `figmaNodeIds: false` -/// turns it off. -fn annotate_node_ids( - output: &mut devup_mcp_devup_ui::codegen::CodegenOutput, - snapshot: &devup_mcp_figma::Snapshot, - root_id: &str, -) -> Value { - let sections = snapshot - .nodes - .get(root_id) - .map(|node| { - node.typed_view() - .child_ids() - .map(str::to_owned) - .collect::>() - }) - .unwrap_or_default(); - // A section's own children (the cards of a card row) too, so their - // order and position are checked against the frame. - let parts = sections - .iter() - .filter_map(|id| snapshot.nodes.get(id)) - .flat_map(|node| { - node.typed_view() - .child_ids() - .map(str::to_owned) - .collect::>() - }) - .collect::>(); - let assets = output - .source_map - .entries - .iter() - .filter(|entry| entry.asset_id.is_some()) - .filter_map(|entry| entry.node_id.clone()) - .collect::>(); - let wanted = std::iter::once(root_id.to_owned()) - .chain(sections.iter().cloned()) - .chain(parts.iter().cloned()) - .chain(assets.iter().cloned()) - .collect(); - let written = devup_mcp_devup_ui::codegen::node_ids::annotate_node_ids(output, &wanted); - json!({ - "attribute": devup_mcp_devup_ui::codegen::node_ids::NODE_ID_ATTRIBUTE, - "root": written.iter().any(|id| id == root_id).then_some(root_id), - "sections": sections.iter().filter(|id| written.contains(id)).collect::>(), - "parts": parts.iter().filter(|id| written.contains(id)).collect::>(), - "assets": assets.iter().filter(|id| written.contains(id)).collect::>(), - "note": "Inert data attributes the runtime audit locates sections and assets by. Keep them; to drop them from a production build use Next's compiler.reactRemoveProperties {properties: [\"^data-figma-node-id$\"]}, or set figmaNodeIds false in .devup/screen-plan.config.json and re-export.", - }) -} - -fn generate_collected_component( - payload: &CollectedPayload, - node_id: &str, - options: &CodegenOptions, -) -> Result { - if !options.inline_instances { - let mut pending = vec![node_id]; - let mut visited = std::collections::BTreeSet::new(); - while let Some(id) = pending.pop() { - if !visited.insert(id) { - continue; - } - let Some(node) = payload.snapshot.nodes.get(id) else { - continue; - }; - let view = node.typed_view(); - if view.bool("visible") == Some(false) { - continue; - } - if node.node_type == "INSTANCE" { - let component_id = view - .string("mainComponentId") - .or_else(|| view.string("componentId")) - .or_else(|| view.string("mainComponent")) - .or_else(|| { - view.value("mainComponent") - .and_then(|value| value.get("id")) - .and_then(Value::as_str) - }); - if let Some(component_id) = component_id - && !payload.snapshot.nodes.contains_key(component_id) - { - return Err(DevupError::with_details( - ErrorCode::DevupFigmaNodeNotFound, - "componentTsx needs a component master outside this snapshot. Re-collect the component master, or request tsx to inline the instance.", - false, - json!({"componentId":component_id,"instanceNodeId":id}), - )); - } - } - pending.extend(view.child_ids()); - } - } - generate_component(&payload.snapshot, node_id, options) -} - -pub(super) async fn complete_operation( - operation: PendingOperation, - payload: &CollectedPayload, - source_kind: &str, - artifact: &ArtifactLookup, - output_policy: &OutputPolicy, - artifact_store: &ArtifactStore, -) -> Result { - let project_root = match &operation { - PendingOperation::Export { theme_options, .. } => theme_options.project_root.clone(), - _ => None, - }; - let mut response = project_operation( - operation, - payload, - source_kind, - artifact, - output_policy, - artifact_store, - ) - .await?; - if let Some(source) = response - .get_mut("source") - .filter(|source| source.is_object()) - { - attach_bridge_source(source, payload); - } - // What this export wrote for each marked node, so the audit can tell - // code drift from a converter bug later. - if let Some(root) = project_root.as_deref() - && response.get("figmaNodeIds").is_some() - { - let root_node = payload.target.node_id.clone().unwrap_or_default(); - match super::export_record::write( - output_policy, - root, - &payload.target.file_key, - &root_node, - &response, - ) { - Ok(path) => response["exportRecord"] = json!(path), - Err(error) => response["exportRecordError"] = json!(error.message), - } - } - Ok(response) -} - -/// Names the Devup Bridge plugin when it carried the design, and never claims -/// a file key the plugin did not report. -/// -/// Every answer used to say `kind: "direct"` whatever carried it, and repeated -/// the request's key as the file's - including a key an agent had invented to -/// get past the url field, which the plugin served only because it was the one -/// attached. So the agent could not tell which path had served it, and was -/// told its own invention was the file. -fn attach_bridge_source(source: &mut Value, payload: &CollectedPayload) { - let fallbacks = &payload.stats.direct_fallbacks; - if !fallbacks.is_empty() { - let reasons: std::collections::BTreeSet<&str> = - fallbacks.iter().map(String::as_str).collect(); - let hints: Vec<&str> = reasons - .iter() - .filter_map(|reason| match *reason { - "file-out-of-sight" => Some( - "Figma had stopped fetching data for the file because it was out of sight - its tab behind another tab, or the window minimized or covered by another window - so the plugin's reads came back short and were read again on the metered direct path. Keep the file visible in the Figma desktop app to keep every read on the plugin.", - ), - "plugin-older-than-server" => Some( - "The Devup Bridge plugin is older than this devup-mcp and did not know some reads, so they went to the metered direct path. Update the repository the plugin was imported from and run the plugin again.", - ), - _ => None, - }) - .collect(); - source["directFallbacks"] = json!(fallbacks.len()); - source["directFallbackReasons"] = json!(reasons); - if !hints.is_empty() { - source["directFallbackHint"] = json!(hints.join(" ")); - } - } - let Some(served) = &payload.stats.bridge else { - return; - }; - // A reused acquisition stays `artifact` - this call read nothing - and - // says where the acquisition came from. - if source["kind"] == "direct" { - source["kind"] = json!("bridge"); - } else { - source["origin"] = json!("bridge"); - } - source["bridgePort"] = json!(served.port); - source["fileKey"] = json!(served.file_key); - if served.file_key.is_none() { - source["fileKeyReason"] = json!( - "The Devup Bridge plugin that served this could not report its file key (figma.fileKey is empty, as in Dev Mode), so no file key is claimed." - ); - if !payload.target.is_bridge_only() { - source["requestedFileKey"] = json!(payload.target.file_key); - } - } - source["fileName"] = json!(served.file_name); - source["pageName"] = json!(served.page_name); - source["bridgeReads"] = json!(served.reads); -} - -/// The file key an answer may state: the plugin's own report when the bridge -/// read the file, and the requested key when Figma itself served it. -fn vouched_file_key(payload: &CollectedPayload) -> Option<&str> { - match &payload.stats.bridge { - Some(served) => served.file_key.as_deref(), - None => Some(payload.target.file_key.as_str()), - } -} - -async fn project_operation( - mut operation: PendingOperation, - payload: &CollectedPayload, - source_kind: &str, - artifact: &ArtifactLookup, - output_policy: &OutputPolicy, - artifact_store: &ArtifactStore, -) -> Result { - let collection = if artifact.cache_hit { - CollectionStats::default() - } else { - payload.stats.clone() - }; - let completeness_report = payload.completeness_report(); - if source_kind == "artifact" - && let PendingOperation::Export { - frame_ids, - all_screens, - .. - } = &mut operation - && frame_ids.is_empty() - && !*all_screens - && let Some(saved) = &artifact.capabilities.section_selection - { - frame_ids.clone_from(&saved.frame_ids); - *all_screens = saved.all_screens; - } - match operation { - PendingOperation::Search { - query, - node_types, - match_kind, - limit, - } => { - let matches = search_snapshot( - &payload.snapshot, - &payload.target, - &SearchOptions { - query: query.clone(), - node_types, - match_kind, - limit, - }, - )?; - let quality = OutputQuality { - acquisition: acquisition_quality(&completeness_report, true), - projection: projection_quality(false, &[]), - theme: theme_quality(false, 0, 0), - assets: assets_quality(false, &[], &[]), - }; - let mut response = json!({ - "status": quality.status(), - "quality": quality, - "query": query, - "count": matches.len(), - "matches": matches, - "completeness": payload.completeness, - "collection": collection, - "cache": artifact_metadata(artifact), - "source": { - "kind": source_kind, - "fileKey": payload.target.file_key, - "version": payload.snapshot.version - } - }); - attach_completeness_report(&mut response, quality, &completeness_report, false); - Ok(response) - } - PendingOperation::Explore { limit, target } => { - let result = explore_snapshot(&payload.snapshot, &target, &ExploreOptions { limit })?; - let count = result.candidates.len(); - let quality = OutputQuality { - acquisition: acquisition_quality(&completeness_report, true), - projection: projection_quality(false, &[]), - theme: theme_quality(false, 0, 0), - assets: assets_quality(false, &[], &[]), - }; - let mut response = json!({ - "status": quality.status(), - "quality": quality, - "targetKind": result.target_kind, - "anchor": result.anchor, - "group": result.group, - "count": count, - "candidates": result.candidates, - "truncated": result.truncated, - "diagnostics": payload.snapshot.diagnostics, - "completeness": payload.completeness, - "collection": collection, - "cache": artifact_metadata(artifact), - "source": { - "kind": source_kind, - "fileKey": target.file_key, - "nodeId": target.node_id, - "version": payload.snapshot.version - } - }); - attach_completeness_report(&mut response, quality, &completeness_report, false); - Ok(response) - } - PendingOperation::Export { - outputs, - previous_design_fingerprints, - component_name, - include_diagnostics, - root_layout, - asset_names_per_node, - scope, - theme_options, - strict, - output_paths, - page_scaffold, - frame_ids, - all_screens, - mut asset_captures, - web_ready_images, - mut asset_output_paths, - mut asset_public_root, - fields, - response_selection, - delivery, - width_policy, - } => { - if web_ready_images { - asset_captures = payload - .assets - .iter() - .filter_map(|a| { - Some(devup_mcp_figma::AssetSelection { - usage_aspect_ratio: a.usage_aspect_ratio, - max_width: a.max_width, - srcset: a.srcset, - visible_scope_node_id: a.visible_scope_node_id.clone(), - asset_id: a.asset_id.clone(), - format: a.format?, - scale: a.scale?, - render_mode: a.render_mode, - quality: a.quality, - lossless: a.lossless, - }) - }) - .collect(); - if let Some(root) = &asset_public_root { - for capture in &asset_captures { - let name = format!("{}.webp", sha256_hex(capture.asset_id.as_bytes())); - asset_output_paths.insert( - capture.asset_id.clone(), - root.join("images") - .join("web-ready") - .join(name) - .display() - .to_string(), - ); - } - } - } - let asset_policies = - asset_policy::policies(&payload.snapshot, &payload.assets, asset_names_per_node); - let selected_fields = super::field_select::expand(&fields); - response_selection.validate()?; - let compact_roots = if frame_ids.is_empty() { - &payload.snapshot.roots - } else { - &frame_ids - }; - let node_summaries = (response_selection.active() - || outputs.iter().any(|o| o == "outline")) - .then(|| super::compact::node_summaries(&payload.snapshot, compact_roots)); - page_scaffold::validate(page_scaffold.as_ref(), &outputs)?; - super::validation::validate_width_policy_nodes( - width_policy.as_ref(), - &payload.snapshot, - )?; - let previous_fingerprints = super::design_drift::validate_request( - &outputs, - previous_design_fingerprints.as_deref(), - )?; - let design_fingerprints = outputs - .iter() - .any(|output| { - matches!( - output.as_str(), - "sourceMap" | "designFingerprints" | "designChanges" - ) - }) - .then(|| devup_mcp_devup_ui::provenance::design_fingerprints(&payload.snapshot)); - let scaffold_requested = page_scaffold.is_some(); - if (scaffold_requested - || outputs - .iter() - .any(|o| matches!(o.as_str(), "tsx" | "componentTsx" | "responsiveTsx"))) - && payload - .assets - .iter() - .any(|a| a.render_mode == devup_mcp_figma::AssetRenderMode::Fill) - { - return Err(DevupError::new( - ErrorCode::DevupInvalidInput, - "Legacy fill captures remain separate from code generation; use original-source for CSS projection or renderMode node for native assets.", - false, - )); - } - if (scaffold_requested - || outputs - .iter() - .any(|o| matches!(o.as_str(), "tsx" | "componentTsx" | "responsiveTsx"))) - && payload.assets.iter().any(|a| { - a.render_mode == devup_mcp_figma::AssetRenderMode::Visible - && a.render_info.as_ref().is_some_and(|i| { - i["captureNodeId"].as_str() != Some(a.node_id.as_str()) - }) - }) - { - return Err(DevupError::new( - ErrorCode::DevupInvalidInput, - "Visible ancestor captures cannot replace leaf code at leaf dimensions; request assetManifest or explicitly generate the selected parent.", - false, - )); - } - let keep_tsx = outputs.iter().any(|o| o == "tsx"); - if outputs.iter().any(|output| output == "sourceMap") - && !outputs.iter().any(|output| { - matches!( - output.as_str(), - "tsx" | "componentTsx" | "devupJson" | "pageScaffold" - ) - }) - { - return Err(unavailable_output( - payload, - "sourceMap", - r#"A source map needs a generated output. Even when reusing artifactId, include tsx, componentTsx or devupJson in outputs in the same call. Example: {"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true}"#, - )); - } - // Capture the accepted projection before fields are consumed by - // codegen/file staging. Resource retry reuses this acquisition. - let mut recovery_arguments = json!({ - "artifactId":artifact.artifact_id,"outputs":outputs, - "componentName":component_name,"includeDiagnostics":include_diagnostics, - "rootLayout":match root_layout { devup_mcp_devup_ui::codegen::RootLayout::Standalone => "standalone", devup_mcp_devup_ui::codegen::RootLayout::Embedded => "embedded" }, - "assetNamesPerNode":asset_names_per_node,"scope":scope,"strict":strict, - "devupJsonMode":if theme_options.mode.is_empty() {"full"} else {&theme_options.mode},"projectRoot":theme_options.project_root, - "outputPaths":output_paths,"frameIds":frame_ids,"allScreens":all_screens, - "debug":outputs.iter().any(|o| matches!(o.as_str(), "rawSnapshot" | "rawPayload")), - "delivery":"resource", - "assetRequests":asset_captures.iter().map(|a| json!({"assetId":a.asset_id,"format":a.format,"scale":a.scale,"quality":a.quality,"lossless":a.lossless,"renderMode":a.render_mode,"usageAspectRatio":a.usage_aspect_ratio,"maxWidth":a.max_width,"srcset":a.srcset,"visibleScopeNodeId":a.visible_scope_node_id, - "outputPath":asset_output_paths.get(&a.asset_id)})).collect::>() - }); - if let Some(previous) = &previous_design_fingerprints { - recovery_arguments["previousDesignFingerprints"] = json!(previous); - } - for (key, value) in serde_json::to_value(&response_selection) - .unwrap() - .as_object() - .unwrap() - { - recovery_arguments[key] = value.clone(); - } - if web_ready_images { - recovery_arguments - .as_object_mut() - .unwrap() - .remove("assetRequests"); - recovery_arguments["preset"] = json!("web-ready-images"); - } - if let Some(options) = &page_scaffold { - recovery_arguments["pageScaffold"] = json!(options); - } - if let Some(root) = &asset_public_root { - recovery_arguments["assetPublicRoot"] = json!(root); - } - if let Some(policy) = &width_policy { - recovery_arguments["widthPolicy"] = - json!({"root": "fluid", "anchors": policy.anchors}); - } - let mut result = Map::new(); - // Not restated by quality: this grades how far token resolution - // reached - whether external library variables were covered - where - // quality.theme only says whether what was resolved conflicts. - result.insert("completeness".to_owned(), json!(payload.completeness)); - result.insert("collection".to_owned(), json!(collection)); - result.insert("cache".to_owned(), artifact_metadata(artifact)); - result.insert( - "sectionChecklist".into(), - super::section_checklist::checklist(payload, compact_roots), - ); - let mut failures = payload - .failures - .iter() - .map(|failure| json!(failure)) - .collect::>(); - let mut warnings = Vec::new(); - for asset in payload - .assets - .iter() - .filter(|asset| asset.status == AssetStatus::Failed) - { - let mut failure = json!({"assetId":asset.asset_id,"nodeId":asset.node_id, - "errorCode":asset.error_code,"message":"Asset export failed; inspect errorDetail before retrying.", - "retryable":asset.error_code.as_deref() == Some("DEVUP_ASSET_TIMEOUT")}); - if let Some(detail) = &asset.error_detail { - failure["errorDetail"] = json!(detail); - if let Some(hint) = devup_mcp_figma::figma_unreachable_hint(detail) { - failure["hint"] = json!(hint); - } - } - if asset.error_code.as_deref() == Some("DEVUP_ASSET_NODE_HIDDEN") { - warnings.push(failure); - } else { - failures.push(failure); - } - } - result.insert("failures".to_owned(), json!(failures)); - if !warnings.is_empty() { - result.insert("warnings".to_owned(), json!(warnings)); - } - result.insert( - "source".to_owned(), - json!({ - "kind": source_kind, - "fileKey": payload.target.file_key, - "nodeId": payload.target.node_id, - "version": payload.snapshot.version - }), - ); - let payload_section_index = section_index_from_payload(payload); - let target_kind = if payload_section_index.is_some() { - TargetKind::Section - } else { - classify_target(&payload.snapshot, &payload.target) - }; - result.insert("targetKind".to_owned(), json!(target_kind)); - let selection_index = if target_kind == TargetKind::Section { - let mut index = match &payload_section_index { - Some(index) => index.clone(), - None => { - devup_mcp_figma::build_section_index(&payload.snapshot, &payload.target)? - } - }; - for (id, owner) in devup_mcp_figma::screen_owners( - &payload.snapshot, - index.candidates.iter().map(|c| c.node_id.as_str()), - ) { - if owner.is_some() || !index.node_screen_ids.contains_key(&id) { - index.node_screen_ids.insert(id, owner); - } - } - Some(index) - } else { - None - }; - let manifest_requested = outputs.iter().any(|output| output == "assetManifest"); - result.insert( - "imageFillAudit".into(), - devup_mcp_figma::image_fill_audit(&payload.snapshot, &payload.assets), - ); - let mut audit_assets = devup_mcp_figma::discover_image_fill_assets(&payload.snapshot); - for captured in &payload.assets { - if let Some(existing) = audit_assets - .iter_mut() - .find(|a| a.asset_id == captured.asset_id) - { - *existing = captured.clone(); - } - } - let audit_css = - asset_policy::css_map(&payload.snapshot, &audit_assets, asset_names_per_node); - asset_policy::enrich_audit(result.get_mut("imageFillAudit").unwrap(), &audit_css); - result.get_mut("imageFillAudit").unwrap()["collectionCompleteness"] = json!({"state":completeness_report.state,"snapshotState":completeness_report.snapshot.state,"resourcesState":completeness_report.resources.state,"unresolvedResources":completeness_report.resources.unresolved_count,"failedScreens":payload.failures.len()}); - result.get_mut("imageFillAudit").unwrap()["resourceCompleteness"] = - json!(payload.completeness); - if payload.metadata.get("sectionIndex").is_some() - && payload.metadata.get("selectedRootIds").is_none() - { - let audit = result.get_mut("imageFillAudit").unwrap(); - audit["scope"] = - json!("selection-index-only; descendant image paints not acquired"); - audit["completeness"] = json!("selection-required"); - audit["inScopeImageFills"] = Value::Null; - audit["summary"] = json!( - "Section index only: select frameIds to acquire and audit descendant image fills." - ); - } - if outputs.iter().any(|o| o == "imageAudit") { - result.insert("imageAudit".into(), result["imageFillAudit"].clone()); - } - result.insert( - "assetSummary".to_owned(), - asset_summary( - payload, - artifact, - &payload.snapshot.roots, - manifest_requested, - ), - ); - - if !frame_ids.is_empty() && all_screens { - return Err(DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "frameIds and allScreens cannot be used together.", - false, - )); - } - if target_kind != TargetKind::Section && (!frame_ids.is_empty() || all_screens) { - return Err(DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "frameIds and allScreens can only be used on a Section artifact.", - false, - )); - } - - let mut candidates_truncated = false; - let section_candidates = if target_kind == TargetKind::Section - && outputs.iter().any(|output| { - matches!( - output.as_str(), - "tsx" - | "componentTsx" - | "responsiveTsx" - | "pageScaffold" - | "assetManifest" - | "imageAudit" - | "designFingerprints" - | "designChanges" - ) - }) { - Some(if let Some(index) = &payload_section_index { - candidates_truncated = index.truncated; - index - .candidates - .iter() - .map(|candidate| { - let mut explored = section_candidate_as_explore(candidate); - explored.node.kind = - devup_mcp_figma::classify_explore_node(&explored.node); - explored.selection_reasons = if explored.node.is_screen_candidate() { - vec!["screen-like".into(), "inside-section".into()] - } else { - vec!["explicit-selection-only".into(), "inside-section".into()] - }; - explored - }) - .collect() - } else { - let explored = explore_snapshot( - &payload.snapshot, - &payload.target, - &ExploreOptions { limit: 100 }, - )?; - candidates_truncated = explored.candidates_truncated; - explored.candidates - }) - } else { - None - }; - if let Some(candidates) = §ion_candidates { - result.insert( - "responsiveGroups".into(), - json!(responsive_groups::discover( - candidates, - &artifact.artifact_id - )), - ); - } else { - let groups = responsive_groups::from_roots(payload, &artifact.artifact_id); - if !groups.is_empty() { - result.insert("responsiveGroups".into(), json!(groups)); - } - } - if let Some(candidates) = §ion_candidates - && frame_ids.is_empty() - && !all_screens - { - // Both candidate producers report actual list truncation. - // Reaching the limit alone does not mean candidates were omitted. - let truncated = candidates_truncated; - let (screens, explicit_candidates): (Vec<_>, Vec<_>) = candidates - .iter() - .partition(|candidate| candidate.node.is_screen_candidate()); - let quality = OutputQuality { - acquisition: acquisition_quality(&completeness_report, false), - projection: projection_quality(false, &[]), - theme: theme_quality(false, 0, 0), - assets: assets_quality(false, &[], &[]), - }; - result.insert("status".to_owned(), json!("selection_required")); - if let Some(summary) = result.get_mut("assetSummary") { - summary["manifestIncluded"] = json!(false); - } - result.insert("quality".to_owned(), json!(quality)); - result.insert( - "selection".to_owned(), - json!({ - "kind": "screen-frame", - "status": if truncated { "partial" } else { "complete" }, - "count": screens.len(), - "candidates": screens, - "explicitCandidates": explicit_candidates, - "truncated": truncated - }), - ); - result.insert( - "nextAction".to_owned(), - json!({ - "why": "This is a Section candidate list. Screen artifacts have not been exported yet.", - "how": "Review selection.candidates using name, nodeType, textPreview and textPreviewState. Run the bounded batches below in order, retaining each response before continuing. Select nonstandard cases and notes from selection.explicitCandidates with frameIds or their canonicalUrl. Use the per-candidate nextAction to inspect text when preview is unavailable.", - "doNot": "Do not try to collect the whole Section at once." - }), - ); - // An example built from this call's own artifact and a real - // candidate, so the next step is a call to run rather than a - // shape to assemble. - if let Some(candidate) = screens.first().or_else(|| explicit_candidates.first()) { - result - .get_mut("nextAction") - .expect("nextAction was inserted")["example"] = json!({ - "tool": "devup_figma_export", - "arguments": { - "artifactId": artifact.artifact_id, - "frameIds": [candidate.node.node_id], - "outputs": outputs, - "delivery": "resource", - "componentName": component_name, - "includeDiagnostics": include_diagnostics, - "rootLayout": if root_layout == devup_mcp_devup_ui::codegen::RootLayout::Embedded { "embedded" } else { "standalone" }, - "assetNamesPerNode": asset_names_per_node, - "scope": scope, - "devupJsonMode":if theme_options.mode.is_empty() {"full"} else {&theme_options.mode},"projectRoot":theme_options.project_root, - "strict": strict, - "debug": outputs.iter().any(|output| super::validation::DIAGNOSIS_OUTPUTS.contains(&output.as_str())), - "outputPaths": output_paths, - "assetPublicRoot": asset_public_root, - "assetRequests": asset_captures.iter().map(|capture| json!({ - "assetId":capture.asset_id,"format":capture.format,"scale":capture.scale,"quality":capture.quality,"lossless":capture.lossless,"renderMode":capture.render_mode,"usageAspectRatio":capture.usage_aspect_ratio,"maxWidth":capture.max_width,"srcset":capture.srcset,"visibleScopeNodeId":capture.visible_scope_node_id, - "outputPath":asset_output_paths.get(&capture.asset_id) - })).collect::>() - } - }); - let arguments = - result.get_mut("nextAction").expect("nextAction")["example"]["arguments"] - .as_object_mut() - .expect("example arguments"); - if web_ready_images { - arguments.remove("assetRequests"); - arguments.insert("preset".into(), json!("web-ready-images")); - } - if let Some(previous) = &previous_design_fingerprints { - arguments.insert("previousDesignFingerprints".into(), json!(previous)); - } - if let Some(options) = &page_scaffold { - arguments.insert("pageScaffold".into(), json!(options)); - } - arguments.insert("scope".into(), json!("node")); - if outputs.iter().any(|output| output == "referencePng") { - arguments.remove("artifactId"); - arguments.remove("frameIds"); - arguments.insert("url".into(), json!(candidate.canonical_url)); - } - } else { - result.get_mut("nextAction").expect("nextAction")["example"] = json!({ - "tool":"devup_figma_explore", "arguments":{ - "url":payload.target.link(payload.target.node_id.as_deref()), - "limit":100 - } - }); - } - let output_count = outputs - .iter() - .collect::>() - .len() - .max(1); - let png = outputs.iter().any(|s| s == "referencePng"); - let batch_size = if png { - 1 - } else { - (12 / output_count).clamp(1, 6) - }; - let template = result["nextAction"]["example"]["arguments"].clone(); - let batches: Vec<_> = screens - .chunks(batch_size) - .map(|batch| { - let mut args = template.clone(); - if png { - args["url"] = json!(batch[0].canonical_url); - } else { - args["frameIds"] = - json!(batch.iter().map(|c| &c.node.node_id).collect::>()); - } - json!({"tool":"devup_figma_export","arguments":args}) - }) - .collect(); - result.get_mut("nextAction").unwrap()["maxFramesPerCall"] = json!(batch_size); - result.get_mut("nextAction").unwrap()["batches"] = json!(batches); - for category in ["candidates", "explicitCandidates"] { - if let Some(list) = - result.get_mut("selection").unwrap()[category].as_array_mut() - { - for candidate in list { - if candidate.get("textPreviewState").is_none() { - candidate["textPreviewState"] = json!("unknown-in-cached-snapshot"); - } - candidate["nextAction"] = json!({"tool":"devup_figma_export","arguments":{ - "url":candidate["canonicalUrl"],"outputs":["tsx"],"scope":"node","delivery":"resource"}}); - } - } - } - if scaffold_requested { - page_scaffold::selection_guidance(&mut result); - } - responsive_groups::completion(&mut result, &frame_ids, all_screens); - let projected = projected_outputs_from_result(&result)?; - let mut response = Value::Object(result); - let attachment = apply_delivery( - &mut response, - delivery, - artifact_store, - artifact, - projected, - Some(&recovery_arguments), - ) - .await?; - commit_delivery(attachment); - return Ok(response); - } - - let mut written_paths = Map::new(); - let mut section_tsx_projected = false; - let mut selected_projection_roots = None; - let mut tsx_source_map = None; - let mut generated_output_target = "devupJson"; - let mut devup_json_source_map = None; - let mut projection_diagnostics = Vec::new(); - let mut fidelity_reports = Vec::new(); - let mut theme_conflict_count = 0; - let mut theme_unresolved_count = 0; - let mut pending_text_outputs = std::collections::BTreeMap::new(); - if output_paths.contains_key("imageAudit") - && let Some(audit) = result.get("imageAudit") - { - pending_text_outputs.insert( - "imageAudit".into(), - serde_json::to_string_pretty(audit).unwrap_or_default(), - ); - } - if let Some(current) = &design_fingerprints { - super::design_drift::attach( - &outputs, - previous_fingerprints.as_ref(), - current, - &mut result, - &mut pending_text_outputs, - ); - } - let mut pending_binary_outputs = std::collections::BTreeMap::new(); - let mut pending_asset_manifest = None; - let mut asset_resource_outputs = Vec::new(); - if let Some(candidates) = section_candidates { - let by_id = candidates - .iter() - .map(|candidate| (candidate.node.node_id.as_str(), candidate)) - .collect::>(); - let failed_ids = payload - .failures - .iter() - .map(|failure| failure.node_id.as_str()) - .collect::>(); - let selected = if all_screens { - candidates - .iter() - .filter(|candidate| candidate.node.is_screen_candidate()) - .filter(|candidate| !failed_ids.contains(candidate.node.node_id.as_str())) - .collect::>() - } else { - let requested = frame_ids - .iter() - .map(String::as_str) - .collect::>(); - if requested.len() != frame_ids.len() { - return Err(DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "frameIds contains a duplicate node.", - false, - )); - } - if requested - .iter() - .any(|node_id| !by_id.contains_key(*node_id)) - { - selection_index - .as_ref() - .expect("Section index") - .select(&frame_ids, false)?; - } - candidates - .iter() - .filter(|candidate| { - requested.contains(candidate.node.node_id.as_str()) - && !failed_ids.contains(candidate.node.node_id.as_str()) - }) - .collect::>() - }; - if selected.is_empty() && failed_ids.is_empty() { - return Err(unavailable_output( - payload, - "Section outputs", - "No screen frames match this selection. Use explicit frameIds for a nonstandard frame, or explore another Section.", - )); - } - selected_projection_roots = Some( - selected - .iter() - .map(|candidate| candidate.node.node_id.clone()) - .collect::>(), - ); - if scaffold_requested && selected.len() != 1 { - return Err(DevupError::new( - ErrorCode::DevupInvalidInput, - "pageScaffold requires one frameId for the caller-supplied route.", - false, - )); - } - const MAX_SECTION_FRAMES: usize = 6; - if selected.len() > MAX_SECTION_FRAMES { - return Err(DevupError::with_details( - ErrorCode::DevupInvalidInput, - "This Section projection exceeds the batch budget. Request at most 6 frameIds per call; reuse this artifactId for subsequent batches.", - false, - json!({"artifactId":artifact.artifact_id,"requestedFrameCount":selected.len(), - "recommendedBatchSize":3,"maxFrameCount":MAX_SECTION_FRAMES,"maxFrameOutputUnits":12, - "recommendedFrameIds":selected.iter().take(3).map(|c| &c.node.node_id).collect::>(), - "remainingFrameIds":selected.iter().skip(3).map(|c| &c.node.node_id).collect::>() }), - )); - } - super::validation::validate_export_budget( - &selected - .iter() - .map(|candidate| candidate.node.node_id.clone()) - .collect::>(), - &outputs, - )?; - let mut frames = Vec::with_capacity(selected.len()); - for (index, candidate) in selected.iter().enumerate() { - let frame_component_name = component_name.as_ref().map(|name| { - if selected.len() == 1 { - name.clone() - } else { - format!("{name}{}", index + 1) - } - }); - let mut frame = json!({"nodeId":candidate.node.node_id,"name":candidate.node.name,"outputResults":{}, - "canonicalUrl":candidate.canonical_url}); - let before_failures = failures.len(); - let mut frame_diagnostics = Vec::new(); - for field in ["tsx", "componentTsx"] { - if !outputs.iter().any(|output| output == field) - && !(scaffold_requested && field == "tsx") - { - continue; - } - let output = generate_collected_component( - payload, - &candidate.node.node_id, - &CodegenOptions { - asset_policies: asset_policies.clone(), - component_name: frame_component_name.clone(), - include_diagnostics, - inline_instances: field == "tsx", - root_layout, - asset_names_per_node, - width_policy: width_policy.clone(), - ..CodegenOptions::default() - } - .with_payload_tokens(payload), - ); - let mut output = match output { - Ok(output) => output, - Err(error) => { - failures.push(projection_failure( - payload, - &candidate.node.node_id, - field, - &error, - )); - continue; - } - }; - scope_output(&mut output, field); - frame["outputResults"][field] = output_result(&output); - if frame.get("widthBehavior").is_none() { - frame["widthBehavior"] = - devup_mcp_devup_ui::codegen::width::width_behavior( - &payload.snapshot, - &candidate.node.node_id, - &output, - ); - } - frame_diagnostics.extend(output.diagnostics.iter().cloned()); - fidelity_reports.push(output.fidelity_report.clone()); - frame[field] = json!(output.tsx); - attach_fidelity(&mut frame, &output.fidelity_report, include_diagnostics); - if outputs.iter().any(|output| output == "sourceMap") { - let mut frame_entries: Vec = output - .source_map - .property_entries() - .into_iter() - .filter_map(|entry| serde_json::to_value(entry).ok()) - .collect(); - super::field_select::filter_entries( - &mut frame_entries, - &selected_fields, - ); - frame["sourceMap"] = json!({"version":output.source_map.version, - "rasterizedAssets":output.source_map.rasterized_assets(), - "designFingerprints":design_fingerprints, - "resolutionSemantics":{"axis":"mapping-method","dictionary":"/resolutionSemantics"}, - "entries":frame_entries,"source":{"fileKey":vouched_file_key(payload), - "rootNodeId":candidate.node.node_id,"sourceVersion":payload.source_version, - "generatedOutput":field,"mappingKind":"node-field-property"}}); - } - } - frame["placementContracts"] = json!( - frame_diagnostics - .iter() - .filter(|d| d.code == "DEVUP_CODEGEN_PLACEMENT_CONTRACT") - .collect::>() - ); - frame["projectionIssues"] = json!( - frame_diagnostics - .iter() - .filter(|d| d.fidelity_impact() - != devup_mcp_figma::FidelityImpact::None - || d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED") - .collect::>() - ); - projection_diagnostics.extend(frame_diagnostics.iter().cloned()); - let mut frame_quality = OutputQuality { - acquisition: acquisition_quality(&completeness_report, false), - projection: projection_quality(true, &frame_diagnostics), - theme: theme_quality(false, 0, 0), - assets: assets_quality(false, &[], &[]), - }; - if failures.len() > before_failures { - frame_quality.projection = - if frame.get("tsx").is_some() || frame.get("componentTsx").is_some() { - ProjectionQuality::Lossy - } else { - ProjectionQuality::Failed - }; - } - frame["quality"] = json!(frame_quality); - frame["status"] = json!(if failures.len() > before_failures { - "partial" - } else { - frame_quality.status() - }); - frame["assetSummary"] = asset_summary( - payload, - artifact, - std::slice::from_ref(&candidate.node.node_id), - manifest_requested, - ); - frame["projectionEvidence"] = json!( - frame_diagnostics - .iter() - .filter(|d| { - d.fidelity_impact() == devup_mcp_figma::FidelityImpact::None - && matches!( - d.code.as_str(), - "DEVUP_CODEGEN_NON_RENDERING_ASSET" - | "DEVUP_CODEGEN_ABSOLUTE_VERIFIED" - | "DEVUP_CODEGEN_LAYOUT_ACCOUNTED_FOR" - | "DEVUP_CODEGEN_PROPERTY_EVIDENCE" - ) - }) - .collect::>() - ); - frame_quality.assets = asset_collection_quality(&frame["assetSummary"]); - frame["quality"] = json!(frame_quality); - frame["status"] = json!(if failures.len() > before_failures { - "partial" - } else { - frame_quality.status() - }); - attach_completeness_report( - &mut frame, - frame_quality, - &completeness_report, - include_diagnostics, - ); - if include_diagnostics { - frame["diagnostics"] = json!( - frame_diagnostics - .iter() - .filter(|d| d.code != "DEVUP_CODEGEN_PROPERTY_EVIDENCE") - .collect::>() - ); - } - frames.push(frame); - } - annotate_identical_frames(&mut frames, payload, asset_names_per_node); - if let Some(roots) = &selected_projection_roots { - result.insert( - "assetSummary".to_owned(), - asset_summary(payload, artifact, roots, manifest_requested), - ); - } - if outputs.iter().any(|output| { - matches!(output.as_str(), "tsx" | "componentTsx" | "pageScaffold") - }) { - result.insert("frames".to_owned(), Value::Array(frames)); - } - section_tsx_projected = true; - } - - let mut responsive_snapshot = std::borrow::Cow::Borrowed(&payload.snapshot); - if let Some(roots) = selected_projection_roots { - responsive_snapshot.to_mut().roots = roots; - } - let mut responsive_widths = Vec::new(); - let component_name_for_components = component_name.clone(); - // A screen captured with its other widths is convertible as one - // tree. It is offered whenever those widths are present rather than - // only on request, because a caller asking for a screen that has - // them almost always wants the responsive form and cannot know from - // the node id alone whether it exists. - if outputs - .iter() - .any(|output| output == "tsx" || output == "responsiveTsx") - && let Some(mut merged) = merge_breakpoints( - &responsive_snapshot, - &CodegenOptions { - asset_policies: asset_policies.clone(), - component_name: component_name_for_components.clone(), - include_diagnostics, - inline_instances: false, - root_layout, - asset_names_per_node, - ..CodegenOptions::default() - } - .with_payload_tokens(payload), - )? - && (outputs.iter().any(|output| output == "responsiveTsx") - || !merged.slots.windows(2).any(|pair| pair[0] == pair[1])) - { - // Named as the plugin names it: after the Section the widths - // sit in, with `Page` on the end — `AboutPage` for a Section - // called `about`. The Section is outside the collected - // subtree, so each width carries its name as `parentName`. - // A caller's own name wins; without one and without a - // Section name the page is `ResponsivePage`. - let section_name = payload - .snapshot - .roots - .iter() - .filter_map(|root| payload.snapshot.nodes.get(root)) - .find_map(|root| root.typed_view().string("parentName").map(str::to_owned)); - let name = component_name_for_components.clone().map_or_else( - || { - section_name.map_or_else( - || "ResponsivePage".to_owned(), - |section| format!("{}Page", normalize_component_name(§ion)), - ) - }, - |name| normalize_component_name(&name), - ); - if merged - .slots - .iter() - .collect::>() - .len() - != merged.slots.len() - { - return Err(unavailable_output( - payload, - "responsiveTsx", - "Two frames occupy the same breakpoint slot. Request tsx separately, or select one frame per breakpoint slot.", - )); - } - if let Some(root) = theme_options.project_root.as_deref() - && super::screen_plan::node_ids_enabled(root) - { - let written = - devup_mcp_devup_ui::codegen::node_ids::annotate_merged(&mut merged); - let ids = result.entry("figmaNodeIds").or_insert_with(|| { - json!({"attribute": devup_mcp_devup_ui::codegen::node_ids::NODE_ID_ATTRIBUTE}) - }); - ids["perWidthAttribute"] = - json!(devup_mcp_devup_ui::codegen::node_ids::NODE_IDS_ATTRIBUTE); - ids["responsive"] = json!(written); - ids["responsiveFormat"] = json!( - "data-figma-node-id is the node at the widest width the element is shown at; data-figma-node-ids lists \":\" for every drawn width it is shown at, widest first, space-separated, so [data-figma-node-ids~=\"390:56:78\"] selects it at one width. hiddenAt lists the drawn widths it is hidden at." - ); - } - let module = merged.module(&name); - // The responsive tree builder does not run the ordinary - // finalizer. Preserve each breakpoint's source limitations. - let mut breakpoint_fidelity = Vec::new(); - for root_id in &responsive_snapshot.roots { - let mut source_output = generate_component( - &responsive_snapshot, - root_id, - &CodegenOptions { - asset_policies: asset_policies.clone(), - inline_instances: true, - root_layout, - asset_names_per_node, - ..CodegenOptions::default() - } - .with_payload_tokens(payload), - )?; - scope_output(&mut source_output, "responsiveTsx"); - breakpoint_fidelity - .push(json!({"rootId":root_id,"fidelity":source_output.fidelity_report})); - for mut issue in source_output.diagnostics.into_iter().filter(|d| { - d.fidelity_impact() != devup_mcp_figma::FidelityImpact::None - || d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" - }) { - if let Some(details) = issue.details.as_mut().and_then(Value::as_object_mut) - { - details.insert( - "evidenceScope".into(), - json!("breakpoint-source-projection"), - ); - details.insert("breakpointRootId".into(), json!(root_id)); - details.insert("output".into(), json!("responsiveTsx")); - } - if !projection_diagnostics.contains(&issue) { - projection_diagnostics.push(issue); - } - } - } - if output_paths.contains_key("responsiveTsx") { - pending_text_outputs.insert("responsiveTsx".to_owned(), module.clone()); - } - result.insert("responsiveTsx".to_owned(), json!(module)); - result.insert("responsiveSlots".to_owned(), json!(merged.slots)); - result.insert( - "responsiveEvidence".into(), - responsive_groups::width_evidence(&responsive_snapshot), - ); - result.insert( - "responsiveReviewChecklist".into(), - responsive_groups::review(&responsive_snapshot), - ); - responsive_widths = responsive_snapshot - .roots - .iter() - .filter_map(|id| { - responsive_snapshot - .nodes - .get(id)? - .typed_view() - .number("width") - }) - .collect(); - let responsive_issues: Vec<_> = projection_diagnostics - .iter() - .filter(|d| { - d.details - .as_ref() - .is_some_and(|v| v["output"] == "responsiveTsx") - }) - .cloned() - .collect(); - let responsive_quality = if merged.unrepresented.is_empty() { - projection_quality(true, &responsive_issues) - } else { - ProjectionQuality::Lossy - }; - result.entry("outputResults").or_insert_with(|| json!({}))["responsiveTsx"] = json!({ - "state":"produced","projection":responsive_quality, - "fidelity":{"scope":"breakpoint-source-projections","sourceOutput":"tsx","breakpoints":breakpoint_fidelity, - "syntaxValid":devup_mcp_devup_ui::validation::validate_tsx(&module).is_ok(), - "mergedMappingVerified":merged.verified,"unrepresentedCount":merged.unrepresented.len(), - "mergeGapCount":merged.gaps.len(), - "mergeGaps":merged.gaps.iter().take(20).collect::>(), - "note":if merged.verified { - "Read at each collected width's slot, the merged module writes that width's own verified tsx: the same elements, values and text. This reads the code; it is not a rendering." - } else { - "Read at each collected width's slot, the merged module does not write that width's own verified tsx everywhere; mergeGaps names where. Review responsiveUnrepresented and projectionIssues." - }}, - "_propertyContract":merged.contract}); - - if !merged.unrepresented.is_empty() { - for note in &merged.unrepresented { - projection_diagnostics.push(Diagnostic { - code:"DEVUP_CODEGEN_RESPONSIVE_LOSS".into(), - message:note.detail.clone(), - node_id:Some(note.node_id.clone()), - property:Some("childrenIds".into()), - details:Some(json!({"originalValue":payload.snapshot.nodes.get(¬e.node_id).map(|n| &n.fields), - "appliedValue":null,"appliedValueReason":"Content is unrepresented in the merged output.","stage":"projection","output":"responsiveTsx"})), - fidelity_impact:Some(devup_mcp_figma::FidelityImpact::Lossy), - severity:Some(DiagnosticSeverity::Warning), - ..Diagnostic::default() - }); - } - result.insert( - "responsiveUnrepresented".to_owned(), - json!( - merged - .unrepresented - .iter() - .map(|note| json!({ - "nodeId": note.node_id, - "detail": note.detail - })) - .collect::>() - ), - ); - } - } - - if outputs.iter().any(|output| output == "responsiveTsx") - && !result.contains_key("responsiveTsx") - { - return Err(unavailable_output( - payload, - "responsiveTsx", - "The snapshot has no mergeable breakpoint frames. Request tsx for individual screens, or re-collect a screen with its breakpoint siblings.", - )); - } - - if (keep_tsx || scaffold_requested) && !section_tsx_projected { - let node_id = payload.target.node_id.as_deref().ok_or_else(|| { - DevupError::new( - ErrorCode::DevupFigmaNodeNotFound, - "A TSX export payload requires a node ID.", - false, - ) - })?; - let mut output = generate_component( - &payload.snapshot, - node_id, - &CodegenOptions { - asset_policies: asset_policies.clone(), - component_name, - include_diagnostics, - inline_instances: true, - root_layout, - asset_names_per_node, - width_policy: width_policy.clone(), - ..CodegenOptions::default() - } - .with_payload_tokens(payload), - )?; - scope_output(&mut output, "tsx"); - result.entry("outputResults").or_insert_with(|| json!({}))["tsx"] = - output_result(&output); - result.insert( - "widthBehavior".to_owned(), - devup_mcp_devup_ui::codegen::width::width_behavior( - &payload.snapshot, - node_id, - &output, - ), - ); - projection_diagnostics.extend(output.diagnostics.iter().cloned()); - fidelity_reports.push(output.fidelity_report.clone()); - generated_output_target = "tsx"; - if let Some(root) = theme_options.project_root.as_deref() - && super::screen_plan::node_ids_enabled(root) - { - let annotated = annotate_node_ids(&mut output, &payload.snapshot, node_id); - result.insert("figmaNodeIds".to_owned(), annotated); - } - tsx_source_map = Some(output.source_map.clone()); - if keep_tsx && output_paths.contains_key("tsx") { - pending_text_outputs.insert("tsx".to_owned(), output.tsx.clone()); - } - result.insert("tsx".to_owned(), json!(output.tsx)); - if include_diagnostics { - result.insert( - "diagnostics".to_owned(), - json!( - output - .diagnostics - .iter() - .filter(|d| d.code != "DEVUP_CODEGEN_PROPERTY_EVIDENCE") - .collect::>() - ), - ); - } - } - - if outputs.iter().any(|output| output == "componentTsx") && !section_tsx_projected { - 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_collected_component( - payload, - node_id, - &CodegenOptions { - asset_policies: asset_policies.clone(), - component_name: component_name_for_components.clone(), - include_diagnostics: false, - inline_instances: false, - root_layout, - asset_names_per_node, - width_policy: width_policy.clone(), - ..CodegenOptions::default() - } - .with_payload_tokens(payload), - ); - match output { - Ok(mut output) => { - scope_output(&mut output, "componentTsx"); - result.entry("outputResults").or_insert_with(|| json!({}))["componentTsx"] = - output_result(&output); - projection_diagnostics.extend(output.diagnostics.iter().cloned()); - fidelity_reports.push(output.fidelity_report.clone()); - if tsx_source_map.is_none() { - generated_output_target = "componentTsx"; - tsx_source_map = Some(output.source_map.clone()); - } - if output_paths.contains_key("componentTsx") { - pending_text_outputs - .insert("componentTsx".to_owned(), output.tsx.clone()); - } - result.insert("componentTsx".to_owned(), json!(output.tsx)); - } - Err(error) => { - failures.push(projection_failure(payload, node_id, "componentTsx", &error)) - } - } - } - - if outputs.iter().any(|output| output == "devupJson") { - let variables = payload.variables.as_ref().ok_or_else(|| unavailable_output(payload, "devupJson", - "No variable/style resources were collected. Re-collect from the URL with devupJson in outputs and the desired scope."))?; - let variables = variable_snapshot_from_result(variables)?; - let mut output = theme_export::generate( - &variables, - parse_scope(&scope)?, - &theme_options, - &result, - )?; - theme_export::prepare(&mut output, &theme_options, &mut result, payload, strict)?; - theme_conflict_count = output.conflicts.len(); - theme_unresolved_count = - output.unresolved_variables.len() + output.skipped_styles.len(); - theme_unresolved_count += result - .get("unresolvedUsedTokens") - .and_then(Value::as_array) - .map_or(0, Vec::len); - if let Some(diff) = result.get("themeDiff") { - let conflicts = diff["existingTypographyConflicts"].as_array(); - theme_conflict_count += conflicts.map_or(0, Vec::len); - for conflict in conflicts.into_iter().flatten() { - warnings.push(json!({"code":"DEVUP_THEME_EXISTING_TYPOGRAPHY_CONFLICT", - "message":format!("Existing typography token '{}' differs from Figma; the project's value is preserved and visual fidelity needs review.", conflict["token"].as_str().unwrap_or_default()), - "details":conflict,"severity":"warning"})); - } - } - warnings.extend( - output - .diagnostics - .iter() - .filter(|d| d.code == "DEVUP_THEME_STYLE_SKIPPED") - .map(|d| json!(d)), - ); - projection_diagnostics.extend( - output - .diagnostics - .iter() - .filter(|d| d.code == "DEVUP_THEME_STYLE_SKIPPED") - .cloned(), - ); - devup_json_source_map = Some(output.source_map.clone()); - if output_paths.contains_key("devupJson") { - pending_text_outputs.insert("devupJson".to_owned(), output.json.clone()); - } - result.insert("devupJson".to_owned(), json!(output.json)); - result.insert("themeCounts".to_owned(), json!(output.counts)); - result.insert("themeCompleteness".to_owned(), json!(output.completeness)); - result.insert("conflicts".to_owned(), json!(output.conflicts)); - result.insert( - "unresolvedVariables".to_owned(), - json!(output.unresolved_variables), - ); - if include_diagnostics && !result.contains_key("diagnostics") { - result.insert( - "diagnostics".to_owned(), - json!( - output - .diagnostics - .iter() - .filter(|d| d.code != "DEVUP_CODEGEN_PROPERTY_EVIDENCE") - .collect::>() - ), - ); - } - } - - if outputs.iter().any(|output| output == "rawSnapshot") { - let raw = serde_json::to_value(&payload.snapshot).map_err(|error| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - format!("Cannot serialize the raw snapshot: {error}"), - false, - ) - })?; - let mut raw = raw; - super::field_select::prune_nodes(&mut raw, &selected_fields); - if output_paths.contains_key("rawSnapshot") { - pending_text_outputs.insert( - "rawSnapshot".to_owned(), - serde_json::to_string_pretty(&raw).unwrap_or_default(), - ); - } - result.insert("rawSnapshot".to_owned(), raw); - } - - // The whole collection, not only its node tree. A snapshot kept on - // its own can be converted, but not the way the server converts - // it: the token names come from the variables and styles collected - // beside it, and without them a `$gray200` fill comes out as the - // tail of its variable ID and a `typography="h4"` as five font - // props. Captures kept as fixtures need the resources too, so - // this writes them. The reference PNG is left out — it is large, - // binary, and has its own output. - if outputs.iter().any(|output| output == "rawPayload") { - let mut without_png = payload.clone(); - without_png.reference_png = None; - let raw = serde_json::to_value(&without_png).map_err(|error| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - format!("Cannot serialize the raw payload: {error}"), - false, - ) - })?; - let mut raw = raw; - super::field_select::prune_nodes(&mut raw, &selected_fields); - if output_paths.contains_key("rawPayload") { - pending_text_outputs.insert( - "rawPayload".to_owned(), - serde_json::to_string_pretty(&raw).unwrap_or_default(), - ); - } - result.insert("rawPayload".to_owned(), raw); - } - - if outputs.iter().any(|output| output == "sourceMap") && !section_tsx_projected { - let rasterized_assets = tsx_source_map - .as_ref() - .map(|m| m.rasterized_assets()) - .unwrap_or_default(); - let mut tsx_entries: Vec = tsx_source_map - .map(|source_map| source_map.property_entries()) - .unwrap_or_default() - .into_iter() - .filter_map(|entry| serde_json::to_value(entry).ok()) - .collect(); - super::field_select::filter_entries(&mut tsx_entries, &selected_fields); - let source_map = json!({ - "version": 2, - "rasterizedAssets":rasterized_assets, - "designFingerprints":design_fingerprints, - "resolutionSemantics":devup_mcp_devup_ui::provenance::resolution_semantics(), - "tsx": tsx_entries, - "devupJson": devup_json_source_map - .map(|source_map| source_map.entries) - .unwrap_or_default(), - "source": { - "fileKey": vouched_file_key(payload), - "rootNodeId": payload.target.node_id, - "sourceVersion": payload.source_version, - "generatedOutput":generated_output_target,"mappingKind":"node-field-property" - } - }); - if output_paths.contains_key("sourceMap") { - pending_text_outputs.insert( - "sourceMap".to_owned(), - serde_json::to_string_pretty(&source_map).unwrap_or_default(), - ); - } - result.insert("sourceMap".to_owned(), source_map); - } - - if outputs.iter().any(|output| output == "referencePng") { - let reference = payload.reference_png.as_ref().ok_or_else(|| unavailable_output(payload, "referencePng", - "No reference PNG was collected. Re-collect a single screen URL with referencePng in outputs."))?; - let bytes = STANDARD - .decode(reference.data_base64.as_bytes()) - .map_err(|_| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "The artifact reference PNG base64 is invalid.", - false, - ) - })?; - if bytes.len() != reference.byte_length - || Sha256::digest(&bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::() - != reference.sha256 - { - return Err(DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "The artifact reference PNG length or hash does not match.", - false, - )); - } - if output_paths.contains_key("referencePng") { - pending_binary_outputs.insert("referencePng".to_owned(), bytes); - } - let mut inline = json!(reference); - if let Some(note) = inline - .as_object_mut() - .and_then(|inline| inline.remove("note")) - { - result.insert("referencePngNote".to_owned(), note); - } - result.insert("referencePng".to_owned(), inline); - } - - let reconcile_captured_assets = payload - .assets - .iter() - .any(|asset| asset.status == AssetStatus::Exported) - && outputs.iter().any(|output| { - matches!( - output.as_str(), - "tsx" | "componentTsx" | "responsiveTsx" | "pageScaffold" - ) - }); - if manifest_requested - || reconcile_captured_assets - || scaffold_requested - || outputs.iter().any(|o| o == "assetCssMap") - { - let mut manifest = - asset_policy::discovery(&payload.snapshot, &payload.assets, web_ready_images); - for exported in &payload.assets { - if let Some(existing) = manifest - .assets - .iter_mut() - .find(|asset| asset.asset_id == exported.asset_id) - { - *existing = exported.clone(); - } else { - manifest.assets.push(exported.clone()); - } - } - devup_mcp_figma::annotate_assets(&payload.snapshot, &mut manifest.assets); - manifest.assets.retain(|asset| { - if web_ready_images { - return true; - } - if asset.error_code.as_deref() != Some("DEVUP_ASSET_NODE_HIDDEN") { - return true; - } - if !warnings - .iter() - .any(|warning| warning["assetId"] == asset.asset_id) - { - warnings.push(json!({"assetId":asset.asset_id,"nodeId":asset.node_id, - "errorCode":asset.error_code,"severity":"warning", - "message":"Hidden asset excluded from deliverables."})); - } - false - }); - manifest - .assets - .sort_by(|left, right| left.asset_id.cmp(&right.asset_id)); - // Where the generated code refers to each asset, so a - // consumer can write the bytes there without re-deriving the - // name - which, for two icons of one layer name, it would get - // wrong. - for asset in &mut manifest.assets { - if asset.status == AssetStatus::Failed { - asset.path = None; - asset.output_path = None; - continue; - } - if asset.path.is_none() { - asset.path = asset_policy::canonical_path( - &payload.snapshot, - asset, - asset_names_per_node, - ); - } - } - // Where the bytes will be written, if a layer name is one a - // file system refuses. The code that points at them is - // renamed the same way once every output is assembled. - for asset in &mut manifest.assets { - if let Some(path) = asset.path.as_deref() { - let safe = host_safe_asset_path(path); - if safe != path { - asset.path = Some(safe); - } - } - } - for capture in &asset_captures { - if !payload.assets.iter().any(|asset| { - asset.asset_id == capture.asset_id - && asset.format == Some(capture.format) - && asset.scale == Some(capture.scale) - && asset.render_mode == capture.render_mode - && asset.quality == capture.quality - && asset.lossless == capture.lossless - && asset.usage_aspect_ratio == capture.usage_aspect_ratio - && asset.max_width == capture.max_width - && asset.srcset == capture.srcset - && capture - .visible_scope_node_id - .as_ref() - .is_none_or(|id| asset.visible_scope_node_id.as_ref() == Some(id)) - && matches!(asset.status, AssetStatus::Exported | AssetStatus::Failed) - }) { - return Err(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - format!( - "The exact requested asset export is not in the artifact. Re-collect it from the URL: {}", - capture.asset_id - ), - false, - )); - } - } - manifest.diagnostics = payload - .snapshot - .diagnostics - .iter() - .filter(|diagnostic| diagnostic.resource_kind.as_deref() == Some("asset")) - .cloned() - .collect(); - pending_asset_manifest = Some(manifest); - } - // Reconcile every generated output, including reusable and responsive - // code, against exclusions before inline/resource/file delivery. - let mut assets = devup_mcp_figma::discover_asset_manifest(&payload.snapshot).assets; - for captured in &payload.assets { - if let Some(existing) = assets - .iter_mut() - .find(|asset| asset.asset_id == captured.asset_id) - { - *existing = captured.clone(); - } else { - assets.push(captured.clone()); - } - } - let reference_paths = |asset: &devup_mcp_figma::AssetManifestEntry| { - let generated = asset - .field - .strip_prefix("fills/") - .and_then(|i| i.parse::().ok()) - .and_then(|i| { - devup_mcp_devup_ui::codegen::image_fill_path( - &payload.snapshot, - &asset.node_id, - i, - asset_names_per_node, - ) - }) - .or_else(|| { - devup_mcp_devup_ui::codegen::asset_path( - &payload.snapshot, - &asset.node_id, - asset_names_per_node, - ) - }); - generated - .into_iter() - .chain(asset.path.clone()) - .flat_map(|path| [host_safe_asset_path(&path), path]) - .collect::>() - }; - let available_paths = assets - .iter() - .filter(|asset| asset.error_code.as_deref() != Some("DEVUP_ASSET_NODE_HIDDEN")) - .flat_map(&reference_paths) - .collect::>(); - for asset in assets - .iter() - .filter(|asset| asset.error_code.as_deref() == Some("DEVUP_ASSET_NODE_HIDDEN")) - { - let paths = reference_paths(asset) - .into_iter() - .filter(|path| !available_paths.contains(path)) - .collect::>(); - let references = |code: &Value| { - code.as_str() - .is_some_and(|code| paths.iter().any(|path| code.contains(path))) - }; - let source_node = payload.snapshot.nodes.get(&asset.node_id); - let exclusion_details = json!({"referencePaths":paths, - "assetNodeId":asset.node_id, - "exclusionReason":source_node.and_then(devup_mcp_figma::asset_exclusion_reason), - "visible":source_node.and_then(|n| n.typed_view().value("visible")), - "opacity":source_node.and_then(|n| n.typed_view().value("opacity")), - "absoluteRenderBounds":source_node.and_then(|n| n.typed_view().value("absoluteRenderBounds")), - "policyConflict":source_node.is_some_and(|n| devup_mcp_figma::asset_exclusion_reason(n).is_none())}); - for field in ["tsx", "componentTsx", "responsiveTsx"] { - if result.get(field).is_some_and(&references) { - result.remove(field); - pending_text_outputs.remove(field); - failures.push(json!({"nodeId":payload.target.node_id,"assetId":asset.asset_id,"output":field, - "errorCode":"DEVUP_EXCLUDED_ASSET_REFERENCED","stage":"projection","details":exclusion_details, - "message":"Generated code references an excluded asset; this output was withheld."})); - } - if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { - for frame in frames { - if frame.get(field).is_some_and(&references) { - frame.as_object_mut().unwrap().remove(field); - frame["status"] = json!("partial"); - frame["quality"]["projection"] = json!("lossy"); - frame["quality"]["assets"] = json!("partial"); - failures.push(json!({"nodeId":frame["nodeId"],"assetId":asset.asset_id,"output":field, - "errorCode":"DEVUP_EXCLUDED_ASSET_REFERENCED","stage":"projection","details":exclusion_details, - "message":"Generated code references an excluded asset; this output was withheld."})); - } - } - } - } - } - for failure in failures.iter().filter(|f| f.get("output").is_some()) { - let issue = failure_issue(failure); - projection_diagnostics.push(issue.clone()); - if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { - for frame in frames - .iter_mut() - .filter(|f| f["nodeId"] == failure["nodeId"]) - { - frame["projectionIssues"] - .as_array_mut() - .unwrap() - .push(json!(issue)); - } - } - } - responsive_groups::completion(&mut result, &frame_ids, all_screens); - result.insert( - "placementContracts".into(), - json!( - projection_diagnostics - .iter() - .filter(|d| d.code == "DEVUP_CODEGEN_PLACEMENT_CONTRACT") - .collect::>() - ), - ); - result.insert( - "projectionIssues".into(), - json!( - projection_diagnostics - .iter() - .filter( - |d| d.fidelity_impact() != devup_mcp_figma::FidelityImpact::None - || d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" - ) - .collect::>() - ), - ); - result.insert( - "projectionEvidence".into(), - json!( - projection_diagnostics - .iter() - .filter(|d| { - d.fidelity_impact() == devup_mcp_figma::FidelityImpact::None - && matches!( - d.code.as_str(), - "DEVUP_CODEGEN_NON_RENDERING_ASSET" - | "DEVUP_CODEGEN_ABSOLUTE_VERIFIED" - | "DEVUP_CODEGEN_LAYOUT_ACCOUNTED_FOR" - | "DEVUP_CODEGEN_PROPERTY_EVIDENCE" - ) - }) - .collect::>() - ), - ); - let mut quality = OutputQuality { - acquisition: acquisition_quality(&completeness_report, false), - projection: projection_quality( - outputs.iter().any(|output| { - matches!( - output.as_str(), - "tsx" | "componentTsx" | "responsiveTsx" | "pageScaffold" - ) - }), - &projection_diagnostics, - ), - theme: theme_quality( - outputs.iter().any(|output| output == "devupJson"), - theme_conflict_count, - theme_unresolved_count, - ), - assets: assets_quality( - outputs.iter().any(|output| output == "assetManifest"), - &asset_captures - .iter() - .map(|capture| capture.asset_id.clone()) - .collect::>(), - &payload.assets, - ), - }; - if let Some(summary) = result.get("assetSummary") { - quality.assets = asset_collection_quality(summary); - } - if failures - .iter() - .any(|failure| failure.get("assetId").is_some()) - { - quality.assets = super::quality::AssetsQuality::Partial; - } - if failures - .iter() - .any(|failure| failure.get("output").is_some()) - { - quality.projection = super::quality::ProjectionQuality::Lossy; - } - if !warnings.is_empty() { - result.insert("warnings".to_owned(), json!(warnings)); - } - if target_kind == TargetKind::Section && !failures.is_empty() { - let failed_frames = failures - .iter() - .filter(|failure| failure.get("assetId").is_none()) - .filter_map(|failure| failure["nodeId"].as_str()) - .collect::>(); - if !failed_frames.is_empty() { - let mut arguments = json!({"url":payload.target.link(payload.target.node_id.as_deref()), - "frameIds":failed_frames,"outputs":outputs,"scope":scope,"delivery":delivery, - "rootLayout":match root_layout { devup_mcp_devup_ui::codegen::RootLayout::Standalone => "standalone", devup_mcp_devup_ui::codegen::RootLayout::Embedded => "embedded" }, - "assetNamesPerNode":asset_names_per_node,"strict":strict,"includeDiagnostics":include_diagnostics}); - if let Some(name) = &component_name_for_components { - arguments["componentName"] = json!(name); - } - result.insert("resume".into(), json!({"tool":"devup_figma_export", "arguments":arguments, - "note":"Retry only failed frames. Missing collected nodes require a fresh URL acquisition; component master failures may require collecting the master or requesting tsx."})); - } - } - result.insert("failures".to_owned(), json!(failures)); - let fidelity_violation = fidelity_reports - .iter() - .any(|report| !report.strict_compatible()); - if strict && (quality.strict_violation() || fidelity_violation || !failures.is_empty()) - { - return Err(DevupError::with_details( - ErrorCode::DevupSnapshotUnsupported, - format!( - "strict export only allows exact/complete output: status={}, quality={}", - quality.status(), - serde_json::to_string(&quality).unwrap_or_default() - ), - false, - json!({ - "quality": quality, - "fidelity": fidelity_reports, - "failures": failures, - "completenessReport": completeness_report, - "projectionIssues": result.get("projectionIssues") - }), - )); - } - let final_status = if failures.is_empty() { - quality.status() - } else { - "partial" - }; - result.insert("status".to_owned(), json!(final_status)); - result.insert("quality".to_owned(), json!(quality)); - if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { - for frame in frames { - let id = frame["nodeId"].as_str().unwrap_or_default().to_owned(); - let frame_failures = failures - .iter() - .filter(|f| f["nodeId"] == id) - .cloned() - .collect::>(); - attach_review_and_deliverable( - frame.as_object_mut().unwrap(), - &outputs, - &frame_failures, - Some(&id), - ); - if !frame_failures.is_empty() { - frame["failures"] = json!(frame_failures); - } - } - } - attach_review_and_deliverable( - &mut result, - &outputs, - &failures, - payload.target.node_id.as_deref(), - ); - // Only the whole-node tsx is missing its fidelity report at this - // point; each Section frame already carries its own. - if !section_tsx_projected && let Some(report) = fidelity_reports.first() { - let mut carrier = Value::Object(Map::new()); - attach_fidelity(&mut carrier, report, include_diagnostics); - if let Some(fidelity) = carrier.get("fidelity") { - result.insert("fidelity".to_owned(), fidelity.clone()); - } - } - let mut carrier = Value::Object(Map::new()); - attach_completeness_report( - &mut carrier, - quality, - &completeness_report, - include_diagnostics, - ); - if let Some(report) = carrier.get("completenessReport") { - result.insert("completenessReport".to_owned(), report.clone()); - } - if scaffold_requested - && let Some(refusal) = - page_scaffold::directory_refusal(&result, &output_paths, output_policy)? - { - return Ok(refusal); - } - if scaffold_requested && let Some(manifest) = &pending_asset_manifest { - page_scaffold::prepare_assets( - &result, - &output_paths, - manifest, - &mut asset_output_paths, - &mut asset_public_root, - output_policy, - )?; - } - let replacements = if let Some(manifest) = &mut pending_asset_manifest { - reconcile_asset_paths( - manifest, - &mut asset_output_paths, - asset_public_root.as_deref(), - output_policy, - )? - } else { - BTreeMap::new() - }; - let owners = if let Some(index) = &selection_index { - index.node_screen_ids.clone() - } else { - devup_mcp_figma::screen_owners( - &payload.snapshot, - payload.snapshot.roots.iter().map(String::as_str), - ) - }; - // Share definitions only for labels present in these outputs. The - // full label catalog remains documented and standalone maps carry it. - if let Some(frames) = result.get("frames").and_then(Value::as_array) - && frames.iter().any(|frame| frame.get("sourceMap").is_some()) - { - let labels = frames - .iter() - .flat_map(|f| { - f["outputResults"] - .as_object() - .into_iter() - .flat_map(|o| o.values()) - }) - .flat_map(|o| o["_sourceMap"].as_array().into_iter().flatten()) - .filter_map(|e| e["resolution"].as_str()) - .collect::>(); - let mut semantics = devup_mcp_devup_ui::provenance::resolution_semantics(); - semantics - .as_object_mut() - .unwrap() - .remove("verificationAxis"); - semantics["relation"] = json!( - "Same node/property/screen/output: ABSOLUTE components.*.state is independent." - ); - semantics["values"] - .as_object_mut() - .unwrap() - .retain(|label, _| labels.contains(label.as_str())); - result.insert("resolutionSemantics".into(), semantics); - } - if let Some(manifest) = &mut pending_asset_manifest { - for asset in &mut manifest.assets { - if let Some(path) = &asset.path { - for variant in &mut asset.variants { - variant.path = Some(asset_policy::variant_path(path)); - } - } - } - let css_map = asset_policy::css_map( - &payload.snapshot, - &manifest.assets, - asset_names_per_node, - ); - for field in ["imageAudit", "imageFillAudit"] { - if let Some(audit) = result.get_mut(field) { - asset_policy::enrich_audit(audit, &css_map); - } - } - if web_ready_images || outputs.iter().any(|o| o == "assetCssMap") { - result.insert("assetCssMap".into(), css_map.clone()); - } - for field in ["imageAudit", "assetCssMap"] { - if output_paths.contains_key(field) - && let Some(body) = result.get(field) - { - pending_text_outputs.insert( - field.into(), - serde_json::to_string_pretty(body).unwrap_or_default(), - ); - } - } - } - let mut value = Value::Object(std::mem::take(&mut result)); - rewrite_result_asset_references(&mut value, &replacements); - audit_delivered_properties(&mut value); - super::interactions::attach(&mut value, &payload.snapshot); - attach_diagnostic_screens(&mut value, &owners); - super::verdict_scope::attach(&mut value); - attach_verification_scope(&mut value, &responsive_widths); - if strict && value["mappingComplete"] == false { - return Err(DevupError::with_details( - ErrorCode::DevupSnapshotUnsupported, - "strict export requires complete generated-property provenance.", - false, - json!({"quality":value["quality"],"projectionIssues":value["projectionIssues"]}), - )); - } - result = value.as_object().expect("result object").clone(); - if outputs.iter().any(|o| o == "outline") { - let outline = - super::compact::outline(node_summaries.as_ref().unwrap(), &response_selection); - if output_paths.contains_key("outline") { - pending_text_outputs.insert( - "outline".into(), - serde_json::to_string_pretty(&outline).unwrap(), - ); - } - result.insert("outline".into(), outline); - } - for (name, contents) in &mut pending_text_outputs { - if matches!( - name.as_str(), - "tsx" | "componentTsx" | "responsiveTsx" | "pageScaffold" - ) { - if let Some(code) = result.get(name).and_then(Value::as_str) { - *contents = code.to_owned(); - } - } else if name == "sourceMap" { - *contents = - serde_json::to_string_pretty(&result["sourceMap"]).unwrap_or_default(); - } else if matches!(name.as_str(), "rawSnapshot" | "rawPayload") { - *contents = serde_json::to_string_pretty(&result[name]).unwrap_or_default(); - } - } - let mut planned_outputs = Vec::new(); - let mut supported_keys = Vec::new(); - if let Some(options) = &page_scaffold { - let (key, planned) = page_scaffold::attach( - &mut result, - options, - &output_paths, - pending_asset_manifest.as_ref(), - &asset_output_paths, - output_policy, - keep_tsx, - )?; - supported_keys.push(key); - planned_outputs.extend(planned); - } - for field in [ - "tsx", - "componentTsx", - "responsiveTsx", - "devupJson", - "rawSnapshot", - "rawPayload", - "sourceMap", - "designFingerprints", - "referencePng", - "imageAudit", - "assetCssMap", - "outline", - ] { - if result.contains_key(field) { - supported_keys.push(field.to_owned()); - } - } - if let Some(frames) = result.get("frames").and_then(Value::as_array) { - for frame in frames { - for field in ["tsx", "componentTsx", "sourceMap"] { - let Some(contents) = frame.get(field) else { - continue; - }; - let key = format!("frame:{}:{field}", frame["nodeId"].as_str().unwrap()); - supported_keys.push(key.clone()); - if let Some(path) = output_paths.get(&key) { - let bytes = if let Some(code) = contents.as_str() { - code.as_bytes().to_vec() - } else { - serde_json::to_vec_pretty(contents).unwrap() - }; - planned_outputs.push((key, output_policy.resolve(path)?, bytes)); - } - } - } - } - let path_diagnostics: Vec<_> = output_paths.keys().filter(|key| !supported_keys.contains(key)).map(|key| { - json!({"code":"DEVUP_OUTPUT_PATH_UNSUPPORTED","key":key,"written":false, - "message":"This key has no writable output in this projection. Use a supportedKeys entry; frame outputs require frame::."}) - }).collect(); - result.insert("outputPathResults".into(), json!({"supportedKeys":supported_keys, - "frameKeyFormat":if scaffold_requested { "frame::" } else { "frame::" }, - "diagnostics":path_diagnostics, - "note":"outputPaths reports committed writes only. Unsupported or unavailable keys do not write files; assetRequests use their own outputPath."})); - if scaffold_requested { - let kinds: Map = supported_keys - .iter() - .filter(|key| key.ends_with("pageScaffold")) - .map(|key| (key.clone(), json!("directory"))) - .collect(); - result.get_mut("outputPathResults").unwrap()["pathKinds"] = json!(kinds); - } - for (output, contents) in pending_text_outputs { - if let Some(path) = output_paths.get(&output) { - planned_outputs.push(( - output, - output_policy.resolve(path)?, - contents.into_bytes(), - )); - } - } - for (output, bytes) in pending_binary_outputs { - if let Some(path) = output_paths.get(&output) { - planned_outputs.push((output, output_policy.resolve(path)?, bytes)); - } - } - if let Some(manifest) = pending_asset_manifest.as_ref() { - for asset in &manifest.assets { - if asset.status != AssetStatus::Exported { - continue; - } - let Some(path) = asset_output_paths.get(&asset.asset_id) else { - continue; - }; - let data = asset.data_base64.as_deref().ok_or_else(|| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "The exported asset binary is not in the artifact.", - false, - ) - })?; - let bytes = STANDARD.decode(data.as_bytes()).map_err(|_| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "The exported asset binary base64 is invalid.", - false, - ) - })?; - planned_outputs.push(( - format!("asset:{}", asset.asset_id), - output_policy.resolve(path)?, - bytes, - )); - for variant in &asset.variants { - let variant_path = asset_policy::variant_path(path); - let bytes = STANDARD - .decode(variant.data_base64.as_deref().unwrap_or_default()) - .map_err(|_| { - DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "Invalid variant base64.", - false, - ) - })?; - planned_outputs.push(( - format!("asset:{}@2x", asset.asset_id), - output_policy.resolve(&variant_path)?, - bytes, - )); - } - } - } - if page_scaffold.as_ref().is_some_and(|options| !options.write) { - planned_outputs.clear(); - } - let mut transaction = OutputTransaction::new(); - if page_scaffold - .as_ref() - .is_some_and(|options| !options.overwrite) - { - transaction.refuse_existing(); - } - // Two nodes can be one picture - a logo drawn at three sizes - // shares a file, as the plugin has it - so several assets resolve - // to one path. That is one write, not a collision. Only differing - // bytes under one name are a mistake, and that is worth refusing. - // Two different drawings under one layer name land here too: the - // plugin names an asset after its layer, and a snapshot cannot - // tell two drawings of one name apart - only the exported bytes - // can. The first is written and the rest are reported, so the - // code still points at a file that exists and the caller learns - // which names hide more than one picture. - let fingerprint_path = planned_outputs - .iter() - .find(|(name, _, _)| name == "designFingerprints") - .map(|(_, target, _)| target.display_path().to_path_buf()); - let mut staged_content: BTreeMap = BTreeMap::new(); - let mut shared_names: BTreeMap> = BTreeMap::new(); - for (name, target, bytes) in planned_outputs { - let path = target.display_path().to_string_lossy().into_owned(); - let fingerprint = sha256_hex(&bytes); - match staged_content.get(&path) { - Some(staged) if *staged == fingerprint => { - written_paths.insert(name, json!(path)); - continue; - } - Some(_) - if scaffold_requested - || fingerprint_path.as_deref() == Some(target.display_path()) => - { - return Err(DevupError::new( - ErrorCode::DevupInvalidInput, - format!("Output file collision: different outputs claim {path}."), - false, - )); - } - Some(_) => { - let asset = name.strip_prefix("asset:").unwrap_or(&name).to_owned(); - shared_names.entry(path).or_default().push(asset); - continue; - } - None => { - staged_content.insert(path.clone(), fingerprint); - } - } - written_paths.insert(name.clone(), json!(path)); - transaction.stage(name, target, &bytes)?; - } - if manifest_requested && let Some(mut manifest) = pending_asset_manifest { - asset_resource_outputs = projected_asset_outputs(&manifest)?; - for asset in &mut manifest.assets { - if asset.status != AssetStatus::Exported { - continue; - } - let output_name = format!("asset:{}", asset.asset_id); - let Some(path) = written_paths.get(&output_name).and_then(Value::as_str) else { - continue; - }; - asset.output_path = Some(path.to_owned()); - asset.data_base64 = None; - for variant in &mut asset.variants { - if let Some(path) = written_paths - .get(&format!("asset:{}@2x", asset.asset_id)) - .and_then(Value::as_str) - { - variant.output_path = Some(path.into()); - variant.data_base64 = None; - } - } - } - for (path, others) in &shared_names { - manifest.diagnostics.push(Diagnostic { - code: "DEVUP_ASSET_NAME_SHARED".to_owned(), - message: format!( - "{} further drawing(s) claim the file {path}; the first is written. \ - An asset is named after its layer, as the plugin names it, so two \ - drawings a designer named alike cannot both be written.", - others.len() - ), - severity: Some(DiagnosticSeverity::Warning), - resource_kind: Some("asset".to_owned()), - details: Some(json!({ "outputPath": path, "notWritten": others })), - ..Diagnostic::default() - }); - } - let mut manifest = json!(manifest); - if let Some(assets) = manifest["assets"].as_array_mut() { - for asset in assets { - let name = asset["nodeId"] - .as_str() - .and_then(|id| payload.snapshot.nodes.get(id)) - .and_then(|node| node.typed_view().name()); - if let Some(name) = name { - asset["layerName"] = json!(name); - } - } - } - result.insert("assetManifest".to_owned(), manifest); - } - if web_ready_images { - let captures = result["assetManifest"]["assets"].as_array().into_iter().flatten() - .filter(|a| asset_captures.iter().any(|c| c.asset_id == a["assetId"].as_str().unwrap_or_default())) - .map(|a| json!({"assetId":a["assetId"],"nodeId":a["nodeId"],"format":a["format"],"scale":a["scale"], - "quality":a["quality"],"lossless":a["lossless"],"renderMode":a["renderMode"],"status":a["status"], - "role":if a["sourceKind"] == "image-fill" {"descendant-image-fill"}else{"generated-code-native-raster-boundary"}, - "sha256":a["sha256"],"byteLength":a["byteLength"],"path":a["path"],"outputPath":a["outputPath"],"errorCode":a["errorCode"]})).collect::>(); - let failed = captures - .iter() - .filter(|a| a["status"] != "exported") - .count(); - result.insert("webReadyImages".into(), json!({"preset":"web-ready-images","version":1,"maxAssetsPerBatch":6, - "total":asset_captures.len(),"exported":captures.len()-failed,"failed":failed, - "complete":failed == 0 && captures.len() == asset_captures.len() && payload.failures.is_empty() && completeness_report.snapshot.state == devup_mcp_figma::CompletenessState::Complete, - "collectionCompleteness":completeness_report.snapshot.state, - "coverage":"all acquired in-scope IMAGE paints including hidden/failed dispositions; consult collectionCompleteness for acquisition gaps","defaultRenderMode":"original-source","quality":85,"requestedScale":2,"effectsAppliedBy":"css","cssMap":"asset-css-map.json", - "plan":captures})); - } - if scaffold_requested { - page_scaffold::committed_paths(&result, &mut written_paths); - } - result.insert("outputPaths".to_owned(), Value::Object(written_paths)); - for value in result.values_mut() { - attach_diagnostic_screens(value, &owners); - } - let outline_only = - !response_selection.active() && outputs.iter().all(|o| o == "outline"); - let compact_details = if let Some(summary) = &node_summaries { - let selected = if outline_only { - super::compact::outline_response(&mut result, summary) - } else { - super::compact::select(&mut result, summary, &response_selection) - }; - selected.map_err(|mut error| { - let mut recovery=recovery_arguments.clone(); - recovery.as_object_mut().unwrap().remove("path"); - recovery.as_object_mut().unwrap().remove("responseFields"); - if outputs.iter().all(|o|o=="outline") { recovery["responseLimit"]=json!(response_selection.response_limit.min(25)); } - error.details=json!({"artifactId":artifact.artifact_id,"maxSelectedBytes":65536, - "nextAction":{"tool":"devup_figma_export","arguments":recovery, - "how":"Read original outputs as bounded resources, or select a narrower path or lower responseLimit against this same artifact. Scalar values are never silently truncated."}}); - error - })? - } else { - None - }; - let projected_outputs = projected_outputs_from_result(&result)?; - let mut projected_outputs = projected_outputs; - projected_outputs.extend(asset_resource_outputs); - projected_outputs.extend(compact_details); - let mut result = Value::Object(result); - let attachment = apply_delivery( - &mut result, - if response_selection.active() || outline_only { - DeliveryMode::Resource - } else { - delivery - }, - artifact_store, - artifact, - projected_outputs, - Some(&recovery_arguments), - ) - .await - .map_err(|error| super::delivery::with_inline_recovery(error, recovery_arguments))?; - if let Err(error) = transaction.commit() { - rollback_delivery(artifact_store, artifact, attachment).await; - return Err(error); - } - commit_delivery(attachment); - if let Some(path) = result["outputPaths"].get("designFingerprints").cloned() { - result["outputPathResults"]["committed"] = json!({"designFingerprints":path}); - } - Ok(result) - } - PendingOperation::Collect | PendingOperation::Artifact { .. } => Err(DevupError::new( - ErrorCode::DevupFigmaHandoffInvalid, - "An internal collect operation cannot be completed from an MCP artifact.", - false, - )), - } -} - -/// Last code boundary: run after all TSX/map/evidence rewrites and before any -/// file or resource is materialized. New postprocessors cannot silently add or -/// change a property without updating its verified derivation contract. -fn audit_delivered_properties(value: &mut Value) { - let mut complete = true; - let mut maps = Map::new(); - let mut frame_issues = Vec::new(); - for field in ["tsx", "componentTsx", "responsiveTsx"] { - if !value.get(field).is_some_and(Value::is_string) { - continue; - } - if let Some(map) = value["outputResults"][field] - .as_object_mut() - .and_then(|o| o.remove("_sourceMap")) - { - maps.insert(field.into(), map); - } - let contract = value["outputResults"][field] - .as_object_mut() - .and_then(|o| o.remove("_propertyContract")) - .and_then(|v| v.as_array().cloned()) - .unwrap_or_default(); - let Some(code) = value[field].as_str() else { - continue; - }; - let mut missing = - devup_mcp_devup_ui::provenance::attributes::uncovered_attributes(code, &contract); - let layout = &value["outputResults"][field]["fidelity"]["layout"]; - if layout["total"] - .as_u64() - .zip(layout["covered"].as_u64()) - .is_some_and(|(total, covered)| covered < total) - { - missing.push(json!({"reason":"unmapped-source-layout-fields", - "sourceFields":value["outputResults"][field]["fidelity"]["uncoveredLayout"], - "propertyMappingVerified":false, - "message":"Source layout obligations lack verified emitted mappings, even if every generated attribute has provenance."})); - } - if field == "responsiveTsx" - && value["outputResults"][field]["fidelity"]["mergedMappingVerified"] != true - { - missing.push(json!({"reason":"unverified-responsive-merge", - "propertyMappingVerified":false, - "gaps":value["outputResults"][field]["fidelity"]["mergeGaps"], - "message":"Read at some width's slot, the merged module does not write that width's own verified tsx; gaps names where."})); - } - value["outputResults"][field]["mappingComplete"] = json!(missing.is_empty()); - if missing.is_empty() { - continue; - } - complete = false; - let issue = json!({"code":"DEVUP_CODEGEN_PROPERTY_UNMAPPED", - "message":"The final bidirectional contract has uncovered generated attributes or source layout obligations. Existing value-loss classifications remain unchanged.", - "nodeId":value["nodeId"].as_str().or(value["source"]["nodeId"].as_str()),"severity":"warning","fidelityImpact":"none", - "details":{"stage":"delivery-property-audit","output":field,"classification":"mapping-incomplete", - "unmappedProperties":missing,"propertyMappingVerified":false, - "nextAction":"Add sourceMap or projectionEvidence derivations for these final attributes. Breakpoint-source maps do not establish merged responsive mappings.", - "evidenceLimit":"No value loss or browser composition measurement is inferred from missing provenance."}}); - if !value["projectionIssues"].is_array() { - value["projectionIssues"] = json!([]); - } - value["projectionIssues"] - .as_array_mut() - .unwrap() - .push(issue); - if value["outputResults"][field]["projection"] == "exact" { - value["outputResults"][field]["projection"] = json!("mapping-incomplete"); - } - value["outputResults"][field]["mappingComplete"] = json!(false); - } - if maps.len() > 1 && value.get("sourceMap").is_some() { - value["sourceMap"]["byOutput"] = Value::Object(maps); - } - if let Some(frames) = value.get_mut("frames").and_then(Value::as_array_mut) { - for frame in frames { - audit_delivered_properties(frame); - complete &= frame["mappingComplete"] != false; - frame_issues.extend( - frame["projectionIssues"] - .as_array() - .into_iter() - .flatten() - .filter(|d| d["details"]["stage"] == "delivery-property-audit") - .cloned(), - ); - } - } - if !frame_issues.is_empty() { - if !value["projectionIssues"].is_array() { - value["projectionIssues"] = json!([]); - } - value["projectionIssues"] - .as_array_mut() - .unwrap() - .extend(frame_issues); - } - value["mappingComplete"] = json!(complete); - if !complete { - if value["quality"]["projection"] == "exact" { - value["quality"]["projection"] = json!("mapping-incomplete"); - } - if value["status"] == "complete" { - value["status"] = json!("partial"); - } - if let Some(deliverable) = value.get_mut("deliverable") { - deliverable["isFinal"] = json!(false); - deliverable["note"] = json!( - "Generated values remain available; review missing provenance in projectionIssues before accepting the output." - ); - } - let failures = value["failures"].as_array().cloned().unwrap_or_default(); - let node_id = value["nodeId"] - .as_str() - .or(value["source"]["nodeId"].as_str()) - .map(str::to_owned); - attach_review_and_deliverable( - value.as_object_mut().unwrap(), - &["tsx".into(), "componentTsx".into(), "responsiveTsx".into()], - &failures, - node_id.as_deref(), - ); - } -} - -#[cfg(test)] -mod tests { - use super::{host_safe_asset_path, with_host_safe_asset_paths}; - - #[test] - fn a_layer_name_no_file_system_takes_is_renamed_wherever_the_code_points_at_it() { - let code = concat!( - "\n", - "\n", - "\n", - "\n", - ); - assert_eq!( - with_host_safe_asset_paths(code), - concat!( - "\n", - "\n", - "\n", - "\n", - ) - ); - } - - #[test] - fn the_written_path_is_renamed_the_same_way_the_code_is() { - assert_eq!( - host_safe_asset_path("/icons/grommet-icons:language.svg"), - "/icons/grommet-icons-language-d201f62b16f5.svg" - ); - assert_eq!( - host_safe_asset_path("/images/Frame 269.png"), - "/images/Frame-269-3e2af8571e3f.png" - ); - } -} - -/// Apply at the delivery boundary so diagnostics, evidence, and issues share -/// the same ID contract, including resource-delivered diagnostic containers. -fn attach_diagnostic_screens(value: &mut Value, owners: &BTreeMap>) { - match value { - Value::Array(items) => { - for item in items { - attach_diagnostic_screens(item, owners); - } - } - Value::Object(object) => { - if object.get("code").and_then(Value::as_str).is_some() - && object.get("message").and_then(Value::as_str).is_some() - { - let owner = object - .get("nodeId") - .and_then(Value::as_str) - .and_then(|id| owners.get(id)) - .and_then(|id| id.as_deref()); - object.insert("screenId".into(), json!(owner)); - if owner.is_none() { - object.insert("screenIdReason".into(), json!("This diagnostic has no node in a known selectable screen; no screen ID was inferred.")); - } - } - for child in object.values_mut() { - attach_diagnostic_screens(child, owners); - } - } - _ => {} - } -} - -#[cfg(test)] -mod w1_regressions { - include!("projection/design_drift_tests.rs"); - include!("projection/feedback_theme_tests.rs"); - use super::super::artifacts::ArtifactRequestKey; - use super::*; - use devup_mcp_figma::{CollectionRequest, CollectionScope}; - - #[test] - fn r13_mapping_gap_is_not_exact_or_value_loss() { - let d = Diagnostic { - code: "DEVUP_CODEGEN_PROPERTY_UNMAPPED".into(), - fidelity_impact: Some(devup_mcp_figma::FidelityImpact::None), - ..Default::default() - }; - let quality = serde_json::to_value(projection_quality(true, &[d])).unwrap(); - assert_eq!(quality, "mapping-incomplete"); - } - - #[test] - fn r17_source_layout_gap_cannot_be_overwritten_by_complete_attribute_audit() { - let mut result = json!({"tsx":"","quality":{"projection":"lossy"},"status":"partial", - "outputResults":{"tsx":{"projection":"lossy","fidelity":{"layout":{"total":1,"covered":0},"uncoveredLayout":["child#layoutSizingHorizontal"]},"_propertyContract":[]}},"projectionIssues":[]}); - audit_delivered_properties(&mut result); - assert_eq!(result["mappingComplete"], false); - assert_eq!(result["outputResults"]["tsx"]["mappingComplete"], false); - assert_eq!(result["quality"]["projection"], "lossy"); - } - - #[test] - fn r13_review_malformed_postprocess_cannot_pass_an_empty_ast() { - let mut result = json!({"tsx":"export const X = = 2400000 - ); - assert_eq!(result["nextAction"]["arguments"]["delivery"], "resource"); - commit_delivery(attachment); - } - - #[tokio::test] - async fn r13_resource_next_action_executes_same_named_frame_without_upstream() { - use super::super::{DevupAuth, DevupServer, Services}; - use devup_mcp_figma::{AuthStatus, FigmaUpstream, ReadToolCall, UpstreamResult}; - use std::sync::Arc; - struct Offline; - #[async_trait::async_trait] - impl DevupAuth for Offline { - async fn status(&self) -> Result { - panic!("unexpected authentication") - } - async fn login(&self) -> Result { - panic!("unexpected login") - } - async fn logout(&self) -> Result { - panic!("unexpected logout") - } - } - #[async_trait::async_trait] - impl FigmaUpstream for Offline { - async fn list_tools(&self) -> Result, DevupError> { - panic!("unexpected upstream discovery") - } - async fn call_read_tool(&self, _: ReadToolCall) -> Result { - panic!("unexpected upstream read") - } - } - let store = ArtifactStore::default(); - let mut data = section(2); - data.metadata["selectedRootIds"] = json!(["1:1", "1:2"]); - let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )); - let artifact = store.insert(key, data).await.unwrap(); - let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { - frame_ids, - component_name, - .. - } = &mut op - { - *frame_ids = vec!["1:1".into(), "1:2".into()]; - *component_name = Some("AuditScreen".into()); - } - let baseline = complete_operation( - op.clone(), - &artifact.payload, - "artifact", - &artifact, - &policy, - &store, - ) - .await - .unwrap(); - if let PendingOperation::Export { delivery, .. } = &mut op { - *delivery = DeliveryMode::Resource; - } - let resource = complete_operation( - op, - &artifact.payload, - "artifact", - &artifact, - &policy, - &store, - ) - .await - .unwrap(); - let mut server = DevupServer::new(Services::new(Arc::new(Offline), Arc::new(Offline))); - server.artifacts = store; - let result = server - .devup_figma_export(rmcp::handler::server::wrapper::Parameters( - serde_json::from_value(resource["nextAction"]["arguments"].clone()).unwrap(), - )) - .await - .unwrap() - .structured_content - .unwrap(); - assert_eq!(result["cache"]["artifactId"], artifact.artifact_id); - assert_eq!(result["frames"].as_array().unwrap().len(), 1); - assert_eq!(result["frames"][0]["tsx"], baseline["frames"][0]["tsx"]); - } - - #[test] - fn r13_delivery_gate_detects_future_postprocessor_even_without_public_map() { - let mut data = payload(); - for field in ["layoutSizingHorizontal", "layoutSizingVertical"] { - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert(field.into(), json!("FIXED")); - } - let output = generate_component(&data.snapshot, "1:1", &CodegenOptions::default()).unwrap(); - assert_eq!(output_result(&output)["projection"], "exact"); - let mut result = json!({"tsx":output.tsx,"quality":{"projection":"exact"},"status":"complete", - "deliverable":{"isFinal":true},"outputResults":{"tsx":output_result(&output)},"projectionIssues":[]}); - result["tsx"] = json!(output.tsx.replacen( - ">(json!({ - "characters":"ab", "textTruncation":"DISABLED", - "styledTextSegments":[{"characters":"a","fontSize":16,"fontName":{"family":"Arial"}},{"characters":"b","fontSize":16,"fontName":{"family":"serif"}}] - })).unwrap()); - let output = generate_component(&data.snapshot, "1:1", &CodegenOptions::default()).unwrap(); - assert!( - output - .diagnostics - .iter() - .any(|d| d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED") - ); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { strict, .. } = &mut op { - *strict = true; - } - let error = project(data, op).await.unwrap_err(); - assert!( - error.details["projectionIssues"] - .as_array() - .is_some_and(|issues| issues.iter().any(|d| d["code"] - == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" - && d["nodeId"] == "1:1" - && d["property"] == "fontFamily" - && d["fidelityImpact"] == "none")) - ); - } - - #[tokio::test] - async fn r13_agreeing_widths_verify_the_responsive_merge() { - let mut op = operation(&["responsiveTsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(responsive_payload(), op).await.unwrap(); - let responsive = &result["outputResults"]["responsiveTsx"]; - assert_eq!(responsive["mappingComplete"], true, "{result}"); - assert_eq!(responsive["fidelity"]["mergedMappingVerified"], true); - assert!( - !result["projectionIssues"] - .as_array() - .into_iter() - .flatten() - .any(|d| d["details"]["output"] == "responsiveTsx" - && d["code"] == "DEVUP_CODEGEN_PROPERTY_UNMAPPED") - ); - } - - #[tokio::test] - async fn r13_responsive_mapping_gap_is_explicit_and_strict_rejects() { - let mut op = operation(&["responsiveTsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - // Only the mobile width is filled. devup-ui keeps a value into the - // wider slots, so the desktop width is drawn red where it is not. - let mut data = responsive_payload(); - data.snapshot.nodes.get_mut("1:1").unwrap().fields.insert( - "fills".into(), - json!([{"type":"SOLID","color":{"r":1,"g":0,"b":0}}]), - ); - let result = project(data.clone(), op.clone()).await.unwrap(); - assert!(result["responsiveTsx"].is_string()); - assert_eq!(result["mappingComplete"], false); - let merge = result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .flat_map(|d| { - d["details"]["unmappedProperties"] - .as_array() - .into_iter() - .flatten() - }) - .find(|p| p["reason"] == "unverified-responsive-merge") - .unwrap(); - assert!( - merge["gaps"] - .as_array() - .unwrap() - .iter() - .any(|gap| gap["property"] == "bg" - && gap["slot"] == 4 - && gap["widthNodeId"] == "1:2"), - "{merge}" - ); - assert_ne!( - result["outputResults"]["responsiveTsx"]["projection"], - "exact" - ); - assert!( - result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|d| d["code"] == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" - && d["details"]["output"] == "responsiveTsx" - && d["fidelityImpact"] == "none") - ); - if let PendingOperation::Export { strict, .. } = &mut op { - *strict = true; - } - assert!(project(data, op).await.is_err()); - } - - #[tokio::test] - async fn r13_final_frame_contract_covers_both_outputs_after_filename_rewrite() { - let mut data = payload(); - data.snapshot = - serde_json::from_str(include_str!("../../../../fixtures/r13/f13-1-snapshot.json")) - .unwrap(); - data.target.node_id = Some("3997:46715".into()); - let mut op = operation(&["tsx", "componentTsx", "sourceMap"]); - if let PendingOperation::Export { - include_diagnostics, - .. - } = &mut op - { - *include_diagnostics = false; - } - let result = project(data, op).await.unwrap(); - for field in ["tsx", "componentTsx"] { - let code = result[field].as_str().unwrap(); - let entries = result["sourceMap"]["byOutput"][field].as_array().unwrap(); - for attr in devup_mcp_devup_ui::provenance::attributes::generated_attributes(code) { - assert!( - entries - .iter() - .any(|e| e["generatedProperty"] == attr.source) - || result["projectionEvidence"] - .as_array() - .unwrap() - .iter() - .any(|d| d["details"]["output"] == field - && d["details"]["generatedProperty"] == attr.source), - "{field}: {}", - attr.source - ); - } - } - } - - #[tokio::test] - async fn r13_asset_derivations_survive_map_and_diagnostics_opt_out() { - let mut data = payload(); - data.snapshot = - serde_json::from_str(include_str!("../../../../fixtures/r13/f13-1-snapshot.json")) - .unwrap(); - data.target.node_id = Some("3997:46715".into()); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { - include_diagnostics, - .. - } = &mut op - { - *include_diagnostics = false; - } - let result = project(data, op).await.unwrap(); - assert!(result.get("sourceMap").is_none()); - for prop in ["boxShadow", "objectFit", "objectPos"] { - let evidence = result["projectionEvidence"] - .as_array() - .unwrap() - .iter() - .find(|d| d["nodeId"] == "3997:46741" && d["property"] == prop) - .unwrap_or_else(|| panic!("Missing asset evidence: {prop}")); - assert_eq!(evidence["fidelityImpact"], "none"); - assert_eq!(evidence["details"]["output"], "tsx"); - assert!( - evidence["details"]["sourceFields"] - .as_array() - .is_some_and(|fields| !fields.is_empty()) - ); - assert!(evidence["details"]["calculation"].is_string()); - } - } - - #[tokio::test] - async fn r13_frame_output_paths_report_unsupported_and_write_supported_keys() { - let dir = std::env::temp_dir().join(format!("r13-paths-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let target = dir.join("frame.tsx"); - let expected = crate::test_paths::canonical(&dir).join("frame.tsx"); - let mut op = operation(&["tsx", "sourceMap"]); - if let PendingOperation::Export { - frame_ids, - output_paths, - .. - } = &mut op - { - *frame_ids = vec!["1:1".into()]; - output_paths.insert( - "tsx".into(), - dir.join("ignored.tsx").to_string_lossy().into(), - ); - output_paths.insert("frame:1:1:tsx".into(), target.to_string_lossy().into()); - output_paths.insert("typo".into(), dir.join("typo").to_string_lossy().into()); - } - let result = project(section(2), op).await.unwrap(); - assert_eq!( - result["outputPathResults"]["diagnostics"] - .as_array() - .unwrap() - .len(), - 2 - ); - assert!( - result["outputPathResults"]["supportedKeys"] - .as_array() - .unwrap() - .contains(&json!("frame:1:1:tsx")) - ); - assert_eq!( - std::fs::read_to_string(&target).unwrap(), - result["frames"][0]["tsx"].as_str().unwrap() - ); - assert_eq!( - result["outputPaths"]["frame:1:1:tsx"], - expected.to_string_lossy().as_ref() - ); - assert!(!dir.join("ignored.tsx").exists()); - std::fs::remove_file(target).unwrap(); - std::fs::remove_dir(dir).unwrap(); - } - - #[tokio::test] - async fn r13_explicit_resource_offers_single_screen_single_output_reprojection() { - let mut op = operation(&["tsx", "rawSnapshot", "sourceMap"]); - if let PendingOperation::Export { - frame_ids, - delivery, - .. - } = &mut op - { - *frame_ids = vec!["1:1".into(), "1:2".into()]; - *delivery = DeliveryMode::Resource; - } - let result = project(section(2), op).await.unwrap(); - let action = &result["nextAction"]; - assert_eq!(action["tool"], "devup_figma_export"); - assert!(action["arguments"]["artifactId"].is_string()); - assert_eq!(action["arguments"]["frameIds"], json!(["1:1"])); - assert_eq!(action["arguments"]["outputs"], json!(["tsx"])); - assert_eq!(action["arguments"]["delivery"], "inline"); - assert!(action["sizeEstimate"]["outputBytes"].as_u64().unwrap() > 0); - assert_eq!(action["sizeEstimate"]["isMeasuredReprojection"], false); - } - - #[tokio::test] - async fn r10_content_sizing_uncertainty_survives_diagnostics_opt_out() { - let data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-118-payload.json" - )) - .unwrap(); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { - frame_ids, - include_diagnostics, - .. - } = &mut op - { - *frame_ids = vec!["3997:46129".into()]; - *include_diagnostics = false; - } - let result = project(data, op).await.unwrap(); - for owner in [&result, &result["frames"][0]] { - let evidence: Vec<_> = owner["projectionEvidence"] - .as_array() - .unwrap() - .iter() - .filter(|d| d["details"]["resolution"] == "accounted-for-content-sizing") - .collect(); - assert_eq!(evidence.len(), 2); - for d in evidence { - assert_eq!(d["screenId"], "3997:46129"); - assert_eq!( - d["details"]["verification"]["reasonCode"], - "font-metrics-not-measured" - ); - assert_eq!(d["fidelityImpact"], "none"); - } - } - } - - #[tokio::test] - async fn r10_diagnostics_identify_exportable_screen() { - let data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-118-payload.json" - )) - .unwrap(); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { - frame_ids, - include_diagnostics, - .. - } = &mut op - { - *frame_ids = vec!["3997:46129".into()]; - *include_diagnostics = true; - } - let result = project(data, op).await.unwrap(); - for key in ["diagnostics", "projectionIssues", "placementContracts"] { - for d in result["frames"][0][key].as_array().unwrap() { - assert_eq!(d["screenId"], "3997:46129", "{d}"); - } - } - for d in result["projectionIssues"].as_array().unwrap() { - assert_eq!(d["screenId"], "3997:46129", "{d}"); - } - } - - #[tokio::test] - async fn r10_resource_note_does_not_claim_nonfinal_tsx_is_final() { - let data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-120-payload.json" - )) - .unwrap(); - let mut op = operation(&["tsx", "componentTsx"]); - if let PendingOperation::Export { - frame_ids, - delivery, - .. - } = &mut op - { - *frame_ids = vec!["3997:46582".into()]; - *delivery = DeliveryMode::Resource; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["deliverable"]["isFinal"], false); - assert!( - !result["deliverable"]["note"] - .as_str() - .unwrap() - .contains("final TSX") - ); - } - - #[tokio::test] - async fn r10_descendant_selection_has_corrected_arguments() { - let mut data = section(2); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("childrenIds".into(), json!(["child"])); - data.snapshot.nodes.insert("child".into(), serde_json::from_value(json!({"id":"child","type":"TEXT","fields":{"parentId":"1:1","characters":"hello"}})).unwrap()); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec!["child".into()]; - } - let error = project(data.clone(), op).await.unwrap_err(); - assert_eq!( - error.details["nextAction"]["arguments"]["frameIds"], - json!(["1:1"]), - "{error:?}" - ); - assert_eq!( - error.details["selectionIssues"][0]["reason"], - "descendant-of-screen" - ); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec!["absent".into()]; - } - let error = project(data, op).await.unwrap_err(); - assert_eq!( - error.details["selectionIssues"][0]["reason"], - "not-found-in-section" - ); - assert!(error.details["nextAction"].is_null()); - } - - #[tokio::test] - async fn r3_responsive_has_its_own_fidelity_result() { - let mut op = operation(&["responsiveTsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(responsive_payload(), op).await.unwrap(); - assert_eq!( - result["outputResults"]["responsiveTsx"]["state"], - "produced" - ); - let fidelity = &result["outputResults"]["responsiveTsx"]["fidelity"]; - assert_eq!(fidelity["scope"], "breakpoint-source-projections"); - assert_eq!(fidelity["breakpoints"].as_array().unwrap().len(), 2); - assert_eq!(fidelity["mergedMappingVerified"], true); - } - - #[tokio::test] - async fn r2_responsive_hidden_instance_and_visible_desktop() { - for instance in [true, false] { - let mut data = responsive_payload(); - for i in 1..=2 { - let root = format!("1:{i}"); - let child = format!("2:{i}"); - data.snapshot - .nodes - .get_mut(&root) - .unwrap() - .fields - .insert("childrenIds".into(), json!([child])); - data.snapshot.nodes.insert( - child.clone(), - serde_json::from_value(json!({ - "id":child,"type":if instance {"INSTANCE"} else {"FRAME"}, - "fields":{"parentId":root,"name":"Logo","width":24,"height":24, - "opacity":if instance || i == 1 {0} else {1}, - "fills":[{"type":"IMAGE","imageHash":"logo","scaleMode":"TILE"}]} - })) - .unwrap(), - ); - } - let mut op = operation(&["responsiveTsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data, op).await.unwrap(); - let code = result["responsiveTsx"] - .as_str() - .expect("responsive TSX exists"); - devup_mcp_devup_ui::validation::validate_tsx(code).unwrap(); - assert!( - code.contains("visibility="), - "hidden layout must survive: {code}" - ); - if instance { - assert!(!code.contains("/images/"), "{code}"); - } else { - // Different paint prop sets can produce separate responsive - // branches. The desktop branch must then be independently - // visible instead of inheriting the hidden mobile box. - let desktop = &code[code.find("bg=").expect("desktop paint")..]; - assert!( - code.contains("\"initial\"") - || (!desktop.contains("visibility=\"hidden\"") - && code.contains("\"block\"")), - "desktop must restore paint: {code}" - ); - assert!(code.contains("/images/"), "desktop paint retained: {code}"); - } - } - } - - #[tokio::test] - async fn r2_nonrendering_background_and_responsive_keep_code() { - for scale in ["FILL", "TILE", "BACKGROUND"] { - for field in ["tsx", "componentTsx", "responsiveTsx"] { - let mut data = responsive_payload(); - for i in 1..=2 { - let root = format!("1:{i}"); - let child = format!("2:{i}"); - data.snapshot - .nodes - .get_mut(&root) - .unwrap() - .fields - .insert("childrenIds".into(), json!([child])); - data.snapshot.nodes.insert(child.clone(), serde_json::from_value(json!({"id":child,"type":if field == "componentTsx" { "INSTANCE" } else { "FRAME" }, - "fields":{"parentId":root,"name":"Transparent paint","width":24,"height":24,"visible":true,"opacity":0, - "fills":[{"type":"IMAGE","imageHash":"hidden","scaleMode":if scale == "BACKGROUND" { "FILL" } else { scale }}]}})).unwrap()); - if scale == "BACKGROUND" { - let text_id = format!("3:{i}"); - data.snapshot - .nodes - .get_mut(&child) - .unwrap() - .fields - .insert("childrenIds".into(), json!([text_id])); - data.snapshot.nodes.insert(text_id.clone(), serde_json::from_value(json!({"id":text_id,"type":"TEXT", - "fields":{"parentId":child,"characters":"Retained text","width":20,"height":12}})).unwrap()); - } - } - let mut op = operation(&[field, "assetManifest"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data, op).await.unwrap(); - assert!( - result["failures"].as_array().unwrap().is_empty(), - "{scale}/{field}: {result}" - ); - if field != "responsiveTsx" { - for frame in result["frames"].as_array().unwrap() { - let code = frame[field] - .as_str() - .expect("background exclusion must retain code"); - assert!(!code.contains("/images/")); - assert!(code.contains("visibility=\"hidden\""), "{field}: {code}"); - devup_mcp_devup_ui::validation::validate_tsx(code).unwrap(); - } - } else { - let code = result[field] - .as_str() - .expect("responsive exclusion must retain code"); - assert!(!code.contains("/images/")); - devup_mcp_devup_ui::validation::validate_tsx(code).unwrap(); - } - } - } - } - - #[tokio::test] - async fn r2_production_six_frames_return_valid_story_tsx() { - let data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-119-payload.json" - )) - .unwrap(); - let generated = generate_collected_component( - &data, - "3997:46690", - &CodegenOptions { - inline_instances: true, - asset_names_per_node: true, - ..CodegenOptions::default() - } - .with_payload_tokens(&data), - ) - .unwrap(); - let path = - devup_mcp_devup_ui::codegen::image_fill_path(&data.snapshot, "3997:46703", 0, true) - .unwrap(); - eprintln!( - "story pre-guard excluded path {path}; referenced={}", - generated.tsx.contains(&path) - ); - let mut op = operation(&["tsx", "assetManifest"]); - if let PendingOperation::Export { - frame_ids, - include_diagnostics, - .. - } = &mut op - { - *frame_ids = [ - "3997:46315", - "3997:46715", - "3997:46333", - "3997:46361", - "3997:46461", - "3997:46690", - ] - .map(str::to_owned) - .to_vec(); - *include_diagnostics = false; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["frames"].as_array().unwrap().len(), 6); - for frame in result["frames"].as_array().unwrap() { - let tsx = frame["tsx"] - .as_str() - .expect("every selected frame must retain TSX"); - devup_mcp_devup_ui::validation::validate_tsx(tsx).unwrap(); - assert!(!tsx.contains(&host_safe_asset_path(&path))); - if frame["nodeId"] == "3997:46690" { - assert!(tsx.contains("작은 시장, 큰 사랑")); - assert!(tsx.contains("visibility=\"hidden\"")); - assert!(tsx.contains("boxSize=\"24px\"")); - } - } - assert!(result["failures"].as_array().unwrap().is_empty()); - } - - #[tokio::test] - async fn r2_withheld_output_has_issue_and_explicit_deliverable() { - let mut data = payload(); - data.snapshot.nodes.get_mut("1:1").unwrap().node_type = "VECTOR".into(); - data.assets.push(serde_json::from_value(json!({"assetId":"1:1:node", "nodeId":"1:1", - "field":"node", "sourceKind":"node", "status":"failed", "errorCode":"DEVUP_ASSET_NODE_HIDDEN"})).unwrap()); - let result = project(data, operation(&["tsx", "assetManifest"])) - .await - .unwrap(); - assert_eq!(result["deliverable"]["isFinal"], false); - assert_eq!(result["deliverable"]["withheldOutputs"][0]["output"], "tsx"); - let issue = result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .find(|i| i["code"] == "DEVUP_EXCLUDED_ASSET_REFERENCED") - .unwrap(); - assert_eq!(issue["code"], "DEVUP_EXCLUDED_ASSET_REFERENCED"); - assert_eq!(issue["property"], "assetReference"); - assert!(issue["details"]["originalValue"].is_object()); - assert_eq!(issue["details"]["appliedValue"]["state"], "withheld"); - assert!(issue["details"]["nextAction"].is_string()); - } - - #[tokio::test] - async fn r2_component_layout_has_generated_evidence_and_output_scope() { - let mut data = section(1); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("childrenIds".into(), json!(["2:1"])); - data.snapshot.nodes.insert( - "2:1".into(), - serde_json::from_value(json!({ - "id":"2:1","type":"INSTANCE","fields":{"name":"Header", "parentId":"1:1", - "visible":true,"width":360,"height":66,"childrenIds":[]} - })) - .unwrap(), - ); - let mut op = operation(&["tsx", "componentTsx"]); - if let PendingOperation::Export { - all_screens, - include_diagnostics, - .. - } = &mut op - { - *all_screens = true; - *include_diagnostics = false; - } - let result = project(data, op).await.unwrap(); - let frame = &result["frames"][0]; - let issues = frame["projectionIssues"].as_array().unwrap(); - for issue in issues - .iter() - .filter(|i| i["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") - { - assert!(issue["details"]["appliedValue"].is_object(), "{issue}"); - assert!(issue["details"]["output"].is_string(), "{issue}"); - assert!(issue["details"]["nextAction"].is_string(), "{issue}"); - } - assert!(frame["outputResults"]["tsx"]["fidelity"].is_object()); - assert!(frame["outputResults"]["componentTsx"]["fidelity"].is_object()); - assert!( - !frame["projectionReview"]["groups"] - .as_array() - .unwrap() - .is_empty() - ); - } - - #[tokio::test] - async fn r5_wquw119_missing_mask_size_is_output_scoped_and_not_final() { - let mut data = payload(); - data.snapshot = - serde_json::from_str(include_str!("../../../../fixtures/wquw-119-snapshot.json")) - .unwrap(); - data.target.node_id = Some("3997:46315".into()); - data.target.file_key = data.snapshot.file_key.clone(); - let node = data.snapshot.nodes.get_mut("3997:46317").unwrap(); - node.fields.remove("height"); - node.fields.remove("absoluteBoundingBox"); - let mut op = operation(&["tsx", "sourceMap"]); - if let PendingOperation::Export { - include_diagnostics, - .. - } = &mut op - { - *include_diagnostics = false; - } - let result = project(data, op).await.unwrap(); - assert_ne!(result["quality"]["projection"], "exact"); - assert_eq!(result["deliverable"]["isFinal"], false); - assert!( - result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|issue| issue["nodeId"] == "3997:46317" - && issue["property"] == "height" - && issue["details"]["output"] == "tsx"), - "{result}" - ); - } - - #[tokio::test] - async fn r4_direct_export_discloses_capture_boundary_without_diagnostics() { - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { - include_diagnostics, - .. - } = &mut op - { - *include_diagnostics = false; - } - let result = project(payload(), op).await.unwrap(); - let contract = &result["placementContracts"][0]["details"]; - assert_eq!(contract["output"], "tsx"); - assert_eq!(contract["parentCollected"], false); - assert!(contract["parentMissingReason"].is_string()); - assert_eq!(contract["containingBlock"], "normal-flow-host"); - assert!(contract["coordinateBasis"].is_string()); - } - - /// A screen exported on its own said nothing about the surface it is - /// drawn on, so learning that the desktop frame behind an app screen was - /// `#E6EDE6` took a second export of that parent — a second Figma - /// acquisition to read a colour this capture was already carrying. - #[tokio::test] - async fn r4_placement_contract_reports_the_collected_parent_background() { - let mut data = payload(); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("parentId".into(), json!("0:1")); - data.snapshot.nodes.insert( - "0:1".into(), - serde_json::from_value(json!({ - "id":"0:1", "type":"FRAME", "fields":{ - "name":"Desktop", "parentId":null, "childrenIds":["1:1"], "visible":true, - "x":0, "y":0, "width":1920, "height":1080, "layoutMode":"NONE", - "fills":[{"type":"SOLID", "visible":true, "opacity":1, "blendMode":"NORMAL", - "color":{"r":230.0/255.0, "g":237.0/255.0, "b":230.0/255.0}}]} - })) - .unwrap(), - ); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { - include_diagnostics, - .. - } = &mut op - { - *include_diagnostics = false; - } - let result = project(data, op).await.unwrap(); - let details = &result["placementContracts"][0]["details"]; - assert_eq!(details["parentCollected"], true, "{result}"); - let background = &details["sourceParentBackground"]; - assert_eq!(background["color"], "#E6EDE6", "{result}"); - assert_eq!(background["name"], "Desktop", "{result}"); - assert!(background["note"].is_string(), "{result}"); - // Reported, never emitted: the parent is still not this component. - assert_eq!(details["parentIncludedInOutput"], false, "{result}"); - } - - #[tokio::test] - async fn r4_export_exposes_placement_contract_without_diagnostics() { - let data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-120-payload.json" - )) - .unwrap(); - let mut op = operation(&["tsx", "componentTsx"]); - if let PendingOperation::Export { - frame_ids, - include_diagnostics, - .. - } = &mut op - { - *frame_ids = vec!["3997:46582".into()]; - *include_diagnostics = false; - } - let result = project(data, op).await.unwrap(); - let frame = &result["frames"][0]; - for field in ["tsx", "componentTsx"] { - let source = frame[field].as_str().unwrap(); - let tag = source - .split("return (") - .nth(1) - .unwrap() - .split('>') - .next() - .unwrap(); - for prop in ["pos=\"relative\"", "w=\"360px\"", "h=\"740px\""] { - assert!(tag.contains(prop), "{tag}"); - } - assert!(source.contains("whiteSpace=\"pre-wrap\"")); - let contract = frame["placementContracts"] - .as_array() - .unwrap() - .iter() - .find(|d| d["details"]["output"] == field) - .unwrap(); - assert_eq!(contract["details"]["containingBlock"], "generated-root"); - } - assert_eq!(result["placementContracts"].as_array().unwrap().len(), 2); - assert_eq!( - frame["outputResults"]["tsx"]["fidelity"]["layout"]["covered"], - 134 // R17: prior 117 + 17 verified horizontal FILL obligations. - ); - assert_eq!( - frame["outputResults"]["componentTsx"]["fidelity"]["impacts"]["lossy"], - 40 // The paragraph now preserves its spaces; all other losses remain. - ); - assert!( - !frame["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|issue| { - issue["code"] == "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE" - && issue["nodeId"] == "3997:46604" - && issue["property"] == "characters" - && issue["fidelityImpact"] == "lossy" - }) - ); - } - - #[tokio::test] - async fn r9_confirmed_evidence_survives_diagnostics_opt_out() { - let mut data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-120-payload.json" - )) - .unwrap(); - data.snapshot - .nodes - .get_mut("3997:46621") - .unwrap() - .fields - .insert("visible".into(), json!(false)); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { - frame_ids, - include_diagnostics, - .. - } = &mut op - { - *frame_ids = vec!["3997:46582".into()]; - *include_diagnostics = false; - } - let result = project(data, op).await.unwrap(); - for owner in [&result, &result["frames"][0]] { - let evidence = owner["projectionEvidence"] - .as_array() - .expect("confirmed evidence remains available without diagnostics"); - let d = evidence - .iter() - .find(|d| { - d["nodeId"] == "3997:46621" && d["code"] == "DEVUP_CODEGEN_NON_RENDERING_ASSET" - }) - .unwrap(); - assert_eq!(d["fidelityImpact"], "none"); - assert_eq!(d["details"]["verification"]["field"], "visible"); - assert_eq!(d["details"]["verification"]["state"], "accounted-for"); - assert_eq!(d["details"]["output"], "tsx"); - assert!( - !owner["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|d| d["nodeId"] == "3997:46621") - ); - } - } - - #[tokio::test] - async fn r2_production_layout_evidence_is_actionable_per_output() { - for (raw, ids, fields, count) in [ - ( - include_str!("../../../../fixtures/r2/wquw-118-payload.json"), - vec!["3997:46242", "3997:46277", "3997:46129"], - vec!["tsx", "assetManifest"], - 0, // R10: both auto-size dimensions are accounted for, not lost. - ), - ( - include_str!("../../../../fixtures/r2/wquw-120-payload.json"), - vec!["3997:46582"], - vec!["tsx", "componentTsx", "assetManifest"], - 40, - ), - ] { - let data: CollectedPayload = serde_json::from_str(raw).unwrap(); - let mut op = operation(&fields); - if let PendingOperation::Export { - frame_ids, - include_diagnostics, - delivery, - .. - } = &mut op - { - *frame_ids = ids.into_iter().map(str::to_owned).collect(); - *include_diagnostics = false; - *delivery = DeliveryMode::Auto; - } - let store = ArtifactStore::default(); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )), - data, - ) - .await - .unwrap(); - let mut result = complete_operation( - op, - &artifact.payload, - "fixture", - &artifact, - &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), - &store, - ) - .await - .unwrap(); - // Rich image evidence can promote this response to resources. Read - // the retained complete diagnostic payload before asserting geometry. - if let Some(resource) = store - .output_manifests() - .await - .into_iter() - .find(|m| m.name == "projection-details.json") - { - let mut bytes = Vec::new(); - for index in 0..resource.chunk_count { - bytes.extend( - store - .read_resource_output( - &artifact.artifact_id, - &resource.output_id, - Some(index), - ) - .await - .unwrap() - .1, - ); - } - let details: Value = serde_json::from_slice(&bytes).unwrap(); - for (field, value) in details["response"].as_object().unwrap() { - result[field] = value.clone(); - } - for frame in result["frames"].as_array_mut().into_iter().flatten() { - if let Some(full) = details["frames"] - .as_array() - .unwrap() - .iter() - .find(|full| full["nodeId"] == frame["nodeId"]) - { - for (field, value) in full.as_object().unwrap() { - frame[field] = value.clone(); - } - } - } - } - let issues = result["projectionIssues"].as_array().unwrap(); - for issue in issues - .iter() - .chain(result["projectionEvidence"].as_array().unwrap()) - .filter(|i| { - matches!( - i["code"].as_str(), - Some("DEVUP_CODEGEN_ABSOLUTE_FALLBACK" | "DEVUP_CODEGEN_ABSOLUTE_VERIFIED") - ) - }) - { - let original = &issue["details"]["originalValue"]; - assert_eq!(original["x"], 0); - assert_eq!(original["y"], 0); - assert_eq!(original["width"], 360); - assert_eq!(original["height"], 740); - assert_eq!(original["parent"]["width"], 360); - assert_eq!(original["constraints"]["horizontal"], "CENTER"); - assert_eq!(original["children"][0]["x"], 20); - let padding = &issue["details"]["appliedValue"]["derivedPadding"]; - assert_eq!(padding["left"], 20.0); - assert_eq!(padding["top"], if count == 40 { 232.5 } else { 185.5 }); - } - let layout: Vec<_> = issues - .iter() - .filter(|i| i["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") - .collect(); - let original_class = if count == 40 { - "component-reference" - } else { - "text-auto-size" - }; - if count == 0 { - assert_eq!( - result["projectionEvidence"] - .as_array() - .unwrap() - .iter() - .filter(|d| d["details"]["resolution"] == "accounted-for-content-sizing") - .count(), - 2 - ); - } - assert_eq!( - layout - .iter() - .filter(|i| i["details"]["classification"] == original_class) - .count(), - if count == 40 { 39 } else { count } // R17 fluid width now proves one previously missing mapping. - ); - for issue in layout { - assert_eq!( - issue["details"]["appliedValue"]["state"], "emitted", - "{issue}" - ); - assert!( - issue["details"]["appliedValue"]["generatedSource"] - .as_str() - .is_some_and(|s| !s.is_empty()) - ); - assert!(issue["details"]["nextAction"].is_string()); - if issue["details"]["classification"] == "component-reference" { - assert_eq!(issue["details"]["output"], "componentTsx"); - assert_eq!(issue["details"]["classification"], "component-reference"); - } else if issue["property"] == "layoutSizingHorizontal" { - // R17 checks source FILL even when no width property was emitted. - assert_eq!(issue["details"]["originalValue"], "FILL"); - assert_eq!(issue["fidelityImpact"], "lossy"); - assert_eq!( - issue["details"]["implicitCssVerification"]["state"], - "not-accounted-for" - ); - assert!(issue["details"]["implicitCssVerification"]["reasonCode"].is_string()); - } else if issue["property"] == "layoutSizingVertical" { - // R6 exposes a real percentage-height dependency that R2 did not count. - assert_eq!(issue["nodeId"], "3997:46313"); - assert_eq!(issue["details"]["output"], "tsx"); - assert_eq!(issue["fidelityImpact"], "lossy"); - assert_eq!(issue["details"]["originalValue"], "FILL"); - assert_eq!( - issue["details"]["implicitCssVerification"]["state"], - "not-accounted-for" - ); - } else if matches!( - issue["details"]["classification"].as_str(), - Some("asset-projection" | "property-unmapped") - ) { - // R5 additionally discloses unproven asset percentage axes. - assert_eq!(issue["details"]["output"], "tsx"); - assert!( - matches!(issue["property"].as_str(), Some("width" | "height")), - "{issue}" - ); - } else { - assert_eq!(issue["details"]["output"], "tsx"); - assert_eq!(issue["details"]["classification"], "text-auto-size"); - assert!( - issue["details"]["appliedValue"]["generatedSource"] - .as_str() - .unwrap() - .contains("", - ) - .unwrap(), - ); - let result = project(data, operation(&["tsx", "assetManifest"])) - .await - .unwrap(); - assert!(result["tsx"].is_string()); - assert_eq!(result["failures"], json!([])); - assert_eq!(result["assetSummary"]["status"], "collected"); - } - - #[tokio::test] - async fn r1_shared_visible_asset_path_is_not_excluded() { - let mut data = payload(); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("childrenIds".into(), json!(["2:1", "2:2", "2:3"])); - for (id, visible) in [("2:1", false), ("2:2", true)] { - data.snapshot.nodes.insert(id.into(), serde_json::from_value(json!({"id":id,"type":"VECTOR", - "fields":{"name":"Shared", "parentId":"1:1", "visible":visible,"width":24,"height":24}})).unwrap()); - } - data.snapshot.nodes.insert("2:3".into(), serde_json::from_value(json!({"id":"2:3", "type":"TEXT", "fields":{"parentId":"1:1", "characters":"Label"}})).unwrap()); - let mut op = operation(&["tsx", "assetManifest"]); - if let PendingOperation::Export { - asset_names_per_node, - .. - } = &mut op - { - *asset_names_per_node = false; - } - let result = project(data, op).await.unwrap(); - assert!(result["tsx"].is_string()); - assert_eq!(result["failures"], json!([])); - let path = result["assetManifest"]["assets"][0]["path"] - .as_str() - .unwrap(); - assert!(result["tsx"].as_str().unwrap().contains(path)); - } - - fn payload() -> CollectedPayload { - serde_json::from_value(json!({ - "target": {"fileKey":"W1Fixture", "nodeId":"1:1", "branchKey":null}, - "scope":"node", "metadata":{}, "variables":null, "styles":null, - "completeness":"resolved-values-only", "sourceVersion":null, - "snapshot": {"fileKey":"W1Fixture", "version":null, "roots":["1:1"], - "nodes":{"1:1":{"id":"1:1","type":"FRAME","fields":{ - "name":"Screen", "x":0,"y":0,"width":390,"height":800, - "visible":true,"childrenIds":[]}}},"diagnostics":[]} - })) - .unwrap() - } - - #[tokio::test] - async fn p1_frames_report_generated_asset_only_equivalence() { - let mut data = section(3); - for i in 1..=3 { - let id = format!("1:{i}"); - let node = data.snapshot.nodes.get_mut(&id).unwrap(); - node.fields.insert( - "fills".into(), - json!([{"type":"IMAGE", "imageHash":format!("image{i}"), "scaleMode":"FILL"}]), - ); - } - data.snapshot - .nodes - .get_mut("1:3") - .unwrap() - .fields - .insert("childrenIds".into(), json!(["3:1"])); - data.snapshot.nodes.insert( - "3:1".into(), - serde_json::from_value(json!({ - "id":"3:1", "type":"TEXT", "fields":{"name":"Copy", "parentId":"1:3", - "characters":"Different copy", "fontSize":16, "width":100, "height":24} - })) - .unwrap(), - ); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["frames"][1]["structurallyIdenticalTo"], "1:1"); - assert_eq!( - result["frames"][1]["differsOnly"], - json!(["assetReferences"]) - ); - assert!(result["frames"][2].get("structurallyIdenticalTo").is_none()); - } - - #[tokio::test] - async fn p1_asset_summary_distinguishes_none_uncollected_and_hidden() { - let result = project(payload(), operation(&["tsx"])).await.unwrap(); - assert_eq!(result["assetSummary"]["status"], "none"); - let mut data = payload(); - data.snapshot.nodes.get_mut("1:1").unwrap().node_type = "VECTOR".into(); - let result = project(data.clone(), operation(&["assetManifest"])) - .await - .unwrap(); - assert_eq!(result["assetSummary"]["status"], "not-collected"); - assert_eq!( - result["assetSummary"]["unavailable"][0]["reason"], - "not-requested" - ); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("visible".into(), json!(false)); - let result = project(data, operation(&["assetManifest"])).await.unwrap(); - assert_eq!(result["assetSummary"]["status"], "not-collected"); - assert_eq!( - result["assetSummary"]["unavailable"][0]["reason"], - "hidden-node" - ); - } - - #[tokio::test] - async fn p1_incomplete_asset_discovery_never_claims_no_assets() { - let mut data = payload(); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("childrenIds".into(), json!(["missing"])); - let result = project(data, operation(&["assetManifest"])).await.unwrap(); - assert_eq!(result["assetSummary"]["status"], "unknown"); - assert_eq!(result["assetSummary"]["discovery"], "incomplete"); - } - - #[tokio::test] - async fn p1_selection_separates_explicit_cases_from_automatic_screens() { - let mut data = section(2); - data.metadata["sectionIndex"]["candidates"][1]["bounds"]["width"] = json!(150); - data.metadata["sectionIndex"]["candidates"][1]["bounds"]["height"] = json!(150); - let result = project(data.clone(), operation(&["tsx"])).await.unwrap(); - assert_eq!(result["selection"]["count"], 1); - assert_eq!( - result["selection"]["explicitCandidates"][0]["node"]["nodeId"], - "1:2" - ); - assert_eq!( - result["selection"]["explicitCandidates"][0]["node"]["kind"], - "annotation" - ); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec!["1:2".into()]; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["frames"][0]["nodeId"], "1:2"); - } - - #[test] - fn p1_asset_comparison_preserves_literal_text_and_unknown_paths() { - let paths = vec!["/icons/a.svg".into(), "/icons/b.svg".into()]; - assert_eq!( - generated_structure("", &paths), - generated_structure("", &paths) - ); - assert_ne!( - generated_structure("{\"url(/icons/a.svg)\"}", &paths), - generated_structure("{\"url(/icons/b.svg)\"}", &paths) - ); - assert_ne!( - generated_structure("", &paths), - generated_structure("", &paths) - ); - } - - #[test] - fn p1_layered_backgrounds_compare_every_asset_reference() { - let paths = vec!["/images/a.png".into(), "/images/b.png".into()]; - let first = ""; - let second = ""; - assert_eq!( - generated_structure(first, &paths), - generated_structure(second, &paths) - ); - assert_ne!( - generated_structure(first, &paths), - generated_structure(&second.replace("red", "green"), &paths) - ); - } - - #[tokio::test] - async fn p1_selection_does_not_claim_to_include_an_unproduced_manifest() { - let result = project(section(2), operation(&["tsx", "assetManifest"])) - .await - .unwrap(); - assert_eq!(result["status"], "selection_required"); - assert!(result.get("assetManifest").is_none()); - assert_eq!(result["assetSummary"]["manifestIncluded"], false); - for output in ["imageAudit", "assetManifest"] { - let result = project(section(2), operation(&[output])).await.unwrap(); - assert_eq!(result["status"], "selection_required"); - assert!( - result["nextAction"]["example"]["arguments"]["outputs"] - .as_array() - .unwrap() - .iter() - .any(|o| o == output) - ); - } - } - - #[test] - fn p1_comparison_scopes_partial_frames_to_their_generated_outputs() { - let mut frames = vec![ - json!({"nodeId":"1", "status":"complete", "tsx":"code", "componentTsx":"component"}), - json!({"nodeId":"2", "status":"partial", "tsx":"code"}), - json!({"nodeId":"3", "status":"partial", "tsx":"code", "componentTsx":"component"}), - json!({"nodeId":"4", "status":"partial"}), - ]; - annotate_identical_frames(&mut frames, &payload(), true); - assert!(frames[1].get("structurallyIdenticalTo").is_none()); - assert_eq!(frames[2]["structurallyIdenticalTo"], "1"); - assert_eq!(frames[2]["comparedOutputs"], json!(["tsx", "componentTsx"])); - assert!(frames[3].get("structurallyIdenticalTo").is_none()); - } - - #[tokio::test] - async fn p1_identical_hints_and_asset_summary_survive_resource_delivery() { - let mut op = operation(&["tsx", "componentTsx"]); - if let PendingOperation::Export { - all_screens, - delivery, - .. - } = &mut op - { - *all_screens = true; - *delivery = DeliveryMode::Resource; - } - let result = project(section(2), op).await.unwrap(); - assert!(result["frames"][0].get("tsx").is_none()); - assert_eq!(result["frames"][1]["structurallyIdenticalTo"], "1:1"); - assert_eq!(result["frames"][1]["differsOnly"], json!([])); - assert_eq!( - result["frames"][1]["comparedOutputs"], - json!(["tsx", "componentTsx"]) - ); - assert!(result["assetSummary"].is_object()); - assert!(result["frames"][0]["assetSummary"].is_object()); - } - - #[tokio::test] - async fn p1_collected_assets_are_distinct_from_delivered_manifest() { - let mut data = payload(); - data.snapshot.nodes.get_mut("1:1").unwrap().node_type = "VECTOR".into(); - data.assets.push( - devup_mcp_figma::exported_asset_from_bytes( - &devup_mcp_figma::AssetRequest { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - render_mode: devup_mcp_figma::AssetRenderMode::Node, - quality: None, - lossless: None, - asset_id: "1:1:node".into(), - node_id: "1:1".into(), - field: "node".into(), - image_hash: None, - format: devup_mcp_figma::AssetFormat::Svg, - scale: 1.0, - }, - b"", - ) - .unwrap(), - ); - let result = project(data, operation(&["tsx"])).await.unwrap(); - assert_eq!(result["assetSummary"]["status"], "collected"); - assert_eq!(result["assetSummary"]["collectedCount"], 1); - assert_eq!(result["assetSummary"]["manifestIncluded"], false); - assert!(result.get("assetManifest").is_none()); - } - - #[tokio::test] - async fn p3_identical_bytes_share_manifest_paths() { - let mut data = payload(); - for (id, path) in [("1:2", "/icons/a.svg"), ("1:3", "/icons/b.svg")] { - data.assets.push( - serde_json::from_value(json!({ - "assetId":id,"nodeId":id,"field":"svg","sourceKind":"svg", - "status":"exported","format":"svg","scale":1,"mimeType":"image/svg+xml", - "dataBase64":STANDARD.encode(b""), "byteLength":6, - "sha256":sha256_hex(b""),"path":path - })) - .unwrap(), - ); - } - let result = project(data, operation(&["assetManifest"])).await.unwrap(); - let assets = result["assetManifest"]["assets"].as_array().unwrap(); - assert_eq!(assets[0]["path"], assets[1]["path"]); - } - - #[tokio::test] - async fn p3_selection_call_preserves_export_options() { - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { - component_name, - strict, - .. - } = &mut op - { - *component_name = Some("TicketScreen".into()); - *strict = true; - } - let result = project(section(2), op).await.unwrap(); - let args = &result["nextAction"]["example"]["arguments"]; - assert_eq!(args["componentName"], "TicketScreen"); - assert_eq!(args["strict"], true); - assert_eq!(args["frameIds"], json!(["1:1"])); - } - - fn original_source_payload() -> CollectedPayload { - let mut png = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(800, 500, |x, y| { - image::Rgba([(x % 255) as u8, (y % 255) as u8, 127, 179]) - })) - .write_to(&mut png, image::ImageFormat::Png) - .unwrap(); - let mut data = p3_asset_payload(png.get_ref()); - data.assets.clear(); - data.snapshot.nodes.remove("1:3"); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("childrenIds".into(), json!(["1:2"])); - let node = data.snapshot.nodes.get_mut("1:2").unwrap(); - node.fields.insert("width".into(), json!(120.1)); - node.fields.insert("height".into(), json!(80)); - node.fields.insert( - "effects".into(), - json!([{"type":"LAYER_BLUR","radius":12,"visible":true}]), - ); - let req = devup_mcp_figma::AssetRequest { - usage_aspect_ratio: Some(1.0), - max_width: Some(300), - srcset: true, - visible_scope_node_id: None, - quality: Some(85), - lossless: Some(true), - render_mode: devup_mcp_figma::AssetRenderMode::OriginalSource, - asset_id: "1:2:fills:0".into(), - node_id: "1:2".into(), - field: "fills/0".into(), - image_hash: Some("1:2".into()), - format: devup_mcp_figma::AssetFormat::Webp, - scale: 2.0, - }; - let result = devup_mcp_figma::UpstreamResult { - raw: json!({"kind":"devupAssetExport","fileKey":"W1Fixture","version":null,"assetId":req.asset_id,"nodeId":req.node_id,"field":req.field,"imageHash":req.image_hash,"format":"webp","scale":2,"renderMode":"original-source","usageAspectRatio":1,"maxWidth":300,"srcset":true,"visibleScopeNodeId":null,"status":"exported","mimeType":"image/png","byteLength":png.get_ref().len(),"sha256":sha256_hex(png.get_ref()),"data":STANDARD.encode(png.get_ref()),"exportDiagnostics":{"method":"original-fill","stages":[],"renderInfo":{"representation":"original-fill","nodeSize":{"width":120.1,"height":80,"aspectRatio":120.1/80.0},"sourceProperties":{"scaleMode":"FILL","rotation":0,"effects":[{"type":"LAYER_BLUR","radius":12}]}}}}), - }; - let devup_mcp_figma::AssetExportOutcome::Entry(entry) = - devup_mcp_figma::asset_export_from_result(&result, "W1Fixture", None, &req).unwrap() - else { - panic!("entry") - }; - data.assets.push(*entry); - data - } - - #[tokio::test] - async fn original_source_variants_css_and_public_files_share_actual_byte_paths() { - let root = - std::env::temp_dir().join(format!("devup-original-public-{}", rand::random::())); - std::fs::create_dir_all(&root).unwrap(); - let root = crate::test_paths::canonical(&root); - let base = root.join("images").join("source.webp"); - let css = root.join("asset-css-map.json"); - let mut op = operation(&["tsx", "assetManifest", "assetCssMap", "imageAudit"]); - if let PendingOperation::Export { - asset_output_paths, - asset_public_root, - output_paths, - .. - } = &mut op - { - asset_output_paths.insert("1:2:fills:0".into(), base.to_string_lossy().into()); - *asset_public_root = Some(root.clone()); - output_paths.insert("assetCssMap".into(), css.to_string_lossy().into()); - } - let value = project(original_source_payload(), op).await.unwrap(); - let asset = &value["assetManifest"]["assets"][0]; - let path = asset["path"].as_str().unwrap(); - assert_eq!(path, "/images/source.webp"); - assert_eq!(asset["variants"][0]["path"], "/images/source@2x.webp"); - let base_image = image::open(&base).unwrap(); - let twice = image::open(root.join("images/source@2x.webp")).unwrap(); - assert_eq!(base_image.width(), 121); - assert_eq!(twice.width(), 241); - let code = value["tsx"].as_str().unwrap(); - assert!(code.contains(path)); - assert!(code.contains("/images/source@2x.webp"), "{code}"); - assert!(code.contains("blur(12px)")); - assert!(code.contains("srcSet=")); - let map: Value = serde_json::from_slice(&std::fs::read(&css).unwrap()).unwrap(); - assert_eq!( - std::fs::read(&css).unwrap(), - serde_json::to_vec_pretty(&value["assetCssMap"]).unwrap(), - "written CSS mapping must match the inline JSON encoding byte-for-byte" - ); - assert_eq!(map["assets"][0]["effectsBaked"], false); - assert_eq!(map["assets"][0]["assetPath"], path); - assert_eq!( - value["imageAudit"]["assets"][0]["aspectRatioComparison"]["nodeVsUsage"], - "mismatch" - ); - assert!( - value["imageAudit"]["assets"][0]["aspectRatioComparison"]["usageEvidence"] - .as_str() - .unwrap() - .contains("caller-declared") - ); - std::fs::remove_dir_all(root).unwrap(); - } - - #[tokio::test] - async fn original_source_variant_and_css_map_resources_recover_without_reacquiring() { - let store = ArtifactStore::default(); - let data = original_source_payload(); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )), - data, - ) - .await - .unwrap(); - let mut op = operation(&["tsx", "assetManifest", "assetCssMap"]); - if let PendingOperation::Export { delivery, .. } = &mut op { - *delivery = DeliveryMode::Resource; - } - let value = complete_operation( - op, - &artifact.payload, - "fixture", - &artifact, - &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), - &store, - ) - .await - .unwrap(); - let manifests = store.output_manifests().await; - assert!(manifests.iter().any(|m| m.name == "asset-css-map.json")); - assert_eq!( - manifests - .iter() - .filter(|m| m.mime_type == "image/webp") - .count(), - 2 - ); - for manifest in manifests.iter().filter(|m| m.mime_type == "image/webp") { - let mut bytes = Vec::new(); - for index in 0..manifest.chunk_count { - bytes.extend( - store - .read_resource_output( - &artifact.artifact_id, - &manifest.output_id, - Some(index), - ) - .await - .unwrap() - .1, - ); - } - assert_eq!( - image::guess_format(&bytes).unwrap(), - image::ImageFormat::WebP - ); - assert_eq!(sha256_hex(&bytes), manifest.sha256); - } - assert_eq!( - value["nextAction"]["arguments"]["artifactId"], - artifact.artifact_id - ); - assert!(value["assetCssMap"]["assets"].is_null()); - } - - fn p3_asset_payload(second_bytes: &[u8]) -> CollectedPayload { - let mut data = payload(); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("childrenIds".into(), json!(["1:2", "1:3"])); - for (id, bytes) in [("1:2", b"first image".as_slice()), ("1:3", second_bytes)] { - data.snapshot.nodes.insert( - id.into(), - serde_json::from_value(json!({ - "id":id,"type":"RECTANGLE","fields":{"name":"Logo","parentId":"1:1", - "visible":true,"width":40,"height":40,"x":0,"y":0,"isAsset":true, - "fills":[{"type":"IMAGE","imageHash":id,"scaleMode":"FILL"}]} - })) - .unwrap(), - ); - data.assets.push(serde_json::from_value(json!({ - "assetId":format!("{id}:fills:0"),"nodeId":id,"field":"fills/0","sourceKind":"image", - "status":"exported","format":"png","scale":1,"mimeType":"image/png", - "dataBase64":STANDARD.encode(bytes),"byteLength":bytes.len(),"sha256":sha256_hex(bytes) - })).unwrap()); - } - data - } - - #[tokio::test] - async fn image_modes_keep_file_manifest_and_shared_resource_observations_consistent() { - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::new_rgba8(8, 4) - .write_to(&mut encoded, image::ImageFormat::Png) - .unwrap(); - let bytes = encoded.into_inner(); - for delivery_mode in [DeliveryMode::Inline, DeliveryMode::Resource] { - let mut data = p3_asset_payload(&bytes); - data.assets.clear(); - for (id, mode) in [ - ("1:2", devup_mcp_figma::AssetRenderMode::Node), - ("1:3", devup_mcp_figma::AssetRenderMode::Fill), - ] { - data.assets.push( - devup_mcp_figma::exported_asset_from_bytes( - &devup_mcp_figma::AssetRequest { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - asset_id: format!("{id}:fills:0"), - node_id: id.into(), - field: "fills/0".into(), - image_hash: Some(id.into()), - format: devup_mcp_figma::AssetFormat::Png, - scale: 1.0, - render_mode: mode, - quality: None, - lossless: None, - }, - &bytes, - ) - .unwrap(), - ); - } - let path = std::env::temp_dir().join(format!( - "image-modes-{}-{:?}.png", - std::process::id(), - delivery_mode - )); - let mut op = operation(&["assetManifest"]); - if let PendingOperation::Export { - delivery, - asset_output_paths, - .. - } = &mut op - { - *delivery = delivery_mode; - asset_output_paths - .insert("1:2:fills:0".into(), path.to_string_lossy().into_owned()); - } - let store = ArtifactStore::default(); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )), - data, - ) - .await - .unwrap(); - let result = complete_operation( - op, - &artifact.payload, - "fixture", - &artifact, - &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), - &store, - ) - .await - .unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), bytes); - let manifest = if delivery_mode == DeliveryMode::Resource { - let manifest = store - .output_manifests() - .await - .into_iter() - .find(|m| m.name == "asset-manifest.json") - .unwrap(); - let mut bytes = Vec::new(); - for index in 0..manifest.chunk_count { - bytes.extend( - store - .read_resource_output( - &artifact.artifact_id, - &manifest.output_id, - Some(index), - ) - .await - .unwrap() - .1, - ); - } - serde_json::from_slice::(&bytes).unwrap() - } else { - result["assetManifest"].clone() - }; - let assets = manifest["assets"].as_array().unwrap(); - for (asset, mode) in assets.iter().zip(["node", "fill"]) { - assert_eq!(asset["renderMode"], mode); - assert_eq!(asset["renderInfo"]["outputSize"]["width"], 8.0); - assert_eq!(asset["renderInfo"]["aspectRatioComparison"], "mismatch"); - assert_eq!( - asset["outputPath"], - result["outputPaths"][format!("asset:{}", asset["assetId"].as_str().unwrap())] - ); - assert!(asset["suggestedFilename"].as_str().unwrap().contains(mode)); - } - if delivery_mode == DeliveryMode::Resource { - let resource = result["resources"] - .as_array() - .unwrap() - .iter() - .find(|r| r["mimeType"] == "image/png") - .unwrap(); - let metadata = resource["assetMetadata"]["assets"].as_array().unwrap(); - assert_eq!(metadata.len(), 2); - for (alias, asset) in metadata.iter().zip(assets) { - assert_eq!(alias["renderMode"], asset["renderMode"]); - assert_eq!(alias["renderInfo"], asset["renderInfo"]); - assert_eq!(alias["suggestedFilename"], asset["suggestedFilename"]); - } - } - std::fs::remove_file(path).unwrap(); - } - } - - #[tokio::test] - async fn original_fill_artifacts_cannot_silently_replace_rendered_codegen_assets() { - let mut data = p3_asset_payload(b"same image"); - data.assets[0].render_mode = devup_mcp_figma::AssetRenderMode::Fill; - for output in ["tsx", "componentTsx", "responsiveTsx"] { - let error = project(data.clone(), operation(&[output])) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupInvalidInput); - assert!(error.message.contains("renderMode node")); - } - } - - #[tokio::test] - async fn auto_native_assets_reproject_code_and_audit_without_changing_cached_capture() { - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::new_rgba8(40, 40) - .write_to(&mut encoded, image::ImageFormat::Png) - .unwrap(); - let bytes = encoded.into_inner(); - let mut data = p3_asset_payload(&bytes); - for asset in &mut data.assets { - asset.render_mode = devup_mcp_figma::AssetRenderMode::Auto; - asset.source_kind = "image-fill".into(); - asset.data_base64 = Some(STANDARD.encode(&bytes)); - asset.byte_length = Some(bytes.len()); - asset.sha256 = Some(sha256_hex(&bytes)); - asset.render_info = Some( - json!({"representation":"native-node","sourceProperties":{"scaleMode":"CROP","imageTransform":[[0.5,0,0],[0,1,0]],"effects":[{"type":"LAYER_BLUR","radius":12}]},"autoDecision":{"eligible":false,"selected":"node","reasons":["paint-transform-or-unknown"]}}), - ); - } - let store = ArtifactStore::default(); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )), - data, - ) - .await - .unwrap(); - let before = artifact.payload.assets.clone(); - let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); - for outputs in [ - &["tsx", "componentTsx", "assetManifest", "sourceMap"][..], - &["assetManifest"][..], - &["tsx"][..], - ] { - let result = complete_operation( - operation(outputs), - &artifact.payload, - "fixture", - &artifact, - &policy, - &store, - ) - .await - .unwrap(); - assert_eq!(result["imageFillAudit"]["total"], 2); - assert_eq!(result["imageFillAudit"]["exact"], 0); - assert_eq!(result["imageFillAudit"]["unknown"], 2); - if outputs.contains(&"assetManifest") { - for asset in result["assetManifest"]["assets"].as_array().unwrap() { - assert_eq!(asset["renderMode"], "auto"); - assert_eq!(asset["renderInfo"]["renderedAs"], "node-render"); - assert_eq!(asset["renderInfo"]["effectsBakedIntoAsset"], true); - assert_eq!(asset["renderInfo"]["canvasFidelity"], "unverified"); - } - } - if outputs.contains(&"sourceMap") { - let baked = result["sourceMap"]["rasterizedAssets"].as_array().unwrap(); - assert_eq!(baked.len(), 2); - assert!(baked.iter().all( - |a| a["effectsBakedIntoAsset"] == true && a["cssEffectsSuppressed"] == true - )); - } - } - assert_eq!(artifact.payload.assets, before); - } - - fn webp_test_payload(count: usize) -> CollectedPayload { - let mut png = std::io::Cursor::new(Vec::new()); - image::DynamicImage::new_rgba8(40, 40) - .write_to(&mut png, image::ImageFormat::Png) - .unwrap(); - let mut data = p3_asset_payload(png.get_ref()); - data.assets.clear(); - data.snapshot.nodes.get_mut("1:1").unwrap().fields.insert( - "childrenIds".into(), - json!((2..count + 2).map(|i| format!("1:{i}")).collect::>()), - ); - let prototype = data.snapshot.nodes["1:2"].clone(); - for i in 2..count + 2 { - let id = format!("1:{i}"); - let mut node = prototype.clone(); - node.id = id.clone(); - data.snapshot.nodes.insert(id.clone(), node); - let request = devup_mcp_figma::AssetRequest { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - asset_id: format!("{id}:fills:0"), - node_id: id.clone(), - field: "fills/0".into(), - image_hash: Some(id), - format: devup_mcp_figma::AssetFormat::Webp, - scale: 1.0, - render_mode: devup_mcp_figma::AssetRenderMode::Node, - quality: Some(43), - lossless: Some(false), - }; - data.assets - .push(devup_mcp_figma::exported_asset_from_bytes(&request, png.get_ref()).unwrap()); - } - data - } - - #[tokio::test] - async fn webp_image_audit_writes_a_supported_output_and_requires_design_capability() { - let root = std::env::temp_dir().join(format!("devup-webp-audit-{}", rand::random::())); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("image-audit.json"); - let mut op = operation(&["imageAudit"]); - if let PendingOperation::Export { output_paths, .. } = &mut op { - output_paths.insert("imageAudit".into(), path.display().to_string()); - } - let result = project(webp_test_payload(2), op).await.unwrap(); - let written: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(written["assets"].as_array().unwrap().len(), 2); - assert!( - result["outputPathResults"]["supportedKeys"] - .as_array() - .unwrap() - .iter() - .any(|o| o == "imageAudit") - ); - assert!( - result["outputPathResults"]["diagnostics"] - .as_array() - .unwrap() - .is_empty() - ); - let row = &written["assets"][0]; - assert_eq!(row["nodeSize"]["width"], 40.0); - assert_eq!(row["outputSize"]["width"], 40.0); - assert_eq!(row["recommendedFormat"], "webp"); - assert_eq!(row["visibleSourceRatio"]["known"], false); - assert_eq!( - row["visibleSourceRatio"]["reason"], - "baked-render-source-coverage-unverified" - ); - assert!( - row["visibleSourceRatioEvidence"] - .as_str() - .unwrap() - .contains("not-visible-percentages") - ); - let data = webp_test_payload(2); - let mut request = CollectionRequest::new(data.target.clone(), CollectionScope::File); - request.variables_only = true; - let store = ArtifactStore::default(); - let artifact = store - .insert(ArtifactRequestKey::from_collection(&request), data) - .await - .unwrap(); - assert!( - super::super::validation::validate_artifact_projection( - &artifact, - &["imageAudit".into()], - "file", - &[] - ) - .is_err() - ); - std::fs::remove_dir_all(root).unwrap(); - } - - #[tokio::test] - async fn webp_preset_replays_more_than_six_captures_with_public_files_and_coupled_resources() { - let root = - std::env::temp_dir().join(format!("devup-webp-preset-{}", rand::random::())); - std::fs::create_dir_all(&root).unwrap(); - let root = crate::test_paths::canonical(&root); - let data = webp_test_payload(9); - let store = ArtifactStore::default(); - let mut request = CollectionRequest::new(data.target.clone(), CollectionScope::Node); - request.web_ready_images = true; - let artifact = store - .insert(ArtifactRequestKey::from_collection(&request), data) - .await - .unwrap(); - assert_eq!(artifact.capabilities.asset_capture_count, 9); - assert!(artifact.capabilities.web_ready_images); - for mode in [DeliveryMode::Inline, DeliveryMode::Resource] { - let mut op = operation(&["tsx", "assetManifest", "imageAudit"]); - if let PendingOperation::Export { - web_ready_images, - asset_public_root, - delivery, - .. - } = &mut op - { - *web_ready_images = true; - *asset_public_root = Some(root.clone()); - *delivery = mode; - } - let result = complete_operation( - op, - &artifact.payload, - "fixture", - &artifact, - &OutputPolicy::from_roots(vec![root.clone()]).unwrap(), - &store, - ) - .await - .unwrap(); - assert_eq!(result["webReadyImages"]["total"], 9); - assert_eq!(result["webReadyImages"]["failed"], 0); - let files = result["outputPaths"].as_object().unwrap(); - assert_eq!(files.len(), 9); - for path in files.values().filter_map(Value::as_str) { - assert!(path.ends_with(".webp")); - assert_eq!( - image::guess_format(&std::fs::read(path).unwrap()).unwrap(), - image::ImageFormat::WebP - ); - } - if mode == DeliveryMode::Inline { - assert!(result["tsx"].as_str().unwrap().contains(".webp")); - assert_eq!(result["imageAudit"]["assets"].as_array().unwrap().len(), 9); - for a in result["assetManifest"]["assets"].as_array().unwrap() { - assert_eq!(a["quality"], 43); - assert!(a["path"].as_str().unwrap().ends_with(".webp")); - } - } else { - assert!(result["imageAudit"]["assets"].is_null()); - assert!(result["webReadyImages"]["plan"].is_null()); - let args = &result["nextAction"]["arguments"]; - assert_eq!(args["preset"], "web-ready-images"); - assert!(args["assetRequests"].is_null()); - assert!( - args["outputs"] - .as_array() - .unwrap() - .iter() - .any(|o| o == "assetManifest") - ); - let input: super::super::tools::FigmaExportInput = - serde_json::from_value(args.clone()).unwrap(); - super::super::validation::parse_asset_requests(&input.asset_requests).unwrap(); - } - } - std::fs::remove_dir_all(root).unwrap(); - } - - #[tokio::test] - async fn webp_scaffold_chooses_webp_paths_before_mapping_and_preserves_alias_identity() { - let root = - std::env::temp_dir().join(format!("devup-webp-scaffold-{}", rand::random::())); - std::fs::create_dir_all(&root).unwrap(); - for write in [false, true] { - let mut op = operation(&["tsx", "assetManifest", "pageScaffold"]); - if let PendingOperation::Export { - page_scaffold, - output_paths, - .. - } = &mut op - { - *page_scaffold = - Some(serde_json::from_value(json!({"write":write,"route":"/"})).unwrap()); - output_paths.insert("pageScaffold".into(), root.display().to_string()); - } - let result = project(webp_test_payload(2), op).await.unwrap(); - assert!(result["tsx"].as_str().unwrap().contains(".webp")); - for asset in result["assetManifest"]["assets"].as_array().unwrap() { - assert!(asset["path"].as_str().unwrap().ends_with(".webp")); - assert_eq!(asset["quality"], 43); - if write { - let bytes = std::fs::read(asset["outputPath"].as_str().unwrap()).unwrap(); - assert_eq!(asset["sha256"], sha256_hex(&bytes)); - } - } - } - std::fs::remove_dir_all(root).unwrap(); - } - - #[tokio::test] - async fn webp_failed_capture_preserves_options_and_returns_partial_beside_success() { - let mut data = webp_test_payload(2); - let a = &mut data.assets[1]; - a.status = AssetStatus::Failed; - a.data_base64 = None; - a.byte_length = None; - a.sha256 = None; - a.mime_type = None; - a.error_code = Some("DEVUP_ASSET_WEBP_POSTPROCESS_FAILED".into()); - let selections = data - .assets - .iter() - .map(|a| devup_mcp_figma::AssetSelection { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - asset_id: a.asset_id.clone(), - format: a.format.unwrap(), - scale: a.scale.unwrap(), - render_mode: a.render_mode, - quality: a.quality, - lossless: a.lossless, - }) - .collect(); - let mut op = operation(&["assetManifest"]); - if let PendingOperation::Export { asset_captures, .. } = &mut op { - *asset_captures = selections; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["status"], "partial"); - assert_eq!(result["assetManifest"]["assets"][1]["quality"], 43); - assert_eq!(result["assetManifest"]["assets"][1]["status"], "failed"); - } - - #[tokio::test] - async fn large_image_fill_audit_preserves_all_rows_in_resource_with_bounded_inline_summary() { - let data = payload(); - let store = ArtifactStore::default(); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )), - data, - ) - .await - .unwrap(); - let rows = (0..10000).map(|i| json!({"assetId":format!("synthetic-{i}:fills:0"),"nodeId":format!("synthetic-{i}"),"canvasFidelity":"unverified","reviewReasons":["image-transform"],"unknownReasons":["canvas-reference-not-compared","ancestor-dependencies-unknown"]})).collect::>(); - let mut result = json!({"status":"partial","imageFillAudit":{"total":rows.len(),"exact":0,"needsReview":rows.len(),"unknown":rows.len(),"summary":"10000 fills require review","assets":rows}, - "imageAudit":{"total":rows.len(),"assets":rows}}); - assert!(serde_json::to_vec(&result).unwrap().len() > 1024 * 1024); - let outputs = projected_outputs_from_result(result.as_object().unwrap()).unwrap(); - let attachment = apply_delivery( - &mut result, - DeliveryMode::Auto, - &store, - &artifact, - outputs, - None, - ) - .await - .unwrap(); - commit_delivery(attachment); - assert_eq!(result["imageFillAudit"]["total"], 10000); - assert!(result["imageAudit"]["assets"].is_null()); - assert!( - result["imageAudit"]["detailsResource"]["uri"] - .as_str() - .unwrap() - .starts_with("devup://") - ); - assert!(result["imageFillAudit"]["assets"].is_null()); - assert!( - result["imageFillAudit"]["detailsResource"]["uri"] - .as_str() - .unwrap() - .starts_with("devup://") - ); - assert!(serde_json::to_vec(&result).unwrap().len() < 16384); - let resource = store - .output_manifests() - .await - .into_iter() - .find(|m| m.name == "image-fill-audit.json") - .unwrap(); - let mut bytes = Vec::new(); - for index in 0..resource.chunk_count { - bytes.extend( - store - .read_resource_output(&artifact.artifact_id, &resource.output_id, Some(index)) - .await - .unwrap() - .1, - ); - } - let full: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(full["assets"].as_array().unwrap().len(), 10000); - assert_eq!(full["assets"][9999]["assetId"], "synthetic-9999:fills:0"); - } - - #[tokio::test] - async fn p3_public_mapping_and_dedup_rewrite_inline_and_written_tsx() { - let directory = std::env::temp_dir().join(format!( - "devup-p3-assets-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&directory).unwrap(); - let root = crate::test_paths::canonical(&directory); - let first_path = root.join("icons/BI icon.png"); - let second_path = root.join("icons/other.png"); - let tsx_path = root.join("Screen.tsx"); - let mut op = operation(&["tsx", "assetManifest"]); - if let PendingOperation::Export { - asset_public_root, - asset_output_paths, - output_paths, - .. - } = &mut op - { - *asset_public_root = Some(root.clone()); - asset_output_paths.insert("1:2:fills:0".into(), first_path.to_string_lossy().into()); - asset_output_paths.insert("1:3:fills:0".into(), second_path.to_string_lossy().into()); - output_paths.insert("tsx".into(), tsx_path.to_string_lossy().into()); - } - let result = project(p3_asset_payload(b"first image"), op).await.unwrap(); - let code = result["tsx"].as_str().unwrap(); - assert!(code.contains("/icons/BI%20icon.png"), "{code}"); - assert_eq!(std::fs::read_to_string(&tsx_path).unwrap(), code); - assert_eq!(std::fs::read(&first_path).unwrap(), b"first image"); - assert!(!second_path.exists()); - for asset in result["assetManifest"]["assets"].as_array().unwrap() { - assert_eq!(asset["path"], "/icons/BI%20icon.png"); - assert_eq!( - std::path::Path::new(asset["outputPath"].as_str().unwrap()), - first_path - ); - } - std::fs::remove_dir_all(directory).unwrap(); - } - - #[tokio::test] - async fn p3_same_name_different_bytes_keep_distinct_paths_and_default_names() { - let result = project( - p3_asset_payload(b"different image"), - operation(&["tsx", "assetManifest"]), - ) - .await - .unwrap(); - let assets = result["assetManifest"]["assets"].as_array().unwrap(); - assert_ne!(assets[0]["path"], assets[1]["path"]); - for asset in assets { - assert!( - result["tsx"] - .as_str() - .unwrap() - .contains(asset["path"].as_str().unwrap()) - ); - } - } - - #[tokio::test] - async fn p3_identical_assets_share_one_binary_resource() { - let mut op = operation(&["tsx", "assetManifest"]); - if let PendingOperation::Export { delivery, .. } = &mut op { - *delivery = DeliveryMode::Resource; - } - let result = project(p3_asset_payload(b"first image"), op).await.unwrap(); - let resources = result["resources"].as_array().unwrap(); - assert_eq!( - resources - .iter() - .filter(|resource| resource["mimeType"] == "image/png") - .count(), - 1 - ); - } - - #[test] - fn p3_rewrites_all_code_variants_without_cascading_or_prefix_changes() { - let paths = BTreeMap::from([ - ("/icons/a.svg".into(), "/icons/b.svg".into()), - ("/icons/b.svg".into(), "/icons/c.svg".into()), - ]); - let mut result = json!({"tsx":"", - "componentTsx":"", - "responsiveTsx":"", - "frames":[{"tsx":""}], - "other":"/icons/a.svg"}); - rewrite_result_asset_references(&mut result, &paths); - assert_eq!(result["tsx"], ""); - assert_eq!(result["frames"][0]["tsx"], result["tsx"]); - assert_eq!( - result["componentTsx"], - "" - ); - assert_eq!(result["responsiveTsx"], ""); - assert_eq!(result["other"], "/icons/a.svg"); - assert_eq!( - rewrite_asset_references("url(/icons/a.svg.extra)", &paths), - "url(/icons/a.svg.extra)" - ); - } - - #[tokio::test] - async fn p3_selection_reference_png_followup_targets_single_url() { - let result = project(section(2), operation(&["tsx", "referencePng"])) - .await - .unwrap(); - let args = &result["nextAction"]["example"]["arguments"]; - assert!(args["url"].is_string()); - assert!(args.get("frameIds").is_none()); - assert!(args.get("artifactId").is_none()); - assert_eq!(args["scope"], "node"); - } - - #[tokio::test] - async fn p3_default_paths_are_not_rewritten_to_requested_files() { - let directory = std::env::temp_dir().join(format!( - "devup-p3-default-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&directory).unwrap(); - let path = directory.join("chosen.png"); - let mut op = operation(&["tsx", "assetManifest"]); - if let PendingOperation::Export { - asset_output_paths, .. - } = &mut op - { - asset_output_paths.insert("1:2:fills:0".into(), path.to_string_lossy().into()); - } - let result = project(p3_asset_payload(b"different"), op).await.unwrap(); - assert!(path.exists()); - let asset = &result["assetManifest"]["assets"][0]; - assert_ne!(asset["path"], "/chosen.png"); - assert!( - result["tsx"] - .as_str() - .unwrap() - .contains(asset["path"].as_str().unwrap()) - ); - std::fs::remove_dir_all(directory).unwrap(); - } - - #[tokio::test] - async fn p3_unverified_equal_hashes_do_not_merge_different_bytes() { - let mut data = p3_asset_payload(b"different"); - data.assets[1].sha256 = data.assets[0].sha256.clone(); - let error = project(data, operation(&["tsx", "assetManifest"])) - .await - .unwrap_err(); - assert!(error.message.contains("hash does not match"), "{error}"); - assert!(!super::super::validation::is_caller_mistake(&error)); - } - - #[tokio::test] - async fn p3_tsx_only_reprojection_preserves_deduplicated_references() { - let data = p3_asset_payload(b"first image"); - let with_manifest = project(data.clone(), operation(&["tsx", "assetManifest"])) - .await - .unwrap(); - let without_manifest = project(data, operation(&["tsx"])).await.unwrap(); - assert_eq!(with_manifest["tsx"], without_manifest["tsx"]); - assert!(without_manifest.get("assetManifest").is_none()); - } - - #[tokio::test] - async fn p3_canonical_reference_is_independent_of_requested_duplicate_file() { - let directory = std::env::temp_dir().join(format!( - "devup-p3-canonical-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&directory).unwrap(); - let data = p3_asset_payload(b"first image"); - let canonical = project(data.clone(), operation(&["tsx", "assetManifest"])) - .await - .unwrap(); - let mut op = operation(&["tsx", "assetManifest"]); - if let PendingOperation::Export { - asset_output_paths, .. - } = &mut op - { - asset_output_paths.insert( - "1:3:fills:0".into(), - directory.join("chosen.png").to_string_lossy().into(), - ); - } - let written = project(data, op).await.unwrap(); - assert_eq!(canonical["tsx"], written["tsx"]); - assert_eq!( - canonical["assetManifest"]["assets"][0]["path"], - written["assetManifest"]["assets"][0]["path"] - ); - std::fs::remove_dir_all(directory).unwrap(); - } - - fn section(count: usize) -> CollectedPayload { - let mut payload = payload(); - let template = payload.snapshot.nodes["1:1"].clone(); - payload.target.node_id = Some("0:1".into()); - payload.snapshot.nodes.clear(); - payload.snapshot.roots.clear(); - let mut candidates = Vec::new(); - for i in 1..=count { - let id = format!("1:{i}"); - let mut node = template.clone(); - node.id = id.clone(); - payload.snapshot.nodes.insert(id.clone(), node); - payload.snapshot.roots.push(id.clone()); - candidates.push(json!({"nodeId":id,"name":"Screen","nodeType":"FRAME", - "textPreview":"","visible":true,"bounds":{"x":0,"y":0,"width":390,"height":800}, - "parentId":"0:1","breadcrumb":[],"directChildCount":0, - "subtreeNodeCount":1,"estimatedSerializedBytes":500,"selectionReasons":["inside-section"], - "canonicalUrl":format!("https://www.figma.com/design/W1Fixture/Test?node-id=1-{i}")})); - } - payload.metadata = json!({"sectionIndex":{"fileKey":"W1Fixture","sourceVersion":null, - "section":{"nodeId":"0:1","name":"Section","bounds":{"x":0,"y":0,"width":2000,"height":2000}}, - "candidates":candidates,"truncated":false}}); - payload - } - - fn operation(outputs: &[&str]) -> PendingOperation { - PendingOperation::Export { - outputs: outputs.iter().map(|s| (*s).into()).collect(), - component_name: None, - include_diagnostics: true, - root_layout: Default::default(), - asset_names_per_node: true, - scope: "node".into(), - theme_options: Default::default(), - strict: false, - output_paths: BTreeMap::new(), - page_scaffold: None, - previous_design_fingerprints: None, - frame_ids: vec![], - all_screens: false, - asset_captures: vec![], - web_ready_images: false, - asset_output_paths: BTreeMap::new(), - asset_public_root: None, - fields: vec![], - response_selection: Default::default(), - delivery: DeliveryMode::Inline, - width_policy: None, - } - } - - fn section_without_index(count: usize, projection_truncated: bool) -> CollectedPayload { - let mut data = section(count); - data.metadata = json!({}); - let mut root = data.snapshot.nodes["1:1"].clone(); - root.id = "0:1".into(); - root.node_type = "SECTION".into(); - root.fields = serde_json::from_value(json!({ - "name":"Section", "visible":true, "x":0, "y":0, - "width":2000, "height":2000, - "childrenIds":data.snapshot.roots, - "projectionTruncated":projection_truncated - })) - .unwrap(); - for node in data.snapshot.nodes.values_mut() { - node.fields.insert("parentId".into(), json!("0:1")); - } - data.snapshot.nodes.insert(root.id.clone(), root); - data.snapshot.roots = vec!["0:1".into()]; - data - } - - #[tokio::test] - async fn r17_large_section_provides_bounded_continuation_batches() { - let result = project( - section_without_index(23, false), - operation(&["tsx", "sourceMap", "rawSnapshot"]), - ) - .await - .unwrap(); - let next = &result["nextAction"]; - assert_eq!(next["maxFramesPerCall"], 4); - let batches = next["batches"].as_array().expect("bounded batch examples"); - assert_eq!(batches.len(), 6); - assert_eq!( - batches - .iter() - .map(|b| b["arguments"]["frameIds"].as_array().unwrap().len()) - .sum::(), - 23 - ); - for batch in batches { - assert!(batch["arguments"]["frameIds"].as_array().unwrap().len() <= 4); - } - assert!( - !next["how"] - .as_str() - .unwrap() - .contains("Use allScreens:true") - ); - } - - #[tokio::test] - async fn r17_section_png_batches_use_single_direct_frame_urls() { - let result = project( - section_without_index(23, false), - operation(&["tsx", "referencePng"]), - ) - .await - .unwrap(); - assert_eq!(result["nextAction"]["maxFramesPerCall"], 1); - let batches = result["nextAction"]["batches"].as_array().unwrap(); - assert_eq!(batches.len(), 23); - let mut urls = std::collections::BTreeSet::new(); - for batch in batches { - let args = &batch["arguments"]; - assert!(args.get("artifactId").is_none()); - assert!(args.get("frameIds").is_none()); - assert_eq!(args["scope"], "node"); - assert_eq!(args["outputs"], json!(["tsx", "referencePng"])); - let url = args["url"].as_str().unwrap(); - assert!(url.contains("node-id=")); - assert!(urls.insert(url)); - } - } - - #[tokio::test] - async fn w1_selection_exactly_100_candidates_is_complete() { - let result = project(section_without_index(100, false), operation(&["tsx"])) - .await - .unwrap(); - assert_eq!(result["status"], "selection_required"); - assert_eq!(result["selection"]["count"], 100); - assert_eq!(result["selection"]["truncated"], false); - assert_eq!(result["selection"]["status"], "complete"); - } - - #[tokio::test] - async fn w1_selection_over_100_candidates_is_partial() { - let result = project(section_without_index(101, false), operation(&["tsx"])) - .await - .unwrap(); - assert_eq!(result["selection"]["count"], 100); - assert_eq!(result["selection"]["truncated"], true); - assert_eq!(result["selection"]["status"], "partial"); - } - - #[tokio::test] - async fn w1_selection_separates_projection_and_candidate_truncation() { - let data = section_without_index(100, true); - let explored = - explore_snapshot(&data.snapshot, &data.target, &ExploreOptions { limit: 100 }).unwrap(); - assert!(explored.truncated); - assert!(!explored.candidates_truncated); - let result = project(data, operation(&["tsx"])).await.unwrap(); - assert_eq!(result["selection"]["truncated"], false); - assert_eq!(result["selection"]["status"], "complete"); - } - - #[tokio::test] - async fn r7_real_wquw118_layout_contracts_are_preserved() { - let data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-118-payload.json" - )) - .unwrap(); - let mut op = operation(&["tsx", "sourceMap"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec!["3997:46277".into()]; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["frames"].as_array().unwrap().len(), 1); - let frame = &result["frames"][0]; - assert_eq!(frame["nodeId"], "3997:46277"); - let entries = frame["sourceMap"]["entries"].as_array().unwrap(); - for (id, field, resolution) in [ - ("3997:46293", "width", "accounted-for-implicit-flex-grow"), - ("3997:46295", "width", "accounted-for-implicit-flex-grow"), - ( - "3997:46313", - "layoutSizingVertical", - "accounted-for-implicit-flex-stretch", - ), - ] { - assert!( - entries.iter().any(|e| e["nodeId"] == id - && e["property"] == field - && e["resolution"] == resolution), - "{id}" - ); - } - assert!( - frame["tsx"] - .as_str() - .unwrap() - .contains("alignSelf=\"stretch\"") - ); - for id in ["3997:46293", "3997:46295", "3997:46313"] { - assert!( - !result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|i| i["nodeId"] == id && i["fidelityImpact"] == "lossy"), - "{result}" - ); - } - } - - #[tokio::test] - async fn r7_artifact_reuses_stored_selection() { - let data = section(2); - let store = ArtifactStore::default(); - let mut request = CollectionRequest::new(data.target.clone(), CollectionScope::Node); - request.section = Some(devup_mcp_figma::SectionReadOptions { - frame_ids: vec!["1:2".into()], - all_screens: false, - }); - let artifact = store - .insert(ArtifactRequestKey::from_collection(&request), data) - .await - .unwrap(); - let result = complete_operation( - operation(&["tsx"]), - &artifact.payload, - "artifact", - &artifact, - &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), - &store, - ) - .await - .unwrap(); - assert_ne!(result["status"], "selection_required"); - assert_eq!(result["frames"][0]["nodeId"], "1:2"); - assert_eq!(result["frames"].as_array().unwrap().len(), 1); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec!["1:1".into()]; - } - let explicit = complete_operation( - op, - &artifact.payload, - "artifact", - &artifact, - &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), - &store, - ) - .await - .unwrap(); - assert_eq!(explicit["frames"][0]["nodeId"], "1:1"); - } - - #[tokio::test] - async fn r7_auto_debug_resource_has_inline_reprojection_guidance() { - let mut data = payload(); - data.metadata = json!({"large":"x".repeat(300_000)}); - let mut op = operation(&["tsx", "rawPayload", "sourceMap"]); - if let PendingOperation::Export { delivery, .. } = &mut op { - *delivery = DeliveryMode::Auto; - } - let result = project(data, op).await.unwrap(); - assert!(result["resources"].is_array()); - assert_eq!( - result["nextAction"]["example"]["arguments"]["delivery"], - "inline" - ); - assert_eq!(result["nextAction"]["example"]["arguments"]["debug"], true); - assert!( - result["nextAction"]["how"] - .as_str() - .unwrap() - .contains("resource") - ); - } - - #[tokio::test] - async fn r12_oversized_export_retains_artifact_and_callable_resource_projection() { - use super::super::{DevupAuth, DevupServer, Services}; - use devup_mcp_figma::{AuthStatus, FigmaUpstream, ReadToolCall, UpstreamResult}; - use std::sync::Arc; - struct Offline; - #[async_trait::async_trait] - impl DevupAuth for Offline { - async fn status(&self) -> Result { - panic!("cached projection must not authenticate") - } - async fn login(&self) -> Result { - panic!("cached projection must not login") - } - async fn logout(&self) -> Result { - panic!("cached projection must not logout") - } - } - #[async_trait::async_trait] - impl FigmaUpstream for Offline { - async fn list_tools(&self) -> Result, DevupError> { - panic!("cached projection must not list upstream tools") - } - async fn call_read_tool(&self, _: ReadToolCall) -> Result { - panic!("cached projection must not call Figma") - } - } - let mut data = payload(); - data.metadata = json!({"large":"x".repeat(600_000)}); - let store = ArtifactStore::default(); - let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )); - let artifact = store.insert(key, data).await.unwrap(); - let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); - let outputs = ["tsx", "rawPayload", "sourceMap", "assetManifest"]; - let error = complete_operation( - operation(&outputs), - &artifact.payload, - "artifact", - &artifact, - &policy, - &store, - ) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); - let envelope = - super::super::structured_tool_error(super::super::to_mcp_error(error.clone())); - let wire = serde_json::to_value(&envelope).unwrap(); - assert_eq!(wire["isError"], true); - assert_eq!( - serde_json::from_str::(wire["content"][0]["text"].as_str().unwrap()).unwrap(), - wire["structuredContent"] - ); - assert_eq!(wire["structuredContent"]["error"]["details"], error.details); - let details = &error.details; - assert_eq!(details["recoveryState"], "available"); - assert_eq!(details["nextAction"]["tool"], "devup_figma_export"); - let args = &details["nextAction"]["arguments"]; - assert_eq!(args["artifactId"], artifact.artifact_id); - assert_eq!(args["delivery"], "resource"); - assert_eq!(args["outputs"], json!(outputs)); - assert_eq!(args["debug"], true); - assert!(details["serializedResponseBytes"].as_u64().unwrap() > 1_048_576); - assert!(store.get(&artifact.artifact_id).await.is_some()); - let mut calls = vec![args.clone()]; - for next in details["splitRequests"].as_array().unwrap() { - assert_eq!(next["tool"], "devup_figma_export"); - calls.push(next["arguments"].clone()); - } - assert!(calls.len() > 1); - let mut server = DevupServer::new(Services::new(Arc::new(Offline), Arc::new(Offline))); - server.artifacts = store.clone(); - for args in calls { - // Deserialize and execute the complete callable arguments through - // the actual tool handler, including its validation contract. - let result = server - .devup_figma_export(rmcp::handler::server::wrapper::Parameters( - serde_json::from_value(args).unwrap(), - )) - .await - .unwrap() - .structured_content - .unwrap(); - assert!(result["resources"].is_array()); - assert_eq!(result["cache"]["artifactId"], artifact.artifact_id); - } - } - - #[test] - fn r8_nonsection_semantic_asset_mapping_uses_delivered_path() { - let mut result = json!({"tsx":"", "sourceMap":{"tsx":[{"nodeId":"한글:1","property":"fills","generatedProperty":"src=\"/icons/grommet-icons:language.svg\"","resolution":"asset"}]}}); - rewrite_result_asset_references(&mut result, &BTreeMap::new()); - assert_eq!( - result["sourceMap"]["tsx"][0]["generatedProperty"], - "src=\"/icons/grommet-icons-language-d201f62b16f5.svg\"" - ); - } - - #[tokio::test] - async fn r8_three_frame_nine_output_units_measure_local_projection() { - let data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-119-payload.json" - )) - .unwrap(); - let mut op = operation(&["tsx", "rawSnapshot", "sourceMap"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec![ - "3997:46315".into(), - "3997:46715".into(), - "3997:46333".into(), - ]; - } - let began = std::time::Instant::now(); - let store = ArtifactStore::default(); - let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )); - let artifact = store.insert(key, data).await.unwrap(); - let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); - // R17 source FILL issues exceed the unchanged inline limit. Exercise the - // retained-artifact recovery and read every output, rather than hiding loss. - let error = complete_operation( - op.clone(), - &artifact.payload, - "fixture", - &artifact, - &policy, - &store, - ) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); - assert_eq!(error.details["recoveryState"], "available"); - assert_eq!( - error.details["nextAction"]["arguments"]["artifactId"], - artifact.artifact_id - ); - assert_eq!( - error.details["nextAction"]["arguments"]["delivery"], - "resource" - ); - if let PendingOperation::Export { delivery, .. } = &mut op { - *delivery = DeliveryMode::Resource; - } - let mut result = - complete_operation(op, &artifact.payload, "fixture", &artifact, &policy, &store) - .await - .unwrap(); - let mut outputs = BTreeMap::new(); - for manifest in store.output_manifests().await { - let mut bytes = Vec::new(); - for index in 0..manifest.chunk_count { - bytes.extend( - store - .read_output_chunk(&artifact.artifact_id, &manifest.output_id, index) - .await - .unwrap(), - ); - } - assert_eq!(bytes.len(), manifest.raw_bytes); - outputs.insert(manifest.name, String::from_utf8(bytes).unwrap()); - } - assert_eq!(outputs.len(), 8); // shared snapshot/audit plus 3 TSX/sourceMap pairs - assert_eq!( - serde_json::from_str::(&outputs["raw-snapshot.json"]).unwrap(), - serde_json::to_value(&artifact.payload.snapshot).unwrap() - ); - for index in 0..3 { - let tsx = &outputs[&format!("frame-{}.tsx", index + 1)]; - assert!(tsx.contains("export function")); - result["frames"][index]["tsx"] = json!(tsx); - result["frames"][index]["sourceMap"] = - serde_json::from_str(&outputs[&format!("frame-{}.source-map.json", index + 1)]) - .unwrap(); - } - assert_eq!(result["frames"].as_array().unwrap().len(), 3); - for frame in result["frames"].as_array().unwrap() { - assert!(frame["tsx"].is_string()); - assert!(frame["sourceMap"].is_object()); - assert!(frame["sourceMap"]["rasterizedAssets"].is_array()); - } - eprintln!( - "R8 offline 3-frame/9-unit projection: {:?}; no upstream timing inferred", - began.elapsed() - ); - } - - #[tokio::test] - async fn r8_final_korean_semantic_map_follows_asset_path_rewrites() { - let mut data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-120-payload.json" - )) - .unwrap(); - data.snapshot = - serde_json::from_str(include_str!("../../../../fixtures/r8/modal-snapshot.json")) - .unwrap(); - let mut op = operation(&["tsx", "sourceMap"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec!["3997:46582".into()]; - } - let started = std::time::Instant::now(); - let result = project(data, op).await.unwrap(); - eprintln!( - "R8 modal projection: {:?}; executable: {:?}", - started.elapsed(), - std::env::current_exe().unwrap() - ); - let frame = &result["frames"][0]; - let tsx = frame["tsx"].as_str().unwrap(); - assert!(tsx.contains("추가 체험")); - let entries = frame["sourceMap"]["entries"].as_array().unwrap(); - for entry in entries { - assert!( - entry.get("generatedRange").is_none(), - "public offsets must not return: {entry}" - ); - assert!(entry["nodeId"].is_string() && entry["property"].is_string()); - let generated = entry["generatedProperty"] - .as_str() - .expect("mapped property required"); - assert!(!generated.is_empty()); - if !generated.starts_with("implicit:") && generated != "children" { - assert!( - tsx.contains(generated), - "mapping must name emitted property: {entry}" - ); - } - } - let modal_type = entries - .iter() - .find(|e| e["nodeId"] == "3997:46621" && e["property"] == "type") - .unwrap(); - assert_eq!(modal_type["generatedProperty"], "Box"); - assert_eq!(modal_type["resolution"], "exact"); - assert_eq!(frame["sourceMap"]["source"]["generatedOutput"], "tsx"); - eprintln!( - "R8 sourceMap={} bytes, TSX={} bytes", - serde_json::to_vec(&frame["sourceMap"]).unwrap().len(), - tsx.len() - ); - } - - #[tokio::test] - async fn r16_verdict_scope_identifies_remaining_asset_axes_without_diagnostics() { - let mut data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-118-payload.json" - )) - .unwrap(); - data.snapshot = - serde_json::from_str(include_str!("../../../../fixtures/r14/asset-snapshot.json")) - .unwrap(); - let mut op = operation(&["tsx", "sourceMap"]); - if let PendingOperation::Export { - frame_ids, - include_diagnostics, - .. - } = &mut op - { - *frame_ids = vec!["3997:46667".into()]; - *include_diagnostics = false; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["status"], "partial"); - assert_eq!(result["quality"]["projection"], "approximated"); - for owner in [&result, &result["frames"][0]] { - let scope = &owner["verdictScope"]; - assert_eq!(scope["status"], "partial"); - assert_eq!(scope["projection"], "approximated"); - let causes: Vec<_> = scope["projectionCauses"] - .as_array() - .unwrap() - .iter() - .filter(|c| { - c["nodeId"] == "3997:46668" && c["code"] == "DEVUP_CODEGEN_ABSOLUTE_FALLBACK" - }) - .collect(); - assert_eq!(causes.len(), 2); - assert_eq!(causes[0]["component"], "horizontal"); - assert_eq!(causes[1]["component"], "vertical"); - for cause in causes { - assert_eq!(cause["screenId"], "3997:46667"); - assert_eq!(cause["state"], "approximated"); - assert!(cause["reason"].as_str().is_some_and(|s| !s.is_empty())); - } - } - } - - #[tokio::test] - async fn r16_complete_scope_and_mapping_semantics_survive_resource_delivery() { - for (delivery_mode, real_fill) in [ - (DeliveryMode::Inline, false), - (DeliveryMode::Resource, false), - (DeliveryMode::Inline, true), - (DeliveryMode::Resource, true), - ] { - let data: CollectedPayload = if real_fill { - serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-118-payload.json" - )) - .unwrap() - } else { - let mut data = section(1); - data.snapshot.nodes.get_mut("1:1").unwrap().fields.extend( - serde_json::from_value::>(json!({"layoutSizingHorizontal":"FIXED", "layoutSizingVertical":"FIXED", "fills":[{"type":"SOLID","color":{"r":0.2,"g":0.4,"b":0.6}}]})).unwrap() - ); - data - }; - let mut op = operation(&["tsx", "sourceMap"]); - if let PendingOperation::Export { - frame_ids, - delivery, - .. - } = &mut op - { - *frame_ids = vec![if real_fill { "3997:46129" } else { "1:1" }.into()]; - *delivery = delivery_mode; - } - let result = project(data, op).await.unwrap(); - if real_fill { - assert_eq!(result["status"], "partial"); - assert_eq!(result["verdictScope"]["projection"], "lossy"); - let fill_issues: Vec<_> = result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .filter(|d| d["property"] == "layoutSizingHorizontal") - .collect(); - assert_eq!(fill_issues.len(), 18); - for issue in fill_issues { - assert_eq!(issue["fidelityImpact"], "lossy"); - assert_eq!(issue["details"]["originalValue"], "FILL"); - } - assert_eq!( - result["frames"][0]["outputResults"]["tsx"]["mappingComplete"], - false - ); - } else { - assert_eq!(result["status"], "complete"); - assert_eq!(result["verdictScope"]["projectionCauses"], json!([])); - assert_eq!(result["verdictScope"]["statusCauses"], json!([])); - assert_eq!(result["verdictScope"]["projection"], "exact"); - } - assert_eq!(result["resolutionSemantics"]["axis"], "mapping-method"); - assert!( - result["resolutionSemantics"]["values"]["raw-fallback"] - .as_str() - .unwrap() - .contains("verified") - ); - if result.get("resources").is_none() { - assert_eq!( - result["frames"][0]["sourceMap"]["resolutionSemantics"]["axis"], - "mapping-method" - ); - } - } - } - - #[tokio::test] - async fn fields_narrow_raw_snapshot_and_source_map() { - let mut op = operation(&["tsx", "rawSnapshot", "sourceMap"]); - if let PendingOperation::Export { fields, .. } = &mut op { - *fields = vec!["typography".into()]; - } - let result = project(payload(), op).await.unwrap(); - for node in result["rawSnapshot"]["nodes"].as_object().unwrap().values() { - for key in node["fields"].as_object().unwrap().keys() { - assert!( - [ - "fontSize", - "fontName", - "fontWeight", - "lineHeight", - "letterSpacing", - "textStyleId", - "styledTextSegments", - "textAlignHorizontal", - "characters", - "childrenIds", - "parentId", - "name", - "visible", - "isAsset" - ] - .contains(&key.as_str()), - "unselected field survived: {key}" - ); - } - } - for entry in result["sourceMap"]["tsx"].as_array().into_iter().flatten() { - assert!(entry["property"] != "width", "{entry}"); - } - } - #[tokio::test] - async fn r16_non_projection_partial_has_its_own_status_cause() { - let mut data = payload(); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .field_errors - .insert("name".into(), "unavailable".into()); - let result = project(data, operation(&["rawSnapshot"])).await.unwrap(); - assert_eq!(result["status"], "partial"); - assert_eq!(result["quality"]["projection"], "not-requested"); - assert_eq!(result["verdictScope"]["projectionCauses"], json!([])); - assert!( - result["verdictScope"]["statusCauses"] - .as_array() - .unwrap() - .iter() - .any(|c| c["domain"] == "acquisition" && c["state"] == "partial") - ); - let acquisition = result["verdictScope"]["statusCauses"] - .as_array() - .unwrap() - .iter() - .find(|c| c["domain"] == "acquisition") - .unwrap(); - assert_eq!(acquisition["evidenceScope"], "response"); - } - - #[tokio::test] - async fn r16_frame_scope_retains_scoped_output_failures() { - let mut data = section(2); - // Unlike the deliberately underspecified generic Section fixture, - // this successful sibling has a complete fixed-dimension proof. - data.snapshot.nodes.get_mut("1:1").unwrap().fields.extend( - serde_json::from_value::>(json!({ - "layoutSizingHorizontal":"FIXED", "layoutSizingVertical":"FIXED" - })) - .unwrap(), - ); - data.snapshot.nodes.remove("1:2"); - data.snapshot.roots = vec!["1:1".into()]; - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec!["1:1".into(), "1:2".into()]; - } - let result = project(data, op).await.unwrap(); - let good = &result["frames"][0]; - let failed = &result["frames"][1]; - assert_eq!(good["verdictScope"]["statusCauses"], json!([])); - let causes = failed["verdictScope"]["statusCauses"].as_array().unwrap(); - assert!(causes.iter().any(|c| c["domain"] == "requested-output" - && c["nodeId"] == "1:2" - && c["output"] == "tsx")); - for cause in causes.iter().filter(|c| c["domain"] == "requested-output") { - let i = cause["failureIndex"].as_u64().unwrap() as usize; - assert_eq!(failed["failures"][i]["errorCode"], cause["code"]); - } - } - - #[tokio::test] - async fn r16_resource_scope_retains_every_projection_issue() { - for asset_case in [false, true] { - let mut data: CollectedPayload = serde_json::from_str(include_str!( - "../../../../fixtures/r2/wquw-119-payload.json" - )) - .unwrap(); - let selected = if asset_case { - data.snapshot = serde_json::from_str(include_str!( - "../../../../fixtures/r14/asset-snapshot.json" - )) - .unwrap(); - vec!["3997:46667".into()] - } else { - vec![ - "3997:46315".into(), - "3997:46715".into(), - "3997:46333".into(), - ] - }; - let mut op = operation(&["tsx", "rawSnapshot", "sourceMap"]); - if let PendingOperation::Export { - frame_ids, - delivery, - .. - } = &mut op - { - *frame_ids = selected; - *delivery = DeliveryMode::Resource; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["resolutionSemantics"]["axis"], "mapping-method"); - if asset_case { - assert!(!result["projectionIssues"].as_array().unwrap().is_empty()); - } - for owner in std::iter::once(&result).chain(result["frames"].as_array().unwrap()) { - let causes = owner["verdictScope"]["projectionCauses"] - .as_array() - .unwrap(); - let issues = owner["projectionIssues"].as_array().unwrap(); - for (i, issue) in issues.iter().enumerate() { - assert!(causes.iter().any(|c| c["diagnosticIndex"] == i - && c["nodeId"] == issue["nodeId"] - && c["code"] == issue["code"])); - } - eprintln!( - "R16 metadata node={} scopeBytes={} issues={}", - owner["nodeId"], - serde_json::to_vec(&owner["verdictScope"]).unwrap().len(), - issues.len() - ); - } - } - } - - include!("projection_fingerprint_tests.rs"); - - #[tokio::test] - async fn feedback_interaction_hints_match_saved_files_and_resource_hashes() { - for section_case in [false, true] { - let mut baseline_op = operation(&["tsx", "sourceMap"]); - if section_case && let PendingOperation::Export { frame_ids, .. } = &mut baseline_op { - *frame_ids = vec!["1:1".into()]; - } - let baseline = project( - if section_case { section(2) } else { payload() }, - baseline_op, - ) - .await - .unwrap(); - let mut data = if section_case { section(2) } else { payload() }; - let node = data.snapshot.nodes.get_mut("1:1").unwrap(); - node.fields.insert("name".into(), json!("CTA Button")); - node.fields - .insert("variantProperties".into(), json!({"State":"disabled"})); - node.fields.insert("reactions".into(),json!([{"trigger":{"type":"ON_CLICK"},"actions":[{"type":"URL","url":"https://example.com/confirmed"}]}])); - let target = std::env::temp_dir().join(format!( - "feedback-hints-{}-{}.tsx", - std::process::id(), - rand::random::() - )); - let mut op = operation(&["tsx", "sourceMap"]); - if let PendingOperation::Export { - delivery, - output_paths, - frame_ids, - .. - } = &mut op - { - *delivery = DeliveryMode::Resource; - if section_case { - *frame_ids = vec!["1:1".into()]; - } - output_paths.insert( - if section_case { "frame:1:1:tsx" } else { "tsx" }.into(), - target.to_string_lossy().into(), - ); - } - let store = ArtifactStore::default(); - let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )); - let artifact = store.insert(key, data).await.unwrap(); - let result = complete_operation( - op, - &artifact.payload, - "fixture", - &artifact, - &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), - &store, - ) - .await - .unwrap(); - let manifest = store - .output_manifests() - .await - .into_iter() - .find(|m| m.mime_type == "text/typescript") - .unwrap(); - let mut bytes = vec![]; - for index in 0..manifest.chunk_count { - bytes.extend( - store - .read_output_chunk(&manifest.artifact_id, &manifest.output_id, index) - .await - .unwrap(), - ); - } - assert_eq!(bytes, std::fs::read(&target).unwrap()); - assert_eq!(bytes.len(), manifest.raw_bytes); - assert_eq!(sha256_hex(&bytes), manifest.sha256); - let code = String::from_utf8(bytes).unwrap(); - assert!(code.contains("// @devup-interaction ")); - assert!(code.contains("https://example.com/confirmed")); - assert!(code.contains("disabled")); - assert!(!code.contains("href=")); - assert_eq!(result["deliverable"]["isFinal"], false); - assert_eq!(result["mappingComplete"], baseline["mappingComplete"]); - assert!( - result["interactiveCandidates"] - .as_array() - .is_some_and(|a| !a.is_empty()) - ); - std::fs::remove_file(target).unwrap(); - } - } - - async fn project( - payload: CollectedPayload, - operation: PendingOperation, - ) -> Result { - let store = ArtifactStore::default(); - let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( - payload.target.clone(), - CollectionScope::Node, - )); - let artifact = store.insert(key, payload).await.unwrap(); - complete_operation( - operation, - &artifact.payload, - "fixture", - &artifact, - &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), - &store, - ) - .await - } - - #[tokio::test] - async fn w1_component_tsx_projects_selected_frames_without_section_root() { - let mut op = operation(&["componentTsx"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec!["1:1".into()]; - } - let result = project(section(2), op).await.unwrap(); - assert_eq!(result["frames"].as_array().unwrap().len(), 1); - assert!( - result["frames"][0]["componentTsx"] - .as_str() - .unwrap() - .contains("export function") - ); - assert_ne!(result["quality"]["projection"], "not-requested"); - } - - #[tokio::test] - async fn w1_unavailable_responsive_output_is_actionable_even_with_tsx_and_strict() { - for outputs in [vec!["responsiveTsx"], vec!["tsx", "responsiveTsx"]] { - for strict_value in [false, true] { - let mut op = operation(&outputs); - if let PendingOperation::Export { - all_screens, - strict, - .. - } = &mut op - { - *all_screens = true; - *strict = strict_value; - } - let error = project(section(2), op).await.unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupInvalidInput); - assert!(error.message.contains("responsiveTsx")); - assert!(error.message.contains("tsx")); - assert!(error.details.is_object()); - } - } - } - - #[tokio::test] - async fn w1_asset_failure_is_partial_and_strict_refuses_it() { - let mut data = payload(); - data.assets.push(serde_json::from_value(json!({"assetId":"1:1:fills:0","nodeId":"1:1", - "field":"fills/0","sourceKind":"image","status":"failed","errorCode":"DEVUP_ASSET_EXPORT_FAILED"})).unwrap()); - let result = project(data.clone(), operation(&["assetManifest"])) - .await - .unwrap(); - assert_eq!(result["status"], "partial"); - assert_eq!(result["failures"][0]["assetId"], "1:1:fills:0"); - assert_ne!(result["quality"]["assets"], "complete"); - let mut strict_op = operation(&["assetManifest"]); - if let PendingOperation::Export { strict, .. } = &mut strict_op { - *strict = true; - } - assert!(project(data, strict_op).await.is_err()); - } - - #[tokio::test] - async fn nested_asset_failure_preserves_details_without_promising_a_path_or_retry() { - for (code, detail, retryable) in [ - ( - "DEVUP_ASSET_EXPORT_FAILED", - "export failed after 10400 ms: Unable to establish connection to Figma after 10 seconds", - false, - ), - ( - "DEVUP_ASSET_EXPORT_FAILED", - "native export: renderer failed; geometry fallback unsupported: mask requires native export", - false, - ), - ( - "DEVUP_ASSET_TIMEOUT", - "export exceeded 180000 ms asset budget", - true, - ), - ] { - let mut data = payload(); - data.assets.push(serde_json::from_value(json!({"assetId":"1:1:fills:0","nodeId":"1:1", - "field":"fills/0","sourceKind":"image","status":"failed","errorCode":code, - "errorDetail":detail,"path":"/icons/not-generated.svg","outputPath":"C:/not-generated.svg"})).unwrap()); - let result = project(data, operation(&["assetManifest"])).await.unwrap(); - assert_eq!(result["status"], "partial"); - assert_eq!(result["failures"][0]["retryable"], retryable); - assert_eq!(result["failures"][0]["errorDetail"], detail); - let asset = &result["assetManifest"]["assets"][0]; - assert_eq!(asset["status"], "failed"); - assert!(asset["path"].is_null() && asset["outputPath"].is_null()); - assert_eq!( - result["assetSummary"]["unavailable"][0]["errorDetail"], - detail - ); - assert!(result["outputPaths"].as_object().unwrap().is_empty()); - } - } - - #[tokio::test] - async fn w1_frame_failure_preserves_other_frames_and_context() { - let mut data = section(2); - data.snapshot.nodes.remove("1:2"); - data.snapshot.roots.retain(|id| id != "1:2"); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["status"], "partial"); - assert!(result["frames"][0]["tsx"].is_string()); - assert_eq!(result["failures"][0]["nodeId"], "1:2"); - assert_eq!(result["frames"][1]["quality"]["projection"], "failed"); - assert_eq!( - result["failures"][0]["details"]["snapshotRootIds"], - json!(["1:1"]) - ); - } - - #[tokio::test] - async fn w1_all_screens_uses_explore_classification() { - let mut data = section(3); - data.metadata["sectionIndex"]["candidates"][1]["bounds"] = - json!({"x":0,"y":0,"width":900,"height":100}); - data.metadata["sectionIndex"]["candidates"][2]["bounds"] = - json!({"x":0,"y":0,"width":100,"height":100}); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["frames"].as_array().unwrap().len(), 1); - assert_eq!(result["frames"][0]["nodeId"], "1:1"); - } - - #[tokio::test] - async fn w1_large_section_has_actionable_batch_refusal() { - let mut op = operation(&["tsx", "assetManifest"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let error = project(section(14), op).await.unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupInvalidInput); - assert!(error.details["recommendedFrameIds"].is_array()); - } - - #[tokio::test] - async fn w1_resource_delivery_moves_all_code_and_updates_note() { - let store = ArtifactStore::default(); - let data = payload(); - let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )); - let artifact = store.insert(key, data).await.unwrap(); - let mut result = json!({"tsx":"screen", "responsiveTsx":"responsive", "componentTsx":"component", - "frames":[{"componentTsx":"frame-component"}], - "deliverable":{"note":"Implement from this value."}}); - let outputs = projected_outputs_from_result(result.as_object().unwrap()).unwrap(); - let attachment = apply_delivery( - &mut result, - DeliveryMode::Resource, - &store, - &artifact, - outputs, - None, - ) - .await - .unwrap(); - commit_delivery(attachment); - assert!(result.get("componentTsx").is_none()); - assert!(result.get("responsiveTsx").is_none()); - assert!(result["frames"][0].get("componentTsx").is_none()); - assert_eq!(result["resources"].as_array().unwrap().len(), 4); - assert!( - result["deliverable"]["note"] - .as_str() - .unwrap() - .contains("resources") - ); - } - - #[test] - fn w1_asset_paths_are_ascii_bounded_and_windows_safe() { - assert_eq!( - host_safe_asset_path("/images/Frame 269.png"), - "/images/Frame-269-3e2af8571e3f.png" - ); - for name in [ - "한글.svg".to_owned(), - "CON.svg".to_owned(), - format!("{}.png", "a".repeat(300)), - ] { - let path = host_safe_asset_path(&format!("/icons/{name}")); - assert!(path.is_ascii()); - assert!(path.len() < 200); - assert_ne!(path.to_uppercase(), "/ICONS/CON.SVG"); - assert_eq!(host_safe_asset_path(&path), path); - } - assert_ne!( - host_safe_asset_path("/icons/한글.svg"), - host_safe_asset_path("/icons/한국.svg") - ); - } - - #[test] - fn w1_fixture_asset_paths_are_normalized_without_changing_other_code() { - let code = include_str!("../../../../fixtures/plugin-answers/notice/pure.tsx"); - let safe = with_host_safe_asset_paths(code); - assert!(!safe.contains("/icons/Property 1=")); - assert_eq!(with_host_safe_asset_paths(&safe), safe); - } - #[tokio::test] - async fn w1_index_accepts_component_projection_selection() { - let store = ArtifactStore::default(); - let data = section(2); - let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )); - let artifact = store.insert(key, data).await.unwrap(); - for output in ["componentTsx", "responsiveTsx"] { - super::super::validation::validate_artifact_projection( - &artifact, - &[output.into()], - "node", - &[], - ) - .unwrap(); - } - } - - #[tokio::test] - async fn w1_hidden_asset_is_warning_and_requested_failure_is_partial() { - let mut data = payload(); - data.assets.push( - serde_json::from_value(json!({"assetId":"1:1:fills:0","nodeId":"1:1", - "field":"fills/0","sourceKind":"image","status":"failed","format":"png","scale":1, - "errorCode":"DEVUP_ASSET_NODE_HIDDEN"})) - .unwrap(), - ); - let mut op = operation(&["assetManifest"]); - if let PendingOperation::Export { asset_captures, .. } = &mut op { - asset_captures.push(devup_mcp_figma::AssetSelection { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - render_mode: devup_mcp_figma::AssetRenderMode::Node, - quality: None, - lossless: None, - asset_id: "1:1:fills:0".into(), - format: devup_mcp_figma::AssetFormat::Png, - scale: 1.0, - }); - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["failures"], json!([])); - assert_eq!(result["warnings"][0]["assetId"], "1:1:fills:0"); - assert_eq!(result["status"], "complete"); - } - - #[tokio::test] - async fn w1_manifest_preserves_layer_name_and_section_paths_match() { - let mut data = section(1); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("name".into(), json!("BI - 아이콘")); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("childrenIds".into(), json!(["2:1"])); - data.snapshot.nodes.insert( - "2:1".into(), - serde_json::from_value(json!({"id":"2:1","type":"VECTOR", - "fields":{"name":"BI - 아이콘", "parentId":"1:1","width":24,"height":24,"visible":true, - "fills":[{"type":"SOLID","color":{"r":0,"g":0,"b":0}}]}})) - .unwrap(), - ); - let mut op = operation(&["tsx", "assetManifest"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data, op).await.unwrap(); - let asset = &result["assetManifest"]["assets"][0]; - assert_eq!(asset["layerName"], "BI - 아이콘"); - let path = asset["path"].as_str().unwrap(); - assert!(path.is_ascii()); - assert!(!path.contains(' ')); - assert!(result["frames"][0]["tsx"].as_str().unwrap().contains(path)); - } - - #[tokio::test] - async fn w1_missing_master_is_partial_with_component_id() { - let mut data = section(1); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("childrenIds".into(), json!(["2:1"])); - data.snapshot.nodes.insert( - "2:1".into(), - serde_json::from_value(json!({"id":"2:1","type":"INSTANCE", - "fields":{"name":"Button","parentId":"1:1","componentId":"9:9","visible":true}})) - .unwrap(), - ); - let mut op = operation(&["tsx", "componentTsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["status"], "partial"); - assert!(result["frames"][0]["tsx"].is_string()); - assert_eq!(result["failures"][0]["details"]["componentId"], "9:9"); - assert_eq!(result["failures"][0]["details"]["instanceNodeId"], "2:1"); - } - - #[tokio::test] - async fn w1_source_map_without_generator_is_actionable() { - let error = project(payload(), operation(&["sourceMap"])) - .await - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupInvalidInput); - assert!(error.message.contains("tsx")); - } - - #[tokio::test] - async fn r6_source_map_error_explains_artifact_outputs() { - let error = project(payload(), operation(&["rawSnapshot", "sourceMap"])) - .await - .unwrap_err(); - assert!( - error.message.contains("artifactId") && error.message.contains("same call"), - "{}", - error.message - ); - assert!(error.message.contains(r#"{"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true}"#)); - } - - #[tokio::test] - async fn r6_completion_vertical_loss_is_scoped_to_tsx() { - let mut data = payload(); - data.snapshot = serde_json::from_str(include_str!( - "../../../devup-mcp-devup-ui/tests/fixtures/r6-completion.json" - )) - .unwrap(); - data.target.node_id = Some("3997:46333".into()); - let mut op = operation(&["tsx", "sourceMap"]); - if let PendingOperation::Export { root_layout, .. } = &mut op { - *root_layout = devup_mcp_devup_ui::codegen::RootLayout::Embedded; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["deliverable"]["isFinal"], false); - assert!( - result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|d| d["nodeId"] == "3997:46333" - && d["property"] == "layoutSizingVertical" - && d["fidelityImpact"] == "lossy" - && d["details"]["output"] == "tsx") - ); - } - - fn responsive_payload() -> CollectedPayload { - let mut data = section(2); - for (id, name, width) in [("1:1", "mobile", 390), ("1:2", "desktop", 1440)] { - let node = data.snapshot.nodes.get_mut(id).unwrap(); - node.fields.insert("name".into(), json!(name)); - node.fields.insert("width".into(), json!(width)); - } - data - } - - #[tokio::test] - async fn w1_responsive_does_not_merge_unselected_frames() { - let mut op = operation(&["responsiveTsx"]); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - *frame_ids = vec!["1:1".into()]; - } - let result = project(responsive_payload(), op).await; - assert!( - result.is_err(), - "one selected width cannot be a responsive merge" - ); - } - - #[tokio::test] - async fn w1_responsive_success_has_code_resource_and_no_empty_frames() { - let mut op = operation(&["responsiveTsx"]); - if let PendingOperation::Export { - all_screens, - delivery, - .. - } = &mut op - { - *all_screens = true; - *delivery = DeliveryMode::Resource; - } - let result = project(responsive_payload(), op).await.unwrap(); - assert_ne!(result["quality"]["projection"], "not-requested"); - assert!( - result.get("frames").is_none(), - "no per-frame outputs were requested" - ); - assert!( - result["resources"] - .as_array() - .unwrap() - .iter() - .any(|r| r["name"] == "responsiveTsx") - ); - } - - #[tokio::test] - async fn w1_empty_all_screens_is_actionable() { - let mut data = section(1); - data.metadata["sectionIndex"]["candidates"][0]["bounds"] = - json!({"x":0,"y":0,"width":100,"height":100}); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let error = project(data, op).await.unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupInvalidInput); - assert!(error.message.contains("frameIds")); - } - - #[tokio::test] - async fn w1_partial_frames_provide_retry_arguments() { - let mut data = section(2); - data.snapshot.nodes.remove("1:2"); - data.snapshot.roots.retain(|id| id != "1:2"); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["resume"]["arguments"]["frameIds"], json!(["1:2"])); - assert_eq!(result["resume"]["arguments"]["outputs"], json!(["tsx"])); - assert!(result["resume"]["arguments"]["url"].is_string()); - } - - #[tokio::test] - async fn w1_discovered_hidden_assets_are_warnings_not_failed_deliverables() { - let mut data = payload(); - let node = data.snapshot.nodes.get_mut("1:1").unwrap(); - node.node_type = "VECTOR".into(); - node.fields.insert("visible".into(), json!(false)); - let result = project(data, operation(&["assetManifest"])).await.unwrap(); - assert_eq!(result["status"], "complete"); - assert_eq!(result["assetManifest"]["assets"], json!([])); - assert_eq!( - result["warnings"][0]["errorCode"], - "DEVUP_ASSET_NODE_HIDDEN" - ); - } - - #[tokio::test] - async fn w1_index_unavailable_output_explains_collection_step() { - let store = ArtifactStore::default(); - let data = section(2); - let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( - data.target.clone(), - CollectionScope::Node, - )); - let artifact = store.insert(key, data).await.unwrap(); - let error = super::super::validation::validate_artifact_projection( - &artifact, - &["assetManifest".into()], - "node", - &[], - ) - .unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupInvalidInput); - assert!(error.message.contains("frameIds")); - } - #[test] - fn w1_slash_in_layer_name_matches_manifest_and_code() { - let original = "/icons/한글 Foo/Bar-1-2.svg"; - let path = host_safe_asset_path(original); - let code = with_host_safe_asset_paths(&format!("")); - assert!(path.is_ascii()); - assert!(!path.contains(' ')); - assert_eq!(path.matches('/').count(), 2); - assert!(code.contains(&path)); - } - - #[tokio::test] - async fn w1_captured_main_component_id_is_reported_when_missing() { - let mut data = section(1); - data.snapshot - .nodes - .get_mut("1:1") - .unwrap() - .fields - .insert("childrenIds".into(), json!(["2:1"])); - data.snapshot.nodes.insert( - "2:1".into(), - serde_json::from_value(json!({"id":"2:1","type":"INSTANCE", - "fields":{"name":"Button","parentId":"1:1","mainComponentId":"9:9","visible":true}})) - .unwrap(), - ); - let mut op = operation(&["componentTsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["status"], "partial"); - assert_eq!(result["failures"][0]["details"]["componentId"], "9:9"); - } - - #[tokio::test] - async fn w1_responsive_text_loss_is_partial_and_strict_rejects() { - let mut data = responsive_payload(); - for (root, id, text) in [ - ("1:1", "2:1", "Mobile only"), - ("1:2", "2:2", "Desktop only"), - ] { - data.snapshot - .nodes - .get_mut(root) - .unwrap() - .fields - .insert("childrenIds".into(), json!([id])); - data.snapshot.nodes.insert(id.into(), serde_json::from_value(json!({"id":id,"type":"TEXT", - "fields":{"name":"Label","parentId":root,"characters":text,"visible":true,"fontSize":16}})).unwrap()); - } - let mut op = operation(&["responsiveTsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data.clone(), op.clone()).await.unwrap(); - assert!( - result["responsiveUnrepresented"] - .as_array() - .is_some_and(|notes| !notes.is_empty()) - ); - assert_eq!(result["status"], "partial"); - assert_eq!(result["quality"]["projection"], "lossy"); - if let PendingOperation::Export { strict, .. } = &mut op { - *strict = true; - } - assert!(project(data, op).await.is_err()); - } - - #[tokio::test] - async fn w1_responsive_slot_collision_is_actionable() { - let mut data = responsive_payload(); - data.snapshot - .nodes - .get_mut("1:2") - .unwrap() - .fields - .insert("width".into(), json!(400)); - let mut op = operation(&["responsiveTsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let error = project(data, op).await.unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupInvalidInput); - assert!(error.message.contains("breakpoint")); - } - - #[tokio::test] - async fn w1_missing_collected_outputs_explain_how_to_recollect() { - for output in ["devupJson", "referencePng"] { - let error = project(payload(), operation(&[output])).await.unwrap_err(); - assert_eq!(error.code, ErrorCode::DevupInvalidInput); - assert!(error.message.contains(output)); - assert!(error.details["snapshotRootIds"].is_array()); - } - } - #[tokio::test] - async fn w1_optional_responsive_collision_keeps_requested_tsx() { - let mut data = responsive_payload(); - data.snapshot - .nodes - .get_mut("1:2") - .unwrap() - .fields - .insert("width".into(), json!(400)); - let mut op = operation(&["tsx"]); - if let PendingOperation::Export { all_screens, .. } = &mut op { - *all_screens = true; - } - let result = project(data, op).await.unwrap(); - assert_eq!(result["frames"].as_array().unwrap().len(), 2); - assert!(result["frames"][0]["tsx"].is_string()); - assert!(result.get("responsiveTsx").is_none()); - } - - #[test] - fn w1_ascii_slugs_do_not_merge_distinct_variant_assets() { - assert_ne!( - host_safe_asset_path("/icons/Icons=a b.svg"), - host_safe_asset_path("/icons/Icons=a-b.svg") - ); - assert_ne!( - host_safe_asset_path("/icons/Foo Bar.svg"), - host_safe_asset_path("/icons/Foo-Bar.svg") - ); - } - #[tokio::test] - async fn w7_scaffold_assets_match_public_paths_and_preview_has_no_asset_writes() { - let dir = - std::env::temp_dir().join(format!("devup-w7-assets-{:016x}", rand::random::())); - std::fs::create_dir_all(dir.join("public")).unwrap(); - let root = crate::test_paths::canonical(&dir); - let mut op = operation(&["pageScaffold"]); - if let PendingOperation::Export { - page_scaffold, - output_paths, - asset_public_root, - asset_output_paths, - .. - } = &mut op - { - *page_scaffold = Some(page_scaffold::PageScaffoldOptions { - route: "/settings/profile".into(), - write: false, - overwrite: false, - }); - output_paths.insert("pageScaffold".into(), root.to_string_lossy().into_owned()); - *asset_public_root = Some(root.join("public")); - asset_output_paths.insert( - "1:2:fills:0".into(), - root.join("public/icons/actual.png") - .to_string_lossy() - .into_owned(), - ); - } - let preview = project(p3_asset_payload(b"first image"), op.clone()) - .await - .unwrap(); - assert_eq!(std::fs::read_dir(root.join("public")).unwrap().count(), 0); - assert!(!root.join("src").exists()); - assert!(preview.get("tsx").is_none()); - assert!(preview.get("assetManifest").is_none()); - let preview_files = preview["pageScaffold"]["files"].as_array().unwrap(); - assert_eq!( - preview_files.len(), - 3, - "identical captured bytes reuse one asset" - ); - if let PendingOperation::Export { - page_scaffold: Some(options), - .. - } = &mut op - { - options.write = true; - } - let written = project(p3_asset_payload(b"first image"), op).await.unwrap(); - let files = written["pageScaffold"]["files"].as_array().unwrap(); - assert_eq!(preview_files, files); - let code = files - .iter() - .find(|f| f["path"].as_str().unwrap().contains("components")) - .unwrap()["content"] - .as_str() - .unwrap(); - assert!(code.contains("/icons/actual.png"), "{code}"); - for file in files { - let expected = if file["encoding"] == "base64" { - STANDARD.decode(file["content"].as_str().unwrap()).unwrap() - } else { - file["content"].as_str().unwrap().as_bytes().to_vec() - }; - assert_eq!( - std::fs::read(file["path"].as_str().unwrap()).unwrap(), - expected - ); - } - assert_eq!( - std::fs::read(root.join("public/icons/actual.png")).unwrap(), - b"first image" - ); - std::fs::remove_dir_all(root).unwrap(); - } - - #[tokio::test] - async fn w7_frame_scaffold_uses_frame_output_key_and_rejects_multi_route_guess() { - let dir = - std::env::temp_dir().join(format!("devup-w7-frame-{:016x}", rand::random::())); - std::fs::create_dir(&dir).unwrap(); - let mut op = operation(&["pageScaffold"]); - if let PendingOperation::Export { - page_scaffold, - output_paths, - frame_ids, - .. - } = &mut op - { - *page_scaffold = Some(page_scaffold::PageScaffoldOptions { - route: "settings".into(), - write: true, - overwrite: false, - }); - *frame_ids = vec!["1:1".into()]; - output_paths.insert( - "frame:1:1:pageScaffold".into(), - dir.to_string_lossy().into_owned(), - ); - output_paths.insert( - "pageScaffold".into(), - dir.join("ignored").to_string_lossy().into_owned(), - ); - } - let value = project(section(2), op.clone()).await.unwrap(); - assert!(value.get("pageScaffold").is_none()); - assert!(value["frames"][0].get("tsx").is_none()); - assert_eq!( - value["frames"][0]["pageScaffold"]["files"] - .as_array() - .unwrap() - .len(), - 2 - ); - assert!( - value["outputPathResults"]["supportedKeys"] - .as_array() - .unwrap() - .contains(&json!("frame:1:1:pageScaffold")) - ); - assert!( - value["outputPathResults"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|d| d["key"] == "pageScaffold") - ); - assert!(!dir.join("ignored").exists()); - if let PendingOperation::Export { frame_ids, .. } = &mut op { - frame_ids.push("1:2".into()); - } - assert!( - project(section(2), op) - .await - .unwrap_err() - .message - .contains("one frameId") - ); - std::fs::remove_dir_all(dir).unwrap(); - } - #[tokio::test] - async fn w7_section_selection_keeps_one_route_and_a_usable_frame_key() { - let mut op = operation(&["pageScaffold"]); - if let PendingOperation::Export { - page_scaffold, - output_paths, - .. - } = &mut op - { - *page_scaffold = Some(page_scaffold::PageScaffoldOptions { - route: "/settings".into(), - write: true, - overwrite: false, - }); - output_paths.insert("pageScaffold".into(), "project".into()); - } - let value = project(section(3), op).await.unwrap(); - assert_eq!(value["status"], "selection_required"); - assert_eq!(value["nextAction"]["maxFramesPerCall"], 1); - assert!( - value["nextAction"].get("batches").is_none(), - "one supplied route cannot be applied to every screen" - ); - let args = &value["nextAction"]["example"]["arguments"]; - assert_eq!(args["pageScaffold"]["route"], "/settings"); - assert_eq!(args["outputPaths"]["frame:1:1:pageScaffold"], "project"); - assert!(args["outputPaths"].get("pageScaffold").is_none()); - } -} +//! Projection, evidence, asset reconciliation and delivery. +// Sections retain the original namespace and visibility. +include!("projection/setup.rs"); +include!("projection/asset_paths.rs"); +include!("projection/resource_outputs.rs"); +include!("projection/resource_delivery.rs"); +include!("projection/asset_resources.rs"); +include!("projection/safe_file_names.rs"); +include!("projection/asset_reference_names.rs"); +include!("projection/frame_identity.rs"); +include!("projection/asset_summary.rs"); +include!("projection/artifact_metadata.rs"); +include!("projection/verification_scope.rs"); +include!("projection/diagnostics.rs"); +include!("projection/component_projection.rs"); +include!("projection/completion.rs"); +include!("projection/diagnostic_screens.rs"); +include!("projection/regression_tests.rs"); +include!("projection/project_operation.rs"); +include!("projection/delivered_property_audit.rs"); diff --git a/crates/devup-mcp/src/server/projection/artifact_metadata.rs b/crates/devup-mcp/src/server/projection/artifact_metadata.rs new file mode 100644 index 00000000..bdc706f4 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/artifact_metadata.rs @@ -0,0 +1,36 @@ +fn projection_key(outputs: &[ProjectedOutput]) -> String { + let mut hasher = Sha256::new(); + for output in outputs { + hasher.update(output.name.as_bytes()); + hasher.update([0]); + hasher.update(output.mime_type.as_bytes()); + hasher.update([u8::from(output.is_binary)]); + hasher.update((output.bytes.len() as u64).to_le_bytes()); + hasher.update(&output.bytes); + } + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + let _ = write!(&mut encoded, "{byte:02x}"); + } + encoded +} + +pub(super) fn artifact_metadata(artifact: &ArtifactLookup) -> Value { + let origin_collection = &artifact.payload.stats; + json!({ + "artifactId": artifact.artifact_id, + "contentHash": artifact.content_hash, + "cacheHit": artifact.cache_hit, + "reuseKind": artifact.reuse_kind, + "ageSeconds": artifact.age_seconds, + "remainingTtlSeconds": artifact.remaining_ttl_seconds, + "avoidedFigmaToolCalls": if artifact.cache_hit { origin_collection.figma_tool_calls } else { 0 }, + "originCollection": origin_collection, + "capabilities": artifact.capabilities, + "reuse": reuse_summary(&artifact.capabilities), + "sizeBytes": artifact.size_bytes, + "acquiredAt": format_epoch_rfc3339(artifact.created_at_epoch_seconds), + "expiresAt": format_epoch_rfc3339(artifact.expires_at_epoch_seconds) + }) +} diff --git a/crates/devup-mcp/src/server/projection/asset_paths.rs b/crates/devup-mcp/src/server/projection/asset_paths.rs new file mode 100644 index 00000000..3541cda5 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/asset_paths.rs @@ -0,0 +1,176 @@ +/// Reconcile only exports backed by verified bytes. Preserve an entry per node, +/// but make identical content use one file and one code reference. +fn reconcile_asset_paths( + manifest: &mut AssetManifest, + output_paths: &mut BTreeMap, + public_root: Option<&std::path::Path>, + policy: &OutputPolicy, +) -> Result, DevupError> { + // This verifies decoded length/hash as well as MIME before any write. + projected_asset_outputs(manifest)?; + let mut groups = BTreeMap::<_, Vec>::new(); + for (index, asset) in manifest.assets.iter().enumerate() { + if asset.status != AssetStatus::Exported { + continue; + } + let bytes = STANDARD + .decode(asset.data_base64.as_deref().unwrap_or_default()) + .map_err(|_| { + DevupError::new( + ErrorCode::DevupCodegenFailed, + "Invalid exported asset base64.", + false, + ) + })?; + let key = ( + sha256_hex(&bytes), + asset.format.map(|format| format.extension()), + asset.mime_type.clone(), + asset + .variants + .iter() + .map(|v| (v.sha256.clone(), v.width, v.height)) + .collect::>(), + ); + groups.entry(key).or_default().push(index); + } + let mut replacements = BTreeMap::new(); + for members in groups.values() { + // Canonical code names must survive a later code-only projection. + // Select the placeholder independently from the requested file target. + let canonical_path = members + .iter() + .find_map(|&index| manifest.assets[index].path.clone()); + let output_path = members + .iter() + .find_map(|&index| output_paths.get(&manifest.assets[index].asset_id).cloned()); + let canonical_path = canonical_path.map(|p| { + if manifest.assets[members[0]].format == Some(devup_mcp_figma::AssetFormat::Webp) { + p.rsplit_once('.') + .map_or_else(|| format!("{p}.webp"), |(stem, _)| format!("{stem}.webp")) + } else { + p + } + }); + let path = if let (Some(root), Some(output)) = (public_root, &output_path) { + Some(super::validation::public_asset_url( + root, + policy.resolve(output)?.display_path(), + )?) + } else { + canonical_path + }; + for &index in members { + let asset = &mut manifest.assets[index]; + if let Some(output) = &output_path { + output_paths.insert(asset.asset_id.clone(), output.clone()); + } + let old_path = std::mem::replace(&mut asset.path, path.clone()); + if let (Some(old), Some(new)) = (&old_path, &path) + && !asset.variants.is_empty() + { + replacements.insert( + asset_policy::variant_path(old), + asset_policy::variant_path(new), + ); + } + if let (Some(old), Some(new)) = (old_path, path.as_ref()) + && old != *new + && let Some(previous) = replacements.insert(old, new.clone()) + && previous != *new + { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "One placeholder refers to different exported assets. Use assetNamesPerNode:true.", + false, + )); + } + } + } + Ok(replacements) +} + +fn rewrite_asset_references(code: &str, paths: &BTreeMap) -> String { + // One pass prevents a replacement from becoming another replacement's input. + let mut result = String::with_capacity(code.len()); + let mut cursor = 0; + while cursor < code.len() { + let rest = &code[cursor..]; + let matched = paths.iter().find(|(old, _)| { + rest.starts_with(old.as_str()) + && (cursor == 0 + || matches!( + code.as_bytes()[cursor - 1], + b'"' | b'\'' | b'(' | b' ' | b',' + )) + && rest + .as_bytes() + .get(old.len()) + .is_none_or(|next| matches!(next, b'"' | b'\'' | b')' | b' ' | b',')) + }); + if let Some((old, new)) = matched { + result.push_str(new); + cursor += old.len(); + } else { + let character = rest.chars().next().expect("nonempty remainder"); + result.push(character); + cursor += character.len_utf8(); + } + } + result +} + +fn rewrite_result_asset_references(value: &mut Value, paths: &BTreeMap) { + match value { + Value::Object(object) => { + for (key, value) in object { + if matches!( + key.as_str(), + "tsx" + | "componentTsx" + | "responsiveTsx" + | "generatedProperty" + | "generatedSource" + | "srcSet" + ) { + match value { + Value::String(code) => { + *code = if key == "srcSet" { + rewrite_asset_references(code, paths) + } else { + rewrite_asset_references(&with_host_safe_asset_paths(code), paths) + } + } + Value::Array(items) => { + for item in items { + if let Value::String(code) = item { + *code = rewrite_asset_references( + &with_host_safe_asset_paths(code), + paths, + ); + } else { + rewrite_result_asset_references(item, paths); + } + } + } + _ => rewrite_result_asset_references(value, paths), + } + } else if matches!(key.as_str(), "assetPath" | "path") { + if let Value::String(path) = value + && let Some(replacement) = paths.get(path) + { + *path = replacement.clone(); + } + } else if !matches!(key.as_str(), "rawSnapshot" | "rawPayload" | "originalValue") { + rewrite_result_asset_references(value, paths); + } + } + } + Value::Array(values) => { + for value in values { + rewrite_result_asset_references(value, paths); + } + } + _ => {} + } +} diff --git a/crates/devup-mcp/src/server/projection/asset_reference_names.rs b/crates/devup-mcp/src/server/projection/asset_reference_names.rs new file mode 100644 index 00000000..fcc15f8f --- /dev/null +++ b/crates/devup-mcp/src/server/projection/asset_reference_names.rs @@ -0,0 +1,49 @@ +/// Every asset the generated code points at, renamed the way its bytes will +/// be written. The code holds each path inside a string or a `url(...)`, so a +/// name runs to the first quote or closing parenthesis - spaces and dots +/// belong to it, as they do in `Frame 269.png`. +fn with_host_safe_asset_paths(code: &str) -> String { + let mut safe = String::with_capacity(code.len()); + let mut rest = code; + loop { + let Some((at, prefix)) = ["/icons/", "/images/"] + .into_iter() + .filter_map(|prefix| rest.find(prefix).map(|at| (at, prefix))) + .min_by_key(|(at, _)| *at) + else { + safe.push_str(rest); + return safe; + }; + let after = at + prefix.len(); + safe.push_str(&rest[..after]); + let mut end = rest[after..] + .find(['"', '\'', ')']) + .map_or(rest.len(), |offset| after + offset); + // srcSet contains several URLs followed by numeric density/width + // descriptors. Keep each URL separate while retaining spaces inside + // ordinary quoted layer filenames. + let candidate = &rest[after..end]; + let first = candidate + .split_once(',') + .map_or(candidate, |(first, _)| first) + .trim_end(); + if let Some((filename, descriptor)) = first.rsplit_once(char::is_whitespace) + && descriptor + .strip_suffix('x') + .or_else(|| descriptor.strip_suffix('w')) + .and_then(|n| n.parse::().ok()) + .is_some_and(|n| n.is_finite() && n > 0.0) + { + end = after + filename.trim_end().len(); + } + safe.push_str(&host_safe_file_name(&rest[after..end])); + rest = &rest[end..]; + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} diff --git a/crates/devup-mcp/src/server/projection/asset_resources.rs b/crates/devup-mcp/src/server/projection/asset_resources.rs new file mode 100644 index 00000000..df1a75ee --- /dev/null +++ b/crates/devup-mcp/src/server/projection/asset_resources.rs @@ -0,0 +1,159 @@ +fn materialize_asset_resource_references( + result: &mut Value, + outputs: &mut [ProjectedOutput], + artifact_id: &str, +) -> Result<(), DevupError> { + let Some(assets) = result + .get_mut("assetManifest") + .and_then(|manifest| manifest.get_mut("assets")) + .and_then(Value::as_array_mut) + else { + return Ok(()); + }; + for output in outputs.iter().filter(|output| output.asset_id.is_some()) { + let asset_id = output.asset_id.as_deref().unwrap_or_default(); + if !assets + .iter() + .any(|asset| asset["assetId"].as_str() == Some(asset_id)) + { + return Err(DevupError::new( + ErrorCode::DevupCodegenFailed, + "No manifest entry matches this asset resource.", + false, + )); + } + let format = assets + .iter() + .find(|asset| asset["assetId"].as_str() == Some(asset_id)) + .expect("asset checked above")["format"] + .clone(); + let hash = sha256_hex(&output.bytes); + for asset in assets.iter_mut() { + for variant in asset + .get_mut("variants") + .and_then(Value::as_array_mut) + .into_iter() + .flatten() + { + if variant["sha256"].as_str() == Some(hash.as_str()) + && variant["mimeType"] == output.mime_type + && variant["byteLength"].as_u64() == Some(output.bytes.len() as u64) + { + let variant = variant.as_object_mut().expect("variant object"); + variant.remove("dataBase64"); + variant.insert("resource".into(),json!({"uri":output.manifest_uri(artifact_id),"mimeType":output.mime_type,"byteLength":output.bytes.len(),"sha256":hash})); + } + } + } + for asset in assets.iter_mut().filter(|asset| { + asset["status"] == "exported" + && asset["sha256"].as_str() == Some(hash.as_str()) + && asset["mimeType"] == output.mime_type + && asset["format"] == format + }) { + let asset = asset.as_object_mut().expect("manifest asset object"); + asset.remove("dataBase64"); + asset.insert( + "resource".to_owned(), + json!({ + "uri":output.manifest_uri(artifact_id),"mimeType":output.mime_type, + "byteLength":output.bytes.len(),"sha256":hash,"assetMetadata":output.asset_metadata + }), + ); + } + } + if let Some(manifest_output) = outputs + .iter_mut() + .find(|output| output.name == "asset-manifest.json") + { + manifest_output.bytes = + encode_projected_json(result.get("assetManifest").ok_or_else(|| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "The asset manifest resource is missing.", + false, + ) + })?)?; + } + Ok(()) +} + +fn projected_asset_outputs(manifest: &AssetManifest) -> Result, DevupError> { + let mut outputs = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + let invalid = + |message: &str| DevupError::new(ErrorCode::DevupSnapshotUnsupported, message, false); + for (index, asset) in manifest + .assets + .iter() + .enumerate() + .filter(|(_, a)| a.status == AssetStatus::Exported) + { + let mut renditions = vec![( + asset.data_base64.as_deref(), + asset.byte_length, + asset.sha256.as_deref(), + asset.mime_type.as_deref(), + None, + )]; + renditions.extend(asset.variants.iter().map(|v| { + ( + v.data_base64.as_deref(), + Some(v.byte_length), + Some(v.sha256.as_str()), + Some(v.mime_type.as_str()), + Some(v), + ) + })); + for (data, length, hash, mime, variant) in renditions { + let data = + data.ok_or_else(|| invalid("The exported asset binary is not in the artifact."))?; + if data.len() > devup_mcp_figma::MAX_ASSET_BYTES.div_ceil(3) * 4 { + return Err(invalid("Asset base64 exceeds its encoded budget.")); + } + let bytes = STANDARD + .decode(data) + .map_err(|_| invalid("The exported asset binary base64 is invalid."))?; + let mime = mime.ok_or_else(|| invalid("The exported asset has no MIME type."))?; + if length != Some(bytes.len()) || hash != Some(sha256_hex(&bytes).as_str()) { + return Err(invalid("The exported asset length or hash does not match.")); + } + if let Some(variant) = variant { + if mime != "image/webp" || !variant.density.is_finite() || variant.density <= 1.0 { + return Err(invalid("Invalid srcset derivative identity.")); + } + let reader = image::ImageReader::new(std::io::Cursor::new(&bytes)) + .with_guessed_format() + .map_err(|_| invalid("Invalid variant codec."))?; + if reader.into_dimensions().ok() != Some((variant.width, variant.height)) { + return Err(invalid("Variant encoded dimensions do not match.")); + } + } + let extension = if variant.is_some() { + "webp" + } else { + asset + .format + .map_or("bin", devup_mcp_figma::AssetFormat::extension) + }; + if !seen.insert((sha256_hex(&bytes), extension.to_owned(), mime.to_owned())) { + continue; + } + let mut output = ProjectedOutput::asset( + format!( + "asset-{}{}.{extension}", + index + 1, + if variant.is_some() { "-2x" } else { "" } + ), + mime, + bytes, + &asset.asset_id, + ); + output.asset_metadata = Some( + json!({"assets":manifest.assets.iter().filter(|other|other.status==AssetStatus::Exported&&(other.sha256.as_deref()==hash || other.variants.iter().any(|v|Some(v.sha256.as_str())==hash))).map(|other|json!({"assetId":other.asset_id,"nodeId":other.node_id,"renderMode":other.render_mode,"format":other.format,"scale":other.scale,"quality":other.quality,"lossless":other.lossless,"maxWidth":other.max_width,"srcset":other.srcset,"visibleScopeNodeId":other.visible_scope_node_id,"renderInfo":other.render_info,"suggestedFilename":other.suggested_filename})).collect::>(),"density":variant.map(|v|v.density)}), + ); + outputs.push(output); + } + } + Ok(outputs) +} diff --git a/crates/devup-mcp/src/server/projection/asset_summary.rs b/crates/devup-mcp/src/server/projection/asset_summary.rs new file mode 100644 index 00000000..05e7e9dc --- /dev/null +++ b/crates/devup-mcp/src/server/projection/asset_summary.rs @@ -0,0 +1,119 @@ +/// This describes collection, even when the caller did not request a manifest. +/// Missing evidence is never reported as proof that the design has no assets. +fn asset_summary( + payload: &CollectedPayload, + artifact: &ArtifactLookup, + roots: &[String], + manifest_requested: bool, +) -> Value { + let mut snapshot = payload.snapshot.clone(); + snapshot.roots = roots.to_vec(); + let mut reachable = std::collections::BTreeSet::new(); + let mut pending = roots.to_vec(); + while let Some(id) = pending.pop() { + if !reachable.insert(id.clone()) { + continue; + } + if let Some(node) = snapshot.nodes.get(&id) { + pending.extend(node.typed_view().child_ids().map(str::to_owned)); + } + } + snapshot.nodes.retain(|id, _| reachable.contains(id)); + let mut assets = asset_policy::discovery( + &snapshot, + &payload.assets, + artifact.capabilities.web_ready_images, + ) + .assets; + for captured in payload + .assets + .iter() + .filter(|asset| reachable.contains(&asset.node_id)) + { + if let Some(existing) = assets + .iter_mut() + .find(|asset| asset.asset_id == captured.asset_id) + { + *existing = captured.clone(); + } else { + assets.push(captured.clone()); + } + } + let index_only = artifact.capabilities.kind == super::artifacts::ArtifactKind::SectionIndex; + let incomplete = index_only + || snapshot.audit().state != devup_mcp_figma::CompletenessState::Complete + || snapshot + .nodes + .values() + .any(|node| node.typed_view().bool("projectionTruncated") == Some(true)); + let collected = assets + .iter() + .filter(|asset| asset.status == AssetStatus::Exported) + .count(); + let unavailable = assets.iter().filter(|asset| asset.status != AssetStatus::Exported).map(|asset| { + let reason = match asset.error_code.as_deref() { + Some("DEVUP_ASSET_NODE_HIDDEN") => "hidden-node", + Some(_) => "export-failed", + None if artifact.capabilities.asset_capture_count == 0 => "not-requested", + None => "capture-not-in-artifact", + }; + let mut entry = json!({"assetId":asset.asset_id,"nodeId":asset.node_id,"reason":reason,"errorCode":asset.error_code}); + if let Some(detail) = &asset.error_detail { + entry["errorDetail"] = json!(detail); + } + entry + }).collect::>(); + let excluded = assets + .iter() + .filter(|asset| asset.error_code.as_deref() == Some("DEVUP_ASSET_NODE_HIDDEN")) + .count(); + let status = if assets.is_empty() { + if incomplete { "unknown" } else { "none" } + } else if collected == 0 { + "not-collected" + } else if collected < assets.len() - excluded || incomplete { + "partial" + } else { + "collected" + }; + let description = match status { + "none" => "Complete discovery found no assets.", + "unknown" => "Incomplete discovery cannot establish whether assets exist.", + "not-collected" => { + "Assets were discovered but no binary bytes were collected; unavailable explains unrequested captures, hidden exclusions, or export failures." + } + "partial" => { + include_str!("../text/projection-asset_summary-2538c470.md") + } + "collected" => "All discovered non-hidden deliverable assets have collected binary bytes.", + _ => unreachable!(), + }; + json!({"status":status,"description":description,"excludedCount":excluded,"scopeRootIds":roots, + "discovery":if incomplete { "incomplete" } else { "complete" }, + "discoveryReason":if index_only { Some("index-only") } else if incomplete { Some("snapshot-incomplete") } else { None }, + "discoveredCount":assets.len(),"collectedCount":collected, + "manifestIncluded":manifest_requested,"unavailable":unavailable, + "batchMerge":{"command":"devup-mcp --merge-asset-batches batch1.json batch2.json","input":"Saved complete export responses, MCP response envelopes, or poll records containing response","scope":"union-of-supplied-batches"}}) +} + +/// Binary collection quality, independent of whether a manifest was delivered. +fn asset_collection_quality(summary: &Value) -> super::quality::AssetsQuality { + use super::quality::AssetsQuality; + match summary["status"].as_str() { + Some("collected") => AssetsQuality::Complete, + Some("partial") => AssetsQuality::Partial, + Some("not-collected") + if summary["unavailable"].as_array().is_some_and(|items| { + items.iter().any(|item| { + matches!( + item["reason"].as_str(), + Some("export-failed" | "capture-not-in-artifact") + ) + }) + }) => + { + AssetsQuality::Partial + } + _ => AssetsQuality::NotRequested, + } +} diff --git a/crates/devup-mcp/src/server/projection/completion.rs b/crates/devup-mcp/src/server/projection/completion.rs new file mode 100644 index 00000000..5381b69d --- /dev/null +++ b/crates/devup-mcp/src/server/projection/completion.rs @@ -0,0 +1,112 @@ +pub(super) async fn complete_operation( + operation: PendingOperation, + payload: &CollectedPayload, + source_kind: &str, + artifact: &ArtifactLookup, + output_policy: &OutputPolicy, + artifact_store: &ArtifactStore, +) -> Result { + let project_root = match &operation { + PendingOperation::Export { theme_options, .. } => theme_options.project_root.clone(), + _ => None, + }; + let mut response = project_operation( + operation, + payload, + source_kind, + artifact, + output_policy, + artifact_store, + ) + .await?; + if let Some(source) = response + .get_mut("source") + .filter(|source| source.is_object()) + { + attach_bridge_source(source, payload); + } + // What this export wrote for each marked node, so the audit can tell + // code drift from a converter bug later. + if let Some(root) = project_root.as_deref() + && response.get("figmaNodeIds").is_some() + { + let root_node = payload.target.node_id.clone().unwrap_or_default(); + match super::export_record::write( + output_policy, + root, + &payload.target.file_key, + &root_node, + &response, + ) { + Ok(path) => response["exportRecord"] = json!(path), + Err(error) => response["exportRecordError"] = json!(error.message), + } + } + Ok(response) +} + +/// Names the Devup Bridge plugin when it carried the design, and never claims +/// a file key the plugin did not report. +/// +/// Every answer used to say `kind: "direct"` whatever carried it, and repeated +/// the request's key as the file's - including a key an agent had invented to +/// get past the url field, which the plugin served only because it was the one +/// attached. So the agent could not tell which path had served it, and was +/// told its own invention was the file. +fn attach_bridge_source(source: &mut Value, payload: &CollectedPayload) { + let fallbacks = &payload.stats.direct_fallbacks; + if !fallbacks.is_empty() { + let reasons: std::collections::BTreeSet<&str> = + fallbacks.iter().map(String::as_str).collect(); + let hints: Vec<&str> = reasons + .iter() + .filter_map(|reason| match *reason { + "file-out-of-sight" => Some(include_str!( + "../text/projection-attach_bridge_source-e2f65c04.md" + )), + "plugin-older-than-server" => Some(include_str!( + "../text/projection-attach_bridge_source-65001189.md" + )), + _ => None, + }) + .collect(); + source["directFallbacks"] = json!(fallbacks.len()); + source["directFallbackReasons"] = json!(reasons); + if !hints.is_empty() { + source["directFallbackHint"] = json!(hints.join(" ")); + } + } + let Some(served) = &payload.stats.bridge else { + return; + }; + // A reused acquisition stays `artifact` - this call read nothing - and + // says where the acquisition came from. + if source["kind"] == "direct" { + source["kind"] = json!("bridge"); + } else { + source["origin"] = json!("bridge"); + } + source["bridgePort"] = json!(served.port); + source["fileKey"] = json!(served.file_key); + if served.file_key.is_none() { + source["fileKeyReason"] = json!( + "The Devup Bridge plugin that served this could not report its file key (figma.fileKey is empty, as in Dev Mode), so no file key is claimed." + ); + if !payload.target.is_bridge_only() { + source["requestedFileKey"] = json!(payload.target.file_key); + } + } + source["fileName"] = json!(served.file_name); + source["pageName"] = json!(served.page_name); + source["bridgeReads"] = json!(served.reads); +} + +/// The file key an answer may state: the plugin's own report when the bridge +/// read the file, and the requested key when Figma itself served it. +fn vouched_file_key(payload: &CollectedPayload) -> Option<&str> { + match &payload.stats.bridge { + Some(served) => served.file_key.as_deref(), + None => Some(payload.target.file_key.as_str()), + } +} + diff --git a/crates/devup-mcp/src/server/projection/component_projection.rs b/crates/devup-mcp/src/server/projection/component_projection.rs new file mode 100644 index 00000000..e21aa9c5 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/component_projection.rs @@ -0,0 +1,100 @@ +/// Marks the page root, its top-level children (the sections), their +/// children and every asset with `data-figma-node-id`, so the runtime audit captures a section +/// or an asset by the node it came from. Only for exports into a project +/// (`projectRoot`); `.devup/screen-plan.config.json` `figmaNodeIds: false` +/// turns it off. +fn annotate_node_ids( + output: &mut devup_mcp_devup_ui::codegen::CodegenOutput, + snapshot: &devup_mcp_figma::Snapshot, + root_id: &str, +) -> Value { + let sections = snapshot + .nodes + .get(root_id) + .map(|node| { + node.typed_view() + .child_ids() + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + // A section's own children (the cards of a card row) too, so their + // order and position are checked against the frame. + let parts = sections + .iter() + .filter_map(|id| snapshot.nodes.get(id)) + .flat_map(|node| { + node.typed_view() + .child_ids() + .map(str::to_owned) + .collect::>() + }) + .collect::>(); + let assets = output + .source_map + .entries + .iter() + .filter(|entry| entry.asset_id.is_some()) + .filter_map(|entry| entry.node_id.clone()) + .collect::>(); + let wanted = std::iter::once(root_id.to_owned()) + .chain(sections.iter().cloned()) + .chain(parts.iter().cloned()) + .chain(assets.iter().cloned()) + .collect(); + let written = devup_mcp_devup_ui::codegen::node_ids::annotate_node_ids(output, &wanted); + json!({ + "attribute": devup_mcp_devup_ui::codegen::node_ids::NODE_ID_ATTRIBUTE, + "root": written.iter().any(|id| id == root_id).then_some(root_id), + "sections": sections.iter().filter(|id| written.contains(id)).collect::>(), + "parts": parts.iter().filter(|id| written.contains(id)).collect::>(), + "assets": assets.iter().filter(|id| written.contains(id)).collect::>(), + "note": include_str!("../text/projection-annotate_node_ids-b03e6d56.md"), + }) +} + +fn generate_collected_component( + payload: &CollectedPayload, + node_id: &str, + options: &CodegenOptions, +) -> Result { + if !options.inline_instances { + let mut pending = vec![node_id]; + let mut visited = std::collections::BTreeSet::new(); + while let Some(id) = pending.pop() { + if !visited.insert(id) { + continue; + } + let Some(node) = payload.snapshot.nodes.get(id) else { + continue; + }; + let view = node.typed_view(); + if view.bool("visible") == Some(false) { + continue; + } + if node.node_type == "INSTANCE" { + let component_id = view + .string("mainComponentId") + .or_else(|| view.string("componentId")) + .or_else(|| view.string("mainComponent")) + .or_else(|| { + view.value("mainComponent") + .and_then(|value| value.get("id")) + .and_then(Value::as_str) + }); + if let Some(component_id) = component_id + && !payload.snapshot.nodes.contains_key(component_id) + { + return Err(DevupError::with_details( + ErrorCode::DevupFigmaNodeNotFound, + "componentTsx needs a component master outside this snapshot. Re-collect the component master, or request tsx to inline the instance.", + false, + json!({"componentId":component_id,"instanceNodeId":id}), + )); + } + } + pending.extend(view.child_ids()); + } + } + generate_component(&payload.snapshot, node_id, options) +} diff --git a/crates/devup-mcp/src/server/projection/delivered_property_audit.rs b/crates/devup-mcp/src/server/projection/delivered_property_audit.rs new file mode 100644 index 00000000..371cc318 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/delivered_property_audit.rs @@ -0,0 +1,123 @@ +/// Last code boundary: run after all TSX/map/evidence rewrites and before any +/// file or resource is materialized. New postprocessors cannot silently add or +/// change a property without updating its verified derivation contract. +fn audit_delivered_properties(value: &mut Value) { + let mut complete = true; + let mut maps = Map::new(); + let mut frame_issues = Vec::new(); + for field in ["tsx", "componentTsx", "responsiveTsx"] { + if !value.get(field).is_some_and(Value::is_string) { + continue; + } + if let Some(map) = value["outputResults"][field] + .as_object_mut() + .and_then(|o| o.remove("_sourceMap")) + { + maps.insert(field.into(), map); + } + let contract = value["outputResults"][field] + .as_object_mut() + .and_then(|o| o.remove("_propertyContract")) + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default(); + let Some(code) = value[field].as_str() else { + continue; + }; + let mut missing = + devup_mcp_devup_ui::provenance::attributes::uncovered_attributes(code, &contract); + let layout = &value["outputResults"][field]["fidelity"]["layout"]; + if layout["total"] + .as_u64() + .zip(layout["covered"].as_u64()) + .is_some_and(|(total, covered)| covered < total) + { + missing.push(json!({"reason":"unmapped-source-layout-fields", + "sourceFields":value["outputResults"][field]["fidelity"]["uncoveredLayout"], + "propertyMappingVerified":false, + "message":"Source layout obligations lack verified emitted mappings, even if every generated attribute has provenance."})); + } + if field == "responsiveTsx" + && value["outputResults"][field]["fidelity"]["mergedMappingVerified"] != true + { + missing.push(json!({"reason":"unverified-responsive-merge", + "propertyMappingVerified":false, + "gaps":value["outputResults"][field]["fidelity"]["mergeGaps"], + "message":"Read at some width's slot, the merged module does not write that width's own verified tsx; gaps names where."})); + } + value["outputResults"][field]["mappingComplete"] = json!(missing.is_empty()); + if missing.is_empty() { + continue; + } + complete = false; + let issue = json!({"code":"DEVUP_CODEGEN_PROPERTY_UNMAPPED", + "message":"The final bidirectional contract has uncovered generated attributes or source layout obligations. Existing value-loss classifications remain unchanged.", + "nodeId":value["nodeId"].as_str().or(value["source"]["nodeId"].as_str()),"severity":"warning","fidelityImpact":"none", + "details":{"stage":"delivery-property-audit","output":field,"classification":"mapping-incomplete", + "unmappedProperties":missing,"propertyMappingVerified":false, + "nextAction":"Add sourceMap or projectionEvidence derivations for these final attributes. Breakpoint-source maps do not establish merged responsive mappings.", + "evidenceLimit":"No value loss or browser composition measurement is inferred from missing provenance."}}); + if !value["projectionIssues"].is_array() { + value["projectionIssues"] = json!([]); + } + value["projectionIssues"] + .as_array_mut() + .unwrap() + .push(issue); + if value["outputResults"][field]["projection"] == "exact" { + value["outputResults"][field]["projection"] = json!("mapping-incomplete"); + } + value["outputResults"][field]["mappingComplete"] = json!(false); + } + if maps.len() > 1 && value.get("sourceMap").is_some() { + value["sourceMap"]["byOutput"] = Value::Object(maps); + } + if let Some(frames) = value.get_mut("frames").and_then(Value::as_array_mut) { + for frame in frames { + audit_delivered_properties(frame); + complete &= frame["mappingComplete"] != false; + frame_issues.extend( + frame["projectionIssues"] + .as_array() + .into_iter() + .flatten() + .filter(|d| d["details"]["stage"] == "delivery-property-audit") + .cloned(), + ); + } + } + if !frame_issues.is_empty() { + if !value["projectionIssues"].is_array() { + value["projectionIssues"] = json!([]); + } + value["projectionIssues"] + .as_array_mut() + .unwrap() + .extend(frame_issues); + } + value["mappingComplete"] = json!(complete); + if !complete { + if value["quality"]["projection"] == "exact" { + value["quality"]["projection"] = json!("mapping-incomplete"); + } + if value["status"] == "complete" { + value["status"] = json!("partial"); + } + if let Some(deliverable) = value.get_mut("deliverable") { + deliverable["isFinal"] = json!(false); + deliverable["note"] = json!( + "Generated values remain available; review missing provenance in projectionIssues before accepting the output." + ); + } + let failures = value["failures"].as_array().cloned().unwrap_or_default(); + let node_id = value["nodeId"] + .as_str() + .or(value["source"]["nodeId"].as_str()) + .map(str::to_owned); + attach_review_and_deliverable( + value.as_object_mut().unwrap(), + &["tsx".into(), "componentTsx".into(), "responsiveTsx".into()], + &failures, + node_id.as_deref(), + ); + } +} diff --git a/crates/devup-mcp/src/server/projection/diagnostic_screens.rs b/crates/devup-mcp/src/server/projection/diagnostic_screens.rs new file mode 100644 index 00000000..93c2d99d --- /dev/null +++ b/crates/devup-mcp/src/server/projection/diagnostic_screens.rs @@ -0,0 +1,30 @@ +/// Apply at the delivery boundary so diagnostics, evidence, and issues share +/// the same ID contract, including resource-delivered diagnostic containers. +fn attach_diagnostic_screens(value: &mut Value, owners: &BTreeMap>) { + match value { + Value::Array(items) => { + for item in items { + attach_diagnostic_screens(item, owners); + } + } + Value::Object(object) => { + if object.get("code").and_then(Value::as_str).is_some() + && object.get("message").and_then(Value::as_str).is_some() + { + let owner = object + .get("nodeId") + .and_then(Value::as_str) + .and_then(|id| owners.get(id)) + .and_then(|id| id.as_deref()); + object.insert("screenId".into(), json!(owner)); + if owner.is_none() { + object.insert("screenIdReason".into(), json!("This diagnostic has no node in a known selectable screen; no screen ID was inferred.")); + } + } + for child in object.values_mut() { + attach_diagnostic_screens(child, owners); + } + } + _ => {} + } +} diff --git a/crates/devup-mcp/src/server/projection/diagnostics.rs b/crates/devup-mcp/src/server/projection/diagnostics.rs new file mode 100644 index 00000000..929d5d84 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/diagnostics.rs @@ -0,0 +1,70 @@ +/// Adds `completenessReport` only when it has something to say. +/// +/// `quality.acquisition` already grades the capture, and on a clean one the +/// report underneath it is six empty arrays and a row of counters that agree +/// with the grade. Measured on one export it was 360 of the 1,911 bytes every +/// response carried before this, none of it actionable. It is attached +/// whenever the capture is not clean, and whenever the caller asked for +/// diagnostics and therefore wants the detail regardless. +/// Adds `fidelity` whenever the report has something `quality` does not say. +/// +/// Keyed on the report itself, not on `quality.projection`. That was the +/// first attempt and it hid a real defect: `projection_quality` is computed +/// from diagnostics alone, so a coverage shortfall that raises no diagnostic +/// leaves the grade reading `exact` and took the report away with it. +/// Measured on a real 50-node screen, `variables` was 19 of 20 - a `$primary` +/// binding frozen into an exported SVG - while `quality.projection` said +/// `exact`, so the response went from showing the shortfall to hiding it. +/// +/// `strict_compatible` is the same predicate `strict: true` refuses on, which +/// keeps the two from disagreeing about whether anything was lost. On the ten +/// real captured screens it is true - nothing sent - for every screen with no +/// shortfall and no approximation, which is what this is for. +fn attach_fidelity( + response: &mut Value, + report: &devup_mcp_devup_ui::provenance::FidelityReport, + include_diagnostics: bool, +) { + if include_diagnostics || !report.strict_compatible() { + response["fidelity"] = json!(report); + } +} + +fn attach_completeness_report( + response: &mut Value, + quality: OutputQuality, + report: &devup_mcp_figma::PayloadCompletenessReport, + include_diagnostics: bool, +) { + let clean = matches!( + quality.acquisition, + AcquisitionQuality::Complete | AcquisitionQuality::ExpectedProjection + ); + if include_diagnostics || !clean { + response["completenessReport"] = json!(report); + } +} + +fn unavailable_output(payload: &CollectedPayload, output: &str, reason: &str) -> DevupError { + DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!("{output} cannot be produced. {reason}"), + false, + json!({"output":output,"nodeId":payload.target.node_id,"snapshotRootIds":payload.snapshot.roots, + "collectedTargetIds":payload.snapshot.roots}), + ) +} + +fn projection_failure( + payload: &CollectedPayload, + node_id: &str, + output: &str, + error: &DevupError, +) -> Value { + let mut details = error.details.as_object().cloned().unwrap_or_default(); + details.insert("nodeId".into(), json!(node_id)); + details.insert("snapshotRootIds".into(), json!(payload.snapshot.roots)); + details.insert("collectedTargetIds".into(), json!(payload.snapshot.roots)); + json!({"nodeId":node_id,"output":output,"errorCode":error.code,"message":error.message, + "retryable":error.retryable,"details":details}) +} diff --git a/crates/devup-mcp/src/server/projection/exclude_hidden_asset_outputs.rs b/crates/devup-mcp/src/server/projection/exclude_hidden_asset_outputs.rs new file mode 100644 index 00000000..a66274c0 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/exclude_hidden_asset_outputs.rs @@ -0,0 +1,110 @@ +use super::*; + +pub(super) struct HiddenAssetOutputs<'a> { + pub(super) payload: &'a CollectedPayload, + pub(super) asset_names_per_node: bool, + pub(super) result: Map, + pub(super) failures: Vec, + pub(super) pending_text_outputs: BTreeMap, +} + +type FilteredAssetOutputs = + Result<(Map, Vec, BTreeMap), DevupError>; + +pub(super) fn exclude_hidden_asset_outputs(input: HiddenAssetOutputs<'_>) -> FilteredAssetOutputs { + let HiddenAssetOutputs { + payload, + asset_names_per_node, + mut result, + mut failures, + mut pending_text_outputs, + } = input; + // Reconcile every generated output, including reusable and responsive + // code, against exclusions before inline/resource/file delivery. + let mut assets = devup_mcp_figma::discover_asset_manifest(&payload.snapshot).assets; + for captured in &payload.assets { + if let Some(existing) = assets + .iter_mut() + .find(|asset| asset.asset_id == captured.asset_id) + { + *existing = captured.clone(); + } else { + assets.push(captured.clone()); + } + } + let reference_paths = |asset: &devup_mcp_figma::AssetManifestEntry| { + let generated = asset + .field + .strip_prefix("fills/") + .and_then(|i| i.parse::().ok()) + .and_then(|i| { + devup_mcp_devup_ui::codegen::image_fill_path( + &payload.snapshot, + &asset.node_id, + i, + asset_names_per_node, + ) + }) + .or_else(|| { + devup_mcp_devup_ui::codegen::asset_path( + &payload.snapshot, + &asset.node_id, + asset_names_per_node, + ) + }); + generated + .into_iter() + .chain(asset.path.clone()) + .flat_map(|path| [host_safe_asset_path(&path), path]) + .collect::>() + }; + let available_paths = assets + .iter() + .filter(|asset| asset.error_code.as_deref() != Some("DEVUP_ASSET_NODE_HIDDEN")) + .flat_map(&reference_paths) + .collect::>(); + for asset in assets + .iter() + .filter(|asset| asset.error_code.as_deref() == Some("DEVUP_ASSET_NODE_HIDDEN")) + { + let paths = reference_paths(asset) + .into_iter() + .filter(|path| !available_paths.contains(path)) + .collect::>(); + let references = |code: &Value| { + code.as_str() + .is_some_and(|code| paths.iter().any(|path| code.contains(path))) + }; + let source_node = payload.snapshot.nodes.get(&asset.node_id); + let exclusion_details = json!({"referencePaths":paths, + "assetNodeId":asset.node_id, + "exclusionReason":source_node.and_then(devup_mcp_figma::asset_exclusion_reason), + "visible":source_node.and_then(|n| n.typed_view().value("visible")), + "opacity":source_node.and_then(|n| n.typed_view().value("opacity")), + "absoluteRenderBounds":source_node.and_then(|n| n.typed_view().value("absoluteRenderBounds")), + "policyConflict":source_node.is_some_and(|n| devup_mcp_figma::asset_exclusion_reason(n).is_none())}); + for field in ["tsx", "componentTsx", "responsiveTsx"] { + if result.get(field).is_some_and(&references) { + result.remove(field); + pending_text_outputs.remove(field); + failures.push(json!({"nodeId":payload.target.node_id,"assetId":asset.asset_id,"output":field, + "errorCode":"DEVUP_EXCLUDED_ASSET_REFERENCED","stage":"projection","details":exclusion_details, + "message":"Generated code references an excluded asset; this output was withheld."})); + } + if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { + for frame in frames { + if frame.get(field).is_some_and(&references) { + frame.as_object_mut().unwrap().remove(field); + frame["status"] = json!("partial"); + frame["quality"]["projection"] = json!("lossy"); + frame["quality"]["assets"] = json!("partial"); + failures.push(json!({"nodeId":frame["nodeId"],"assetId":asset.asset_id,"output":field, + "errorCode":"DEVUP_EXCLUDED_ASSET_REFERENCED","stage":"projection","details":exclusion_details, + "message":"Generated code references an excluded asset; this output was withheld."})); + } + } + } + } + } + Ok((result, failures, pending_text_outputs)) +} diff --git a/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs b/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs new file mode 100644 index 00000000..91d75764 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/finalize_export_delivery.rs @@ -0,0 +1,505 @@ +use super::*; + +pub(super) struct ExportDelivery<'a> { + pub(super) payload: &'a CollectedPayload, + pub(super) artifact: &'a ArtifactLookup, + pub(super) artifact_store: &'a ArtifactStore, + pub(super) output_policy: &'a OutputPolicy, + pub(super) outputs: Vec, + pub(super) asset_names_per_node: bool, + pub(super) strict: bool, + pub(super) output_paths: BTreeMap, + pub(super) page_scaffold: Option, + pub(super) asset_captures: Vec, + pub(super) web_ready_images: bool, + pub(super) asset_output_paths: BTreeMap, + pub(super) asset_public_root: Option, + pub(super) response_selection: super::super::compact::ResponseSelection, + pub(super) delivery: DeliveryMode, + pub(super) result: Map, + pub(super) recovery_arguments: Value, + pub(super) selection_index: Option, + pub(super) completeness_report: devup_mcp_figma::PayloadCompletenessReport, + pub(super) scaffold_requested: bool, + pub(super) manifest_requested: bool, + pub(super) keep_tsx: bool, + pub(super) node_summaries: Option, + pub(super) written_paths: Map, + pub(super) pending_text_outputs: BTreeMap, + pub(super) pending_binary_outputs: BTreeMap>, + pub(super) pending_asset_manifest: Option, + pub(super) asset_resource_outputs: Vec, + pub(super) responsive_widths: Vec, +} + +pub(super) async fn finalize_export_delivery( + input: ExportDelivery<'_>, +) -> Result { + let ExportDelivery { + payload, + artifact, + artifact_store, + output_policy, + outputs, + asset_names_per_node, + strict, + output_paths, + page_scaffold, + asset_captures, + web_ready_images, + mut asset_output_paths, + mut asset_public_root, + response_selection, + delivery, + mut result, + recovery_arguments, + selection_index, + completeness_report, + scaffold_requested, + manifest_requested, + keep_tsx, + node_summaries, + mut written_paths, + mut pending_text_outputs, + pending_binary_outputs, + mut pending_asset_manifest, + mut asset_resource_outputs, + responsive_widths, + } = input; + if scaffold_requested + && let Some(refusal) = + page_scaffold::directory_refusal(&result, &output_paths, output_policy)? + { + return Ok(refusal); + } + if scaffold_requested && let Some(manifest) = &pending_asset_manifest { + page_scaffold::prepare_assets( + &result, + &output_paths, + manifest, + &mut asset_output_paths, + &mut asset_public_root, + output_policy, + )?; + } + let replacements = if let Some(manifest) = &mut pending_asset_manifest { + reconcile_asset_paths( + manifest, + &mut asset_output_paths, + asset_public_root.as_deref(), + output_policy, + )? + } else { + BTreeMap::new() + }; + let owners = if let Some(index) = &selection_index { + index.node_screen_ids.clone() + } else { + devup_mcp_figma::screen_owners( + &payload.snapshot, + payload.snapshot.roots.iter().map(String::as_str), + ) + }; + // Share definitions only for labels present in these outputs. The + // full label catalog remains documented and standalone maps carry it. + if let Some(frames) = result.get("frames").and_then(Value::as_array) + && frames.iter().any(|frame| frame.get("sourceMap").is_some()) + { + let labels = frames + .iter() + .flat_map(|f| { + f["outputResults"] + .as_object() + .into_iter() + .flat_map(|o| o.values()) + }) + .flat_map(|o| o["_sourceMap"].as_array().into_iter().flatten()) + .filter_map(|e| e["resolution"].as_str()) + .collect::>(); + let mut semantics = devup_mcp_devup_ui::provenance::resolution_semantics(); + semantics + .as_object_mut() + .unwrap() + .remove("verificationAxis"); + semantics["relation"] = + json!("Same node/property/screen/output: ABSOLUTE components.*.state is independent."); + semantics["values"] + .as_object_mut() + .unwrap() + .retain(|label, _| labels.contains(label.as_str())); + result.insert("resolutionSemantics".into(), semantics); + } + if let Some(manifest) = &mut pending_asset_manifest { + for asset in &mut manifest.assets { + if let Some(path) = &asset.path { + for variant in &mut asset.variants { + variant.path = Some(asset_policy::variant_path(path)); + } + } + } + let css_map = + asset_policy::css_map(&payload.snapshot, &manifest.assets, asset_names_per_node); + for field in ["imageAudit", "imageFillAudit"] { + if let Some(audit) = result.get_mut(field) { + asset_policy::enrich_audit(audit, &css_map); + } + } + if web_ready_images || outputs.iter().any(|o| o == "assetCssMap") { + result.insert("assetCssMap".into(), css_map.clone()); + } + for field in ["imageAudit", "assetCssMap"] { + if output_paths.contains_key(field) + && let Some(body) = result.get(field) + { + pending_text_outputs.insert( + field.into(), + serde_json::to_string_pretty(body).unwrap_or_default(), + ); + } + } + } + let mut value = Value::Object(std::mem::take(&mut result)); + rewrite_result_asset_references(&mut value, &replacements); + audit_delivered_properties(&mut value); + super::super::interactions::attach(&mut value, &payload.snapshot); + attach_diagnostic_screens(&mut value, &owners); + super::super::verdict_scope::attach(&mut value); + attach_verification_scope(&mut value, &responsive_widths); + if strict && value["mappingComplete"] == false { + return Err(DevupError::with_details( + ErrorCode::DevupSnapshotUnsupported, + "strict export requires complete generated-property provenance.", + false, + json!({"quality":value["quality"],"projectionIssues":value["projectionIssues"]}), + )); + } + result = value.as_object().expect("result object").clone(); + if outputs.iter().any(|o| o == "outline") { + let outline = + super::super::compact::outline(node_summaries.as_ref().unwrap(), &response_selection); + if output_paths.contains_key("outline") { + pending_text_outputs.insert( + "outline".into(), + serde_json::to_string_pretty(&outline).unwrap(), + ); + } + result.insert("outline".into(), outline); + } + for (name, contents) in &mut pending_text_outputs { + if matches!( + name.as_str(), + "tsx" | "componentTsx" | "responsiveTsx" | "pageScaffold" + ) { + if let Some(code) = result.get(name).and_then(Value::as_str) { + *contents = code.to_owned(); + } + } else if name == "sourceMap" { + *contents = serde_json::to_string_pretty(&result["sourceMap"]).unwrap_or_default(); + } else if matches!(name.as_str(), "rawSnapshot" | "rawPayload") { + *contents = serde_json::to_string_pretty(&result[name]).unwrap_or_default(); + } + } + let mut planned_outputs = Vec::new(); + let mut supported_keys = Vec::new(); + if let Some(options) = &page_scaffold { + let (key, planned) = page_scaffold::attach( + &mut result, + options, + &output_paths, + pending_asset_manifest.as_ref(), + &asset_output_paths, + output_policy, + keep_tsx, + )?; + supported_keys.push(key); + planned_outputs.extend(planned); + } + for field in [ + "tsx", + "componentTsx", + "responsiveTsx", + "devupJson", + "rawSnapshot", + "rawPayload", + "sourceMap", + "designFingerprints", + "referencePng", + "imageAudit", + "assetCssMap", + "outline", + ] { + if result.contains_key(field) { + supported_keys.push(field.to_owned()); + } + } + if let Some(frames) = result.get("frames").and_then(Value::as_array) { + for frame in frames { + for field in ["tsx", "componentTsx", "sourceMap"] { + let Some(contents) = frame.get(field) else { + continue; + }; + let key = format!("frame:{}:{field}", frame["nodeId"].as_str().unwrap()); + supported_keys.push(key.clone()); + if let Some(path) = output_paths.get(&key) { + let bytes = if let Some(code) = contents.as_str() { + code.as_bytes().to_vec() + } else { + serde_json::to_vec_pretty(contents).unwrap() + }; + planned_outputs.push((key, output_policy.resolve(path)?, bytes)); + } + } + } + } + let path_diagnostics: Vec<_> = output_paths.keys().filter(|key| !supported_keys.contains(key)).map(|key| { + json!({"code":"DEVUP_OUTPUT_PATH_UNSUPPORTED","key":key,"written":false, + "message":"This key has no writable output in this projection. Use a supportedKeys entry; frame outputs require frame::."}) +}).collect(); + result.insert("outputPathResults".into(), json!({"supportedKeys":supported_keys, + "frameKeyFormat":if scaffold_requested { "frame::" } else { "frame::" }, + "diagnostics":path_diagnostics, + "note":"outputPaths reports committed writes only. Unsupported or unavailable keys do not write files; assetRequests use their own outputPath."})); + if scaffold_requested { + let kinds: Map = supported_keys + .iter() + .filter(|key| key.ends_with("pageScaffold")) + .map(|key| (key.clone(), json!("directory"))) + .collect(); + result.get_mut("outputPathResults").unwrap()["pathKinds"] = json!(kinds); + } + for (output, contents) in pending_text_outputs { + if let Some(path) = output_paths.get(&output) { + planned_outputs.push((output, output_policy.resolve(path)?, contents.into_bytes())); + } + } + for (output, bytes) in pending_binary_outputs { + if let Some(path) = output_paths.get(&output) { + planned_outputs.push((output, output_policy.resolve(path)?, bytes)); + } + } + if let Some(manifest) = pending_asset_manifest.as_ref() { + for asset in &manifest.assets { + if asset.status != AssetStatus::Exported { + continue; + } + let Some(path) = asset_output_paths.get(&asset.asset_id) else { + continue; + }; + let data = asset.data_base64.as_deref().ok_or_else(|| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "The exported asset binary is not in the artifact.", + false, + ) + })?; + let bytes = STANDARD.decode(data.as_bytes()).map_err(|_| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "The exported asset binary base64 is invalid.", + false, + ) + })?; + planned_outputs.push(( + format!("asset:{}", asset.asset_id), + output_policy.resolve(path)?, + bytes, + )); + for variant in &asset.variants { + let variant_path = asset_policy::variant_path(path); + let bytes = STANDARD + .decode(variant.data_base64.as_deref().unwrap_or_default()) + .map_err(|_| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "Invalid variant base64.", + false, + ) + })?; + planned_outputs.push(( + format!("asset:{}@2x", asset.asset_id), + output_policy.resolve(&variant_path)?, + bytes, + )); + } + } + } + if page_scaffold.as_ref().is_some_and(|options| !options.write) { + planned_outputs.clear(); + } + let mut transaction = OutputTransaction::new(); + if page_scaffold + .as_ref() + .is_some_and(|options| !options.overwrite) + { + transaction.refuse_existing(); + } + // Two nodes can be one picture - a logo drawn at three sizes + // shares a file, as the plugin has it - so several assets resolve + // to one path. That is one write, not a collision. Only differing + // bytes under one name are a mistake, and that is worth refusing. + // Two different drawings under one layer name land here too: the + // plugin names an asset after its layer, and a snapshot cannot + // tell two drawings of one name apart - only the exported bytes + // can. The first is written and the rest are reported, so the + // code still points at a file that exists and the caller learns + // which names hide more than one picture. + let fingerprint_path = planned_outputs + .iter() + .find(|(name, _, _)| name == "designFingerprints") + .map(|(_, target, _)| target.display_path().to_path_buf()); + let mut staged_content: BTreeMap = BTreeMap::new(); + let mut shared_names: BTreeMap> = BTreeMap::new(); + for (name, target, bytes) in planned_outputs { + let path = target.display_path().to_string_lossy().into_owned(); + let fingerprint = sha256_hex(&bytes); + match staged_content.get(&path) { + Some(staged) if *staged == fingerprint => { + written_paths.insert(name, json!(path)); + continue; + } + Some(_) + if scaffold_requested + || fingerprint_path.as_deref() == Some(target.display_path()) => + { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + format!("Output file collision: different outputs claim {path}."), + false, + )); + } + Some(_) => { + let asset = name.strip_prefix("asset:").unwrap_or(&name).to_owned(); + shared_names.entry(path).or_default().push(asset); + continue; + } + None => { + staged_content.insert(path.clone(), fingerprint); + } + } + written_paths.insert(name.clone(), json!(path)); + transaction.stage(name, target, &bytes)?; + } + if manifest_requested && let Some(mut manifest) = pending_asset_manifest { + asset_resource_outputs = projected_asset_outputs(&manifest)?; + for asset in &mut manifest.assets { + if asset.status != AssetStatus::Exported { + continue; + } + let output_name = format!("asset:{}", asset.asset_id); + let Some(path) = written_paths.get(&output_name).and_then(Value::as_str) else { + continue; + }; + asset.output_path = Some(path.to_owned()); + asset.data_base64 = None; + for variant in &mut asset.variants { + if let Some(path) = written_paths + .get(&format!("asset:{}@2x", asset.asset_id)) + .and_then(Value::as_str) + { + variant.output_path = Some(path.into()); + variant.data_base64 = None; + } + } + } + for (path, others) in &shared_names { + manifest.diagnostics.push(Diagnostic { + code: "DEVUP_ASSET_NAME_SHARED".to_owned(), + message: format!( + include_str!("../text/projection-project_operation_Diagnostic-6ab28a84.md"), + others.len(), + path = path + ), + severity: Some(DiagnosticSeverity::Warning), + resource_kind: Some("asset".to_owned()), + details: Some(json!({ "outputPath": path, "notWritten": others })), + ..Diagnostic::default() + }); + } + let mut manifest = json!(manifest); + if let Some(assets) = manifest["assets"].as_array_mut() { + for asset in assets { + let name = asset["nodeId"] + .as_str() + .and_then(|id| payload.snapshot.nodes.get(id)) + .and_then(|node| node.typed_view().name()); + if let Some(name) = name { + asset["layerName"] = json!(name); + } + } + } + result.insert("assetManifest".to_owned(), manifest); + } + if web_ready_images { + let captures = result["assetManifest"]["assets"].as_array().into_iter().flatten() + .filter(|a| asset_captures.iter().any(|c| c.asset_id == a["assetId"].as_str().unwrap_or_default())) + .map(|a| json!({"assetId":a["assetId"],"nodeId":a["nodeId"],"format":a["format"],"scale":a["scale"], + "quality":a["quality"],"lossless":a["lossless"],"renderMode":a["renderMode"],"status":a["status"], + "role":if a["sourceKind"] == "image-fill" {"descendant-image-fill"}else{"generated-code-native-raster-boundary"}, + "sha256":a["sha256"],"byteLength":a["byteLength"],"path":a["path"],"outputPath":a["outputPath"],"errorCode":a["errorCode"]})).collect::>(); + let failed = captures + .iter() + .filter(|a| a["status"] != "exported") + .count(); + result.insert("webReadyImages".into(), json!({"preset":"web-ready-images","version":1,"maxAssetsPerBatch":6, + "total":asset_captures.len(),"exported":captures.len()-failed,"failed":failed, + "complete":failed == 0 && captures.len() == asset_captures.len() && payload.failures.is_empty() && completeness_report.snapshot.state == devup_mcp_figma::CompletenessState::Complete, + "collectionCompleteness":completeness_report.snapshot.state, + "coverage":"all acquired in-scope IMAGE paints including hidden/failed dispositions; consult collectionCompleteness for acquisition gaps","defaultRenderMode":"original-source","quality":85,"requestedScale":2,"effectsAppliedBy":"css","cssMap":"asset-css-map.json", + "plan":captures})); + } + if scaffold_requested { + page_scaffold::committed_paths(&result, &mut written_paths); + } + result.insert("outputPaths".to_owned(), Value::Object(written_paths)); + for value in result.values_mut() { + attach_diagnostic_screens(value, &owners); + } + let outline_only = !response_selection.active() && outputs.iter().all(|o| o == "outline"); + let compact_details = if let Some(summary) = &node_summaries { + let selected = if outline_only { + super::super::compact::outline_response(&mut result, summary) + } else { + super::super::compact::select(&mut result, summary, &response_selection) + }; + selected.map_err(|mut error| { + let mut recovery=recovery_arguments.clone(); + recovery.as_object_mut().unwrap().remove("path"); + recovery.as_object_mut().unwrap().remove("responseFields"); + if outputs.iter().all(|o|o=="outline") { recovery["responseLimit"]=json!(response_selection.response_limit.min(25)); } + error.details=json!({"artifactId":artifact.artifact_id,"maxSelectedBytes":65536, + "nextAction":{"tool":"devup_figma_export","arguments":recovery, + "how":"Read original outputs as bounded resources, or select a narrower path or lower responseLimit against this same artifact. Scalar values are never silently truncated."}}); + error + })? + } else { + None + }; + let projected_outputs = projected_outputs_from_result(&result)?; + let mut projected_outputs = projected_outputs; + projected_outputs.extend(asset_resource_outputs); + projected_outputs.extend(compact_details); + let mut result = Value::Object(result); + let attachment = apply_delivery( + &mut result, + if response_selection.active() || outline_only { + DeliveryMode::Resource + } else { + delivery + }, + artifact_store, + artifact, + projected_outputs, + Some(&recovery_arguments), + ) + .await + .map_err(|error| super::super::delivery::with_inline_recovery(error, recovery_arguments))?; + if let Err(error) = transaction.commit() { + rollback_delivery(artifact_store, artifact, attachment).await; + return Err(error); + } + commit_delivery(attachment); + if let Some(path) = result["outputPaths"].get("designFingerprints").cloned() { + result["outputPathResults"]["committed"] = json!({"designFingerprints":path}); + } + Ok(result) +} diff --git a/crates/devup-mcp/src/server/projection/frame_identity.rs b/crates/devup-mcp/src/server/projection/frame_identity.rs new file mode 100644 index 00000000..efffcda4 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/frame_identity.rs @@ -0,0 +1,115 @@ +/// Compare the generated text, removing only known asset references in codegen's +/// src and CSS URL forms. Literal text, layout, imports and component names stay +/// significant. Separate segments avoid collisions with a placeholder string. +fn generated_structure(code: &str, paths: &[String]) -> Vec { + let mut ranges = Vec::new(); + for attribute in ["src", "bg", "backgroundImage", "maskImage"] { + let opening = format!("{attribute}=\""); + for (at, _) in code.match_indices(&opening) { + // An attribute name must start at a word boundary, not inside an + // unrelated prop. Codegen quotes literal text containing quotes. + if !code[..at] + .chars() + .next_back() + .is_some_and(char::is_whitespace) + { + continue; + } + let start = at + opening.len(); + let Some(end) = code[start..].find('"') else { + continue; + }; + let value = &code[start..start + end]; + for path in paths { + if attribute == "src" { + if value == path { + ranges.push((start, start + path.len())); + } + continue; + } + // Preserve all paint layers and positions while recognizing + // every image, including URLs after gradients or other images. + for (prefix, suffix) in [("url(", ")"), ("url('", "')")] { + let reference = format!("{prefix}{path}{suffix}"); + for (offset, _) in value.match_indices(&reference) { + let asset_start = start + offset + prefix.len(); + ranges.push((asset_start, asset_start + path.len())); + } + } + } + } + } + ranges.sort_unstable(); + ranges.dedup(); + let mut segments = Vec::new(); + let mut previous = 0; + for (start, end) in ranges { + if start < previous { + continue; + } + segments.push(code[previous..start].to_owned()); + previous = end; + } + segments.push(code[previous..].to_owned()); + segments +} + +fn annotate_identical_frames(frames: &mut [Value], payload: &CollectedPayload, per_node: bool) { + let paths = devup_mcp_figma::discover_asset_manifest(&payload.snapshot) + .assets + .iter() + .filter_map(|asset| { + match asset + .field + .strip_prefix("fills/") + .and_then(|n| n.parse::().ok()) + { + Some(index) => devup_mcp_devup_ui::codegen::image_fill_path( + &payload.snapshot, + &asset.node_id, + index, + per_node, + ), + None => devup_mcp_devup_ui::codegen::asset_path( + &payload.snapshot, + &asset.node_id, + per_node, + ), + } + }) + .map(|path| host_safe_asset_path(&path)) + .collect::>(); + let mut seen = BTreeMap::new(); + for frame in frames { + // Fidelity warnings may make a frame partial without changing the + // generated text. Compare exactly the outputs that exist; the key and + // comparedOutputs prevent missing outputs from implying equivalence. + let generated = ["tsx", "componentTsx"] + .into_iter() + .filter_map(|field| frame[field].as_str().map(|code| (field, code.to_owned()))) + .collect::>(); + if generated.is_empty() { + continue; + } + let key = generated + .iter() + .map(|(field, code)| (*field, generated_structure(code, &paths))) + .collect::>(); + if let Some((node_id, original)) = seen.get(&key) { + frame["structurallyIdenticalTo"] = json!(node_id); + frame["differsOnly"] = if original == &generated { + json!([]) + } else { + json!(["assetReferences"]) + }; + frame["comparedOutputs"] = json!( + generated + .iter() + .map(|(field, _)| *field) + .collect::>() + ); + } else { + seen.insert(key, (frame["nodeId"].clone(), generated)); + } + } +} diff --git a/crates/devup-mcp/src/server/projection/image_fill_audit.rs b/crates/devup-mcp/src/server/projection/image_fill_audit.rs new file mode 100644 index 00000000..a19e545d --- /dev/null +++ b/crates/devup-mcp/src/server/projection/image_fill_audit.rs @@ -0,0 +1,41 @@ +use super::*; + +pub(super) fn attach_image_fill_audit( + result: &mut Map, + payload: &CollectedPayload, + completeness_report: &devup_mcp_figma::PayloadCompletenessReport, + outputs: &[String], + asset_names_per_node: bool, +) { + result.insert( + "imageFillAudit".into(), + devup_mcp_figma::image_fill_audit(&payload.snapshot, &payload.assets), + ); + let mut audit_assets = devup_mcp_figma::discover_image_fill_assets(&payload.snapshot); + for captured in &payload.assets { + if let Some(existing) = audit_assets + .iter_mut() + .find(|a| a.asset_id == captured.asset_id) + { + *existing = captured.clone(); + } + } + let audit_css = asset_policy::css_map(&payload.snapshot, &audit_assets, asset_names_per_node); + asset_policy::enrich_audit(result.get_mut("imageFillAudit").unwrap(), &audit_css); + result.get_mut("imageFillAudit").unwrap()["collectionCompleteness"] = json!({"state":completeness_report.state,"snapshotState":completeness_report.snapshot.state,"resourcesState":completeness_report.resources.state,"unresolvedResources":completeness_report.resources.unresolved_count,"failedScreens":payload.failures.len()}); + result.get_mut("imageFillAudit").unwrap()["resourceCompleteness"] = json!(payload.completeness); + if payload.metadata.get("sectionIndex").is_some() + && payload.metadata.get("selectedRootIds").is_none() + { + let audit = result.get_mut("imageFillAudit").unwrap(); + audit["scope"] = json!("selection-index-only; descendant image paints not acquired"); + audit["completeness"] = json!("selection-required"); + audit["inScopeImageFills"] = Value::Null; + audit["summary"] = json!( + "Section index only: select frameIds to acquire and audit descendant image fills." + ); + } + if outputs.iter().any(|o| o == "imageAudit") { + result.insert("imageAudit".into(), result["imageFillAudit"].clone()); + } +} diff --git a/crates/devup-mcp/src/server/projection/prepare_asset_manifest.rs b/crates/devup-mcp/src/server/projection/prepare_asset_manifest.rs new file mode 100644 index 00000000..7456f526 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/prepare_asset_manifest.rs @@ -0,0 +1,141 @@ +use super::*; + +pub(super) struct AssetManifestPreparation<'a> { + pub(super) payload: &'a CollectedPayload, + pub(super) outputs: &'a Vec, + pub(super) asset_names_per_node: bool, + pub(super) asset_captures: &'a Vec, + pub(super) web_ready_images: bool, + pub(super) warnings: Vec, + pub(super) scaffold_requested: bool, + pub(super) manifest_requested: bool, + pub(super) pending_asset_manifest: Option, +} + +type PreparedAssetManifest = Result<(Option, Vec), DevupError>; + +pub(super) fn prepare_asset_manifest(input: AssetManifestPreparation<'_>) -> PreparedAssetManifest { + let AssetManifestPreparation { + payload, + outputs, + asset_names_per_node, + asset_captures, + web_ready_images, + mut warnings, + scaffold_requested, + manifest_requested, + mut pending_asset_manifest, + } = input; + let reconcile_captured_assets = payload + .assets + .iter() + .any(|asset| asset.status == AssetStatus::Exported) + && outputs.iter().any(|output| { + matches!( + output.as_str(), + "tsx" | "componentTsx" | "responsiveTsx" | "pageScaffold" + ) + }); + if manifest_requested + || reconcile_captured_assets + || scaffold_requested + || outputs.iter().any(|o| o == "assetCssMap") + { + let mut manifest = + asset_policy::discovery(&payload.snapshot, &payload.assets, web_ready_images); + for exported in &payload.assets { + if let Some(existing) = manifest + .assets + .iter_mut() + .find(|asset| asset.asset_id == exported.asset_id) + { + *existing = exported.clone(); + } else { + manifest.assets.push(exported.clone()); + } + } + devup_mcp_figma::annotate_assets(&payload.snapshot, &mut manifest.assets); + manifest.assets.retain(|asset| { + if web_ready_images { + return true; + } + if asset.error_code.as_deref() != Some("DEVUP_ASSET_NODE_HIDDEN") { + return true; + } + if !warnings + .iter() + .any(|warning| warning["assetId"] == asset.asset_id) + { + warnings.push(json!({"assetId":asset.asset_id,"nodeId":asset.node_id, + "errorCode":asset.error_code,"severity":"warning", + "message":"Hidden asset excluded from deliverables."})); + } + false + }); + manifest + .assets + .sort_by(|left, right| left.asset_id.cmp(&right.asset_id)); + // Where the generated code refers to each asset, so a + // consumer can write the bytes there without re-deriving the + // name - which, for two icons of one layer name, it would get + // wrong. + for asset in &mut manifest.assets { + if asset.status == AssetStatus::Failed { + asset.path = None; + asset.output_path = None; + continue; + } + if asset.path.is_none() { + asset.path = + asset_policy::canonical_path(&payload.snapshot, asset, asset_names_per_node); + } + } + // Where the bytes will be written, if a layer name is one a + // file system refuses. The code that points at them is + // renamed the same way once every output is assembled. + for asset in &mut manifest.assets { + if let Some(path) = asset.path.as_deref() { + let safe = host_safe_asset_path(path); + if safe != path { + asset.path = Some(safe); + } + } + } + for capture in asset_captures { + if !payload.assets.iter().any(|asset| { + asset.asset_id == capture.asset_id + && asset.format == Some(capture.format) + && asset.scale == Some(capture.scale) + && asset.render_mode == capture.render_mode + && asset.quality == capture.quality + && asset.lossless == capture.lossless + && asset.usage_aspect_ratio == capture.usage_aspect_ratio + && asset.max_width == capture.max_width + && asset.srcset == capture.srcset + && capture + .visible_scope_node_id + .as_ref() + .is_none_or(|id| asset.visible_scope_node_id.as_ref() == Some(id)) + && matches!(asset.status, AssetStatus::Exported | AssetStatus::Failed) + }) { + return Err(DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + format!( + "The exact requested asset export is not in the artifact. Re-collect it from the URL: {}", + capture.asset_id + ), + false, + )); + } + } + manifest.diagnostics = payload + .snapshot + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.resource_kind.as_deref() == Some("asset")) + .cloned() + .collect(); + pending_asset_manifest = Some(manifest); + } + Ok((pending_asset_manifest, warnings)) +} diff --git a/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs b/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs new file mode 100644 index 00000000..19f0f655 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs @@ -0,0 +1,257 @@ +use super::*; + +pub(super) struct NodeAndThemeOutputs<'a> { + pub(super) payload: &'a CollectedPayload, + pub(super) outputs: &'a Vec, + pub(super) component_name: Option, + pub(super) include_diagnostics: bool, + pub(super) root_layout: devup_mcp_devup_ui::codegen::RootLayout, + pub(super) asset_names_per_node: bool, + pub(super) scope: &'a String, + pub(super) theme_options: &'a theme_export::ThemeExportOptions, + pub(super) strict: bool, + pub(super) output_paths: &'a BTreeMap, + pub(super) width_policy: &'a Option, + pub(super) result: Map, + pub(super) failures: &'a mut Vec, + pub(super) warnings: Vec, + pub(super) asset_policies: + &'a BTreeMap, + pub(super) scaffold_requested: bool, + pub(super) keep_tsx: bool, + pub(super) section_tsx_projected: bool, + pub(super) tsx_source_map: Option, + pub(super) generated_output_target: &'static str, + pub(super) devup_json_source_map: Option, + pub(super) projection_diagnostics: Vec, + pub(super) fidelity_reports: Vec, + pub(super) theme_conflict_count: usize, + pub(super) theme_unresolved_count: usize, + pub(super) pending_text_outputs: BTreeMap, + pub(super) component_name_for_components: &'a Option, +} + +type NodeAndThemeProjection = Result< + ( + Map, + Vec, + Vec, + BTreeMap, + Option, + &'static str, + Option, + usize, + usize, + Vec, + ), + DevupError, +>; + +pub(super) fn project_node_and_theme_outputs( + input: NodeAndThemeOutputs<'_>, +) -> NodeAndThemeProjection { + let NodeAndThemeOutputs { + payload, + outputs, + component_name, + include_diagnostics, + root_layout, + asset_names_per_node, + scope, + theme_options, + strict, + output_paths, + width_policy, + mut result, + failures, + mut warnings, + asset_policies, + scaffold_requested, + keep_tsx, + section_tsx_projected, + mut tsx_source_map, + mut generated_output_target, + mut devup_json_source_map, + mut projection_diagnostics, + mut fidelity_reports, + mut theme_conflict_count, + mut theme_unresolved_count, + mut pending_text_outputs, + component_name_for_components, + } = input; + if (keep_tsx || scaffold_requested) && !section_tsx_projected { + let node_id = payload.target.node_id.as_deref().ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaNodeNotFound, + "A TSX export payload requires a node ID.", + false, + ) + })?; + let mut output = generate_component( + &payload.snapshot, + node_id, + &CodegenOptions { + asset_policies: asset_policies.clone(), + component_name, + include_diagnostics, + inline_instances: true, + root_layout, + asset_names_per_node, + width_policy: width_policy.clone(), + ..CodegenOptions::default() + } + .with_payload_tokens(payload), + )?; + scope_output(&mut output, "tsx"); + result.entry("outputResults").or_insert_with(|| json!({}))["tsx"] = output_result(&output); + result.insert( + "widthBehavior".to_owned(), + devup_mcp_devup_ui::codegen::width::width_behavior(&payload.snapshot, node_id, &output), + ); + projection_diagnostics.extend(output.diagnostics.iter().cloned()); + fidelity_reports.push(output.fidelity_report.clone()); + generated_output_target = "tsx"; + if let Some(root) = theme_options.project_root.as_deref() + && super::super::screen_plan::node_ids_enabled(root) + { + let annotated = annotate_node_ids(&mut output, &payload.snapshot, node_id); + result.insert("figmaNodeIds".to_owned(), annotated); + } + tsx_source_map = Some(output.source_map.clone()); + if keep_tsx && output_paths.contains_key("tsx") { + pending_text_outputs.insert("tsx".to_owned(), output.tsx.clone()); + } + result.insert("tsx".to_owned(), json!(output.tsx)); + if include_diagnostics { + result.insert( + "diagnostics".to_owned(), + json!( + output + .diagnostics + .iter() + .filter(|d| d.code != "DEVUP_CODEGEN_PROPERTY_EVIDENCE") + .collect::>() + ), + ); + } + } + + if outputs.iter().any(|output| output == "componentTsx") && !section_tsx_projected { + 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_collected_component( + payload, + node_id, + &CodegenOptions { + asset_policies: asset_policies.clone(), + component_name: component_name_for_components.clone(), + include_diagnostics: false, + inline_instances: false, + root_layout, + asset_names_per_node, + width_policy: width_policy.clone(), + ..CodegenOptions::default() + } + .with_payload_tokens(payload), + ); + match output { + Ok(mut output) => { + scope_output(&mut output, "componentTsx"); + result.entry("outputResults").or_insert_with(|| json!({}))["componentTsx"] = + output_result(&output); + projection_diagnostics.extend(output.diagnostics.iter().cloned()); + fidelity_reports.push(output.fidelity_report.clone()); + if tsx_source_map.is_none() { + generated_output_target = "componentTsx"; + tsx_source_map = Some(output.source_map.clone()); + } + if output_paths.contains_key("componentTsx") { + pending_text_outputs.insert("componentTsx".to_owned(), output.tsx.clone()); + } + result.insert("componentTsx".to_owned(), json!(output.tsx)); + } + Err(error) => { + failures.push(projection_failure(payload, node_id, "componentTsx", &error)) + } + } + } + + if outputs.iter().any(|output| output == "devupJson") { + let variables = payload.variables.as_ref().ok_or_else(|| unavailable_output(payload, "devupJson", + "No variable/style resources were collected. Re-collect from the URL with devupJson in outputs and the desired scope."))?; + let variables = variable_snapshot_from_result(variables)?; + let mut output = + theme_export::generate(&variables, parse_scope(scope)?, theme_options, &result)?; + theme_export::prepare(&mut output, theme_options, &mut result, payload, strict)?; + theme_conflict_count = output.conflicts.len(); + theme_unresolved_count = output.unresolved_variables.len() + output.skipped_styles.len(); + theme_unresolved_count += result + .get("unresolvedUsedTokens") + .and_then(Value::as_array) + .map_or(0, Vec::len); + if let Some(diff) = result.get("themeDiff") { + let conflicts = diff["existingTypographyConflicts"].as_array(); + theme_conflict_count += conflicts.map_or(0, Vec::len); + for conflict in conflicts.into_iter().flatten() { + warnings.push(json!({"code":"DEVUP_THEME_EXISTING_TYPOGRAPHY_CONFLICT", + "message":format!("Existing typography token '{}' differs from Figma; the project's value is preserved and visual fidelity needs review.", conflict["token"].as_str().unwrap_or_default()), + "details":conflict,"severity":"warning"})); + } + } + warnings.extend( + output + .diagnostics + .iter() + .filter(|d| d.code == "DEVUP_THEME_STYLE_SKIPPED") + .map(|d| json!(d)), + ); + projection_diagnostics.extend( + output + .diagnostics + .iter() + .filter(|d| d.code == "DEVUP_THEME_STYLE_SKIPPED") + .cloned(), + ); + devup_json_source_map = Some(output.source_map.clone()); + if output_paths.contains_key("devupJson") { + pending_text_outputs.insert("devupJson".to_owned(), output.json.clone()); + } + result.insert("devupJson".to_owned(), json!(output.json)); + result.insert("themeCounts".to_owned(), json!(output.counts)); + result.insert("themeCompleteness".to_owned(), json!(output.completeness)); + result.insert("conflicts".to_owned(), json!(output.conflicts)); + result.insert( + "unresolvedVariables".to_owned(), + json!(output.unresolved_variables), + ); + if include_diagnostics && !result.contains_key("diagnostics") { + result.insert( + "diagnostics".to_owned(), + json!( + output + .diagnostics + .iter() + .filter(|d| d.code != "DEVUP_CODEGEN_PROPERTY_EVIDENCE") + .collect::>() + ), + ); + } + } + Ok(( + result, + projection_diagnostics, + fidelity_reports, + pending_text_outputs, + tsx_source_map, + generated_output_target, + devup_json_source_map, + theme_conflict_count, + theme_unresolved_count, + warnings, + )) +} diff --git a/crates/devup-mcp/src/server/projection/project_operation.rs b/crates/devup-mcp/src/server/projection/project_operation.rs new file mode 100644 index 00000000..e07cfbc8 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/project_operation.rs @@ -0,0 +1,823 @@ +async fn project_operation( + mut operation: PendingOperation, + payload: &CollectedPayload, + source_kind: &str, + artifact: &ArtifactLookup, + output_policy: &OutputPolicy, + artifact_store: &ArtifactStore, +) -> Result { + let collection = if artifact.cache_hit { + CollectionStats::default() + } else { + payload.stats.clone() + }; + let completeness_report = payload.completeness_report(); + if source_kind == "artifact" + && let PendingOperation::Export { + frame_ids, + all_screens, + .. + } = &mut operation + && frame_ids.is_empty() + && !*all_screens + && let Some(saved) = &artifact.capabilities.section_selection + { + frame_ids.clone_from(&saved.frame_ids); + *all_screens = saved.all_screens; + } + match operation { + PendingOperation::Search { + query, + node_types, + match_kind, + limit, + } => { + let matches = search_snapshot( + &payload.snapshot, + &payload.target, + &SearchOptions { + query: query.clone(), + node_types, + match_kind, + limit, + }, + )?; + let quality = OutputQuality { + acquisition: acquisition_quality(&completeness_report, true), + projection: projection_quality(false, &[]), + theme: theme_quality(false, 0, 0), + assets: assets_quality(false, &[], &[]), + }; + let mut response = json!({ + "status": quality.status(), + "quality": quality, + "query": query, + "count": matches.len(), + "matches": matches, + "completeness": payload.completeness, + "collection": collection, + "cache": artifact_metadata(artifact), + "source": { + "kind": source_kind, + "fileKey": payload.target.file_key, + "version": payload.snapshot.version + } + }); + attach_completeness_report(&mut response, quality, &completeness_report, false); + Ok(response) + } + PendingOperation::Explore { limit, target } => { + let result = explore_snapshot(&payload.snapshot, &target, &ExploreOptions { limit })?; + let count = result.candidates.len(); + let quality = OutputQuality { + acquisition: acquisition_quality(&completeness_report, true), + projection: projection_quality(false, &[]), + theme: theme_quality(false, 0, 0), + assets: assets_quality(false, &[], &[]), + }; + let mut response = json!({ + "status": quality.status(), + "quality": quality, + "targetKind": result.target_kind, + "anchor": result.anchor, + "group": result.group, + "count": count, + "candidates": result.candidates, + "truncated": result.truncated, + "diagnostics": payload.snapshot.diagnostics, + "completeness": payload.completeness, + "collection": collection, + "cache": artifact_metadata(artifact), + "source": { + "kind": source_kind, + "fileKey": target.file_key, + "nodeId": target.node_id, + "version": payload.snapshot.version + } + }); + attach_completeness_report(&mut response, quality, &completeness_report, false); + Ok(response) + } + PendingOperation::Export { + outputs, + previous_design_fingerprints, + component_name, + include_diagnostics, + root_layout, + asset_names_per_node, + scope, + theme_options, + strict, + output_paths, + page_scaffold, + frame_ids, + all_screens, + mut asset_captures, + web_ready_images, + mut asset_output_paths, + asset_public_root, + fields, + response_selection, + delivery, + width_policy, + } => { + if web_ready_images { + asset_captures = payload + .assets + .iter() + .filter_map(|a| { + Some(devup_mcp_figma::AssetSelection { + usage_aspect_ratio: a.usage_aspect_ratio, + max_width: a.max_width, + srcset: a.srcset, + visible_scope_node_id: a.visible_scope_node_id.clone(), + asset_id: a.asset_id.clone(), + format: a.format?, + scale: a.scale?, + render_mode: a.render_mode, + quality: a.quality, + lossless: a.lossless, + }) + }) + .collect(); + if let Some(root) = &asset_public_root { + for capture in &asset_captures { + let name = format!("{}.webp", sha256_hex(capture.asset_id.as_bytes())); + asset_output_paths.insert( + capture.asset_id.clone(), + root.join("images") + .join("web-ready") + .join(name) + .display() + .to_string(), + ); + } + } + } + let asset_policies = + asset_policy::policies(&payload.snapshot, &payload.assets, asset_names_per_node); + let selected_fields = super::field_select::expand(&fields); + response_selection.validate()?; + let compact_roots = if frame_ids.is_empty() { + &payload.snapshot.roots + } else { + &frame_ids + }; + let node_summaries = (response_selection.active() + || outputs.iter().any(|o| o == "outline")) + .then(|| super::compact::node_summaries(&payload.snapshot, compact_roots)); + page_scaffold::validate(page_scaffold.as_ref(), &outputs)?; + super::validation::validate_width_policy_nodes( + width_policy.as_ref(), + &payload.snapshot, + )?; + let previous_fingerprints = super::design_drift::validate_request( + &outputs, + previous_design_fingerprints.as_deref(), + )?; + let design_fingerprints = outputs + .iter() + .any(|output| { + matches!( + output.as_str(), + "sourceMap" | "designFingerprints" | "designChanges" + ) + }) + .then(|| devup_mcp_devup_ui::provenance::design_fingerprints(&payload.snapshot)); + let scaffold_requested = page_scaffold.is_some(); + if (scaffold_requested + || outputs + .iter() + .any(|o| matches!(o.as_str(), "tsx" | "componentTsx" | "responsiveTsx"))) + && payload + .assets + .iter() + .any(|a| a.render_mode == devup_mcp_figma::AssetRenderMode::Fill) + { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "Legacy fill captures remain separate from code generation; use original-source for CSS projection or renderMode node for native assets.", + false, + )); + } + if (scaffold_requested + || outputs + .iter() + .any(|o| matches!(o.as_str(), "tsx" | "componentTsx" | "responsiveTsx"))) + && payload.assets.iter().any(|a| { + a.render_mode == devup_mcp_figma::AssetRenderMode::Visible + && a.render_info.as_ref().is_some_and(|i| { + i["captureNodeId"].as_str() != Some(a.node_id.as_str()) + }) + }) + { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "Visible ancestor captures cannot replace leaf code at leaf dimensions; request assetManifest or explicitly generate the selected parent.", + false, + )); + } + let keep_tsx = outputs.iter().any(|o| o == "tsx"); + if outputs.iter().any(|output| output == "sourceMap") + && !outputs.iter().any(|output| { + matches!( + output.as_str(), + "tsx" | "componentTsx" | "devupJson" | "pageScaffold" + ) + }) + { + return Err(unavailable_output( + payload, + "sourceMap", + include_str!("../text/projection-project_operation-648fa6f6.md"), + )); + } + // Capture the accepted projection before fields are consumed by + // codegen/file staging. Resource retry reuses this acquisition. + let mut recovery_arguments = json!({ + "artifactId":artifact.artifact_id,"outputs":outputs, + "componentName":component_name,"includeDiagnostics":include_diagnostics, + "rootLayout":match root_layout { devup_mcp_devup_ui::codegen::RootLayout::Standalone => "standalone", devup_mcp_devup_ui::codegen::RootLayout::Embedded => "embedded" }, + "assetNamesPerNode":asset_names_per_node,"scope":scope,"strict":strict, + "devupJsonMode":if theme_options.mode.is_empty() {"full"} else {&theme_options.mode},"projectRoot":theme_options.project_root, + "outputPaths":output_paths,"frameIds":frame_ids,"allScreens":all_screens, + "debug":outputs.iter().any(|o| matches!(o.as_str(), "rawSnapshot" | "rawPayload")), + "delivery":"resource", + "assetRequests":asset_captures.iter().map(|a| json!({"assetId":a.asset_id,"format":a.format,"scale":a.scale,"quality":a.quality,"lossless":a.lossless,"renderMode":a.render_mode,"usageAspectRatio":a.usage_aspect_ratio,"maxWidth":a.max_width,"srcset":a.srcset,"visibleScopeNodeId":a.visible_scope_node_id, + "outputPath":asset_output_paths.get(&a.asset_id)})).collect::>() + }); + if let Some(previous) = &previous_design_fingerprints { + recovery_arguments["previousDesignFingerprints"] = json!(previous); + } + for (key, value) in serde_json::to_value(&response_selection) + .unwrap() + .as_object() + .unwrap() + { + recovery_arguments[key] = value.clone(); + } + if web_ready_images { + recovery_arguments + .as_object_mut() + .unwrap() + .remove("assetRequests"); + recovery_arguments["preset"] = json!("web-ready-images"); + } + if let Some(options) = &page_scaffold { + recovery_arguments["pageScaffold"] = json!(options); + } + if let Some(root) = &asset_public_root { + recovery_arguments["assetPublicRoot"] = json!(root); + } + if let Some(policy) = &width_policy { + recovery_arguments["widthPolicy"] = + json!({"root": "fluid", "anchors": policy.anchors}); + } + let mut result = Map::new(); + // Not restated by quality: this grades how far token resolution + // reached - whether external library variables were covered - where + // quality.theme only says whether what was resolved conflicts. + result.insert("completeness".to_owned(), json!(payload.completeness)); + result.insert("collection".to_owned(), json!(collection)); + result.insert("cache".to_owned(), artifact_metadata(artifact)); + result.insert( + "sectionChecklist".into(), + super::section_checklist::checklist(payload, compact_roots), + ); + let mut failures = payload + .failures + .iter() + .map(|failure| json!(failure)) + .collect::>(); + let mut warnings = Vec::new(); + for asset in payload + .assets + .iter() + .filter(|asset| asset.status == AssetStatus::Failed) + { + let mut failure = json!({"assetId":asset.asset_id,"nodeId":asset.node_id, + "errorCode":asset.error_code,"message":"Asset export failed; inspect errorDetail before retrying.", + "retryable":asset.error_code.as_deref() == Some("DEVUP_ASSET_TIMEOUT")}); + if let Some(detail) = &asset.error_detail { + failure["errorDetail"] = json!(detail); + if let Some(hint) = devup_mcp_figma::figma_unreachable_hint(detail) { + failure["hint"] = json!(hint); + } + } + if asset.error_code.as_deref() == Some("DEVUP_ASSET_NODE_HIDDEN") { + warnings.push(failure); + } else { + failures.push(failure); + } + } + result.insert("failures".to_owned(), json!(failures)); + if !warnings.is_empty() { + result.insert("warnings".to_owned(), json!(warnings)); + } + result.insert( + "source".to_owned(), + json!({ + "kind": source_kind, + "fileKey": payload.target.file_key, + "nodeId": payload.target.node_id, + "version": payload.snapshot.version + }), + ); + let payload_section_index = section_index_from_payload(payload); + let target_kind = if payload_section_index.is_some() { + TargetKind::Section + } else { + classify_target(&payload.snapshot, &payload.target) + }; + result.insert("targetKind".to_owned(), json!(target_kind)); + let selection_index = if target_kind == TargetKind::Section { + let mut index = match &payload_section_index { + Some(index) => index.clone(), + None => { + devup_mcp_figma::build_section_index(&payload.snapshot, &payload.target)? + } + }; + for (id, owner) in devup_mcp_figma::screen_owners( + &payload.snapshot, + index.candidates.iter().map(|c| c.node_id.as_str()), + ) { + if owner.is_some() || !index.node_screen_ids.contains_key(&id) { + index.node_screen_ids.insert(id, owner); + } + } + Some(index) + } else { + None + }; + let manifest_requested = outputs.iter().any(|output| output == "assetManifest"); + image_fill_audit::attach_image_fill_audit( + &mut result, + payload, + &completeness_report, + &outputs, + asset_names_per_node, + ); + result.insert( + "assetSummary".to_owned(), + asset_summary( + payload, + artifact, + &payload.snapshot.roots, + manifest_requested, + ), + ); + + if !frame_ids.is_empty() && all_screens { + return Err(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "frameIds and allScreens cannot be used together.", + false, + )); + } + if target_kind != TargetKind::Section && (!frame_ids.is_empty() || all_screens) { + return Err(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "frameIds and allScreens can only be used on a Section artifact.", + false, + )); + } + + let mut candidates_truncated = false; + let section_candidates = if target_kind == TargetKind::Section + && outputs.iter().any(|output| { + matches!( + output.as_str(), + "tsx" + | "componentTsx" + | "responsiveTsx" + | "pageScaffold" + | "assetManifest" + | "imageAudit" + | "designFingerprints" + | "designChanges" + ) + }) { + Some(if let Some(index) = &payload_section_index { + candidates_truncated = index.truncated; + index + .candidates + .iter() + .map(|candidate| { + let mut explored = section_candidate_as_explore(candidate); + explored.node.kind = + devup_mcp_figma::classify_explore_node(&explored.node); + explored.selection_reasons = if explored.node.is_screen_candidate() { + vec!["screen-like".into(), "inside-section".into()] + } else { + vec!["explicit-selection-only".into(), "inside-section".into()] + }; + explored + }) + .collect() + } else { + let explored = explore_snapshot( + &payload.snapshot, + &payload.target, + &ExploreOptions { limit: 100 }, + )?; + candidates_truncated = explored.candidates_truncated; + explored.candidates + }) + } else { + None + }; + if let Some(candidates) = §ion_candidates { + result.insert( + "responsiveGroups".into(), + json!(responsive_groups::discover( + candidates, + &artifact.artifact_id + )), + ); + } else { + let groups = responsive_groups::from_roots(payload, &artifact.artifact_id); + if !groups.is_empty() { + result.insert("responsiveGroups".into(), json!(groups)); + } + } + if let Some(candidates) = §ion_candidates + && frame_ids.is_empty() + && !all_screens + { + return section_selection_reply::section_selection_reply( + section_selection_reply::SectionSelection { + payload, + artifact, + artifact_store, + outputs: &outputs, + previous_design_fingerprints: &previous_design_fingerprints, + component_name: &component_name, + include_diagnostics, + root_layout, + asset_names_per_node, + scope: &scope, + theme_options: &theme_options, + strict, + output_paths: &output_paths, + page_scaffold: &page_scaffold, + frame_ids: &frame_ids, + all_screens, + asset_captures: &asset_captures, + web_ready_images, + asset_output_paths: &asset_output_paths, + asset_public_root: &asset_public_root, + delivery, + result, + recovery_arguments: &recovery_arguments, + candidates, + candidates_truncated, + completeness_report: &completeness_report, + scaffold_requested, + }, + ) + .await; + } + let written_paths = Map::new(); + let mut section_tsx_projected = false; + let mut selected_projection_roots = None; + let mut tsx_source_map = None; + let mut generated_output_target = "devupJson"; + let mut devup_json_source_map = None; + let mut projection_diagnostics = Vec::new(); + let mut fidelity_reports = Vec::new(); + let mut theme_conflict_count = 0; + let mut theme_unresolved_count = 0; + let mut pending_text_outputs = std::collections::BTreeMap::new(); + if output_paths.contains_key("imageAudit") + && let Some(audit) = result.get("imageAudit") + { + pending_text_outputs.insert( + "imageAudit".into(), + serde_json::to_string_pretty(audit).unwrap_or_default(), + ); + } + if let Some(current) = &design_fingerprints { + super::design_drift::attach( + &outputs, + previous_fingerprints.as_ref(), + current, + &mut result, + &mut pending_text_outputs, + ); + } + let mut pending_binary_outputs = std::collections::BTreeMap::new(); + let mut pending_asset_manifest = None; + let asset_resource_outputs = Vec::new(); + ( + result, + failures, + projection_diagnostics, + fidelity_reports, + selected_projection_roots, + section_tsx_projected, + ) = project_section_frames::project_section_frames( + project_section_frames::SectionFrames { + failures, + design_fingerprints: &design_fingerprints, + payload, + artifact, + outputs: &outputs, + component_name: &component_name, + include_diagnostics, + root_layout, + asset_names_per_node, + frame_ids: &frame_ids, + all_screens, + width_policy: &width_policy, + result, + selection_index: &selection_index, + section_candidates, + completeness_report: &completeness_report, + asset_policies: &asset_policies, + selected_fields: &selected_fields, + scaffold_requested, + manifest_requested, + section_tsx_projected, + selected_projection_roots, + projection_diagnostics, + fidelity_reports, + }, + )?; + let component_name_for_components = component_name.clone(); + let (next_result, next_diagnostics, next_text_outputs, responsive_widths) = + project_responsive_output::project_responsive_output( + project_responsive_output::ResponsiveOutput { + payload, + outputs: &outputs, + include_diagnostics, + root_layout, + asset_names_per_node, + theme_options: &theme_options, + output_paths: &output_paths, + result, + asset_policies: &asset_policies, + selected_projection_roots, + projection_diagnostics, + pending_text_outputs, + component_name_for_components: &component_name_for_components, + }, + )?; + result = next_result; + projection_diagnostics = next_diagnostics; + pending_text_outputs = next_text_outputs; + ( + result, + projection_diagnostics, + fidelity_reports, + pending_text_outputs, + tsx_source_map, + generated_output_target, + devup_json_source_map, + theme_conflict_count, + theme_unresolved_count, + warnings, + ) = project_node_and_theme_outputs::project_node_and_theme_outputs( + project_node_and_theme_outputs::NodeAndThemeOutputs { + payload, + outputs: &outputs, + component_name, + include_diagnostics, + root_layout, + asset_names_per_node, + scope: &scope, + theme_options: &theme_options, + strict, + output_paths: &output_paths, + width_policy: &width_policy, + result, + failures: &mut failures, + warnings, + asset_policies: &asset_policies, + scaffold_requested, + keep_tsx, + section_tsx_projected, + tsx_source_map, + generated_output_target, + devup_json_source_map, + projection_diagnostics, + fidelity_reports, + theme_conflict_count, + theme_unresolved_count, + pending_text_outputs, + component_name_for_components: &component_name_for_components, + }, + )?; + if outputs.iter().any(|output| output == "rawSnapshot") { + let raw = serde_json::to_value(&payload.snapshot).map_err(|error| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + format!("Cannot serialize the raw snapshot: {error}"), + false, + ) + })?; + let mut raw = raw; + super::field_select::prune_nodes(&mut raw, &selected_fields); + if output_paths.contains_key("rawSnapshot") { + pending_text_outputs.insert( + "rawSnapshot".to_owned(), + serde_json::to_string_pretty(&raw).unwrap_or_default(), + ); + } + result.insert("rawSnapshot".to_owned(), raw); + } + + // The whole collection, not only its node tree. A snapshot kept on + // its own can be converted, but not the way the server converts + // it: the token names come from the variables and styles collected + // beside it, and without them a `$gray200` fill comes out as the + // tail of its variable ID and a `typography="h4"` as five font + // props. Captures kept as fixtures need the resources too, so + // this writes them. The reference PNG is left out — it is large, + // binary, and has its own output. + if outputs.iter().any(|output| output == "rawPayload") { + let mut without_png = payload.clone(); + without_png.reference_png = None; + let raw = serde_json::to_value(&without_png).map_err(|error| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + format!("Cannot serialize the raw payload: {error}"), + false, + ) + })?; + let mut raw = raw; + super::field_select::prune_nodes(&mut raw, &selected_fields); + if output_paths.contains_key("rawPayload") { + pending_text_outputs.insert( + "rawPayload".to_owned(), + serde_json::to_string_pretty(&raw).unwrap_or_default(), + ); + } + result.insert("rawPayload".to_owned(), raw); + } + + if outputs.iter().any(|output| output == "sourceMap") && !section_tsx_projected { + let rasterized_assets = tsx_source_map + .as_ref() + .map(|m| m.rasterized_assets()) + .unwrap_or_default(); + let mut tsx_entries: Vec = tsx_source_map + .map(|source_map| source_map.property_entries()) + .unwrap_or_default() + .into_iter() + .filter_map(|entry| serde_json::to_value(entry).ok()) + .collect(); + super::field_select::filter_entries(&mut tsx_entries, &selected_fields); + let source_map = json!({ + "version": 2, + "rasterizedAssets":rasterized_assets, + "designFingerprints":design_fingerprints, + "resolutionSemantics":devup_mcp_devup_ui::provenance::resolution_semantics(), + "tsx": tsx_entries, + "devupJson": devup_json_source_map + .map(|source_map| source_map.entries) + .unwrap_or_default(), + "source": { + "fileKey": vouched_file_key(payload), + "rootNodeId": payload.target.node_id, + "sourceVersion": payload.source_version, + "generatedOutput":generated_output_target,"mappingKind":"node-field-property" + } + }); + if output_paths.contains_key("sourceMap") { + pending_text_outputs.insert( + "sourceMap".to_owned(), + serde_json::to_string_pretty(&source_map).unwrap_or_default(), + ); + } + result.insert("sourceMap".to_owned(), source_map); + } + + if outputs.iter().any(|output| output == "referencePng") { + let reference = payload.reference_png.as_ref().ok_or_else(|| unavailable_output(payload, "referencePng", + "No reference PNG was collected. Re-collect a single screen URL with referencePng in outputs."))?; + let bytes = STANDARD + .decode(reference.data_base64.as_bytes()) + .map_err(|_| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "The artifact reference PNG base64 is invalid.", + false, + ) + })?; + if bytes.len() != reference.byte_length + || Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + != reference.sha256 + { + return Err(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "The artifact reference PNG length or hash does not match.", + false, + )); + } + if output_paths.contains_key("referencePng") { + pending_binary_outputs.insert("referencePng".to_owned(), bytes); + } + let mut inline = json!(reference); + if let Some(note) = inline + .as_object_mut() + .and_then(|inline| inline.remove("note")) + { + result.insert("referencePngNote".to_owned(), note); + } + result.insert("referencePng".to_owned(), inline); + } + + (pending_asset_manifest, warnings) = prepare_asset_manifest::prepare_asset_manifest( + prepare_asset_manifest::AssetManifestPreparation { + payload, + outputs: &outputs, + asset_names_per_node, + asset_captures: &asset_captures, + web_ready_images, + warnings, + scaffold_requested, + manifest_requested, + pending_asset_manifest, + }, + )?; + (result, failures, pending_text_outputs) = + exclude_hidden_asset_outputs::exclude_hidden_asset_outputs( + exclude_hidden_asset_outputs::HiddenAssetOutputs { + failures, + payload, + asset_names_per_node, + result, + pending_text_outputs, + }, + )?; + (result,) = summarize_projection_diagnostics::summarize_projection_diagnostics( + summarize_projection_diagnostics::ProjectionDiagnostics { + payload, + outputs: &outputs, + include_diagnostics, + root_layout, + asset_names_per_node, + scope: &scope, + strict, + frame_ids: &frame_ids, + all_screens, + asset_captures: &asset_captures, + delivery, + result, + failures, + warnings, + completeness_report: &completeness_report, + section_tsx_projected, + projection_diagnostics, + fidelity_reports, + theme_conflict_count, + theme_unresolved_count, + component_name_for_components: &component_name_for_components, + target_kind, + }, + )?; + return finalize_export_delivery::finalize_export_delivery( + finalize_export_delivery::ExportDelivery { + payload, + artifact, + artifact_store, + output_policy, + outputs, + asset_names_per_node, + strict, + output_paths, + page_scaffold, + asset_captures, + web_ready_images, + asset_output_paths, + asset_public_root, + response_selection, + delivery, + result, + recovery_arguments, + selection_index, + completeness_report, + scaffold_requested, + manifest_requested, + keep_tsx, + node_summaries, + written_paths, + pending_text_outputs, + pending_binary_outputs, + pending_asset_manifest, + asset_resource_outputs, + responsive_widths, + }, + ) + .await; + } + PendingOperation::Collect | PendingOperation::Artifact { .. } => Err(DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + "An internal collect operation cannot be completed from an MCP artifact.", + false, + )), + } +} diff --git a/crates/devup-mcp/src/server/projection/project_responsive_output.rs b/crates/devup-mcp/src/server/projection/project_responsive_output.rs new file mode 100644 index 00000000..e0cb0547 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/project_responsive_output.rs @@ -0,0 +1,257 @@ +use super::*; + +pub(super) struct ResponsiveOutput<'a> { + pub(super) payload: &'a CollectedPayload, + pub(super) outputs: &'a Vec, + pub(super) include_diagnostics: bool, + pub(super) root_layout: devup_mcp_devup_ui::codegen::RootLayout, + pub(super) asset_names_per_node: bool, + pub(super) theme_options: &'a theme_export::ThemeExportOptions, + pub(super) output_paths: &'a BTreeMap, + pub(super) result: Map, + pub(super) asset_policies: + &'a BTreeMap, + pub(super) selected_projection_roots: Option>, + pub(super) projection_diagnostics: Vec, + pub(super) pending_text_outputs: BTreeMap, + pub(super) component_name_for_components: &'a Option, +} + +type ResponsiveProjection = Result< + ( + Map, + Vec, + BTreeMap, + Vec, + ), + DevupError, +>; + +pub(super) fn project_responsive_output(input: ResponsiveOutput<'_>) -> ResponsiveProjection { + let ResponsiveOutput { + payload, + outputs, + include_diagnostics, + root_layout, + asset_names_per_node, + theme_options, + output_paths, + mut result, + asset_policies, + selected_projection_roots, + mut projection_diagnostics, + mut pending_text_outputs, + component_name_for_components, + } = input; + let mut responsive_snapshot = std::borrow::Cow::Borrowed(&payload.snapshot); + if let Some(roots) = selected_projection_roots { + responsive_snapshot.to_mut().roots = roots; + } + let mut responsive_widths = Vec::new(); + // A screen captured with its other widths is convertible as one + // tree. It is offered whenever those widths are present rather than + // only on request, because a caller asking for a screen that has + // them almost always wants the responsive form and cannot know from + // the node id alone whether it exists. + if outputs + .iter() + .any(|output| output == "tsx" || output == "responsiveTsx") + && let Some(mut merged) = merge_breakpoints( + &responsive_snapshot, + &CodegenOptions { + asset_policies: asset_policies.clone(), + component_name: component_name_for_components.clone(), + include_diagnostics, + inline_instances: false, + root_layout, + asset_names_per_node, + ..CodegenOptions::default() + } + .with_payload_tokens(payload), + )? + && (outputs.iter().any(|output| output == "responsiveTsx") + || !merged.slots.windows(2).any(|pair| pair[0] == pair[1])) + { + // Named as the plugin names it: after the Section the widths + // sit in, with `Page` on the end — `AboutPage` for a Section + // called `about`. The Section is outside the collected + // subtree, so each width carries its name as `parentName`. + // A caller's own name wins; without one and without a + // Section name the page is `ResponsivePage`. + let section_name = payload + .snapshot + .roots + .iter() + .filter_map(|root| payload.snapshot.nodes.get(root)) + .find_map(|root| root.typed_view().string("parentName").map(str::to_owned)); + let name = component_name_for_components.clone().map_or_else( + || { + section_name.map_or_else( + || "ResponsivePage".to_owned(), + |section| format!("{}Page", normalize_component_name(§ion)), + ) + }, + |name| normalize_component_name(&name), + ); + if merged + .slots + .iter() + .collect::>() + .len() + != merged.slots.len() + { + return Err(unavailable_output( + payload, + "responsiveTsx", + "Two frames occupy the same breakpoint slot. Request tsx separately, or select one frame per breakpoint slot.", + )); + } + if let Some(root) = theme_options.project_root.as_deref() + && super::super::screen_plan::node_ids_enabled(root) + { + let written = devup_mcp_devup_ui::codegen::node_ids::annotate_merged(&mut merged); + let ids = result.entry("figmaNodeIds").or_insert_with( + || json!({"attribute": devup_mcp_devup_ui::codegen::node_ids::NODE_ID_ATTRIBUTE}), + ); + ids["perWidthAttribute"] = + json!(devup_mcp_devup_ui::codegen::node_ids::NODE_IDS_ATTRIBUTE); + ids["responsive"] = json!(written); + ids["responsiveFormat"] = json!(include_str!( + "../text/projection-project_operation-8a156710.md" + )); + } + let module = merged.module(&name); + // The responsive tree builder does not run the ordinary + // finalizer. Preserve each breakpoint's source limitations. + let mut breakpoint_fidelity = Vec::new(); + for root_id in &responsive_snapshot.roots { + let mut source_output = generate_component( + &responsive_snapshot, + root_id, + &CodegenOptions { + asset_policies: asset_policies.clone(), + inline_instances: true, + root_layout, + asset_names_per_node, + ..CodegenOptions::default() + } + .with_payload_tokens(payload), + )?; + scope_output(&mut source_output, "responsiveTsx"); + breakpoint_fidelity + .push(json!({"rootId":root_id,"fidelity":source_output.fidelity_report})); + for mut issue in source_output.diagnostics.into_iter().filter(|d| { + d.fidelity_impact() != devup_mcp_figma::FidelityImpact::None + || d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" + }) { + if let Some(details) = issue.details.as_mut().and_then(Value::as_object_mut) { + details.insert( + "evidenceScope".into(), + json!("breakpoint-source-projection"), + ); + details.insert("breakpointRootId".into(), json!(root_id)); + details.insert("output".into(), json!("responsiveTsx")); + } + if !projection_diagnostics.contains(&issue) { + projection_diagnostics.push(issue); + } + } + } + if output_paths.contains_key("responsiveTsx") { + pending_text_outputs.insert("responsiveTsx".to_owned(), module.clone()); + } + result.insert("responsiveTsx".to_owned(), json!(module)); + result.insert("responsiveSlots".to_owned(), json!(merged.slots)); + result.insert( + "responsiveEvidence".into(), + responsive_groups::width_evidence(&responsive_snapshot), + ); + result.insert( + "responsiveReviewChecklist".into(), + responsive_groups::review(&responsive_snapshot), + ); + responsive_widths = responsive_snapshot + .roots + .iter() + .filter_map(|id| { + responsive_snapshot + .nodes + .get(id)? + .typed_view() + .number("width") + }) + .collect(); + let responsive_issues: Vec<_> = projection_diagnostics + .iter() + .filter(|d| { + d.details + .as_ref() + .is_some_and(|v| v["output"] == "responsiveTsx") + }) + .cloned() + .collect(); + let responsive_quality = if merged.unrepresented.is_empty() { + projection_quality(true, &responsive_issues) + } else { + ProjectionQuality::Lossy + }; + result.entry("outputResults").or_insert_with(|| json!({}))["responsiveTsx"] = json!({ + "state":"produced","projection":responsive_quality, + "fidelity":{"scope":"breakpoint-source-projections","sourceOutput":"tsx","breakpoints":breakpoint_fidelity, + "syntaxValid":devup_mcp_devup_ui::validation::validate_tsx(&module).is_ok(), + "mergedMappingVerified":merged.verified,"unrepresentedCount":merged.unrepresented.len(), + "mergeGapCount":merged.gaps.len(), + "mergeGaps":merged.gaps.iter().take(20).collect::>(), + "note":if merged.verified { + "Read at each collected width's slot, the merged module writes that width's own verified tsx: the same elements, values and text. This reads the code; it is not a rendering." + } else { + include_str!("../text/projection-project_operation-9a2b2731.md") + }}, + "_propertyContract":merged.contract}); + + if !merged.unrepresented.is_empty() { + for note in &merged.unrepresented { + projection_diagnostics.push(Diagnostic { + code:"DEVUP_CODEGEN_RESPONSIVE_LOSS".into(), + message:note.detail.clone(), + node_id:Some(note.node_id.clone()), + property:Some("childrenIds".into()), + details:Some(json!({"originalValue":payload.snapshot.nodes.get(¬e.node_id).map(|n| &n.fields), + "appliedValue":null,"appliedValueReason":"Content is unrepresented in the merged output.","stage":"projection","output":"responsiveTsx"})), + fidelity_impact:Some(devup_mcp_figma::FidelityImpact::Lossy), + severity:Some(DiagnosticSeverity::Warning), + ..Diagnostic::default() + }); + } + result.insert( + "responsiveUnrepresented".to_owned(), + json!( + merged + .unrepresented + .iter() + .map(|note| json!({ + "nodeId": note.node_id, + "detail": note.detail + })) + .collect::>() + ), + ); + } + } + + if outputs.iter().any(|output| output == "responsiveTsx") + && !result.contains_key("responsiveTsx") + { + return Err(unavailable_output( + payload, + "responsiveTsx", + "The snapshot has no mergeable breakpoint frames. Request tsx for individual screens, or re-collect a screen with its breakpoint siblings.", + )); + } + Ok(( + result, + projection_diagnostics, + pending_text_outputs, + responsive_widths, + )) +} diff --git a/crates/devup-mcp/src/server/projection/project_section_frames.rs b/crates/devup-mcp/src/server/projection/project_section_frames.rs new file mode 100644 index 00000000..f13be26b --- /dev/null +++ b/crates/devup-mcp/src/server/projection/project_section_frames.rs @@ -0,0 +1,333 @@ +use super::*; + +pub(super) struct SectionFrames<'a> { + pub(super) design_fingerprints: &'a Option>, + pub(super) payload: &'a CollectedPayload, + pub(super) artifact: &'a ArtifactLookup, + pub(super) outputs: &'a Vec, + pub(super) component_name: &'a Option, + pub(super) include_diagnostics: bool, + pub(super) root_layout: devup_mcp_devup_ui::codegen::RootLayout, + pub(super) asset_names_per_node: bool, + pub(super) frame_ids: &'a Vec, + pub(super) all_screens: bool, + pub(super) width_policy: &'a Option, + pub(super) result: Map, + pub(super) failures: Vec, + pub(super) selection_index: &'a Option, + pub(super) section_candidates: Option>, + pub(super) completeness_report: &'a devup_mcp_figma::PayloadCompletenessReport, + pub(super) asset_policies: + &'a BTreeMap, + pub(super) selected_fields: &'a std::collections::BTreeSet, + pub(super) scaffold_requested: bool, + pub(super) manifest_requested: bool, + pub(super) section_tsx_projected: bool, + pub(super) selected_projection_roots: Option>, + pub(super) projection_diagnostics: Vec, + pub(super) fidelity_reports: Vec, +} + +type SectionFrameProjection = Result< + ( + Map, + Vec, + Vec, + Vec, + Option>, + bool, + ), + DevupError, +>; + +pub(super) fn project_section_frames(input: SectionFrames<'_>) -> SectionFrameProjection { + let SectionFrames { + design_fingerprints, + payload, + artifact, + outputs, + component_name, + include_diagnostics, + root_layout, + asset_names_per_node, + frame_ids, + all_screens, + width_policy, + mut result, + mut failures, + selection_index, + section_candidates, + completeness_report, + asset_policies, + selected_fields, + scaffold_requested, + manifest_requested, + mut section_tsx_projected, + mut selected_projection_roots, + mut projection_diagnostics, + mut fidelity_reports, + } = input; + if let Some(candidates) = section_candidates { + let by_id = candidates + .iter() + .map(|candidate| (candidate.node.node_id.as_str(), candidate)) + .collect::>(); + let failed_ids = payload + .failures + .iter() + .map(|failure| failure.node_id.as_str()) + .collect::>(); + let selected = if all_screens { + candidates + .iter() + .filter(|candidate| candidate.node.is_screen_candidate()) + .filter(|candidate| !failed_ids.contains(candidate.node.node_id.as_str())) + .collect::>() + } else { + let requested = frame_ids + .iter() + .map(String::as_str) + .collect::>(); + if requested.len() != frame_ids.len() { + return Err(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "frameIds contains a duplicate node.", + false, + )); + } + if requested + .iter() + .any(|node_id| !by_id.contains_key(*node_id)) + { + selection_index + .as_ref() + .expect("Section index") + .select(frame_ids, false)?; + } + candidates + .iter() + .filter(|candidate| { + requested.contains(candidate.node.node_id.as_str()) + && !failed_ids.contains(candidate.node.node_id.as_str()) + }) + .collect::>() + }; + if selected.is_empty() && failed_ids.is_empty() { + return Err(unavailable_output( + payload, + "Section outputs", + "No screen frames match this selection. Use explicit frameIds for a nonstandard frame, or explore another Section.", + )); + } + selected_projection_roots = Some( + selected + .iter() + .map(|candidate| candidate.node.node_id.clone()) + .collect::>(), + ); + if scaffold_requested && selected.len() != 1 { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "pageScaffold requires one frameId for the caller-supplied route.", + false, + )); + } + const MAX_SECTION_FRAMES: usize = 6; + if selected.len() > MAX_SECTION_FRAMES { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + "This Section projection exceeds the batch budget. Request at most 6 frameIds per call; reuse this artifactId for subsequent batches.", + false, + json!({"artifactId":artifact.artifact_id,"requestedFrameCount":selected.len(), + "recommendedBatchSize":3,"maxFrameCount":MAX_SECTION_FRAMES,"maxFrameOutputUnits":12, + "recommendedFrameIds":selected.iter().take(3).map(|c| &c.node.node_id).collect::>(), + "remainingFrameIds":selected.iter().skip(3).map(|c| &c.node.node_id).collect::>() }), + )); + } + super::super::validation::validate_export_budget( + &selected + .iter() + .map(|candidate| candidate.node.node_id.clone()) + .collect::>(), + outputs, + )?; + let mut frames = Vec::with_capacity(selected.len()); + for (index, candidate) in selected.iter().enumerate() { + let frame_component_name = component_name.as_ref().map(|name| { + if selected.len() == 1 { + name.clone() + } else { + format!("{name}{}", index + 1) + } + }); + let mut frame = json!({"nodeId":candidate.node.node_id,"name":candidate.node.name,"outputResults":{}, + "canonicalUrl":candidate.canonical_url}); + let before_failures = failures.len(); + let mut frame_diagnostics = Vec::new(); + for field in ["tsx", "componentTsx"] { + if !outputs.iter().any(|output| output == field) + && !(scaffold_requested && field == "tsx") + { + continue; + } + let output = generate_collected_component( + payload, + &candidate.node.node_id, + &CodegenOptions { + asset_policies: asset_policies.clone(), + component_name: frame_component_name.clone(), + include_diagnostics, + inline_instances: field == "tsx", + root_layout, + asset_names_per_node, + width_policy: width_policy.clone(), + ..CodegenOptions::default() + } + .with_payload_tokens(payload), + ); + let mut output = match output { + Ok(output) => output, + Err(error) => { + failures.push(projection_failure( + payload, + &candidate.node.node_id, + field, + &error, + )); + continue; + } + }; + scope_output(&mut output, field); + frame["outputResults"][field] = output_result(&output); + if frame.get("widthBehavior").is_none() { + frame["widthBehavior"] = devup_mcp_devup_ui::codegen::width::width_behavior( + &payload.snapshot, + &candidate.node.node_id, + &output, + ); + } + frame_diagnostics.extend(output.diagnostics.iter().cloned()); + fidelity_reports.push(output.fidelity_report.clone()); + frame[field] = json!(output.tsx); + attach_fidelity(&mut frame, &output.fidelity_report, include_diagnostics); + if outputs.iter().any(|output| output == "sourceMap") { + let mut frame_entries: Vec = output + .source_map + .property_entries() + .into_iter() + .filter_map(|entry| serde_json::to_value(entry).ok()) + .collect(); + super::super::field_select::filter_entries(&mut frame_entries, selected_fields); + frame["sourceMap"] = json!({"version":output.source_map.version, + "rasterizedAssets":output.source_map.rasterized_assets(), + "designFingerprints":design_fingerprints, + "resolutionSemantics":{"axis":"mapping-method","dictionary":"/resolutionSemantics"}, + "entries":frame_entries,"source":{"fileKey":vouched_file_key(payload), + "rootNodeId":candidate.node.node_id,"sourceVersion":payload.source_version, + "generatedOutput":field,"mappingKind":"node-field-property"}}); + } + } + frame["placementContracts"] = json!( + frame_diagnostics + .iter() + .filter(|d| d.code == "DEVUP_CODEGEN_PLACEMENT_CONTRACT") + .collect::>() + ); + frame["projectionIssues"] = json!( + frame_diagnostics + .iter() + .filter( + |d| d.fidelity_impact() != devup_mcp_figma::FidelityImpact::None + || d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" + ) + .collect::>() + ); + projection_diagnostics.extend(frame_diagnostics.iter().cloned()); + let mut frame_quality = OutputQuality { + acquisition: acquisition_quality(completeness_report, false), + projection: projection_quality(true, &frame_diagnostics), + theme: theme_quality(false, 0, 0), + assets: assets_quality(false, &[], &[]), + }; + if failures.len() > before_failures { + frame_quality.projection = + if frame.get("tsx").is_some() || frame.get("componentTsx").is_some() { + ProjectionQuality::Lossy + } else { + ProjectionQuality::Failed + }; + } + frame["quality"] = json!(frame_quality); + frame["status"] = json!(if failures.len() > before_failures { + "partial" + } else { + frame_quality.status() + }); + frame["assetSummary"] = asset_summary( + payload, + artifact, + std::slice::from_ref(&candidate.node.node_id), + manifest_requested, + ); + frame["projectionEvidence"] = json!( + frame_diagnostics + .iter() + .filter(|d| { + d.fidelity_impact() == devup_mcp_figma::FidelityImpact::None + && matches!( + d.code.as_str(), + "DEVUP_CODEGEN_NON_RENDERING_ASSET" + | "DEVUP_CODEGEN_ABSOLUTE_VERIFIED" + | "DEVUP_CODEGEN_LAYOUT_ACCOUNTED_FOR" + | "DEVUP_CODEGEN_PROPERTY_EVIDENCE" + ) + }) + .collect::>() + ); + frame_quality.assets = asset_collection_quality(&frame["assetSummary"]); + frame["quality"] = json!(frame_quality); + frame["status"] = json!(if failures.len() > before_failures { + "partial" + } else { + frame_quality.status() + }); + attach_completeness_report( + &mut frame, + frame_quality, + completeness_report, + include_diagnostics, + ); + if include_diagnostics { + frame["diagnostics"] = json!( + frame_diagnostics + .iter() + .filter(|d| d.code != "DEVUP_CODEGEN_PROPERTY_EVIDENCE") + .collect::>() + ); + } + frames.push(frame); + } + annotate_identical_frames(&mut frames, payload, asset_names_per_node); + if let Some(roots) = &selected_projection_roots { + result.insert( + "assetSummary".to_owned(), + asset_summary(payload, artifact, roots, manifest_requested), + ); + } + if outputs + .iter() + .any(|output| matches!(output.as_str(), "tsx" | "componentTsx" | "pageScaffold")) + { + result.insert("frames".to_owned(), Value::Array(frames)); + } + section_tsx_projected = true; + } + Ok(( + result, + failures, + projection_diagnostics, + fidelity_reports, + selected_projection_roots, + section_tsx_projected, + )) +} diff --git a/crates/devup-mcp/src/server/projection/regression_tests.rs b/crates/devup-mcp/src/server/projection/regression_tests.rs new file mode 100644 index 00000000..301ac9e3 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/regression_tests.rs @@ -0,0 +1,41 @@ +#[cfg(test)] +mod tests { + use super::{host_safe_asset_path, with_host_safe_asset_paths}; + + #[test] + fn a_layer_name_no_file_system_takes_is_renamed_wherever_the_code_points_at_it() { + let code = include_str!( + "../text/projection-tests_a_layer_name_no_file_system_takes_is_renamed_wherever_the_code_p-90121fb1.md" + ); + assert_eq!( + with_host_safe_asset_paths(code), + concat!( + "\n", + "\n", + "\n", + "\n", + ) + ); + } + + #[test] + fn the_written_path_is_renamed_the_same_way_the_code_is() { + assert_eq!( + host_safe_asset_path("/icons/grommet-icons:language.svg"), + "/icons/grommet-icons-language-d201f62b16f5.svg" + ); + assert_eq!( + host_safe_asset_path("/images/Frame 269.png"), + "/images/Frame-269-3e2af8571e3f.png" + ); + } +} + +#[cfg(test)] +mod w1_regressions { + include!("w1_regressions.rs"); + include!("w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs"); + include!("w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs"); + include!("w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs"); + include!("w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs"); +} diff --git a/crates/devup-mcp/src/server/projection/resource_delivery.rs b/crates/devup-mcp/src/server/projection/resource_delivery.rs new file mode 100644 index 00000000..b95412d2 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/resource_delivery.rs @@ -0,0 +1,263 @@ +pub(super) async fn apply_delivery( + result: &mut Value, + mode: DeliveryMode, + artifact_store: &ArtifactStore, + artifact: &ArtifactLookup, + mut outputs: Vec, + reprojection_arguments: Option<&Value>, +) -> Result, DevupError> { + if outputs.is_empty() || choose_delivery_for_result(mode, result, &outputs)?.inline { + return Ok(None); + } + let frames = result["frames"].as_array(); + let selected_frame = frames.and_then(|frames| { + frames.iter().enumerate().find(|(_, frame)| { + frame.get("pageScaffold").is_some() + || ["tsx", "componentTsx"] + .iter() + .any(|field| frame.get(*field).is_some_and(Value::is_string)) + }) + }); + let frame = selected_frame.map(|(_, frame)| frame); + let selected = [ + "tsx", + "componentTsx", + "devupJson", + "rawSnapshot", + "rawPayload", + "responsiveTsx", + "assetManifest", + "imageAudit", + "pageScaffold", + "referencePng", + "designFingerprints", + "designChanges", + ] + .into_iter() + .find_map(|field| { + frame + .and_then(|f| f.get(field)) + .or_else(|| result.get(field)) + .map(|body| (field, body)) + }); + if let Some((field, body)) = selected { + let binary = field == "referencePng"; + let bytes = if binary { + outputs + .iter() + .find(|o| o.name == "reference.png") + .map_or(0, |o| o.bytes.len()) + } else { + body.as_str() + .map_or_else(|| serde_json::to_vec(body).unwrap().len(), str::len) + }; + let escaped_bytes = if binary { + bytes.div_ceil(3) * 4 + } else { + serde_json::to_vec(body).unwrap().len() + }; + let coupled = reprojection_arguments.is_some_and(|a| { + a.get("pageScaffold").is_some() + || a["preset"] == "web-ready-images" + || a["assetRequests"].as_array().is_some_and(|a| !a.is_empty()) + || (matches!( + a["devupJsonMode"].as_str(), + Some("used-only" | "missing-only") + ) && a["outputs"] + .as_array() + .is_some_and(|outputs| outputs.iter().any(|output| output == "devupJson"))) + }); + let mut args = reprojection_arguments.cloned().unwrap_or_else(|| json!({})); + if !coupled { + args["artifactId"] = json!(artifact.artifact_id); + args["outputs"] = json!([field]); + if let Some(frame) = frame { + args["frameIds"] = json!([frame["nodeId"]]); + args["allScreens"] = json!(false); + if frames.is_some_and(|frames| frames.len() > 1) + && let Some(name) = args["componentName"].as_str() + { + args["componentName"] = + json!(format!("{name}{}", selected_frame.unwrap().0 + 1)); + } + } + args["debug"] = json!(true); + args["delivery"] = json!( + if escaped_bytes * 2 > super::delivery::MAX_INLINE_TOTAL_BYTES { + "resource" + } else { + "inline" + } + ); + args.as_object_mut().unwrap().remove("outputPaths"); + args.as_object_mut().unwrap().remove("assetRequests"); + } + if args.get("pageScaffold").is_some() { + args["pageScaffold"]["write"] = json!(false); + } + if matches!( + args["devupJsonMode"].as_str(), + Some("used-only" | "missing-only") + ) && args.get("pageScaffold").is_none() + && !args["assetRequests"] + .as_array() + .is_some_and(|requests| !requests.is_empty()) + { + args.as_object_mut().unwrap().remove("outputPaths"); + } + let example = json!({"tool":"devup_figma_export","arguments":args}); + result["nextAction"] = json!({"tool":"devup_figma_export","arguments":args,"example":example, + "how":if coupled { include_str!("../text/projection-apply_delivery-4796ba61.md") } + else if field == "responsiveTsx" { "Read the linked resource or reproject this responsive output with the same breakpoint selection. A responsive merge requires multiple source screens." } + else { include_str!("../text/projection-apply_delivery-6938bf43.md") }, + "sizeEstimate":{"outputBytes":bytes,"minimumWireBytes":escaped_bytes * 2, + "limitBytes":super::delivery::MAX_INLINE_TOTAL_BYTES,"isMeasuredReprojection":false, + "inlineFit":if escaped_bytes * 2 > super::delivery::MAX_INLINE_TOTAL_BYTES {"exceeds-limit"} else {"unknown"}, + "binary":binary, + "basis":include_str!("../text/projection-apply_delivery-e5a0fbb2.md")}}); + } + resource_diagnostic_details(result, &mut outputs, &artifact.artifact_id)?; + let projection_key = projection_key(&outputs); + materialize_asset_resource_references(result, &mut outputs, &artifact.artifact_id)?; + let reservation = artifact_store + .reserve_outputs(&artifact.artifact_id, &projection_key, outputs) + .await?; + let manifests = reservation.manifests().to_vec(); + if let Some(details) = manifests.iter().find(|m| m.name == "compact-details.json") { + result["selectionEvidence"]["uri"] = json!(details.manifest_uri); + } + if let Some(old_uri) = result["metadataDelivery"]["uri"] + .as_str() + .map(str::to_owned) + && let Some(manifest) = manifests + .iter() + .find(|m| m.name == "projection-details.json") + && old_uri != manifest.manifest_uri + { + fn rebind(value: &mut Value, old: &str, new: &str) { + match value { + Value::String(s) if s == old => *s = new.to_owned(), + Value::Array(items) => items.iter_mut().for_each(|v| rebind(v, old, new)), + Value::Object(items) => items.values_mut().for_each(|v| rebind(v, old, new)), + _ => {} + } + } + rebind(result, &old_uri, &manifest.manifest_uri); + } + let result = result.as_object_mut().ok_or_else(|| { + DevupError::new( + ErrorCode::DevupFigmaHandoffInvalid, + "The resource delivery result is not a JSON object.", + false, + ) + })?; + for (field, name) in [ + ("imageFillAudit", "image-fill-audit.json"), + ("imageAudit", "image-audit.json"), + ("assetCssMap", "asset-css-map.json"), + ] { + if let Some(audit) = result.get_mut(field).and_then(Value::as_object_mut) { + audit.remove("assets"); + if let Some(manifest) = manifests.iter().find(|m| m.name == name) { + audit.insert("detailsResource".into(), json!({"uri":manifest.manifest_uri,"name":manifest.name,"contentHash":manifest.sha256,"requiresFullDetailRead":true})); + } + } + } + if let Some(plan) = result + .get_mut("webReadyImages") + .and_then(Value::as_object_mut) + { + plan.remove("plan"); + if let Some(manifest) = manifests.iter().find(|m| m.name == "web-ready-images.json") { + plan.insert("detailsResource".into(), json!({"uri":manifest.manifest_uri,"name":manifest.name,"contentHash":manifest.sha256,"requiresFullDetailRead":true})); + } + } + for field in [ + "tsx", + "componentTsx", + "responsiveTsx", + "devupJson", + "rawSnapshot", + "rawPayload", + "sourceMap", + "designFingerprints", + "designChanges", + "assetManifest", + "pageScaffold", + "referencePng", + "outline", + ] { + if field == "outline" + && result + .get("deliverable") + .is_some_and(|d| d["kind"] == "outline-data") + { + continue; + } + result.remove(field); + } + if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { + for frame in frames { + if let Some(frame) = frame.as_object_mut() { + frame.remove("tsx"); + frame.remove("componentTsx"); + frame.remove("sourceMap"); + frame.remove("pageScaffold"); + } + } + } + if let Some(deliverable) = result + .get_mut("deliverable") + .filter(|d| d["kind"] != "selected-data" && d["kind"] != "outline-data") + { + deliverable["note"] = json!(if deliverable["isFinal"] == true { + "The final TSX deliverables are in resources. Read the linked resources to implement them." + } else { + "The available TSX outputs are in resources. Review projectionIssues and outputResults before using them; these outputs are not final." + }); + } + result.insert( + "resources".to_owned(), + Value::Array( + manifests + .into_iter() + .map(|manifest| { + json!({ + "type": "resource_link", + "uri": manifest.manifest_uri, + "name": manifest.name, + "mimeType": manifest.mime_type, + "size": manifest.raw_bytes, + "contentHash": manifest.sha256, + "assetMetadata": manifest.asset_metadata, + "expiresAt": format_epoch_rfc3339(manifest.expires_at_epoch_seconds) + }) + }) + .collect(), + ), + ); + // Resource promotion must also bound the final metadata envelope. + if let Err(error) = + choose_delivery_for_result(DeliveryMode::Inline, &Value::Object(result.clone()), &[]) + { + reservation.rollback(); + return Err(error); + } + Ok(Some(DeliveryAttachment { reservation })) +} + +pub(super) async fn rollback_delivery( + _artifact_store: &ArtifactStore, + _artifact: &ArtifactLookup, + attachment: Option, +) { + if let Some(attachment) = attachment { + attachment.reservation.rollback(); + } +} + +pub(super) fn commit_delivery(attachment: Option) { + if let Some(attachment) = attachment { + attachment.reservation.commit(); + } +} diff --git a/crates/devup-mcp/src/server/projection/resource_outputs.rs b/crates/devup-mcp/src/server/projection/resource_outputs.rs new file mode 100644 index 00000000..7a1bf5f1 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/resource_outputs.rs @@ -0,0 +1,264 @@ +pub(super) fn projected_outputs_from_result( + result: &Map, +) -> Result, DevupError> { + let mut outputs = Vec::new(); + for field in ["tsx", "componentTsx", "responsiveTsx"] { + if let Some(tsx) = result.get(field).and_then(Value::as_str) { + outputs.push(ProjectedOutput::text( + field, + "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", + "application/json", + devup_json.as_bytes().to_vec(), + )); + } + if let Some(reference) = result.get("referencePng") { + let data = reference + .get("dataBase64") + .and_then(Value::as_str) + .ok_or_else(|| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "The referencePng resource has no base64 data.", + false, + ) + })?; + let bytes = STANDARD.decode(data.as_bytes()).map_err(|_| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "The referencePng resource base64 is invalid.", + false, + ) + })?; + outputs.push(ProjectedOutput::binary("reference.png", "image/png", bytes)); + } + for (field, name) in [ + ("rawSnapshot", "raw-snapshot.json"), + ("rawPayload", "raw-payload.json"), + ("sourceMap", "source-map.json"), + ("designFingerprints", "design-fingerprints.json"), + ("designChanges", "design-changes.json"), + ("assetManifest", "asset-manifest.json"), + ("imageFillAudit", "image-fill-audit.json"), + ("imageAudit", "image-audit.json"), + ("assetCssMap", "asset-css-map.json"), + ("webReadyImages", "web-ready-images.json"), + ("pageScaffold", "page-scaffold.json"), + ("outline", "outline.json"), + ("selected", "selected.json"), + ] { + if let Some(value) = result.get(field) { + if field == "imageFillAudit" && value["assets"].as_array().is_none_or(Vec::is_empty) { + continue; + } + outputs.push(ProjectedOutput::text( + name, + "application/json", + encode_projected_json(value)?, + )); + } + } + if let Some(frames) = result.get("frames").and_then(Value::as_array) { + for (index, frame) in frames.iter().enumerate() { + if let Some(scaffold) = frame.get("pageScaffold") { + outputs.push(ProjectedOutput::text( + format!("frame-{}.page-scaffold.json", index + 1), + "application/json", + encode_projected_json(scaffold)?, + )); + } + for field in ["tsx", "componentTsx"] { + if let Some(tsx) = frame.get(field).and_then(Value::as_str) { + let name = if field == "tsx" { + format!("frame-{}.tsx", index + 1) + } else { + format!("frame-{}.component.tsx", index + 1) + }; + outputs.push(ProjectedOutput::text( + name, + "text/typescript", + tsx.as_bytes().to_vec(), + )); + } + } + if let Some(source_map) = frame.get("sourceMap") { + outputs.push(ProjectedOutput::text( + format!("frame-{}.source-map.json", index + 1), + "application/json", + encode_projected_json(source_map)?, + )); + } + } + } + Ok(outputs) +} + +fn encode_projected_json(value: &Value) -> Result, DevupError> { + serde_json::to_vec(value).map_err(|error| { + DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + format!("Cannot serialize the resource output to JSON: {error}"), + false, + ) + }) +} + +pub(super) struct DeliveryAttachment { + reservation: OutputReservation, +} + +/// Keep complete diagnostic evidence readable without duplicating it into a +/// multi-megabyte tool envelope. Only resource delivery changes these details; +/// diagnostic identities/counts/severities and verdicts remain inline. +fn resource_diagnostic_details( + result: &mut Value, + outputs: &mut Vec, + artifact_id: &str, +) -> Result<(), DevupError> { + const FIELDS: [&str; 3] = ["diagnostics", "projectionIssues", "projectionEvidence"]; + const BULKY: [&str; 5] = [ + "verdictScope", + "projectionReview", + "placementContracts", + "outputResults", + "widthBehavior", + ]; + let mut full = json!({"response":{},"frames":[]}); + for field in FIELDS.into_iter().chain(BULKY) { + if let Some(value) = result.get(field) { + full["response"][field] = value.clone(); + } + } + if let Some(frames) = result["frames"].as_array() { + for frame in frames { + let mut record = json!({"nodeId":frame["nodeId"]}); + for field in FIELDS.into_iter().chain(BULKY) { + if let Some(value) = frame.get(field) { + record[field] = value.clone(); + } + } + full["frames"].as_array_mut().unwrap().push(record); + } + } + let bytes = encode_projected_json(&full)?; + if bytes.len() <= super::delivery::MAX_INLINE_OUTPUT_BYTES / 2 { + return Ok(()); + } + let output = ProjectedOutput::text("projection-details.json", "application/json", bytes); + let uri = output.manifest_uri(artifact_id); + fn preview( + value: &Value, + depth: usize, + path: &str, + omissions: &mut Map, + ) -> Value { + let (original, returned) = match value { + Value::Array(v) => (v.len(), if depth > 0 { v.len().min(8) } else { 0 }), + Value::Object(v) => (v.len(), if depth > 0 { v.len().min(32) } else { 0 }), + Value::String(v) => (v.chars().count(), v.chars().count().min(320)), + _ => (0, 0), + }; + if original > returned { + omissions.insert(path.into(), json!({"originalCount":original,"returnedCount":returned,"omittedCount":original-returned})); + } + match value { + Value::String(s) => json!(s.chars().take(320).collect::()), + Value::Array(items) if depth > 0 => json!( + items + .iter() + .take(8) + .enumerate() + .map(|(i, v)| preview(v, depth - 1, &format!("{path}/{i}"), omissions)) + .collect::>() + ), + Value::Object(items) if depth > 0 => Value::Object( + items + .iter() + .take(32) + .map(|(k, v)| { + ( + k.clone(), + preview( + v, + depth - 1, + &format!("{path}/{}", k.replace('~', "~0").replace('/', "~1")), + omissions, + ), + ) + }) + .collect(), + ), + Value::Array(items) => json!({"state":"resource-details","entryCount":items.len()}), + Value::Object(items) => json!({"state":"resource-details","entryCount":items.len()}), + _ => value.clone(), + } + } + fn summarize(value: &mut Value, prefix: &str) { + for field in FIELDS { + if let Some(items) = value[field].as_array_mut() { + for (index, item) in items.iter_mut().enumerate() { + let Some(details) = item.get_mut("details").filter(|d| d.is_object()) else { + continue; + }; + let mut summary = Map::new(); + // Preserve small source values (notably FILL/HUG) and + // contracts; only bulky detail values need a resource. + for (key, value) in details.as_object().unwrap() { + if value.to_string().len() <= 512 + || (key == "originalValue" && value.to_string().len() <= 1024) + { + summary.insert(key.clone(), value.clone()); + } + } + for key in [ + "classification", + "nextAction", + "output", + "nodeIds", + "candidateCount", + "state", + "propertyMappingVerified", + ] { + if let Some(value) = details.get(key) { + summary.insert(key.into(), value.clone()); + } + } + if Value::Object(summary.clone()) == *details { + continue; + } + // Share the long URI once at the response root rather + // than repeating it for every diagnostic in both MCP forms. + summary.insert("fullDetailsResource".into(), json!({"uriPath":"/metadataDelivery/uri","jsonPointer":format!("{prefix}/{field}/{index}/details")})); + *details = Value::Object(summary); + } + } + } + for field in BULKY { + if let Some(original) = value + .get(field) + .filter(|v| v.to_string().len() > 16_384) + .cloned() + { + let mut omissions = Map::new(); + value[field] = preview(&original, 3, "", &mut omissions); + value["metadataPreviewFields"][field] = json!({"truncated":true,"uriPath":"/metadataDelivery/uri","jsonPointer":format!("{prefix}/{field}"),"originalBytes":original.to_string().len(),"omissions":omissions,"maxArrayEntries":8,"maxObjectEntries":32,"maxStringChars":320,"maxDepth":3}); + } + } + } + summarize(result, "/response"); + if let Some(frames) = result["frames"].as_array_mut() { + for (index, frame) in frames.iter_mut().enumerate() { + summarize(frame, &format!("/frames/{index}")); + } + } + result["metadataDelivery"] = json!({"mode":"resource-details","uri":uri,"name":"projection-details.json","fields":FIELDS.into_iter().chain(BULKY).collect::>(),"requiresFullDetailRead":true, + "note":include_str!("../text/projection-resource_diagnostic_details-9b1c1ed8.md")}); + outputs.push(output); + Ok(()) +} diff --git a/crates/devup-mcp/src/server/projection/safe_file_names.rs b/crates/devup-mcp/src/server/projection/safe_file_names.rs new file mode 100644 index 00000000..c8a15cec --- /dev/null +++ b/crates/devup-mcp/src/server/projection/safe_file_names.rs @@ -0,0 +1,58 @@ +/// Bound generated filenames, retaining a hash when transliteration would lose +/// identity. The generated folders are short; user-chosen output roots remain +/// subject to OutputPolicy and the host filesystem's own path limits. +fn host_safe_file_name(name: &str) -> String { + let (stem, extension) = name.rsplit_once('.').unwrap_or((name, "")); + let mut slug = stem + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '@') { + c + } else { + '-' + } + }) + .collect::(); + slug = slug.trim_matches('-').to_owned(); + let upper = slug.to_ascii_uppercase(); + let reserved = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (upper.len() == 4 + && (upper.starts_with("COM") || upper.starts_with("LPT")) + && matches!(upper.as_bytes()[3], b'1'..=b'9')); + let needs_hash = + !name.is_ascii() || slug != stem || slug.len() > 120 || slug.is_empty() || reserved; + if slug.is_empty() || reserved { + slug.insert_str(0, "asset-"); + } + if needs_hash { + slug.truncate(100); + } + if needs_hash { + slug.push('-'); + slug.push_str(&sha256_hex(name.as_bytes())[..12]); + } + let extension = extension + .chars() + .filter(char::is_ascii_alphanumeric) + .take(10) + .collect::(); + if !extension.is_empty() { + slug.push('.'); + slug.push_str(&extension); + } + slug +} + +/// `/icons/ic:round-arrow-left.svg` as `/icons/ic-round-arrow-left-b041d24e668d.svg`. Only +/// the file name is touched; the folder the code looks in is left alone. +fn host_safe_asset_path(path: &str) -> String { + for prefix in ["/icons/", "/images/"] { + if let Some(name) = path.strip_prefix(prefix) { + return format!("{prefix}{}", host_safe_file_name(name)); + } + } + match path.rsplit_once('/') { + Some((directory, name)) => format!("{directory}/{}", host_safe_file_name(name)), + None => host_safe_file_name(path), + } +} diff --git a/crates/devup-mcp/src/server/projection/section_selection_reply.rs b/crates/devup-mcp/src/server/projection/section_selection_reply.rs new file mode 100644 index 00000000..50cdfefb --- /dev/null +++ b/crates/devup-mcp/src/server/projection/section_selection_reply.rs @@ -0,0 +1,211 @@ +use super::*; + +pub(super) struct SectionSelection<'a> { + pub(super) payload: &'a CollectedPayload, + pub(super) artifact: &'a ArtifactLookup, + pub(super) artifact_store: &'a ArtifactStore, + pub(super) outputs: &'a Vec, + pub(super) previous_design_fingerprints: &'a Option, + pub(super) component_name: &'a Option, + pub(super) include_diagnostics: bool, + pub(super) root_layout: devup_mcp_devup_ui::codegen::RootLayout, + pub(super) asset_names_per_node: bool, + pub(super) scope: &'a String, + pub(super) theme_options: &'a theme_export::ThemeExportOptions, + pub(super) strict: bool, + pub(super) output_paths: &'a BTreeMap, + pub(super) page_scaffold: &'a Option, + pub(super) frame_ids: &'a Vec, + pub(super) all_screens: bool, + pub(super) asset_captures: &'a Vec, + pub(super) web_ready_images: bool, + pub(super) asset_output_paths: &'a BTreeMap, + pub(super) asset_public_root: &'a Option, + pub(super) delivery: DeliveryMode, + pub(super) result: Map, + pub(super) recovery_arguments: &'a Value, + pub(super) candidates: &'a Vec, + pub(super) candidates_truncated: bool, + pub(super) completeness_report: &'a devup_mcp_figma::PayloadCompletenessReport, + pub(super) scaffold_requested: bool, +} + +pub(super) async fn section_selection_reply( + input: SectionSelection<'_>, +) -> Result { + let SectionSelection { + payload, + artifact, + artifact_store, + outputs, + previous_design_fingerprints, + component_name, + include_diagnostics, + root_layout, + asset_names_per_node, + scope, + theme_options, + strict, + output_paths, + page_scaffold, + frame_ids, + all_screens, + asset_captures, + web_ready_images, + asset_output_paths, + asset_public_root, + delivery, + mut result, + recovery_arguments, + candidates, + candidates_truncated, + completeness_report, + scaffold_requested, + } = input; + // Both candidate producers report actual list truncation. + // Reaching the limit alone does not mean candidates were omitted. + let truncated = candidates_truncated; + let (screens, explicit_candidates): (Vec<_>, Vec<_>) = candidates + .iter() + .partition(|candidate| candidate.node.is_screen_candidate()); + let quality = OutputQuality { + acquisition: acquisition_quality(completeness_report, false), + projection: projection_quality(false, &[]), + theme: theme_quality(false, 0, 0), + assets: assets_quality(false, &[], &[]), + }; + result.insert("status".to_owned(), json!("selection_required")); + if let Some(summary) = result.get_mut("assetSummary") { + summary["manifestIncluded"] = json!(false); + } + result.insert("quality".to_owned(), json!(quality)); + result.insert( + "selection".to_owned(), + json!({ + "kind": "screen-frame", + "status": if truncated { "partial" } else { "complete" }, + "count": screens.len(), + "candidates": screens, + "explicitCandidates": explicit_candidates, + "truncated": truncated + }), + ); + result.insert( + "nextAction".to_owned(), + json!({ + "why": "This is a Section candidate list. Screen artifacts have not been exported yet.", + "how": include_str!("../text/projection-project_operation-2b397164.md"), + "doNot": "Do not try to collect the whole Section at once." + }), + ); + // An example built from this call's own artifact and a real + // candidate, so the next step is a call to run rather than a + // shape to assemble. + if let Some(candidate) = screens.first().or_else(|| explicit_candidates.first()) { + result + .get_mut("nextAction") + .expect("nextAction was inserted")["example"] = json!({ + "tool": "devup_figma_export", + "arguments": { + "artifactId": artifact.artifact_id, + "frameIds": [candidate.node.node_id], + "outputs": outputs, + "delivery": "resource", + "componentName": component_name, + "includeDiagnostics": include_diagnostics, + "rootLayout": if root_layout == devup_mcp_devup_ui::codegen::RootLayout::Embedded { "embedded" } else { "standalone" }, + "assetNamesPerNode": asset_names_per_node, + "scope": scope, + "devupJsonMode":if theme_options.mode.is_empty() {"full"} else {&theme_options.mode},"projectRoot":theme_options.project_root, + "strict": strict, + "debug": outputs.iter().any(|output| super::super::validation::DIAGNOSIS_OUTPUTS.contains(&output.as_str())), + "outputPaths": output_paths, + "assetPublicRoot": asset_public_root, + "assetRequests": asset_captures.iter().map(|capture| json!({ + "assetId":capture.asset_id,"format":capture.format,"scale":capture.scale,"quality":capture.quality,"lossless":capture.lossless,"renderMode":capture.render_mode,"usageAspectRatio":capture.usage_aspect_ratio,"maxWidth":capture.max_width,"srcset":capture.srcset,"visibleScopeNodeId":capture.visible_scope_node_id, + "outputPath":asset_output_paths.get(&capture.asset_id) + })).collect::>() + } + }); + let arguments = result.get_mut("nextAction").expect("nextAction")["example"]["arguments"] + .as_object_mut() + .expect("example arguments"); + if web_ready_images { + arguments.remove("assetRequests"); + arguments.insert("preset".into(), json!("web-ready-images")); + } + if let Some(previous) = &previous_design_fingerprints { + arguments.insert("previousDesignFingerprints".into(), json!(previous)); + } + if let Some(options) = &page_scaffold { + arguments.insert("pageScaffold".into(), json!(options)); + } + arguments.insert("scope".into(), json!("node")); + if outputs.iter().any(|output| output == "referencePng") { + arguments.remove("artifactId"); + arguments.remove("frameIds"); + arguments.insert("url".into(), json!(candidate.canonical_url)); + } + } else { + result.get_mut("nextAction").expect("nextAction")["example"] = json!({ + "tool":"devup_figma_explore", "arguments":{ + "url":payload.target.link(payload.target.node_id.as_deref()), + "limit":100 + } + }); + } + let output_count = outputs + .iter() + .collect::>() + .len() + .max(1); + let png = outputs.iter().any(|s| s == "referencePng"); + let batch_size = if png { + 1 + } else { + (12 / output_count).clamp(1, 6) + }; + let template = result["nextAction"]["example"]["arguments"].clone(); + let batches: Vec<_> = screens + .chunks(batch_size) + .map(|batch| { + let mut args = template.clone(); + if png { + args["url"] = json!(batch[0].canonical_url); + } else { + args["frameIds"] = json!(batch.iter().map(|c| &c.node.node_id).collect::>()); + } + json!({"tool":"devup_figma_export","arguments":args}) + }) + .collect(); + result.get_mut("nextAction").unwrap()["maxFramesPerCall"] = json!(batch_size); + result.get_mut("nextAction").unwrap()["batches"] = json!(batches); + for category in ["candidates", "explicitCandidates"] { + if let Some(list) = result.get_mut("selection").unwrap()[category].as_array_mut() { + for candidate in list { + if candidate.get("textPreviewState").is_none() { + candidate["textPreviewState"] = json!("unknown-in-cached-snapshot"); + } + candidate["nextAction"] = json!({"tool":"devup_figma_export","arguments":{ + "url":candidate["canonicalUrl"],"outputs":["tsx"],"scope":"node","delivery":"resource"}}); + } + } + } + if scaffold_requested { + page_scaffold::selection_guidance(&mut result); + } + responsive_groups::completion(&mut result, frame_ids, all_screens); + let projected = projected_outputs_from_result(&result)?; + let mut response = Value::Object(result); + let attachment = apply_delivery( + &mut response, + delivery, + artifact_store, + artifact, + projected, + Some(recovery_arguments), + ) + .await?; + commit_delivery(attachment); + Ok(response) +} diff --git a/crates/devup-mcp/src/server/projection/setup.rs b/crates/devup-mcp/src/server/projection/setup.rs new file mode 100644 index 00000000..56018f54 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/setup.rs @@ -0,0 +1,61 @@ +#[path = "asset_policy.rs"] +mod asset_policy; +#[path = "exclude_hidden_asset_outputs.rs"] +mod exclude_hidden_asset_outputs; +#[path = "finalize_export_delivery.rs"] +mod finalize_export_delivery; +#[path = "image_fill_audit.rs"] +mod image_fill_audit; +#[path = "../page_scaffold.rs"] +pub(crate) mod page_scaffold; +#[path = "prepare_asset_manifest.rs"] +mod prepare_asset_manifest; +#[path = "project_node_and_theme_outputs.rs"] +mod project_node_and_theme_outputs; +#[path = "project_responsive_output.rs"] +mod project_responsive_output; +#[path = "project_section_frames.rs"] +mod project_section_frames; +#[path = "responsive_groups.rs"] +mod responsive_groups; +#[path = "section_selection_reply.rs"] +mod section_selection_reply; +#[path = "summarize_projection_diagnostics.rs"] +mod summarize_projection_diagnostics; +#[path = "theme_export.rs"] +pub(crate) mod theme_export; + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use super::result_contract::{ + attach_review_and_deliverable, failure_issue, output_result, scope_output, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use devup_mcp_devup_ui::{ + codegen::{ + CodegenOptions, generate_component, normalize_component_name, responsive::merge_breakpoints, + }, + theme::{generate_devup_json, variable_snapshot_from_result}, +}; +use devup_mcp_figma::{ + AssetManifest, AssetStatus, CollectedPayload, CollectionStats, DevupError, Diagnostic, + DiagnosticSeverity, ErrorCode, ExploreOptions, SearchOptions, TargetKind, classify_target, + explore_snapshot, search_snapshot, +}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; + +use super::{ + artifacts::{ArtifactLookup, ArtifactStore, OutputReservation}, + delivery::{DeliveryMode, ProjectedOutput, choose_delivery_for_result}, + format_epoch_rfc3339, + operation::PendingOperation, + output::{OutputPolicy, OutputTransaction}, + parse_scope, + quality::{ + AcquisitionQuality, OutputQuality, ProjectionQuality, acquisition_quality, assets_quality, + projection_quality, theme_quality, + }, + section_candidate_as_explore, section_index_from_payload, +}; diff --git a/crates/devup-mcp/src/server/projection/summarize_projection_diagnostics.rs b/crates/devup-mcp/src/server/projection/summarize_projection_diagnostics.rs new file mode 100644 index 00000000..deae7f8d --- /dev/null +++ b/crates/devup-mcp/src/server/projection/summarize_projection_diagnostics.rs @@ -0,0 +1,245 @@ +use super::*; + +pub(super) struct ProjectionDiagnostics<'a> { + pub(super) payload: &'a CollectedPayload, + pub(super) outputs: &'a Vec, + pub(super) include_diagnostics: bool, + pub(super) root_layout: devup_mcp_devup_ui::codegen::RootLayout, + pub(super) asset_names_per_node: bool, + pub(super) scope: &'a String, + pub(super) strict: bool, + pub(super) frame_ids: &'a Vec, + pub(super) all_screens: bool, + pub(super) asset_captures: &'a Vec, + pub(super) delivery: DeliveryMode, + pub(super) result: Map, + pub(super) failures: Vec, + pub(super) warnings: Vec, + pub(super) completeness_report: &'a devup_mcp_figma::PayloadCompletenessReport, + pub(super) section_tsx_projected: bool, + pub(super) projection_diagnostics: Vec, + pub(super) fidelity_reports: Vec, + pub(super) theme_conflict_count: usize, + pub(super) theme_unresolved_count: usize, + pub(super) component_name_for_components: &'a Option, + pub(super) target_kind: TargetKind, +} + +pub(super) fn summarize_projection_diagnostics( + input: ProjectionDiagnostics<'_>, +) -> Result<(Map,), DevupError> { + let ProjectionDiagnostics { + payload, + outputs, + include_diagnostics, + root_layout, + asset_names_per_node, + scope, + strict, + frame_ids, + all_screens, + asset_captures, + delivery, + mut result, + failures, + warnings, + completeness_report, + section_tsx_projected, + mut projection_diagnostics, + fidelity_reports, + theme_conflict_count, + theme_unresolved_count, + component_name_for_components, + target_kind, + } = input; + for failure in failures.iter().filter(|f| f.get("output").is_some()) { + let issue = failure_issue(failure); + projection_diagnostics.push(issue.clone()); + if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { + for frame in frames + .iter_mut() + .filter(|f| f["nodeId"] == failure["nodeId"]) + { + frame["projectionIssues"] + .as_array_mut() + .unwrap() + .push(json!(issue)); + } + } + } + responsive_groups::completion(&mut result, frame_ids, all_screens); + result.insert( + "placementContracts".into(), + json!( + projection_diagnostics + .iter() + .filter(|d| d.code == "DEVUP_CODEGEN_PLACEMENT_CONTRACT") + .collect::>() + ), + ); + result.insert( + "projectionIssues".into(), + json!( + projection_diagnostics + .iter() + .filter( + |d| d.fidelity_impact() != devup_mcp_figma::FidelityImpact::None + || d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" + ) + .collect::>() + ), + ); + result.insert( + "projectionEvidence".into(), + json!( + projection_diagnostics + .iter() + .filter(|d| { + d.fidelity_impact() == devup_mcp_figma::FidelityImpact::None + && matches!( + d.code.as_str(), + "DEVUP_CODEGEN_NON_RENDERING_ASSET" + | "DEVUP_CODEGEN_ABSOLUTE_VERIFIED" + | "DEVUP_CODEGEN_LAYOUT_ACCOUNTED_FOR" + | "DEVUP_CODEGEN_PROPERTY_EVIDENCE" + ) + }) + .collect::>() + ), + ); + let mut quality = OutputQuality { + acquisition: acquisition_quality(completeness_report, false), + projection: projection_quality( + outputs.iter().any(|output| { + matches!( + output.as_str(), + "tsx" | "componentTsx" | "responsiveTsx" | "pageScaffold" + ) + }), + &projection_diagnostics, + ), + theme: theme_quality( + outputs.iter().any(|output| output == "devupJson"), + theme_conflict_count, + theme_unresolved_count, + ), + assets: assets_quality( + outputs.iter().any(|output| output == "assetManifest"), + &asset_captures + .iter() + .map(|capture| capture.asset_id.clone()) + .collect::>(), + &payload.assets, + ), + }; + if let Some(summary) = result.get("assetSummary") { + quality.assets = asset_collection_quality(summary); + } + if failures + .iter() + .any(|failure| failure.get("assetId").is_some()) + { + quality.assets = super::super::quality::AssetsQuality::Partial; + } + if failures + .iter() + .any(|failure| failure.get("output").is_some()) + { + quality.projection = super::super::quality::ProjectionQuality::Lossy; + } + if !warnings.is_empty() { + result.insert("warnings".to_owned(), json!(warnings)); + } + if target_kind == TargetKind::Section && !failures.is_empty() { + let failed_frames = failures + .iter() + .filter(|failure| failure.get("assetId").is_none()) + .filter_map(|failure| failure["nodeId"].as_str()) + .collect::>(); + if !failed_frames.is_empty() { + let mut arguments = json!({"url":payload.target.link(payload.target.node_id.as_deref()), + "frameIds":failed_frames,"outputs":outputs,"scope":scope,"delivery":delivery, + "rootLayout":match root_layout { devup_mcp_devup_ui::codegen::RootLayout::Standalone => "standalone", devup_mcp_devup_ui::codegen::RootLayout::Embedded => "embedded" }, + "assetNamesPerNode":asset_names_per_node,"strict":strict,"includeDiagnostics":include_diagnostics}); + if let Some(name) = &component_name_for_components { + arguments["componentName"] = json!(name); + } + result.insert("resume".into(), json!({"tool":"devup_figma_export", "arguments":arguments, + "note":"Retry only failed frames. Missing collected nodes require a fresh URL acquisition; component master failures may require collecting the master or requesting tsx."})); + } + } + result.insert("failures".to_owned(), json!(failures)); + let fidelity_violation = fidelity_reports + .iter() + .any(|report| !report.strict_compatible()); + if strict && (quality.strict_violation() || fidelity_violation || !failures.is_empty()) { + return Err(DevupError::with_details( + ErrorCode::DevupSnapshotUnsupported, + format!( + "strict export only allows exact/complete output: status={}, quality={}", + quality.status(), + serde_json::to_string(&quality).unwrap_or_default() + ), + false, + json!({ + "quality": quality, + "fidelity": fidelity_reports, + "failures": failures, + "completenessReport": completeness_report, + "projectionIssues": result.get("projectionIssues") + }), + )); + } + let final_status = if failures.is_empty() { + quality.status() + } else { + "partial" + }; + result.insert("status".to_owned(), json!(final_status)); + result.insert("quality".to_owned(), json!(quality)); + if let Some(frames) = result.get_mut("frames").and_then(Value::as_array_mut) { + for frame in frames { + let id = frame["nodeId"].as_str().unwrap_or_default().to_owned(); + let frame_failures = failures + .iter() + .filter(|f| f["nodeId"] == id) + .cloned() + .collect::>(); + attach_review_and_deliverable( + frame.as_object_mut().unwrap(), + outputs, + &frame_failures, + Some(&id), + ); + if !frame_failures.is_empty() { + frame["failures"] = json!(frame_failures); + } + } + } + attach_review_and_deliverable( + &mut result, + outputs, + &failures, + payload.target.node_id.as_deref(), + ); + // Only the whole-node tsx is missing its fidelity report at this + // point; each Section frame already carries its own. + if !section_tsx_projected && let Some(report) = fidelity_reports.first() { + let mut carrier = Value::Object(Map::new()); + attach_fidelity(&mut carrier, report, include_diagnostics); + if let Some(fidelity) = carrier.get("fidelity") { + result.insert("fidelity".to_owned(), fidelity.clone()); + } + } + let mut carrier = Value::Object(Map::new()); + attach_completeness_report( + &mut carrier, + quality, + completeness_report, + include_diagnostics, + ); + if let Some(report) = carrier.get("completenessReport") { + result.insert("completenessReport".to_owned(), report.clone()); + } + Ok((result,)) +} diff --git a/crates/devup-mcp/src/server/projection/verification_scope.rs b/crates/devup-mcp/src/server/projection/verification_scope.rs new file mode 100644 index 00000000..75133ebe --- /dev/null +++ b/crates/devup-mcp/src/server/projection/verification_scope.rs @@ -0,0 +1,100 @@ +/// Which widths the generated code is checked at, and how: the mapping and the +/// width-change report are static, and nothing here was rendered. Said apart +/// so an exact grade at the drawn width is not read as a claim about others. +fn attach_verification_scope(value: &mut Value, responsive_widths: &[f64]) { + let mut widths = value["widthBehavior"]["designWidth"] + .as_f64() + .into_iter() + .chain( + value["frames"] + .as_array() + .into_iter() + .flatten() + .filter_map(|frame| frame["widthBehavior"]["designWidth"].as_f64()), + ) + .chain(responsive_widths.iter().copied()) + .collect::>(); + widths.sort_by(f64::total_cmp); + widths.dedup(); + if widths.is_empty() { + return; + } + let mut scope = json!({ + "collectedWidths": widths, + "staticallyChecked": { + "mapping": "Each generated property against its source field, at the collected widths (quality.projection).", + "widthChange": "Anchors and sizing intent when a container is not its drawn width (widthBehavior); read from constraints and geometry, not rendered.", + }, + "rendered": { + "state": "not-performed", + "note": "Nothing was rendered. devup_visual_compare compares a consumer-rendered PNG at a drawn width; devup_runtime_audit plans and ingests a consumer-side browser run at any widths.", + }, + }); + if !responsive_widths.is_empty() { + scope["betweenCollectedWidths"] = json!( + "responsiveTsx switches values at breakpoints; widths between and beyond the collected ones were never drawn." + ); + } + value["verificationScope"] = scope; +} + +/// Which further outputs this artifactId can project without calling Figma, +/// and which need the original url again. Each output keeps its own +/// preconditions (responsiveTsx still needs several widths); this only says +/// whether the capture holds what it reads. +fn reuse_summary(capabilities: &super::artifacts::ArtifactCapabilities) -> Value { + use super::artifacts::ArtifactKind; + use devup_mcp_figma::ResourceScope; + let mut with_artifact = Vec::new(); + let mut needs_url = Vec::new(); + match capabilities.kind { + ArtifactKind::Design => { + with_artifact.extend([ + "outline", + "tsx", + "componentTsx", + "responsiveTsx", + "sourceMap", + "assetManifest", + "designFingerprints", + "designChanges", + "pageScaffold", + "rawSnapshot", + "rawPayload", + ]); + if capabilities.resource_scope == ResourceScope::None { + needs_url.push("devupJson"); + } else { + with_artifact.push("devupJson"); + } + if capabilities.resource_scope != ResourceScope::File { + needs_url.push("devupJson:scope=file"); + } + if capabilities.reference_png { + with_artifact.push("referencePng"); + } else { + needs_url.push("referencePng"); + } + needs_url.push("assetRequests-not-yet-captured"); + } + ArtifactKind::ThemeOnly => { + with_artifact.push("devupJson"); + needs_url.push("design-outputs"); + } + ArtifactKind::SectionIndex => {} + ArtifactKind::Measurement | ArtifactKind::Search | ArtifactKind::Explore => { + needs_url.push("export-outputs") + } + } + let mut reuse = json!({ + "withArtifactId": with_artifact, + "needsUrl": needs_url, + "processBound": "Held in this devup-mcp process's memory until expiresAt; a restarted or different process cannot serve this artifactId.", + }); + if capabilities.kind == ArtifactKind::SectionIndex { + reuse["selection"] = json!( + "frameIds or allScreens with this artifactId collects the selected screens; nothing is projected from the index itself." + ); + } + reuse +} diff --git a/crates/devup-mcp/src/server/projection/w1_regressions.rs b/crates/devup-mcp/src/server/projection/w1_regressions.rs new file mode 100644 index 00000000..741ada1d --- /dev/null +++ b/crates/devup-mcp/src/server/projection/w1_regressions.rs @@ -0,0 +1,972 @@ + +include!("design_drift_tests.rs"); +include!("feedback_theme_tests.rs"); +use super::super::artifacts::ArtifactRequestKey; +use super::*; +use devup_mcp_figma::{CollectionRequest, CollectionScope}; + +#[test] +fn r13_mapping_gap_is_not_exact_or_value_loss() { + let d = Diagnostic { + code: "DEVUP_CODEGEN_PROPERTY_UNMAPPED".into(), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::None), + ..Default::default() + }; + let quality = serde_json::to_value(projection_quality(true, &[d])).unwrap(); + assert_eq!(quality, "mapping-incomplete"); +} + +#[test] +fn r17_source_layout_gap_cannot_be_overwritten_by_complete_attribute_audit() { + let mut result = json!({"tsx":"","quality":{"projection":"lossy"},"status":"partial", + "outputResults":{"tsx":{"projection":"lossy","fidelity":{"layout":{"total":1,"covered":0},"uncoveredLayout":["child#layoutSizingHorizontal"]},"_propertyContract":[]}},"projectionIssues":[]}); + audit_delivered_properties(&mut result); + assert_eq!(result["mappingComplete"], false); + assert_eq!(result["outputResults"]["tsx"]["mappingComplete"], false); + assert_eq!(result["quality"]["projection"], "lossy"); +} + +#[test] +fn r13_review_malformed_postprocess_cannot_pass_an_empty_ast() { + let mut result = json!({"tsx":"export const X = = 2400000 + ); + assert_eq!(result["nextAction"]["arguments"]["delivery"], "resource"); + commit_delivery(attachment); +} + +#[tokio::test] +async fn r13_resource_next_action_executes_same_named_frame_without_upstream() { + use super::super::{DevupAuth, DevupServer, Services}; + use devup_mcp_figma::{AuthStatus, FigmaUpstream, ReadToolCall, UpstreamResult}; + use std::sync::Arc; + struct Offline; + #[async_trait::async_trait] + impl DevupAuth for Offline { + async fn status(&self) -> Result { + panic!("unexpected authentication") + } + async fn login(&self) -> Result { + panic!("unexpected login") + } + async fn logout(&self) -> Result { + panic!("unexpected logout") + } + } + #[async_trait::async_trait] + impl FigmaUpstream for Offline { + async fn list_tools(&self) -> Result, DevupError> { + panic!("unexpected upstream discovery") + } + async fn call_read_tool(&self, _: ReadToolCall) -> Result { + panic!("unexpected upstream read") + } + } + let store = ArtifactStore::default(); + let mut data = section(2); + data.metadata["selectedRootIds"] = json!(["1:1", "1:2"]); + let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )); + let artifact = store.insert(key, data).await.unwrap(); + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { + frame_ids, + component_name, + .. + } = &mut op + { + *frame_ids = vec!["1:1".into(), "1:2".into()]; + *component_name = Some("AuditScreen".into()); + } + let baseline = complete_operation( + op.clone(), + &artifact.payload, + "artifact", + &artifact, + &policy, + &store, + ) + .await + .unwrap(); + if let PendingOperation::Export { delivery, .. } = &mut op { + *delivery = DeliveryMode::Resource; + } + let resource = complete_operation( + op, + &artifact.payload, + "artifact", + &artifact, + &policy, + &store, + ) + .await + .unwrap(); + let mut server = DevupServer::new(Services::new(Arc::new(Offline), Arc::new(Offline))); + server.artifacts = store; + let result = server + .devup_figma_export(rmcp::handler::server::wrapper::Parameters( + serde_json::from_value(resource["nextAction"]["arguments"].clone()).unwrap(), + )) + .await + .unwrap() + .structured_content + .unwrap(); + assert_eq!(result["cache"]["artifactId"], artifact.artifact_id); + assert_eq!(result["frames"].as_array().unwrap().len(), 1); + assert_eq!(result["frames"][0]["tsx"], baseline["frames"][0]["tsx"]); +} + +#[test] +fn r13_delivery_gate_detects_future_postprocessor_even_without_public_map() { + let mut data = payload(); + for field in ["layoutSizingHorizontal", "layoutSizingVertical"] { + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert(field.into(), json!("FIXED")); + } + let output = generate_component(&data.snapshot, "1:1", &CodegenOptions::default()).unwrap(); + assert_eq!(output_result(&output)["projection"], "exact"); + let mut result = json!({"tsx":output.tsx,"quality":{"projection":"exact"},"status":"complete", + "deliverable":{"isFinal":true},"outputResults":{"tsx":output_result(&output)},"projectionIssues":[]}); + result["tsx"] = json!( + output + .tsx + .replacen(">(json!({ + "characters":"ab", "textTruncation":"DISABLED", + "styledTextSegments":[{"characters":"a","fontSize":16,"fontName":{"family":"Arial"}},{"characters":"b","fontSize":16,"fontName":{"family":"serif"}}] + })).unwrap()); + let output = generate_component(&data.snapshot, "1:1", &CodegenOptions::default()).unwrap(); + assert!( + output + .diagnostics + .iter() + .any(|d| d.code == "DEVUP_CODEGEN_PROPERTY_UNMAPPED") + ); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { strict, .. } = &mut op { + *strict = true; + } + let error = project(data, op).await.unwrap_err(); + assert!( + error.details["projectionIssues"] + .as_array() + .is_some_and(|issues| issues.iter().any(|d| d["code"] + == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" + && d["nodeId"] == "1:1" + && d["property"] == "fontFamily" + && d["fidelityImpact"] == "none")) + ); +} + +#[tokio::test] +async fn r13_agreeing_widths_verify_the_responsive_merge() { + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(responsive_payload(), op).await.unwrap(); + let responsive = &result["outputResults"]["responsiveTsx"]; + assert_eq!(responsive["mappingComplete"], true, "{result}"); + assert_eq!(responsive["fidelity"]["mergedMappingVerified"], true); + assert!( + !result["projectionIssues"] + .as_array() + .into_iter() + .flatten() + .any(|d| d["details"]["output"] == "responsiveTsx" + && d["code"] == "DEVUP_CODEGEN_PROPERTY_UNMAPPED") + ); +} + +#[tokio::test] +async fn r13_responsive_mapping_gap_is_explicit_and_strict_rejects() { + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + // Only the mobile width is filled. devup-ui keeps a value into the + // wider slots, so the desktop width is drawn red where it is not. + let mut data = responsive_payload(); + data.snapshot.nodes.get_mut("1:1").unwrap().fields.insert( + "fills".into(), + json!([{"type":"SOLID","color":{"r":1,"g":0,"b":0}}]), + ); + let result = project(data.clone(), op.clone()).await.unwrap(); + assert!(result["responsiveTsx"].is_string()); + assert_eq!(result["mappingComplete"], false); + let merge = result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .flat_map(|d| { + d["details"]["unmappedProperties"] + .as_array() + .into_iter() + .flatten() + }) + .find(|p| p["reason"] == "unverified-responsive-merge") + .unwrap(); + assert!( + merge["gaps"] + .as_array() + .unwrap() + .iter() + .any(|gap| gap["property"] == "bg" && gap["slot"] == 4 && gap["widthNodeId"] == "1:2"), + "{merge}" + ); + assert_ne!( + result["outputResults"]["responsiveTsx"]["projection"], + "exact" + ); + assert!( + result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|d| d["code"] == "DEVUP_CODEGEN_PROPERTY_UNMAPPED" + && d["details"]["output"] == "responsiveTsx" + && d["fidelityImpact"] == "none") + ); + if let PendingOperation::Export { strict, .. } = &mut op { + *strict = true; + } + assert!(project(data, op).await.is_err()); +} + +#[tokio::test] +async fn r13_final_frame_contract_covers_both_outputs_after_filename_rewrite() { + let mut data = payload(); + data.snapshot = serde_json::from_str(include_str!( + "../../../../../fixtures/r13/f13-1-snapshot.json" + )) + .unwrap(); + data.target.node_id = Some("3997:46715".into()); + let mut op = operation(&["tsx", "componentTsx", "sourceMap"]); + if let PendingOperation::Export { + include_diagnostics, + .. + } = &mut op + { + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + for field in ["tsx", "componentTsx"] { + let code = result[field].as_str().unwrap(); + let entries = result["sourceMap"]["byOutput"][field].as_array().unwrap(); + for attr in devup_mcp_devup_ui::provenance::attributes::generated_attributes(code) { + assert!( + entries + .iter() + .any(|e| e["generatedProperty"] == attr.source) + || result["projectionEvidence"] + .as_array() + .unwrap() + .iter() + .any(|d| d["details"]["output"] == field + && d["details"]["generatedProperty"] == attr.source), + "{field}: {}", + attr.source + ); + } + } +} + +#[tokio::test] +async fn r13_asset_derivations_survive_map_and_diagnostics_opt_out() { + let mut data = payload(); + data.snapshot = serde_json::from_str(include_str!( + "../../../../../fixtures/r13/f13-1-snapshot.json" + )) + .unwrap(); + data.target.node_id = Some("3997:46715".into()); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { + include_diagnostics, + .. + } = &mut op + { + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + assert!(result.get("sourceMap").is_none()); + for prop in ["boxShadow", "objectFit", "objectPos"] { + let evidence = result["projectionEvidence"] + .as_array() + .unwrap() + .iter() + .find(|d| d["nodeId"] == "3997:46741" && d["property"] == prop) + .unwrap_or_else(|| panic!("Missing asset evidence: {prop}")); + assert_eq!(evidence["fidelityImpact"], "none"); + assert_eq!(evidence["details"]["output"], "tsx"); + assert!( + evidence["details"]["sourceFields"] + .as_array() + .is_some_and(|fields| !fields.is_empty()) + ); + assert!(evidence["details"]["calculation"].is_string()); + } +} + +#[tokio::test] +async fn r13_frame_output_paths_report_unsupported_and_write_supported_keys() { + let dir = std::env::temp_dir().join(format!("r13-paths-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let target = dir.join("frame.tsx"); + let expected = crate::test_paths::canonical(&dir).join("frame.tsx"); + let mut op = operation(&["tsx", "sourceMap"]); + if let PendingOperation::Export { + frame_ids, + output_paths, + .. + } = &mut op + { + *frame_ids = vec!["1:1".into()]; + output_paths.insert( + "tsx".into(), + dir.join("ignored.tsx").to_string_lossy().into(), + ); + output_paths.insert("frame:1:1:tsx".into(), target.to_string_lossy().into()); + output_paths.insert("typo".into(), dir.join("typo").to_string_lossy().into()); + } + let result = project(section(2), op).await.unwrap(); + assert_eq!( + result["outputPathResults"]["diagnostics"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert!( + result["outputPathResults"]["supportedKeys"] + .as_array() + .unwrap() + .contains(&json!("frame:1:1:tsx")) + ); + assert_eq!( + std::fs::read_to_string(&target).unwrap(), + result["frames"][0]["tsx"].as_str().unwrap() + ); + assert_eq!( + result["outputPaths"]["frame:1:1:tsx"], + expected.to_string_lossy().as_ref() + ); + assert!(!dir.join("ignored.tsx").exists()); + std::fs::remove_file(target).unwrap(); + std::fs::remove_dir(dir).unwrap(); +} + +#[tokio::test] +async fn r13_explicit_resource_offers_single_screen_single_output_reprojection() { + let mut op = operation(&["tsx", "rawSnapshot", "sourceMap"]); + if let PendingOperation::Export { + frame_ids, + delivery, + .. + } = &mut op + { + *frame_ids = vec!["1:1".into(), "1:2".into()]; + *delivery = DeliveryMode::Resource; + } + let result = project(section(2), op).await.unwrap(); + let action = &result["nextAction"]; + assert_eq!(action["tool"], "devup_figma_export"); + assert!(action["arguments"]["artifactId"].is_string()); + assert_eq!(action["arguments"]["frameIds"], json!(["1:1"])); + assert_eq!(action["arguments"]["outputs"], json!(["tsx"])); + assert_eq!(action["arguments"]["delivery"], "inline"); + assert!(action["sizeEstimate"]["outputBytes"].as_u64().unwrap() > 0); + assert_eq!(action["sizeEstimate"]["isMeasuredReprojection"], false); +} + +#[tokio::test] +async fn r10_content_sizing_uncertainty_survives_diagnostics_opt_out() { + let data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-118-payload.json" + )) + .unwrap(); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { + frame_ids, + include_diagnostics, + .. + } = &mut op + { + *frame_ids = vec!["3997:46129".into()]; + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + for owner in [&result, &result["frames"][0]] { + let evidence: Vec<_> = owner["projectionEvidence"] + .as_array() + .unwrap() + .iter() + .filter(|d| d["details"]["resolution"] == "accounted-for-content-sizing") + .collect(); + assert_eq!(evidence.len(), 2); + for d in evidence { + assert_eq!(d["screenId"], "3997:46129"); + assert_eq!( + d["details"]["verification"]["reasonCode"], + "font-metrics-not-measured" + ); + assert_eq!(d["fidelityImpact"], "none"); + } + } +} + +#[tokio::test] +async fn r10_diagnostics_identify_exportable_screen() { + let data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-118-payload.json" + )) + .unwrap(); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { + frame_ids, + include_diagnostics, + .. + } = &mut op + { + *frame_ids = vec!["3997:46129".into()]; + *include_diagnostics = true; + } + let result = project(data, op).await.unwrap(); + for key in ["diagnostics", "projectionIssues", "placementContracts"] { + for d in result["frames"][0][key].as_array().unwrap() { + assert_eq!(d["screenId"], "3997:46129", "{d}"); + } + } + for d in result["projectionIssues"].as_array().unwrap() { + assert_eq!(d["screenId"], "3997:46129", "{d}"); + } +} + +#[tokio::test] +async fn r10_resource_note_does_not_claim_nonfinal_tsx_is_final() { + let data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-120-payload.json" + )) + .unwrap(); + let mut op = operation(&["tsx", "componentTsx"]); + if let PendingOperation::Export { + frame_ids, + delivery, + .. + } = &mut op + { + *frame_ids = vec!["3997:46582".into()]; + *delivery = DeliveryMode::Resource; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["deliverable"]["isFinal"], false); + assert!( + !result["deliverable"]["note"] + .as_str() + .unwrap() + .contains("final TSX") + ); +} + +#[tokio::test] +async fn r10_descendant_selection_has_corrected_arguments() { + let mut data = section(2); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["child"])); + data.snapshot.nodes.insert( + "child".into(), + serde_json::from_value( + json!({"id":"child","type":"TEXT","fields":{"parentId":"1:1","characters":"hello"}}), + ) + .unwrap(), + ); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec!["child".into()]; + } + let error = project(data.clone(), op).await.unwrap_err(); + assert_eq!( + error.details["nextAction"]["arguments"]["frameIds"], + json!(["1:1"]), + "{error:?}" + ); + assert_eq!( + error.details["selectionIssues"][0]["reason"], + "descendant-of-screen" + ); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec!["absent".into()]; + } + let error = project(data, op).await.unwrap_err(); + assert_eq!( + error.details["selectionIssues"][0]["reason"], + "not-found-in-section" + ); + assert!(error.details["nextAction"].is_null()); +} + +#[tokio::test] +async fn r3_responsive_has_its_own_fidelity_result() { + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(responsive_payload(), op).await.unwrap(); + assert_eq!( + result["outputResults"]["responsiveTsx"]["state"], + "produced" + ); + let fidelity = &result["outputResults"]["responsiveTsx"]["fidelity"]; + assert_eq!(fidelity["scope"], "breakpoint-source-projections"); + assert_eq!(fidelity["breakpoints"].as_array().unwrap().len(), 2); + assert_eq!(fidelity["mergedMappingVerified"], true); +} + +#[tokio::test] +async fn r2_responsive_hidden_instance_and_visible_desktop() { + for instance in [true, false] { + let mut data = responsive_payload(); + for i in 1..=2 { + let root = format!("1:{i}"); + let child = format!("2:{i}"); + data.snapshot + .nodes + .get_mut(&root) + .unwrap() + .fields + .insert("childrenIds".into(), json!([child])); + data.snapshot.nodes.insert( + child.clone(), + serde_json::from_value(json!({ + "id":child,"type":if instance {"INSTANCE"} else {"FRAME"}, + "fields":{"parentId":root,"name":"Logo","width":24,"height":24, + "opacity":if instance || i == 1 {0} else {1}, + "fills":[{"type":"IMAGE","imageHash":"logo","scaleMode":"TILE"}]} + })) + .unwrap(), + ); + } + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data, op).await.unwrap(); + let code = result["responsiveTsx"] + .as_str() + .expect("responsive TSX exists"); + devup_mcp_devup_ui::validation::validate_tsx(code).unwrap(); + assert!( + code.contains("visibility="), + "hidden layout must survive: {code}" + ); + if instance { + assert!(!code.contains("/images/"), "{code}"); + } else { + // Different paint prop sets can produce separate responsive + // branches. The desktop branch must then be independently + // visible instead of inheriting the hidden mobile box. + let desktop = &code[code.find("bg=").expect("desktop paint")..]; + assert!( + code.contains("\"initial\"") + || (!desktop.contains("visibility=\"hidden\"") && code.contains("\"block\"")), + "desktop must restore paint: {code}" + ); + assert!(code.contains("/images/"), "desktop paint retained: {code}"); + } + } +} + +#[tokio::test] +async fn r2_nonrendering_background_and_responsive_keep_code() { + for scale in ["FILL", "TILE", "BACKGROUND"] { + for field in ["tsx", "componentTsx", "responsiveTsx"] { + let mut data = responsive_payload(); + for i in 1..=2 { + let root = format!("1:{i}"); + let child = format!("2:{i}"); + data.snapshot + .nodes + .get_mut(&root) + .unwrap() + .fields + .insert("childrenIds".into(), json!([child])); + data.snapshot.nodes.insert(child.clone(), serde_json::from_value(json!({"id":child,"type":if field == "componentTsx" { "INSTANCE" } else { "FRAME" }, + "fields":{"parentId":root,"name":"Transparent paint","width":24,"height":24,"visible":true,"opacity":0, + "fills":[{"type":"IMAGE","imageHash":"hidden","scaleMode":if scale == "BACKGROUND" { "FILL" } else { scale }}]}})).unwrap()); + if scale == "BACKGROUND" { + let text_id = format!("3:{i}"); + data.snapshot + .nodes + .get_mut(&child) + .unwrap() + .fields + .insert("childrenIds".into(), json!([text_id])); + data.snapshot.nodes.insert(text_id.clone(), serde_json::from_value(json!({"id":text_id,"type":"TEXT", + "fields":{"parentId":child,"characters":"Retained text","width":20,"height":12}})).unwrap()); + } + } + let mut op = operation(&[field, "assetManifest"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data, op).await.unwrap(); + assert!( + result["failures"].as_array().unwrap().is_empty(), + "{scale}/{field}: {result}" + ); + if field != "responsiveTsx" { + for frame in result["frames"].as_array().unwrap() { + let code = frame[field] + .as_str() + .expect("background exclusion must retain code"); + assert!(!code.contains("/images/")); + assert!(code.contains("visibility=\"hidden\""), "{field}: {code}"); + devup_mcp_devup_ui::validation::validate_tsx(code).unwrap(); + } + } else { + let code = result[field] + .as_str() + .expect("responsive exclusion must retain code"); + assert!(!code.contains("/images/")); + devup_mcp_devup_ui::validation::validate_tsx(code).unwrap(); + } + } + } +} + +#[tokio::test] +async fn r2_production_six_frames_return_valid_story_tsx() { + let data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-119-payload.json" + )) + .unwrap(); + let generated = generate_collected_component( + &data, + "3997:46690", + &CodegenOptions { + inline_instances: true, + asset_names_per_node: true, + ..CodegenOptions::default() + } + .with_payload_tokens(&data), + ) + .unwrap(); + let path = devup_mcp_devup_ui::codegen::image_fill_path(&data.snapshot, "3997:46703", 0, true) + .unwrap(); + eprintln!( + "story pre-guard excluded path {path}; referenced={}", + generated.tsx.contains(&path) + ); + let mut op = operation(&["tsx", "assetManifest"]); + if let PendingOperation::Export { + frame_ids, + include_diagnostics, + .. + } = &mut op + { + *frame_ids = [ + "3997:46315", + "3997:46715", + "3997:46333", + "3997:46361", + "3997:46461", + "3997:46690", + ] + .map(str::to_owned) + .to_vec(); + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["frames"].as_array().unwrap().len(), 6); + for frame in result["frames"].as_array().unwrap() { + let tsx = frame["tsx"] + .as_str() + .expect("every selected frame must retain TSX"); + devup_mcp_devup_ui::validation::validate_tsx(tsx).unwrap(); + assert!(!tsx.contains(&host_safe_asset_path(&path))); + if frame["nodeId"] == "3997:46690" { + assert!(tsx.contains("작은 시장, 큰 사랑")); + assert!(tsx.contains("visibility=\"hidden\"")); + assert!(tsx.contains("boxSize=\"24px\"")); + } + } + assert!(result["failures"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn r2_withheld_output_has_issue_and_explicit_deliverable() { + let mut data = payload(); + data.snapshot.nodes.get_mut("1:1").unwrap().node_type = "VECTOR".into(); + data.assets.push(serde_json::from_value(json!({"assetId":"1:1:node", "nodeId":"1:1", + "field":"node", "sourceKind":"node", "status":"failed", "errorCode":"DEVUP_ASSET_NODE_HIDDEN"})).unwrap()); + let result = project(data, operation(&["tsx", "assetManifest"])) + .await + .unwrap(); + assert_eq!(result["deliverable"]["isFinal"], false); + assert_eq!(result["deliverable"]["withheldOutputs"][0]["output"], "tsx"); + let issue = result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .find(|i| i["code"] == "DEVUP_EXCLUDED_ASSET_REFERENCED") + .unwrap(); + assert_eq!(issue["code"], "DEVUP_EXCLUDED_ASSET_REFERENCED"); + assert_eq!(issue["property"], "assetReference"); + assert!(issue["details"]["originalValue"].is_object()); + assert_eq!(issue["details"]["appliedValue"]["state"], "withheld"); + assert!(issue["details"]["nextAction"].is_string()); +} + +#[tokio::test] +async fn r2_component_layout_has_generated_evidence_and_output_scope() { + let mut data = section(1); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["2:1"])); + data.snapshot.nodes.insert( + "2:1".into(), + serde_json::from_value(json!({ + "id":"2:1","type":"INSTANCE","fields":{"name":"Header", "parentId":"1:1", + "visible":true,"width":360,"height":66,"childrenIds":[]} + })) + .unwrap(), + ); + let mut op = operation(&["tsx", "componentTsx"]); + if let PendingOperation::Export { + all_screens, + include_diagnostics, + .. + } = &mut op + { + *all_screens = true; + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + let frame = &result["frames"][0]; + let issues = frame["projectionIssues"].as_array().unwrap(); + for issue in issues + .iter() + .filter(|i| i["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") + { + assert!(issue["details"]["appliedValue"].is_object(), "{issue}"); + assert!(issue["details"]["output"].is_string(), "{issue}"); + assert!(issue["details"]["nextAction"].is_string(), "{issue}"); + } + assert!(frame["outputResults"]["tsx"]["fidelity"].is_object()); + assert!(frame["outputResults"]["componentTsx"]["fidelity"].is_object()); + assert!( + !frame["projectionReview"]["groups"] + .as_array() + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn r5_wquw119_missing_mask_size_is_output_scoped_and_not_final() { + let mut data = payload(); + data.snapshot = serde_json::from_str(include_str!( + "../../../../../fixtures/wquw-119-snapshot.json" + )) + .unwrap(); + data.target.node_id = Some("3997:46315".into()); + data.target.file_key = data.snapshot.file_key.clone(); + let node = data.snapshot.nodes.get_mut("3997:46317").unwrap(); + node.fields.remove("height"); + node.fields.remove("absoluteBoundingBox"); + let mut op = operation(&["tsx", "sourceMap"]); + if let PendingOperation::Export { + include_diagnostics, + .. + } = &mut op + { + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + assert_ne!(result["quality"]["projection"], "exact"); + assert_eq!(result["deliverable"]["isFinal"], false); + assert!( + result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|issue| issue["nodeId"] == "3997:46317" + && issue["property"] == "height" + && issue["details"]["output"] == "tsx"), + "{result}" + ); +} + +#[tokio::test] +async fn r4_direct_export_discloses_capture_boundary_without_diagnostics() { + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { + include_diagnostics, + .. + } = &mut op + { + *include_diagnostics = false; + } + let result = project(payload(), op).await.unwrap(); + let contract = &result["placementContracts"][0]["details"]; + assert_eq!(contract["output"], "tsx"); + assert_eq!(contract["parentCollected"], false); + assert!(contract["parentMissingReason"].is_string()); + assert_eq!(contract["containingBlock"], "normal-flow-host"); + assert!(contract["coordinateBasis"].is_string()); +} + +/// A screen exported on its own said nothing about the surface it is +/// drawn on, so learning that the desktop frame behind an app screen was +/// `#E6EDE6` took a second export of that parent — a second Figma +/// acquisition to read a colour this capture was already carrying. +#[tokio::test] +async fn r4_placement_contract_reports_the_collected_parent_background() { + let mut data = payload(); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("parentId".into(), json!("0:1")); + data.snapshot.nodes.insert( + "0:1".into(), + serde_json::from_value(json!({ + "id":"0:1", "type":"FRAME", "fields":{ + "name":"Desktop", "parentId":null, "childrenIds":["1:1"], "visible":true, + "x":0, "y":0, "width":1920, "height":1080, "layoutMode":"NONE", + "fills":[{"type":"SOLID", "visible":true, "opacity":1, "blendMode":"NORMAL", + "color":{"r":230.0/255.0, "g":237.0/255.0, "b":230.0/255.0}}]} + })) + .unwrap(), + ); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { + include_diagnostics, + .. + } = &mut op + { + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + let details = &result["placementContracts"][0]["details"]; + assert_eq!(details["parentCollected"], true, "{result}"); + let background = &details["sourceParentBackground"]; + assert_eq!(background["color"], "#E6EDE6", "{result}"); + assert_eq!(background["name"], "Desktop", "{result}"); + assert!(background["note"].is_string(), "{result}"); + // Reported, never emitted: the parent is still not this component. + assert_eq!(details["parentIncludedInOutput"], false, "{result}"); +} diff --git a/crates/devup-mcp/src/server/projection/w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs b/crates/devup-mcp/src/server/projection/w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs new file mode 100644 index 00000000..30919f46 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs @@ -0,0 +1,973 @@ + +#[tokio::test] +async fn original_source_variant_and_css_map_resources_recover_without_reacquiring() { + let store = ArtifactStore::default(); + let data = original_source_payload(); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )), + data, + ) + .await + .unwrap(); + let mut op = operation(&["tsx", "assetManifest", "assetCssMap"]); + if let PendingOperation::Export { delivery, .. } = &mut op { + *delivery = DeliveryMode::Resource; + } + let value = complete_operation( + op, + &artifact.payload, + "fixture", + &artifact, + &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), + &store, + ) + .await + .unwrap(); + let manifests = store.output_manifests().await; + assert!(manifests.iter().any(|m| m.name == "asset-css-map.json")); + assert_eq!( + manifests + .iter() + .filter(|m| m.mime_type == "image/webp") + .count(), + 2 + ); + for manifest in manifests.iter().filter(|m| m.mime_type == "image/webp") { + let mut bytes = Vec::new(); + for index in 0..manifest.chunk_count { + bytes.extend( + store + .read_resource_output(&artifact.artifact_id, &manifest.output_id, Some(index)) + .await + .unwrap() + .1, + ); + } + assert_eq!( + image::guess_format(&bytes).unwrap(), + image::ImageFormat::WebP + ); + assert_eq!(sha256_hex(&bytes), manifest.sha256); + } + assert_eq!( + value["nextAction"]["arguments"]["artifactId"], + artifact.artifact_id + ); + assert!(value["assetCssMap"]["assets"].is_null()); +} + +fn p3_asset_payload(second_bytes: &[u8]) -> CollectedPayload { + let mut data = payload(); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["1:2", "1:3"])); + for (id, bytes) in [("1:2", b"first image".as_slice()), ("1:3", second_bytes)] { + data.snapshot.nodes.insert( + id.into(), + serde_json::from_value(json!({ + "id":id,"type":"RECTANGLE","fields":{"name":"Logo","parentId":"1:1", + "visible":true,"width":40,"height":40,"x":0,"y":0,"isAsset":true, + "fills":[{"type":"IMAGE","imageHash":id,"scaleMode":"FILL"}]} + })) + .unwrap(), + ); + data.assets.push(serde_json::from_value(json!({ + "assetId":format!("{id}:fills:0"),"nodeId":id,"field":"fills/0","sourceKind":"image", + "status":"exported","format":"png","scale":1,"mimeType":"image/png", + "dataBase64":STANDARD.encode(bytes),"byteLength":bytes.len(),"sha256":sha256_hex(bytes) + })).unwrap()); + } + data +} + +#[tokio::test] +async fn image_modes_keep_file_manifest_and_shared_resource_observations_consistent() { + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(8, 4) + .write_to(&mut encoded, image::ImageFormat::Png) + .unwrap(); + let bytes = encoded.into_inner(); + for delivery_mode in [DeliveryMode::Inline, DeliveryMode::Resource] { + let mut data = p3_asset_payload(&bytes); + data.assets.clear(); + for (id, mode) in [ + ("1:2", devup_mcp_figma::AssetRenderMode::Node), + ("1:3", devup_mcp_figma::AssetRenderMode::Fill), + ] { + data.assets.push( + devup_mcp_figma::exported_asset_from_bytes( + &devup_mcp_figma::AssetRequest { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + asset_id: format!("{id}:fills:0"), + node_id: id.into(), + field: "fills/0".into(), + image_hash: Some(id.into()), + format: devup_mcp_figma::AssetFormat::Png, + scale: 1.0, + render_mode: mode, + quality: None, + lossless: None, + }, + &bytes, + ) + .unwrap(), + ); + } + let path = std::env::temp_dir().join(format!( + "image-modes-{}-{:?}.png", + std::process::id(), + delivery_mode + )); + let mut op = operation(&["assetManifest"]); + if let PendingOperation::Export { + delivery, + asset_output_paths, + .. + } = &mut op + { + *delivery = delivery_mode; + asset_output_paths.insert("1:2:fills:0".into(), path.to_string_lossy().into_owned()); + } + let store = ArtifactStore::default(); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )), + data, + ) + .await + .unwrap(); + let result = complete_operation( + op, + &artifact.payload, + "fixture", + &artifact, + &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), + &store, + ) + .await + .unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let manifest = if delivery_mode == DeliveryMode::Resource { + let manifest = store + .output_manifests() + .await + .into_iter() + .find(|m| m.name == "asset-manifest.json") + .unwrap(); + let mut bytes = Vec::new(); + for index in 0..manifest.chunk_count { + bytes.extend( + store + .read_resource_output( + &artifact.artifact_id, + &manifest.output_id, + Some(index), + ) + .await + .unwrap() + .1, + ); + } + serde_json::from_slice::(&bytes).unwrap() + } else { + result["assetManifest"].clone() + }; + let assets = manifest["assets"].as_array().unwrap(); + for (asset, mode) in assets.iter().zip(["node", "fill"]) { + assert_eq!(asset["renderMode"], mode); + assert_eq!(asset["renderInfo"]["outputSize"]["width"], 8.0); + assert_eq!(asset["renderInfo"]["aspectRatioComparison"], "mismatch"); + assert_eq!( + asset["outputPath"], + result["outputPaths"][format!("asset:{}", asset["assetId"].as_str().unwrap())] + ); + assert!(asset["suggestedFilename"].as_str().unwrap().contains(mode)); + } + if delivery_mode == DeliveryMode::Resource { + let resource = result["resources"] + .as_array() + .unwrap() + .iter() + .find(|r| r["mimeType"] == "image/png") + .unwrap(); + let metadata = resource["assetMetadata"]["assets"].as_array().unwrap(); + assert_eq!(metadata.len(), 2); + for (alias, asset) in metadata.iter().zip(assets) { + assert_eq!(alias["renderMode"], asset["renderMode"]); + assert_eq!(alias["renderInfo"], asset["renderInfo"]); + assert_eq!(alias["suggestedFilename"], asset["suggestedFilename"]); + } + } + std::fs::remove_file(path).unwrap(); + } +} + +#[tokio::test] +async fn original_fill_artifacts_cannot_silently_replace_rendered_codegen_assets() { + let mut data = p3_asset_payload(b"same image"); + data.assets[0].render_mode = devup_mcp_figma::AssetRenderMode::Fill; + for output in ["tsx", "componentTsx", "responsiveTsx"] { + let error = project(data.clone(), operation(&[output])) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + assert!(error.message.contains("renderMode node")); + } +} + +#[tokio::test] +async fn auto_native_assets_reproject_code_and_audit_without_changing_cached_capture() { + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(40, 40) + .write_to(&mut encoded, image::ImageFormat::Png) + .unwrap(); + let bytes = encoded.into_inner(); + let mut data = p3_asset_payload(&bytes); + for asset in &mut data.assets { + asset.render_mode = devup_mcp_figma::AssetRenderMode::Auto; + asset.source_kind = "image-fill".into(); + asset.data_base64 = Some(STANDARD.encode(&bytes)); + asset.byte_length = Some(bytes.len()); + asset.sha256 = Some(sha256_hex(&bytes)); + asset.render_info = Some( + json!({"representation":"native-node","sourceProperties":{"scaleMode":"CROP","imageTransform":[[0.5,0,0],[0,1,0]],"effects":[{"type":"LAYER_BLUR","radius":12}]},"autoDecision":{"eligible":false,"selected":"node","reasons":["paint-transform-or-unknown"]}}), + ); + } + let store = ArtifactStore::default(); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )), + data, + ) + .await + .unwrap(); + let before = artifact.payload.assets.clone(); + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + for outputs in [ + &["tsx", "componentTsx", "assetManifest", "sourceMap"][..], + &["assetManifest"][..], + &["tsx"][..], + ] { + let result = complete_operation( + operation(outputs), + &artifact.payload, + "fixture", + &artifact, + &policy, + &store, + ) + .await + .unwrap(); + assert_eq!(result["imageFillAudit"]["total"], 2); + assert_eq!(result["imageFillAudit"]["exact"], 0); + assert_eq!(result["imageFillAudit"]["unknown"], 2); + if outputs.contains(&"assetManifest") { + for asset in result["assetManifest"]["assets"].as_array().unwrap() { + assert_eq!(asset["renderMode"], "auto"); + assert_eq!(asset["renderInfo"]["renderedAs"], "node-render"); + assert_eq!(asset["renderInfo"]["effectsBakedIntoAsset"], true); + assert_eq!(asset["renderInfo"]["canvasFidelity"], "unverified"); + } + } + if outputs.contains(&"sourceMap") { + let baked = result["sourceMap"]["rasterizedAssets"].as_array().unwrap(); + assert_eq!(baked.len(), 2); + assert!( + baked.iter().all( + |a| a["effectsBakedIntoAsset"] == true && a["cssEffectsSuppressed"] == true + ) + ); + } + } + assert_eq!(artifact.payload.assets, before); +} + +fn webp_test_payload(count: usize) -> CollectedPayload { + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(40, 40) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let mut data = p3_asset_payload(png.get_ref()); + data.assets.clear(); + data.snapshot.nodes.get_mut("1:1").unwrap().fields.insert( + "childrenIds".into(), + json!((2..count + 2).map(|i| format!("1:{i}")).collect::>()), + ); + let prototype = data.snapshot.nodes["1:2"].clone(); + for i in 2..count + 2 { + let id = format!("1:{i}"); + let mut node = prototype.clone(); + node.id = id.clone(); + data.snapshot.nodes.insert(id.clone(), node); + let request = devup_mcp_figma::AssetRequest { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + asset_id: format!("{id}:fills:0"), + node_id: id.clone(), + field: "fills/0".into(), + image_hash: Some(id), + format: devup_mcp_figma::AssetFormat::Webp, + scale: 1.0, + render_mode: devup_mcp_figma::AssetRenderMode::Node, + quality: Some(43), + lossless: Some(false), + }; + data.assets + .push(devup_mcp_figma::exported_asset_from_bytes(&request, png.get_ref()).unwrap()); + } + data +} + +#[tokio::test] +async fn webp_image_audit_writes_a_supported_output_and_requires_design_capability() { + let root = std::env::temp_dir().join(format!("devup-webp-audit-{}", rand::random::())); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("image-audit.json"); + let mut op = operation(&["imageAudit"]); + if let PendingOperation::Export { output_paths, .. } = &mut op { + output_paths.insert("imageAudit".into(), path.display().to_string()); + } + let result = project(webp_test_payload(2), op).await.unwrap(); + let written: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(written["assets"].as_array().unwrap().len(), 2); + assert!( + result["outputPathResults"]["supportedKeys"] + .as_array() + .unwrap() + .iter() + .any(|o| o == "imageAudit") + ); + assert!( + result["outputPathResults"]["diagnostics"] + .as_array() + .unwrap() + .is_empty() + ); + let row = &written["assets"][0]; + assert_eq!(row["nodeSize"]["width"], 40.0); + assert_eq!(row["outputSize"]["width"], 40.0); + assert_eq!(row["recommendedFormat"], "webp"); + assert_eq!(row["visibleSourceRatio"]["known"], false); + assert_eq!( + row["visibleSourceRatio"]["reason"], + "baked-render-source-coverage-unverified" + ); + assert!( + row["visibleSourceRatioEvidence"] + .as_str() + .unwrap() + .contains("not-visible-percentages") + ); + let data = webp_test_payload(2); + let mut request = CollectionRequest::new(data.target.clone(), CollectionScope::File); + request.variables_only = true; + let store = ArtifactStore::default(); + let artifact = store + .insert(ArtifactRequestKey::from_collection(&request), data) + .await + .unwrap(); + assert!( + super::super::validation::validate_artifact_projection( + &artifact, + &["imageAudit".into()], + "file", + &[] + ) + .is_err() + ); + std::fs::remove_dir_all(root).unwrap(); +} + +#[tokio::test] +async fn webp_preset_replays_more_than_six_captures_with_public_files_and_coupled_resources() { + let root = std::env::temp_dir().join(format!("devup-webp-preset-{}", rand::random::())); + std::fs::create_dir_all(&root).unwrap(); + let root = crate::test_paths::canonical(&root); + let data = webp_test_payload(9); + let store = ArtifactStore::default(); + let mut request = CollectionRequest::new(data.target.clone(), CollectionScope::Node); + request.web_ready_images = true; + let artifact = store + .insert(ArtifactRequestKey::from_collection(&request), data) + .await + .unwrap(); + assert_eq!(artifact.capabilities.asset_capture_count, 9); + assert!(artifact.capabilities.web_ready_images); + for mode in [DeliveryMode::Inline, DeliveryMode::Resource] { + let mut op = operation(&["tsx", "assetManifest", "imageAudit"]); + if let PendingOperation::Export { + web_ready_images, + asset_public_root, + delivery, + .. + } = &mut op + { + *web_ready_images = true; + *asset_public_root = Some(root.clone()); + *delivery = mode; + } + let result = complete_operation( + op, + &artifact.payload, + "fixture", + &artifact, + &OutputPolicy::from_roots(vec![root.clone()]).unwrap(), + &store, + ) + .await + .unwrap(); + assert_eq!(result["webReadyImages"]["total"], 9); + assert_eq!(result["webReadyImages"]["failed"], 0); + let files = result["outputPaths"].as_object().unwrap(); + assert_eq!(files.len(), 9); + for path in files.values().filter_map(Value::as_str) { + assert!(path.ends_with(".webp")); + assert_eq!( + image::guess_format(&std::fs::read(path).unwrap()).unwrap(), + image::ImageFormat::WebP + ); + } + if mode == DeliveryMode::Inline { + assert!(result["tsx"].as_str().unwrap().contains(".webp")); + assert_eq!(result["imageAudit"]["assets"].as_array().unwrap().len(), 9); + for a in result["assetManifest"]["assets"].as_array().unwrap() { + assert_eq!(a["quality"], 43); + assert!(a["path"].as_str().unwrap().ends_with(".webp")); + } + } else { + assert!(result["imageAudit"]["assets"].is_null()); + assert!(result["webReadyImages"]["plan"].is_null()); + let args = &result["nextAction"]["arguments"]; + assert_eq!(args["preset"], "web-ready-images"); + assert!(args["assetRequests"].is_null()); + assert!( + args["outputs"] + .as_array() + .unwrap() + .iter() + .any(|o| o == "assetManifest") + ); + let input: super::super::tools::FigmaExportInput = + serde_json::from_value(args.clone()).unwrap(); + super::super::validation::parse_asset_requests(&input.asset_requests).unwrap(); + } + } + std::fs::remove_dir_all(root).unwrap(); +} + +#[tokio::test] +async fn webp_scaffold_chooses_webp_paths_before_mapping_and_preserves_alias_identity() { + let root = std::env::temp_dir().join(format!("devup-webp-scaffold-{}", rand::random::())); + std::fs::create_dir_all(&root).unwrap(); + for write in [false, true] { + let mut op = operation(&["tsx", "assetManifest", "pageScaffold"]); + if let PendingOperation::Export { + page_scaffold, + output_paths, + .. + } = &mut op + { + *page_scaffold = + Some(serde_json::from_value(json!({"write":write,"route":"/"})).unwrap()); + output_paths.insert("pageScaffold".into(), root.display().to_string()); + } + let result = project(webp_test_payload(2), op).await.unwrap(); + assert!(result["tsx"].as_str().unwrap().contains(".webp")); + for asset in result["assetManifest"]["assets"].as_array().unwrap() { + assert!(asset["path"].as_str().unwrap().ends_with(".webp")); + assert_eq!(asset["quality"], 43); + if write { + let bytes = std::fs::read(asset["outputPath"].as_str().unwrap()).unwrap(); + assert_eq!(asset["sha256"], sha256_hex(&bytes)); + } + } + } + std::fs::remove_dir_all(root).unwrap(); +} + +#[tokio::test] +async fn webp_failed_capture_preserves_options_and_returns_partial_beside_success() { + let mut data = webp_test_payload(2); + let a = &mut data.assets[1]; + a.status = AssetStatus::Failed; + a.data_base64 = None; + a.byte_length = None; + a.sha256 = None; + a.mime_type = None; + a.error_code = Some("DEVUP_ASSET_WEBP_POSTPROCESS_FAILED".into()); + let selections = data + .assets + .iter() + .map(|a| devup_mcp_figma::AssetSelection { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + asset_id: a.asset_id.clone(), + format: a.format.unwrap(), + scale: a.scale.unwrap(), + render_mode: a.render_mode, + quality: a.quality, + lossless: a.lossless, + }) + .collect(); + let mut op = operation(&["assetManifest"]); + if let PendingOperation::Export { asset_captures, .. } = &mut op { + *asset_captures = selections; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["status"], "partial"); + assert_eq!(result["assetManifest"]["assets"][1]["quality"], 43); + assert_eq!(result["assetManifest"]["assets"][1]["status"], "failed"); +} + +#[tokio::test] +async fn large_image_fill_audit_preserves_all_rows_in_resource_with_bounded_inline_summary() { + let data = payload(); + let store = ArtifactStore::default(); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )), + data, + ) + .await + .unwrap(); + let rows = (0..10000).map(|i| json!({"assetId":format!("synthetic-{i}:fills:0"),"nodeId":format!("synthetic-{i}"),"canvasFidelity":"unverified","reviewReasons":["image-transform"],"unknownReasons":["canvas-reference-not-compared","ancestor-dependencies-unknown"]})).collect::>(); + let mut result = json!({"status":"partial","imageFillAudit":{"total":rows.len(),"exact":0,"needsReview":rows.len(),"unknown":rows.len(),"summary":"10000 fills require review","assets":rows}, + "imageAudit":{"total":rows.len(),"assets":rows}}); + assert!(serde_json::to_vec(&result).unwrap().len() > 1024 * 1024); + let outputs = projected_outputs_from_result(result.as_object().unwrap()).unwrap(); + let attachment = apply_delivery( + &mut result, + DeliveryMode::Auto, + &store, + &artifact, + outputs, + None, + ) + .await + .unwrap(); + commit_delivery(attachment); + assert_eq!(result["imageFillAudit"]["total"], 10000); + assert!(result["imageAudit"]["assets"].is_null()); + assert!( + result["imageAudit"]["detailsResource"]["uri"] + .as_str() + .unwrap() + .starts_with("devup://") + ); + assert!(result["imageFillAudit"]["assets"].is_null()); + assert!( + result["imageFillAudit"]["detailsResource"]["uri"] + .as_str() + .unwrap() + .starts_with("devup://") + ); + assert!(serde_json::to_vec(&result).unwrap().len() < 16384); + let resource = store + .output_manifests() + .await + .into_iter() + .find(|m| m.name == "image-fill-audit.json") + .unwrap(); + let mut bytes = Vec::new(); + for index in 0..resource.chunk_count { + bytes.extend( + store + .read_resource_output(&artifact.artifact_id, &resource.output_id, Some(index)) + .await + .unwrap() + .1, + ); + } + let full: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(full["assets"].as_array().unwrap().len(), 10000); + assert_eq!(full["assets"][9999]["assetId"], "synthetic-9999:fills:0"); +} + +#[tokio::test] +async fn p3_public_mapping_and_dedup_rewrite_inline_and_written_tsx() { + let directory = std::env::temp_dir().join(format!( + "devup-p3-assets-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&directory).unwrap(); + let root = crate::test_paths::canonical(&directory); + let first_path = root.join("icons/BI icon.png"); + let second_path = root.join("icons/other.png"); + let tsx_path = root.join("Screen.tsx"); + let mut op = operation(&["tsx", "assetManifest"]); + if let PendingOperation::Export { + asset_public_root, + asset_output_paths, + output_paths, + .. + } = &mut op + { + *asset_public_root = Some(root.clone()); + asset_output_paths.insert("1:2:fills:0".into(), first_path.to_string_lossy().into()); + asset_output_paths.insert("1:3:fills:0".into(), second_path.to_string_lossy().into()); + output_paths.insert("tsx".into(), tsx_path.to_string_lossy().into()); + } + let result = project(p3_asset_payload(b"first image"), op).await.unwrap(); + let code = result["tsx"].as_str().unwrap(); + assert!(code.contains("/icons/BI%20icon.png"), "{code}"); + assert_eq!(std::fs::read_to_string(&tsx_path).unwrap(), code); + assert_eq!(std::fs::read(&first_path).unwrap(), b"first image"); + assert!(!second_path.exists()); + for asset in result["assetManifest"]["assets"].as_array().unwrap() { + assert_eq!(asset["path"], "/icons/BI%20icon.png"); + assert_eq!( + std::path::Path::new(asset["outputPath"].as_str().unwrap()), + first_path + ); + } + std::fs::remove_dir_all(directory).unwrap(); +} + +#[tokio::test] +async fn p3_same_name_different_bytes_keep_distinct_paths_and_default_names() { + let result = project( + p3_asset_payload(b"different image"), + operation(&["tsx", "assetManifest"]), + ) + .await + .unwrap(); + let assets = result["assetManifest"]["assets"].as_array().unwrap(); + assert_ne!(assets[0]["path"], assets[1]["path"]); + for asset in assets { + assert!( + result["tsx"] + .as_str() + .unwrap() + .contains(asset["path"].as_str().unwrap()) + ); + } +} + +#[tokio::test] +async fn p3_identical_assets_share_one_binary_resource() { + let mut op = operation(&["tsx", "assetManifest"]); + if let PendingOperation::Export { delivery, .. } = &mut op { + *delivery = DeliveryMode::Resource; + } + let result = project(p3_asset_payload(b"first image"), op).await.unwrap(); + let resources = result["resources"].as_array().unwrap(); + assert_eq!( + resources + .iter() + .filter(|resource| resource["mimeType"] == "image/png") + .count(), + 1 + ); +} + +#[test] +fn p3_rewrites_all_code_variants_without_cascading_or_prefix_changes() { + let paths = BTreeMap::from([ + ("/icons/a.svg".into(), "/icons/b.svg".into()), + ("/icons/b.svg".into(), "/icons/c.svg".into()), + ]); + let mut result = json!({"tsx":"", + "componentTsx":"", + "responsiveTsx":"", + "frames":[{"tsx":""}], + "other":"/icons/a.svg"}); + rewrite_result_asset_references(&mut result, &paths); + assert_eq!(result["tsx"], ""); + assert_eq!(result["frames"][0]["tsx"], result["tsx"]); + assert_eq!( + result["componentTsx"], + "" + ); + assert_eq!(result["responsiveTsx"], ""); + assert_eq!(result["other"], "/icons/a.svg"); + assert_eq!( + rewrite_asset_references("url(/icons/a.svg.extra)", &paths), + "url(/icons/a.svg.extra)" + ); +} + +#[tokio::test] +async fn p3_selection_reference_png_followup_targets_single_url() { + let result = project(section(2), operation(&["tsx", "referencePng"])) + .await + .unwrap(); + let args = &result["nextAction"]["example"]["arguments"]; + assert!(args["url"].is_string()); + assert!(args.get("frameIds").is_none()); + assert!(args.get("artifactId").is_none()); + assert_eq!(args["scope"], "node"); +} + +#[tokio::test] +async fn p3_default_paths_are_not_rewritten_to_requested_files() { + let directory = std::env::temp_dir().join(format!( + "devup-p3-default-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("chosen.png"); + let mut op = operation(&["tsx", "assetManifest"]); + if let PendingOperation::Export { + asset_output_paths, .. + } = &mut op + { + asset_output_paths.insert("1:2:fills:0".into(), path.to_string_lossy().into()); + } + let result = project(p3_asset_payload(b"different"), op).await.unwrap(); + assert!(path.exists()); + let asset = &result["assetManifest"]["assets"][0]; + assert_ne!(asset["path"], "/chosen.png"); + assert!( + result["tsx"] + .as_str() + .unwrap() + .contains(asset["path"].as_str().unwrap()) + ); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[tokio::test] +async fn p3_unverified_equal_hashes_do_not_merge_different_bytes() { + let mut data = p3_asset_payload(b"different"); + data.assets[1].sha256 = data.assets[0].sha256.clone(); + let error = project(data, operation(&["tsx", "assetManifest"])) + .await + .unwrap_err(); + assert!(error.message.contains("hash does not match"), "{error}"); + assert!(!super::super::validation::is_caller_mistake(&error)); +} + +#[tokio::test] +async fn p3_tsx_only_reprojection_preserves_deduplicated_references() { + let data = p3_asset_payload(b"first image"); + let with_manifest = project(data.clone(), operation(&["tsx", "assetManifest"])) + .await + .unwrap(); + let without_manifest = project(data, operation(&["tsx"])).await.unwrap(); + assert_eq!(with_manifest["tsx"], without_manifest["tsx"]); + assert!(without_manifest.get("assetManifest").is_none()); +} + +#[tokio::test] +async fn p3_canonical_reference_is_independent_of_requested_duplicate_file() { + let directory = std::env::temp_dir().join(format!( + "devup-p3-canonical-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&directory).unwrap(); + let data = p3_asset_payload(b"first image"); + let canonical = project(data.clone(), operation(&["tsx", "assetManifest"])) + .await + .unwrap(); + let mut op = operation(&["tsx", "assetManifest"]); + if let PendingOperation::Export { + asset_output_paths, .. + } = &mut op + { + asset_output_paths.insert( + "1:3:fills:0".into(), + directory.join("chosen.png").to_string_lossy().into(), + ); + } + let written = project(data, op).await.unwrap(); + assert_eq!(canonical["tsx"], written["tsx"]); + assert_eq!( + canonical["assetManifest"]["assets"][0]["path"], + written["assetManifest"]["assets"][0]["path"] + ); + std::fs::remove_dir_all(directory).unwrap(); +} + +fn section(count: usize) -> CollectedPayload { + let mut payload = payload(); + let template = payload.snapshot.nodes["1:1"].clone(); + payload.target.node_id = Some("0:1".into()); + payload.snapshot.nodes.clear(); + payload.snapshot.roots.clear(); + let mut candidates = Vec::new(); + for i in 1..=count { + let id = format!("1:{i}"); + let mut node = template.clone(); + node.id = id.clone(); + payload.snapshot.nodes.insert(id.clone(), node); + payload.snapshot.roots.push(id.clone()); + candidates.push(json!({"nodeId":id,"name":"Screen","nodeType":"FRAME", + "textPreview":"","visible":true,"bounds":{"x":0,"y":0,"width":390,"height":800}, + "parentId":"0:1","breadcrumb":[],"directChildCount":0, + "subtreeNodeCount":1,"estimatedSerializedBytes":500,"selectionReasons":["inside-section"], + "canonicalUrl":format!("https://www.figma.com/design/W1Fixture/Test?node-id=1-{i}")})); + } + payload.metadata = json!({"sectionIndex":{"fileKey":"W1Fixture","sourceVersion":null, + "section":{"nodeId":"0:1","name":"Section","bounds":{"x":0,"y":0,"width":2000,"height":2000}}, + "candidates":candidates,"truncated":false}}); + payload +} + +fn operation(outputs: &[&str]) -> PendingOperation { + PendingOperation::Export { + outputs: outputs.iter().map(|s| (*s).into()).collect(), + component_name: None, + include_diagnostics: true, + root_layout: Default::default(), + asset_names_per_node: true, + scope: "node".into(), + theme_options: Default::default(), + strict: false, + output_paths: BTreeMap::new(), + page_scaffold: None, + previous_design_fingerprints: None, + frame_ids: vec![], + all_screens: false, + asset_captures: vec![], + web_ready_images: false, + asset_output_paths: BTreeMap::new(), + asset_public_root: None, + fields: vec![], + response_selection: Default::default(), + delivery: DeliveryMode::Inline, + width_policy: None, + } +} + +fn section_without_index(count: usize, projection_truncated: bool) -> CollectedPayload { + let mut data = section(count); + data.metadata = json!({}); + let mut root = data.snapshot.nodes["1:1"].clone(); + root.id = "0:1".into(); + root.node_type = "SECTION".into(); + root.fields = serde_json::from_value(json!({ + "name":"Section", "visible":true, "x":0, "y":0, + "width":2000, "height":2000, + "childrenIds":data.snapshot.roots, + "projectionTruncated":projection_truncated + })) + .unwrap(); + for node in data.snapshot.nodes.values_mut() { + node.fields.insert("parentId".into(), json!("0:1")); + } + data.snapshot.nodes.insert(root.id.clone(), root); + data.snapshot.roots = vec!["0:1".into()]; + data +} + +#[tokio::test] +async fn r17_large_section_provides_bounded_continuation_batches() { + let result = project( + section_without_index(23, false), + operation(&["tsx", "sourceMap", "rawSnapshot"]), + ) + .await + .unwrap(); + let next = &result["nextAction"]; + assert_eq!(next["maxFramesPerCall"], 4); + let batches = next["batches"].as_array().expect("bounded batch examples"); + assert_eq!(batches.len(), 6); + assert_eq!( + batches + .iter() + .map(|b| b["arguments"]["frameIds"].as_array().unwrap().len()) + .sum::(), + 23 + ); + for batch in batches { + assert!(batch["arguments"]["frameIds"].as_array().unwrap().len() <= 4); + } + assert!( + !next["how"] + .as_str() + .unwrap() + .contains("Use allScreens:true") + ); +} + +#[tokio::test] +async fn r17_section_png_batches_use_single_direct_frame_urls() { + let result = project( + section_without_index(23, false), + operation(&["tsx", "referencePng"]), + ) + .await + .unwrap(); + assert_eq!(result["nextAction"]["maxFramesPerCall"], 1); + let batches = result["nextAction"]["batches"].as_array().unwrap(); + assert_eq!(batches.len(), 23); + let mut urls = std::collections::BTreeSet::new(); + for batch in batches { + let args = &batch["arguments"]; + assert!(args.get("artifactId").is_none()); + assert!(args.get("frameIds").is_none()); + assert_eq!(args["scope"], "node"); + assert_eq!(args["outputs"], json!(["tsx", "referencePng"])); + let url = args["url"].as_str().unwrap(); + assert!(url.contains("node-id=")); + assert!(urls.insert(url)); + } +} + +#[tokio::test] +async fn w1_selection_exactly_100_candidates_is_complete() { + let result = project(section_without_index(100, false), operation(&["tsx"])) + .await + .unwrap(); + assert_eq!(result["status"], "selection_required"); + assert_eq!(result["selection"]["count"], 100); + assert_eq!(result["selection"]["truncated"], false); + assert_eq!(result["selection"]["status"], "complete"); +} + +#[tokio::test] +async fn w1_selection_over_100_candidates_is_partial() { + let result = project(section_without_index(101, false), operation(&["tsx"])) + .await + .unwrap(); + assert_eq!(result["selection"]["count"], 100); + assert_eq!(result["selection"]["truncated"], true); + assert_eq!(result["selection"]["status"], "partial"); +} + +#[tokio::test] +async fn w1_selection_separates_projection_and_candidate_truncation() { + let data = section_without_index(100, true); + let explored = + explore_snapshot(&data.snapshot, &data.target, &ExploreOptions { limit: 100 }).unwrap(); + assert!(explored.truncated); + assert!(!explored.candidates_truncated); + let result = project(data, operation(&["tsx"])).await.unwrap(); + assert_eq!(result["selection"]["truncated"], false); + assert_eq!(result["selection"]["status"], "complete"); +} diff --git a/crates/devup-mcp/src/server/projection/w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs b/crates/devup-mcp/src/server/projection/w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs new file mode 100644 index 00000000..d18e745c --- /dev/null +++ b/crates/devup-mcp/src/server/projection/w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs @@ -0,0 +1,980 @@ + +#[tokio::test] +async fn r4_export_exposes_placement_contract_without_diagnostics() { + let data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-120-payload.json" + )) + .unwrap(); + let mut op = operation(&["tsx", "componentTsx"]); + if let PendingOperation::Export { + frame_ids, + include_diagnostics, + .. + } = &mut op + { + *frame_ids = vec!["3997:46582".into()]; + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + let frame = &result["frames"][0]; + for field in ["tsx", "componentTsx"] { + let source = frame[field].as_str().unwrap(); + let tag = source + .split("return (") + .nth(1) + .unwrap() + .split('>') + .next() + .unwrap(); + for prop in ["pos=\"relative\"", "w=\"360px\"", "h=\"740px\""] { + assert!(tag.contains(prop), "{tag}"); + } + assert!(source.contains("whiteSpace=\"pre-wrap\"")); + let contract = frame["placementContracts"] + .as_array() + .unwrap() + .iter() + .find(|d| d["details"]["output"] == field) + .unwrap(); + assert_eq!(contract["details"]["containingBlock"], "generated-root"); + } + assert_eq!(result["placementContracts"].as_array().unwrap().len(), 2); + assert_eq!( + frame["outputResults"]["tsx"]["fidelity"]["layout"]["covered"], + 134 // R17: prior 117 + 17 verified horizontal FILL obligations. + ); + assert_eq!( + frame["outputResults"]["componentTsx"]["fidelity"]["impacts"]["lossy"], + 40 // The paragraph now preserves its spaces; all other losses remain. + ); + assert!( + !frame["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|issue| { + issue["code"] == "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE" + && issue["nodeId"] == "3997:46604" + && issue["property"] == "characters" + && issue["fidelityImpact"] == "lossy" + }) + ); +} + +#[tokio::test] +async fn r9_confirmed_evidence_survives_diagnostics_opt_out() { + let mut data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-120-payload.json" + )) + .unwrap(); + data.snapshot + .nodes + .get_mut("3997:46621") + .unwrap() + .fields + .insert("visible".into(), json!(false)); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { + frame_ids, + include_diagnostics, + .. + } = &mut op + { + *frame_ids = vec!["3997:46582".into()]; + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + for owner in [&result, &result["frames"][0]] { + let evidence = owner["projectionEvidence"] + .as_array() + .expect("confirmed evidence remains available without diagnostics"); + let d = evidence + .iter() + .find(|d| { + d["nodeId"] == "3997:46621" && d["code"] == "DEVUP_CODEGEN_NON_RENDERING_ASSET" + }) + .unwrap(); + assert_eq!(d["fidelityImpact"], "none"); + assert_eq!(d["details"]["verification"]["field"], "visible"); + assert_eq!(d["details"]["verification"]["state"], "accounted-for"); + assert_eq!(d["details"]["output"], "tsx"); + assert!( + !owner["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|d| d["nodeId"] == "3997:46621") + ); + } +} + +#[tokio::test] +async fn r2_production_layout_evidence_is_actionable_per_output() { + for (raw, ids, fields, count) in [ + ( + include_str!("../../../../../fixtures/r2/wquw-118-payload.json"), + vec!["3997:46242", "3997:46277", "3997:46129"], + vec!["tsx", "assetManifest"], + 0, // R10: both auto-size dimensions are accounted for, not lost. + ), + ( + include_str!("../../../../../fixtures/r2/wquw-120-payload.json"), + vec!["3997:46582"], + vec!["tsx", "componentTsx", "assetManifest"], + 40, + ), + ] { + let data: CollectedPayload = serde_json::from_str(raw).unwrap(); + let mut op = operation(&fields); + if let PendingOperation::Export { + frame_ids, + include_diagnostics, + delivery, + .. + } = &mut op + { + *frame_ids = ids.into_iter().map(str::to_owned).collect(); + *include_diagnostics = false; + *delivery = DeliveryMode::Auto; + } + let store = ArtifactStore::default(); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )), + data, + ) + .await + .unwrap(); + let mut result = complete_operation( + op, + &artifact.payload, + "fixture", + &artifact, + &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), + &store, + ) + .await + .unwrap(); + // Rich image evidence can promote this response to resources. Read + // the retained complete diagnostic payload before asserting geometry. + if let Some(resource) = store + .output_manifests() + .await + .into_iter() + .find(|m| m.name == "projection-details.json") + { + let mut bytes = Vec::new(); + for index in 0..resource.chunk_count { + bytes.extend( + store + .read_resource_output( + &artifact.artifact_id, + &resource.output_id, + Some(index), + ) + .await + .unwrap() + .1, + ); + } + let details: Value = serde_json::from_slice(&bytes).unwrap(); + for (field, value) in details["response"].as_object().unwrap() { + result[field] = value.clone(); + } + for frame in result["frames"].as_array_mut().into_iter().flatten() { + if let Some(full) = details["frames"] + .as_array() + .unwrap() + .iter() + .find(|full| full["nodeId"] == frame["nodeId"]) + { + for (field, value) in full.as_object().unwrap() { + frame[field] = value.clone(); + } + } + } + } + let issues = result["projectionIssues"].as_array().unwrap(); + for issue in issues + .iter() + .chain(result["projectionEvidence"].as_array().unwrap()) + .filter(|i| { + matches!( + i["code"].as_str(), + Some("DEVUP_CODEGEN_ABSOLUTE_FALLBACK" | "DEVUP_CODEGEN_ABSOLUTE_VERIFIED") + ) + }) + { + let original = &issue["details"]["originalValue"]; + assert_eq!(original["x"], 0); + assert_eq!(original["y"], 0); + assert_eq!(original["width"], 360); + assert_eq!(original["height"], 740); + assert_eq!(original["parent"]["width"], 360); + assert_eq!(original["constraints"]["horizontal"], "CENTER"); + assert_eq!(original["children"][0]["x"], 20); + let padding = &issue["details"]["appliedValue"]["derivedPadding"]; + assert_eq!(padding["left"], 20.0); + assert_eq!(padding["top"], if count == 40 { 232.5 } else { 185.5 }); + } + let layout: Vec<_> = issues + .iter() + .filter(|i| i["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") + .collect(); + let original_class = if count == 40 { + "component-reference" + } else { + "text-auto-size" + }; + if count == 0 { + assert_eq!( + result["projectionEvidence"] + .as_array() + .unwrap() + .iter() + .filter(|d| d["details"]["resolution"] == "accounted-for-content-sizing") + .count(), + 2 + ); + } + assert_eq!( + layout + .iter() + .filter(|i| i["details"]["classification"] == original_class) + .count(), + if count == 40 { 39 } else { count } // R17 fluid width now proves one previously missing mapping. + ); + for issue in layout { + assert_eq!( + issue["details"]["appliedValue"]["state"], "emitted", + "{issue}" + ); + assert!( + issue["details"]["appliedValue"]["generatedSource"] + .as_str() + .is_some_and(|s| !s.is_empty()) + ); + assert!(issue["details"]["nextAction"].is_string()); + if issue["details"]["classification"] == "component-reference" { + assert_eq!(issue["details"]["output"], "componentTsx"); + assert_eq!(issue["details"]["classification"], "component-reference"); + } else if issue["property"] == "layoutSizingHorizontal" { + // R17 checks source FILL even when no width property was emitted. + assert_eq!(issue["details"]["originalValue"], "FILL"); + assert_eq!(issue["fidelityImpact"], "lossy"); + assert_eq!( + issue["details"]["implicitCssVerification"]["state"], + "not-accounted-for" + ); + assert!(issue["details"]["implicitCssVerification"]["reasonCode"].is_string()); + } else if issue["property"] == "layoutSizingVertical" { + // R6 exposes a real percentage-height dependency that R2 did not count. + assert_eq!(issue["nodeId"], "3997:46313"); + assert_eq!(issue["details"]["output"], "tsx"); + assert_eq!(issue["fidelityImpact"], "lossy"); + assert_eq!(issue["details"]["originalValue"], "FILL"); + assert_eq!( + issue["details"]["implicitCssVerification"]["state"], + "not-accounted-for" + ); + } else if matches!( + issue["details"]["classification"].as_str(), + Some("asset-projection" | "property-unmapped") + ) { + // R5 additionally discloses unproven asset percentage axes. + assert_eq!(issue["details"]["output"], "tsx"); + assert!( + matches!(issue["property"].as_str(), Some("width" | "height")), + "{issue}" + ); + } else { + assert_eq!(issue["details"]["output"], "tsx"); + assert_eq!(issue["details"]["classification"], "text-auto-size"); + assert!( + issue["details"]["appliedValue"]["generatedSource"] + .as_str() + .unwrap() + .contains("", + ) + .unwrap(), + ); + let result = project(data, operation(&["tsx", "assetManifest"])) + .await + .unwrap(); + assert!(result["tsx"].is_string()); + assert_eq!(result["failures"], json!([])); + assert_eq!(result["assetSummary"]["status"], "collected"); +} + +#[tokio::test] +async fn r1_shared_visible_asset_path_is_not_excluded() { + let mut data = payload(); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["2:1", "2:2", "2:3"])); + for (id, visible) in [("2:1", false), ("2:2", true)] { + data.snapshot.nodes.insert(id.into(), serde_json::from_value(json!({"id":id,"type":"VECTOR", + "fields":{"name":"Shared", "parentId":"1:1", "visible":visible,"width":24,"height":24}})).unwrap()); + } + data.snapshot.nodes.insert( + "2:3".into(), + serde_json::from_value( + json!({"id":"2:3", "type":"TEXT", "fields":{"parentId":"1:1", "characters":"Label"}}), + ) + .unwrap(), + ); + let mut op = operation(&["tsx", "assetManifest"]); + if let PendingOperation::Export { + asset_names_per_node, + .. + } = &mut op + { + *asset_names_per_node = false; + } + let result = project(data, op).await.unwrap(); + assert!(result["tsx"].is_string()); + assert_eq!(result["failures"], json!([])); + let path = result["assetManifest"]["assets"][0]["path"] + .as_str() + .unwrap(); + assert!(result["tsx"].as_str().unwrap().contains(path)); +} + +fn payload() -> CollectedPayload { + serde_json::from_value(json!({ + "target": {"fileKey":"W1Fixture", "nodeId":"1:1", "branchKey":null}, + "scope":"node", "metadata":{}, "variables":null, "styles":null, + "completeness":"resolved-values-only", "sourceVersion":null, + "snapshot": {"fileKey":"W1Fixture", "version":null, "roots":["1:1"], + "nodes":{"1:1":{"id":"1:1","type":"FRAME","fields":{ + "name":"Screen", "x":0,"y":0,"width":390,"height":800, + "visible":true,"childrenIds":[]}}},"diagnostics":[]} + })) + .unwrap() +} + +#[tokio::test] +async fn p1_frames_report_generated_asset_only_equivalence() { + let mut data = section(3); + for i in 1..=3 { + let id = format!("1:{i}"); + let node = data.snapshot.nodes.get_mut(&id).unwrap(); + node.fields.insert( + "fills".into(), + json!([{"type":"IMAGE", "imageHash":format!("image{i}"), "scaleMode":"FILL"}]), + ); + } + data.snapshot + .nodes + .get_mut("1:3") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["3:1"])); + data.snapshot.nodes.insert( + "3:1".into(), + serde_json::from_value(json!({ + "id":"3:1", "type":"TEXT", "fields":{"name":"Copy", "parentId":"1:3", + "characters":"Different copy", "fontSize":16, "width":100, "height":24} + })) + .unwrap(), + ); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["frames"][1]["structurallyIdenticalTo"], "1:1"); + assert_eq!( + result["frames"][1]["differsOnly"], + json!(["assetReferences"]) + ); + assert!(result["frames"][2].get("structurallyIdenticalTo").is_none()); +} + +#[tokio::test] +async fn p1_asset_summary_distinguishes_none_uncollected_and_hidden() { + let result = project(payload(), operation(&["tsx"])).await.unwrap(); + assert_eq!(result["assetSummary"]["status"], "none"); + let mut data = payload(); + data.snapshot.nodes.get_mut("1:1").unwrap().node_type = "VECTOR".into(); + let result = project(data.clone(), operation(&["assetManifest"])) + .await + .unwrap(); + assert_eq!(result["assetSummary"]["status"], "not-collected"); + assert_eq!( + result["assetSummary"]["unavailable"][0]["reason"], + "not-requested" + ); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("visible".into(), json!(false)); + let result = project(data, operation(&["assetManifest"])).await.unwrap(); + assert_eq!(result["assetSummary"]["status"], "not-collected"); + assert_eq!( + result["assetSummary"]["unavailable"][0]["reason"], + "hidden-node" + ); +} + +#[tokio::test] +async fn p1_incomplete_asset_discovery_never_claims_no_assets() { + let mut data = payload(); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["missing"])); + let result = project(data, operation(&["assetManifest"])).await.unwrap(); + assert_eq!(result["assetSummary"]["status"], "unknown"); + assert_eq!(result["assetSummary"]["discovery"], "incomplete"); +} + +#[tokio::test] +async fn p1_selection_separates_explicit_cases_from_automatic_screens() { + let mut data = section(2); + data.metadata["sectionIndex"]["candidates"][1]["bounds"]["width"] = json!(150); + data.metadata["sectionIndex"]["candidates"][1]["bounds"]["height"] = json!(150); + let result = project(data.clone(), operation(&["tsx"])).await.unwrap(); + assert_eq!(result["selection"]["count"], 1); + assert_eq!( + result["selection"]["explicitCandidates"][0]["node"]["nodeId"], + "1:2" + ); + assert_eq!( + result["selection"]["explicitCandidates"][0]["node"]["kind"], + "annotation" + ); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec!["1:2".into()]; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["frames"][0]["nodeId"], "1:2"); +} + +#[test] +fn p1_asset_comparison_preserves_literal_text_and_unknown_paths() { + let paths = vec!["/icons/a.svg".into(), "/icons/b.svg".into()]; + assert_eq!( + generated_structure("", &paths), + generated_structure("", &paths) + ); + assert_ne!( + generated_structure("{\"url(/icons/a.svg)\"}", &paths), + generated_structure("{\"url(/icons/b.svg)\"}", &paths) + ); + assert_ne!( + generated_structure("", &paths), + generated_structure("", &paths) + ); +} + +#[test] +fn p1_layered_backgrounds_compare_every_asset_reference() { + let paths = vec!["/images/a.png".into(), "/images/b.png".into()]; + let first = ""; + let second = ""; + assert_eq!( + generated_structure(first, &paths), + generated_structure(second, &paths) + ); + assert_ne!( + generated_structure(first, &paths), + generated_structure(&second.replace("red", "green"), &paths) + ); +} + +#[tokio::test] +async fn p1_selection_does_not_claim_to_include_an_unproduced_manifest() { + let result = project(section(2), operation(&["tsx", "assetManifest"])) + .await + .unwrap(); + assert_eq!(result["status"], "selection_required"); + assert!(result.get("assetManifest").is_none()); + assert_eq!(result["assetSummary"]["manifestIncluded"], false); + for output in ["imageAudit", "assetManifest"] { + let result = project(section(2), operation(&[output])).await.unwrap(); + assert_eq!(result["status"], "selection_required"); + assert!( + result["nextAction"]["example"]["arguments"]["outputs"] + .as_array() + .unwrap() + .iter() + .any(|o| o == output) + ); + } +} + +#[test] +fn p1_comparison_scopes_partial_frames_to_their_generated_outputs() { + let mut frames = vec![ + json!({"nodeId":"1", "status":"complete", "tsx":"code", "componentTsx":"component"}), + json!({"nodeId":"2", "status":"partial", "tsx":"code"}), + json!({"nodeId":"3", "status":"partial", "tsx":"code", "componentTsx":"component"}), + json!({"nodeId":"4", "status":"partial"}), + ]; + annotate_identical_frames(&mut frames, &payload(), true); + assert!(frames[1].get("structurallyIdenticalTo").is_none()); + assert_eq!(frames[2]["structurallyIdenticalTo"], "1"); + assert_eq!(frames[2]["comparedOutputs"], json!(["tsx", "componentTsx"])); + assert!(frames[3].get("structurallyIdenticalTo").is_none()); +} + +#[tokio::test] +async fn p1_identical_hints_and_asset_summary_survive_resource_delivery() { + let mut op = operation(&["tsx", "componentTsx"]); + if let PendingOperation::Export { + all_screens, + delivery, + .. + } = &mut op + { + *all_screens = true; + *delivery = DeliveryMode::Resource; + } + let result = project(section(2), op).await.unwrap(); + assert!(result["frames"][0].get("tsx").is_none()); + assert_eq!(result["frames"][1]["structurallyIdenticalTo"], "1:1"); + assert_eq!(result["frames"][1]["differsOnly"], json!([])); + assert_eq!( + result["frames"][1]["comparedOutputs"], + json!(["tsx", "componentTsx"]) + ); + assert!(result["assetSummary"].is_object()); + assert!(result["frames"][0]["assetSummary"].is_object()); +} + +#[tokio::test] +async fn p1_collected_assets_are_distinct_from_delivered_manifest() { + let mut data = payload(); + data.snapshot.nodes.get_mut("1:1").unwrap().node_type = "VECTOR".into(); + data.assets.push( + devup_mcp_figma::exported_asset_from_bytes( + &devup_mcp_figma::AssetRequest { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + render_mode: devup_mcp_figma::AssetRenderMode::Node, + quality: None, + lossless: None, + asset_id: "1:1:node".into(), + node_id: "1:1".into(), + field: "node".into(), + image_hash: None, + format: devup_mcp_figma::AssetFormat::Svg, + scale: 1.0, + }, + b"", + ) + .unwrap(), + ); + let result = project(data, operation(&["tsx"])).await.unwrap(); + assert_eq!(result["assetSummary"]["status"], "collected"); + assert_eq!(result["assetSummary"]["collectedCount"], 1); + assert_eq!(result["assetSummary"]["manifestIncluded"], false); + assert!(result.get("assetManifest").is_none()); +} + +#[tokio::test] +async fn p3_identical_bytes_share_manifest_paths() { + let mut data = payload(); + for (id, path) in [("1:2", "/icons/a.svg"), ("1:3", "/icons/b.svg")] { + data.assets.push( + serde_json::from_value(json!({ + "assetId":id,"nodeId":id,"field":"svg","sourceKind":"svg", + "status":"exported","format":"svg","scale":1,"mimeType":"image/svg+xml", + "dataBase64":STANDARD.encode(b""), "byteLength":6, + "sha256":sha256_hex(b""),"path":path + })) + .unwrap(), + ); + } + let result = project(data, operation(&["assetManifest"])).await.unwrap(); + let assets = result["assetManifest"]["assets"].as_array().unwrap(); + assert_eq!(assets[0]["path"], assets[1]["path"]); +} + +#[tokio::test] +async fn p3_selection_call_preserves_export_options() { + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { + component_name, + strict, + .. + } = &mut op + { + *component_name = Some("TicketScreen".into()); + *strict = true; + } + let result = project(section(2), op).await.unwrap(); + let args = &result["nextAction"]["example"]["arguments"]; + assert_eq!(args["componentName"], "TicketScreen"); + assert_eq!(args["strict"], true); + assert_eq!(args["frameIds"], json!(["1:1"])); +} + +fn original_source_payload() -> CollectedPayload { + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(800, 500, |x, y| { + image::Rgba([(x % 255) as u8, (y % 255) as u8, 127, 179]) + })) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let mut data = p3_asset_payload(png.get_ref()); + data.assets.clear(); + data.snapshot.nodes.remove("1:3"); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["1:2"])); + let node = data.snapshot.nodes.get_mut("1:2").unwrap(); + node.fields.insert("width".into(), json!(120.1)); + node.fields.insert("height".into(), json!(80)); + node.fields.insert( + "effects".into(), + json!([{"type":"LAYER_BLUR","radius":12,"visible":true}]), + ); + let req = devup_mcp_figma::AssetRequest { + usage_aspect_ratio: Some(1.0), + max_width: Some(300), + srcset: true, + visible_scope_node_id: None, + quality: Some(85), + lossless: Some(true), + render_mode: devup_mcp_figma::AssetRenderMode::OriginalSource, + asset_id: "1:2:fills:0".into(), + node_id: "1:2".into(), + field: "fills/0".into(), + image_hash: Some("1:2".into()), + format: devup_mcp_figma::AssetFormat::Webp, + scale: 2.0, + }; + let result = devup_mcp_figma::UpstreamResult { + raw: json!({"kind":"devupAssetExport","fileKey":"W1Fixture","version":null,"assetId":req.asset_id,"nodeId":req.node_id,"field":req.field,"imageHash":req.image_hash,"format":"webp","scale":2,"renderMode":"original-source","usageAspectRatio":1,"maxWidth":300,"srcset":true,"visibleScopeNodeId":null,"status":"exported","mimeType":"image/png","byteLength":png.get_ref().len(),"sha256":sha256_hex(png.get_ref()),"data":STANDARD.encode(png.get_ref()),"exportDiagnostics":{"method":"original-fill","stages":[],"renderInfo":{"representation":"original-fill","nodeSize":{"width":120.1,"height":80,"aspectRatio":120.1/80.0},"sourceProperties":{"scaleMode":"FILL","rotation":0,"effects":[{"type":"LAYER_BLUR","radius":12}]}}}}), + }; + let devup_mcp_figma::AssetExportOutcome::Entry(entry) = + devup_mcp_figma::asset_export_from_result(&result, "W1Fixture", None, &req).unwrap() + else { + panic!("entry") + }; + data.assets.push(*entry); + data +} + +#[tokio::test] +async fn original_source_variants_css_and_public_files_share_actual_byte_paths() { + let root = + std::env::temp_dir().join(format!("devup-original-public-{}", rand::random::())); + std::fs::create_dir_all(&root).unwrap(); + let root = crate::test_paths::canonical(&root); + let base = root.join("images").join("source.webp"); + let css = root.join("asset-css-map.json"); + let mut op = operation(&["tsx", "assetManifest", "assetCssMap", "imageAudit"]); + if let PendingOperation::Export { + asset_output_paths, + asset_public_root, + output_paths, + .. + } = &mut op + { + asset_output_paths.insert("1:2:fills:0".into(), base.to_string_lossy().into()); + *asset_public_root = Some(root.clone()); + output_paths.insert("assetCssMap".into(), css.to_string_lossy().into()); + } + let value = project(original_source_payload(), op).await.unwrap(); + let asset = &value["assetManifest"]["assets"][0]; + let path = asset["path"].as_str().unwrap(); + assert_eq!(path, "/images/source.webp"); + assert_eq!(asset["variants"][0]["path"], "/images/source@2x.webp"); + let base_image = image::open(&base).unwrap(); + let twice = image::open(root.join("images/source@2x.webp")).unwrap(); + assert_eq!(base_image.width(), 121); + assert_eq!(twice.width(), 241); + let code = value["tsx"].as_str().unwrap(); + assert!(code.contains(path)); + assert!(code.contains("/images/source@2x.webp"), "{code}"); + assert!(code.contains("blur(12px)")); + assert!(code.contains("srcSet=")); + let map: Value = serde_json::from_slice(&std::fs::read(&css).unwrap()).unwrap(); + assert_eq!( + std::fs::read(&css).unwrap(), + serde_json::to_vec_pretty(&value["assetCssMap"]).unwrap(), + "written CSS mapping must match the inline JSON encoding byte-for-byte" + ); + assert_eq!(map["assets"][0]["effectsBaked"], false); + assert_eq!(map["assets"][0]["assetPath"], path); + assert_eq!( + value["imageAudit"]["assets"][0]["aspectRatioComparison"]["nodeVsUsage"], + "mismatch" + ); + assert!( + value["imageAudit"]["assets"][0]["aspectRatioComparison"]["usageEvidence"] + .as_str() + .unwrap() + .contains("caller-declared") + ); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/devup-mcp/src/server/projection/w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs b/crates/devup-mcp/src/server/projection/w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs new file mode 100644 index 00000000..917fc6e5 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs @@ -0,0 +1,971 @@ + +#[tokio::test] +async fn r7_real_wquw118_layout_contracts_are_preserved() { + let data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-118-payload.json" + )) + .unwrap(); + let mut op = operation(&["tsx", "sourceMap"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec!["3997:46277".into()]; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["frames"].as_array().unwrap().len(), 1); + let frame = &result["frames"][0]; + assert_eq!(frame["nodeId"], "3997:46277"); + let entries = frame["sourceMap"]["entries"].as_array().unwrap(); + for (id, field, resolution) in [ + ("3997:46293", "width", "accounted-for-implicit-flex-grow"), + ("3997:46295", "width", "accounted-for-implicit-flex-grow"), + ( + "3997:46313", + "layoutSizingVertical", + "accounted-for-implicit-flex-stretch", + ), + ] { + assert!( + entries.iter().any(|e| e["nodeId"] == id + && e["property"] == field + && e["resolution"] == resolution), + "{id}" + ); + } + assert!( + frame["tsx"] + .as_str() + .unwrap() + .contains("alignSelf=\"stretch\"") + ); + for id in ["3997:46293", "3997:46295", "3997:46313"] { + assert!( + !result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|i| i["nodeId"] == id && i["fidelityImpact"] == "lossy"), + "{result}" + ); + } +} + +#[tokio::test] +async fn r7_artifact_reuses_stored_selection() { + let data = section(2); + let store = ArtifactStore::default(); + let mut request = CollectionRequest::new(data.target.clone(), CollectionScope::Node); + request.section = Some(devup_mcp_figma::SectionReadOptions { + frame_ids: vec!["1:2".into()], + all_screens: false, + }); + let artifact = store + .insert(ArtifactRequestKey::from_collection(&request), data) + .await + .unwrap(); + let result = complete_operation( + operation(&["tsx"]), + &artifact.payload, + "artifact", + &artifact, + &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), + &store, + ) + .await + .unwrap(); + assert_ne!(result["status"], "selection_required"); + assert_eq!(result["frames"][0]["nodeId"], "1:2"); + assert_eq!(result["frames"].as_array().unwrap().len(), 1); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec!["1:1".into()]; + } + let explicit = complete_operation( + op, + &artifact.payload, + "artifact", + &artifact, + &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), + &store, + ) + .await + .unwrap(); + assert_eq!(explicit["frames"][0]["nodeId"], "1:1"); +} + +#[tokio::test] +async fn r7_auto_debug_resource_has_inline_reprojection_guidance() { + let mut data = payload(); + data.metadata = json!({"large":"x".repeat(300_000)}); + let mut op = operation(&["tsx", "rawPayload", "sourceMap"]); + if let PendingOperation::Export { delivery, .. } = &mut op { + *delivery = DeliveryMode::Auto; + } + let result = project(data, op).await.unwrap(); + assert!(result["resources"].is_array()); + assert_eq!( + result["nextAction"]["example"]["arguments"]["delivery"], + "inline" + ); + assert_eq!(result["nextAction"]["example"]["arguments"]["debug"], true); + assert!( + result["nextAction"]["how"] + .as_str() + .unwrap() + .contains("resource") + ); +} + +#[tokio::test] +async fn r12_oversized_export_retains_artifact_and_callable_resource_projection() { + use super::super::{DevupAuth, DevupServer, Services}; + use devup_mcp_figma::{AuthStatus, FigmaUpstream, ReadToolCall, UpstreamResult}; + use std::sync::Arc; + struct Offline; + #[async_trait::async_trait] + impl DevupAuth for Offline { + async fn status(&self) -> Result { + panic!("cached projection must not authenticate") + } + async fn login(&self) -> Result { + panic!("cached projection must not login") + } + async fn logout(&self) -> Result { + panic!("cached projection must not logout") + } + } + #[async_trait::async_trait] + impl FigmaUpstream for Offline { + async fn list_tools(&self) -> Result, DevupError> { + panic!("cached projection must not list upstream tools") + } + async fn call_read_tool(&self, _: ReadToolCall) -> Result { + panic!("cached projection must not call Figma") + } + } + let mut data = payload(); + data.metadata = json!({"large":"x".repeat(600_000)}); + let store = ArtifactStore::default(); + let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )); + let artifact = store.insert(key, data).await.unwrap(); + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + let outputs = ["tsx", "rawPayload", "sourceMap", "assetManifest"]; + let error = complete_operation( + operation(&outputs), + &artifact.payload, + "artifact", + &artifact, + &policy, + &store, + ) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); + let envelope = super::super::structured_tool_error(super::super::to_mcp_error(error.clone())); + let wire = serde_json::to_value(&envelope).unwrap(); + assert_eq!(wire["isError"], true); + assert_eq!( + serde_json::from_str::(wire["content"][0]["text"].as_str().unwrap()).unwrap(), + wire["structuredContent"] + ); + assert_eq!(wire["structuredContent"]["error"]["details"], error.details); + let details = &error.details; + assert_eq!(details["recoveryState"], "available"); + assert_eq!(details["nextAction"]["tool"], "devup_figma_export"); + let args = &details["nextAction"]["arguments"]; + assert_eq!(args["artifactId"], artifact.artifact_id); + assert_eq!(args["delivery"], "resource"); + assert_eq!(args["outputs"], json!(outputs)); + assert_eq!(args["debug"], true); + assert!(details["serializedResponseBytes"].as_u64().unwrap() > 1_048_576); + assert!(store.get(&artifact.artifact_id).await.is_some()); + let mut calls = vec![args.clone()]; + for next in details["splitRequests"].as_array().unwrap() { + assert_eq!(next["tool"], "devup_figma_export"); + calls.push(next["arguments"].clone()); + } + assert!(calls.len() > 1); + let mut server = DevupServer::new(Services::new(Arc::new(Offline), Arc::new(Offline))); + server.artifacts = store.clone(); + for args in calls { + // Deserialize and execute the complete callable arguments through + // the actual tool handler, including its validation contract. + let result = server + .devup_figma_export(rmcp::handler::server::wrapper::Parameters( + serde_json::from_value(args).unwrap(), + )) + .await + .unwrap() + .structured_content + .unwrap(); + assert!(result["resources"].is_array()); + assert_eq!(result["cache"]["artifactId"], artifact.artifact_id); + } +} + +#[test] +fn r8_nonsection_semantic_asset_mapping_uses_delivered_path() { + let mut result = json!({"tsx":"", "sourceMap":{"tsx":[{"nodeId":"한글:1","property":"fills","generatedProperty":"src=\"/icons/grommet-icons:language.svg\"","resolution":"asset"}]}}); + rewrite_result_asset_references(&mut result, &BTreeMap::new()); + assert_eq!( + result["sourceMap"]["tsx"][0]["generatedProperty"], + "src=\"/icons/grommet-icons-language-d201f62b16f5.svg\"" + ); +} + +#[tokio::test] +async fn r8_three_frame_nine_output_units_measure_local_projection() { + let data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-119-payload.json" + )) + .unwrap(); + let mut op = operation(&["tsx", "rawSnapshot", "sourceMap"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec![ + "3997:46315".into(), + "3997:46715".into(), + "3997:46333".into(), + ]; + } + let began = std::time::Instant::now(); + let store = ArtifactStore::default(); + let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )); + let artifact = store.insert(key, data).await.unwrap(); + let policy = OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(); + // R17 source FILL issues exceed the unchanged inline limit. Exercise the + // retained-artifact recovery and read every output, rather than hiding loss. + let error = complete_operation( + op.clone(), + &artifact.payload, + "fixture", + &artifact, + &policy, + &store, + ) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupFigmaResponseTooLarge); + assert_eq!(error.details["recoveryState"], "available"); + assert_eq!( + error.details["nextAction"]["arguments"]["artifactId"], + artifact.artifact_id + ); + assert_eq!( + error.details["nextAction"]["arguments"]["delivery"], + "resource" + ); + if let PendingOperation::Export { delivery, .. } = &mut op { + *delivery = DeliveryMode::Resource; + } + let mut result = + complete_operation(op, &artifact.payload, "fixture", &artifact, &policy, &store) + .await + .unwrap(); + let mut outputs = BTreeMap::new(); + for manifest in store.output_manifests().await { + let mut bytes = Vec::new(); + for index in 0..manifest.chunk_count { + bytes.extend( + store + .read_output_chunk(&artifact.artifact_id, &manifest.output_id, index) + .await + .unwrap(), + ); + } + assert_eq!(bytes.len(), manifest.raw_bytes); + outputs.insert(manifest.name, String::from_utf8(bytes).unwrap()); + } + assert_eq!(outputs.len(), 8); // shared snapshot/audit plus 3 TSX/sourceMap pairs + assert_eq!( + serde_json::from_str::(&outputs["raw-snapshot.json"]).unwrap(), + serde_json::to_value(&artifact.payload.snapshot).unwrap() + ); + for index in 0..3 { + let tsx = &outputs[&format!("frame-{}.tsx", index + 1)]; + assert!(tsx.contains("export function")); + result["frames"][index]["tsx"] = json!(tsx); + result["frames"][index]["sourceMap"] = + serde_json::from_str(&outputs[&format!("frame-{}.source-map.json", index + 1)]) + .unwrap(); + } + assert_eq!(result["frames"].as_array().unwrap().len(), 3); + for frame in result["frames"].as_array().unwrap() { + assert!(frame["tsx"].is_string()); + assert!(frame["sourceMap"].is_object()); + assert!(frame["sourceMap"]["rasterizedAssets"].is_array()); + } + eprintln!( + "R8 offline 3-frame/9-unit projection: {:?}; no upstream timing inferred", + began.elapsed() + ); +} + +#[tokio::test] +async fn r8_final_korean_semantic_map_follows_asset_path_rewrites() { + let mut data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-120-payload.json" + )) + .unwrap(); + data.snapshot = serde_json::from_str(include_str!( + "../../../../../fixtures/r8/modal-snapshot.json" + )) + .unwrap(); + let mut op = operation(&["tsx", "sourceMap"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec!["3997:46582".into()]; + } + let started = std::time::Instant::now(); + let result = project(data, op).await.unwrap(); + eprintln!( + "R8 modal projection: {:?}; executable: {:?}", + started.elapsed(), + std::env::current_exe().unwrap() + ); + let frame = &result["frames"][0]; + let tsx = frame["tsx"].as_str().unwrap(); + assert!(tsx.contains("추가 체험")); + let entries = frame["sourceMap"]["entries"].as_array().unwrap(); + for entry in entries { + assert!( + entry.get("generatedRange").is_none(), + "public offsets must not return: {entry}" + ); + assert!(entry["nodeId"].is_string() && entry["property"].is_string()); + let generated = entry["generatedProperty"] + .as_str() + .expect("mapped property required"); + assert!(!generated.is_empty()); + if !generated.starts_with("implicit:") && generated != "children" { + assert!( + tsx.contains(generated), + "mapping must name emitted property: {entry}" + ); + } + } + let modal_type = entries + .iter() + .find(|e| e["nodeId"] == "3997:46621" && e["property"] == "type") + .unwrap(); + assert_eq!(modal_type["generatedProperty"], "Box"); + assert_eq!(modal_type["resolution"], "exact"); + assert_eq!(frame["sourceMap"]["source"]["generatedOutput"], "tsx"); + eprintln!( + "R8 sourceMap={} bytes, TSX={} bytes", + serde_json::to_vec(&frame["sourceMap"]).unwrap().len(), + tsx.len() + ); +} + +#[tokio::test] +async fn r16_verdict_scope_identifies_remaining_asset_axes_without_diagnostics() { + let mut data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-118-payload.json" + )) + .unwrap(); + data.snapshot = serde_json::from_str(include_str!( + "../../../../../fixtures/r14/asset-snapshot.json" + )) + .unwrap(); + let mut op = operation(&["tsx", "sourceMap"]); + if let PendingOperation::Export { + frame_ids, + include_diagnostics, + .. + } = &mut op + { + *frame_ids = vec!["3997:46667".into()]; + *include_diagnostics = false; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["status"], "partial"); + assert_eq!(result["quality"]["projection"], "approximated"); + for owner in [&result, &result["frames"][0]] { + let scope = &owner["verdictScope"]; + assert_eq!(scope["status"], "partial"); + assert_eq!(scope["projection"], "approximated"); + let causes: Vec<_> = scope["projectionCauses"] + .as_array() + .unwrap() + .iter() + .filter(|c| { + c["nodeId"] == "3997:46668" && c["code"] == "DEVUP_CODEGEN_ABSOLUTE_FALLBACK" + }) + .collect(); + assert_eq!(causes.len(), 2); + assert_eq!(causes[0]["component"], "horizontal"); + assert_eq!(causes[1]["component"], "vertical"); + for cause in causes { + assert_eq!(cause["screenId"], "3997:46667"); + assert_eq!(cause["state"], "approximated"); + assert!(cause["reason"].as_str().is_some_and(|s| !s.is_empty())); + } + } +} + +#[tokio::test] +async fn r16_complete_scope_and_mapping_semantics_survive_resource_delivery() { + for (delivery_mode, real_fill) in [ + (DeliveryMode::Inline, false), + (DeliveryMode::Resource, false), + (DeliveryMode::Inline, true), + (DeliveryMode::Resource, true), + ] { + let data: CollectedPayload = if real_fill { + serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-118-payload.json" + )) + .unwrap() + } else { + let mut data = section(1); + data.snapshot.nodes.get_mut("1:1").unwrap().fields.extend( + serde_json::from_value::>(json!({"layoutSizingHorizontal":"FIXED", "layoutSizingVertical":"FIXED", "fills":[{"type":"SOLID","color":{"r":0.2,"g":0.4,"b":0.6}}]})).unwrap() + ); + data + }; + let mut op = operation(&["tsx", "sourceMap"]); + if let PendingOperation::Export { + frame_ids, + delivery, + .. + } = &mut op + { + *frame_ids = vec![if real_fill { "3997:46129" } else { "1:1" }.into()]; + *delivery = delivery_mode; + } + let result = project(data, op).await.unwrap(); + if real_fill { + assert_eq!(result["status"], "partial"); + assert_eq!(result["verdictScope"]["projection"], "lossy"); + let fill_issues: Vec<_> = result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .filter(|d| d["property"] == "layoutSizingHorizontal") + .collect(); + assert_eq!(fill_issues.len(), 18); + for issue in fill_issues { + assert_eq!(issue["fidelityImpact"], "lossy"); + assert_eq!(issue["details"]["originalValue"], "FILL"); + } + assert_eq!( + result["frames"][0]["outputResults"]["tsx"]["mappingComplete"], + false + ); + } else { + assert_eq!(result["status"], "complete"); + assert_eq!(result["verdictScope"]["projectionCauses"], json!([])); + assert_eq!(result["verdictScope"]["statusCauses"], json!([])); + assert_eq!(result["verdictScope"]["projection"], "exact"); + } + assert_eq!(result["resolutionSemantics"]["axis"], "mapping-method"); + assert!( + result["resolutionSemantics"]["values"]["raw-fallback"] + .as_str() + .unwrap() + .contains("verified") + ); + if result.get("resources").is_none() { + assert_eq!( + result["frames"][0]["sourceMap"]["resolutionSemantics"]["axis"], + "mapping-method" + ); + } + } +} + +#[tokio::test] +async fn fields_narrow_raw_snapshot_and_source_map() { + let mut op = operation(&["tsx", "rawSnapshot", "sourceMap"]); + if let PendingOperation::Export { fields, .. } = &mut op { + *fields = vec!["typography".into()]; + } + let result = project(payload(), op).await.unwrap(); + for node in result["rawSnapshot"]["nodes"].as_object().unwrap().values() { + for key in node["fields"].as_object().unwrap().keys() { + assert!( + [ + "fontSize", + "fontName", + "fontWeight", + "lineHeight", + "letterSpacing", + "textStyleId", + "styledTextSegments", + "textAlignHorizontal", + "characters", + "childrenIds", + "parentId", + "name", + "visible", + "isAsset" + ] + .contains(&key.as_str()), + "unselected field survived: {key}" + ); + } + } + for entry in result["sourceMap"]["tsx"].as_array().into_iter().flatten() { + assert!(entry["property"] != "width", "{entry}"); + } +} +#[tokio::test] +async fn r16_non_projection_partial_has_its_own_status_cause() { + let mut data = payload(); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .field_errors + .insert("name".into(), "unavailable".into()); + let result = project(data, operation(&["rawSnapshot"])).await.unwrap(); + assert_eq!(result["status"], "partial"); + assert_eq!(result["quality"]["projection"], "not-requested"); + assert_eq!(result["verdictScope"]["projectionCauses"], json!([])); + assert!( + result["verdictScope"]["statusCauses"] + .as_array() + .unwrap() + .iter() + .any(|c| c["domain"] == "acquisition" && c["state"] == "partial") + ); + let acquisition = result["verdictScope"]["statusCauses"] + .as_array() + .unwrap() + .iter() + .find(|c| c["domain"] == "acquisition") + .unwrap(); + assert_eq!(acquisition["evidenceScope"], "response"); +} + +#[tokio::test] +async fn r16_frame_scope_retains_scoped_output_failures() { + let mut data = section(2); + // Unlike the deliberately underspecified generic Section fixture, + // this successful sibling has a complete fixed-dimension proof. + data.snapshot.nodes.get_mut("1:1").unwrap().fields.extend( + serde_json::from_value::>(json!({ + "layoutSizingHorizontal":"FIXED", "layoutSizingVertical":"FIXED" + })) + .unwrap(), + ); + data.snapshot.nodes.remove("1:2"); + data.snapshot.roots = vec!["1:1".into()]; + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec!["1:1".into(), "1:2".into()]; + } + let result = project(data, op).await.unwrap(); + let good = &result["frames"][0]; + let failed = &result["frames"][1]; + assert_eq!(good["verdictScope"]["statusCauses"], json!([])); + let causes = failed["verdictScope"]["statusCauses"].as_array().unwrap(); + assert!(causes.iter().any(|c| c["domain"] == "requested-output" + && c["nodeId"] == "1:2" + && c["output"] == "tsx")); + for cause in causes.iter().filter(|c| c["domain"] == "requested-output") { + let i = cause["failureIndex"].as_u64().unwrap() as usize; + assert_eq!(failed["failures"][i]["errorCode"], cause["code"]); + } +} + +#[tokio::test] +async fn r16_resource_scope_retains_every_projection_issue() { + for asset_case in [false, true] { + let mut data: CollectedPayload = serde_json::from_str(include_str!( + "../../../../../fixtures/r2/wquw-119-payload.json" + )) + .unwrap(); + let selected = if asset_case { + data.snapshot = serde_json::from_str(include_str!( + "../../../../../fixtures/r14/asset-snapshot.json" + )) + .unwrap(); + vec!["3997:46667".into()] + } else { + vec![ + "3997:46315".into(), + "3997:46715".into(), + "3997:46333".into(), + ] + }; + let mut op = operation(&["tsx", "rawSnapshot", "sourceMap"]); + if let PendingOperation::Export { + frame_ids, + delivery, + .. + } = &mut op + { + *frame_ids = selected; + *delivery = DeliveryMode::Resource; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["resolutionSemantics"]["axis"], "mapping-method"); + if asset_case { + assert!(!result["projectionIssues"].as_array().unwrap().is_empty()); + } + for owner in std::iter::once(&result).chain(result["frames"].as_array().unwrap()) { + let causes = owner["verdictScope"]["projectionCauses"] + .as_array() + .unwrap(); + let issues = owner["projectionIssues"].as_array().unwrap(); + for (i, issue) in issues.iter().enumerate() { + assert!(causes.iter().any(|c| c["diagnosticIndex"] == i + && c["nodeId"] == issue["nodeId"] + && c["code"] == issue["code"])); + } + eprintln!( + "R16 metadata node={} scopeBytes={} issues={}", + owner["nodeId"], + serde_json::to_vec(&owner["verdictScope"]).unwrap().len(), + issues.len() + ); + } + } +} + +include!("../projection_fingerprint_tests.rs"); + +#[tokio::test] +async fn feedback_interaction_hints_match_saved_files_and_resource_hashes() { + for section_case in [false, true] { + let mut baseline_op = operation(&["tsx", "sourceMap"]); + if section_case && let PendingOperation::Export { frame_ids, .. } = &mut baseline_op { + *frame_ids = vec!["1:1".into()]; + } + let baseline = project( + if section_case { section(2) } else { payload() }, + baseline_op, + ) + .await + .unwrap(); + let mut data = if section_case { section(2) } else { payload() }; + let node = data.snapshot.nodes.get_mut("1:1").unwrap(); + node.fields.insert("name".into(), json!("CTA Button")); + node.fields + .insert("variantProperties".into(), json!({"State":"disabled"})); + node.fields.insert("reactions".into(),json!([{"trigger":{"type":"ON_CLICK"},"actions":[{"type":"URL","url":"https://example.com/confirmed"}]}])); + let target = std::env::temp_dir().join(format!( + "feedback-hints-{}-{}.tsx", + std::process::id(), + rand::random::() + )); + let mut op = operation(&["tsx", "sourceMap"]); + if let PendingOperation::Export { + delivery, + output_paths, + frame_ids, + .. + } = &mut op + { + *delivery = DeliveryMode::Resource; + if section_case { + *frame_ids = vec!["1:1".into()]; + } + output_paths.insert( + if section_case { "frame:1:1:tsx" } else { "tsx" }.into(), + target.to_string_lossy().into(), + ); + } + let store = ArtifactStore::default(); + let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )); + let artifact = store.insert(key, data).await.unwrap(); + let result = complete_operation( + op, + &artifact.payload, + "fixture", + &artifact, + &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), + &store, + ) + .await + .unwrap(); + let manifest = store + .output_manifests() + .await + .into_iter() + .find(|m| m.mime_type == "text/typescript") + .unwrap(); + let mut bytes = vec![]; + for index in 0..manifest.chunk_count { + bytes.extend( + store + .read_output_chunk(&manifest.artifact_id, &manifest.output_id, index) + .await + .unwrap(), + ); + } + assert_eq!(bytes, std::fs::read(&target).unwrap()); + assert_eq!(bytes.len(), manifest.raw_bytes); + assert_eq!(sha256_hex(&bytes), manifest.sha256); + let code = String::from_utf8(bytes).unwrap(); + assert!(code.contains("// @devup-interaction ")); + assert!(code.contains("https://example.com/confirmed")); + assert!(code.contains("disabled")); + assert!(!code.contains("href=")); + assert_eq!(result["deliverable"]["isFinal"], false); + assert_eq!(result["mappingComplete"], baseline["mappingComplete"]); + assert!( + result["interactiveCandidates"] + .as_array() + .is_some_and(|a| !a.is_empty()) + ); + std::fs::remove_file(target).unwrap(); + } +} + +async fn project( + payload: CollectedPayload, + operation: PendingOperation, +) -> Result { + let store = ArtifactStore::default(); + let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( + payload.target.clone(), + CollectionScope::Node, + )); + let artifact = store.insert(key, payload).await.unwrap(); + complete_operation( + operation, + &artifact.payload, + "fixture", + &artifact, + &OutputPolicy::from_roots(vec![std::env::temp_dir()]).unwrap(), + &store, + ) + .await +} + +#[tokio::test] +async fn w1_component_tsx_projects_selected_frames_without_section_root() { + let mut op = operation(&["componentTsx"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec!["1:1".into()]; + } + let result = project(section(2), op).await.unwrap(); + assert_eq!(result["frames"].as_array().unwrap().len(), 1); + assert!( + result["frames"][0]["componentTsx"] + .as_str() + .unwrap() + .contains("export function") + ); + assert_ne!(result["quality"]["projection"], "not-requested"); +} + +#[tokio::test] +async fn w1_unavailable_responsive_output_is_actionable_even_with_tsx_and_strict() { + for outputs in [vec!["responsiveTsx"], vec!["tsx", "responsiveTsx"]] { + for strict_value in [false, true] { + let mut op = operation(&outputs); + if let PendingOperation::Export { + all_screens, + strict, + .. + } = &mut op + { + *all_screens = true; + *strict = strict_value; + } + let error = project(section(2), op).await.unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + assert!(error.message.contains("responsiveTsx")); + assert!(error.message.contains("tsx")); + assert!(error.details.is_object()); + } + } +} + +#[tokio::test] +async fn w1_asset_failure_is_partial_and_strict_refuses_it() { + let mut data = payload(); + data.assets.push(serde_json::from_value(json!({"assetId":"1:1:fills:0","nodeId":"1:1", + "field":"fills/0","sourceKind":"image","status":"failed","errorCode":"DEVUP_ASSET_EXPORT_FAILED"})).unwrap()); + let result = project(data.clone(), operation(&["assetManifest"])) + .await + .unwrap(); + assert_eq!(result["status"], "partial"); + assert_eq!(result["failures"][0]["assetId"], "1:1:fills:0"); + assert_ne!(result["quality"]["assets"], "complete"); + let mut strict_op = operation(&["assetManifest"]); + if let PendingOperation::Export { strict, .. } = &mut strict_op { + *strict = true; + } + assert!(project(data, strict_op).await.is_err()); +} + +#[tokio::test] +async fn nested_asset_failure_preserves_details_without_promising_a_path_or_retry() { + for (code, detail, retryable) in [ + ( + "DEVUP_ASSET_EXPORT_FAILED", + "export failed after 10400 ms: Unable to establish connection to Figma after 10 seconds", + false, + ), + ( + "DEVUP_ASSET_EXPORT_FAILED", + "native export: renderer failed; geometry fallback unsupported: mask requires native export", + false, + ), + ( + "DEVUP_ASSET_TIMEOUT", + "export exceeded 180000 ms asset budget", + true, + ), + ] { + let mut data = payload(); + data.assets.push(serde_json::from_value(json!({"assetId":"1:1:fills:0","nodeId":"1:1", + "field":"fills/0","sourceKind":"image","status":"failed","errorCode":code, + "errorDetail":detail,"path":"/icons/not-generated.svg","outputPath":"C:/not-generated.svg"})).unwrap()); + let result = project(data, operation(&["assetManifest"])).await.unwrap(); + assert_eq!(result["status"], "partial"); + assert_eq!(result["failures"][0]["retryable"], retryable); + assert_eq!(result["failures"][0]["errorDetail"], detail); + let asset = &result["assetManifest"]["assets"][0]; + assert_eq!(asset["status"], "failed"); + assert!(asset["path"].is_null() && asset["outputPath"].is_null()); + assert_eq!( + result["assetSummary"]["unavailable"][0]["errorDetail"], + detail + ); + assert!(result["outputPaths"].as_object().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn w1_frame_failure_preserves_other_frames_and_context() { + let mut data = section(2); + data.snapshot.nodes.remove("1:2"); + data.snapshot.roots.retain(|id| id != "1:2"); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["status"], "partial"); + assert!(result["frames"][0]["tsx"].is_string()); + assert_eq!(result["failures"][0]["nodeId"], "1:2"); + assert_eq!(result["frames"][1]["quality"]["projection"], "failed"); + assert_eq!( + result["failures"][0]["details"]["snapshotRootIds"], + json!(["1:1"]) + ); +} + +#[tokio::test] +async fn w1_all_screens_uses_explore_classification() { + let mut data = section(3); + data.metadata["sectionIndex"]["candidates"][1]["bounds"] = + json!({"x":0,"y":0,"width":900,"height":100}); + data.metadata["sectionIndex"]["candidates"][2]["bounds"] = + json!({"x":0,"y":0,"width":100,"height":100}); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["frames"].as_array().unwrap().len(), 1); + assert_eq!(result["frames"][0]["nodeId"], "1:1"); +} + +#[tokio::test] +async fn w1_large_section_has_actionable_batch_refusal() { + let mut op = operation(&["tsx", "assetManifest"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let error = project(section(14), op).await.unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + assert!(error.details["recommendedFrameIds"].is_array()); +} + +#[tokio::test] +async fn w1_resource_delivery_moves_all_code_and_updates_note() { + let store = ArtifactStore::default(); + let data = payload(); + let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )); + let artifact = store.insert(key, data).await.unwrap(); + let mut result = json!({"tsx":"screen", "responsiveTsx":"responsive", "componentTsx":"component", + "frames":[{"componentTsx":"frame-component"}], + "deliverable":{"note":"Implement from this value."}}); + let outputs = projected_outputs_from_result(result.as_object().unwrap()).unwrap(); + let attachment = apply_delivery( + &mut result, + DeliveryMode::Resource, + &store, + &artifact, + outputs, + None, + ) + .await + .unwrap(); + commit_delivery(attachment); + assert!(result.get("componentTsx").is_none()); + assert!(result.get("responsiveTsx").is_none()); + assert!(result["frames"][0].get("componentTsx").is_none()); + assert_eq!(result["resources"].as_array().unwrap().len(), 4); + assert!( + result["deliverable"]["note"] + .as_str() + .unwrap() + .contains("resources") + ); +} + +#[test] +fn w1_asset_paths_are_ascii_bounded_and_windows_safe() { + assert_eq!( + host_safe_asset_path("/images/Frame 269.png"), + "/images/Frame-269-3e2af8571e3f.png" + ); + for name in [ + "한글.svg".to_owned(), + "CON.svg".to_owned(), + format!("{}.png", "a".repeat(300)), + ] { + let path = host_safe_asset_path(&format!("/icons/{name}")); + assert!(path.is_ascii()); + assert!(path.len() < 200); + assert_ne!(path.to_uppercase(), "/ICONS/CON.SVG"); + assert_eq!(host_safe_asset_path(&path), path); + } + assert_ne!( + host_safe_asset_path("/icons/한글.svg"), + host_safe_asset_path("/icons/한국.svg") + ); +} + +#[test] +fn w1_fixture_asset_paths_are_normalized_without_changing_other_code() { + let code = include_str!("../../../../../fixtures/plugin-answers/notice/pure.tsx"); + let safe = with_host_safe_asset_paths(code); + assert!(!safe.contains("/icons/Property 1=")); + assert_eq!(with_host_safe_asset_paths(&safe), safe); +} +#[tokio::test] +async fn w1_index_accepts_component_projection_selection() { + let store = ArtifactStore::default(); + let data = section(2); + let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )); + let artifact = store.insert(key, data).await.unwrap(); + for output in ["componentTsx", "responsiveTsx"] { + super::super::validation::validate_artifact_projection( + &artifact, + &[output.into()], + "node", + &[], + ) + .unwrap(); + } +} diff --git a/crates/devup-mcp/src/server/projection/w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs b/crates/devup-mcp/src/server/projection/w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs new file mode 100644 index 00000000..bd761602 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs @@ -0,0 +1,548 @@ + +#[tokio::test] +async fn w1_hidden_asset_is_warning_and_requested_failure_is_partial() { + let mut data = payload(); + data.assets.push( + serde_json::from_value(json!({"assetId":"1:1:fills:0","nodeId":"1:1", + "field":"fills/0","sourceKind":"image","status":"failed","format":"png","scale":1, + "errorCode":"DEVUP_ASSET_NODE_HIDDEN"})) + .unwrap(), + ); + let mut op = operation(&["assetManifest"]); + if let PendingOperation::Export { asset_captures, .. } = &mut op { + asset_captures.push(devup_mcp_figma::AssetSelection { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + render_mode: devup_mcp_figma::AssetRenderMode::Node, + quality: None, + lossless: None, + asset_id: "1:1:fills:0".into(), + format: devup_mcp_figma::AssetFormat::Png, + scale: 1.0, + }); + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["failures"], json!([])); + assert_eq!(result["warnings"][0]["assetId"], "1:1:fills:0"); + assert_eq!(result["status"], "complete"); +} + +#[tokio::test] +async fn w1_manifest_preserves_layer_name_and_section_paths_match() { + let mut data = section(1); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("name".into(), json!("BI - 아이콘")); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["2:1"])); + data.snapshot.nodes.insert( + "2:1".into(), + serde_json::from_value(json!({"id":"2:1","type":"VECTOR", + "fields":{"name":"BI - 아이콘", "parentId":"1:1","width":24,"height":24,"visible":true, + "fills":[{"type":"SOLID","color":{"r":0,"g":0,"b":0}}]}})) + .unwrap(), + ); + let mut op = operation(&["tsx", "assetManifest"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data, op).await.unwrap(); + let asset = &result["assetManifest"]["assets"][0]; + assert_eq!(asset["layerName"], "BI - 아이콘"); + let path = asset["path"].as_str().unwrap(); + assert!(path.is_ascii()); + assert!(!path.contains(' ')); + assert!(result["frames"][0]["tsx"].as_str().unwrap().contains(path)); +} + +#[tokio::test] +async fn w1_missing_master_is_partial_with_component_id() { + let mut data = section(1); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["2:1"])); + data.snapshot.nodes.insert( + "2:1".into(), + serde_json::from_value(json!({"id":"2:1","type":"INSTANCE", + "fields":{"name":"Button","parentId":"1:1","componentId":"9:9","visible":true}})) + .unwrap(), + ); + let mut op = operation(&["tsx", "componentTsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["status"], "partial"); + assert!(result["frames"][0]["tsx"].is_string()); + assert_eq!(result["failures"][0]["details"]["componentId"], "9:9"); + assert_eq!(result["failures"][0]["details"]["instanceNodeId"], "2:1"); +} + +#[tokio::test] +async fn w1_source_map_without_generator_is_actionable() { + let error = project(payload(), operation(&["sourceMap"])) + .await + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + assert!(error.message.contains("tsx")); +} + +#[tokio::test] +async fn r6_source_map_error_explains_artifact_outputs() { + let error = project(payload(), operation(&["rawSnapshot", "sourceMap"])) + .await + .unwrap_err(); + assert!( + error.message.contains("artifactId") && error.message.contains("same call"), + "{}", + error.message + ); + assert!(error.message.contains( + r#"{"artifactId":"","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true}"# + )); +} + +#[tokio::test] +async fn r6_completion_vertical_loss_is_scoped_to_tsx() { + let mut data = payload(); + data.snapshot = serde_json::from_str(include_str!( + "../../../../devup-mcp-devup-ui/tests/fixtures/r6-completion.json" + )) + .unwrap(); + data.target.node_id = Some("3997:46333".into()); + let mut op = operation(&["tsx", "sourceMap"]); + if let PendingOperation::Export { root_layout, .. } = &mut op { + *root_layout = devup_mcp_devup_ui::codegen::RootLayout::Embedded; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["deliverable"]["isFinal"], false); + assert!( + result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|d| d["nodeId"] == "3997:46333" + && d["property"] == "layoutSizingVertical" + && d["fidelityImpact"] == "lossy" + && d["details"]["output"] == "tsx") + ); +} + +fn responsive_payload() -> CollectedPayload { + let mut data = section(2); + for (id, name, width) in [("1:1", "mobile", 390), ("1:2", "desktop", 1440)] { + let node = data.snapshot.nodes.get_mut(id).unwrap(); + node.fields.insert("name".into(), json!(name)); + node.fields.insert("width".into(), json!(width)); + } + data +} + +#[tokio::test] +async fn w1_responsive_does_not_merge_unselected_frames() { + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + *frame_ids = vec!["1:1".into()]; + } + let result = project(responsive_payload(), op).await; + assert!( + result.is_err(), + "one selected width cannot be a responsive merge" + ); +} + +#[tokio::test] +async fn w1_responsive_success_has_code_resource_and_no_empty_frames() { + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { + all_screens, + delivery, + .. + } = &mut op + { + *all_screens = true; + *delivery = DeliveryMode::Resource; + } + let result = project(responsive_payload(), op).await.unwrap(); + assert_ne!(result["quality"]["projection"], "not-requested"); + assert!( + result.get("frames").is_none(), + "no per-frame outputs were requested" + ); + assert!( + result["resources"] + .as_array() + .unwrap() + .iter() + .any(|r| r["name"] == "responsiveTsx") + ); +} + +#[tokio::test] +async fn w1_empty_all_screens_is_actionable() { + let mut data = section(1); + data.metadata["sectionIndex"]["candidates"][0]["bounds"] = + json!({"x":0,"y":0,"width":100,"height":100}); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let error = project(data, op).await.unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + assert!(error.message.contains("frameIds")); +} + +#[tokio::test] +async fn w1_partial_frames_provide_retry_arguments() { + let mut data = section(2); + data.snapshot.nodes.remove("1:2"); + data.snapshot.roots.retain(|id| id != "1:2"); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["resume"]["arguments"]["frameIds"], json!(["1:2"])); + assert_eq!(result["resume"]["arguments"]["outputs"], json!(["tsx"])); + assert!(result["resume"]["arguments"]["url"].is_string()); +} + +#[tokio::test] +async fn w1_discovered_hidden_assets_are_warnings_not_failed_deliverables() { + let mut data = payload(); + let node = data.snapshot.nodes.get_mut("1:1").unwrap(); + node.node_type = "VECTOR".into(); + node.fields.insert("visible".into(), json!(false)); + let result = project(data, operation(&["assetManifest"])).await.unwrap(); + assert_eq!(result["status"], "complete"); + assert_eq!(result["assetManifest"]["assets"], json!([])); + assert_eq!( + result["warnings"][0]["errorCode"], + "DEVUP_ASSET_NODE_HIDDEN" + ); +} + +#[tokio::test] +async fn w1_index_unavailable_output_explains_collection_step() { + let store = ArtifactStore::default(); + let data = section(2); + let key = ArtifactRequestKey::from_collection(&CollectionRequest::new( + data.target.clone(), + CollectionScope::Node, + )); + let artifact = store.insert(key, data).await.unwrap(); + let error = super::super::validation::validate_artifact_projection( + &artifact, + &["assetManifest".into()], + "node", + &[], + ) + .unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + assert!(error.message.contains("frameIds")); +} +#[test] +fn w1_slash_in_layer_name_matches_manifest_and_code() { + let original = "/icons/한글 Foo/Bar-1-2.svg"; + let path = host_safe_asset_path(original); + let code = with_host_safe_asset_paths(&format!("")); + assert!(path.is_ascii()); + assert!(!path.contains(' ')); + assert_eq!(path.matches('/').count(), 2); + assert!(code.contains(&path)); +} + +#[tokio::test] +async fn w1_captured_main_component_id_is_reported_when_missing() { + let mut data = section(1); + data.snapshot + .nodes + .get_mut("1:1") + .unwrap() + .fields + .insert("childrenIds".into(), json!(["2:1"])); + data.snapshot.nodes.insert( + "2:1".into(), + serde_json::from_value(json!({"id":"2:1","type":"INSTANCE", + "fields":{"name":"Button","parentId":"1:1","mainComponentId":"9:9","visible":true}})) + .unwrap(), + ); + let mut op = operation(&["componentTsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["status"], "partial"); + assert_eq!(result["failures"][0]["details"]["componentId"], "9:9"); +} + +#[tokio::test] +async fn w1_responsive_text_loss_is_partial_and_strict_rejects() { + let mut data = responsive_payload(); + for (root, id, text) in [ + ("1:1", "2:1", "Mobile only"), + ("1:2", "2:2", "Desktop only"), + ] { + data.snapshot + .nodes + .get_mut(root) + .unwrap() + .fields + .insert("childrenIds".into(), json!([id])); + data.snapshot.nodes.insert(id.into(), serde_json::from_value(json!({"id":id,"type":"TEXT", + "fields":{"name":"Label","parentId":root,"characters":text,"visible":true,"fontSize":16}})).unwrap()); + } + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data.clone(), op.clone()).await.unwrap(); + assert!( + result["responsiveUnrepresented"] + .as_array() + .is_some_and(|notes| !notes.is_empty()) + ); + assert_eq!(result["status"], "partial"); + assert_eq!(result["quality"]["projection"], "lossy"); + if let PendingOperation::Export { strict, .. } = &mut op { + *strict = true; + } + assert!(project(data, op).await.is_err()); +} + +#[tokio::test] +async fn w1_responsive_slot_collision_is_actionable() { + let mut data = responsive_payload(); + data.snapshot + .nodes + .get_mut("1:2") + .unwrap() + .fields + .insert("width".into(), json!(400)); + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let error = project(data, op).await.unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + assert!(error.message.contains("breakpoint")); +} + +#[tokio::test] +async fn w1_missing_collected_outputs_explain_how_to_recollect() { + for output in ["devupJson", "referencePng"] { + let error = project(payload(), operation(&[output])).await.unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + assert!(error.message.contains(output)); + assert!(error.details["snapshotRootIds"].is_array()); + } +} +#[tokio::test] +async fn w1_optional_responsive_collision_keeps_requested_tsx() { + let mut data = responsive_payload(); + data.snapshot + .nodes + .get_mut("1:2") + .unwrap() + .fields + .insert("width".into(), json!(400)); + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { all_screens, .. } = &mut op { + *all_screens = true; + } + let result = project(data, op).await.unwrap(); + assert_eq!(result["frames"].as_array().unwrap().len(), 2); + assert!(result["frames"][0]["tsx"].is_string()); + assert!(result.get("responsiveTsx").is_none()); +} + +#[test] +fn w1_ascii_slugs_do_not_merge_distinct_variant_assets() { + assert_ne!( + host_safe_asset_path("/icons/Icons=a b.svg"), + host_safe_asset_path("/icons/Icons=a-b.svg") + ); + assert_ne!( + host_safe_asset_path("/icons/Foo Bar.svg"), + host_safe_asset_path("/icons/Foo-Bar.svg") + ); +} +#[tokio::test] +async fn w7_scaffold_assets_match_public_paths_and_preview_has_no_asset_writes() { + let dir = std::env::temp_dir().join(format!("devup-w7-assets-{:016x}", rand::random::())); + std::fs::create_dir_all(dir.join("public")).unwrap(); + let root = crate::test_paths::canonical(&dir); + let mut op = operation(&["pageScaffold"]); + if let PendingOperation::Export { + page_scaffold, + output_paths, + asset_public_root, + asset_output_paths, + .. + } = &mut op + { + *page_scaffold = Some(page_scaffold::PageScaffoldOptions { + route: "/settings/profile".into(), + write: false, + overwrite: false, + }); + output_paths.insert("pageScaffold".into(), root.to_string_lossy().into_owned()); + *asset_public_root = Some(root.join("public")); + asset_output_paths.insert( + "1:2:fills:0".into(), + root.join("public/icons/actual.png") + .to_string_lossy() + .into_owned(), + ); + } + let preview = project(p3_asset_payload(b"first image"), op.clone()) + .await + .unwrap(); + assert_eq!(std::fs::read_dir(root.join("public")).unwrap().count(), 0); + assert!(!root.join("src").exists()); + assert!(preview.get("tsx").is_none()); + assert!(preview.get("assetManifest").is_none()); + let preview_files = preview["pageScaffold"]["files"].as_array().unwrap(); + assert_eq!( + preview_files.len(), + 3, + "identical captured bytes reuse one asset" + ); + if let PendingOperation::Export { + page_scaffold: Some(options), + .. + } = &mut op + { + options.write = true; + } + let written = project(p3_asset_payload(b"first image"), op).await.unwrap(); + let files = written["pageScaffold"]["files"].as_array().unwrap(); + assert_eq!(preview_files, files); + let code = files + .iter() + .find(|f| f["path"].as_str().unwrap().contains("components")) + .unwrap()["content"] + .as_str() + .unwrap(); + assert!(code.contains("/icons/actual.png"), "{code}"); + for file in files { + let expected = if file["encoding"] == "base64" { + STANDARD.decode(file["content"].as_str().unwrap()).unwrap() + } else { + file["content"].as_str().unwrap().as_bytes().to_vec() + }; + assert_eq!( + std::fs::read(file["path"].as_str().unwrap()).unwrap(), + expected + ); + } + assert_eq!( + std::fs::read(root.join("public/icons/actual.png")).unwrap(), + b"first image" + ); + std::fs::remove_dir_all(root).unwrap(); +} + +#[tokio::test] +async fn w7_frame_scaffold_uses_frame_output_key_and_rejects_multi_route_guess() { + let dir = std::env::temp_dir().join(format!("devup-w7-frame-{:016x}", rand::random::())); + std::fs::create_dir(&dir).unwrap(); + let mut op = operation(&["pageScaffold"]); + if let PendingOperation::Export { + page_scaffold, + output_paths, + frame_ids, + .. + } = &mut op + { + *page_scaffold = Some(page_scaffold::PageScaffoldOptions { + route: "settings".into(), + write: true, + overwrite: false, + }); + *frame_ids = vec!["1:1".into()]; + output_paths.insert( + "frame:1:1:pageScaffold".into(), + dir.to_string_lossy().into_owned(), + ); + output_paths.insert( + "pageScaffold".into(), + dir.join("ignored").to_string_lossy().into_owned(), + ); + } + let value = project(section(2), op.clone()).await.unwrap(); + assert!(value.get("pageScaffold").is_none()); + assert!(value["frames"][0].get("tsx").is_none()); + assert_eq!( + value["frames"][0]["pageScaffold"]["files"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert!( + value["outputPathResults"]["supportedKeys"] + .as_array() + .unwrap() + .contains(&json!("frame:1:1:pageScaffold")) + ); + assert!( + value["outputPathResults"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|d| d["key"] == "pageScaffold") + ); + assert!(!dir.join("ignored").exists()); + if let PendingOperation::Export { frame_ids, .. } = &mut op { + frame_ids.push("1:2".into()); + } + assert!( + project(section(2), op) + .await + .unwrap_err() + .message + .contains("one frameId") + ); + std::fs::remove_dir_all(dir).unwrap(); +} +#[tokio::test] +async fn w7_section_selection_keeps_one_route_and_a_usable_frame_key() { + let mut op = operation(&["pageScaffold"]); + if let PendingOperation::Export { + page_scaffold, + output_paths, + .. + } = &mut op + { + *page_scaffold = Some(page_scaffold::PageScaffoldOptions { + route: "/settings".into(), + write: true, + overwrite: false, + }); + output_paths.insert("pageScaffold".into(), "project".into()); + } + let value = project(section(3), op).await.unwrap(); + assert_eq!(value["status"], "selection_required"); + assert_eq!(value["nextAction"]["maxFramesPerCall"], 1); + assert!( + value["nextAction"].get("batches").is_none(), + "one supplied route cannot be applied to every screen" + ); + let args = &value["nextAction"]["example"]["arguments"]; + assert_eq!(args["pageScaffold"]["route"], "/settings"); + assert_eq!(args["outputPaths"]["frame:1:1:pageScaffold"], "project"); + assert!(args["outputPaths"].get("pageScaffold").is_none()); +} diff --git a/crates/devup-mcp/src/server/result_contract.rs b/crates/devup-mcp/src/server/result_contract.rs index 91bedc42..1c1bf289 100644 --- a/crates/devup-mcp/src/server/result_contract.rs +++ b/crates/devup-mcp/src/server/result_contract.rs @@ -109,7 +109,7 @@ pub(super) fn attach_review_and_deliverable( }); if !issues.is_empty() { result.insert("projectionReview".into(), json!({"issueCount":issues.len(),"groups":groups, - "order":"Withheld outputs first, then unmapped properties, component references, and other approximations; larger groups first within each priority. Priority is review order, not measured pixel impact."})); + "order":include_str!("text/result_contract-attach_review_and_deliverable-b1b16fb6.md")})); } if !requested .iter() diff --git a/crates/devup-mcp/src/server/runtime_audit.rs b/crates/devup-mcp/src/server/runtime_audit.rs index 88a90539..c88718e9 100644 --- a/crates/devup-mcp/src/server/runtime_audit.rs +++ b/crates/devup-mcp/src/server/runtime_audit.rs @@ -11,2961 +11,8 @@ //! from the items, verifies each screenshot's hash, size and decoded width, and //! says which facts it verified and which only the executor declared. -use std::collections::BTreeMap; -use std::path::Path; - -use devup_mcp_figma::{DevupError, ErrorCode}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; - -use super::artifacts::ArtifactStore; -use super::output::{OutputPolicy, OutputTransaction}; - -pub(crate) const PLAN_KIND: &str = "devup-runtime-audit-plan"; -pub(crate) const REPORT_KIND: &str = "devup-runtime-audit-report"; -const SCHEMA_VERSION: u64 = 1; -const MAX_VIEWPORTS: usize = 8; -const MAX_SCREENS: usize = 60; -const MAX_STEPS: usize = 20; -// A full audit names a bounds, capture and health check per section and -// asset, so a screen carries far more than a hand-written plan would. -const MAX_CHECKS: usize = 200; -const MAX_TIMEOUT_MS: u64 = 120_000; -const MAX_VIEWPORT_SIDE: u32 = 4096; -const MAX_TEXT: usize = 300; -const MAX_PLAN_BYTES: usize = 1024 * 1024; -const MAX_REPORT_BYTES: usize = 8 * 1024 * 1024; -const MAX_SCREENSHOT_BYTES: usize = 16 * 1024 * 1024; -const MAX_SCREENSHOTS: usize = 400; -const MAX_LISTED: usize = 50; -// Keep complete per-width rows in the response without unbounded duplication. -const MAX_DIMENSION_ROWS: usize = 100; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct RuntimeAuditInput { - /// plan: validate and write a plan for the consumer-side executor. - /// ingest: verify the executor's report against its plan. - #[schemars(extend("enum" = ["plan", "ingest"]))] - pub action: String, - /// Required for plan. - #[serde(default)] - pub plan: Option, - /// plan: where to write the plan JSON, under an allowed write root. - #[serde(default)] - pub output_path: Option, - /// plan: emit the consumer Playwright runner and its pinned npm manifest - /// into this directory under an allowed root; requires outputPath. - #[serde(default)] - pub template_path: Option, - /// ingest: the plan JSON the executor ran. - #[serde(default)] - pub plan_path: Option, - /// ingest: the executor's report.json. Screenshot paths in it are - /// relative to its directory. - #[serde(default)] - pub report_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct PlanInput { - /// http(s) URL of the running consumer app, e.g. http://127.0.0.1:4173. - pub base_url: String, - /// Origins the run may load from; every other request is blocked and - /// recorded. Must include the baseUrl origin. - pub allowed_origins: Vec, - /// Defaults to 1440, 1100, 858 and 390 CSS-pixel widths at height 900. - #[serde(default = "default_viewports")] - pub viewports: Vec, - /// Visited in order in one isolated browser context per viewport. - pub screens: Vec, - #[serde(default)] - pub readiness: Option, - /// Font families that must be loaded before checks; a missing one is an - /// environment error, not a layout failure. - #[serde(default)] - pub fonts: Vec, - #[serde(default)] - pub locale: Option, - #[serde(default)] - pub timezone: Option, - #[serde(default)] - pub step_timeout_ms: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct ViewportInput { - pub width: u32, - pub height: u32, - #[serde(default = "default_scale")] - pub device_scale_factor: f64, - #[serde(default)] - pub mobile: bool, - #[serde(default)] - pub touch: bool, -} - -fn default_scale() -> f64 { - 1.0 -} - -fn default_viewports() -> Vec { - [1440, 1100, 858, 390] - .into_iter() - .map(|width| ViewportInput { - width, - height: 900, - device_scale_factor: 1.0, - mobile: false, - touch: false, - }) - .collect() -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct ScreenInput { - pub id: String, - /// Navigated to directly. Omitted, the screen is reached by its steps - /// from the previous one. - #[serde(default)] - pub route_path: Option, - /// The component this screen renders, recorded as an anchor only. - #[serde(default)] - pub component_path: Option, - #[serde(default)] - pub steps: Vec, - #[serde(default)] - pub expect: Option, - #[serde(default)] - pub checks: Vec, - /// The Figma frame this screen was generated from and the widths it was - /// drawn at. Only those widths have a visual original. - #[serde(default)] - pub figma: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct StepInput { - #[schemars(extend("enum" = ["click", "fill"]))] - pub action: String, - pub target: LocatorInput, - /// The text for fill. - #[serde(default)] - pub value: Option, - #[serde(default)] - pub timeout_ms: Option, -} - -/// Exactly one way of finding an element: a role with its accessible name, a -/// test id, visible text, or a CSS selector (checks only). -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct LocatorInput { - #[serde(default)] - pub role: Option, - #[serde(default)] - pub name: Option, - #[serde(default)] - pub test_id: Option, - #[serde(default)] - pub text: Option, - #[serde(default)] - pub css: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct ExpectInput { - /// The path the page must reach, compared without origin or query. - #[serde(default)] - pub url_path: Option, - /// A heading with this accessible name must be visible. - #[serde(default)] - pub heading: Option, - #[serde(default)] - pub visible: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct CheckInput { - /// no-horizontal-overflow: the page is no wider than the viewport. - /// contained: subject's box lies inside container's. - /// aligned: subject keeps offsetPx from container's edge (left/right) or - /// shares its centre (center). - /// spans: subject spans container's width less insetPx on each side. - /// visible: subject renders with a non-empty box. - /// dimensions: compare the explicitly mapped element box in CSS pixels - /// with acquired Figma logical dimensions at each named viewport width. - /// view-transition: follow the subject link to another route and confirm - /// a view transition ran (document.startViewTransition, or a - /// cross-document pagereveal transition). It navigates away, so the - /// executor runs it after the screen's other checks and screenshot; the - /// next screen should start from its own routePath. The plan writer - /// moves it after the screen's other checks and the executor returns to - /// the screen's URL after it, so order in the plan cannot break a run. - /// page-health: no error page text ("This page couldn't load", - /// "Application error", ...), page error, console error or failed - /// navigation while the screen was open. - /// bounds: subject's size and position relative to container's top-left, - /// in CSS px, against `expected` (the Figma geometry). Exact: layout is - /// converted statically, so only half a pixel of rounding is allowed and - /// no tolerance can be set. - /// hidden: subject is absent or has no visible box - an element a width's - /// frame does not draw. - /// no-clipped-text: no text inside subject (or the page) is cut off by an - /// overflow-hidden box; ellipsis and line clamp are intended and pass. - /// interaction: hovering or keyboard-focusing subject (`state`) changes - /// something visible; without a subject, every visible link and button. - /// capture: screenshot subject's element box for devup_visual_compare - /// against `reference`; ingest turns it into a ready call. - #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "bounds", "no-clipped-text", "interaction", "capture", "hidden"]))] - pub kind: String, - #[serde(default)] - pub subject: Option, - #[serde(default)] - pub container: Option, - #[serde(default)] - #[schemars(extend("enum" = ["left", "right", "center"]))] - pub edge: Option, - #[serde(default)] - pub offset_px: Option, - #[serde(default)] - pub inset_px: Option, - #[serde(default)] - pub tolerance_px: Option, - /// What of the subject is measured: its element box (default), or the - /// box its CSS mask paints - a rule drawn as a mask can keep its drawn - /// length inside an element box that stretches. - #[serde(default)] - #[schemars(extend("enum" = ["box", "painted-mask"]))] - pub measure: Option, - #[serde(default)] - pub label: Option, - /// Explicit mappings only. Widths omitted here have no design reference; - /// intermediate widths are never interpolated or inferred from screenshots. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub figma_dimensions: Vec, - /// bounds: the expected box relative to container, in CSS px. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expected: Option, - /// interaction: hover or focus (keyboard, so :focus-visible applies). - #[serde(default, skip_serializing_if = "Option::is_none")] - #[schemars(extend("enum" = ["hover", "focus"]))] - pub state: Option, - /// Only at these viewport widths; empty means every width. A bounds or - /// capture check holds Figma geometry, which exists only where the frame - /// was drawn. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub widths: Vec, - /// The Figma node this check is about, so a failure names it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub node_id: Option, - /// capture: what the element screenshot is compared with. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reference: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct BoundsInput { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -/// A native-node PNG of the same Figma node, captured by devup_figma_export -/// (assetCaptures, renderMode node, format png, scale 1). -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct CaptureReferenceInput { - /// section: a top-level child of the frame. asset: an image or icon. - #[schemars(extend("enum" = ["section", "asset"]))] - pub scope: String, - pub artifact_id: String, - pub asset_id: String, - /// Changed-pixel ratio the comparison allows; default 0.03 for a - /// section and 0.05 for an asset, which absorbs anti-aliased edges but - /// not a filled shape where an outline was drawn. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub threshold: Option, -} - -/// Changed-pixel ratios that absorb anti-aliasing between Figma's renderer -/// and Chromium. A small icon has proportionally more edge, so it gets more. -pub(crate) const PAGE_THRESHOLD: f64 = 0.03; -pub(crate) const SECTION_THRESHOLD: f64 = 0.03; -pub(crate) const ASSET_THRESHOLD: f64 = 0.05; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct DimensionReferenceInput { - pub viewport_width: u32, - pub artifact_id: String, - pub node_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct ReadinessInput { - /// A selector the app renders once it is ready to be measured. - #[serde(default)] - pub selector: Option, - #[serde(default)] - pub timeout_ms: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct FigmaAnchorInput { - pub node_id: String, - pub drawn_widths: Vec, - /// The frame drawn at each width when a page has one per width, with - /// the devup_figma_export artifact holding its referencePng. Ingest puts - /// that artifactId in the page comparison it returns. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub frames: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct FrameReferenceInput { - pub width: u32, - pub node_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifact_id: Option, - /// Changed-pixel ratio the page comparison allows; default 0.03. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub threshold: Option, -} - -fn invalid(message: impl Into) -> DevupError { - DevupError::new(ErrorCode::DevupInvalidInput, message, false) -} - -fn sha256(bytes: &[u8]) -> String { - Sha256::digest(bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - -fn origin_of(url: &str) -> Result { - let parsed = url::Url::parse(url).map_err(|_| invalid(format!("{url} is not a URL.")))?; - if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { - return Err(invalid(format!( - "{url} must be an http or https URL with a host." - ))); - } - if !parsed.username().is_empty() || parsed.password().is_some() { - return Err(invalid( - "URLs in a runtime audit plan cannot carry credentials.", - )); - } - Ok(parsed.origin().ascii_serialization()) -} - -fn bounded_text(field: &str, value: &str) -> Result<(), DevupError> { - if value.trim().is_empty() || value.chars().count() > MAX_TEXT { - return Err(invalid(format!( - "{field} must be non-empty and at most {MAX_TEXT} characters." - ))); - } - Ok(()) -} - -fn timeout(field: &str, value: Option) -> Result<(), DevupError> { - if value.is_some_and(|ms| ms == 0 || ms > MAX_TIMEOUT_MS) { - return Err(invalid(format!( - "{field} must be between 1 and {MAX_TIMEOUT_MS} ms." - ))); - } - Ok(()) -} - -fn validate_locator( - field: &str, - locator: &LocatorInput, - allow_css: bool, -) -> Result<(), DevupError> { - let ways = [ - locator.role.is_some(), - locator.test_id.is_some(), - locator.text.is_some(), - locator.css.is_some(), - ] - .iter() - .filter(|set| **set) - .count(); - if ways != 1 { - return Err(invalid(format!( - "{field} needs exactly one of role (with name), testId, text{}.", - if allow_css { " or css" } else { "" } - ))); - } - if locator.css.is_some() && !allow_css { - return Err(invalid(format!( - "{field} must be found by role and accessible name, testId or text; css is for checks only." - ))); - } - if locator.name.is_some() && locator.role.is_none() { - return Err(invalid(format!("{field}.name needs role."))); - } - for (name, value) in [ - ("role", &locator.role), - ("name", &locator.name), - ("testId", &locator.test_id), - ("text", &locator.text), - ("css", &locator.css), - ] { - if let Some(value) = value { - bounded_text(&format!("{field}.{name}"), value)?; - } - } - Ok(()) -} - -fn validate_route(field: &str, path: &str) -> Result<(), DevupError> { - bounded_text(field, path)?; - if !path.starts_with('/') || path.starts_with("//") || path.contains('\\') { - return Err(invalid(format!( - "{field} must be a path on the baseUrl origin, starting with a single /." - ))); - } - Ok(()) -} - -/// Every rule a plan must satisfy before an executor may run it. -pub(crate) fn validate_plan(plan: &PlanInput) -> Result<(), DevupError> { - let base_origin = origin_of(&plan.base_url)?; - let allowed = plan - .allowed_origins - .iter() - .map(|origin| origin_of(origin)) - .collect::, _>>()?; - if !allowed.contains(&base_origin) { - return Err(invalid(format!( - "allowedOrigins must include the baseUrl origin {base_origin}." - ))); - } - if plan.viewports.is_empty() || plan.viewports.len() > MAX_VIEWPORTS { - return Err(invalid(format!( - "viewports must hold 1 to {MAX_VIEWPORTS} entries." - ))); - } - for viewport in &plan.viewports { - if !(1..=MAX_VIEWPORT_SIDE).contains(&viewport.width) - || !(1..=MAX_VIEWPORT_SIDE).contains(&viewport.height) - || !(viewport.device_scale_factor > 0.0 && viewport.device_scale_factor <= 4.0) - { - return Err(invalid(format!( - "Each viewport needs width and height in 1..={MAX_VIEWPORT_SIDE} and a deviceScaleFactor in (0, 4]." - ))); - } - } - let mut viewports = std::collections::BTreeSet::new(); - if plan - .viewports - .iter() - .any(|v| !viewports.insert((v.width, v.height, v.device_scale_factor.to_bits()))) - { - return Err(invalid( - "Duplicate viewports would make report attribution ambiguous.", - )); - } - if plan.screens.is_empty() || plan.screens.len() > MAX_SCREENS { - return Err(invalid(format!( - "screens must hold 1 to {MAX_SCREENS} entries." - ))); - } - let dimension_rows = plan.viewports.len() - * plan - .screens - .iter() - .map(|screen| { - screen - .checks - .iter() - .filter(|check| check.kind == "dimensions") - .count() - }) - .sum::(); - if dimension_rows > MAX_DIMENSION_ROWS { - return Err(invalid(format!( - "A plan may contain at most {MAX_DIMENSION_ROWS} dimension rows (dimension checks x viewports); split the plan to keep every width row explicit." - ))); - } - if plan.viewports.len() * plan.screens.len() > MAX_SCREENSHOTS { - return Err(invalid(format!( - "viewports x screens must be at most {MAX_SCREENSHOTS}, the screenshots ingest verifies for one report; split the plan." - ))); - } - if plan.screens[0].route_path.is_none() { - return Err(invalid("The first screen needs a routePath to start from.")); - } - let mut ids = std::collections::BTreeSet::new(); - for screen in &plan.screens { - bounded_text("screens[].id", &screen.id)?; - if !ids.insert(screen.id.as_str()) { - return Err(invalid(format!("Screen id {} is repeated.", screen.id))); - } - if let Some(path) = &screen.route_path { - validate_route("screens[].routePath", path)?; - } - if let Some(path) = &screen.component_path { - bounded_text("screens[].componentPath", path)?; - } - if screen.route_path.is_none() && screen.steps.is_empty() { - return Err(invalid(format!( - "Screen {} needs a routePath or the steps that reach it.", - screen.id - ))); - } - if screen.steps.len() > MAX_STEPS || screen.checks.len() > MAX_CHECKS { - return Err(invalid(format!( - "Screen {} exceeds {MAX_STEPS} steps or {MAX_CHECKS} checks.", - screen.id - ))); - } - for step in &screen.steps { - validate_locator("steps[].target", &step.target, false)?; - timeout("steps[].timeoutMs", step.timeout_ms)?; - match (step.action.as_str(), &step.value) { - ("click", None) => {} - ("fill", Some(value)) if value.chars().count() <= MAX_TEXT => {} - _ => { - return Err(invalid( - "A step is click (no value) or fill (with a value of at most 300 characters).", - )); - } - } - } - if let Some(expect) = &screen.expect { - if let Some(path) = &expect.url_path { - validate_route("expect.urlPath", path)?; - } - if let Some(heading) = &expect.heading { - bounded_text("expect.heading", heading)?; - } - if let Some(visible) = &expect.visible { - validate_locator("expect.visible", visible, true)?; - } - } - for check in &screen.checks { - validate_check(check)?; - if check - .figma_dimensions - .iter() - .any(|r| !plan.viewports.iter().any(|v| v.width == r.viewport_width)) - { - return Err(invalid( - "figmaDimensions.viewportWidth must name a planned viewport width.", - )); - } - } - if let Some(figma) = &screen.figma { - bounded_text("figma.nodeId", &figma.node_id)?; - if figma.drawn_widths.is_empty() { - return Err(invalid("figma.drawnWidths must name at least one width.")); - } - } - } - for font in &plan.fonts { - bounded_text("fonts[]", font)?; - } - if let Some(readiness) = &plan.readiness { - if let Some(selector) = &readiness.selector { - bounded_text("readiness.selector", selector)?; - } - timeout("readiness.timeoutMs", readiness.timeout_ms)?; - } - timeout("stepTimeoutMs", plan.step_timeout_ms) -} - -fn validate_check(check: &CheckInput) -> Result<(), DevupError> { - let needs = |subject: bool, container: bool| -> Result<(), DevupError> { - if subject != check.subject.is_some() || container != check.container.is_some() { - return Err(invalid(format!( - "A {} check takes {}subject and {}container.", - check.kind, - if subject { "a " } else { "no " }, - if container { "a " } else { "no " }, - ))); - } - Ok(()) - }; - match check.kind.as_str() { - "no-horizontal-overflow" | "page-health" => needs(false, false)?, - "visible" | "view-transition" | "hidden" => needs(true, false)?, - "no-clipped-text" => { - if check.container.is_some() { - return Err(invalid("A no-clipped-text check takes no container.")); - } - } - "bounds" => { - needs(true, true)?; - if check.tolerance_px.is_some() { - return Err(invalid( - "A bounds check is exact (half a pixel of rounding); it takes no tolerancePx. A mismatch is code drift or a converter bug, never noise.", - )); - } - let Some(expected) = &check.expected else { - return Err(invalid( - "A bounds check needs expected {x, y, width, height}.", - )); - }; - if [expected.x, expected.y, expected.width, expected.height] - .iter() - .any(|value| !value.is_finite() || value.abs() > 100_000.0) - || expected.width < 0.0 - || expected.height < 0.0 - { - return Err(invalid( - "bounds expected values must be finite px, width and height nonnegative.", - )); - } - } - "interaction" => { - // Without a subject it sweeps every visible link and button. - if check.container.is_some() { - return Err(invalid("An interaction check takes no container.")); - } - if !matches!(check.state.as_deref(), Some("hover" | "focus")) { - return Err(invalid("An interaction check needs state hover or focus.")); - } - } - "capture" => { - needs(true, false)?; - let Some(reference) = &check.reference else { - return Err(invalid( - "A capture check needs reference {scope, artifactId, assetId}.", - )); - }; - if !matches!(reference.scope.as_str(), "section" | "asset") { - return Err(invalid("capture reference.scope must be section or asset.")); - } - bounded_text("reference.artifactId", &reference.artifact_id)?; - bounded_text("reference.assetId", &reference.asset_id)?; - if reference - .threshold - .is_some_and(|value| !(0.0..=1.0).contains(&value)) - { - return Err(invalid("reference.threshold must be between 0 and 1.")); - } - } - "dimensions" => { - needs(true, false)?; - if check.tolerance_px.is_none() - || check.figma_dimensions.is_empty() - || !matches!(check.measure.as_deref(), None | Some("box")) - { - return Err(invalid( - "A dimensions check needs tolerancePx, figmaDimensions and measure box.", - )); - } - } - "contained" | "spans" => needs(true, true)?, - "aligned" => { - needs(true, true)?; - if !matches!(check.edge.as_deref(), Some("left" | "right" | "center")) { - return Err(invalid( - "An aligned check needs edge left, right or center.", - )); - } - } - other => return Err(invalid(format!("Unknown check kind {other}."))), - } - for (field, present, kind) in [ - ("expected", check.expected.is_some(), "bounds"), - ("state", check.state.is_some(), "interaction"), - ("reference", check.reference.is_some(), "capture"), - ] { - if present && check.kind != kind { - return Err(invalid(format!("{field} is only valid for {kind} checks."))); - } - } - if let Some(node_id) = &check.node_id { - bounded_text("checks[].nodeId", node_id)?; - } - if check.kind != "dimensions" && !check.figma_dimensions.is_empty() { - return Err(invalid( - "figmaDimensions is only valid for dimensions checks.", - )); - } - let mut widths = std::collections::BTreeSet::new(); - for reference in &check.figma_dimensions { - bounded_text("figmaDimensions.artifactId", &reference.artifact_id)?; - bounded_text("figmaDimensions.nodeId", &reference.node_id)?; - if !widths.insert(reference.viewport_width) { - return Err(invalid( - "A dimensions check needs at most one explicit reference per width.", - )); - } - } - for (field, locator) in [("subject", &check.subject), ("container", &check.container)] { - if let Some(locator) = locator { - validate_locator(&format!("checks[].{field}"), locator, true)?; - } - } - match check.measure.as_deref() { - None | Some("box") => {} - Some("painted-mask") if check.subject.is_some() => {} - Some(other) => { - return Err(invalid(format!( - "measure {other} is not box, or painted-mask on a check with a subject." - ))); - } - } - for value in [check.offset_px, check.inset_px, check.tolerance_px] - .into_iter() - .flatten() - { - if !value.is_finite() || value.abs() > 10_000.0 { - return Err(invalid( - "Check offsets, insets and tolerances must be finite px values.", - )); - } - } - if check.tolerance_px.is_some_and(|value| value < 0.0) { - return Err(invalid("tolerancePx must be nonnegative.")); - } - if let Some(label) = &check.label { - bounded_text("checks[].label", label)?; - } - Ok(()) -} - -/// The id a plan is known by: the SHA-256 of its normalized form, so the same -/// plan always has the same id and any edit gives a new one. -fn plan_id(plan: &PlanInput) -> Result { - let bytes = serde_json::to_vec(plan) - .map_err(|error| invalid(format!("Cannot serialize the plan: {error}")))?; - Ok(sha256(&bytes)) -} - -#[cfg(test)] -fn plan( - input: PlanInput, - policy: &OutputPolicy, - output_path: Option<&str>, -) -> Result { - write_plan(input, policy, output_path, None, Vec::new()) -} - -/// Resolve design dimensions from retained acquisitions, never caller numbers. -/// Missing/expired artifacts remain explicit so they cannot become a passing check. -async fn dimension_references(input: &PlanInput, artifacts: &ArtifactStore) -> Vec { - let mut references = Vec::new(); - for screen in &input.screens { - for (index, check) in screen.checks.iter().enumerate() { - for reference in &check.figma_dimensions { - let mut value = json!({"screen": screen.id, "check": index, - "viewportWidth": reference.viewport_width, "artifactId": reference.artifact_id, - "nodeId": reference.node_id, "status": "unverified", - "reason": "The retained acquisition is missing or expired; acquire the node again.", - "mapping": "caller-declared", "units": "figma-logical-px"}); - if let Some(artifact) = artifacts.get(&reference.artifact_id).await { - value["artifactContentHash"] = json!(artifact.content_hash); - value["staleness"] = json!("retained snapshot; not compared with live Figma"); - value["reason"] = - json!("The acquired node has no verified logical width and height."); - if let Some(node) = artifact.payload.snapshot.nodes.get(&reference.node_id) { - let view = node.typed_view(); - let width = (!node.field_errors.contains_key("width")) - .then(|| view.number("width")) - .flatten() - .or_else(|| { - (!node.field_errors.contains_key("size")) - .then(|| view.value("size")?.get("x")?.as_f64()) - .flatten() - }); - let height = (!node.field_errors.contains_key("height")) - .then(|| view.number("height")) - .flatten() - .or_else(|| { - (!node.field_errors.contains_key("size")) - .then(|| view.value("size")?.get("y")?.as_f64()) - .flatten() - }); - if let (Some(width), Some(height)) = (width, height) - && width.is_finite() - && height.is_finite() - && width >= 0.0 - && height >= 0.0 - { - value["status"] = json!("acquired"); - value["width"] = json!(width); - value["height"] = json!(height); - value["reason"] = Value::Null; - value["source"] = json!( - "node logical width/height or size; no screenshot, zoom, DPR or bounding-box inference" - ); - } - } - } - references.push(value); - } - } - } - references -} - -pub(crate) async fn create_plan( - mut input: PlanInput, - policy: &OutputPolicy, - output_path: Option<&str>, - template_path: Option<&str>, - artifacts: &ArtifactStore, -) -> Result { - order_checks(&mut input); - validate_plan(&input)?; - let references = dimension_references(&input, artifacts).await; - write_plan(input, policy, output_path, template_path, references) -} - -/// One screen at one width by what was checked: health (renders, no -/// overflow, no clipped text), geometry (size and position), interaction -/// (states and view transitions), and the three visual levels, which stay -/// "to compare" until devup_visual_compare results come back through -/// devup_screen_plan status. -fn columns( - screen: &ScreenInput, - viewport: &ViewportInput, - judged: &[(&str, &CheckInput)], - row: &Value, -) -> Value { - let fold = |kinds: &[&str]| { - let statuses = judged - .iter() - .filter(|(_, check)| kinds.contains(&check.kind.as_str())) - .map(|(status, _)| *status) - .collect::>(); - if statuses.is_empty() { - "-" - } else if statuses.contains(&"fail") { - "fail" - } else if statuses.iter().all(|status| *status == "pass") { - "pass" - } else if statuses.contains(&"inconclusive") { - "inconclusive" - } else { - "not-run" - } - }; - let unreached = row["reasons"] - .as_array() - .into_iter() - .flatten() - .filter_map(Value::as_str) - .any(|reason| reason.starts_with("page error") || reason.starts_with("screen not reached")); - let health = if unreached { - "fail" - } else { - fold(&["page-health", "no-horizontal-overflow", "no-clipped-text"]) - }; - let captures = |scope: &str| { - let count = judged - .iter() - .filter(|(status, check)| { - *status == "pass" - && check - .reference - .as_ref() - .is_some_and(|reference| reference.scope == scope) - }) - .count(); - let missing = judged - .iter() - .filter(|(status, check)| { - *status != "pass" - && check - .reference - .as_ref() - .is_some_and(|reference| reference.scope == scope) - }) - .count(); - match (count, missing) { - (0, 0) => json!("-"), - (_, 0) => json!(format!("{count} to compare")), - _ => json!(format!("{count} to compare, {missing} not captured")), - } - }; - let drawn = screen - .figma - .as_ref() - .is_some_and(|figma| figma.drawn_widths.contains(&viewport.width)); - json!({ - "health": health, - "geometry": fold(&["bounds", "dimensions", "contained", "aligned", "spans", "visible"]), - "interaction": fold(&["interaction", "view-transition"]), - "page": if drawn { json!(if row["screenshot"].is_string() { "1 to compare" } else { "not captured" }) } else { json!("not drawn") }, - "section": captures("section"), - "asset": captures("asset"), - }) -} - -/// A check limited to some widths does not exist at the others. -fn applies(check: &CheckInput, width: u32) -> bool { - check.widths.is_empty() || check.widths.contains(&width) -} - -fn planned_items(plan: &PlanInput) -> usize { - plan.viewports - .iter() - .map(|viewport| { - plan.screens - .iter() - .flat_map(|screen| &screen.checks) - .filter(|check| applies(check, viewport.width)) - .count() - }) - .sum() -} - -/// A view-transition check clicks away from its screen, so every one moves -/// behind the screen's other checks, keeping their order otherwise. The -/// executor also returns to the screen's URL after it; together a plan -/// cannot measure the wrong page whatever order it was written in. -pub(crate) fn order_checks(input: &mut PlanInput) { - for screen in &mut input.screens { - screen - .checks - .sort_by_key(|check| check.kind == "view-transition"); - } -} - -fn write_plan( - input: PlanInput, - policy: &OutputPolicy, - output_path: Option<&str>, - template_path: Option<&str>, - dimension_references: Vec, -) -> Result { - validate_plan(&input)?; - if template_path.is_some() && output_path.is_none() { - return Err(invalid( - "templatePath requires outputPath for the bound plan JSON.", - )); - } - let id = plan_id(&input)?; - let references = input - .screens - .iter() - .filter_map(|screen| { - let figma = screen.figma.as_ref()?; - let (comparable, layout_only): (Vec<_>, Vec<_>) = input - .viewports - .iter() - .map(|viewport| viewport.width) - .partition(|width| figma.drawn_widths.contains(width)); - Some(json!({"screen": screen.id, "nodeId": figma.node_id, - "drawnWidths": figma.drawn_widths, "visualOriginalAt": comparable, - "layoutChecksOnlyAt": layout_only})) - }) - .collect::>(); - let document = json!({ - "schemaVersion": SCHEMA_VERSION, - "kind": PLAN_KIND, - "planId": id, - "createdBy": super::delivery::server_identity(), - "plan": input, - "expected": { - "runs": input.viewports.len(), - "screensPerRun": input.screens.len(), - "checkItems": planned_items(&input), - "dimensionRows": input.viewports.len() * input.screens.iter().map(|s| s.checks.iter().filter(|c| c.kind == "dimensions").count()).sum::(), - }, - "limits": {"dimensionRows": MAX_DIMENSION_ROWS, "dimensionRowsOmitted": 0}, - "figmaReferences": references, - "dimensionReferences": dimension_references, - "executor": { - "package": "harness/runtime-audit", - "install": "npm ci && npx playwright install chromium", - "run": "node run.mjs --plan --out ", - "isolation": "One fresh browser context per viewport, with no profile, cookies or storage from the user's browser. Requests to origins outside allowedOrigins are blocked and recorded.", - }, - }); - let mut written = None; - let mut template_files = Vec::new(); - let mut transaction = OutputTransaction::new(); - if let Some(directory) = template_path { - transaction.refuse_existing(); - for (name, bytes) in [ - ( - "run.mjs", - include_bytes!("../../../../harness/runtime-audit/run.mjs").as_slice(), - ), - ( - "checks.mjs", - include_bytes!("../../../../harness/runtime-audit/checks.mjs").as_slice(), - ), - ( - "strip-figma-node-ids.mjs", - include_bytes!("../../../../harness/runtime-audit/strip-figma-node-ids.mjs") - .as_slice(), - ), - ( - "package.json", - include_bytes!("../../../../harness/runtime-audit/package.json").as_slice(), - ), - ( - "package-lock.json", - include_bytes!("../../../../harness/runtime-audit/package-lock.json").as_slice(), - ), - ] { - let requested = Path::new(directory).join(name); - let target = policy.resolve(&requested.to_string_lossy())?; - template_files.push(json!({"path": target.display_path().display().to_string(), "sha256": sha256(bytes)})); - transaction.stage(name, target, bytes)?; - } - } - if let Some(path) = output_path { - let target = policy.resolve(path)?; - let display = target.display_path().display().to_string(); - let bytes = serde_json::to_vec_pretty(&document) - .map_err(|error| invalid(format!("Cannot serialize the plan: {error}")))?; - if bytes.len() > MAX_PLAN_BYTES { - return Err(invalid( - "The bound plan exceeds the ingest size limit; split the plan.", - )); - } - transaction.stage("plan", target, &bytes)?; - written = Some(display); - } - transaction.commit()?; - let mut result = document; - result["writtenTo"] = json!(written); - result["template"] = json!({"files": template_files, "executed": false, - "workingDirectory": template_path, - "commands": [ ["npm", "ci"], ["npx", "playwright", "install", "chromium"], - ["node", "run.mjs", "--plan", written.as_deref().unwrap_or(""), "--out", "report"] ], - "note": "Execute these argument arrays in the generated directory in your consumer project with its app running. The server executes nothing. The plan and report must remain under allowed roots for ingest."}); - result["nextAction"] = json!({ - "where": "the consumer project", - "how": "Run the executor against this plan with the app served at baseUrl, then call devup_runtime_audit with action ingest, the plan path and the report.json it writes. Nothing here has run yet.", - }); - Ok(result) -} - -fn read_json( - policy: &OutputPolicy, - path: &str, - limit: usize, -) -> Result<(Vec, Value, std::path::PathBuf), DevupError> { - let target = policy.resolve(path)?; - let bytes = target.read_bounded(limit)?; - let value = serde_json::from_slice(&bytes) - .map_err(|error| invalid(format!("{path} is not valid JSON: {error}")))?; - Ok((bytes, value, target.display_path().to_path_buf())) -} - -fn rejected(reason: &str, details: Value) -> Value { - json!({ - "verdict": "rejected", - "reason": reason, - "details": details, - "note": "Nothing in this report was counted. A report that cannot be tied to the plan it claims to run is not evidence of that plan.", - }) -} - -#[cfg(test)] -fn ingest(policy: &OutputPolicy, plan_path: &str, report_path: &str) -> Result { - ingest_references(policy, plan_path, report_path, None) -} - -pub(crate) async fn ingest_with_artifacts( - policy: &OutputPolicy, - plan_path: &str, - report_path: &str, - artifacts: &ArtifactStore, -) -> Result { - let read_policy = policy.clone(); - let read_path = plan_path.to_owned(); - let (_, document, _) = - tokio::task::spawn_blocking(move || read_json(&read_policy, &read_path, MAX_PLAN_BYTES)) - .await - .map_err(|_| invalid("The plan could not be read to the end."))??; - let references = match serde_json::from_value::(document["plan"].clone()) { - Ok(plan) if validate_plan(&plan).is_ok() => dimension_references(&plan, artifacts).await, - Ok(_) => Vec::new(), - Err(_) => Vec::new(), - }; - let policy = policy.clone(); - let plan_path = plan_path.to_owned(); - let report_path = report_path.to_owned(); - tokio::task::spawn_blocking(move || { - ingest_references(&policy, &plan_path, &report_path, Some(&references)) - }) - .await - .map_err(|_| invalid("The report could not be read to the end."))? -} - -fn ingest_references( - policy: &OutputPolicy, - plan_path: &str, - report_path: &str, - acquired: Option<&[Value]>, -) -> Result { - let (plan_bytes, plan_document, _) = read_json(policy, plan_path, MAX_PLAN_BYTES)?; - if plan_document["kind"] != PLAN_KIND || plan_document["schemaVersion"] != SCHEMA_VERSION { - return Ok(rejected( - "plan-kind", - json!({"expected": PLAN_KIND, "schemaVersion": SCHEMA_VERSION}), - )); - } - let Ok(plan) = serde_json::from_value::(plan_document["plan"].clone()) else { - return Ok(rejected("plan-schema", json!({"path": plan_path}))); - }; - if let Err(error) = validate_plan(&plan) { - return Ok(rejected("plan-invalid", json!({"message": error.message}))); - } - let id = plan_id(&plan)?; - if plan_document["planId"] != id.as_str() { - return Ok(rejected( - "plan-edited", - json!({"declared": plan_document["planId"], "recomputed": id}), - )); - } - let (_, report, report_file) = read_json(policy, report_path, MAX_REPORT_BYTES)?; - if report["kind"] != REPORT_KIND || report["schemaVersion"] != SCHEMA_VERSION { - return Ok(rejected( - "report-kind", - json!({"expected": REPORT_KIND, "schemaVersion": SCHEMA_VERSION}), - )); - } - if report["planId"] != id.as_str() { - return Ok(rejected( - "report-for-another-plan", - json!({"planId": id, "reportPlanId": report["planId"]}), - )); - } - let plan_file_hash = sha256(&plan_bytes); - let plan_file_matches = report["planSha256"] - .as_str() - .map(|declared| declared == plan_file_hash); - if plan_file_matches == Some(false) { - return Ok(rejected( - "plan-file-differs", - json!({"planSha256": plan_file_hash, "reportPlanSha256": report["planSha256"]}), - )); - } - let report_dir = report_file - .parent() - .map(Path::to_path_buf) - .unwrap_or_default(); - - let mut counts = BTreeMap::<&str, usize>::new(); - let mut failures = Vec::new(); - let mut runs = Vec::new(); - let mut screenshot_budget = MAX_SCREENSHOTS; - let mut integrity_problems = Vec::new(); - let mut environment_invalid = false; - let mut visual_comparisons = Vec::new(); - let mut dimension_rows = Vec::new(); - let mut screen_rows = Vec::new(); - let mut findings = Vec::new(); - let mut missing_nodes = Vec::new(); - let mut links = BTreeMap::>::new(); - for viewport in &plan.viewports { - let matching_runs = report["runs"] - .as_array() - .into_iter() - .flatten() - .filter(|run| { - run["viewport"]["width"] == viewport.width - && run["viewport"]["height"] == viewport.height - && run["viewport"]["deviceScaleFactor"].as_f64() - == Some(viewport.device_scale_factor) - }) - .count(); - if matching_runs > 1 { - return Ok(rejected( - "ambiguous-report-items", - json!({"viewport": viewport}), - )); - } - let run = report["runs"].as_array().into_iter().flatten().find(|run| { - run["viewport"]["width"] == viewport.width - && run["viewport"]["height"] == viewport.height - && run["viewport"]["deviceScaleFactor"] - .as_f64() - .is_some_and(|scale| (scale - viewport.device_scale_factor).abs() < 1e-9) - }); - let environment_errors = run - .map(|run| { - run["environmentErrors"] - .as_array() - .cloned() - .unwrap_or_default() - }) - .unwrap_or_default(); - if run.is_none() || !environment_errors.is_empty() { - environment_invalid = true; - } - let mut screenshots_verified = 0; - let mut screenshot_issues = Vec::new(); - let mut drawn_width_shots = Vec::new(); - let mut captures = Vec::new(); - for screen in &plan.screens { - if run.is_some_and(|run| { - run["screens"] - .as_array() - .into_iter() - .flatten() - .filter(|s| s["id"] == screen.id) - .count() - > 1 - }) { - return Ok(rejected( - "ambiguous-report-items", - json!({"screen": screen.id}), - )); - } - let reported = run.and_then(|run| { - run["screens"] - .as_array()? - .iter() - .find(|candidate| candidate["id"] == screen.id.as_str()) - }); - if let (Some(route), Some(found)) = ( - screen.route_path.as_ref(), - reported.and_then(|s| s["links"].as_array()), - ) { - links.entry(route.clone()).or_default().extend( - found - .iter() - .filter_map(Value::as_str) - .filter(|href| href.starts_with('/') && href.len() <= MAX_TEXT) - .take(200) - .map(str::to_owned), - ); - } - let mut outcomes = Vec::new(); - let mut judged = Vec::new(); - for (index, check) in screen.checks.iter().enumerate() { - if !applies(check, viewport.width) { - continue; - } - if reported.is_some_and(|s| { - s["checks"] - .as_array() - .into_iter() - .flatten() - .filter(|item| item["index"] == index) - .count() - > 1 - }) { - return Ok(rejected( - "ambiguous-report-items", - json!({"screen": screen.id, "check": index}), - )); - } - let item = reported.and_then(|screen| { - screen["checks"] - .as_array()? - .iter() - .find(|item| item["index"] == index) - }); - let dimension = (check.kind == "dimensions").then(|| { - dimension_row( - screen, - index, - check, - viewport, - item, - &plan_document, - acquired, - plan_file_matches == Some(true), - run.is_some() - && environment_errors.is_empty() - && reported.is_some_and(|s| s["status"] == "completed"), - ) - }); - let status = match dimension - .as_ref() - .map(|row| &row["status"]) - .or_else(|| item.map(|item| &item["status"])) - .and_then(Value::as_str) - { - Some("pass") => "pass", - Some("fail") => "fail", - Some("inconclusive") => "inconclusive", - Some("skipped") => "skipped", - Some("not-run") => "not-run", - Some(_) => "inconclusive", - None => "not-run", - }; - *counts.entry(status).or_default() += 1; - if status == "fail" && failures.len() < MAX_LISTED { - failures.push(json!({ - "viewport": {"width": viewport.width, "height": viewport.height}, - "screen": screen.id, - "check": index, - "kind": check.kind, - "label": check.label, - "reason": dimension.as_ref().map(|row| &row["reason"]).or_else(|| item.map(|item| &item["reason"])), - "measured": dimension.as_ref().map(|row| &row["domCssPx"]).or_else(|| item.map(|item| &item["measured"])), - })); - } - outcomes.push(( - status, - check.kind.as_str(), - dimension - .as_ref() - .map(|row| &row["reason"]) - .or_else(|| item.map(|item| &item["reason"])) - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - )); - let reason = dimension - .as_ref() - .map(|row| &row["reason"]) - .or_else(|| item.map(|item| &item["reason"])) - .and_then(Value::as_str) - .unwrap_or_default(); - judged.push((status, check)); - if status == "fail" { - findings.push(finding( - screen, - viewport, - Some((index, check)), - failure_class(&check.kind, reason), - reason, - dimension - .as_ref() - .map(|row| &row["domCssPx"]) - .or_else(|| item.map(|item| &item["measured"])) - .unwrap_or(&Value::Null), - reported.and_then(|s| s["screenshot"]["path"].as_str()), - )); - } - if status == "inconclusive" - && reason.contains("matched no element") - && let Some(node_id) = &check.node_id - { - missing_nodes.push(json!({"screen": screen.id, "width": viewport.width, - "nodeId": node_id, "check": index, "kind": check.kind})); - } - if check.kind == "capture" - && status == "pass" - && let (Some(reference), Some(shot)) = ( - &check.reference, - item.map(|item| &item["screenshot"]) - .filter(|shot| shot.is_object()), - ) - { - match verify_capture(policy, &report_dir, shot, None) { - Ok(verified) => captures.push((screen, index, check, reference, verified)), - Err(Integrity::Tampered(issue)) => { - integrity_problems.push(json!({"screen": screen.id, "issue": issue})); - } - Err(Integrity::Unverified(issue)) => { - screenshot_issues.push(json!({"screen": screen.id, "issue": issue})); - } - } - } - if let Some(row) = dimension { - dimension_rows.push(row); - } - } - let mut row = screen_row( - screen, - viewport, - run.is_some(), - reported, - &environment_errors, - &outcomes, - ); - row["columns"] = columns(screen, viewport, &judged, &row); - // A screen that never rendered fails as a runtime error, not as - // the checks that could not measure it. - if row["status"] == "fail" && !outcomes.iter().any(|(status, _, _)| *status == "fail") { - let reason = array_text(&row["reasons"]); - findings.push(finding( - screen, - viewport, - None, - "runtime-error", - &reason, - &Value::Null, - None, - )); - } - screen_rows.push(row); - let Some(shot) = reported - .map(|screen| &screen["screenshot"]) - .filter(|shot| shot.is_object()) - else { - screenshot_issues.push( - json!({"screen": screen.id, "issue": "not-verified: screenshot missing"}), - ); - continue; - }; - if screenshot_budget == 0 { - screenshot_issues.push( - json!({"screen": screen.id, "issue": "not-verified: screenshot limit reached"}), - ); - continue; - } - screenshot_budget -= 1; - match verify_screenshot(policy, &report_dir, shot, viewport) { - Ok(verified) => { - screenshots_verified += 1; - if let Some(figma) = screen - .figma - .as_ref() - .filter(|figma| figma.drawn_widths.contains(&viewport.width)) - { - drawn_width_shots.push((screen, figma.node_id.as_str(), verified)); - } - } - Err(Integrity::Tampered(issue)) => { - integrity_problems.push(json!({"screen": screen.id, "issue": issue})); - } - Err(Integrity::Unverified(issue)) => { - screenshot_issues.push(json!({"screen": screen.id, "issue": issue})); - } - } - } - if !screenshot_issues.is_empty() { - environment_invalid = true; - } - let run_valid = - run.is_some() && environment_errors.is_empty() && screenshot_issues.is_empty(); - for (screen, node_id, shot) in &drawn_width_shots { - visual_comparisons.push(visual_comparison( - screen, node_id, viewport, shot, &report, run_valid, - )); - } - for (screen, index, check, reference, shot) in &captures { - visual_comparisons.push(capture_comparison( - screen, *index, check, reference, viewport, shot, - )); - } - runs.push(json!({ - "viewport": viewport, - "reported": run.is_some(), - "environmentErrors": environment_errors, - "environmentValid": run.is_some() && environment_errors.is_empty(), - "screenshots": {"verified": screenshots_verified, "issues": screenshot_issues}, - })); - } - if !integrity_problems.is_empty() { - return Ok(rejected( - "artifact-changed", - json!({"artifacts": integrity_problems}), - )); - } - let planned = planned_items(&plan); - let count = |status: &str| counts.get(status).copied().unwrap_or(0); - let verdict = if count("fail") > 0 && !environment_invalid { - "fail" - } else if count("pass") == planned && !environment_invalid && report["status"] == "completed" { - "pass" - } else { - "inconclusive" - }; - let declared = &report["summary"]; - let declared_matches = declared.is_object().then(|| { - ["pass", "fail", "inconclusive", "skipped"] - .iter() - .all(|status| declared[*status].as_u64().unwrap_or(0) == count(status) as u64) - }); - let figma = plan - .screens - .iter() - .filter_map(|screen| { - let figma = screen.figma.as_ref()?; - let (visual, layout_only): (Vec<_>, Vec<_>) = plan - .viewports - .iter() - .map(|viewport| viewport.width) - .partition(|width| figma.drawn_widths.contains(width)); - Some(json!({"screen": screen.id, "nodeId": figma.node_id, - "visualOriginalAt": visual, "layoutChecksOnlyAt": layout_only, - "note": "Compare screenshots with devup_visual_compare only at the drawn widths - visualComparisons holds the call for each one; elsewhere there is no visual original and only the layout checks above apply."})) - }) - .collect::>(); - Ok(json!({ - "verdict": verdict, - "planId": id, - "integrity": {"planIdRecomputed": true, "planFileHashMatches": plan_file_matches}, - "coverage": {"plannedCheckItems": planned, "notRun": count("not-run"), - "reportStatus": report["status"]}, - "summary": {"pass": count("pass"), "fail": count("fail"), - "inconclusive": count("inconclusive"), "skipped": count("skipped"), - "notRun": count("not-run")}, - "declaredSummaryMatches": declared_matches, - "failures": failures, - "runs": runs, - "figmaReferences": figma, - "visualComparisons": visual_comparisons, - "findings": findings, - "missingNodes": missing_nodes, - "links": links, - "screenTable": screen_rows, - "widthTable": plan.viewports.iter().map(|viewport| json!({ - "width": viewport.width, "height": viewport.height, - "deviceScaleFactor": viewport.device_scale_factor, - "dimensions": dimension_rows.iter().filter(|row| row["viewport"] == json!(viewport)).collect::>() - })).collect::>(), - "toleranceExceedances": dimension_rows.iter().filter(|row| row["status"] == "fail").collect::>(), - "dimensionCoverage": {"plannedRows": dimension_rows.len(), "returnedRows": dimension_rows.len(), - "omittedRows": 0, "limit": MAX_DIMENSION_ROWS}, - "executor": report["executor"], - "environment": report["environment"], - "evidence": { - "independentlyVerified": [ - "the plan file's content id and SHA-256", - "the report's binding to that plan", - "every count above, recomputed from the reported items", - "each screenshot's SHA-256, byte length, PNG decoding and width", - "dimension arithmetic and per-check tolerance filtering from reported CSS geometry; available retained Figma reference dimensions are rechecked", - ], - "executorDeclared": [ - "element geometry, locator counts, visibility and non-dimension check outcomes", - "the same-origin links found on each page", - "page, console and network errors", - "timing, browser and environment versions", - ], - "notJudged": "Whether the screens look right to a person. Layout checks measure the relations the plan names and nothing else.", - }, - })) -} - -/// One screen at one width, folded from its checks, its own record and the -/// environment errors the run could pin on it. This is the row a person reads -/// to learn whether `/about` works at 390; `failures` and `runs` say the same -/// things but spread across three lists. -/// -/// A page error is attributed only when its message names this screen's route -/// (`navigation-failed` does). An uncaught script error carries no route, so -/// it leaves every screen at that width inconclusive rather than failing the -/// one that may not have thrown it. -fn screen_row( - screen: &ScreenInput, - viewport: &ViewportInput, - run_reported: bool, - reported: Option<&Value>, - environment_errors: &[Value], - outcomes: &[(&str, &str, String)], -) -> Value { - let message = |error: &Value| error["message"].as_str().unwrap_or_default().to_owned(); - let own_navigation = environment_errors - .iter() - .filter(|error| { - error["kind"] == "navigation-failed" - && screen.route_path.as_deref().is_some_and(|route| { - error["message"] - .as_str() - .is_some_and(|text| text.starts_with(&format!("{route}:"))) - }) - }) - .map(|error| format!("page error: {}", message(error))) - .collect::>(); - let unattributed = environment_errors - .iter() - .filter(|error| error["kind"] == "page-error") - .map(|error| format!("page error during this width's run: {}", message(error))) - .collect::>(); - let failed_checks = outcomes - .iter() - .filter(|(status, _, _)| *status == "fail") - .map(|(_, kind, reason)| match *kind { - "no-horizontal-overflow" => format!("horizontal overflow: {reason}"), - "view-transition" => format!("view transition: {reason}"), - kind => format!("{kind}: {reason}"), - }) - .collect::>(); - let record_status = reported.and_then(|screen| screen["status"].as_str()); - let record_reason = reported - .and_then(|screen| screen["reason"].as_str()) - .unwrap_or_default(); - let (status, reasons) = if !run_reported || reported.is_none() { - ( - "not-run", - vec!["no report item for this screen at this width".to_owned()], - ) - } else if !own_navigation.is_empty() { - ("fail", own_navigation) - } else if record_status == Some("failed") { - ("fail", vec![format!("screen not reached: {record_reason}")]) - } else if !failed_checks.is_empty() { - ("fail", failed_checks) - } else if record_status == Some("skipped") { - ("not-run", vec![format!("skipped: {record_reason}")]) - } else if !unattributed.is_empty() { - ("inconclusive", unattributed) - } else if outcomes.is_empty() { - ( - "inconclusive", - vec!["reached, but the plan names no checks for this screen".to_owned()], - ) - } else if outcomes.iter().all(|(status, _, _)| *status == "pass") { - ("pass", Vec::new()) - } else { - ( - "inconclusive", - outcomes - .iter() - .filter(|(status, _, _)| *status != "pass") - .map(|(status, kind, reason)| format!("{kind} {status}: {reason}")) - .collect(), - ) - }; - json!({ - "screen": screen.id, - "routePath": screen.route_path, - "width": viewport.width, - "status": status, - "reasons": reasons, - "screenshot": reported.and_then(|screen| screen["screenshot"]["path"].as_str()), - }) -} - -#[allow(clippy::too_many_arguments)] -fn dimension_row( - screen: &ScreenInput, - index: usize, - check: &CheckInput, - viewport: &ViewportInput, - item: Option<&Value>, - document: &Value, - acquired: Option<&[Value]>, - hash_bound: bool, - environment_valid: bool, -) -> Value { - let matches = |reference: &&Value| { - reference["screen"] == screen.id - && reference["check"] == index - && reference["viewportWidth"] == viewport.width - }; - let reference = document["dimensionReferences"] - .as_array() - .into_iter() - .flatten() - .find(matches); - let verified = acquired - .and_then(|refs| refs.iter().find(matches)) - .filter(|fresh| { - fresh["status"] == "acquired" && reference.is_some_and(|saved| *fresh == saved) - }); - let measured = item.map(|item| &item["measured"]); - // Report files are untrusted: expose only bounded identity and numeric - // geometry, rather than echoing arbitrary objects attached to a check. - let mapping = check - .figma_dimensions - .iter() - .find(|r| r.viewport_width == viewport.width); - let reference_summary = mapping.map(|mapping| { - json!({ - "artifactId": mapping.artifact_id, "nodeId": mapping.node_id, - "status": if verified.is_some() { "acquired" } else { "unverified" }, - "width": reference.and_then(|r| r["width"].as_f64()), - "height": reference.and_then(|r| r["height"].as_f64()), - "units": "figma-logical-px", - "artifactContentHash": verified.and_then(|r| r["artifactContentHash"].as_str()), - "staleness": "retained snapshot; not compared with live Figma", - }) - }); - let dom = measured.map(|m| { - json!({"x": m["subject"]["x"].as_f64(), "y": m["subject"]["y"].as_f64(), - "width": m["subject"]["width"].as_f64(), "height": m["subject"]["height"].as_f64()}) - }); - let mut row = json!({"screen": screen.id, "check": index, "label": check.label, - "viewport": viewport, "subject": check.subject, "mapping": "caller-declared", - "reference": reference_summary, "referenceVerifiedAgainstRetainedAcquisition": verified.is_some(), - "domCssPx": dom, "domEvidence": "executor-declared", - "tolerancePx": check.tolerance_px, "deltaPx": null, "status": "inconclusive", - "reason": "Design reference or reported CSS geometry is unverified."}); - let declared_status = item.and_then(|item| item["status"].as_str()); - if declared_status.is_none() { - row["status"] = json!("not-run"); - row["reason"] = json!("No measurement was reported for this planned check."); - } else if declared_status == Some("skipped") { - row["status"] = json!("skipped"); - row["reason"] = json!("The executor skipped this measurement."); - } else if reference.is_none() { - row["reason"] = json!( - "No acquired Figma dimensions were explicitly mapped at this width; no interpolation was applied." - ); - } else if verified.is_none() { - row["reason"] = json!( - "The saved reference could not be matched to the retained acquisition; acquire and plan again." - ); - } else if !hash_bound || !environment_valid { - row["reason"] = - json!("The plan file hash, screen completion or runtime environment is unverified."); - } else if matches!(declared_status, Some("pass" | "fail")) { - let m = measured.unwrap(); - if m["units"] == "css-px" - && m["subjectCount"] == 1 - && m["subjectVisible"] == true - && let (Some(width), Some(height)) = ( - m["subject"]["width"].as_f64(), - m["subject"]["height"].as_f64(), - ) - && width.is_finite() - && height.is_finite() - && width >= 0.0 - && height >= 0.0 - { - let expected = verified.unwrap(); - let dw = width - expected["width"].as_f64().unwrap(); - let dh = height - expected["height"].as_f64().unwrap(); - let tolerance = check.tolerance_px.unwrap(); - row["deltaPx"] = json!({"width": dw, "height": dh}); - row["status"] = json!(if dw.abs() <= tolerance && dh.abs() <= tolerance { - "pass" - } else { - "fail" - }); - row["reason"] = json!( - "Compared acquired logical dimensions with declared DOM CSS pixels without DPR or screenshot scaling." - ); - row["executorOutcomeMatches"] = json!(row["status"] == item.unwrap()["status"]); - } - } - row -} - -enum Integrity { - Tampered(String), - Unverified(String), -} - -struct VerifiedShot { - path: String, - sha256: String, - width: u32, - height: u32, -} - -fn verify_screenshot( - policy: &OutputPolicy, - report_dir: &Path, - shot: &Value, - viewport: &ViewportInput, -) -> Result { - verify_capture(policy, report_dir, shot, Some(viewport)) -} - -/// An element capture has the element's size, not the viewport's; only its -/// bytes and PNG decoding are checked. -fn verify_capture( - policy: &OutputPolicy, - report_dir: &Path, - shot: &Value, - viewport: Option<&ViewportInput>, -) -> Result { - let path = shot["path"] - .as_str() - .ok_or_else(|| Integrity::Unverified("no path".into()))?; - let relative = Path::new(path); - if relative.is_absolute() - || relative - .components() - .any(|part| !matches!(part, std::path::Component::Normal(_))) - { - return Err(Integrity::Unverified(format!( - "{path} must be relative to the report directory, without .. or a root" - ))); - } - let joined = report_dir.join(relative); - let target = policy - .resolve(&joined.to_string_lossy()) - .map_err(|error| Integrity::Unverified(format!("{path}: {}", error.message)))?; - let bytes = target - .read_bounded(MAX_SCREENSHOT_BYTES) - .map_err(|error| Integrity::Unverified(format!("{path}: {}", error.message)))?; - let digest = sha256(&bytes); - if shot["sha256"].as_str() != Some(digest.as_str()) - || shot["bytes"].as_u64() != Some(bytes.len() as u64) - { - return Err(Integrity::Tampered(format!( - "{path}: its SHA-256 or length differs from the report" - ))); - } - let [width, height] = devup_mcp_visual::png_dimensions(&bytes).map_err(|_| { - Integrity::Unverified(format!( - "{path}: not a decodable PNG within 8192px per side" - )) - })?; - if let Some(viewport) = viewport { - let expected = (f64::from(viewport.width) * viewport.device_scale_factor).round() as u32; - let minimum = (f64::from(viewport.height) * viewport.device_scale_factor).round() as u32; - if width != expected || height < minimum { - return Err(Integrity::Unverified(format!( - "{path}: {width}x{height} is not {expected} wide and at least {minimum} tall; the capture does not show this viewport" - ))); - } - } - Ok(VerifiedShot { - path: target.display_path().display().to_string(), - sha256: digest, - width, - height, - }) -} - -/// The one place a finding's identity is made, so a later run of the same -/// check at the same width finds its earlier attempts. -fn comparison_id(screen: &ScreenInput, kind: &str, subject: &str, width: u32) -> String { - format!("{}|{kind}|{subject}|{width}", screen.id) -} - -fn diff_path(actual: &str, id: &str) -> String { - let directory = Path::new(actual).parent().unwrap_or(Path::new("")); - let name = id - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '-' { - c - } else { - '_' - } - }) - .collect::(); - directory - .join("diff") - .join(format!("{name}.png")) - .display() - .to_string() -} - -fn array_text(value: &Value) -> String { - value - .as_array() - .into_iter() - .flatten() - .filter_map(Value::as_str) - .collect::>() - .join("; ") -} - -/// What kind of problem a failed check is, so a fix starts in the right -/// place: a page that does not render, content wider than its box, a box in -/// the wrong place or of the wrong size, a state or a transition missing. -pub(crate) fn failure_class(kind: &str, reason: &str) -> &'static str { - match kind { - "page-health" => "runtime-error", - "no-horizontal-overflow" | "no-clipped-text" => "overflow", - "bounds" if reason.starts_with("position") => "layout-position", - "bounds" | "dimensions" | "visible" => "layout-size", - "contained" | "aligned" | "spans" => "layout-position", - "interaction" => "interaction", - "view-transition" => "view-transition", - _ => "layout-position", - } -} - -/// A concrete first step for each class. The measured evidence names the -/// element where the executor reported one. -pub(crate) fn fix_hint(class: &str, kind: &str, measured: &Value) -> String { - match class { - "runtime-error" => "Open the URL at this width and read the first page or console error: an error-boundary text (\"This page couldn't load\", \"Application error\") means a component threw while rendering or a data fetch failed. Fix the throwing component or give the data a loading and error state, then re-run.".to_owned(), - "overflow" if kind == "no-clipped-text" => "Text is cut off by a box with overflow hidden: let the box grow (drop the fixed height or width the Figma frame had), let the text wrap, or, when truncation is designed, use textOverflow ellipsis or lineClamp.".to_owned(), - "overflow" => format!( - "Content is wider than the viewport{}: give the element maxW=\"100%\", let its row wrap (flexWrap=\"wrap\") or shrink (minW={{0}}), and turn a fixed Figma width into a responsive value at this width.", - measured["widest"].as_str().map(|widest| format!(" (widest: {widest})")).unwrap_or_default() - ), - "layout-position" | "layout-size" if kind == "bounds" => "Layout is exact at a drawn width: a box off by more than half a pixel is code drift (the code no longer writes what the export wrote) or a converter bug (it does, and renders wrong). devup_screen_plan status tells which; never hand-tune the value.".to_owned(), - "layout-position" => "The element sits in the wrong place relative to its parent: compare the parent's gap, padding and alignment (justifyContent, alignItems) with the Figma frame, check the order of its children and any absolute offsets.".to_owned(), - "layout-size" => "The element has the wrong size: compare its width and height props with the Figma sizing (fixed, fill or hug), make fill stretch at widths that were not drawn, and check an image's aspect ratio and min/max sizes.".to_owned(), - "code-drift" => "Restore the values the export wrote for this element (the finding lists what is missing and what was added); layout is exact, so do not tune it by hand - re-export the node if the design changed.".to_owned(), - "converter-bug" => "The code still equals the export and the browser box differs from Figma: a devup-mcp converter bug. File the repro; a local override may finish the site only with a comment naming the repro file. Retrying does not help.".to_owned(), - "asset-mismatch" => "The rendered asset differs from the Figma node's render. Likely causes, in order: a CSS mask on a multi-colour SVG paints one solid shape (render it with instead; masks are for single-colour icons only); the wrong file at the src path; a crop or objectFit (cover vs contain) or a missing viewBox. Open the diff PNG to see which.".to_owned(), - "section-visual" => "The section differs from its Figma render: open the diff PNG, find the highlighted area and fix the elements there; the usual causes are font size, weight or line height, a missing background or border, and spacing.".to_owned(), - "page-visual" => "The page differs from the frame: fix the section and asset comparisons first, they localise the problem; a difference only the page shows is usually the page background, its padding or the spacing between sections.".to_owned(), - "interaction" => "A link or button shows no hover or focus change: give it the interaction recipe for its role from the plan defaults (theme tokens only, at most two properties, the shared 150-250ms transition) and a visible _focusVisible outline.".to_owned(), - "view-transition" => "No view transition ran on this navigation: wrap the routed content in React's (or add @view-transition { navigation: auto } for a cross-document transition) and navigate with the framework's Link so it is a client navigation.".to_owned(), - _ => "Re-run the check and read its reason.".to_owned(), - } -} - -fn finding( - screen: &ScreenInput, - viewport: &ViewportInput, - check: Option<(usize, &CheckInput)>, - class: &str, - reason: &str, - measured: &Value, - screenshot: Option<&str>, -) -> Value { - let kind = check.map_or("screen", |(_, check)| check.kind.as_str()); - let subject = check - .and_then(|(index, check)| { - check - .node_id - .clone() - .or_else(|| check.label.clone()) - .or_else(|| Some(format!("#{index}"))) - }) - .unwrap_or_else(|| "page".to_owned()); - json!({ - "id": comparison_id(screen, kind, &subject, viewport.width), - "class": class, - "screen": screen.id, - "routePath": screen.route_path, - "width": viewport.width, - "check": check.map(|(index, _)| index), - "kind": kind, - "label": check.and_then(|(_, check)| check.label.clone()), - "nodeId": check.and_then(|(_, check)| check.node_id.clone()), - "codeFile": screen.component_path, - "reason": reason, - "fixHint": fix_hint(class, kind, measured), - "evidence": {"screenshot": screenshot, "measured": measured}, - }) -} - -/// The `devup_visual_compare` call for one element capture against the -/// Figma node's own native render. -fn capture_comparison( - screen: &ScreenInput, - index: usize, - check: &CheckInput, - reference: &CaptureReferenceInput, - viewport: &ViewportInput, - shot: &VerifiedShot, -) -> Value { - let class = if reference.scope == "asset" { - "asset-mismatch" - } else { - "section-visual" - }; - let subject = check.node_id.clone().unwrap_or_else(|| format!("#{index}")); - let id = comparison_id(screen, &reference.scope, &subject, viewport.width); - let threshold = reference - .threshold - .unwrap_or(if reference.scope == "asset" { - ASSET_THRESHOLD - } else { - SECTION_THRESHOLD - }); - let mut blockers = Vec::new(); - if (viewport.device_scale_factor - 1.0).abs() > f64::EPSILON { - blockers.push(format!( - "Captured at deviceScaleFactor {}; the native render is at scale 1.", - viewport.device_scale_factor - )); - } - json!({ - "id": id, - "class": class, - "screen": screen.id, - "routePath": screen.route_path, - "nodeId": check.node_id, - "label": check.label, - "check": index, - "codeFile": screen.component_path, - "viewport": viewport, - "screenshot": {"path": shot.path, "sha256": shot.sha256, "width": shot.width, "height": shot.height}, - "call": { - "tool": "devup_visual_compare", - "arguments": { - "actual": shot.path, - "reference": {"artifactId": reference.artifact_id, "assetId": reference.asset_id}, - "threshold": threshold, - "diffPath": diff_path(&shot.path, &id), - }, - }, - "ifItFails": {"class": class, "fixHint": fix_hint(class, "capture", &Value::Null)}, - "blockers": blockers, - "note": "A size mismatch with the native render is reported as dimensions, not pixels: fix the element's size (its bounds check says by how much) and compare again.", - }) -} - -/// The `devup_visual_compare` call for one screenshot taken at a width its -/// Figma frame was drawn at, with every field the audit itself knows filled -/// in, the ones only the consumer can state named, and what would still make -/// it inconclusive said up front. -fn visual_comparison( - screen: &ScreenInput, - node_id: &str, - viewport: &ViewportInput, - shot: &VerifiedShot, - report: &Value, - environment_valid: bool, -) -> Value { - let mut environment = json!({ - "schemaVersion": 1, - "actualPng": shot.path, - "viewport": {"width": viewport.width, "height": viewport.height, - "deviceScaleFactor": viewport.device_scale_factor}, - "environmentStatus": if environment_valid { "valid" } else { "environment-invalid" }, - }); - let mut must_add = vec![ - "reference.artifactId", - "environment.tsxResource", - "environment.themePath", - "environment.assetDirectory", - "environment.devupUiVersion", - "environment.fontManifest", - ]; - let browser = &report["executor"]["browser"]; - match (browser["name"].as_str(), browser["version"].as_str()) { - (Some(name), Some(version)) => { - environment["renderer"] = - json!({"name": format!("playwright-{name}"), "version": version}); - } - _ => must_add.push("environment.renderer"), - } - for (field, value) in [ - ("os", &report["environment"]["os"]), - ("locale", &report["environment"]["locale"]), - ("timezone", &report["environment"]["timezone"]), - ] { - match value.as_str() { - Some(text) => environment[field] = json!(text), - None => must_add.push(match field { - "os" => "environment.os", - "locale" => "environment.locale", - _ => "environment.timezone", - }), - } - } - let mut blockers = Vec::new(); - if (viewport.device_scale_factor - 1.0).abs() > f64::EPSILON { - blockers.push(format!( - "Captured at deviceScaleFactor {}; the Figma reference is at scale 1, so plan this width at 1.", - viewport.device_scale_factor - )); - } - if shot.height != viewport.height { - blockers.push(format!( - "The page is {}px tall in a {}px viewport, so the full-page capture is {}x{}. devup_visual_compare needs the viewport to be the reference's size: plan this width at the frame's drawn height.", - shot.height, viewport.height, shot.width, shot.height - )); - } - if !environment_valid { - blockers.push( - "This run reported environment errors or an unverified screenshot; the comparison stays inconclusive." - .to_owned(), - ); - } - let frame = screen - .figma - .iter() - .flat_map(|figma| &figma.frames) - .find(|frame| frame.width == viewport.width); - let node_id = frame.map_or(node_id, |frame| frame.node_id.as_str()); - let artifact = frame.and_then(|frame| frame.artifact_id.clone()); - if artifact.is_some() { - must_add.retain(|field| *field != "reference.artifactId"); - } - let id = comparison_id(screen, "page", node_id, viewport.width); - let threshold = frame - .and_then(|frame| frame.threshold) - .unwrap_or(PAGE_THRESHOLD); - json!({ - "id": id, - "class": "page-visual", - "screen": screen.id, - "routePath": screen.route_path, - "nodeId": node_id, - "codeFile": screen.component_path, - "viewport": viewport, - "screenshot": {"path": shot.path, "sha256": shot.sha256, "width": shot.width, "height": shot.height}, - "call": { - "tool": "devup_visual_compare", - "arguments": { - "actual": shot.path, - "reference": {"artifactId": artifact.unwrap_or_else(|| format!(""))}, - "threshold": threshold, - "diffPath": diff_path(&shot.path, &id), - "environment": environment, - }, - }, - "ifItFails": {"class": "page-visual", "fixHint": fix_hint("page-visual", "", &Value::Null)}, - "consumerMustAdd": must_add, - "blockers": blockers, - "note": "Filled from the plan, the verified screenshot and the executor's report. Export the frame with outputs [\"referencePng\"] first and put its artifactId in reference; devup_visual_compare pins that reference itself. The fields in consumerMustAdd are the consumer's to state.", - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - async fn dimension_fixture() -> (ArtifactStore, String) { - use devup_mcp_figma::{CollectedPayload, CollectionRequest, CollectionScope, FigmaTarget}; - let store = ArtifactStore::default(); - let target = FigmaTarget { - file_key: "synthetic".into(), - node_id: Some("target".into()), - branch_key: None, - }; - let request = CollectionRequest::new(target.clone(), CollectionScope::Node); - let payload: CollectedPayload = serde_json::from_value(json!({ - "target": target, "scope": "node", "metadata": {}, - "snapshot": {"fileKey": "synthetic", "version": "1", "roots": ["target"], - "nodes": {"target": {"id": "target", "type": "FRAME", "fields": {"width": 100, "height": 80}}, - "bounds-only": {"id": "bounds-only", "type": "FRAME", "fields": {"absoluteBoundingBox": {"width": 100, "height": 80}}}, - "errored": {"id": "errored", "type": "FRAME", "fields": {"width": 100, "height": 80}, "fieldErrors": {"width": "unavailable"}}}, "diagnostics": []}, - "variables": null, "styles": null, "completeness": "resolved-values-only", "sourceVersion": "1" - })).unwrap(); - let artifact = store - .get_or_acquire( - super::super::artifacts::ArtifactRequestKey::from_collection(&request), - false, - || async { Ok(payload) }, - ) - .await - .unwrap(); - (store, artifact.artifact_id) - } - - fn dimensions_plan(artifact: &str) -> PlanInput { - let widths = [1440, 1100, 858, 390]; - let mappings = widths - .iter() - .map( - |width| json!({"viewportWidth": width, "artifactId": artifact, "nodeId": "target"}), - ) - .collect::>(); - serde_json::from_value(json!({ - "baseUrl": "http://127.0.0.1:4173", "allowedOrigins": ["http://127.0.0.1:4173"], - "viewports": widths.iter().map(|width| json!({"width": width, "height": 100, "deviceScaleFactor": 2})).collect::>(), - "screens": [{"id": "dimensions", "routePath": "/", "checks": [ - {"kind": "dimensions", "subject": {"testId": "target"}, "tolerancePx": 0.5, "figmaDimensions": mappings}, - {"kind": "dimensions", "subject": {"testId": "target"}, "tolerancePx": 0.49, "figmaDimensions": mappings} - ]}] - })).unwrap() - } - - #[tokio::test] - async fn acquired_dimensions_are_rechecked_and_tolerances_recomputed_at_all_widths() { - let (dir, policy) = root(); - let (store, artifact) = dimension_fixture().await; - let planned = create_plan( - dimensions_plan(&artifact), - &policy, - Some("plan.json"), - Some("runner"), - &store, - ) - .await - .unwrap(); - assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 5); - assert_eq!(planned["template"]["executed"], false); - assert_eq!( - std::fs::read(dir.join("runner/run.mjs")).unwrap(), - include_bytes!("../../../../harness/runtime-audit/run.mjs") - ); - let plan_bytes = std::fs::read(dir.join("plan.json")).unwrap(); - let mut report = json!({"schemaVersion": 1, "kind": REPORT_KIND, "planId": planned["planId"], - "planSha256": sha256(&plan_bytes), "status": "completed", "runs": []}); - for width in [1440_u32, 1100, 858, 390] { - let bytes = png(width * 2, 200); - let name = format!("{width}.png"); - std::fs::write(dir.join(&name), &bytes).unwrap(); - report["runs"].as_array_mut().unwrap().push(json!({ - "viewport": {"width": width, "height": 100, "deviceScaleFactor": 2}, "environmentErrors": [], - "screens": [{"id": "dimensions", "status": "completed", - "checks": (0..2).map(|index| json!({"index": index, "status": "pass", "measured": { - "units": "css-px", "subjectCount": 1, "subjectVisible": true, - "subject": {"x": 0, "y": 0, "width": 100.5, "height": 80, "extra": "untrusted-subject-extension"}}})).collect::>(), - "screenshot": {"path": name, "sha256": sha256(&bytes), "bytes": bytes.len()}}] - })); - } - let write = |value: &Value| { - std::fs::write(dir.join("report.json"), serde_json::to_vec(value).unwrap()).unwrap() - }; - write(&report); - let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &store) - .await - .unwrap(); - assert_eq!(result["verdict"], "fail", "{result}"); - assert_eq!(result["summary"]["pass"], 4); - assert_eq!(result["summary"]["fail"], 4); - assert!(!result.to_string().contains("untrusted-subject-extension")); - assert_eq!(result["toleranceExceedances"].as_array().unwrap().len(), 4); - for row in result["widthTable"].as_array().unwrap() { - assert_eq!(row["dimensions"][0]["deltaPx"]["width"], 0.5); - assert_eq!( - row["dimensions"][0]["referenceVerifiedAgainstRetainedAcquisition"], - true - ); - assert_eq!(row["dimensions"][1]["executorOutcomeMatches"], false); - } - - let mut ambiguous = report.clone(); - let repeated = ambiguous["runs"][0]["screens"][0]["checks"][0].clone(); - ambiguous["runs"][0]["screens"][0]["checks"] - .as_array_mut() - .unwrap() - .push(repeated); - write(&ambiguous); - assert_eq!( - ingest_with_artifacts(&policy, "plan.json", "report.json", &store) - .await - .unwrap()["reason"], - "ambiguous-report-items" - ); - - let mut missing = report.clone(); - missing["runs"].as_array_mut().unwrap().pop(); - missing["runs"][0]["screens"][0]["checks"][0]["status"] = json!("skipped"); - write(&missing); - let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &store) - .await - .unwrap(); - assert_eq!(result["verdict"], "inconclusive"); - assert_eq!(result["summary"]["notRun"], 2); - assert_eq!(result["summary"]["skipped"], 1); - assert_eq!( - result["widthTable"][3]["dimensions"][0]["status"], - "not-run" - ); - - write(&report); - let empty_store = ArtifactStore::default(); - let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &empty_store) - .await - .unwrap(); - assert_eq!(result["summary"]["inconclusive"], 8); - assert_eq!(result["toleranceExceedances"], json!([])); - assert_eq!( - result["widthTable"][0]["dimensions"][0]["referenceVerifiedAgainstRetainedAcquisition"], - false - ); - - for (field, value) in [ - ("width", json!(100.5)), - ("height", json!(80.5)), - ("status", json!("caller-asserted")), - ("artifactContentHash", json!("forged")), - ] { - let mut forged = planned.clone(); - forged["dimensionReferences"][0][field] = value; - let forged_bytes = serde_json::to_vec(&forged).unwrap(); - std::fs::write(dir.join("plan.json"), &forged_bytes).unwrap(); - let mut rebound = report.clone(); - rebound["planSha256"] = json!(sha256(&forged_bytes)); - write(&rebound); - let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &store) - .await - .unwrap(); - assert_eq!( - result["widthTable"][0]["dimensions"][0]["status"], "inconclusive", - "{field}" - ); - } - - std::fs::write(dir.join("plan.json"), &plan_bytes).unwrap(); - write(&report); - std::fs::write(dir.join("1440.png"), png(2880, 201)).unwrap(); - assert_eq!( - ingest_with_artifacts(&policy, "plan.json", "report.json", &store) - .await - .unwrap()["reason"], - "artifact-changed" - ); - drop(policy); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[tokio::test] - async fn dimension_references_and_template_outputs_refuse_unverified_or_unsafe_inputs() { - let (dir, policy) = root(); - let (store, artifact) = dimension_fixture().await; - let input = dimensions_plan(&artifact); - let mut oversized = input.clone(); - oversized.screens[0].checks = - vec![input.screens[0].checks[0].clone(); MAX_DIMENSION_ROWS / 4 + 1]; - assert!(validate_plan(&oversized).is_err()); - let mut defaults = serde_json::to_value(&input).unwrap(); - defaults.as_object_mut().unwrap().remove("viewports"); - assert_eq!( - serde_json::from_value::(defaults) - .unwrap() - .viewports - .iter() - .map(|v| v.width) - .collect::>(), - vec![1440, 1100, 858, 390] - ); - for node_id in ["bounds-only", "errored", "absent"] { - let mut missing = input.clone(); - missing.screens[0].checks[0].figma_dimensions[0].node_id = node_id.into(); - let value = create_plan(missing, &policy, None, None, &store) - .await - .unwrap(); - assert_eq!( - value["dimensionReferences"][0]["status"], "unverified", - "{node_id}" - ); - } - for tolerance in [-0.1, 10000.1] { - let mut invalid = input.clone(); - invalid.screens[0].checks[0].tolerance_px = Some(tolerance); - assert!(validate_plan(&invalid).is_err()); - } - let mut duplicate = input.clone(); - let mapping = duplicate.screens[0].checks[0].figma_dimensions[0].clone(); - duplicate.screens[0].checks[0] - .figma_dimensions - .push(mapping); - assert!(validate_plan(&duplicate).is_err()); - for (output, template) in [ - (Some("safe.json"), Some("../escape")), - (Some("../escape.json"), Some("safe")), - (None, Some("safe")), - ] { - assert!( - create_plan(input.clone(), &policy, output, template, &store) - .await - .is_err() - ); - assert!(!dir.join("safe.json").exists()); - assert!(!dir.join("safe/run.mjs").exists()); - } - std::fs::create_dir_all(dir.join("existing")).unwrap(); - std::fs::write(dir.join("existing/checks.mjs"), b"keep").unwrap(); - assert!( - create_plan(input, &policy, Some("safe.json"), Some("existing"), &store) - .await - .is_err() - ); - assert_eq!( - std::fs::read(dir.join("existing/checks.mjs")).unwrap(), - b"keep" - ); - assert!(!dir.join("safe.json").exists()); - assert!(!dir.join("existing/run.mjs").exists()); - drop(policy); - std::fs::remove_dir_all(dir).unwrap(); - } - - fn plan_input() -> PlanInput { - serde_json::from_value(json!({ - "baseUrl": "http://127.0.0.1:4173", - "allowedOrigins": ["http://127.0.0.1:4173"], - "viewports": [{"width": 360, "height": 740}, {"width": 1200, "height": 800}], - "screens": [ - {"id": "today", "routePath": "/today", - "figma": {"nodeId": "1:138", "drawnWidths": [360]}, - "checks": [ - {"kind": "no-horizontal-overflow"}, - {"kind": "contained", "subject": {"testId": "badge-text"}, "container": {"testId": "badge"}} - ]}, - {"id": "next", "steps": [{"action": "click", "target": {"role": "button", "name": "Next"}}], - "expect": {"urlPath": "/next"}, - "checks": [{"kind": "aligned", "edge": "right", "offsetPx": 36, - "subject": {"testId": "qr"}, "container": {"testId": "card"}}]} - ] - })) - .unwrap() - } - - fn root() -> (std::path::PathBuf, OutputPolicy) { - let dir = std::env::temp_dir().join(format!( - "devup-runtime-audit-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&dir).unwrap(); - let policy = OutputPolicy::from_roots(vec![dir.clone()]).unwrap(); - (dir, policy) - } - - fn png(width: u32, height: u32) -> Vec { - let image = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255])); - let mut bytes = std::io::Cursor::new(Vec::new()); - image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); - bytes.into_inner() - } - - fn report_for( - dir: &Path, - plan_file: &[u8], - id: &str, - check_status: &str, - height_1200: u32, - ) -> Value { - let mut runs = Vec::new(); - for (width, height) in [(360_u32, 740_u32), (1200, height_1200)] { - let mut screens = Vec::new(); - for (screen, checks) in [("today", 2), ("next", 1)] { - let name = format!("{width}-{screen}.png"); - let bytes = png(width, height.max(740)); - std::fs::write(dir.join(&name), &bytes).unwrap(); - screens.push(json!({"id": screen, "status": "completed", - "checks": (0..checks).map(|index| json!({"index": index, "status": check_status, "reason": "measured"})).collect::>(), - "screenshot": {"path": name, "sha256": sha256(&bytes), "bytes": bytes.len()}})); - } - runs.push(json!({"viewport": {"width": width, "height": if width == 360 { 740 } else { 800 }, "deviceScaleFactor": 1}, - "environmentErrors": [], "screens": screens})); - } - json!({"schemaVersion": 1, "kind": REPORT_KIND, "planId": id, "planSha256": sha256(plan_file), - "status": "completed", "runs": runs, "summary": {"pass": 6, "fail": 0, "inconclusive": 0, "skipped": 0}}) - } - - fn write_plan(dir: &Path, policy: &OutputPolicy) -> (String, Vec) { - let document = plan( - plan_input(), - policy, - Some(&dir.join("plan.json").to_string_lossy()), - ) - .unwrap(); - let bytes = std::fs::read(dir.join("plan.json")).unwrap(); - (document["planId"].as_str().unwrap().to_owned(), bytes) - } - - #[test] - fn a_view_transition_check_needs_the_link_to_follow() { - let mut plan = plan_input(); - plan.screens[0].checks.push( - serde_json::from_value(json!({"kind": "view-transition", "subject": {"testId": "go"}})) - .unwrap(), - ); - assert!(validate_plan(&plan).is_ok()); - plan.screens[0] - .checks - .push(serde_json::from_value(json!({"kind": "view-transition"})).unwrap()); - let error = validate_plan(&plan).unwrap_err(); - assert!( - error - .message - .contains("view-transition check takes a subject"), - "{}", - error.message - ); - } - - #[test] - fn the_new_check_kinds_take_what_they_measure() { - let ok = |check: Value| { - let mut plan = plan_input(); - plan.screens[0] - .checks - .push(serde_json::from_value(check).unwrap()); - validate_plan(&plan).map_err(|error| error.message) - }; - let node = |id: &str| json!({"css": format!("[data-figma-node-id=\"{id}\"]")}); - assert!(ok(json!({"kind": "page-health"})).is_ok()); - assert!(ok(json!({"kind": "no-clipped-text"})).is_ok()); - assert!(ok(json!({"kind": "interaction", "state": "hover"})).is_ok()); - assert!( - ok( - json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1"), - "expected": {"x": 0, "y": 0, "width": 10, "height": 10}, "widths": [1440]}) - ) - .is_ok() - ); - assert!( - ok(json!({"kind": "capture", "subject": node("1:2"), - "reference": {"scope": "asset", "artifactId": "a", "assetId": "b"}})) - .is_ok() - ); - assert!( - ok(json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1")})) - .unwrap_err() - .contains("expected") - ); - assert!( - ok(json!({"kind": "interaction"})) - .unwrap_err() - .contains("hover or focus") - ); - assert!(ok(json!({"kind": "hidden", "subject": node("1:9")})).is_ok()); - assert!( - ok( - json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1"), - "expected": {"x": 0, "y": 0, "width": 10, "height": 10}, "tolerancePx": 4}) - ) - .unwrap_err() - .contains("exact"), - "geometry takes no tolerance" - ); - assert!( - ok(json!({"kind": "capture", "subject": node("1:2")})) - .unwrap_err() - .contains("reference") - ); - assert!( - ok(json!({"kind": "visible", "subject": node("1:2"), "state": "hover"})) - .unwrap_err() - .contains("only valid for interaction") - ); - } - - #[test] - fn view_transition_checks_move_behind_the_rest_of_their_screen() { - let mut plan = plan_input(); - plan.screens[0].checks = serde_json::from_value(json!([ - {"kind": "view-transition", "subject": {"testId": "go"}, "label": "vt"}, - {"kind": "page-health", "label": "health"}, - {"kind": "no-horizontal-overflow", "label": "overflow"}, - ])) - .unwrap(); - order_checks(&mut plan); - let labels = plan.screens[0] - .checks - .iter() - .map(|check| check.label.clone().unwrap()) - .collect::>(); - assert_eq!(labels, ["health", "overflow", "vt"]); - } - - #[test] - fn a_plan_is_identified_by_its_content_and_refuses_what_is_not_declarative() { - let first = plan_id(&plan_input()).unwrap(); - assert_eq!(first, plan_id(&plan_input()).unwrap()); - let mut edited = plan_input(); - edited.viewports[0].width = 361; - assert_ne!(first, plan_id(&edited).unwrap()); - - let shell = serde_json::from_value::(json!({ - "baseUrl": "http://127.0.0.1:4173", "allowedOrigins": ["http://127.0.0.1:4173"], - "viewports": [{"width": 360, "height": 740}], - "screens": [{"id": "a", "routePath": "/", "command": "rm -rf /"}] - })); - assert!( - shell.is_err(), - "unknown fields such as a command are refused" - ); - - for (field, value) in [ - ("baseUrl", json!("file:///etc/passwd")), - ("allowedOrigins", json!(["http://evil.example"])), - ("viewports", json!([{"width": 99999, "height": 740}])), - ] { - let mut raw = serde_json::to_value(plan_input()).unwrap(); - raw[field] = value; - let parsed: PlanInput = serde_json::from_value(raw).unwrap(); - assert!(validate_plan(&parsed).is_err(), "{field} must be refused"); - } - let mut css_step = plan_input(); - css_step.screens[1].steps[0].target = LocatorInput { - css: Some("button".into()), - ..LocatorInput::default() - }; - assert!( - validate_plan(&css_step).is_err(), - "steps use role/testId/text" - ); - - let mut unverifiable = plan_input(); - unverifiable.viewports = vec![unverifiable.viewports[0].clone(); MAX_VIEWPORTS]; - let screen = unverifiable.screens[0].clone(); - unverifiable.screens = (0..MAX_SCREENS) - .map(|index| ScreenInput { - id: format!("screen-{index}"), - ..screen.clone() - }) - .collect(); - assert!( - validate_plan(&unverifiable).is_err(), - "a plan with more screenshots than ingest verifies could never pass" - ); - } - - #[test] - fn a_complete_passing_report_passes_and_counts_are_recomputed() { - let (dir, policy) = root(); - let (id, plan_file) = write_plan(&dir, &policy); - let report = report_for(&dir, &plan_file, &id, "pass", 800); - std::fs::write( - dir.join("report.json"), - serde_json::to_vec(&report).unwrap(), - ) - .unwrap(); - let result = ingest( - &policy, - &dir.join("plan.json").to_string_lossy(), - &dir.join("report.json").to_string_lossy(), - ) - .unwrap(); - assert_eq!(result["verdict"], "pass", "{result}"); - assert_eq!(result["summary"]["pass"], 6); - assert_eq!(result["declaredSummaryMatches"], true); - assert_eq!( - result["figmaReferences"][0]["visualOriginalAt"], - json!([360]) - ); - assert_eq!( - result["figmaReferences"][0]["layoutChecksOnlyAt"], - json!([1200]) - ); - drop(policy); - std::fs::remove_dir_all(dir).unwrap(); - } - - /// The screen table is what `devup_screen_plan status` reads: one row per - /// screen and width, a failed overflow check named as overflow, a failed - /// navigation pinned on its own route, and a width with no run not-run. - #[test] - fn the_screen_table_folds_checks_and_page_errors_per_width() { - let (dir, policy) = root(); - let (id, plan_file) = write_plan(&dir, &policy); - let mut report = report_for(&dir, &plan_file, &id, "pass", 800); - report["runs"][0]["screens"][0]["checks"][0]["status"] = json!("fail"); - report["runs"][0]["screens"][0]["checks"][0]["reason"] = json!("content reaches 412px"); - report["runs"][1]["environmentErrors"] = - json!([{"kind": "navigation-failed", "message": "/today: HTTP 500"}]); - std::fs::write( - dir.join("report.json"), - serde_json::to_vec(&report).unwrap(), - ) - .unwrap(); - let result = ingest( - &policy, - &dir.join("plan.json").to_string_lossy(), - &dir.join("report.json").to_string_lossy(), - ) - .unwrap(); - let row = |screen: &str, width: u32| { - result["screenTable"] - .as_array() - .unwrap() - .iter() - .find(|row| row["screen"] == screen && row["width"] == width) - .cloned() - .unwrap() - }; - assert_eq!(row("today", 360)["status"], "fail"); - assert_eq!( - row("today", 360)["reasons"][0], - "horizontal overflow: content reaches 412px" - ); - assert_eq!(row("today", 1200)["status"], "fail"); - assert_eq!( - row("today", 1200)["reasons"][0], - "page error: /today: HTTP 500" - ); - assert_eq!(row("next", 360)["status"], "pass"); - assert_eq!(row("next", 360)["screenshot"], "360-next.png"); - - report["runs"].as_array_mut().unwrap().truncate(1); - std::fs::write( - dir.join("report.json"), - serde_json::to_vec(&report).unwrap(), - ) - .unwrap(); - let result = ingest( - &policy, - &dir.join("plan.json").to_string_lossy(), - &dir.join("report.json").to_string_lossy(), - ) - .unwrap(); - let missing = result["screenTable"] - .as_array() - .unwrap() - .iter() - .filter(|row| row["width"] == 1200) - .collect::>(); - assert_eq!(missing.len(), 2); - assert!(missing.iter().all(|row| row["status"] == "not-run")); - drop(policy); - std::fs::remove_dir_all(dir).unwrap(); - } - - /// Only the screen's drawn width has a visual original, and its verified - /// screenshot comes back as a ready comparison: what the audit knows is - /// filled in, what only the consumer knows is named. - #[test] - fn a_drawn_width_screenshot_comes_back_as_a_ready_visual_comparison() { - let (dir, policy) = root(); - let (id, plan_file) = write_plan(&dir, &policy); - let mut report = report_for(&dir, &plan_file, &id, "pass", 800); - report["executor"] = json!({"browser": {"name": "chromium", "version": "131.0.1"}}); - report["environment"] = json!({"os": "linux 6.1", "locale": null, "timezone": "UTC"}); - std::fs::write( - dir.join("report.json"), - serde_json::to_vec(&report).unwrap(), - ) - .unwrap(); - let result = ingest( - &policy, - &dir.join("plan.json").to_string_lossy(), - &dir.join("report.json").to_string_lossy(), - ) - .unwrap(); - let comparisons = result["visualComparisons"].as_array().unwrap(); - assert_eq!( - comparisons.len(), - 1, - "only today at 360 was drawn: {result}" - ); - let comparison = &comparisons[0]; - assert_eq!(comparison["screen"], "today"); - assert_eq!(comparison["nodeId"], "1:138"); - let arguments = &comparison["call"]["arguments"]; - assert_eq!(comparison["call"]["tool"], "devup_visual_compare"); - assert!( - arguments["actual"] - .as_str() - .unwrap() - .ends_with("360-today.png") - ); - let environment = &arguments["environment"]; - assert_eq!(environment["actualPng"], arguments["actual"]); - assert_eq!(environment["environmentStatus"], "valid"); - assert_eq!( - environment["renderer"], - json!({"name": "playwright-chromium", "version": "131.0.1"}) - ); - assert_eq!(environment["timezone"], "UTC"); - let must_add = comparison["consumerMustAdd"].as_array().unwrap(); - assert!(must_add.contains(&json!("environment.locale"))); - assert!(must_add.contains(&json!("environment.tsxResource"))); - assert_eq!(comparison["blockers"], json!([]), "{comparison}"); - drop(policy); - std::fs::remove_dir_all(dir).unwrap(); - } - - /// Every failed check comes back as a finding of one class with the - /// Figma node, the code file and a fix hint; a capture comes back as a - /// ready asset comparison against the node's own render, and checks - /// limited to a width are neither planned nor not-run elsewhere. - #[test] - fn failures_are_classified_and_captures_become_asset_comparisons() { - let (dir, policy) = root(); - let node = |id: &str| json!({"css": format!("[data-figma-node-id=\"{id}\"]")}); - let input: PlanInput = serde_json::from_value(json!({ - "baseUrl": "http://127.0.0.1:4173", "allowedOrigins": ["http://127.0.0.1:4173"], - "viewports": [{"width": 1440, "height": 900}, {"width": 390, "height": 900}], - "screens": [{"id": "home", "routePath": "/", "componentPath": "src/app/page.tsx", - "figma": {"nodeId": "1:1", "drawnWidths": [1440], "frames": [{"width": 1440, "nodeId": "1:1", "artifactId": "art-1"}]}, - "checks": [ - {"kind": "page-health"}, - {"kind": "bounds", "subject": node("1:3"), "container": node("1:2"), "nodeId": "1:3", - "expected": {"x": 0, "y": 0, "width": 10, "height": 10}, "widths": [1440]}, - {"kind": "interaction", "state": "hover", "widths": [1440]}, - {"kind": "capture", "subject": node("1:4"), "nodeId": "1:4", "widths": [1440], - "reference": {"scope": "asset", "artifactId": "art-1", "assetId": "icon"}}, - ]}], - })) - .unwrap(); - let written = plan( - input, - &policy, - Some(&dir.join("plan.json").to_string_lossy()), - ) - .unwrap(); - assert_eq!( - written["expected"]["checkItems"], 5, - "four at 1440, health alone at 390" - ); - let plan_bytes = std::fs::read(dir.join("plan.json")).unwrap(); - let page = png(1440, 900); - let icon = png(24, 24); - std::fs::write(dir.join("page.png"), &page).unwrap(); - std::fs::write(dir.join("icon.png"), &icon).unwrap(); - let shot = |bytes: &[u8], path: &str| json!({"path": path, "sha256": sha256(bytes), "bytes": bytes.len()}); - let report = json!({"schemaVersion": 1, "kind": REPORT_KIND, "planId": written["planId"], - "planSha256": sha256(&plan_bytes), "status": "completed", "runs": [ - {"viewport": {"width": 1440, "height": 900, "deviceScaleFactor": 1}, "environmentErrors": [], - "screens": [{"id": "home", "status": "completed", "screenshot": shot(&page, "page.png"), "checks": [ - {"index": 0, "status": "pass", "reason": "ok", "measured": {}}, - {"index": 1, "status": "fail", "reason": "position: y off by 60px", "measured": {}}, - {"index": 2, "status": "fail", "reason": "hover changes nothing on 1 of 2", "measured": {}}, - {"index": 3, "status": "pass", "reason": "captured", "measured": {}, "screenshot": shot(&icon, "icon.png")}, - ]}]}, - {"viewport": {"width": 390, "height": 900, "deviceScaleFactor": 1}, "environmentErrors": [], - "screens": [{"id": "home", "status": "completed", "checks": [ - {"index": 0, "status": "fail", "reason": "the page shows \"Application error\"", "measured": {}}, - ]}]}, - ]}); - std::fs::write( - dir.join("report.json"), - serde_json::to_vec(&report).unwrap(), - ) - .unwrap(); - let result = ingest( - &policy, - &dir.join("plan.json").to_string_lossy(), - &dir.join("report.json").to_string_lossy(), - ) - .unwrap(); - assert_eq!(result["summary"]["notRun"], 0, "{result}"); - let findings = result["findings"].as_array().unwrap(); - let classes = findings - .iter() - .map(|finding| { - ( - finding["class"].as_str().unwrap(), - finding["width"].as_u64().unwrap(), - ) - }) - .collect::>(); - assert_eq!( - classes, - [ - ("layout-position", 1440), - ("interaction", 1440), - ("runtime-error", 390) - ] - ); - let position = &findings[0]; - assert_eq!(position["nodeId"], "1:3"); - assert_eq!(position["codeFile"], "src/app/page.tsx"); - assert_eq!(position["id"], "home|bounds|1:3|1440"); - assert!( - position["fixHint"] - .as_str() - .unwrap() - .contains("Layout is exact") - ); - assert!( - findings[2]["fixHint"] - .as_str() - .unwrap() - .contains("error-boundary") - ); - - let comparisons = result["visualComparisons"].as_array().unwrap(); - let page_call = comparisons - .iter() - .find(|c| c["class"] == "page-visual") - .unwrap(); - assert_eq!( - page_call["call"]["arguments"]["reference"], - json!({"artifactId": "art-1"}) - ); - assert_eq!(page_call["call"]["arguments"]["threshold"], PAGE_THRESHOLD); - let asset_call = comparisons - .iter() - .find(|c| c["class"] == "asset-mismatch") - .unwrap(); - assert_eq!(asset_call["id"], "home|asset|1:4|1440"); - assert_eq!( - asset_call["call"]["arguments"]["reference"], - json!({"artifactId": "art-1", "assetId": "icon"}) - ); - assert_eq!( - asset_call["call"]["arguments"]["threshold"], - ASSET_THRESHOLD - ); - assert!( - asset_call["call"]["arguments"]["diffPath"] - .as_str() - .unwrap() - .ends_with("home_asset_1_4_1440.png") - ); - assert!( - asset_call["ifItFails"]["fixHint"] - .as_str() - .unwrap() - .contains("multi-colour SVG") - ); - let wide = result["screenTable"] - .as_array() - .unwrap() - .iter() - .find(|row| row["width"] == 1440) - .unwrap(); - assert_eq!(wide["columns"]["geometry"], "fail"); - assert_eq!(wide["columns"]["asset"], "1 to compare"); - assert_eq!(wide["columns"]["page"], "1 to compare"); - drop(policy); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn a_failure_fails_missing_items_are_inconclusive_and_edits_are_rejected() { - let (dir, policy) = root(); - let (id, plan_file) = write_plan(&dir, &policy); - let plan_path = dir.join("plan.json").to_string_lossy().into_owned(); - let report_path = dir.join("report.json").to_string_lossy().into_owned(); - - let failing = report_for(&dir, &plan_file, &id, "fail", 800); - std::fs::write(&report_path, serde_json::to_vec(&failing).unwrap()).unwrap(); - let result = ingest(&policy, &plan_path, &report_path).unwrap(); - assert_eq!(result["verdict"], "fail"); - assert_eq!( - result["declaredSummaryMatches"], false, - "the declared summary claimed passes" - ); - - let mut partial = report_for(&dir, &plan_file, &id, "pass", 800); - partial["runs"].as_array_mut().unwrap().pop(); - std::fs::write(&report_path, serde_json::to_vec(&partial).unwrap()).unwrap(); - let result = ingest(&policy, &plan_path, &report_path).unwrap(); - assert_eq!(result["verdict"], "inconclusive"); - assert_eq!(result["summary"]["notRun"], 3); - - let mut environment = report_for(&dir, &plan_file, &id, "fail", 800); - environment["runs"][0]["environmentErrors"] = - json!([{"kind": "font-missing", "message": "Pretendard"}]); - std::fs::write(&report_path, serde_json::to_vec(&environment).unwrap()).unwrap(); - assert_eq!( - ingest(&policy, &plan_path, &report_path).unwrap()["verdict"], - "inconclusive" - ); - - let cropped = report_for(&dir, &plan_file, &id, "pass", 700); - std::fs::write(&report_path, serde_json::to_vec(&cropped).unwrap()).unwrap(); - let result = ingest(&policy, &plan_path, &report_path).unwrap(); - assert_eq!( - result["verdict"], "inconclusive", - "a shorter capture than the viewport: {result}" - ); - - let tampered = report_for(&dir, &plan_file, &id, "pass", 800); - std::fs::write(&report_path, serde_json::to_vec(&tampered).unwrap()).unwrap(); - std::fs::write(dir.join("360-today.png"), png(360, 741)).unwrap(); - assert_eq!( - ingest(&policy, &plan_path, &report_path).unwrap()["reason"], - "artifact-changed" - ); - - let mut edited: Value = serde_json::from_slice(&plan_file).unwrap(); - edited["plan"]["viewports"][0]["width"] = json!(390); - std::fs::write(&plan_path, serde_json::to_vec(&edited).unwrap()).unwrap(); - assert_eq!( - ingest(&policy, &plan_path, &report_path).unwrap()["reason"], - "plan-edited" - ); - drop(policy); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn screenshot_paths_cannot_leave_the_report_directory() { - let (dir, policy) = root(); - let viewport = ViewportInput { - width: 360, - height: 740, - device_scale_factor: 1.0, - mobile: false, - touch: false, - }; - for path in ["../outside.png", "/etc/passwd", "C:\\Windows\\win.ini"] { - let shot = json!({"path": path, "sha256": "0", "bytes": 1}); - assert!(matches!( - verify_screenshot(&policy, &dir, &shot, &viewport), - Err(Integrity::Unverified(_)) - )); - } - drop(policy); - std::fs::remove_dir_all(dir).unwrap(); - } -} +// Source sections share this module to preserve privacy and test names. +include!("runtime_audit/inputs_and_capture.rs"); +include!("runtime_audit/write_plan.rs"); +include!("runtime_audit/capture_comparison.rs"); +include!("runtime_audit/tests.rs"); diff --git a/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs b/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs new file mode 100644 index 00000000..2aaeb0cd --- /dev/null +++ b/crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs @@ -0,0 +1,166 @@ + + +/// The `devup_visual_compare` call for one element capture against the +/// Figma node's own native render. +fn capture_comparison( + screen: &ScreenInput, + index: usize, + check: &CheckInput, + reference: &CaptureReferenceInput, + viewport: &ViewportInput, + shot: &VerifiedShot, +) -> Value { + let class = if reference.scope == "asset" { + "asset-mismatch" + } else { + "section-visual" + }; + let subject = check.node_id.clone().unwrap_or_else(|| format!("#{index}")); + let id = comparison_id(screen, &reference.scope, &subject, viewport.width); + let threshold = reference + .threshold + .unwrap_or(if reference.scope == "asset" { + ASSET_THRESHOLD + } else { + SECTION_THRESHOLD + }); + let mut blockers = Vec::new(); + if (viewport.device_scale_factor - 1.0).abs() > f64::EPSILON { + blockers.push(format!( + "Captured at deviceScaleFactor {}; the native render is at scale 1.", + viewport.device_scale_factor + )); + } + json!({ + "id": id, + "class": class, + "screen": screen.id, + "routePath": screen.route_path, + "nodeId": check.node_id, + "label": check.label, + "check": index, + "codeFile": screen.component_path, + "viewport": viewport, + "screenshot": {"path": shot.path, "sha256": shot.sha256, "width": shot.width, "height": shot.height}, + "call": { + "tool": "devup_visual_compare", + "arguments": { + "actual": shot.path, + "reference": {"artifactId": reference.artifact_id, "assetId": reference.asset_id}, + "threshold": threshold, + "diffPath": diff_path(&shot.path, &id), + }, + }, + "ifItFails": {"class": class, "fixHint": fix_hint(class, "capture", &Value::Null)}, + "blockers": blockers, + "note": "A size mismatch with the native render is reported as dimensions, not pixels: fix the element's size (its bounds check says by how much) and compare again.", + }) +} + +/// The `devup_visual_compare` call for one screenshot taken at a width its +/// Figma frame was drawn at, with every field the audit itself knows filled +/// in, the ones only the consumer can state named, and what would still make +/// it inconclusive said up front. +fn visual_comparison( + screen: &ScreenInput, + node_id: &str, + viewport: &ViewportInput, + shot: &VerifiedShot, + report: &Value, + environment_valid: bool, +) -> Value { + let mut environment = json!({ + "schemaVersion": 1, + "actualPng": shot.path, + "viewport": {"width": viewport.width, "height": viewport.height, + "deviceScaleFactor": viewport.device_scale_factor}, + "environmentStatus": if environment_valid { "valid" } else { "environment-invalid" }, + }); + let mut must_add = vec![ + "reference.artifactId", + "environment.tsxResource", + "environment.themePath", + "environment.assetDirectory", + "environment.devupUiVersion", + "environment.fontManifest", + ]; + let browser = &report["executor"]["browser"]; + match (browser["name"].as_str(), browser["version"].as_str()) { + (Some(name), Some(version)) => { + environment["renderer"] = + json!({"name": format!("playwright-{name}"), "version": version}); + } + _ => must_add.push("environment.renderer"), + } + for (field, value) in [ + ("os", &report["environment"]["os"]), + ("locale", &report["environment"]["locale"]), + ("timezone", &report["environment"]["timezone"]), + ] { + match value.as_str() { + Some(text) => environment[field] = json!(text), + None => must_add.push(match field { + "os" => "environment.os", + "locale" => "environment.locale", + _ => "environment.timezone", + }), + } + } + let mut blockers = Vec::new(); + if (viewport.device_scale_factor - 1.0).abs() > f64::EPSILON { + blockers.push(format!( + "Captured at deviceScaleFactor {}; the Figma reference is at scale 1, so plan this width at 1.", + viewport.device_scale_factor + )); + } + if shot.height != viewport.height { + blockers.push(format!( + include_str!("../text/runtime_audit-visual_comparison-8010a061.md"), + shot.height, viewport.height, shot.width, shot.height + )); + } + if !environment_valid { + blockers.push( + "This run reported environment errors or an unverified screenshot; the comparison stays inconclusive." + .to_owned(), + ); + } + let frame = screen + .figma + .iter() + .flat_map(|figma| &figma.frames) + .find(|frame| frame.width == viewport.width); + let node_id = frame.map_or(node_id, |frame| frame.node_id.as_str()); + let artifact = frame.and_then(|frame| frame.artifact_id.clone()); + if artifact.is_some() { + must_add.retain(|field| *field != "reference.artifactId"); + } + let id = comparison_id(screen, "page", node_id, viewport.width); + let threshold = frame + .and_then(|frame| frame.threshold) + .unwrap_or(PAGE_THRESHOLD); + json!({ + "id": id, + "class": "page-visual", + "screen": screen.id, + "routePath": screen.route_path, + "nodeId": node_id, + "codeFile": screen.component_path, + "viewport": viewport, + "screenshot": {"path": shot.path, "sha256": shot.sha256, "width": shot.width, "height": shot.height}, + "call": { + "tool": "devup_visual_compare", + "arguments": { + "actual": shot.path, + "reference": {"artifactId": artifact.unwrap_or_else(|| format!(""))}, + "threshold": threshold, + "diffPath": diff_path(&shot.path, &id), + "environment": environment, + }, + }, + "ifItFails": {"class": "page-visual", "fixHint": fix_hint("page-visual", "", &Value::Null)}, + "consumerMustAdd": must_add, + "blockers": blockers, + "note": include_str!("../text/runtime_audit-visual_comparison-8418b2cf.md"), + }) +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs b/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs new file mode 100644 index 00000000..14a1f3a0 --- /dev/null +++ b/crates/devup-mcp/src/server/runtime_audit/inputs_and_capture.rs @@ -0,0 +1,944 @@ + +use std::collections::BTreeMap; +use std::path::Path; + +use devup_mcp_figma::{DevupError, ErrorCode}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use super::artifacts::ArtifactStore; +use super::output::{OutputPolicy, OutputTransaction}; + +pub(crate) const PLAN_KIND: &str = "devup-runtime-audit-plan"; +pub(crate) const REPORT_KIND: &str = "devup-runtime-audit-report"; +const SCHEMA_VERSION: u64 = 1; +const MAX_VIEWPORTS: usize = 8; +const MAX_SCREENS: usize = 60; +const MAX_STEPS: usize = 20; +// A full audit names a bounds, capture and health check per section and +// asset, so a screen carries far more than a hand-written plan would. +const MAX_CHECKS: usize = 200; +const MAX_TIMEOUT_MS: u64 = 120_000; +const MAX_VIEWPORT_SIDE: u32 = 4096; +const MAX_TEXT: usize = 300; +const MAX_PLAN_BYTES: usize = 1024 * 1024; +const MAX_REPORT_BYTES: usize = 8 * 1024 * 1024; +const MAX_SCREENSHOT_BYTES: usize = 16 * 1024 * 1024; +const MAX_SCREENSHOTS: usize = 400; +const MAX_LISTED: usize = 50; +// Keep complete per-width rows in the response without unbounded duplication. +const MAX_DIMENSION_ROWS: usize = 100; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct RuntimeAuditInput { + /// plan: validate and write a plan for the consumer-side executor. + /// ingest: verify the executor's report against its plan. + #[schemars(extend("enum" = ["plan", "ingest"]))] + pub action: String, + /// Required for plan. + #[serde(default)] + pub plan: Option, + /// plan: where to write the plan JSON, under an allowed write root. + #[serde(default)] + pub output_path: Option, + /// plan: emit the consumer Playwright runner and its pinned npm manifest + /// into this directory under an allowed root; requires outputPath. + #[serde(default)] + pub template_path: Option, + /// ingest: the plan JSON the executor ran. + #[serde(default)] + pub plan_path: Option, + /// ingest: the executor's report.json. Screenshot paths in it are + /// relative to its directory. + #[serde(default)] + pub report_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PlanInput { + /// http(s) URL of the running consumer app, e.g. http://127.0.0.1:4173. + pub base_url: String, + /// Origins the run may load from; every other request is blocked and + /// recorded. Must include the baseUrl origin. + pub allowed_origins: Vec, + /// Defaults to 1440, 1100, 858 and 390 CSS-pixel widths at height 900. + #[serde(default = "default_viewports")] + pub viewports: Vec, + /// Visited in order in one isolated browser context per viewport. + pub screens: Vec, + #[serde(default)] + pub readiness: Option, + /// Font families that must be loaded before checks; a missing one is an + /// environment error, not a layout failure. + #[serde(default)] + pub fonts: Vec, + #[serde(default)] + pub locale: Option, + #[serde(default)] + pub timezone: Option, + #[serde(default)] + pub step_timeout_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ViewportInput { + pub width: u32, + pub height: u32, + #[serde(default = "default_scale")] + pub device_scale_factor: f64, + #[serde(default)] + pub mobile: bool, + #[serde(default)] + pub touch: bool, +} + +fn default_scale() -> f64 { + 1.0 +} + +fn default_viewports() -> Vec { + [1440, 1100, 858, 390] + .into_iter() + .map(|width| ViewportInput { + width, + height: 900, + device_scale_factor: 1.0, + mobile: false, + touch: false, + }) + .collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ScreenInput { + pub id: String, + /// Navigated to directly. Omitted, the screen is reached by its steps + /// from the previous one. + #[serde(default)] + pub route_path: Option, + /// The component this screen renders, recorded as an anchor only. + #[serde(default)] + pub component_path: Option, + #[serde(default)] + pub steps: Vec, + #[serde(default)] + pub expect: Option, + #[serde(default)] + pub checks: Vec, + /// The Figma frame this screen was generated from and the widths it was + /// drawn at. Only those widths have a visual original. + #[serde(default)] + pub figma: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct StepInput { + #[schemars(extend("enum" = ["click", "fill"]))] + pub action: String, + pub target: LocatorInput, + /// The text for fill. + #[serde(default)] + pub value: Option, + #[serde(default)] + pub timeout_ms: Option, +} + +/// Exactly one way of finding an element: a role with its accessible name, a +/// test id, visible text, or a CSS selector (checks only). +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct LocatorInput { + #[serde(default)] + pub role: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub test_id: Option, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub css: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ExpectInput { + /// The path the page must reach, compared without origin or query. + #[serde(default)] + pub url_path: Option, + /// A heading with this accessible name must be visible. + #[serde(default)] + pub heading: Option, + #[serde(default)] + pub visible: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct CheckInput { + /// no-horizontal-overflow: the page is no wider than the viewport. + /// contained: subject's box lies inside container's. + /// aligned: subject keeps offsetPx from container's edge (left/right) or + /// shares its centre (center). + /// spans: subject spans container's width less insetPx on each side. + /// visible: subject renders with a non-empty box. + /// dimensions: compare the explicitly mapped element box in CSS pixels + /// with acquired Figma logical dimensions at each named viewport width. + /// view-transition: follow the subject link to another route and confirm + /// a view transition ran (document.startViewTransition, or a + /// cross-document pagereveal transition). It navigates away, so the + /// executor runs it after the screen's other checks and screenshot; the + /// next screen should start from its own routePath. The plan writer + /// moves it after the screen's other checks and the executor returns to + /// the screen's URL after it, so order in the plan cannot break a run. + /// page-health: no error page text ("This page couldn't load", + /// "Application error", ...), page error, console error or failed + /// navigation while the screen was open. + /// bounds: subject's size and position relative to container's top-left, + /// in CSS px, against `expected` (the Figma geometry). Exact: layout is + /// converted statically, so only half a pixel of rounding is allowed and + /// no tolerance can be set. + /// hidden: subject is absent or has no visible box - an element a width's + /// frame does not draw. + /// no-clipped-text: no text inside subject (or the page) is cut off by an + /// overflow-hidden box; ellipsis and line clamp are intended and pass. + /// interaction: hovering or keyboard-focusing subject (`state`) changes + /// something visible; without a subject, every visible link and button. + /// capture: screenshot subject's element box for devup_visual_compare + /// against `reference`; ingest turns it into a ready call. + #[schemars(extend("enum" = ["no-horizontal-overflow", "contained", "aligned", "spans", "visible", "dimensions", "view-transition", "page-health", "bounds", "no-clipped-text", "interaction", "capture", "hidden"]))] + pub kind: String, + #[serde(default)] + pub subject: Option, + #[serde(default)] + pub container: Option, + #[serde(default)] + #[schemars(extend("enum" = ["left", "right", "center"]))] + pub edge: Option, + #[serde(default)] + pub offset_px: Option, + #[serde(default)] + pub inset_px: Option, + #[serde(default)] + pub tolerance_px: Option, + /// What of the subject is measured: its element box (default), or the + /// box its CSS mask paints - a rule drawn as a mask can keep its drawn + /// length inside an element box that stretches. + #[serde(default)] + #[schemars(extend("enum" = ["box", "painted-mask"]))] + pub measure: Option, + #[serde(default)] + pub label: Option, + /// Explicit mappings only. Widths omitted here have no design reference; + /// intermediate widths are never interpolated or inferred from screenshots. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub figma_dimensions: Vec, + /// bounds: the expected box relative to container, in CSS px. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected: Option, + /// interaction: hover or focus (keyboard, so :focus-visible applies). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(extend("enum" = ["hover", "focus"]))] + pub state: Option, + /// Only at these viewport widths; empty means every width. A bounds or + /// capture check holds Figma geometry, which exists only where the frame + /// was drawn. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub widths: Vec, + /// The Figma node this check is about, so a failure names it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node_id: Option, + /// capture: what the element screenshot is compared with. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct BoundsInput { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// A native-node PNG of the same Figma node, captured by devup_figma_export +/// (assetCaptures, renderMode node, format png, scale 1). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct CaptureReferenceInput { + /// section: a top-level child of the frame. asset: an image or icon. + #[schemars(extend("enum" = ["section", "asset"]))] + pub scope: String, + pub artifact_id: String, + pub asset_id: String, + /// Changed-pixel ratio the comparison allows; default 0.03 for a + /// section and 0.05 for an asset, which absorbs anti-aliased edges but + /// not a filled shape where an outline was drawn. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub threshold: Option, +} + +/// Changed-pixel ratios that absorb anti-aliasing between Figma's renderer +/// and Chromium. A small icon has proportionally more edge, so it gets more. +pub(crate) const PAGE_THRESHOLD: f64 = 0.03; +pub(crate) const SECTION_THRESHOLD: f64 = 0.03; +pub(crate) const ASSET_THRESHOLD: f64 = 0.05; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct DimensionReferenceInput { + pub viewport_width: u32, + pub artifact_id: String, + pub node_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ReadinessInput { + /// A selector the app renders once it is ready to be measured. + #[serde(default)] + pub selector: Option, + #[serde(default)] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct FigmaAnchorInput { + pub node_id: String, + pub drawn_widths: Vec, + /// The frame drawn at each width when a page has one per width, with + /// the devup_figma_export artifact holding its referencePng. Ingest puts + /// that artifactId in the page comparison it returns. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub frames: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct FrameReferenceInput { + pub width: u32, + pub node_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_id: Option, + /// Changed-pixel ratio the page comparison allows; default 0.03. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub threshold: Option, +} + +fn invalid(message: impl Into) -> DevupError { + DevupError::new(ErrorCode::DevupInvalidInput, message, false) +} + +fn sha256(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn origin_of(url: &str) -> Result { + let parsed = url::Url::parse(url).map_err(|_| invalid(format!("{url} is not a URL.")))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return Err(invalid(format!( + "{url} must be an http or https URL with a host." + ))); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(invalid( + "URLs in a runtime audit plan cannot carry credentials.", + )); + } + Ok(parsed.origin().ascii_serialization()) +} + +fn bounded_text(field: &str, value: &str) -> Result<(), DevupError> { + if value.trim().is_empty() || value.chars().count() > MAX_TEXT { + return Err(invalid(format!( + "{field} must be non-empty and at most {MAX_TEXT} characters." + ))); + } + Ok(()) +} + +fn timeout(field: &str, value: Option) -> Result<(), DevupError> { + if value.is_some_and(|ms| ms == 0 || ms > MAX_TIMEOUT_MS) { + return Err(invalid(format!( + "{field} must be between 1 and {MAX_TIMEOUT_MS} ms." + ))); + } + Ok(()) +} + +fn validate_locator( + field: &str, + locator: &LocatorInput, + allow_css: bool, +) -> Result<(), DevupError> { + let ways = [ + locator.role.is_some(), + locator.test_id.is_some(), + locator.text.is_some(), + locator.css.is_some(), + ] + .iter() + .filter(|set| **set) + .count(); + if ways != 1 { + return Err(invalid(format!( + "{field} needs exactly one of role (with name), testId, text{}.", + if allow_css { " or css" } else { "" } + ))); + } + if locator.css.is_some() && !allow_css { + return Err(invalid(format!( + "{field} must be found by role and accessible name, testId or text; css is for checks only." + ))); + } + if locator.name.is_some() && locator.role.is_none() { + return Err(invalid(format!("{field}.name needs role."))); + } + for (name, value) in [ + ("role", &locator.role), + ("name", &locator.name), + ("testId", &locator.test_id), + ("text", &locator.text), + ("css", &locator.css), + ] { + if let Some(value) = value { + bounded_text(&format!("{field}.{name}"), value)?; + } + } + Ok(()) +} + +fn validate_route(field: &str, path: &str) -> Result<(), DevupError> { + bounded_text(field, path)?; + if !path.starts_with('/') || path.starts_with("//") || path.contains('\\') { + return Err(invalid(format!( + "{field} must be a path on the baseUrl origin, starting with a single /." + ))); + } + Ok(()) +} + +/// Every rule a plan must satisfy before an executor may run it. +pub(crate) fn validate_plan(plan: &PlanInput) -> Result<(), DevupError> { + let base_origin = origin_of(&plan.base_url)?; + let allowed = plan + .allowed_origins + .iter() + .map(|origin| origin_of(origin)) + .collect::, _>>()?; + if !allowed.contains(&base_origin) { + return Err(invalid(format!( + "allowedOrigins must include the baseUrl origin {base_origin}." + ))); + } + if plan.viewports.is_empty() || plan.viewports.len() > MAX_VIEWPORTS { + return Err(invalid(format!( + "viewports must hold 1 to {MAX_VIEWPORTS} entries." + ))); + } + for viewport in &plan.viewports { + if !(1..=MAX_VIEWPORT_SIDE).contains(&viewport.width) + || !(1..=MAX_VIEWPORT_SIDE).contains(&viewport.height) + || !(viewport.device_scale_factor > 0.0 && viewport.device_scale_factor <= 4.0) + { + return Err(invalid(format!( + "Each viewport needs width and height in 1..={MAX_VIEWPORT_SIDE} and a deviceScaleFactor in (0, 4]." + ))); + } + } + let mut viewports = std::collections::BTreeSet::new(); + if plan + .viewports + .iter() + .any(|v| !viewports.insert((v.width, v.height, v.device_scale_factor.to_bits()))) + { + return Err(invalid( + "Duplicate viewports would make report attribution ambiguous.", + )); + } + if plan.screens.is_empty() || plan.screens.len() > MAX_SCREENS { + return Err(invalid(format!( + "screens must hold 1 to {MAX_SCREENS} entries." + ))); + } + let dimension_rows = plan.viewports.len() + * plan + .screens + .iter() + .map(|screen| { + screen + .checks + .iter() + .filter(|check| check.kind == "dimensions") + .count() + }) + .sum::(); + if dimension_rows > MAX_DIMENSION_ROWS { + return Err(invalid(format!( + "A plan may contain at most {MAX_DIMENSION_ROWS} dimension rows (dimension checks x viewports); split the plan to keep every width row explicit." + ))); + } + if plan.viewports.len() * plan.screens.len() > MAX_SCREENSHOTS { + return Err(invalid(format!( + "viewports x screens must be at most {MAX_SCREENSHOTS}, the screenshots ingest verifies for one report; split the plan." + ))); + } + if plan.screens[0].route_path.is_none() { + return Err(invalid("The first screen needs a routePath to start from.")); + } + let mut ids = std::collections::BTreeSet::new(); + for screen in &plan.screens { + bounded_text("screens[].id", &screen.id)?; + if !ids.insert(screen.id.as_str()) { + return Err(invalid(format!("Screen id {} is repeated.", screen.id))); + } + if let Some(path) = &screen.route_path { + validate_route("screens[].routePath", path)?; + } + if let Some(path) = &screen.component_path { + bounded_text("screens[].componentPath", path)?; + } + if screen.route_path.is_none() && screen.steps.is_empty() { + return Err(invalid(format!( + "Screen {} needs a routePath or the steps that reach it.", + screen.id + ))); + } + if screen.steps.len() > MAX_STEPS || screen.checks.len() > MAX_CHECKS { + return Err(invalid(format!( + "Screen {} exceeds {MAX_STEPS} steps or {MAX_CHECKS} checks.", + screen.id + ))); + } + for step in &screen.steps { + validate_locator("steps[].target", &step.target, false)?; + timeout("steps[].timeoutMs", step.timeout_ms)?; + match (step.action.as_str(), &step.value) { + ("click", None) => {} + ("fill", Some(value)) if value.chars().count() <= MAX_TEXT => {} + _ => { + return Err(invalid( + "A step is click (no value) or fill (with a value of at most 300 characters).", + )); + } + } + } + if let Some(expect) = &screen.expect { + if let Some(path) = &expect.url_path { + validate_route("expect.urlPath", path)?; + } + if let Some(heading) = &expect.heading { + bounded_text("expect.heading", heading)?; + } + if let Some(visible) = &expect.visible { + validate_locator("expect.visible", visible, true)?; + } + } + for check in &screen.checks { + validate_check(check)?; + if check + .figma_dimensions + .iter() + .any(|r| !plan.viewports.iter().any(|v| v.width == r.viewport_width)) + { + return Err(invalid( + "figmaDimensions.viewportWidth must name a planned viewport width.", + )); + } + } + if let Some(figma) = &screen.figma { + bounded_text("figma.nodeId", &figma.node_id)?; + if figma.drawn_widths.is_empty() { + return Err(invalid("figma.drawnWidths must name at least one width.")); + } + } + } + for font in &plan.fonts { + bounded_text("fonts[]", font)?; + } + if let Some(readiness) = &plan.readiness { + if let Some(selector) = &readiness.selector { + bounded_text("readiness.selector", selector)?; + } + timeout("readiness.timeoutMs", readiness.timeout_ms)?; + } + timeout("stepTimeoutMs", plan.step_timeout_ms) +} + +fn validate_check(check: &CheckInput) -> Result<(), DevupError> { + let needs = |subject: bool, container: bool| -> Result<(), DevupError> { + if subject != check.subject.is_some() || container != check.container.is_some() { + return Err(invalid(format!( + "A {} check takes {}subject and {}container.", + check.kind, + if subject { "a " } else { "no " }, + if container { "a " } else { "no " }, + ))); + } + Ok(()) + }; + match check.kind.as_str() { + "no-horizontal-overflow" | "page-health" => needs(false, false)?, + "visible" | "view-transition" | "hidden" => needs(true, false)?, + "no-clipped-text" => { + if check.container.is_some() { + return Err(invalid("A no-clipped-text check takes no container.")); + } + } + "bounds" => { + needs(true, true)?; + if check.tolerance_px.is_some() { + return Err(invalid( + "A bounds check is exact (half a pixel of rounding); it takes no tolerancePx. A mismatch is code drift or a converter bug, never noise.", + )); + } + let Some(expected) = &check.expected else { + return Err(invalid( + "A bounds check needs expected {x, y, width, height}.", + )); + }; + if [expected.x, expected.y, expected.width, expected.height] + .iter() + .any(|value| !value.is_finite() || value.abs() > 100_000.0) + || expected.width < 0.0 + || expected.height < 0.0 + { + return Err(invalid( + "bounds expected values must be finite px, width and height nonnegative.", + )); + } + } + "interaction" => { + // Without a subject it sweeps every visible link and button. + if check.container.is_some() { + return Err(invalid("An interaction check takes no container.")); + } + if !matches!(check.state.as_deref(), Some("hover" | "focus")) { + return Err(invalid("An interaction check needs state hover or focus.")); + } + } + "capture" => { + needs(true, false)?; + let Some(reference) = &check.reference else { + return Err(invalid( + "A capture check needs reference {scope, artifactId, assetId}.", + )); + }; + if !matches!(reference.scope.as_str(), "section" | "asset") { + return Err(invalid("capture reference.scope must be section or asset.")); + } + bounded_text("reference.artifactId", &reference.artifact_id)?; + bounded_text("reference.assetId", &reference.asset_id)?; + if reference + .threshold + .is_some_and(|value| !(0.0..=1.0).contains(&value)) + { + return Err(invalid("reference.threshold must be between 0 and 1.")); + } + } + "dimensions" => { + needs(true, false)?; + if check.tolerance_px.is_none() + || check.figma_dimensions.is_empty() + || !matches!(check.measure.as_deref(), None | Some("box")) + { + return Err(invalid( + "A dimensions check needs tolerancePx, figmaDimensions and measure box.", + )); + } + } + "contained" | "spans" => needs(true, true)?, + "aligned" => { + needs(true, true)?; + if !matches!(check.edge.as_deref(), Some("left" | "right" | "center")) { + return Err(invalid( + "An aligned check needs edge left, right or center.", + )); + } + } + other => return Err(invalid(format!("Unknown check kind {other}."))), + } + for (field, present, kind) in [ + ("expected", check.expected.is_some(), "bounds"), + ("state", check.state.is_some(), "interaction"), + ("reference", check.reference.is_some(), "capture"), + ] { + if present && check.kind != kind { + return Err(invalid(format!("{field} is only valid for {kind} checks."))); + } + } + if let Some(node_id) = &check.node_id { + bounded_text("checks[].nodeId", node_id)?; + } + if check.kind != "dimensions" && !check.figma_dimensions.is_empty() { + return Err(invalid( + "figmaDimensions is only valid for dimensions checks.", + )); + } + let mut widths = std::collections::BTreeSet::new(); + for reference in &check.figma_dimensions { + bounded_text("figmaDimensions.artifactId", &reference.artifact_id)?; + bounded_text("figmaDimensions.nodeId", &reference.node_id)?; + if !widths.insert(reference.viewport_width) { + return Err(invalid( + "A dimensions check needs at most one explicit reference per width.", + )); + } + } + for (field, locator) in [("subject", &check.subject), ("container", &check.container)] { + if let Some(locator) = locator { + validate_locator(&format!("checks[].{field}"), locator, true)?; + } + } + match check.measure.as_deref() { + None | Some("box") => {} + Some("painted-mask") if check.subject.is_some() => {} + Some(other) => { + return Err(invalid(format!( + "measure {other} is not box, or painted-mask on a check with a subject." + ))); + } + } + for value in [check.offset_px, check.inset_px, check.tolerance_px] + .into_iter() + .flatten() + { + if !value.is_finite() || value.abs() > 10_000.0 { + return Err(invalid( + "Check offsets, insets and tolerances must be finite px values.", + )); + } + } + if check.tolerance_px.is_some_and(|value| value < 0.0) { + return Err(invalid("tolerancePx must be nonnegative.")); + } + if let Some(label) = &check.label { + bounded_text("checks[].label", label)?; + } + Ok(()) +} + +/// The id a plan is known by: the SHA-256 of its normalized form, so the same +/// plan always has the same id and any edit gives a new one. +fn plan_id(plan: &PlanInput) -> Result { + let bytes = serde_json::to_vec(plan) + .map_err(|error| invalid(format!("Cannot serialize the plan: {error}")))?; + Ok(sha256(&bytes)) +} + +#[cfg(test)] +fn plan( + input: PlanInput, + policy: &OutputPolicy, + output_path: Option<&str>, +) -> Result { + write_plan(input, policy, output_path, None, Vec::new()) +} + +/// Resolve design dimensions from retained acquisitions, never caller numbers. +/// Missing/expired artifacts remain explicit so they cannot become a passing check. +async fn dimension_references(input: &PlanInput, artifacts: &ArtifactStore) -> Vec { + let mut references = Vec::new(); + for screen in &input.screens { + for (index, check) in screen.checks.iter().enumerate() { + for reference in &check.figma_dimensions { + let mut value = json!({"screen": screen.id, "check": index, + "viewportWidth": reference.viewport_width, "artifactId": reference.artifact_id, + "nodeId": reference.node_id, "status": "unverified", + "reason": "The retained acquisition is missing or expired; acquire the node again.", + "mapping": "caller-declared", "units": "figma-logical-px"}); + if let Some(artifact) = artifacts.get(&reference.artifact_id).await { + value["artifactContentHash"] = json!(artifact.content_hash); + value["staleness"] = json!("retained snapshot; not compared with live Figma"); + value["reason"] = + json!("The acquired node has no verified logical width and height."); + if let Some(node) = artifact.payload.snapshot.nodes.get(&reference.node_id) { + let view = node.typed_view(); + let width = (!node.field_errors.contains_key("width")) + .then(|| view.number("width")) + .flatten() + .or_else(|| { + (!node.field_errors.contains_key("size")) + .then(|| view.value("size")?.get("x")?.as_f64()) + .flatten() + }); + let height = (!node.field_errors.contains_key("height")) + .then(|| view.number("height")) + .flatten() + .or_else(|| { + (!node.field_errors.contains_key("size")) + .then(|| view.value("size")?.get("y")?.as_f64()) + .flatten() + }); + if let (Some(width), Some(height)) = (width, height) + && width.is_finite() + && height.is_finite() + && width >= 0.0 + && height >= 0.0 + { + value["status"] = json!("acquired"); + value["width"] = json!(width); + value["height"] = json!(height); + value["reason"] = Value::Null; + value["source"] = json!( + "node logical width/height or size; no screenshot, zoom, DPR or bounding-box inference" + ); + } + } + } + references.push(value); + } + } + } + references +} + +pub(crate) async fn create_plan( + mut input: PlanInput, + policy: &OutputPolicy, + output_path: Option<&str>, + template_path: Option<&str>, + artifacts: &ArtifactStore, +) -> Result { + order_checks(&mut input); + validate_plan(&input)?; + let references = dimension_references(&input, artifacts).await; + write_plan(input, policy, output_path, template_path, references) +} + +/// One screen at one width by what was checked: health (renders, no +/// overflow, no clipped text), geometry (size and position), interaction +/// (states and view transitions), and the three visual levels, which stay +/// "to compare" until devup_visual_compare results come back through +/// devup_screen_plan status. +fn columns( + screen: &ScreenInput, + viewport: &ViewportInput, + judged: &[(&str, &CheckInput)], + row: &Value, +) -> Value { + let fold = |kinds: &[&str]| { + let statuses = judged + .iter() + .filter(|(_, check)| kinds.contains(&check.kind.as_str())) + .map(|(status, _)| *status) + .collect::>(); + if statuses.is_empty() { + "-" + } else if statuses.contains(&"fail") { + "fail" + } else if statuses.iter().all(|status| *status == "pass") { + "pass" + } else if statuses.contains(&"inconclusive") { + "inconclusive" + } else { + "not-run" + } + }; + let unreached = row["reasons"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .any(|reason| reason.starts_with("page error") || reason.starts_with("screen not reached")); + let health = if unreached { + "fail" + } else { + fold(&["page-health", "no-horizontal-overflow", "no-clipped-text"]) + }; + let captures = |scope: &str| { + let count = judged + .iter() + .filter(|(status, check)| { + *status == "pass" + && check + .reference + .as_ref() + .is_some_and(|reference| reference.scope == scope) + }) + .count(); + let missing = judged + .iter() + .filter(|(status, check)| { + *status != "pass" + && check + .reference + .as_ref() + .is_some_and(|reference| reference.scope == scope) + }) + .count(); + match (count, missing) { + (0, 0) => json!("-"), + (_, 0) => json!(format!("{count} to compare")), + _ => json!(format!("{count} to compare, {missing} not captured")), + } + }; + let drawn = screen + .figma + .as_ref() + .is_some_and(|figma| figma.drawn_widths.contains(&viewport.width)); + json!({ + "health": health, + "geometry": fold(&["bounds", "dimensions", "contained", "aligned", "spans", "visible"]), + "interaction": fold(&["interaction", "view-transition"]), + "page": if drawn { json!(if row["screenshot"].is_string() { "1 to compare" } else { "not captured" }) } else { json!("not drawn") }, + "section": captures("section"), + "asset": captures("asset"), + }) +} + +/// A check limited to some widths does not exist at the others. +fn applies(check: &CheckInput, width: u32) -> bool { + check.widths.is_empty() || check.widths.contains(&width) +} + +fn planned_items(plan: &PlanInput) -> usize { + plan.viewports + .iter() + .map(|viewport| { + plan.screens + .iter() + .flat_map(|screen| &screen.checks) + .filter(|check| applies(check, viewport.width)) + .count() + }) + .sum() +} + +/// A view-transition check clicks away from its screen, so every one moves +/// behind the screen's other checks, keeping their order otherwise. The +/// executor also returns to the screen's URL after it; together a plan +/// cannot measure the wrong page whatever order it was written in. +pub(crate) fn order_checks(input: &mut PlanInput) { + for screen in &mut input.screens { + screen + .checks + .sort_by_key(|check| check.kind == "view-transition"); + } +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/runtime_audit/tests.rs b/crates/devup-mcp/src/server/runtime_audit/tests.rs new file mode 100644 index 00000000..edb0d9b2 --- /dev/null +++ b/crates/devup-mcp/src/server/runtime_audit/tests.rs @@ -0,0 +1,865 @@ + + +#[cfg(test)] +mod tests { + use super::*; + + async fn dimension_fixture() -> (ArtifactStore, String) { + use devup_mcp_figma::{CollectedPayload, CollectionRequest, CollectionScope, FigmaTarget}; + let store = ArtifactStore::default(); + let target = FigmaTarget { + file_key: "synthetic".into(), + node_id: Some("target".into()), + branch_key: None, + }; + let request = CollectionRequest::new(target.clone(), CollectionScope::Node); + let payload: CollectedPayload = serde_json::from_value(json!({ + "target": target, "scope": "node", "metadata": {}, + "snapshot": {"fileKey": "synthetic", "version": "1", "roots": ["target"], + "nodes": {"target": {"id": "target", "type": "FRAME", "fields": {"width": 100, "height": 80}}, + "bounds-only": {"id": "bounds-only", "type": "FRAME", "fields": {"absoluteBoundingBox": {"width": 100, "height": 80}}}, + "errored": {"id": "errored", "type": "FRAME", "fields": {"width": 100, "height": 80}, "fieldErrors": {"width": "unavailable"}}}, "diagnostics": []}, + "variables": null, "styles": null, "completeness": "resolved-values-only", "sourceVersion": "1" + })).unwrap(); + let artifact = store + .get_or_acquire( + super::super::artifacts::ArtifactRequestKey::from_collection(&request), + false, + || async { Ok(payload) }, + ) + .await + .unwrap(); + (store, artifact.artifact_id) + } + + fn dimensions_plan(artifact: &str) -> PlanInput { + let widths = [1440, 1100, 858, 390]; + let mappings = widths + .iter() + .map( + |width| json!({"viewportWidth": width, "artifactId": artifact, "nodeId": "target"}), + ) + .collect::>(); + serde_json::from_value(json!({ + "baseUrl": "http://127.0.0.1:4173", "allowedOrigins": ["http://127.0.0.1:4173"], + "viewports": widths.iter().map(|width| json!({"width": width, "height": 100, "deviceScaleFactor": 2})).collect::>(), + "screens": [{"id": "dimensions", "routePath": "/", "checks": [ + {"kind": "dimensions", "subject": {"testId": "target"}, "tolerancePx": 0.5, "figmaDimensions": mappings}, + {"kind": "dimensions", "subject": {"testId": "target"}, "tolerancePx": 0.49, "figmaDimensions": mappings} + ]}] + })).unwrap() + } + + #[tokio::test] + async fn acquired_dimensions_are_rechecked_and_tolerances_recomputed_at_all_widths() { + let (dir, policy) = root(); + let (store, artifact) = dimension_fixture().await; + let planned = create_plan( + dimensions_plan(&artifact), + &policy, + Some("plan.json"), + Some("runner"), + &store, + ) + .await + .unwrap(); + assert_eq!(planned["template"]["files"].as_array().unwrap().len(), 5); + assert_eq!(planned["template"]["executed"], false); + assert_eq!( + std::fs::read(dir.join("runner/run.mjs")).unwrap(), + include_bytes!("../../../../../harness/runtime-audit/run.mjs") + ); + let plan_bytes = std::fs::read(dir.join("plan.json")).unwrap(); + let mut report = json!({"schemaVersion": 1, "kind": REPORT_KIND, "planId": planned["planId"], + "planSha256": sha256(&plan_bytes), "status": "completed", "runs": []}); + for width in [1440_u32, 1100, 858, 390] { + let bytes = png(width * 2, 200); + let name = format!("{width}.png"); + std::fs::write(dir.join(&name), &bytes).unwrap(); + report["runs"].as_array_mut().unwrap().push(json!({ + "viewport": {"width": width, "height": 100, "deviceScaleFactor": 2}, "environmentErrors": [], + "screens": [{"id": "dimensions", "status": "completed", + "checks": (0..2).map(|index| json!({"index": index, "status": "pass", "measured": { + "units": "css-px", "subjectCount": 1, "subjectVisible": true, + "subject": {"x": 0, "y": 0, "width": 100.5, "height": 80, "extra": "untrusted-subject-extension"}}})).collect::>(), + "screenshot": {"path": name, "sha256": sha256(&bytes), "bytes": bytes.len()}}] + })); + } + let write = |value: &Value| { + std::fs::write(dir.join("report.json"), serde_json::to_vec(value).unwrap()).unwrap() + }; + write(&report); + let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &store) + .await + .unwrap(); + assert_eq!(result["verdict"], "fail", "{result}"); + assert_eq!(result["summary"]["pass"], 4); + assert_eq!(result["summary"]["fail"], 4); + assert!(!result.to_string().contains("untrusted-subject-extension")); + assert_eq!(result["toleranceExceedances"].as_array().unwrap().len(), 4); + for row in result["widthTable"].as_array().unwrap() { + assert_eq!(row["dimensions"][0]["deltaPx"]["width"], 0.5); + assert_eq!( + row["dimensions"][0]["referenceVerifiedAgainstRetainedAcquisition"], + true + ); + assert_eq!(row["dimensions"][1]["executorOutcomeMatches"], false); + } + + let mut ambiguous = report.clone(); + let repeated = ambiguous["runs"][0]["screens"][0]["checks"][0].clone(); + ambiguous["runs"][0]["screens"][0]["checks"] + .as_array_mut() + .unwrap() + .push(repeated); + write(&ambiguous); + assert_eq!( + ingest_with_artifacts(&policy, "plan.json", "report.json", &store) + .await + .unwrap()["reason"], + "ambiguous-report-items" + ); + + let mut missing = report.clone(); + missing["runs"].as_array_mut().unwrap().pop(); + missing["runs"][0]["screens"][0]["checks"][0]["status"] = json!("skipped"); + write(&missing); + let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &store) + .await + .unwrap(); + assert_eq!(result["verdict"], "inconclusive"); + assert_eq!(result["summary"]["notRun"], 2); + assert_eq!(result["summary"]["skipped"], 1); + assert_eq!( + result["widthTable"][3]["dimensions"][0]["status"], + "not-run" + ); + + write(&report); + let empty_store = ArtifactStore::default(); + let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &empty_store) + .await + .unwrap(); + assert_eq!(result["summary"]["inconclusive"], 8); + assert_eq!(result["toleranceExceedances"], json!([])); + assert_eq!( + result["widthTable"][0]["dimensions"][0]["referenceVerifiedAgainstRetainedAcquisition"], + false + ); + + for (field, value) in [ + ("width", json!(100.5)), + ("height", json!(80.5)), + ("status", json!("caller-asserted")), + ("artifactContentHash", json!("forged")), + ] { + let mut forged = planned.clone(); + forged["dimensionReferences"][0][field] = value; + let forged_bytes = serde_json::to_vec(&forged).unwrap(); + std::fs::write(dir.join("plan.json"), &forged_bytes).unwrap(); + let mut rebound = report.clone(); + rebound["planSha256"] = json!(sha256(&forged_bytes)); + write(&rebound); + let result = ingest_with_artifacts(&policy, "plan.json", "report.json", &store) + .await + .unwrap(); + assert_eq!( + result["widthTable"][0]["dimensions"][0]["status"], "inconclusive", + "{field}" + ); + } + + std::fs::write(dir.join("plan.json"), &plan_bytes).unwrap(); + write(&report); + std::fs::write(dir.join("1440.png"), png(2880, 201)).unwrap(); + assert_eq!( + ingest_with_artifacts(&policy, "plan.json", "report.json", &store) + .await + .unwrap()["reason"], + "artifact-changed" + ); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[tokio::test] + async fn dimension_references_and_template_outputs_refuse_unverified_or_unsafe_inputs() { + let (dir, policy) = root(); + let (store, artifact) = dimension_fixture().await; + let input = dimensions_plan(&artifact); + let mut oversized = input.clone(); + oversized.screens[0].checks = + vec![input.screens[0].checks[0].clone(); MAX_DIMENSION_ROWS / 4 + 1]; + assert!(validate_plan(&oversized).is_err()); + let mut defaults = serde_json::to_value(&input).unwrap(); + defaults.as_object_mut().unwrap().remove("viewports"); + assert_eq!( + serde_json::from_value::(defaults) + .unwrap() + .viewports + .iter() + .map(|v| v.width) + .collect::>(), + vec![1440, 1100, 858, 390] + ); + for node_id in ["bounds-only", "errored", "absent"] { + let mut missing = input.clone(); + missing.screens[0].checks[0].figma_dimensions[0].node_id = node_id.into(); + let value = create_plan(missing, &policy, None, None, &store) + .await + .unwrap(); + assert_eq!( + value["dimensionReferences"][0]["status"], "unverified", + "{node_id}" + ); + } + for tolerance in [-0.1, 10000.1] { + let mut invalid = input.clone(); + invalid.screens[0].checks[0].tolerance_px = Some(tolerance); + assert!(validate_plan(&invalid).is_err()); + } + let mut duplicate = input.clone(); + let mapping = duplicate.screens[0].checks[0].figma_dimensions[0].clone(); + duplicate.screens[0].checks[0] + .figma_dimensions + .push(mapping); + assert!(validate_plan(&duplicate).is_err()); + for (output, template) in [ + (Some("safe.json"), Some("../escape")), + (Some("../escape.json"), Some("safe")), + (None, Some("safe")), + ] { + assert!( + create_plan(input.clone(), &policy, output, template, &store) + .await + .is_err() + ); + assert!(!dir.join("safe.json").exists()); + assert!(!dir.join("safe/run.mjs").exists()); + } + std::fs::create_dir_all(dir.join("existing")).unwrap(); + std::fs::write(dir.join("existing/checks.mjs"), b"keep").unwrap(); + assert!( + create_plan(input, &policy, Some("safe.json"), Some("existing"), &store) + .await + .is_err() + ); + assert_eq!( + std::fs::read(dir.join("existing/checks.mjs")).unwrap(), + b"keep" + ); + assert!(!dir.join("safe.json").exists()); + assert!(!dir.join("existing/run.mjs").exists()); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + + fn plan_input() -> PlanInput { + serde_json::from_value(json!({ + "baseUrl": "http://127.0.0.1:4173", + "allowedOrigins": ["http://127.0.0.1:4173"], + "viewports": [{"width": 360, "height": 740}, {"width": 1200, "height": 800}], + "screens": [ + {"id": "today", "routePath": "/today", + "figma": {"nodeId": "1:138", "drawnWidths": [360]}, + "checks": [ + {"kind": "no-horizontal-overflow"}, + {"kind": "contained", "subject": {"testId": "badge-text"}, "container": {"testId": "badge"}} + ]}, + {"id": "next", "steps": [{"action": "click", "target": {"role": "button", "name": "Next"}}], + "expect": {"urlPath": "/next"}, + "checks": [{"kind": "aligned", "edge": "right", "offsetPx": 36, + "subject": {"testId": "qr"}, "container": {"testId": "card"}}]} + ] + })) + .unwrap() + } + + fn root() -> (std::path::PathBuf, OutputPolicy) { + let dir = std::env::temp_dir().join(format!( + "devup-runtime-audit-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let policy = OutputPolicy::from_roots(vec![dir.clone()]).unwrap(); + (dir, policy) + } + + fn png(width: u32, height: u32) -> Vec { + let image = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255])); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + bytes.into_inner() + } + + fn report_for( + dir: &Path, + plan_file: &[u8], + id: &str, + check_status: &str, + height_1200: u32, + ) -> Value { + let mut runs = Vec::new(); + for (width, height) in [(360_u32, 740_u32), (1200, height_1200)] { + let mut screens = Vec::new(); + for (screen, checks) in [("today", 2), ("next", 1)] { + let name = format!("{width}-{screen}.png"); + let bytes = png(width, height.max(740)); + std::fs::write(dir.join(&name), &bytes).unwrap(); + screens.push(json!({"id": screen, "status": "completed", + "checks": (0..checks).map(|index| json!({"index": index, "status": check_status, "reason": "measured"})).collect::>(), + "screenshot": {"path": name, "sha256": sha256(&bytes), "bytes": bytes.len()}})); + } + runs.push(json!({"viewport": {"width": width, "height": if width == 360 { 740 } else { 800 }, "deviceScaleFactor": 1}, + "environmentErrors": [], "screens": screens})); + } + json!({"schemaVersion": 1, "kind": REPORT_KIND, "planId": id, "planSha256": sha256(plan_file), + "status": "completed", "runs": runs, "summary": {"pass": 6, "fail": 0, "inconclusive": 0, "skipped": 0}}) + } + + fn write_plan(dir: &Path, policy: &OutputPolicy) -> (String, Vec) { + let document = plan( + plan_input(), + policy, + Some(&dir.join("plan.json").to_string_lossy()), + ) + .unwrap(); + let bytes = std::fs::read(dir.join("plan.json")).unwrap(); + (document["planId"].as_str().unwrap().to_owned(), bytes) + } + + #[test] + fn a_view_transition_check_needs_the_link_to_follow() { + let mut plan = plan_input(); + plan.screens[0].checks.push( + serde_json::from_value(json!({"kind": "view-transition", "subject": {"testId": "go"}})) + .unwrap(), + ); + assert!(validate_plan(&plan).is_ok()); + plan.screens[0] + .checks + .push(serde_json::from_value(json!({"kind": "view-transition"})).unwrap()); + let error = validate_plan(&plan).unwrap_err(); + assert!( + error + .message + .contains("view-transition check takes a subject"), + "{}", + error.message + ); + } + + #[test] + fn the_new_check_kinds_take_what_they_measure() { + let ok = |check: Value| { + let mut plan = plan_input(); + plan.screens[0] + .checks + .push(serde_json::from_value(check).unwrap()); + validate_plan(&plan).map_err(|error| error.message) + }; + let node = |id: &str| json!({"css": format!("[data-figma-node-id=\"{id}\"]")}); + assert!(ok(json!({"kind": "page-health"})).is_ok()); + assert!(ok(json!({"kind": "no-clipped-text"})).is_ok()); + assert!(ok(json!({"kind": "interaction", "state": "hover"})).is_ok()); + assert!( + ok( + json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1"), + "expected": {"x": 0, "y": 0, "width": 10, "height": 10}, "widths": [1440]}) + ) + .is_ok() + ); + assert!( + ok(json!({"kind": "capture", "subject": node("1:2"), + "reference": {"scope": "asset", "artifactId": "a", "assetId": "b"}})) + .is_ok() + ); + assert!( + ok(json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1")})) + .unwrap_err() + .contains("expected") + ); + assert!( + ok(json!({"kind": "interaction"})) + .unwrap_err() + .contains("hover or focus") + ); + assert!(ok(json!({"kind": "hidden", "subject": node("1:9")})).is_ok()); + assert!( + ok( + json!({"kind": "bounds", "subject": node("1:2"), "container": node("1:1"), + "expected": {"x": 0, "y": 0, "width": 10, "height": 10}, "tolerancePx": 4}) + ) + .unwrap_err() + .contains("exact"), + "geometry takes no tolerance" + ); + assert!( + ok(json!({"kind": "capture", "subject": node("1:2")})) + .unwrap_err() + .contains("reference") + ); + assert!( + ok(json!({"kind": "visible", "subject": node("1:2"), "state": "hover"})) + .unwrap_err() + .contains("only valid for interaction") + ); + } + + #[test] + fn view_transition_checks_move_behind_the_rest_of_their_screen() { + let mut plan = plan_input(); + plan.screens[0].checks = serde_json::from_value(json!([ + {"kind": "view-transition", "subject": {"testId": "go"}, "label": "vt"}, + {"kind": "page-health", "label": "health"}, + {"kind": "no-horizontal-overflow", "label": "overflow"}, + ])) + .unwrap(); + order_checks(&mut plan); + let labels = plan.screens[0] + .checks + .iter() + .map(|check| check.label.clone().unwrap()) + .collect::>(); + assert_eq!(labels, ["health", "overflow", "vt"]); + } + + #[test] + fn a_plan_is_identified_by_its_content_and_refuses_what_is_not_declarative() { + let first = plan_id(&plan_input()).unwrap(); + assert_eq!(first, plan_id(&plan_input()).unwrap()); + let mut edited = plan_input(); + edited.viewports[0].width = 361; + assert_ne!(first, plan_id(&edited).unwrap()); + + let shell = serde_json::from_value::(json!({ + "baseUrl": "http://127.0.0.1:4173", "allowedOrigins": ["http://127.0.0.1:4173"], + "viewports": [{"width": 360, "height": 740}], + "screens": [{"id": "a", "routePath": "/", "command": "rm -rf /"}] + })); + assert!( + shell.is_err(), + "unknown fields such as a command are refused" + ); + + for (field, value) in [ + ("baseUrl", json!("file:///etc/passwd")), + ("allowedOrigins", json!(["http://evil.example"])), + ("viewports", json!([{"width": 99999, "height": 740}])), + ] { + let mut raw = serde_json::to_value(plan_input()).unwrap(); + raw[field] = value; + let parsed: PlanInput = serde_json::from_value(raw).unwrap(); + assert!(validate_plan(&parsed).is_err(), "{field} must be refused"); + } + let mut css_step = plan_input(); + css_step.screens[1].steps[0].target = LocatorInput { + css: Some("button".into()), + ..LocatorInput::default() + }; + assert!( + validate_plan(&css_step).is_err(), + "steps use role/testId/text" + ); + + let mut unverifiable = plan_input(); + unverifiable.viewports = vec![unverifiable.viewports[0].clone(); MAX_VIEWPORTS]; + let screen = unverifiable.screens[0].clone(); + unverifiable.screens = (0..MAX_SCREENS) + .map(|index| ScreenInput { + id: format!("screen-{index}"), + ..screen.clone() + }) + .collect(); + assert!( + validate_plan(&unverifiable).is_err(), + "a plan with more screenshots than ingest verifies could never pass" + ); + } + + #[test] + fn a_complete_passing_report_passes_and_counts_are_recomputed() { + let (dir, policy) = root(); + let (id, plan_file) = write_plan(&dir, &policy); + let report = report_for(&dir, &plan_file, &id, "pass", 800); + std::fs::write( + dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .unwrap(); + let result = ingest( + &policy, + &dir.join("plan.json").to_string_lossy(), + &dir.join("report.json").to_string_lossy(), + ) + .unwrap(); + assert_eq!(result["verdict"], "pass", "{result}"); + assert_eq!(result["summary"]["pass"], 6); + assert_eq!(result["declaredSummaryMatches"], true); + assert_eq!( + result["figmaReferences"][0]["visualOriginalAt"], + json!([360]) + ); + assert_eq!( + result["figmaReferences"][0]["layoutChecksOnlyAt"], + json!([1200]) + ); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// The screen table is what `devup_screen_plan status` reads: one row per + /// screen and width, a failed overflow check named as overflow, a failed + /// navigation pinned on its own route, and a width with no run not-run. + #[test] + fn the_screen_table_folds_checks_and_page_errors_per_width() { + let (dir, policy) = root(); + let (id, plan_file) = write_plan(&dir, &policy); + let mut report = report_for(&dir, &plan_file, &id, "pass", 800); + report["runs"][0]["screens"][0]["checks"][0]["status"] = json!("fail"); + report["runs"][0]["screens"][0]["checks"][0]["reason"] = json!("content reaches 412px"); + report["runs"][1]["environmentErrors"] = + json!([{"kind": "navigation-failed", "message": "/today: HTTP 500"}]); + std::fs::write( + dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .unwrap(); + let result = ingest( + &policy, + &dir.join("plan.json").to_string_lossy(), + &dir.join("report.json").to_string_lossy(), + ) + .unwrap(); + let row = |screen: &str, width: u32| { + result["screenTable"] + .as_array() + .unwrap() + .iter() + .find(|row| row["screen"] == screen && row["width"] == width) + .cloned() + .unwrap() + }; + assert_eq!(row("today", 360)["status"], "fail"); + assert_eq!( + row("today", 360)["reasons"][0], + "horizontal overflow: content reaches 412px" + ); + assert_eq!(row("today", 1200)["status"], "fail"); + assert_eq!( + row("today", 1200)["reasons"][0], + "page error: /today: HTTP 500" + ); + assert_eq!(row("next", 360)["status"], "pass"); + assert_eq!(row("next", 360)["screenshot"], "360-next.png"); + + report["runs"].as_array_mut().unwrap().truncate(1); + std::fs::write( + dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .unwrap(); + let result = ingest( + &policy, + &dir.join("plan.json").to_string_lossy(), + &dir.join("report.json").to_string_lossy(), + ) + .unwrap(); + let missing = result["screenTable"] + .as_array() + .unwrap() + .iter() + .filter(|row| row["width"] == 1200) + .collect::>(); + assert_eq!(missing.len(), 2); + assert!(missing.iter().all(|row| row["status"] == "not-run")); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Only the screen's drawn width has a visual original, and its verified + /// screenshot comes back as a ready comparison: what the audit knows is + /// filled in, what only the consumer knows is named. + #[test] + fn a_drawn_width_screenshot_comes_back_as_a_ready_visual_comparison() { + let (dir, policy) = root(); + let (id, plan_file) = write_plan(&dir, &policy); + let mut report = report_for(&dir, &plan_file, &id, "pass", 800); + report["executor"] = json!({"browser": {"name": "chromium", "version": "131.0.1"}}); + report["environment"] = json!({"os": "linux 6.1", "locale": null, "timezone": "UTC"}); + std::fs::write( + dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .unwrap(); + let result = ingest( + &policy, + &dir.join("plan.json").to_string_lossy(), + &dir.join("report.json").to_string_lossy(), + ) + .unwrap(); + let comparisons = result["visualComparisons"].as_array().unwrap(); + assert_eq!( + comparisons.len(), + 1, + "only today at 360 was drawn: {result}" + ); + let comparison = &comparisons[0]; + assert_eq!(comparison["screen"], "today"); + assert_eq!(comparison["nodeId"], "1:138"); + let arguments = &comparison["call"]["arguments"]; + assert_eq!(comparison["call"]["tool"], "devup_visual_compare"); + assert!( + arguments["actual"] + .as_str() + .unwrap() + .ends_with("360-today.png") + ); + let environment = &arguments["environment"]; + assert_eq!(environment["actualPng"], arguments["actual"]); + assert_eq!(environment["environmentStatus"], "valid"); + assert_eq!( + environment["renderer"], + json!({"name": "playwright-chromium", "version": "131.0.1"}) + ); + assert_eq!(environment["timezone"], "UTC"); + let must_add = comparison["consumerMustAdd"].as_array().unwrap(); + assert!(must_add.contains(&json!("environment.locale"))); + assert!(must_add.contains(&json!("environment.tsxResource"))); + assert_eq!(comparison["blockers"], json!([]), "{comparison}"); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Every failed check comes back as a finding of one class with the + /// Figma node, the code file and a fix hint; a capture comes back as a + /// ready asset comparison against the node's own render, and checks + /// limited to a width are neither planned nor not-run elsewhere. + #[test] + fn failures_are_classified_and_captures_become_asset_comparisons() { + let (dir, policy) = root(); + let node = |id: &str| json!({"css": format!("[data-figma-node-id=\"{id}\"]")}); + let input: PlanInput = serde_json::from_value(json!({ + "baseUrl": "http://127.0.0.1:4173", "allowedOrigins": ["http://127.0.0.1:4173"], + "viewports": [{"width": 1440, "height": 900}, {"width": 390, "height": 900}], + "screens": [{"id": "home", "routePath": "/", "componentPath": "src/app/page.tsx", + "figma": {"nodeId": "1:1", "drawnWidths": [1440], "frames": [{"width": 1440, "nodeId": "1:1", "artifactId": "art-1"}]}, + "checks": [ + {"kind": "page-health"}, + {"kind": "bounds", "subject": node("1:3"), "container": node("1:2"), "nodeId": "1:3", + "expected": {"x": 0, "y": 0, "width": 10, "height": 10}, "widths": [1440]}, + {"kind": "interaction", "state": "hover", "widths": [1440]}, + {"kind": "capture", "subject": node("1:4"), "nodeId": "1:4", "widths": [1440], + "reference": {"scope": "asset", "artifactId": "art-1", "assetId": "icon"}}, + ]}], + })) + .unwrap(); + let written = plan( + input, + &policy, + Some(&dir.join("plan.json").to_string_lossy()), + ) + .unwrap(); + assert_eq!( + written["expected"]["checkItems"], 5, + "four at 1440, health alone at 390" + ); + let plan_bytes = std::fs::read(dir.join("plan.json")).unwrap(); + let page = png(1440, 900); + let icon = png(24, 24); + std::fs::write(dir.join("page.png"), &page).unwrap(); + std::fs::write(dir.join("icon.png"), &icon).unwrap(); + let shot = |bytes: &[u8], path: &str| json!({"path": path, "sha256": sha256(bytes), "bytes": bytes.len()}); + let report = json!({"schemaVersion": 1, "kind": REPORT_KIND, "planId": written["planId"], + "planSha256": sha256(&plan_bytes), "status": "completed", "runs": [ + {"viewport": {"width": 1440, "height": 900, "deviceScaleFactor": 1}, "environmentErrors": [], + "screens": [{"id": "home", "status": "completed", "screenshot": shot(&page, "page.png"), "checks": [ + {"index": 0, "status": "pass", "reason": "ok", "measured": {}}, + {"index": 1, "status": "fail", "reason": "position: y off by 60px", "measured": {}}, + {"index": 2, "status": "fail", "reason": "hover changes nothing on 1 of 2", "measured": {}}, + {"index": 3, "status": "pass", "reason": "captured", "measured": {}, "screenshot": shot(&icon, "icon.png")}, + ]}]}, + {"viewport": {"width": 390, "height": 900, "deviceScaleFactor": 1}, "environmentErrors": [], + "screens": [{"id": "home", "status": "completed", "checks": [ + {"index": 0, "status": "fail", "reason": "the page shows \"Application error\"", "measured": {}}, + ]}]}, + ]}); + std::fs::write( + dir.join("report.json"), + serde_json::to_vec(&report).unwrap(), + ) + .unwrap(); + let result = ingest( + &policy, + &dir.join("plan.json").to_string_lossy(), + &dir.join("report.json").to_string_lossy(), + ) + .unwrap(); + assert_eq!(result["summary"]["notRun"], 0, "{result}"); + let findings = result["findings"].as_array().unwrap(); + let classes = findings + .iter() + .map(|finding| { + ( + finding["class"].as_str().unwrap(), + finding["width"].as_u64().unwrap(), + ) + }) + .collect::>(); + assert_eq!( + classes, + [ + ("layout-position", 1440), + ("interaction", 1440), + ("runtime-error", 390) + ] + ); + let position = &findings[0]; + assert_eq!(position["nodeId"], "1:3"); + assert_eq!(position["codeFile"], "src/app/page.tsx"); + assert_eq!(position["id"], "home|bounds|1:3|1440"); + assert!( + position["fixHint"] + .as_str() + .unwrap() + .contains("Layout is exact") + ); + assert!( + findings[2]["fixHint"] + .as_str() + .unwrap() + .contains("error-boundary") + ); + + let comparisons = result["visualComparisons"].as_array().unwrap(); + let page_call = comparisons + .iter() + .find(|c| c["class"] == "page-visual") + .unwrap(); + assert_eq!( + page_call["call"]["arguments"]["reference"], + json!({"artifactId": "art-1"}) + ); + assert_eq!(page_call["call"]["arguments"]["threshold"], PAGE_THRESHOLD); + let asset_call = comparisons + .iter() + .find(|c| c["class"] == "asset-mismatch") + .unwrap(); + assert_eq!(asset_call["id"], "home|asset|1:4|1440"); + assert_eq!( + asset_call["call"]["arguments"]["reference"], + json!({"artifactId": "art-1", "assetId": "icon"}) + ); + assert_eq!( + asset_call["call"]["arguments"]["threshold"], + ASSET_THRESHOLD + ); + assert!( + asset_call["call"]["arguments"]["diffPath"] + .as_str() + .unwrap() + .ends_with("home_asset_1_4_1440.png") + ); + assert!( + asset_call["ifItFails"]["fixHint"] + .as_str() + .unwrap() + .contains("multi-colour SVG") + ); + let wide = result["screenTable"] + .as_array() + .unwrap() + .iter() + .find(|row| row["width"] == 1440) + .unwrap(); + assert_eq!(wide["columns"]["geometry"], "fail"); + assert_eq!(wide["columns"]["asset"], "1 to compare"); + assert_eq!(wide["columns"]["page"], "1 to compare"); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn a_failure_fails_missing_items_are_inconclusive_and_edits_are_rejected() { + let (dir, policy) = root(); + let (id, plan_file) = write_plan(&dir, &policy); + let plan_path = dir.join("plan.json").to_string_lossy().into_owned(); + let report_path = dir.join("report.json").to_string_lossy().into_owned(); + + let failing = report_for(&dir, &plan_file, &id, "fail", 800); + std::fs::write(&report_path, serde_json::to_vec(&failing).unwrap()).unwrap(); + let result = ingest(&policy, &plan_path, &report_path).unwrap(); + assert_eq!(result["verdict"], "fail"); + assert_eq!( + result["declaredSummaryMatches"], false, + "the declared summary claimed passes" + ); + + let mut partial = report_for(&dir, &plan_file, &id, "pass", 800); + partial["runs"].as_array_mut().unwrap().pop(); + std::fs::write(&report_path, serde_json::to_vec(&partial).unwrap()).unwrap(); + let result = ingest(&policy, &plan_path, &report_path).unwrap(); + assert_eq!(result["verdict"], "inconclusive"); + assert_eq!(result["summary"]["notRun"], 3); + + let mut environment = report_for(&dir, &plan_file, &id, "fail", 800); + environment["runs"][0]["environmentErrors"] = + json!([{"kind": "font-missing", "message": "Pretendard"}]); + std::fs::write(&report_path, serde_json::to_vec(&environment).unwrap()).unwrap(); + assert_eq!( + ingest(&policy, &plan_path, &report_path).unwrap()["verdict"], + "inconclusive" + ); + + let cropped = report_for(&dir, &plan_file, &id, "pass", 700); + std::fs::write(&report_path, serde_json::to_vec(&cropped).unwrap()).unwrap(); + let result = ingest(&policy, &plan_path, &report_path).unwrap(); + assert_eq!( + result["verdict"], "inconclusive", + "a shorter capture than the viewport: {result}" + ); + + let tampered = report_for(&dir, &plan_file, &id, "pass", 800); + std::fs::write(&report_path, serde_json::to_vec(&tampered).unwrap()).unwrap(); + std::fs::write(dir.join("360-today.png"), png(360, 741)).unwrap(); + assert_eq!( + ingest(&policy, &plan_path, &report_path).unwrap()["reason"], + "artifact-changed" + ); + + let mut edited: Value = serde_json::from_slice(&plan_file).unwrap(); + edited["plan"]["viewports"][0]["width"] = json!(390); + std::fs::write(&plan_path, serde_json::to_vec(&edited).unwrap()).unwrap(); + assert_eq!( + ingest(&policy, &plan_path, &report_path).unwrap()["reason"], + "plan-edited" + ); + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn screenshot_paths_cannot_leave_the_report_directory() { + let (dir, policy) = root(); + let viewport = ViewportInput { + width: 360, + height: 740, + device_scale_factor: 1.0, + mobile: false, + touch: false, + }; + for path in ["../outside.png", "/etc/passwd", "C:\\Windows\\win.ini"] { + let shot = json!({"path": path, "sha256": "0", "bytes": 1}); + assert!(matches!( + verify_screenshot(&policy, &dir, &shot, &viewport), + Err(Integrity::Unverified(_)) + )); + } + drop(policy); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/devup-mcp/src/server/runtime_audit/write_plan.rs b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs new file mode 100644 index 00000000..3b27a486 --- /dev/null +++ b/crates/devup-mcp/src/server/runtime_audit/write_plan.rs @@ -0,0 +1,994 @@ + + +fn write_plan( + input: PlanInput, + policy: &OutputPolicy, + output_path: Option<&str>, + template_path: Option<&str>, + dimension_references: Vec, +) -> Result { + validate_plan(&input)?; + if template_path.is_some() && output_path.is_none() { + return Err(invalid( + "templatePath requires outputPath for the bound plan JSON.", + )); + } + let id = plan_id(&input)?; + let references = input + .screens + .iter() + .filter_map(|screen| { + let figma = screen.figma.as_ref()?; + let (comparable, layout_only): (Vec<_>, Vec<_>) = input + .viewports + .iter() + .map(|viewport| viewport.width) + .partition(|width| figma.drawn_widths.contains(width)); + Some(json!({"screen": screen.id, "nodeId": figma.node_id, + "drawnWidths": figma.drawn_widths, "visualOriginalAt": comparable, + "layoutChecksOnlyAt": layout_only})) + }) + .collect::>(); + let document = json!({ + "schemaVersion": SCHEMA_VERSION, + "kind": PLAN_KIND, + "planId": id, + "createdBy": super::delivery::server_identity(), + "plan": input, + "expected": { + "runs": input.viewports.len(), + "screensPerRun": input.screens.len(), + "checkItems": planned_items(&input), + "dimensionRows": input.viewports.len() * input.screens.iter().map(|s| s.checks.iter().filter(|c| c.kind == "dimensions").count()).sum::(), + }, + "limits": {"dimensionRows": MAX_DIMENSION_ROWS, "dimensionRowsOmitted": 0}, + "figmaReferences": references, + "dimensionReferences": dimension_references, + "executor": { + "package": "harness/runtime-audit", + "install": "npm ci && npx playwright install chromium", + "run": "node run.mjs --plan --out ", + "isolation": "One fresh browser context per viewport, with no profile, cookies or storage from the user's browser. Requests to origins outside allowedOrigins are blocked and recorded.", + }, + }); + let mut written = None; + let mut template_files = Vec::new(); + let mut transaction = OutputTransaction::new(); + if let Some(directory) = template_path { + transaction.refuse_existing(); + for (name, bytes) in [ + ( + "run.mjs", + include_bytes!("../../../../../harness/runtime-audit/run.mjs").as_slice(), + ), + ( + "checks.mjs", + include_bytes!("../../../../../harness/runtime-audit/checks.mjs").as_slice(), + ), + ( + "strip-figma-node-ids.mjs", + include_bytes!("../../../../../harness/runtime-audit/strip-figma-node-ids.mjs") + .as_slice(), + ), + ( + "package.json", + include_bytes!("../../../../../harness/runtime-audit/package.json").as_slice(), + ), + ( + "package-lock.json", + include_bytes!("../../../../../harness/runtime-audit/package-lock.json").as_slice(), + ), + ] { + let requested = Path::new(directory).join(name); + let target = policy.resolve(&requested.to_string_lossy())?; + template_files.push(json!({"path": target.display_path().display().to_string(), "sha256": sha256(bytes)})); + transaction.stage(name, target, bytes)?; + } + } + if let Some(path) = output_path { + let target = policy.resolve(path)?; + let display = target.display_path().display().to_string(); + let bytes = serde_json::to_vec_pretty(&document) + .map_err(|error| invalid(format!("Cannot serialize the plan: {error}")))?; + if bytes.len() > MAX_PLAN_BYTES { + return Err(invalid( + "The bound plan exceeds the ingest size limit; split the plan.", + )); + } + transaction.stage("plan", target, &bytes)?; + written = Some(display); + } + transaction.commit()?; + let mut result = document; + result["writtenTo"] = json!(written); + result["template"] = json!({"files": template_files, "executed": false, + "workingDirectory": template_path, + "commands": [ ["npm", "ci"], ["npx", "playwright", "install", "chromium"], + ["node", "run.mjs", "--plan", written.as_deref().unwrap_or(""), "--out", "report"] ], + "note": include_str!("../text/runtime_audit-write_plan-2d2cc624.md")}); + result["nextAction"] = json!({ + "where": "the consumer project", + "how": include_str!("../text/runtime_audit-write_plan-c3ca4e3c.md"), + }); + Ok(result) +} + +fn read_json( + policy: &OutputPolicy, + path: &str, + limit: usize, +) -> Result<(Vec, Value, std::path::PathBuf), DevupError> { + let target = policy.resolve(path)?; + let bytes = target.read_bounded(limit)?; + let value = serde_json::from_slice(&bytes) + .map_err(|error| invalid(format!("{path} is not valid JSON: {error}")))?; + Ok((bytes, value, target.display_path().to_path_buf())) +} + +fn rejected(reason: &str, details: Value) -> Value { + json!({ + "verdict": "rejected", + "reason": reason, + "details": details, + "note": "Nothing in this report was counted. A report that cannot be tied to the plan it claims to run is not evidence of that plan.", + }) +} + +#[cfg(test)] +fn ingest(policy: &OutputPolicy, plan_path: &str, report_path: &str) -> Result { + ingest_references(policy, plan_path, report_path, None) +} + +pub(crate) async fn ingest_with_artifacts( + policy: &OutputPolicy, + plan_path: &str, + report_path: &str, + artifacts: &ArtifactStore, +) -> Result { + let read_policy = policy.clone(); + let read_path = plan_path.to_owned(); + let (_, document, _) = + tokio::task::spawn_blocking(move || read_json(&read_policy, &read_path, MAX_PLAN_BYTES)) + .await + .map_err(|_| invalid("The plan could not be read to the end."))??; + let references = match serde_json::from_value::(document["plan"].clone()) { + Ok(plan) if validate_plan(&plan).is_ok() => dimension_references(&plan, artifacts).await, + Ok(_) => Vec::new(), + Err(_) => Vec::new(), + }; + let policy = policy.clone(); + let plan_path = plan_path.to_owned(); + let report_path = report_path.to_owned(); + tokio::task::spawn_blocking(move || { + ingest_references(&policy, &plan_path, &report_path, Some(&references)) + }) + .await + .map_err(|_| invalid("The report could not be read to the end."))? +} + +fn ingest_references( + policy: &OutputPolicy, + plan_path: &str, + report_path: &str, + acquired: Option<&[Value]>, +) -> Result { + let (plan_bytes, plan_document, _) = read_json(policy, plan_path, MAX_PLAN_BYTES)?; + if plan_document["kind"] != PLAN_KIND || plan_document["schemaVersion"] != SCHEMA_VERSION { + return Ok(rejected( + "plan-kind", + json!({"expected": PLAN_KIND, "schemaVersion": SCHEMA_VERSION}), + )); + } + let Ok(plan) = serde_json::from_value::(plan_document["plan"].clone()) else { + return Ok(rejected("plan-schema", json!({"path": plan_path}))); + }; + if let Err(error) = validate_plan(&plan) { + return Ok(rejected("plan-invalid", json!({"message": error.message}))); + } + let id = plan_id(&plan)?; + if plan_document["planId"] != id.as_str() { + return Ok(rejected( + "plan-edited", + json!({"declared": plan_document["planId"], "recomputed": id}), + )); + } + let (_, report, report_file) = read_json(policy, report_path, MAX_REPORT_BYTES)?; + if report["kind"] != REPORT_KIND || report["schemaVersion"] != SCHEMA_VERSION { + return Ok(rejected( + "report-kind", + json!({"expected": REPORT_KIND, "schemaVersion": SCHEMA_VERSION}), + )); + } + if report["planId"] != id.as_str() { + return Ok(rejected( + "report-for-another-plan", + json!({"planId": id, "reportPlanId": report["planId"]}), + )); + } + let plan_file_hash = sha256(&plan_bytes); + let plan_file_matches = report["planSha256"] + .as_str() + .map(|declared| declared == plan_file_hash); + if plan_file_matches == Some(false) { + return Ok(rejected( + "plan-file-differs", + json!({"planSha256": plan_file_hash, "reportPlanSha256": report["planSha256"]}), + )); + } + let report_dir = report_file + .parent() + .map(Path::to_path_buf) + .unwrap_or_default(); + + let mut counts = BTreeMap::<&str, usize>::new(); + let mut failures = Vec::new(); + let mut runs = Vec::new(); + let mut screenshot_budget = MAX_SCREENSHOTS; + let mut integrity_problems = Vec::new(); + let mut environment_invalid = false; + let mut visual_comparisons = Vec::new(); + let mut dimension_rows = Vec::new(); + let mut screen_rows = Vec::new(); + let mut findings = Vec::new(); + let mut missing_nodes = Vec::new(); + let mut links = BTreeMap::>::new(); + for viewport in &plan.viewports { + let matching_runs = report["runs"] + .as_array() + .into_iter() + .flatten() + .filter(|run| { + run["viewport"]["width"] == viewport.width + && run["viewport"]["height"] == viewport.height + && run["viewport"]["deviceScaleFactor"].as_f64() + == Some(viewport.device_scale_factor) + }) + .count(); + if matching_runs > 1 { + return Ok(rejected( + "ambiguous-report-items", + json!({"viewport": viewport}), + )); + } + let run = report["runs"].as_array().into_iter().flatten().find(|run| { + run["viewport"]["width"] == viewport.width + && run["viewport"]["height"] == viewport.height + && run["viewport"]["deviceScaleFactor"] + .as_f64() + .is_some_and(|scale| (scale - viewport.device_scale_factor).abs() < 1e-9) + }); + let environment_errors = run + .map(|run| { + run["environmentErrors"] + .as_array() + .cloned() + .unwrap_or_default() + }) + .unwrap_or_default(); + if run.is_none() || !environment_errors.is_empty() { + environment_invalid = true; + } + let mut screenshots_verified = 0; + let mut screenshot_issues = Vec::new(); + let mut drawn_width_shots = Vec::new(); + let mut captures = Vec::new(); + for screen in &plan.screens { + if run.is_some_and(|run| { + run["screens"] + .as_array() + .into_iter() + .flatten() + .filter(|s| s["id"] == screen.id) + .count() + > 1 + }) { + return Ok(rejected( + "ambiguous-report-items", + json!({"screen": screen.id}), + )); + } + let reported = run.and_then(|run| { + run["screens"] + .as_array()? + .iter() + .find(|candidate| candidate["id"] == screen.id.as_str()) + }); + if let (Some(route), Some(found)) = ( + screen.route_path.as_ref(), + reported.and_then(|s| s["links"].as_array()), + ) { + links.entry(route.clone()).or_default().extend( + found + .iter() + .filter_map(Value::as_str) + .filter(|href| href.starts_with('/') && href.len() <= MAX_TEXT) + .take(200) + .map(str::to_owned), + ); + } + let mut outcomes = Vec::new(); + let mut judged = Vec::new(); + for (index, check) in screen.checks.iter().enumerate() { + if !applies(check, viewport.width) { + continue; + } + if reported.is_some_and(|s| { + s["checks"] + .as_array() + .into_iter() + .flatten() + .filter(|item| item["index"] == index) + .count() + > 1 + }) { + return Ok(rejected( + "ambiguous-report-items", + json!({"screen": screen.id, "check": index}), + )); + } + let item = reported.and_then(|screen| { + screen["checks"] + .as_array()? + .iter() + .find(|item| item["index"] == index) + }); + let dimension = (check.kind == "dimensions").then(|| { + dimension_row( + screen, + index, + check, + viewport, + item, + &plan_document, + acquired, + plan_file_matches == Some(true), + run.is_some() + && environment_errors.is_empty() + && reported.is_some_and(|s| s["status"] == "completed"), + ) + }); + let status = match dimension + .as_ref() + .map(|row| &row["status"]) + .or_else(|| item.map(|item| &item["status"])) + .and_then(Value::as_str) + { + Some("pass") => "pass", + Some("fail") => "fail", + Some("inconclusive") => "inconclusive", + Some("skipped") => "skipped", + Some("not-run") => "not-run", + Some(_) => "inconclusive", + None => "not-run", + }; + *counts.entry(status).or_default() += 1; + if status == "fail" && failures.len() < MAX_LISTED { + failures.push(json!({ + "viewport": {"width": viewport.width, "height": viewport.height}, + "screen": screen.id, + "check": index, + "kind": check.kind, + "label": check.label, + "reason": dimension.as_ref().map(|row| &row["reason"]).or_else(|| item.map(|item| &item["reason"])), + "measured": dimension.as_ref().map(|row| &row["domCssPx"]).or_else(|| item.map(|item| &item["measured"])), + })); + } + outcomes.push(( + status, + check.kind.as_str(), + dimension + .as_ref() + .map(|row| &row["reason"]) + .or_else(|| item.map(|item| &item["reason"])) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + )); + let reason = dimension + .as_ref() + .map(|row| &row["reason"]) + .or_else(|| item.map(|item| &item["reason"])) + .and_then(Value::as_str) + .unwrap_or_default(); + judged.push((status, check)); + if status == "fail" { + findings.push(finding( + screen, + viewport, + Some((index, check)), + failure_class(&check.kind, reason), + reason, + dimension + .as_ref() + .map(|row| &row["domCssPx"]) + .or_else(|| item.map(|item| &item["measured"])) + .unwrap_or(&Value::Null), + reported.and_then(|s| s["screenshot"]["path"].as_str()), + )); + } + if status == "inconclusive" + && reason.contains("matched no element") + && let Some(node_id) = &check.node_id + { + missing_nodes.push(json!({"screen": screen.id, "width": viewport.width, + "nodeId": node_id, "check": index, "kind": check.kind})); + } + if check.kind == "capture" + && status == "pass" + && let (Some(reference), Some(shot)) = ( + &check.reference, + item.map(|item| &item["screenshot"]) + .filter(|shot| shot.is_object()), + ) + { + match verify_capture(policy, &report_dir, shot, None) { + Ok(verified) => captures.push((screen, index, check, reference, verified)), + Err(Integrity::Tampered(issue)) => { + integrity_problems.push(json!({"screen": screen.id, "issue": issue})); + } + Err(Integrity::Unverified(issue)) => { + screenshot_issues.push(json!({"screen": screen.id, "issue": issue})); + } + } + } + if let Some(row) = dimension { + dimension_rows.push(row); + } + } + let mut row = screen_row( + screen, + viewport, + run.is_some(), + reported, + &environment_errors, + &outcomes, + ); + row["columns"] = columns(screen, viewport, &judged, &row); + // A screen that never rendered fails as a runtime error, not as + // the checks that could not measure it. + if row["status"] == "fail" && !outcomes.iter().any(|(status, _, _)| *status == "fail") { + let reason = array_text(&row["reasons"]); + findings.push(finding( + screen, + viewport, + None, + "runtime-error", + &reason, + &Value::Null, + None, + )); + } + screen_rows.push(row); + let Some(shot) = reported + .map(|screen| &screen["screenshot"]) + .filter(|shot| shot.is_object()) + else { + screenshot_issues.push( + json!({"screen": screen.id, "issue": "not-verified: screenshot missing"}), + ); + continue; + }; + if screenshot_budget == 0 { + screenshot_issues.push( + json!({"screen": screen.id, "issue": "not-verified: screenshot limit reached"}), + ); + continue; + } + screenshot_budget -= 1; + match verify_screenshot(policy, &report_dir, shot, viewport) { + Ok(verified) => { + screenshots_verified += 1; + if let Some(figma) = screen + .figma + .as_ref() + .filter(|figma| figma.drawn_widths.contains(&viewport.width)) + { + drawn_width_shots.push((screen, figma.node_id.as_str(), verified)); + } + } + Err(Integrity::Tampered(issue)) => { + integrity_problems.push(json!({"screen": screen.id, "issue": issue})); + } + Err(Integrity::Unverified(issue)) => { + screenshot_issues.push(json!({"screen": screen.id, "issue": issue})); + } + } + } + if !screenshot_issues.is_empty() { + environment_invalid = true; + } + let run_valid = + run.is_some() && environment_errors.is_empty() && screenshot_issues.is_empty(); + for (screen, node_id, shot) in &drawn_width_shots { + visual_comparisons.push(visual_comparison( + screen, node_id, viewport, shot, &report, run_valid, + )); + } + for (screen, index, check, reference, shot) in &captures { + visual_comparisons.push(capture_comparison( + screen, *index, check, reference, viewport, shot, + )); + } + runs.push(json!({ + "viewport": viewport, + "reported": run.is_some(), + "environmentErrors": environment_errors, + "environmentValid": run.is_some() && environment_errors.is_empty(), + "screenshots": {"verified": screenshots_verified, "issues": screenshot_issues}, + })); + } + if !integrity_problems.is_empty() { + return Ok(rejected( + "artifact-changed", + json!({"artifacts": integrity_problems}), + )); + } + let planned = planned_items(&plan); + let count = |status: &str| counts.get(status).copied().unwrap_or(0); + let verdict = if count("fail") > 0 && !environment_invalid { + "fail" + } else if count("pass") == planned && !environment_invalid && report["status"] == "completed" { + "pass" + } else { + "inconclusive" + }; + let declared = &report["summary"]; + let declared_matches = declared.is_object().then(|| { + ["pass", "fail", "inconclusive", "skipped"] + .iter() + .all(|status| declared[*status].as_u64().unwrap_or(0) == count(status) as u64) + }); + let figma = plan + .screens + .iter() + .filter_map(|screen| { + let figma = screen.figma.as_ref()?; + let (visual, layout_only): (Vec<_>, Vec<_>) = plan + .viewports + .iter() + .map(|viewport| viewport.width) + .partition(|width| figma.drawn_widths.contains(width)); + Some(json!({"screen": screen.id, "nodeId": figma.node_id, + "visualOriginalAt": visual, "layoutChecksOnlyAt": layout_only, + "note": include_str!("../text/runtime_audit-ingest_references-72b6591d.md")})) + }) + .collect::>(); + Ok(json!({ + "verdict": verdict, + "planId": id, + "integrity": {"planIdRecomputed": true, "planFileHashMatches": plan_file_matches}, + "coverage": {"plannedCheckItems": planned, "notRun": count("not-run"), + "reportStatus": report["status"]}, + "summary": {"pass": count("pass"), "fail": count("fail"), + "inconclusive": count("inconclusive"), "skipped": count("skipped"), + "notRun": count("not-run")}, + "declaredSummaryMatches": declared_matches, + "failures": failures, + "runs": runs, + "figmaReferences": figma, + "visualComparisons": visual_comparisons, + "findings": findings, + "missingNodes": missing_nodes, + "links": links, + "screenTable": screen_rows, + "widthTable": plan.viewports.iter().map(|viewport| json!({ + "width": viewport.width, "height": viewport.height, + "deviceScaleFactor": viewport.device_scale_factor, + "dimensions": dimension_rows.iter().filter(|row| row["viewport"] == json!(viewport)).collect::>() + })).collect::>(), + "toleranceExceedances": dimension_rows.iter().filter(|row| row["status"] == "fail").collect::>(), + "dimensionCoverage": {"plannedRows": dimension_rows.len(), "returnedRows": dimension_rows.len(), + "omittedRows": 0, "limit": MAX_DIMENSION_ROWS}, + "executor": report["executor"], + "environment": report["environment"], + "evidence": { + "independentlyVerified": [ + "the plan file's content id and SHA-256", + "the report's binding to that plan", + "every count above, recomputed from the reported items", + "each screenshot's SHA-256, byte length, PNG decoding and width", + "dimension arithmetic and per-check tolerance filtering from reported CSS geometry; available retained Figma reference dimensions are rechecked", + ], + "executorDeclared": [ + "element geometry, locator counts, visibility and non-dimension check outcomes", + "the same-origin links found on each page", + "page, console and network errors", + "timing, browser and environment versions", + ], + "notJudged": "Whether the screens look right to a person. Layout checks measure the relations the plan names and nothing else.", + }, + })) +} + +/// One screen at one width, folded from its checks, its own record and the +/// environment errors the run could pin on it. This is the row a person reads +/// to learn whether `/about` works at 390; `failures` and `runs` say the same +/// things but spread across three lists. +/// +/// A page error is attributed only when its message names this screen's route +/// (`navigation-failed` does). An uncaught script error carries no route, so +/// it leaves every screen at that width inconclusive rather than failing the +/// one that may not have thrown it. +fn screen_row( + screen: &ScreenInput, + viewport: &ViewportInput, + run_reported: bool, + reported: Option<&Value>, + environment_errors: &[Value], + outcomes: &[(&str, &str, String)], +) -> Value { + let message = |error: &Value| error["message"].as_str().unwrap_or_default().to_owned(); + let own_navigation = environment_errors + .iter() + .filter(|error| { + error["kind"] == "navigation-failed" + && screen.route_path.as_deref().is_some_and(|route| { + error["message"] + .as_str() + .is_some_and(|text| text.starts_with(&format!("{route}:"))) + }) + }) + .map(|error| format!("page error: {}", message(error))) + .collect::>(); + let unattributed = environment_errors + .iter() + .filter(|error| error["kind"] == "page-error") + .map(|error| format!("page error during this width's run: {}", message(error))) + .collect::>(); + let failed_checks = outcomes + .iter() + .filter(|(status, _, _)| *status == "fail") + .map(|(_, kind, reason)| match *kind { + "no-horizontal-overflow" => format!("horizontal overflow: {reason}"), + "view-transition" => format!("view transition: {reason}"), + kind => format!("{kind}: {reason}"), + }) + .collect::>(); + let record_status = reported.and_then(|screen| screen["status"].as_str()); + let record_reason = reported + .and_then(|screen| screen["reason"].as_str()) + .unwrap_or_default(); + let (status, reasons) = if !run_reported || reported.is_none() { + ( + "not-run", + vec!["no report item for this screen at this width".to_owned()], + ) + } else if !own_navigation.is_empty() { + ("fail", own_navigation) + } else if record_status == Some("failed") { + ("fail", vec![format!("screen not reached: {record_reason}")]) + } else if !failed_checks.is_empty() { + ("fail", failed_checks) + } else if record_status == Some("skipped") { + ("not-run", vec![format!("skipped: {record_reason}")]) + } else if !unattributed.is_empty() { + ("inconclusive", unattributed) + } else if outcomes.is_empty() { + ( + "inconclusive", + vec!["reached, but the plan names no checks for this screen".to_owned()], + ) + } else if outcomes.iter().all(|(status, _, _)| *status == "pass") { + ("pass", Vec::new()) + } else { + ( + "inconclusive", + outcomes + .iter() + .filter(|(status, _, _)| *status != "pass") + .map(|(status, kind, reason)| format!("{kind} {status}: {reason}")) + .collect(), + ) + }; + json!({ + "screen": screen.id, + "routePath": screen.route_path, + "width": viewport.width, + "status": status, + "reasons": reasons, + "screenshot": reported.and_then(|screen| screen["screenshot"]["path"].as_str()), + }) +} + +#[allow(clippy::too_many_arguments)] +fn dimension_row( + screen: &ScreenInput, + index: usize, + check: &CheckInput, + viewport: &ViewportInput, + item: Option<&Value>, + document: &Value, + acquired: Option<&[Value]>, + hash_bound: bool, + environment_valid: bool, +) -> Value { + let matches = |reference: &&Value| { + reference["screen"] == screen.id + && reference["check"] == index + && reference["viewportWidth"] == viewport.width + }; + let reference = document["dimensionReferences"] + .as_array() + .into_iter() + .flatten() + .find(matches); + let verified = acquired + .and_then(|refs| refs.iter().find(matches)) + .filter(|fresh| { + fresh["status"] == "acquired" && reference.is_some_and(|saved| *fresh == saved) + }); + let measured = item.map(|item| &item["measured"]); + // Report files are untrusted: expose only bounded identity and numeric + // geometry, rather than echoing arbitrary objects attached to a check. + let mapping = check + .figma_dimensions + .iter() + .find(|r| r.viewport_width == viewport.width); + let reference_summary = mapping.map(|mapping| { + json!({ + "artifactId": mapping.artifact_id, "nodeId": mapping.node_id, + "status": if verified.is_some() { "acquired" } else { "unverified" }, + "width": reference.and_then(|r| r["width"].as_f64()), + "height": reference.and_then(|r| r["height"].as_f64()), + "units": "figma-logical-px", + "artifactContentHash": verified.and_then(|r| r["artifactContentHash"].as_str()), + "staleness": "retained snapshot; not compared with live Figma", + }) + }); + let dom = measured.map(|m| { + json!({"x": m["subject"]["x"].as_f64(), "y": m["subject"]["y"].as_f64(), + "width": m["subject"]["width"].as_f64(), "height": m["subject"]["height"].as_f64()}) + }); + let mut row = json!({"screen": screen.id, "check": index, "label": check.label, + "viewport": viewport, "subject": check.subject, "mapping": "caller-declared", + "reference": reference_summary, "referenceVerifiedAgainstRetainedAcquisition": verified.is_some(), + "domCssPx": dom, "domEvidence": "executor-declared", + "tolerancePx": check.tolerance_px, "deltaPx": null, "status": "inconclusive", + "reason": "Design reference or reported CSS geometry is unverified."}); + let declared_status = item.and_then(|item| item["status"].as_str()); + if declared_status.is_none() { + row["status"] = json!("not-run"); + row["reason"] = json!("No measurement was reported for this planned check."); + } else if declared_status == Some("skipped") { + row["status"] = json!("skipped"); + row["reason"] = json!("The executor skipped this measurement."); + } else if reference.is_none() { + row["reason"] = json!( + "No acquired Figma dimensions were explicitly mapped at this width; no interpolation was applied." + ); + } else if verified.is_none() { + row["reason"] = json!( + "The saved reference could not be matched to the retained acquisition; acquire and plan again." + ); + } else if !hash_bound || !environment_valid { + row["reason"] = + json!("The plan file hash, screen completion or runtime environment is unverified."); + } else if matches!(declared_status, Some("pass" | "fail")) { + let m = measured.unwrap(); + if m["units"] == "css-px" + && m["subjectCount"] == 1 + && m["subjectVisible"] == true + && let (Some(width), Some(height)) = ( + m["subject"]["width"].as_f64(), + m["subject"]["height"].as_f64(), + ) + && width.is_finite() + && height.is_finite() + && width >= 0.0 + && height >= 0.0 + { + let expected = verified.unwrap(); + let dw = width - expected["width"].as_f64().unwrap(); + let dh = height - expected["height"].as_f64().unwrap(); + let tolerance = check.tolerance_px.unwrap(); + row["deltaPx"] = json!({"width": dw, "height": dh}); + row["status"] = json!(if dw.abs() <= tolerance && dh.abs() <= tolerance { + "pass" + } else { + "fail" + }); + row["reason"] = json!( + "Compared acquired logical dimensions with declared DOM CSS pixels without DPR or screenshot scaling." + ); + row["executorOutcomeMatches"] = json!(row["status"] == item.unwrap()["status"]); + } + } + row +} + +enum Integrity { + Tampered(String), + Unverified(String), +} + +struct VerifiedShot { + path: String, + sha256: String, + width: u32, + height: u32, +} + +fn verify_screenshot( + policy: &OutputPolicy, + report_dir: &Path, + shot: &Value, + viewport: &ViewportInput, +) -> Result { + verify_capture(policy, report_dir, shot, Some(viewport)) +} + +/// An element capture has the element's size, not the viewport's; only its +/// bytes and PNG decoding are checked. +fn verify_capture( + policy: &OutputPolicy, + report_dir: &Path, + shot: &Value, + viewport: Option<&ViewportInput>, +) -> Result { + let path = shot["path"] + .as_str() + .ok_or_else(|| Integrity::Unverified("no path".into()))?; + let relative = Path::new(path); + if relative.is_absolute() + || relative + .components() + .any(|part| !matches!(part, std::path::Component::Normal(_))) + { + return Err(Integrity::Unverified(format!( + "{path} must be relative to the report directory, without .. or a root" + ))); + } + let joined = report_dir.join(relative); + let target = policy + .resolve(&joined.to_string_lossy()) + .map_err(|error| Integrity::Unverified(format!("{path}: {}", error.message)))?; + let bytes = target + .read_bounded(MAX_SCREENSHOT_BYTES) + .map_err(|error| Integrity::Unverified(format!("{path}: {}", error.message)))?; + let digest = sha256(&bytes); + if shot["sha256"].as_str() != Some(digest.as_str()) + || shot["bytes"].as_u64() != Some(bytes.len() as u64) + { + return Err(Integrity::Tampered(format!( + "{path}: its SHA-256 or length differs from the report" + ))); + } + let [width, height] = devup_mcp_visual::png_dimensions(&bytes).map_err(|_| { + Integrity::Unverified(format!( + "{path}: not a decodable PNG within 8192px per side" + )) + })?; + if let Some(viewport) = viewport { + let expected = (f64::from(viewport.width) * viewport.device_scale_factor).round() as u32; + let minimum = (f64::from(viewport.height) * viewport.device_scale_factor).round() as u32; + if width != expected || height < minimum { + return Err(Integrity::Unverified(format!( + "{path}: {width}x{height} is not {expected} wide and at least {minimum} tall; the capture does not show this viewport" + ))); + } + } + Ok(VerifiedShot { + path: target.display_path().display().to_string(), + sha256: digest, + width, + height, + }) +} + +/// The one place a finding's identity is made, so a later run of the same +/// check at the same width finds its earlier attempts. +fn comparison_id(screen: &ScreenInput, kind: &str, subject: &str, width: u32) -> String { + format!("{}|{kind}|{subject}|{width}", screen.id) +} + +fn diff_path(actual: &str, id: &str) -> String { + let directory = Path::new(actual).parent().unwrap_or(Path::new("")); + let name = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c + } else { + '_' + } + }) + .collect::(); + directory + .join("diff") + .join(format!("{name}.png")) + .display() + .to_string() +} + +fn array_text(value: &Value) -> String { + value + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>() + .join("; ") +} + +/// What kind of problem a failed check is, so a fix starts in the right +/// place: a page that does not render, content wider than its box, a box in +/// the wrong place or of the wrong size, a state or a transition missing. +pub(crate) fn failure_class(kind: &str, reason: &str) -> &'static str { + match kind { + "page-health" => "runtime-error", + "no-horizontal-overflow" | "no-clipped-text" => "overflow", + "bounds" if reason.starts_with("position") => "layout-position", + "bounds" | "dimensions" | "visible" => "layout-size", + "contained" | "aligned" | "spans" => "layout-position", + "interaction" => "interaction", + "view-transition" => "view-transition", + _ => "layout-position", + } +} + +/// A concrete first step for each class. The measured evidence names the +/// element where the executor reported one. +pub(crate) fn fix_hint(class: &str, kind: &str, measured: &Value) -> String { + match class { + "runtime-error" => include_str!("../text/runtime_audit-fix_hint-3a12f919.md").to_owned(), + "overflow" if kind == "no-clipped-text" => { + include_str!("../text/runtime_audit-fix_hint-b3941912.md").to_owned() + } + "overflow" => format!( + include_str!("../text/runtime_audit-fix_hint-975acb57.md"), + measured["widest"] + .as_str() + .map(|widest| format!(" (widest: {widest})")) + .unwrap_or_default() + ), + "layout-position" | "layout-size" if kind == "bounds" => { + include_str!("../text/runtime_audit-fix_hint-1f874fed.md").to_owned() + } + "layout-position" => include_str!("../text/runtime_audit-fix_hint-516a692f.md").to_owned(), + "layout-size" => include_str!("../text/runtime_audit-fix_hint-1ca5b7eb.md").to_owned(), + "code-drift" => include_str!("../text/runtime_audit-fix_hint-60ba4f27.md").to_owned(), + "converter-bug" => include_str!("../text/runtime_audit-fix_hint-0e677049.md").to_owned(), + "asset-mismatch" => include_str!("../text/runtime_audit-fix_hint-650b18d4.md").to_owned(), + "section-visual" => include_str!("../text/runtime_audit-fix_hint-a13b2885.md").to_owned(), + "page-visual" => include_str!("../text/runtime_audit-fix_hint-5960cebc.md").to_owned(), + "interaction" => include_str!("../text/runtime_audit-fix_hint-e19dc0a1.md").to_owned(), + "view-transition" => include_str!("../text/runtime_audit-fix_hint-7a003bba.md").to_owned(), + _ => "Re-run the check and read its reason.".to_owned(), + } +} + +fn finding( + screen: &ScreenInput, + viewport: &ViewportInput, + check: Option<(usize, &CheckInput)>, + class: &str, + reason: &str, + measured: &Value, + screenshot: Option<&str>, +) -> Value { + let kind = check.map_or("screen", |(_, check)| check.kind.as_str()); + let subject = check + .and_then(|(index, check)| { + check + .node_id + .clone() + .or_else(|| check.label.clone()) + .or_else(|| Some(format!("#{index}"))) + }) + .unwrap_or_else(|| "page".to_owned()); + json!({ + "id": comparison_id(screen, kind, &subject, viewport.width), + "class": class, + "screen": screen.id, + "routePath": screen.route_path, + "width": viewport.width, + "check": check.map(|(index, _)| index), + "kind": kind, + "label": check.and_then(|(_, check)| check.label.clone()), + "nodeId": check.and_then(|(_, check)| check.node_id.clone()), + "codeFile": screen.component_path, + "reason": reason, + "fixHint": fix_hint(class, kind, measured), + "evidence": {"screenshot": screenshot, "measured": measured}, + }) +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index bd230fd4..4ffa1acf 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -26,5705 +26,12 @@ //! from `devup_project_context`, the links from the navigation parser and the //! widths from `devup_runtime_audit`. -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; - -use devup_mcp_figma::{DevupError, ErrorCode, FigmaTarget}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; - -use super::artifacts::ArtifactStore; -use super::output::{OutputPolicy, OutputTransaction}; - -pub(super) const PLAN_KIND: &str = "devup-screen-plan"; -const SCHEMA_VERSION: u64 = 1; -/// Where the plan lives, relative to the project root. The export gate reads -/// it from the same place, so the two cannot disagree about which plan holds. -pub(super) const PLAN_FILE: &str = ".devup/screen-plan.json"; -const PLAN_MARKDOWN: &str = ".devup/screen-plan.md"; -pub(super) const ASK_INSTRUCTION: &str = "Ask every question now, before implementing, with your question tool and never in prose. If the tool has no per-call cap (OpenCode `question`), ask the flat `questions` list in one call; each conditional question says when it applies. If it has one (Claude Code AskUserQuestion: 4), ask the questionRounds back to back in consecutive calls with no other work in between, skipping a question whose `when` was not met. Then confirm. After confirm never ask the user anything: take the recommended or most conservative option and record it with action assume."; -/// What implementation does instead of asking. Someone may have walked away -/// from the keyboard three hours ago; a question then stops all the work. -pub(super) const IMPLEMENT_INSTRUCTION: &str = "Implement URL by URL without asking the user anything. devup_figma_export with projectRoot takes each page's route from this plan. Anything new: take the recommended or most conservative option, record it with devup_screen_plan action assume, and keep going. Then run devup_runtime_audit at every width and devup_screen_plan action status."; -/// The optional project file whose entries count as evidence. -pub(super) const CONFIG_FILE: &str = ".devup/screen-plan.config.json"; -/// The question tools this is shaped for cut headers short. OpenCode's has no -/// hard limit; 30 characters stays readable in all of them. -pub(super) const HEADER_LIMIT: usize = 30; -/// Claude Code's AskUserQuestion takes two to four options and adds its own -/// "Other" for a custom answer. Four is the most every runtime accepts. -const MAX_OPTIONS: usize = 4; -/// AskUserQuestion also takes at most four questions per call. -pub(super) const MAX_QUESTIONS_PER_ROUND: usize = 4; -const RECOMMENDED: &str = " (Recommended)"; -const MAX_SCREENS: usize = 100; -const MAX_PLAN_BYTES: usize = 2 * 1024 * 1024; -/// The widths `devup_runtime_audit` plans by default. Without a report they -/// are what `status` lists as not-run, because the failures this tool exists -/// to surface were at widths nobody drew. -const DEFAULT_WIDTHS: [u32; 4] = [1440, 1100, 858, 390]; -const MAX_SCANNED_SOURCES: usize = 400; -const MAX_SOURCE_BYTES: u64 = 512 * 1024; -const SKIP: &str = "Skip this screen"; -const NOT_A_PAGE: &str = "Not a page (part of another screen)"; - -const MODAL_WORDS: &[&str] = &[ - "modal", - "popup", - "pop-up", - "pop up", - "dialog", - "overlay", - "bottom sheet", - "bottomsheet", - "drawer", - "toast", - "팝업", - "모달", - "레이어", -]; -const DEVICE_WORDS: &[&str] = &[ - "pc", "mobile", "mo", "mob", "m", "tablet", "tab", "desktop", "web", "dt", -]; -/// Device words that name a whole section rather than a frame suffix. -const SECTION_DEVICE_WORDS: &[&str] = &["phone", "모바일", "태블릿", "데스크톱", "데스크탑", "웹"]; -const ROOT_WORDS: &[&str] = &["home", "main", "index", "landing", "메인", "홈"]; - -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct ScreenPlanInput { - /// draft: map screens to routes and return every open question. - /// confirm: apply all answers at once. status: URL x width report, the - /// audit findings, the work queue and the final report. assume: after - /// confirm, record a decision implementation made without asking, or - /// take a pending decision. auditPlan: write the runtime-audit plan for - /// every confirmed URL. defer: record a decision the work needs as - /// pending, with its recommended option, and keep working. - #[schemars(extend("enum" = ["draft", "confirm", "status", "assume", "auditPlan", "defer"]))] - pub action: String, - /// The Next App Router project. The plan is written to - /// /.devup/screen-plan.json and .md, under an allowed root. - pub project_root: Option, - /// draft: Figma Section or frame link. Omitted while one Devup Bridge - /// plugin is attached, the Figma selection is the anchor. - #[serde(default)] - pub url: Option, - /// draft: screens already listed by devup_figma_explore or search. When - /// given, Figma is not read again. - #[serde(default)] - pub screens: Vec, - /// draft: explicit Figma node id -> route bindings. The only way to name a - /// route that no page or nav link already shows. - #[serde(default)] - pub navigation_targets: BTreeMap, - /// draft: free text recorded in the plan, never interpreted. - #[serde(default)] - pub notes: Option, - /// confirm/status: the planId draft returned. - #[serde(default)] - pub plan_id: Option, - /// confirm: every question id -> the chosen option label (or labels when - /// multiple), or custom text. - #[serde(default)] - pub answers: BTreeMap, - /// status: the runtime audit plan JSON, together with auditReportPath. - #[serde(default)] - pub audit_plan_path: Option, - /// status: the report.json the runtime audit executor wrote. - #[serde(default)] - pub audit_report_path: Option, - /// draft: re-read Figma instead of reusing a cached exploration. - #[serde(default)] - pub refresh: bool, - /// assume: what was decided during implementation without asking. - #[serde(default)] - pub assumptions: Vec, - /// assume: the outcome of the dependency step. Each pin is also recorded - /// as an assumption. - #[serde(default)] - pub dependency_update: Option, - /// draft: how many questions one question-tool call takes; default 4 - /// (Claude Code AskUserQuestion). A tool with no cap asks the flat - /// `questions` list in one call instead. - #[serde(default)] - #[schemars(range(min = 1, max = 50))] - pub max_questions_per_round: Option, - /// draft: Figma nodes that may be the favicon or share image (a logo, a - /// frame named og or share), beside the screens. - #[serde(default)] - pub assets: Vec, - /// draft: the agent's own questions - anything it would otherwise guess - /// during the job (form target, contact details, external links, legal - /// pages, data source, language) - asked in the same up-front rounds. - #[serde(default)] - pub extra_questions: Vec, - /// auditPlan: where the app is served; default http://localhost:3000. - #[serde(default)] - pub base_url: Option, - /// auditPlan: app name -> base URL, when the plan spans several apps. - #[serde(default)] - pub base_urls: BTreeMap, - /// auditPlan: every devup_figma_export artifactId of this plan's frames - /// (the TSX export, the referencePng export, the native renders). They - /// are searched for each frame's geometry and references. - #[serde(default)] - pub artifacts: Vec, - /// auditPlan: only these URLs; default every confirmed URL. - #[serde(default)] - pub urls: Vec, - /// status: the devup_visual_compare result for each visualComparisons - /// call the last status returned. - #[serde(default)] - pub visual_results: Vec, - /// status, with auditPlanPath and no report: the runner could not be - /// installed or started, and why. That URL's audit is inconclusive; the - /// work goes on. - #[serde(default)] - pub tooling_unavailable: Option, - /// defer: the decision the work needs. - #[serde(default)] - pub decision: Option, -} - -/// A decision the work hit after confirm. It never stops the work: it is -/// recorded with the option the agent recommends, the rest goes on, and it -/// is taken with assume at the end - except a destructive one, which is -/// left unbuilt. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct PendingDecisionInput { - /// A short id, e.g. contact-form-target. - pub id: String, - /// What it is about: a URL, a frame id, a file. - pub about: String, - pub question: String, - #[serde(default)] - pub options: Vec, - /// The option to take when the work is otherwise done. - pub recommended: String, - /// It deletes or overwrites something, or reaches outside the project. - #[serde(default)] - pub destructive: bool, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct ExtraQuestionInput { - /// Becomes `extra:`. - pub id: String, - /// 30 characters or fewer. - pub header: String, - pub question: String, - /// Two to four, the recommended one first. - pub options: Vec, - #[serde(default)] - pub multiple: bool, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct ExtraOptionInput { - pub label: String, - pub description: String, -} - -/// The agent's own questions in the plan's shape, or why one cannot be asked -/// as given. The recommended option is the first, as everywhere else. -fn extra_questions(extra: &[ExtraQuestionInput]) -> Result, DevupError> { - let mut seen = BTreeSet::new(); - extra - .iter() - .map(|asked| { - let id = format!("extra:{}", asked.id.trim().trim_start_matches("extra:")); - if id == "extra:" || !seen.insert(id.clone()) { - return Err(invalid(format!( - "extraQuestions ids must be non-empty and unique; {:?} is not.", - asked.id - ))); - } - if asked.header.trim().is_empty() || asked.header.chars().count() > HEADER_LIMIT { - return Err(invalid(format!( - "{id}: header must be 1 to {HEADER_LIMIT} characters." - ))); - } - if !(2..=MAX_OPTIONS).contains(&asked.options.len()) { - return Err(invalid(format!("{id}: give 2 to {MAX_OPTIONS} options."))); - } - let choices = asked - .options - .iter() - .map(|option| { - let label = strip_recommended(&option.label).to_owned(); - choice( - label.clone(), - option.description.clone(), - json!({"extra": label}), - ) - }) - .collect::>(); - let mut built = question( - id, - asked.header.clone(), - asked.question.clone(), - choices, - "extra", - ); - built["multiple"] = json!(asked.multiple); - Ok(built) - }) - .collect() -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct AssumptionInput { - /// What it is about, e.g. a frame id or a URL. - pub about: String, - /// The option taken: the plan's recommended one or the most conservative. - pub decision: String, - #[serde(default)] - pub reason: Option, -} - -/// The outcome of the dependency step, recorded once it ran. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct DependencyUpdateInput { - /// How many packages the update bumped. - pub bumped: usize, - /// Packages held back because no code fix exists, with the reason. - #[serde(default)] - pub pins: Vec, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct PinInput { - pub package: String, - pub version: String, - pub reason: String, -} - -/// `.devup/screen-plan.config.json`: how this project's designers name -/// things. Each entry present counts as evidence and replaces the question -/// it would otherwise take. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(super) struct PlanConfig { - /// Replaces the built-in modal words (modal, popup, dialog, ...). - #[serde(default)] - pub modal_keywords: Option>, - /// Layer name -> route. - #[serde(default)] - pub route_aliases: BTreeMap, - /// responsive | separate | ask (the default). Applies to several widths - /// in one section; frames in different sections are always asked. - #[serde(default)] - pub width_grouping: Option, - /// Replaces the built-in words that mark pages, sections and frames as - /// not for implementation (trash, archive, draft, copy, ...). - #[serde(default)] - pub exclude_keywords: Option>, - /// Names containing one of these are never excluded by keyword. - #[serde(default)] - pub include_keywords: Option>, - /// Pre-answers for the site questions: the favicon and share image (a - /// path, or figma:), the site name, its production URL, and - /// motion beyond hover (none | reveal). - #[serde(default)] - pub favicon: Option, - #[serde(default)] - pub og_image: Option, - #[serde(default)] - pub site_name: Option, - #[serde(default)] - pub site_url: Option, - #[serde(default)] - pub motion: Option, - /// false for a project that forbids the dependency update step. - #[serde(default)] - pub update_dependencies: Option, - /// vinext | next: pre-answers the migrate-to-vinext question. - #[serde(default)] - pub framework: Option, - /// false opts the project out of view transitions. - #[serde(default)] - pub view_transitions: Option, - /// false stops exports into this project from writing - /// data-figma-node-id on the page, section and asset elements. - #[serde(default)] - pub figma_node_ids: Option, - /// How many fix-and-rerun rounds one audit finding gets before it is - /// recorded as unresolved with its evidence; default 3. - #[serde(default)] - pub max_fix_attempts: Option, - /// Changed-pixel ratios for the visual comparisons: page, section, - /// asset, and nodes keyed by Figma node id for one asset that needs its - /// own. - #[serde(default)] - pub visual_thresholds: Option, -} - -/// Whether exports into this project mark their elements with -/// data-figma-node-id: on unless the project config says false. An -/// unreadable config leaves it on; draft reports the config error. -pub(super) fn node_ids_enabled(project_root: &str) -> bool { - !matches!( - read_config(project_root), - Ok(Some(PlanConfig { - figma_node_ids: Some(false), - .. - })) - ) -} - -fn read_config(project_root: &str) -> Result, DevupError> { - let path = Path::new(project_root).join(CONFIG_FILE); - let Ok(bytes) = std::fs::read(&path) else { - return Ok(None); - }; - let mut config: PlanConfig = serde_json::from_slice(&bytes).map_err(|error| { - invalid(format!( - "{} is not a valid screen plan config: {error}", - path.display() - )) - })?; - if let Some(mode) = config.width_grouping.as_deref() - && !matches!(mode, "responsive" | "separate" | "ask") - { - return Err(invalid(format!( - "{}: widthGrouping must be responsive, separate or ask, not {mode:?}.", - path.display() - ))); - } - if let Some(framework) = config.framework.as_deref() - && !matches!(framework, "vinext" | "next") - { - return Err(invalid(format!( - "{}: framework must be vinext or next, not {framework:?}.", - path.display() - ))); - } - for (alias, route) in &mut config.route_aliases { - *route = normalize_route(route) - .filter(|route| valid_route(route)) - .ok_or_else(|| { - invalid(format!( - "{}: routeAliases[{alias:?}] = {route:?} is not an app route.", - path.display() - )) - })?; - } - Ok(Some(config)) -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(untagged)] -pub enum AnswerInput { - One(String), - Many(Vec), -} - -impl AnswerInput { - fn values(&self) -> Vec<&str> { - match self { - Self::One(value) => vec![value.as_str()], - Self::Many(values) => values.iter().map(String::as_str).collect(), - } - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct PlanScreenInput { - pub node_id: String, - pub name: String, - pub width: f64, - #[serde(default)] - pub height: Option, - /// The Figma Section (or other parent) the frame sits in, by id or name. - /// devup_figma_explore fills it from the frame's parent. - #[serde(default)] - pub section: Option, - /// The section's layer name, when `section` is an id. - #[serde(default)] - pub section_name: Option, - /// Layer names from the page down to this frame, as devup_figma_explore - /// reports them. Used to keep the plan inside the linked node and out of - /// trash, archive and draft areas. - #[serde(default)] - pub breadcrumb: Vec, - /// Ancestor node ids, nearest first, when known. - #[serde(default)] - pub ancestors: Vec, - /// false when the frame is hidden in Figma. - #[serde(default)] - pub visible: Option, - #[serde(default)] - pub opacity: Option, - /// Shared part names the caller saw in this screen, e.g. Header, Footer. - #[serde(default)] - pub parts: Vec, - /// Links the caller saw in this screen, e.g. prototype destinations. - #[serde(default)] - pub links: Vec, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct PlanLinkInput { - pub label: String, - #[serde(default)] - pub destination_node_id: Option, - #[serde(default)] - pub url: Option, -} - -fn invalid(message: impl Into) -> DevupError { - DevupError::new(ErrorCode::DevupInvalidInput, message, false) -} - -fn text(value: &Value) -> &str { - value.as_str().unwrap_or_default() -} - -fn array(value: &Value) -> &[Value] { - value.as_array().map(Vec::as_slice).unwrap_or_default() -} - -fn sha256(bytes: &[u8]) -> String { - Sha256::digest(bytes) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect() -} - -fn now() -> String { - let seconds = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|elapsed| elapsed.as_secs()) - .unwrap_or_default(); - super::format_epoch_rfc3339(seconds) -} - -/// What the project already says about its routes, gathered once per call. -#[derive(Debug, Clone, Default)] -pub(super) struct ProjectEvidence { - /// (route pattern, page file) from the App Router inventory. - pub routes: Vec<(String, String)>, - /// (link label, href, file:line) for every static link in scanned sources. - pub nav_links: Vec<(String, String, String)>, - /// Components the app's layouts import, so every page already shares them. - pub layout_parts: Vec, - /// `src/app` or `app`, whichever the existing pages use. - pub app_dir: String, - pub inventory_note: Option, - /// Figma node id -> (route, file) for every `data-figma-node-id` already - /// in one route's sources: that page was built from that frame. - pub figma_nodes: BTreeMap, - pub config: PlanConfig, - /// The config file, when one was read. - pub config_file: Option, - /// The project root the relative paths here are under. - pub root: PathBuf, - /// Route pages and the components they render, relative to `root`. - pub sources: Vec, - pub icon_files: Vec, - pub og_files: Vec, - /// (value, where it was found). - pub site_name: Option<(String, String)>, - pub site_url: Option<(String, String)>, - pub package_name: Option, - pub analytics: Vec, - pub not_found: Option, - pub lang: Option<(String, String)>, - /// (version as declared or resolved, where it was read). - pub next_version: Option<(String, String)>, - pub react_version: Option<(String, String)>, - /// Page-transition and animation libraries the project depends on. - pub motion_libraries: Vec, - pub vinext_version: Option<(String, String)>, - /// react-dom and react-server-dom-webpack, which a vinext app pins to - /// the React it runs while `react` itself may say only `^19`. - pub react_peers: Vec<(String, String)>, - /// Every app the project holds, and what runs each. - pub apps: Vec, - /// The app this plan builds, relative to the root (`.` for the root). - /// `None` with several apps: which one is asked. - pub target_app: Option, -} - -impl ProjectEvidence { - /// The target app's directory, or `.`. - fn app_root(&self) -> &str { - self.target_app.as_ref().map_or(".", |app| app.dir.as_str()) - } - - /// The target app's framework, or `unknown`. - fn framework(&self) -> &'static str { - self.target_app - .as_ref() - .map_or("unknown", |app| app.framework) - } -} - -/// Picks the app a plan builds and points `app_dir` at its App Router. -fn choose_app(root: &Path, evidence: &mut ProjectEvidence) { - evidence.apps = super::framework::detect(root); - evidence.target_app = match evidence.apps.as_slice() { - [only] => Some(only.clone()), - apps => apps.iter().find(|app| app.dir == ".").cloned(), - }; - let first = evidence - .target_app - .clone() - .or_else(|| evidence.apps.first().cloned()); - if let Some(app) = first { - evidence.app_dir = app.path(app.app_router.as_deref().unwrap_or("src/app")); - } -} - -/// The `data-figma-node-id="..."` values in one source file. -fn figma_node_ids(source: &str) -> Vec { - let mut ids = Vec::new(); - let mut rest = source; - while let Some(start) = rest.find("data-figma-node-id=") { - rest = &rest[start + "data-figma-node-id=".len()..]; - let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else { - continue; - }; - rest = &rest[1..]; - if let Some(end) = rest.find(quote) { - ids.push(rest[..end].to_owned()); - rest = &rest[end..]; - } - } - ids -} - -/// Reads routes, layout components and static links through the same -/// inventory `devup_project_context scope:"ui"` returns, then the navigation -/// parser `devup_ui_validate` uses on each scanned file. -pub(super) async fn project_evidence(project_root: &str) -> Result { - let inventory = super::project_context::run("ui", Some(project_root), None).await?; - let config = read_config(project_root)?; - let mut evidence = ProjectEvidence { - app_dir: "src/app".into(), - config_file: config.is_some().then(|| CONFIG_FILE.to_owned()), - config: config.unwrap_or_default(), - ..ProjectEvidence::default() - }; - evidence.root = PathBuf::from(project_root); - if inventory["found"] != true { - choose_app(&PathBuf::from(project_root), &mut evidence); - read_site_facts(&PathBuf::from(project_root), &mut evidence); - evidence.inventory_note = Some( - "No App Router source was found; every route is unverified until pages exist or navigationTargets name them." - .into(), - ); - return Ok(evidence); - } - if inventory["truncation"]["truncated"] == true || inventory["unparsedFiles"] != 0 { - evidence.inventory_note = Some( - "The route inventory is truncated or has unparsed files; a route missing here may still exist.".into(), - ); - } - let root = PathBuf::from(text(&inventory["projectRoot"])); - evidence.root = root.clone(); - let mut sources = BTreeSet::new(); - let mut owner = BTreeMap::>::new(); - for route in array(&inventory["routes"]) { - let path = text(&route["route"]).to_owned(); - evidence - .routes - .push((path.clone(), text(&route["page"]).to_owned())); - sources.insert(text(&route["page"]).to_owned()); - owner - .entry(text(&route["page"]).to_owned()) - .or_default() - .insert(path.clone()); - for component in array(&route["components"]) { - sources.insert(text(component).to_owned()); - owner - .entry(text(component).to_owned()) - .or_default() - .insert(path.clone()); - } - } - if evidence - .routes - .iter() - .any(|(_, page)| page.starts_with("app/")) - && !evidence - .routes - .iter() - .any(|(_, page)| page.starts_with("src/app/")) - { - evidence.app_dir = "app".into(); - } - choose_app(&root, &mut evidence); - for component in array(&inventory["components"]) { - sources.insert(text(&component["path"]).to_owned()); - let in_layout = array(&component["importedBy"]).iter().any(|site| { - let path = text(&site["path"]); - path.ends_with("layout.tsx") || path.ends_with("layout.jsx") - }); - if in_layout { - for name in array(&component["exports"]) { - let name = text(name); - if !name.is_empty() - && name != "default" - && !evidence.layout_parts.iter().any(|part| part == name) - { - evidence.layout_parts.push(name.to_owned()); - } - } - } - } - for relative in sources - .iter() - .filter(|path| !path.is_empty()) - .take(MAX_SCANNED_SOURCES) - { - let path = root.join(relative); - if std::fs::metadata(&path).map_or(true, |meta| meta.len() > MAX_SOURCE_BYTES) { - continue; - } - let Ok(source) = std::fs::read_to_string(&path) else { - continue; - }; - // A frame id in exactly one route's files says that route was built - // from it; in several, it is a shared part and says nothing. - if let Some(routes) = owner.get(relative).filter(|routes| routes.len() == 1) { - for id in figma_node_ids(&source) { - evidence.figma_nodes.entry(id).or_insert_with(|| { - ( - routes.iter().next().cloned().unwrap_or_default(), - relative.clone(), - ) - }); - } - } - for element in array(&super::navigation::source_evidence(&source)["elements"]) { - if element["isLink"] == true - && let Some(href) = element["href"].as_str() - && href.starts_with('/') - && !text(&element["name"]).is_empty() - { - evidence.nav_links.push(( - text(&element["name"]).to_owned(), - href.to_owned(), - format!("{relative}:{}", element["line"]), - )); - } - } - } - evidence.sources = sources - .iter() - .filter(|path| !path.is_empty()) - .take(MAX_SCANNED_SOURCES) - .cloned() - .collect(); - read_site_facts(&root, &mut evidence); - Ok(evidence) -} - -/// Lowercased words of a layer name with widths and device words removed, so -/// `About - 1440`, `[MO] About_390` and `about` are one screen. -fn base_words(name: &str) -> Vec { - let lowered = name.to_lowercase(); - let words = lowered - .split(|c: char| !c.is_alphanumeric()) - .filter(|word| !word.is_empty()) - .map(str::to_owned) - .collect::>(); - let kept = words - .iter() - .filter(|word| { - let digits = word.trim_end_matches("px"); - let width = !digits.is_empty() - && digits.chars().all(|c| c.is_ascii_digit()) - && digits - .parse::() - .is_ok_and(|n| (240..=4000).contains(&n)); - !width && !DEVICE_WORDS.contains(&word.as_str()) - }) - .cloned() - .collect::>(); - if kept.is_empty() { words } else { kept } -} - -/// The URL slug a name would have, from its ASCII words only. Empty for a -/// name with none, which is why a slug is offered as an option and never -/// proposed on its own. -fn slug(words: &[String]) -> String { - words - .iter() - .filter(|word| word.chars().all(|c| c.is_ascii_alphanumeric())) - .cloned() - .collect::>() - .join("-") -} - -fn compact(value: &str) -> String { - value - .to_lowercase() - .chars() - .filter(|c| c.is_alphanumeric()) - .collect() -} - -/// The same rule `pageScaffold.route` is held to, so a confirmed route is one -/// the export will accept. -pub(super) fn valid_route(route: &str) -> bool { - let trimmed = route.trim_matches('/'); - route.starts_with('/') - && !route.starts_with("//") - && !route.ends_with("//") - && trimmed.split('/').all(|segment| { - (trimmed.is_empty() || !segment.is_empty()) - && segment != "." - && segment != ".." - && segment - .chars() - .all(|c| c.is_ascii_alphanumeric() || "-_.[]()@".contains(c)) - }) -} - -fn normalize_route(href: &str) -> Option { - let path = href.split(['?', '#']).next().unwrap_or_default().trim(); - if !path.starts_with('/') || path.starts_with("//") { - return None; - } - let trimmed = path.trim_end_matches('/'); - Some(if trimmed.is_empty() { - "/".to_owned() - } else { - trimmed.to_owned() - }) -} - -fn truncate(value: &str, limit: usize) -> String { - if value.chars().count() <= limit { - return value.to_owned(); - } - let mut cut = value.chars().take(limit - 1).collect::(); - cut.push('…'); - cut -} - -fn header(prefix: &str, name: &str) -> String { - truncate(&format!("{prefix}: {name}"), HEADER_LIMIT) -} - -/// One option as the question tool shows it, and what choosing it does. -struct Choice { - label: String, - description: String, - action: Value, -} - -fn choice(label: impl Into, description: impl Into, action: Value) -> Choice { - Choice { - label: label.into(), - description: description.into(), - action, - } -} - -/// A question in the shape every runtime's question tool takes: the -/// recommended option first with " (Recommended)" on its label, two to four -/// options, and custom answers allowed. `resolve` stays in the plan file and is -/// stripped from what the caller is asked. -fn question(id: String, header: String, prompt: String, choices: Vec, kind: &str) -> Value { - let mut seen = BTreeSet::new(); - let choices = choices - .into_iter() - .filter(|choice| seen.insert(choice.label.clone())) - .take(MAX_OPTIONS) - .collect::>(); - let options = choices - .iter() - .enumerate() - .map(|(index, choice)| { - json!({ - "label": if index == 0 { format!("{}{RECOMMENDED}", choice.label) } else { choice.label.clone() }, - "description": choice.description, - }) - }) - .collect::>(); - let resolve = choices - .iter() - .map(|choice| json!({"label": choice.label, "action": choice.action})) - .collect::>(); - json!({ - "id": id, - "header": header, - "question": prompt, - "options": options, - "multiple": false, - "custom": true, - "kind": kind, - "resolve": resolve, - }) -} - -/// The questions as they go to the question tool: no resolution table. -fn ask_shape(questions: &[Value]) -> Vec { - ask_shape_with(questions, questions) -} - -/// As [`ask_shape`], with `all` the whole plan's questions so a conditional -/// question can say in its own text when it applies - the flat list may be -/// asked in one call, where the answer it depends on is not yet known. -fn ask_shape_with(all: &[Value], questions: &[Value]) -> Vec { - questions - .iter() - .map(|question| { - let mut shaped = question.clone(); - let conditions = array(&question["when"]) - .iter() - .map(|condition| { - let parent = all - .iter() - .find(|other| other["id"] == condition["question"]) - .map_or_else( - || text(&condition["question"]).to_owned(), - |other| text(&other["header"]).to_owned(), - ); - format!("\"{parent}\" is \"{}\"", text(&condition["answer"])) - }) - .collect::>(); - if !conditions.is_empty() { - shaped["question"] = json!(format!( - "{} (Only if {}.)", - text(&question["question"]), - conditions.join(" and ") - )); - } - if let Some(object) = shaped.as_object_mut() { - object.remove("resolve"); - object.remove("kind"); - object.remove("fix"); - if let Some(conditions) = object.get_mut("when").and_then(Value::as_array_mut) { - for condition in conditions { - if let Some(condition) = condition.as_object_mut() { - condition.remove("action"); - } - } - } - } - shaped - }) - .collect() -} - -#[derive(Debug, Clone)] -struct Frame { - node_id: String, - name: String, - width: f64, - height: Option, - section: Option, - section_name: Option, -} - -impl Frame { - fn json(&self) -> Value { - json!({"nodeId": self.node_id, "name": self.name, "width": self.width, "height": self.height, - "section": self.section, "sectionName": self.section_name}) - } -} - -/// How the frames of one name are turned into pages. -#[derive(Debug, Clone, PartialEq)] -enum Grouping { - /// Decided: one page, from a single frame, project config, or evidence. - Fixed(&'static str), - /// Several widths in one section: asked, one responsive page recommended. - Ask { - recommend_single: bool, - reason: String, - }, - /// The same screen name in different sections. A designer usually does - /// that for a reason, so this is never resolved here: the question names - /// the sections and frames and offers to have the Figma fixed instead. - Sections { - all_device: bool, - sections: Vec, - why: String, - }, -} - -#[derive(Debug, Clone)] -struct DraftScreen { - frames: Vec, - words: Vec, - parts: Vec, - links: Vec, - grouping: Grouping, - /// Conditions every question about this screen carries: a structure - /// question about it must have been answered with "keep" for any of them - /// to apply. - extra_when: Vec, - /// The structure questions this screen is part of, so confirm can tell - /// whether the user asked for a Figma fix instead. - structure: Vec, -} - -impl DraftScreen { - fn id(&self) -> &str { - &self.frames[0].node_id - } - fn name(&self) -> &str { - &self.frames[0].name - } - fn widths(&self) -> Vec { - widths_of(&self.frames) - } - /// The question that decides how this screen's frames become pages. - fn grouping_question(&self) -> Option { - match self.grouping { - Grouping::Fixed(_) => None, - Grouping::Ask { .. } => Some(format!("group:{}", self.id())), - Grouping::Sections { .. } => Some(format!("sections:{}", self.id())), - } - } -} - -fn widths_of(frames: &[Frame]) -> Vec { - let mut widths = frames - .iter() - .map(|frame| frame.width.round().max(0.0) as u32) - .collect::>(); - widths.sort_unstable_by(|a, b| b.cmp(a)); - widths.dedup(); - widths -} - -fn width_list(widths: &[u32]) -> String { - widths - .iter() - .map(u32::to_string) - .collect::>() - .join("/") -} - -/// Whether a section name says only which device it is for: `PC`, -/// `Mobile`, `Tablet`, `모바일`. -fn device_name(name: &str) -> bool { - let lowered = name.to_lowercase(); - let words = lowered - .split(|c: char| !c.is_alphanumeric()) - .filter(|word| !word.is_empty()) - .collect::>(); - !words.is_empty() - && words.iter().all(|word| { - DEVICE_WORDS.contains(word) - || SECTION_DEVICE_WORDS.contains(word) - || word.chars().all(|c| c.is_ascii_digit()) - }) -} - -fn section_label(frame: &Frame) -> String { - frame - .section_name - .clone() - .or_else(|| frame.section.clone()) - .unwrap_or_else(|| "(no section)".to_owned()) -} - -fn describe_frames(frames: &[Frame]) -> String { - frames - .iter() - .map(|frame| { - format!( - "\"{}\" ({}, {}px, section {})", - frame.name, - frame.node_id, - frame.width.round(), - section_label(frame) - ) - }) - .collect::>() - .join("; ") -} - -/// Frames that share a base name are candidates for one page. Whether they -/// are one is not assumed: -/// -/// - several widths in one section that can merge into one responsive tree -/// are asked, recommending one responsive page; `widthGrouping` in the -/// project config decides this case instead of asking; -/// - the same name in different sections - device-named sections included - -/// is always asked, with the facts and a "fix the Figma" option, because -/// that is a designer's structure and not this server's to correct; -/// - the same name at the same width in one section is not grouped; it is -/// flagged as a possible duplicate by [`structure_questions`]. -fn group_screens(screens: &[PlanScreenInput], config: &PlanConfig) -> Vec { - let mut order = Vec::::new(); - let mut groups = BTreeMap::>::new(); - for screen in screens { - let key = base_words(&screen.name).join(" "); - if !groups.contains_key(&key) { - order.push(key.clone()); - } - groups.entry(key).or_default().push(screen); - } - let make = |members: &[&PlanScreenInput], grouping: Grouping| { - let mut frames = members - .iter() - .map(|screen| Frame { - node_id: screen.node_id.clone(), - name: screen.name.clone(), - width: screen.width, - height: screen.height, - section: screen.section.clone(), - section_name: screen.section_name.clone(), - }) - .collect::>(); - frames.sort_by(|a, b| b.width.total_cmp(&a.width)); - let mut parts = Vec::::new(); - let mut links = Vec::new(); - for screen in members { - for part in &screen.parts { - if !parts.contains(part) { - parts.push(part.clone()); - } - } - links.extend(screen.links.iter().cloned()); - } - DraftScreen { - frames, - words: base_words(&members[0].name), - parts, - links, - grouping, - extra_when: Vec::new(), - structure: Vec::new(), - } - }; - let mut result = Vec::new(); - for key in order { - let members = &groups[&key]; - if members.len() == 1 { - result.push(make(members, Grouping::Fixed("single-frame"))); - continue; - } - // Asked of the base name, not the layer name: `Main - 1440` and - // `Main - 390` are one name across two widths once the width is gone. - let named = members - .iter() - .map(|screen| (key.as_str(), screen.width)) - .collect::>(); - let family = devup_mcp_devup_ui::codegen::responsive::breakpoint_family_is_possible(&named); - let sections = members - .iter() - .map(|screen| screen.section.as_deref()) - .collect::>(); - if sections.len() > 1 { - let probe = make(members, Grouping::Fixed("")); - let mut names = Vec::::new(); - for frame in &probe.frames { - let label = section_label(frame); - if !names.contains(&label) { - names.push(label); - } - } - let all_device = probe - .frames - .iter() - .all(|frame| frame.section_name.as_deref().is_some_and(device_name)); - let mut why = vec!["the same name once widths and device words are removed".to_owned()]; - if family { - why.push(format!( - "their widths ({}) form a desktop-to-mobile pattern", - width_list(&probe.widths()) - )); - } - result.push(make( - members, - Grouping::Sections { - all_device, - sections: names, - why: why.join(", and "), - }, - )); - continue; - } - if !family { - for member in members { - result.push(make(&[member], Grouping::Fixed("single-frame"))); - } - continue; - } - match config.width_grouping.as_deref() { - Some("responsive") => { - result.push(make(members, Grouping::Fixed("config:widthGrouping"))); - } - Some("separate") => { - for member in members { - result.push(make(&[member], Grouping::Fixed("config:widthGrouping"))); - } - } - _ => result.push(make( - members, - Grouping::Ask { - recommend_single: true, - reason: "drawn at several widths in one section with matching names".into(), - }, - )), - } - } - result -} - -/// The base name without a trailing part number: `Main 1` and `Main (2)` -/// are parts of one screen if anything is. -fn part_key(words: &[String]) -> Option { - let (last, rest) = words.split_last()?; - let numbered = - last.chars().all(|c| c.is_ascii_digit()) && last.parse::().is_ok_and(|n| n < 100); - let rest = rest - .iter() - .filter(|word| !matches!(word.as_str(), "part" | "p")) - .cloned() - .collect::>(); - (numbered && !rest.is_empty()).then(|| rest.join(" ")) -} - -const KEEP_SEPARATE: &str = "They are different screens"; -const KEEP_OUTSIDE: &str = "It is its own screen"; -pub(super) const FIX_FIGMA: &str = "The Figma structure is wrong: I will fix it"; - -/// One structural oddity: the frames involved, what looks wrong, and the fix -/// the designer would make. -struct Oddity { - kind: &'static str, - screens: Vec, - facts: String, - keep: &'static str, - keep_description: String, - fix: String, -} - -/// Figma structure that a designer may not have meant, flagged in the same -/// batch with a "fix it" option. Nothing here decides which frame is right. -fn structure_questions(drafts: &[DraftScreen]) -> Vec { - let mut found = Vec::new(); - let singles = drafts - .iter() - .enumerate() - .filter(|(_, screen)| screen.grouping == Grouping::Fixed("single-frame")) - .collect::>(); - // The same name and width in one section. - let mut duplicates = BTreeMap::<(String, Option, u32), Vec>::new(); - for (index, screen) in &singles { - let frame = &screen.frames[0]; - duplicates - .entry(( - screen.words.join(" "), - frame.section.clone(), - widths_of(&screen.frames)[0], - )) - .or_default() - .push(*index); - } - for indices in duplicates.values().filter(|indices| indices.len() > 1) { - let frames = indices - .iter() - .map(|index| drafts[*index].frames[0].clone()) - .collect::>(); - found.push(Oddity { - kind: "duplicate", - screens: indices.clone(), - facts: format!( - "{} frames share one name and one width in one section: {}.", - frames.len(), - describe_frames(&frames) - ), - keep: KEEP_SEPARATE, - keep_description: "Each frame is its own screen and gets its own route.".into(), - fix: format!( - "Delete the stale copy or rename the frames so each screen has one name: {}.", - describe_frames(&frames) - ), - }); - } - // One screen split into numbered parts across sections. - let mut parts = BTreeMap::>::new(); - for (index, screen) in drafts.iter().enumerate() { - if let Some(key) = part_key(&screen.words) { - parts.entry(key).or_default().push(index); - } - } - for indices in parts.values().filter(|indices| indices.len() > 1) { - let frames = indices - .iter() - .flat_map(|index| drafts[*index].frames.clone()) - .collect::>(); - let sections = frames - .iter() - .map(|frame| frame.section.clone()) - .collect::>(); - if sections.len() < 2 { - continue; - } - found.push(Oddity { - kind: "split", - screens: indices.clone(), - facts: format!( - "These numbered frames look like parts of one screen spread across sections: {}.", - describe_frames(&frames) - ), - keep: KEEP_SEPARATE, - keep_description: "Each part is its own screen and gets its own route.".into(), - fix: format!( - "Put the parts of one screen into one frame in one section: {}.", - describe_frames(&frames) - ), - }); - } - // A frame outside any section beside frames that are in sections. - let sectioned = drafts - .iter() - .flat_map(|screen| &screen.frames) - .filter_map(|frame| frame.section.as_ref().map(|_| section_label(frame))) - .collect::>(); - if !sectioned.is_empty() { - for (index, screen) in &singles { - let frame = &screen.frames[0]; - if frame.section.is_some() { - continue; - } - found.push(Oddity { - kind: "loose", - screens: vec![*index], - facts: format!( - "\"{}\" ({}, {}px) sits outside any section, while the other frames are in {}.", - frame.name, - frame.node_id, - frame.width.round(), - sectioned.iter().cloned().collect::>().join(", ") - ), - keep: KEEP_OUTSIDE, - keep_description: "Plan it as a screen of its own, outside the sections.".into(), - fix: format!( - "Move \"{}\" ({}) into the section it belongs to, or out of this area.", - frame.name, frame.node_id - ), - }); - } - } - found -} - -/// Layer names that mark a Figma node as the site's logo or icon, and as its -/// share image. Frames named like this are site assets, not pages. -const LOGO_WORDS: &[&str] = &[ - "logo", - "symbol", - "favicon", - "appicon", - "로고", - "심볼", - "파비콘", -]; -/// Narrow on purpose: a bare "share" or "thumbnail" names real UI - a share -/// sheet, a video thumbnail - and those are screens. -const OG_WORDS: &[&str] = &[ - "og", - "opengraph", - "open graph", - "share image", - "share card", - "social image", - "공유 이미지", - "대표 이미지", -]; -/// Package names that say a project already measures or verifies something. -const ANALYTICS_PACKAGES: &[&str] = &[ - "@vercel/analytics", - "@next/third-parties", - "react-ga4", - "react-gtm-module", - "@amplitude/analytics-browser", - "mixpanel-browser", - "posthog-js", -]; -const ANALYTICS_ENV: &[&str] = &[ - "GA_", - "GTM", - "GOOGLE_ANALYTICS", - "GOOGLE_SITE_VERIFICATION", - "NAVER_SITE_VERIFICATION", -]; - -fn asset_kind(name: &str) -> Option<&'static str> { - if LOGO_WORDS.iter().any(|word| keyword_in(name, word)) || keyword_in(name, "app icon") { - Some("logo") - } else if OG_WORDS.iter().any(|word| keyword_in(name, word)) { - Some("og") - } else { - None - } -} - -/// A Figma node that may become the favicon or the share image. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = json!({"not": {}})))] -pub struct PlanAssetInput { - pub node_id: String, - pub name: String, - #[serde(default)] - pub width: Option, - #[serde(default)] - pub height: Option, -} - -/// Files under `directory` (relative to `root`) whose names start with one of -/// `prefixes`, as relative paths. -fn files_named(root: &Path, directory: &str, prefixes: &[&str]) -> Vec { - let Ok(entries) = std::fs::read_dir(join_relative(root, directory)) else { - return Vec::new(); - }; - let mut found = entries - .flatten() - .filter(|entry| entry.path().is_file()) - .filter_map(|entry| entry.file_name().into_string().ok()) - .filter(|name| { - let lowered = name.to_lowercase(); - prefixes.iter().any(|prefix| lowered.starts_with(prefix)) - }) - .map(|name| format!("{directory}/{name}")) - .collect::>(); - found.sort(); - found -} - -fn join_relative(root: &Path, relative: &str) -> PathBuf { - relative - .split('/') - .filter(|part| !part.is_empty()) - .fold(root.to_path_buf(), |path, part| path.join(part)) -} - -fn read_small_up_to(path: &Path, limit: u64) -> Option { - let metadata = std::fs::metadata(path).ok()?; - if metadata.len() > limit { - return None; - } - std::fs::read_to_string(path).ok() -} - -fn read_small(path: &Path) -> Option { - let metadata = std::fs::metadata(path).ok()?; - if metadata.len() > MAX_SOURCE_BYTES { - return None; - } - std::fs::read_to_string(path).ok() -} - -/// The quoted value after `marker` in `source`, e.g. the URL in -/// `metadataBase: new URL("...")`. -fn quoted_after(source: &str, marker: &str) -> Option { - let rest = &source[source.find(marker)? + marker.len()..]; - let quote_at = rest.find(['"', '\'', '`'])?; - let quote = rest[quote_at..].chars().next()?; - let value = &rest[quote_at + 1..]; - let end = value.find(quote)?; - Some(value[..end].to_owned()).filter(|value| !value.is_empty()) -} - -/// What the project already says about the site as a whole: its icon, share -/// image, name, URL, analytics, language and not-found page. -fn read_site_facts(root: &Path, evidence: &mut ProjectEvidence) { - let app = evidence.app_dir.clone(); - let app_root = evidence.app_root().to_owned(); - let public = if app_root == "." { - "public".to_owned() - } else { - format!("{app_root}/public") - }; - evidence.icon_files = [ - files_named(root, &app, &["icon.", "favicon.", "apple-icon."]), - files_named(root, &public, &["favicon", "icon.", "apple-touch-icon"]), - ] - .concat(); - evidence.og_files = [ - files_named(root, &app, &["opengraph-image.", "twitter-image."]), - files_named(root, &public, &["og", "share", "opengraph"]), - ] - .concat(); - evidence.not_found = ["tsx", "jsx", "js"] - .iter() - .map(|ext| format!("{app}/not-found.{ext}")) - .find(|path| join_relative(root, path).is_file()); - for ext in ["tsx", "jsx", "js"] { - let layout = format!("{app}/layout.{ext}"); - let Some(source) = read_small(&join_relative(root, &layout)) else { - continue; - }; - evidence.lang = quoted_after(&source, "(&text).ok()); - for (name, slot) in [ - ("next", &mut evidence.next_version), - ("react", &mut evidence.react_version), - ("vinext", &mut evidence.vinext_version), - ] { - if let Some(version) = bun_lock.as_deref().and_then(|lock| bun_locked(lock, name)) { - *slot = Some((version, "bun.lock".into())); - } else if let Some(version) = npm_lock - .as_ref() - .and_then(|lock| lock["packages"][format!("node_modules/{name}")]["version"].as_str()) - { - *slot = Some((version.to_owned(), "package-lock.json".into())); - } - } - let env_dirs = if app_root == "." { - vec![".".to_owned()] - } else { - vec![app_root.clone(), ".".to_owned()] - }; - for file in env_dirs.iter().flat_map(|dir| { - [".env", ".env.local", ".env.production"] - .map(|name| join_relative(&PathBuf::from(dir), name)) - }) { - let Some(source) = read_small(&root.join(&file)) else { - continue; - }; - let file = file.display().to_string().replace('\\', "/"); - for line in source.lines() { - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let key = key.trim(); - let value = value.trim().trim_matches(['"', '\'']); - if evidence.site_url.is_none() - && matches!( - key, - "NEXT_PUBLIC_SITE_URL" | "SITE_URL" | "NEXT_PUBLIC_BASE_URL" - ) - && (value.starts_with("http://") || value.starts_with("https://")) - { - evidence.site_url = Some((value.to_owned(), format!("{key} in {file}"))); - } - if evidence.site_name.is_none() - && matches!(key, "NEXT_PUBLIC_SITE_NAME" | "SITE_NAME") - && !value.is_empty() - { - evidence.site_name = Some((value.to_owned(), format!("{key} in {file}"))); - } - if ANALYTICS_ENV.iter().any(|prefix| key.contains(prefix)) { - evidence.analytics.push(format!("{key} in {file}")); - } - } - } - // The app's own package.json first: in a workspace the root one holds - // tooling, the app's holds its framework and React. - for manifest in env_dirs - .iter() - .map(|dir| join_relative(root, dir).join("package.json")) - { - let Some(package) = - read_small(&manifest).and_then(|text| serde_json::from_str::(&text).ok()) - else { - continue; - }; - let shown = manifest - .strip_prefix(root) - .unwrap_or(&manifest) - .display() - .to_string() - .replace('\\', "/"); - if evidence.package_name.is_none() { - evidence.package_name = package["name"].as_str().map(str::to_owned); - } - for section in ["dependencies", "devDependencies"] { - for (name, slot) in [ - ("next", &mut evidence.next_version), - ("react", &mut evidence.react_version), - ("vinext", &mut evidence.vinext_version), - ] { - if slot.is_none() - && let Some(version) = package[section][name].as_str() - { - *slot = Some((version.to_owned(), format!("{shown} {section}"))); - } - } - for peer in ["react-dom", "react-server-dom-webpack"] { - if let Some(version) = package[section][peer].as_str() { - evidence - .react_peers - .push((version.to_owned(), format!("{peer} in {shown} {section}"))); - } - } - for name in MOTION_LIBRARIES { - if package[section].get(*name).is_some() - && !evidence.motion_libraries.iter().any(|known| known == name) - { - evidence.motion_libraries.push((*name).to_owned()); - } - } - } - for section in ["dependencies", "devDependencies"] { - for name in ANALYTICS_PACKAGES { - if package[section].get(*name).is_some() { - evidence.analytics.push(format!("{name} in {shown}")); - } - } - } - } -} - -/// The version bun.lock resolved a package to: its entry reads -/// `"react": ["react@19.3.0", ...]`. -fn bun_locked(lock: &str, name: &str) -> Option { - let marker = format!("\"{name}\": [\"{name}@"); - let start = lock.find(&marker)? + marker.len(); - let end = lock[start..].find('"')? + start; - Some(lock[start..end].to_owned()) - .filter(|version| version.starts_with(|c: char| c.is_ascii_digit())) -} - -/// Libraries that already animate page changes. With one present, adding -/// view transitions is a question, not a default. -const MOTION_LIBRARIES: &[&str] = &[ - "framer-motion", - "motion", - "next-view-transitions", - "react-transition-group", - "@react-spring/web", - "gsap", -]; - -/// The interaction recipe per element role: what each one gets on hover, -/// press and focus. The devup-implementation-plan skill carries the same -/// table with the reasoning; status counts elements against it per role. -const INTERACTION_RECIPES: &[(&str, &str)] = &[ - ( - "primary button", - "hover one token step darker; active scale(0.98) or one more step darker; focusVisible 2px ring with offset; disabled reduced opacity and cursor not-allowed; a loading state (disabled, spinner) while a form it submits is pending", - ), - ( - "secondary / ghost / outline button", - "hover a subtle tinted background; active a slightly stronger tint; the same focus ring and disabled state", - ), - ( - "text link / \"더 보기 >\" link", - "hover a colour change or underline; a trailing chevron moves 2px; focus ring", - ), - ( - "card that links", - "hover a 2-4px lift and a shadow token; the image inside zooms to 1.02-1.04 with overflow hidden; active drops the lift; focus ring on the card", - ), - ( - "nav item / tab", - "hover colour; the current route marked with aria-current=\"page\" and the Figma active style (e.g. the underline the header draws); focus ring", - ), - ( - "icon button", - "hover a background circle or tint; active press; aria-label required", - ), - ( - "input / select / textarea", - "hover border; focus border and ring; invalid state with its message; disabled", - ), - ( - "accordion / disclosure / dropdown", - "aria-expanded; the chevron rotates 180deg", - ), - ( - "mobile menu / drawer", - "an open and close transition, when the design has one", - ), -]; - -const RESTRAINT_RULES: &str = "Figma variant states first, otherwise existing theme tokens only (no new colours; record an assumption when none fits); at most two properties change per state; 150-250ms, ease or ease-out, defined once as a shared token or constant; no bounce and no infinite or attention-seeking animation; transforms of 4px or less and scale between 0.97 and 1.05; everything that moves is off under _motionReduce; non-interactive elements get no hover."; - -/// `major.minor.patch` from a version or range as package.json writes it: -/// `^16.1.0`, `~19.3.0-canary-...`, `16`. `None` for `latest`, -/// `workspace:*`, `catalog:` and the like, which say nothing about the API. -fn version_triple(value: &str) -> Option<(u64, u64, u64)> { - let start = value.find(|c: char| c.is_ascii_digit())?; - if !value[..start] - .chars() - .all(|c| matches!(c, '^' | '~' | '>' | '=' | '<' | 'v' | ' ')) - { - return None; - } - let mut parts = value[start..] - .split(|c: char| !c.is_ascii_digit()) - .take(3) - .map(|part| part.parse::().unwrap_or(0)); - Some(( - parts.next()?, - parts.next().unwrap_or(0), - parts.next().unwrap_or(0), - )) -} - -/// The dependency step every job starts with, word for word as the skills -/// give it. It updates every manifest in the repository - every bun -/// workspace and every Cargo.toml - to the latest versions: `-d` finds them -/// all, `-u` applies the updates as one recoverable batch, and `--rm` removes -/// the lockfiles and installed directories next to each (Cargo.lock and -/// target/ included) so the next install resolves everything fresh. -pub(super) const UPDATE_STEP: &str = "bunx @dependency-check-updates/cli -d -u --rm"; - -/// The documents the view-transition decision rests on. Read when it was -/// written; the decision names them so a reader can check them again. -/// -/// - https://nextjs.org/docs/app/guides/view-transitions - App Router, no -/// config, `import { ViewTransition } from 'react'`, React 19.3. -/// - https://react.dev/reference/react/ViewTransition - the component. -/// - https://vinext.dev/compatibility - Next.js's own -/// app-dir/view-transitions e2e suite passes under vinext (App Router). -/// - : -/// vinext's own example imports `ViewTransition` from 'react' and sets -/// `experimental.viewTransition: true` in its next config. -/// - : -/// `vinext check` labels that import "partial (passthrough fallback)" -/// whatever the React version; the runtime audit's view-transition check is -/// what settles whether it animates. -/// - https://github.com/cloudflare/vinext#readme - peer dependency react ^19.3.1. -const VIEW_TRANSITION_DOCS: &[&str] = &[ - "https://nextjs.org/docs/app/guides/view-transitions", - "https://react.dev/reference/react/ViewTransition", - "https://vinext.dev/compatibility", - "https://github.com/cloudflare/vinext/blob/main/examples/app-router-playground/app/view-transitions/_ui/transitions.tsx", - "https://github.com/cloudflare/vinext/blob/main/packages/vinext/src/check.ts", -]; - -/// The view-transition default: always on. DevFive runs the latest versions -/// (the dependency step comes first), and the latest Next App Router and -/// React support `` on vinext and on the `next` CLI alike, so -/// no version is checked. Whether it really animates is not taken on trust -/// either way: the runtime audit's `view-transition` check follows a link and -/// fails when no transition ran. The only "skipped" results are a project -/// that opts out (`viewTransitions: false`) and one that forbids the update -/// (`updateDependencies: false`) while its installed React predates 19.3. -fn view_transition_default(evidence: &ProjectEvidence) -> Value { - let skipped = |why: String| { - json!({"item": "view-transitions", "mechanism": "skipped", - "decision": "No view transitions.", "evidence": why, "docs": VIEW_TRANSITION_DOCS}) - }; - if evidence.config.view_transitions == Some(false) { - return skipped(format!("viewTransitions: false in {CONFIG_FILE}")); - } - if evidence.config.update_dependencies == Some(false) { - let react = std::iter::once(&evidence.react_version) - .flatten() - .chain(evidence.react_peers.iter()) - .filter_map(|(text, _)| version_triple(text)) - .max(); - if react.is_none_or(|react| react < (19, 3, 0)) { - return skipped(format!( - "updateDependencies: false in {CONFIG_FILE} keeps React below 19.3, which has no ViewTransition" - )); - } - } - let runner = if evidence.framework() == "next" - && evidence.config.framework.as_deref() != Some("vinext") - { - "the next CLI" - } else { - "vinext" - }; - json!({"item": "view-transitions", "mechanism": "react-view-transition", - "decision": "import { ViewTransition } from 'react', wrapping each page's content in its page.tsx (not the layout, which persists): a short cross-fade of about 250ms, no big slides; shared elements (page title, hero image, a card image on its detail page) named only where the same element is on both pages - a static name as the devup-ui viewTransitionName prop, a per-record name on ; `::view-transition { pointer-events: none }`; animation-duration 0s under prefers-reduced-motion. Verify it with a runtime-audit view-transition check (follow a link between two routes; it fails when no transition ran) - a failure is a bug to fix. experimental.viewTransition is added to the next config only if that check fails without it (vinext's own example sets it).", - "evidence": format!("runs on {runner} at the latest versions"), "app": evidence.app_root(), "docs": VIEW_TRANSITION_DOCS}) -} - -/// No app, no routes and no framework: a project still to be created. -fn is_new_project(evidence: &ProjectEvidence) -> bool { - evidence.apps.is_empty() - && evidence.routes.is_empty() - && evidence.next_version.is_none() - && evidence.vinext_version.is_none() -} - -/// The defaults that come before any page: start from the template when there -/// is no app, which framework runs the app, and the dependency update. -fn stack_defaults(evidence: &ProjectEvidence) -> Vec { - let mut defaults = Vec::new(); - if is_new_project(evidence) { - defaults.push(json!({"item": "project-start", - "decision": format!("Start from dev-five-git/service-template - bun workspaces apps/*, vinext, devup-ui, oxlint via eslint-plugin-devup, TS 7 - keeping its structure and AGENTS.md conventions, then run the dependency step ({UPDATE_STEP})."), - "evidence": "no app, route or framework in the project"})); - } else if let Some(app) = &evidence.target_app { - defaults.push( - json!({"item": "framework", "decision": format!("{} runs {}", app.dir, app.framework), - "evidence": app.evidence}), - ); - } - defaults.push(if evidence.config.update_dependencies == Some(false) { - json!({"item": "dependencies", "mechanism": "skipped", - "decision": "Dependencies are not updated: the project config forbids it.", - "evidence": format!("updateDependencies: false in {CONFIG_FILE}")}) - } else { - json!({"item": "dependencies", "mechanism": "update", - "decision": format!("Before implementing, at the repository root: {UPDATE_STEP}, which updates every manifest in the repository - every bun workspace and every Cargo.toml - to the latest versions (-d finds them all, -u applies the updates as one recoverable batch, --rm removes each lockfile and installed directory, Cargo.lock and target/ included, so everything resolves fresh); then bun install, build, lint and typecheck. Fix code to new APIs; pin back only when no fix exists, and record the pin as an assumption. Record the outcome with devup_screen_plan assume (dependencyUpdate)."), - "evidence": "DevFive works on the latest versions; API knowledge from memory is assumed stale"}) - }); - defaults -} - -/// Words that mark an app, or a screen, as the public site or the admin -/// console. An app named `admin` is the admin console; a Figma design for a -/// public site is almost never for it. -const PUBLIC_APP_WORDS: &[&str] = &[ - "front", "web", "site", "www", "landing", "home", "public", "client", -]; -const ADMIN_WORDS: &[&str] = &[ - "admin", - "dashboard", - "console", - "backoffice", - "back office", - "cms", - "manager", - "관리자", - "어드민", - "대시보드", - "회원관리", -]; -/// Screen names a public site has and an admin console does not. -const PUBLIC_SCREEN_WORDS: &[&str] = &[ - "home", - "main", - "landing", - "about", - "company", - "service", - "services", - "contact", - "portfolio", - "pricing", - "blog", - "news", - "faq", - "gnb", - "메인", - "홈", - "회사소개", - "서비스", - "문의", - "포트폴리오", - "소개", -]; -/// Routes that say an app is a console: a sign-in gate and a dashboard. -const ADMIN_ROUTE_SEGMENTS: &[&str] = &["signin", "sign-in", "login", "dashboard", "admin"]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Side { - Public, - Admin, - Unclear, -} - -fn words_side(names: &[&str], public: &[&str]) -> (usize, usize) { - let admin = names - .iter() - .filter(|name| ADMIN_WORDS.iter().any(|word| keyword_in(name, word))) - .count(); - let public = names - .iter() - .filter(|name| public.iter().any(|word| keyword_in(name, word))) - .count(); - (public, admin) -} - -/// Which side an app serves, and the evidence: its directory and package -/// name, then its routes. -fn app_side(app: &super::framework::App, evidence: &ProjectEvidence) -> (Side, Vec) { - let base = app.dir.rsplit('/').next().unwrap_or(&app.dir).to_owned(); - let mut names = vec![base.as_str()]; - if let Some(name) = &app.name { - names.push(name.as_str()); - } - let (mut public, mut admin) = words_side(&names, PUBLIC_APP_WORDS); - let mut why = Vec::new(); - if public > 0 { - why.push(format!("{} is named like the public site", app.dir)); - } - if admin > 0 { - why.push(format!("{} is named like an admin console", app.dir)); - } - let console_routes = evidence - .routes - .iter() - .filter(|(_, page)| page.starts_with(&format!("{}/", app.dir))) - .filter(|(route, _)| { - route - .trim_matches('/') - .split('/') - .any(|segment| ADMIN_ROUTE_SEGMENTS.contains(&segment)) - }) - .map(|(route, _)| route.clone()) - .collect::>(); - if !console_routes.is_empty() { - admin += 1; - why.push(format!( - "{} has console routes ({})", - app.dir, - console_routes.join(", ") - )); - } - if public == admin { - public = 0; - admin = 0; - } - let side = match public.cmp(&admin) { - std::cmp::Ordering::Greater => Side::Public, - std::cmp::Ordering::Less => Side::Admin, - std::cmp::Ordering::Equal => Side::Unclear, - }; - (side, why) -} - -/// Which side one Figma frame belongs to, from its own name and its -/// section's. Admin markers win: a "관리자 - 메인" frame is the console's. -fn screen_side(screen: &PlanScreenInput) -> Side { - let mut names = vec![screen.name.as_str()]; - names.extend(screen.section_name.as_deref()); - names.extend(screen.breadcrumb.iter().map(String::as_str)); - match words_side(&names, PUBLIC_SCREEN_WORDS) { - (_, admin) if admin > 0 => Side::Admin, - (public, _) if public > 0 => Side::Public, - _ => Side::Unclear, - } -} - -fn app_action(app: &super::framework::App) -> Value { - json!({"app": {"dir": app.dir, "appDir": app.path(app.app_router.as_deref().unwrap_or("src/app"))}}) -} - -/// Which app each screen is built in, by meaning rather than by name order. -/// -/// With one app, or a projectRoot that is an app, there is nothing to pick. -/// Otherwise the screens' own names decide: all public screens go to the -/// public app and all admin screens to the admin app without a question; a -/// file with both maps each screen to its side and asks only about screens -/// whose side is unclear; a file whose screens say nothing either way is the -/// one genuinely ambiguous case, asked once - recommending the public app, -/// never whichever comes first alphabetically. -/// -/// Returns the app per screen (by node id), the questions, and the default -/// that records the evidence. -fn assign_apps( - evidence: &ProjectEvidence, - screens: &[PlanScreenInput], -) -> ( - BTreeMap, - Vec, - Option, -) { - let mut assigned = BTreeMap::new(); - if evidence.target_app.is_some() || evidence.apps.len() < 2 { - return (assigned, Vec::new(), None); - } - let sides = evidence - .apps - .iter() - .map(|app| (app, app_side(app, evidence))) - .collect::>(); - let public_app = sides - .iter() - .find(|(_, (side, _))| *side == Side::Public) - .or_else(|| sides.iter().find(|(_, (side, _))| *side != Side::Admin)) - .map(|(app, _)| (*app).clone()) - .unwrap_or_else(|| evidence.apps[0].clone()); - let admin_app = sides - .iter() - .find(|(_, (side, _))| *side == Side::Admin) - .map(|(app, _)| (*app).clone()); - let app_evidence = sides - .iter() - .flat_map(|(_, (_, why))| why.clone()) - .collect::>(); - let screen_sides = screens - .iter() - .map(|screen| (screen, screen_side(screen))) - .collect::>(); - let admin_screens = screen_sides - .iter() - .filter(|(_, side)| *side == Side::Admin) - .count(); - let public_screens = screen_sides - .iter() - .filter(|(_, side)| *side == Side::Public) - .count(); - let mut questions = Vec::new(); - let options = || { - let mut ordered = vec![public_app.clone()]; - ordered.extend( - evidence - .apps - .iter() - .filter(|app| app.dir != public_app.dir) - .cloned(), - ); - ordered - .iter() - .take(MAX_OPTIONS) - .map(|app| { - let side = sides - .iter() - .find(|(candidate, _)| candidate.dir == app.dir) - .map(|(_, (side, _))| *side); - choice( - app.dir.as_str(), - format!( - "{} - {} app{}", - match side { - Some(Side::Public) => "the public site", - Some(Side::Admin) => "the admin console", - _ => "an app", - }, - app.framework, - app.name - .as_deref() - .map(|name| format!(" ({name})")) - .unwrap_or_default() - ), - app_action(app), - ) - }) - .collect::>() - }; - let has_public = sides.iter().any(|(_, (side, _))| *side == Side::Public); - let decision = match (admin_screens, public_screens, &admin_app) { - // Nothing says admin, so the public site: the bias the user chose. - (0, 0, _) if has_public => { - for (screen, _) in &screen_sides { - assigned.insert(screen.node_id.clone(), public_app.clone()); - } - json!({"item": "target-app", "mechanism": "decided", "decision": public_app.dir, - "evidence": ([app_evidence, vec!["the frames name neither side; with no admin evidence the public app is taken".to_owned()]].concat())}) - } - (0, 0, _) => { - let mut asked = question( - "site:app".into(), - "Which app".into(), - format!( - "This repository has {} apps ({}), and the frames' names say nothing about which one they are for. Which app does this design build? A custom answer is its directory.", - evidence.apps.len(), - evidence - .apps - .iter() - .map(|app| app.dir.as_str()) - .collect::>() - .join(", ") - ), - options(), - "site", - ); - asked["field"] = json!("app"); - questions.push(asked); - for (screen, _) in &screen_sides { - assigned.insert(screen.node_id.clone(), public_app.clone()); - } - json!({"item": "target-app", "mechanism": "asked", "question": "site:app", - "decision": format!("Asked; {} is recommended as the public app.", public_app.dir), "evidence": app_evidence}) - } - (admin, public, Some(admin_app)) if admin > 0 && public > 0 => { - for (screen, side) in &screen_sides { - let app = if *side == Side::Admin { - admin_app.clone() - } else { - public_app.clone() - }; - assigned.insert(screen.node_id.clone(), app); - } - json!({"item": "target-app", "mechanism": "per-screen", - "decision": format!("Admin screens go to {}; public screens and screens with no admin evidence go to {}.", admin_app.dir, public_app.dir), - "evidence": app_evidence}) - } - (admin, 0, Some(admin_app)) if admin > 0 => { - for (screen, _) in &screen_sides { - assigned.insert(screen.node_id.clone(), admin_app.clone()); - } - json!({"item": "target-app", "mechanism": "decided", "decision": admin_app.dir, - "evidence": ([app_evidence, vec![format!("{admin} of {} frames are named for the admin console", screens.len())]].concat())}) - } - _ => { - for (screen, _) in &screen_sides { - assigned.insert(screen.node_id.clone(), public_app.clone()); - } - json!({"item": "target-app", "mechanism": "decided", "decision": public_app.dir, - "evidence": ([app_evidence, vec![format!("{public_screens} of {} frames are public-site screens and none is named for an admin console", screens.len())]].concat())}) - } - }; - (assigned, questions, Some(decision)) -} - -/// The questions about the stack itself: migrating off the `next` CLI, and -/// for a new project its name and parts. Which app is [`assign_apps`]'s. -fn stack_questions(evidence: &ProjectEvidence) -> Vec { - let mut questions = Vec::new(); - if evidence.framework() == "next" && evidence.config.framework.is_none() { - let app = evidence.target_app.as_ref(); - let mut asked = question( - "site:framework".into(), - "Framework".into(), - format!( - "{} runs on the next CLI ({}). DevFive's default is vinext, which runs the same App Router on Vite. Migrate it, or keep next?", - app.map_or(".", |app| app.dir.as_str()), - app.map(|app| app.evidence.join("; ")).unwrap_or_default() - ), - vec![ - choice( - "Migrate to vinext", - "vite.config.ts with vinext() and the devup-ui Vite plugin, vinext dev/build/start scripts, no next.config; the code and routes stay the App Router. Done before implementing, then the dependency step.", - json!({"framework": {"source": "vinext"}}), - ), - choice( - "Keep next", - "Stay on the next CLI; view transitions follow its version.", - json!({"framework": {"source": "next"}}), - ), - ], - "site", - ); - asked["field"] = json!("framework"); - questions.push(asked); - } - if is_new_project(evidence) { - let mut name = question( - "site:app-name".into(), - "App name".into(), - "The project starts from dev-five-git/service-template. What is the app called? A custom answer is the name.".into(), - vec![ - choice("front", "The template's own app name (apps/front).", json!({"appName": {"source": "template", "value": "front"}})), - choice("Use the site name", "Named after the site name asked with the other site facts.", json!({"appName": {"source": "site-name"}})), - ], - "site", - ); - name["field"] = json!("appName"); - questions.push(name); - let mut parts = question( - "site:parts".into(), - "Template parts".into(), - "Which parts of the template does this project keep?".into(), - vec![ - choice( - "Front only", - "apps/front alone; admin and the Rust API are removed.", - json!({"parts": {"source": "front"}}), - ), - choice( - "Front + admin + API", - "apps/front, apps/admin and apis/api, as the template ships them.", - json!({"parts": {"source": "all"}}), - ), - ], - "site", - ); - parts["field"] = json!("parts"); - questions.push(parts); - } - questions -} - -/// What the agent fills in by itself, without a question. Recorded in the -/// plan's `defaults` and the final report, so nothing it added is a surprise. -fn site_defaults(evidence: &ProjectEvidence, screens: &[PlanScreenInput]) -> Vec { - let lang = evidence.lang.clone().unwrap_or_else(|| { - let korean = screens - .iter() - .any(|screen| screen.name.chars().any(|c| ('가'..='힣').contains(&c))); - if korean { - ( - "ko".into(), - "Korean text in the design's layer names".into(), - ) - } else { - ( - "en".into(), - "no Korean text in the design's layer names".into(), - ) - } - }); - let mut defaults = vec![ - json!({"item": "interaction-recipes", - "decision": "Each interactive element gets its role's recipe; status counts every role against it.", - "recipes": INTERACTION_RECIPES.iter().map(|(role, recipe)| json!({"role": role, "recipe": recipe})).collect::>(), - "restraint": RESTRAINT_RULES, - "evidence": "devfive-frontend critical-rules 4 (interactive states); devup-ui pseudo-selectors; the devup-implementation-plan recipe table"}), - json!({"item": "transition", - "decision": "One shared transition, 150-250ms, ease or ease-out, on the properties that change (at most two per state), defined once as a token or shared constant and not per element.", - "evidence": "devfive-frontend critical-rules 4 (transition 0.15-0.3s ease)"}), - json!({"item": "reduced-motion", - "decision": "_motionReduce removes transforms and transitions (or one globalCss rule does), so prefers-reduced-motion gets no movement.", - "evidence": "devup-ui _motionReduce"}), - json!({"item": "metadata", - "decision": "Root layout metadata with a title template, description and metadataBase when the site URL is known; every page exports title, description and openGraph taken from its Figma heading and lead text.", - "evidence": "devfive-frontend critical-rules 2 (SEO metadata with openGraph)"}), - json!({"item": "lang", "decision": format!("", lang.0), "evidence": lang.1}), - json!({"item": "alt-text", "decision": "alt text from the Figma layer name or its text content on every image.", - "evidence": "devfive-frontend critical-rules 3 (image alt required)"}), - json!({"item": "image-dimensions", "decision": "width and height on every image, so nothing shifts as it loads.", - "evidence": "no layout shift"}), - ]; - let transitions = view_transition_default(evidence); - defaults.push(if evidence.motion_libraries.is_empty() { - transitions - } else { - json!({"item": "view-transitions", "mechanism": "asked", "question": "site:view-transitions", - "decision": "Asked: the project already animates with its own library.", "ifAdded": transitions, - "evidence": evidence.motion_libraries.join(", ")}) - }); - defaults.push(match &evidence.not_found { - Some(path) => { - json!({"item": "not-found", "decision": format!("Keep {path}."), "evidence": path}) - } - None => json!({"item": "not-found", - "decision": format!("Add {}/not-found.tsx in the site's own style.", evidence.app_dir), - "evidence": "no not-found page in the project"}), - }); - defaults -} - -/// One site fact that needs a file or a fact only the user has. -fn site_question( - field: &str, - id: &str, - header: &str, - prompt: String, - choices: Vec, -) -> Value { - let mut asked = question(id.into(), header.into(), prompt, choices, "site"); - asked["field"] = json!(field); - asked -} - -/// The site-level questions and the state of each fact, asked in the same -/// up-front rounds as everything else. Never asked when the project already -/// says it, or the project config pre-answers it. -fn site_questions( - evidence: &ProjectEvidence, - assets: &[(String, String, &'static str)], - cover: Option<(&str, &str)>, -) -> (Value, Vec) { - let config = &evidence.config; - let mut site = serde_json::Map::new(); - let mut questions = Vec::new(); - let mut file_question = |field: &str, - id: &str, - header: &str, - what: &str, - files: &[String], - kind: &'static str| { - let mut choices = Vec::new(); - for file in files.iter().take(2) { - choices.push(choice( - format!("Use {file}"), - "Already in the project.", - json!({field: {"source": "file", "path": file}}), - )); - } - let (format, target) = if kind == "logo" { - ("svg", format!("{}/icon.svg", evidence.app_dir)) - } else { - ("png", format!("{}/opengraph-image.png", evidence.app_dir)) - }; - for (node, name, _) in assets.iter().filter(|(_, _, found)| *found == kind).take(2) { - choices.push(choice( - format!("Export \"{}\" ({node}) as {}", truncate(name, 30), format.to_uppercase()), - format!("A Figma node in the linked scope; exported with devup_figma_export assetRequests (format {format}) to {target}."), - json!({field: {"source": "figma", "nodeId": node, "format": format, "path": target}}), - )); - } - if kind == "og" - && let Some((node, name)) = cover - { - choices.push(choice( - format!("Use the top of \"{}\" ({node})", truncate(name, 24)), - format!("Export the first screen as PNG and crop it to 1200x630 for {target}."), - json!({field: {"source": "figma-cover", "nodeId": node, "format": "png", "path": target}}), - )); - } - choices.truncate(MAX_OPTIONS - 2); - choices.push(choice( - "I will provide a file", - "Answer with its path, e.g. public/favicon.ico. Nothing is generated or drawn.", - json!({field: {"source": "provide"}}), - )); - choices.push(choice( - "Leave it for later", - "Not added now; status reports it missing.", - json!({field: {"source": "later"}}), - )); - let evidence_text = - if files.is_empty() && !assets.iter().any(|(_, _, found)| *found == kind) { - "Nothing in the project or the linked Figma scope looks like one.".to_owned() - } else { - "The options come from the project's files and the linked Figma scope.".to_owned() - }; - questions.push(site_question( - field, - id, - header, - format!("Which file should be the site's {what}? {evidence_text}"), - choices, - )); - }; - match &config.favicon { - Some(value) => { - site.insert( - "favicon".into(), - json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), - ); - } - None => { - site.insert( - "favicon".into(), - json!({"state": "asked", "question": "site:favicon", "found": evidence.icon_files}), - ); - file_question( - "favicon", - "site:favicon", - "Favicon / app icon", - "favicon and app icon", - &evidence.icon_files, - "logo", - ); - } - } - match &config.og_image { - Some(value) => { - site.insert( - "ogImage".into(), - json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), - ); - } - None => { - site.insert( - "ogImage".into(), - json!({"state": "asked", "question": "site:og", "found": evidence.og_files}), - ); - file_question( - "ogImage", - "site:og", - "Share (OG) image", - "share image (Open Graph)", - &evidence.og_files, - "og", - ); - } - } - match (&config.site_name, &evidence.site_name) { - (Some(value), _) => { - site.insert( - "siteName".into(), - json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), - ); - } - (None, Some((value, from))) => { - site.insert( - "siteName".into(), - json!({"state": "found", "value": value, "evidence": from}), - ); - } - (None, None) => { - site.insert( - "siteName".into(), - json!({"state": "asked", "question": "site:name"}), - ); - let mut choices = Vec::new(); - if let Some(package) = &evidence.package_name { - choices.push(choice( - package.as_str(), - "The package.json name; it may not be the name people see.", - json!({"siteName": {"source": "package", "value": package}}), - )); - } - choices.push(choice( - "Use the main page's heading", - "Taken from the first page's heading text in Figma while building.", - json!({"siteName": {"source": "heading"}}), - )); - choices.push(choice( - "Leave it for later", - "Titles go without a site suffix until it is known.", - json!({"siteName": {"source": "later"}}), - )); - questions.push(site_question( - "siteName", - "site:name", - "Site name", - "What is the site's name, for the title template (\"Page | Name\")? It is not in the project's metadata or environment. A custom answer is the name itself.".into(), - choices, - )); - } - } - match (&config.site_url, &evidence.site_url) { - (Some(value), _) => { - site.insert( - "siteUrl".into(), - json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), - ); - } - (None, Some((value, from))) => { - site.insert( - "siteUrl".into(), - json!({"state": "found", "value": value, "evidence": from}), - ); - } - (None, None) => { - site.insert( - "siteUrl".into(), - json!({"state": "asked", "question": "site:url"}), - ); - questions.push(site_question( - "siteUrl", - "site:url", - "Production URL", - "What is the production URL, for metadataBase and absolute share links? It is not in the project's metadata or .env files. A custom answer is the URL itself.".into(), - vec![ - choice( - "No production URL yet", - "metadataBase is left out; share images use relative URLs until it is set.", - json!({"siteUrl": {"source": "none"}}), - ), - choice( - "Read it from NEXT_PUBLIC_SITE_URL", - "metadataBase comes from that environment variable, set per deployment.", - json!({"siteUrl": {"source": "env", "name": "NEXT_PUBLIC_SITE_URL"}}), - ), - ], - )); - } - } - if !evidence.analytics.is_empty() { - site.insert( - "analytics".into(), - json!({"state": "asked", "question": "site:analytics", "found": evidence.analytics}), - ); - questions.push(site_question( - "analytics", - "site:analytics", - "Analytics / verification", - format!( - "The project shows analytics or verification setup: {}. Wire it into the new root layout?", - evidence.analytics.join(", ") - ), - vec![ - choice("Keep the existing setup", "Carried into the new layout as it is configured.", json!({"analytics": {"source": "keep"}})), - choice("Leave it out", "The new layout has no analytics or verification tags.", json!({"analytics": {"source": "none"}})), - ], - )); - } else { - site.insert("analytics".into(), json!({"state": "not-needed", "evidence": "no analytics or verification signs in the project"})); - } - if !evidence.motion_libraries.is_empty() { - let libraries = evidence.motion_libraries.join(", "); - site.insert("viewTransitions".into(), json!({"state": "asked", "question": "site:view-transitions", "found": evidence.motion_libraries})); - questions.push(site_question( - "viewTransitions", - "site:view-transitions", - "View transitions", - format!("The project already animates with {libraries}. Add view transitions between pages as well, or keep page motion to {libraries}?"), - vec![ - choice(format!("Keep {libraries} only"), "No view transitions; the existing motion stays the only page motion.", json!({"viewTransitions": {"source": "keep-library"}})), - choice("Add view transitions too", "A short cross-fade between pages, beside the existing library.", json!({"viewTransitions": {"source": "add"}})), - ], - )); - } - match &config.motion { - Some(value) => { - site.insert( - "motion".into(), - json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), - ); - } - None => { - site.insert( - "motion".into(), - json!({"state": "asked", "question": "site:motion"}), - ); - questions.push(site_question( - "motion", - "site:motion", - "Motion beyond hover", - "Beyond hover, focus and page transitions, should sections reveal on scroll? Nothing in the plan shows designed motion.".into(), - vec![ - choice("None", "Recommended unless the design shows motion; hover and focus transitions are still added.", json!({"motion": {"source": "none"}})), - choice("Subtle reveal on scroll", "Sections fade and slide in once as they enter; off under prefers-reduced-motion.", json!({"motion": {"source": "reveal"}})), - ], - )); - } - } - (Value::Object(site), questions) -} - -/// The site completeness block of `status`: each item filled, asked and -/// answered, or missing, read from the files as they are now. -fn site_completeness(plan: &Value, evidence: &ProjectEvidence) -> Vec { - let site = &plan["site"]; - let decided = |field: &str| { - let entry = &site[field]; - let source = entry["answer"][field]["source"] - .as_str() - .or_else(|| entry["answer"]["source"].as_str()); - entry["state"] == "config" || (entry["state"] == "answered" && source != Some("later")) - }; - let file_item = |item: &str, field: &str, files: &[String]| { - if !files.is_empty() { - json!({"item": item, "state": "filled", "detail": files.join(", ")}) - } else if decided(field) { - json!({"item": item, "state": "asked-and-answered", "detail": "decided, but no file is in place yet"}) - } else { - json!({"item": item, "state": "missing", "detail": "no file, and no answer that provides one"}) - } - }; - let mut items = vec![ - file_item("favicon", "favicon", &evidence.icon_files), - file_item("og-image", "ogImage", &evidence.og_files), - ]; - for row in array(&plan["routes"]) - .iter() - .filter(|row| row["implementation"] == "page") - { - let url = text(&row["url"]); - let page = evidence - .routes - .iter() - .find(|(pattern, _)| pattern == url) - .map(|(_, page)| page.clone()); - let state = match &page { - Some(page) => match read_small(&join_relative(&evidence.root, page)) { - Some(source) - if devup_mcp_devup_ui::ui_validate::site::exports_metadata(&source) => - { - "filled" - } - _ => "missing", - }, - None => "missing", - }; - items.push(json!({"item": format!("metadata {url}"), "state": state, - "detail": page.unwrap_or_else(|| "page not implemented".into())})); - } - let mut roles = BTreeMap::::new(); - let mut motion = false; - let mut reduced = false; - let mut transitions = false; - for relative in &evidence.sources { - let Some(source) = read_small(&join_relative(&evidence.root, relative)) else { - continue; - }; - for row in devup_mcp_devup_ui::ui_validate::site::role_coverage(relative, &source) { - let entry = roles.entry(row.role).or_default(); - entry.0 += row.total; - entry.1 += row.complete; - } - motion |= source.contains("transition") - || source.contains("transform") - || source.contains("animation"); - reduced |= source.contains("_motionReduce") || source.contains("prefers-reduced-motion"); - transitions |= source.contains("ViewTransition") - || source.contains("viewTransitionName") - || source.contains("@view-transition"); - } - let complete = roles.values().all(|(total, done)| total == done); - items.push(json!({"item": "interaction-roles", - "state": if roles.is_empty() { "not-applicable" } else if complete { "filled" } else { "missing" }, - "detail": if roles.is_empty() { - "no interactive elements in the sources".to_owned() - } else { - roles.iter().map(|(role, (total, done))| format!("{} {done}/{total} {}", role.as_str(), role.requirement())).collect::>().join(", ") - }})); - items.push(json!({"item": "reduced-motion", - "state": if reduced { "filled" } else if motion { "missing" } else { "not-applicable" }, - "detail": if reduced { "prefers-reduced-motion is handled" } else if motion { "transitions or transforms with no prefers-reduced-motion handling" } else { "no motion in the sources" }})); - // Decided again from what is installed now: the plan was drafted before - // the dependency update, and the update is what makes it possible. - let planned = view_transition_default(evidence); - let mechanism = text(&planned["mechanism"]).to_owned(); - let declined = plan["site"]["viewTransitions"]["answer"]["source"] == "keep-library"; - items.push(match mechanism.as_str() { - "skipped" | "" => json!({"item": "view-transitions", "state": "not-applicable", - "detail": format!("skipped: {}", text(&planned["evidence"]))}), - _ if declined => json!({"item": "view-transitions", "state": "not-applicable", - "detail": "skipped: the user kept the project's own page motion"}), - _ if transitions => json!({"item": "view-transitions", "state": "filled", - "detail": format!("enabled ({mechanism}); the runtime audit's view-transition check proves it runs")}), - _ => json!({"item": "view-transitions", "state": "missing", - "detail": format!("planned ({mechanism}) but not in the sources yet")}), - }); - let skipped_update = array(&plan["defaults"]) - .iter() - .any(|default| default["item"] == "dependencies" && default["mechanism"] == "skipped"); - let record = &plan["dependencies"]; - items.push(if skipped_update { - json!({"item": "dependencies", "state": "not-applicable", "detail": format!("not updated: updateDependencies is false in {CONFIG_FILE}")}) - } else if record.is_object() { - let pins = array(&record["pins"]) - .iter() - .map(|pin| format!("{}@{} ({})", text(&pin["package"]), text(&pin["version"]), text(&pin["reason"]))) - .collect::>(); - json!({"item": "dependencies", "state": "filled", - "detail": format!("updated {}: {} packages bumped, {}", text(&record["updatedAt"]), record["bumped"], - if pins.is_empty() { "no pins".to_owned() } else { format!("pinned {}", pins.join(", ")) })}) - } else { - json!({"item": "dependencies", "state": "missing", - "detail": format!("not updated yet: run {UPDATE_STEP}, then record it with assume (dependencyUpdate)")}) - }); - for (item, field) in [("site-name", "siteName"), ("site-url", "siteUrl")] { - let entry = &site[field]; - let state = match entry["state"].as_str() { - Some("found" | "config") => "filled", - Some("answered") if decided(field) => "asked-and-answered", - _ => "missing", - }; - items.push(json!({"item": item, "state": state, "detail": entry.get("value").or(entry.get("answer")).cloned()})); - } - items -} - -/// Names that mark a page, section or frame as not for implementation. Words -/// in ASCII match whole words; the rest match anywhere in the name. -const EXCLUDE_WORDS: &[&str] = &[ - "trash", - "휴지통", - "archive", - "archived", - "아카이브", - "old", - "구버전", - "이전", - "deprecated", - "사용안함", - "미사용", - "backup", - "백업", - "draft", - "wip", - "작업중", - "test", - "테스트", - "sandbox", - "playground", - "copy", - "사본", -]; - -fn keyword_in(name: &str, keyword: &str) -> bool { - let name = name.to_lowercase(); - let keyword = keyword.trim().to_lowercase(); - if keyword.is_empty() { - return false; - } - if keyword.chars().all(|c| c.is_ascii_alphanumeric()) { - name.split(|c: char| !c.is_alphanumeric()) - .any(|word| word == keyword) - } else { - name.contains(&keyword) - } -} - -/// The first exclude keyword in any of `names`, unless an include keyword -/// forces them in. -fn excluded_by<'a>(names: &[(&'a str, &'a str)], config: &PlanConfig) -> Option { - let include = config.include_keywords.as_deref().unwrap_or_default(); - if names - .iter() - .any(|(_, name)| include.iter().any(|keyword| keyword_in(name, keyword))) - { - return None; - } - let defaults = EXCLUDE_WORDS - .iter() - .map(|word| (*word).to_owned()) - .collect::>(); - let keywords = config.exclude_keywords.as_ref().unwrap_or(&defaults); - names.iter().find_map(|(place, name)| { - keywords - .iter() - .find(|keyword| keyword_in(name, keyword)) - .map(|keyword| format!("{place} \"{name}\" contains \"{keyword}\"")) - }) -} - -/// Why a frame is not for implementation, or `None`. The names of the -/// linked node and its ancestors are left out: the user pointed there, which -/// is asked once rather than excluded (see the `scope` question). -fn exclusion_reason( - screen: &PlanScreenInput, - linked: &[String], - config: &PlanConfig, -) -> Option { - if screen.visible == Some(false) { - return Some("hidden in Figma".into()); - } - if screen.opacity.is_some_and(|opacity| opacity <= 0.0) { - return Some("zero opacity in Figma".into()); - } - let mut names = vec![("frame", screen.name.as_str())]; - if let Some(section) = screen.section_name.as_deref() { - names.push(("section", section)); - } - for ancestor in screen - .breadcrumb - .split_last() - .map(|(_, ancestors)| ancestors) - .unwrap_or_default() - { - names.push(("parent", ancestor.as_str())); - } - names.retain(|(place, name)| *place == "frame" || !linked.iter().any(|link| link == name)); - let mut seen = BTreeSet::new(); - names.retain(|(_, name)| seen.insert(*name)); - excluded_by(&names, config) -} - -/// One devup_figma_explore candidate node as a screen to plan. Its parent -/// counts as its section only when the breadcrumb shows one between the page -/// and the frame; a frame drawn straight on the page is in no section. -pub(super) fn screen_from_explore(node: &Value) -> Option { - let breadcrumb = array(&node["breadcrumb"]) - .iter() - .filter_map(Value::as_str) - .map(str::to_owned) - .collect::>(); - let parent = node["parentId"].as_str().map(str::to_owned); - let in_section = breadcrumb.len() >= 3; - Some(PlanScreenInput { - node_id: node["nodeId"].as_str()?.to_owned(), - name: text(&node["name"]).to_owned(), - width: node["bounds"]["width"].as_f64()?, - height: node["bounds"]["height"].as_f64(), - section: parent.clone().filter(|_| in_section), - section_name: in_section.then(|| breadcrumb[breadcrumb.len() - 2].clone()), - ancestors: parent.into_iter().collect(), - visible: node["visible"].as_bool(), - opacity: node["opacity"].as_f64(), - breadcrumb, - ..PlanScreenInput::default() - }) -} - -/// Whether a frame is under the node the user linked. A frame that carries -/// no ancestry at all came from the caller's own list and is taken as given; -/// one whose ancestry is known and does not reach the link is never a -/// candidate. -pub(super) fn in_scope( - screen: &PlanScreenInput, - anchor_id: Option<&str>, - anchor_name: Option<&str>, -) -> bool { - let Some(anchor) = anchor_id else { - return true; - }; - if screen.node_id == anchor - || screen.section.as_deref() == Some(anchor) - || screen.ancestors.iter().any(|id| id == anchor) - { - return true; - } - let ancestor_names = screen - .breadcrumb - .split_last() - .map(|(_, ancestors)| ancestors) - .unwrap_or_default(); - if anchor_name.is_some_and(|name| ancestor_names.iter().any(|ancestor| ancestor == name)) { - return true; - } - screen.section.is_none() && screen.ancestors.is_empty() && screen.breadcrumb.is_empty() -} - -fn with_when(mut question: Value, conditions: &[Value]) -> Value { - if !conditions.is_empty() { - let mut all = array(&question["when"]).to_vec(); - all.extend(conditions.iter().cloned()); - question["when"] = json!(all); - } - question -} - -const SECTIONS_SEPARATE: &str = "Separate pages: the sections are different pages"; -const SECTIONS_SINGLE: &str = "One responsive page: the sections only split by device"; -const KEEP_EXCLUDED: &str = "Keep it excluded"; -const BUILD_IT: &str = "Build it"; -const ACCEPT_EXCLUSIONS: &str = "Accept the exclusions"; -const PLAN_ANYWAY: &str = "Plan them: this is what I linked"; -const NOTHING_HERE: &str = "Nothing here is for implementation"; - -/// One route the evidence names, what kind of evidence it is, and where. -#[derive(Debug, Clone)] -struct RouteHit { - route: String, - kind: &'static str, - detail: String, -} - -/// `name-match` is the only kind that is a guess: a layer name compared with -/// a route segment. Every other kind is something the project or the caller -/// already says. -const NAME_MATCH: &str = "name-match"; - -/// Every route the evidence supports for these frames. -fn route_hits( - frames: &[Frame], - words: &[String], - targets: &BTreeMap, - evidence: &ProjectEvidence, - notes: &mut Vec, -) -> Vec { - let mut hits = Vec::new(); - for frame in frames { - if let Some(target) = targets.get(&frame.node_id) { - match normalize_route(target) { - Some(route) => hits.push(RouteHit { - route, - kind: "navigationTargets", - detail: format!("navigationTargets[{}]", frame.node_id), - }), - None => notes.push(format!( - "navigationTargets[{}] = {target:?} is not an app route and was not used.", - frame.node_id - )), - } - } - if let Some((route, file)) = evidence.figma_nodes.get(&frame.node_id) { - hits.push(RouteHit { - route: route.clone(), - kind: "figma-node", - detail: format!("data-figma-node-id=\"{}\" in {file}", frame.node_id), - }); - } - for (alias, route) in &evidence.config.route_aliases { - if alias == &frame.name || compact(alias) == compact(&words.join("")) { - hits.push(RouteHit { - route: route.clone(), - kind: "config", - detail: format!("{CONFIG_FILE} routeAliases[{alias:?}]"), - }); - } - } - } - let name = compact(&words.join("")); - for (label, href, location) in &evidence.nav_links { - if compact(label).is_empty() || compact(label) != name { - continue; - } - if let Some((route, _)) = evidence - .routes - .iter() - .find(|(route, _)| super::navigation::matches_route(route, href)) - { - hits.push(RouteHit { - route: route.clone(), - kind: "nav-label", - detail: format!("nav-label: {label:?} -> {href} ({location})"), - }); - } - } - if hits.is_empty() { - let screen_slug = slug(words); - let is_root = words.iter().any(|word| ROOT_WORDS.contains(&word.as_str())); - for (route, page) in &evidence.routes { - // The last segment only: `/news/[id]` is a detail page, and a - // screen named News is the list, not that page. - let last = route - .trim_matches('/') - .rsplit('/') - .next() - .unwrap_or_default(); - if (!screen_slug.is_empty() && last == screen_slug) || (route == "/" && is_root) { - hits.push(RouteHit { - route: route.clone(), - kind: NAME_MATCH, - detail: format!("layer name matches the existing route {route} ({page})"), - }); - } - } - } - hits -} - -/// The route the hits agree on, if they agree. -fn agreed(hits: &[RouteHit]) -> Option<&RouteHit> { - let distinct = hits - .iter() - .map(|hit| hit.route.as_str()) - .collect::>(); - (distinct.len() == 1).then(|| &hits[0]) -} - -fn existing_page<'a>(route: &str, evidence: &'a ProjectEvidence) -> Option<&'a str> { - evidence - .routes - .iter() - .find(|(candidate, _)| candidate == route) - .map(|(_, page)| page.as_str()) -} - -fn planned_page(route: &str, app_dir: &str) -> String { - let route = route.trim_matches('/'); - if route.is_empty() { - format!("{app_dir}/page.tsx") - } else { - format!("{app_dir}/{route}/page.tsx") - } -} - -/// A condition on another question's answer: this question is asked only -/// when that one resolved to `action`. The caller sees the label. -fn when(question: &str, label: &str, action: Value) -> Value { - json!({"question": question, "answer": label, "action": action}) -} - -const SINGLE: &str = "One responsive page"; -const SEPARATE: &str = "Separate pages"; -const ACCEPT_ALL: &str = "Accept all"; -const REVIEW: &str = "Review individually"; - -/// Builds the draft plan. Pure: every input is passed in, so the rules are -/// tested without Figma or a project on disk. -#[cfg(test)] -pub(super) fn draft_plan( - screens: &[PlanScreenInput], - targets: &BTreeMap, - evidence: &ProjectEvidence, - source: Value, - notes: Option<&str>, -) -> Result { - draft_plan_with(screens, &[], &[], targets, evidence, source, notes) -} - -/// [`draft_plan`] with Figma nodes that may become the favicon or share -/// image. -pub(super) fn draft_plan_with( - screens: &[PlanScreenInput], - assets: &[PlanAssetInput], - extra: &[ExtraQuestionInput], - targets: &BTreeMap, - evidence: &ProjectEvidence, - source: Value, - notes: Option<&str>, -) -> Result { - if screens.is_empty() { - return Err(invalid( - "draft found no screens. Link a Section or frame, or pass screens from devup_figma_explore.", - )); - } - if screens.len() > MAX_SCREENS { - return Err(invalid(format!( - "draft plans at most {MAX_SCREENS} screens; link one Section at a time." - ))); - } - // Only what the user pointed at, and never what is marked as not for - // implementation. Both are listed, not dropped. - let anchor_id = source["nodeId"].as_str(); - let anchor_name = source["anchorName"].as_str(); - let linked = array(&source["anchorBreadcrumb"]) - .iter() - .filter_map(Value::as_str) - .map(str::to_owned) - .chain(anchor_name.map(str::to_owned)) - .collect::>(); - let mut out_of_scope = Vec::new(); - let mut excluded = Vec::new(); - let mut kept = Vec::new(); - for screen in screens { - if !in_scope(screen, anchor_id, anchor_name) { - out_of_scope.push(json!({"nodeId": screen.node_id, "name": screen.name, - "reason": "not under the linked node; never a candidate"})); - continue; - } - match exclusion_reason(screen, &linked, &evidence.config) { - Some(reason) => excluded.push((screen, reason)), - None => kept.push(screen.clone()), - } - } - if kept.is_empty() && excluded.is_empty() { - return Err(invalid( - "No screen is under the linked node. Link the Section or frame to plan; the plan never widens past it.", - )); - } - let mut questions = Vec::new(); - let mut base_when = Vec::new(); - let linked_names = linked - .iter() - .map(|name| ("linked node", name.as_str())) - .collect::>(); - if let Some(reason) = excluded_by(&linked_names, &evidence.config) { - questions.push(question( - "scope".into(), - "Linked area: build it?".into(), - format!( - "The link you gave points inside an area whose name marks it as not for implementation: {reason}. Plan the screens under it anyway?" - ), - vec![ - choice( - PLAN_ANYWAY, - "You linked this area on purpose; plan its screens.", - json!({"scope": "use"}), - ), - choice( - NOTHING_HERE, - "Build nothing from this link.", - json!({"scope": "none"}), - ), - ], - "scope", - )); - base_when.push(when("scope", PLAN_ANYWAY, json!({"scope": "use"}))); - } - if !excluded.is_empty() { - let listed = excluded - .iter() - .take(8) - .map(|(screen, reason)| format!("\"{}\" ({}): {reason}", screen.name, screen.node_id)) - .collect::>() - .join("; "); - let more = if excluded.len() > 8 { - format!("; and {} more listed in the plan", excluded.len() - 8) - } else { - String::new() - }; - questions.push(with_when( - question( - "exclusions".into(), - "Excluded areas".into(), - format!( - "These {} frames were excluded as trash, archive, draft, copy or hidden, and will not be built: {listed}{more}. Accept the exclusions?", - excluded.len() - ), - vec![ - choice( - ACCEPT_EXCLUSIONS, - "None of them is built; they stay listed in the plan.", - json!({"exclusions": "accept"}), - ), - choice( - REVIEW, - "Ask about each one in the next round.", - json!({"exclusions": "review"}), - ), - ], - "exclusions", - ), - &base_when, - )); - let review = when("exclusions", REVIEW, json!({"exclusions": "review"})); - for (screen, reason) in &excluded { - let id = &screen.node_id; - let conditions = [base_when.clone(), vec![review.clone()]].concat(); - questions.push(with_when( - question( - format!("exclusion:{id}"), - header("Excluded", &screen.name), - format!( - "\"{}\" ({id}) was excluded: {reason}. Keep it out of the build?", - screen.name - ), - vec![ - choice( - KEEP_EXCLUDED, - "Not built; listed as excluded.", - json!({"build": false}), - ), - choice( - BUILD_IT, - "Build it; its URL is asked next.", - json!({"build": true}), - ), - ], - "exclusion", - ), - &conditions, - )); - let screen_slug = slug(&base_words(&screen.name)); - let mut choices = Vec::new(); - if !screen_slug.is_empty() { - choices.push(choice( - format!("/{screen_slug}"), - "Named after the layer.", - json!({"route": format!("/{screen_slug}")}), - )); - } - choices.push(choice( - SKIP, - "Leave it out after all.", - json!({"skip": true}), - )); - if choices.len() < 2 { - choices.push(choice( - NOT_A_PAGE, - "It is a section or state of another screen; it gets no URL.", - json!({"notPage": true}), - )); - } - let built = [ - conditions, - vec![when( - &format!("exclusion:{id}"), - BUILD_IT, - json!({"build": true}), - )], - ] - .concat(); - questions.push(with_when( - question( - format!("exclusion-route:{id}"), - header("Route", &screen.name), - format!("Which URL should \"{}\" ({id}) be served at?", screen.name), - choices, - "route", - ), - &built, - )); - } - } - let excluded_rows = excluded - .iter() - .map(|(screen, reason)| { - json!({"nodeId": screen.node_id, "name": screen.name, "width": screen.width, - "section": screen.section_name.clone().or_else(|| screen.section.clone()), - "reason": reason}) - }) - .collect::>(); - // A frame named like a logo or a share image is a site asset, not a page. - let mut site_assets = assets - .iter() - .filter_map(|asset| { - asset_kind(&asset.name).map(|kind| (asset.node_id.clone(), asset.name.clone(), kind)) - }) - .collect::>(); - kept.retain(|screen| match asset_kind(&screen.name) { - Some(kind) => { - site_assets.push((screen.node_id.clone(), screen.name.clone(), kind)); - false - } - None => true, - }); - for asked in extra_questions(extra)? { - questions.push(with_when(asked, &base_when)); - } - let (screen_apps, app_questions, app_default) = assign_apps(evidence, &kept); - for asked in app_questions { - questions.push(with_when(asked, &base_when)); - } - // One app for the whole plan: its routes are the only route evidence. - let chosen = screen_apps - .values() - .map(|app| app.dir.clone()) - .collect::>(); - let scoped; - let evidence = match (chosen.len(), screen_apps.values().next()) { - (1, Some(app)) - if app_default - .as_ref() - .is_some_and(|item| item["mechanism"] == "decided") => - { - let mut narrowed = evidence.clone(); - narrowed - .routes - .retain(|(_, page)| page.starts_with(&format!("{}/", app.dir))); - narrowed.app_dir = app.path(app.app_router.as_deref().unwrap_or("src/app")); - narrowed.target_app = Some(app.clone()); - scoped = narrowed; - &scoped - } - _ => evidence, - }; - // A mixed file builds into several apps: each screen's routes come from - // its own app, never from another app's pages. - let per_app = if chosen.len() > 1 { - chosen - .iter() - .filter_map(|dir| { - let app = screen_apps.values().find(|app| &app.dir == dir)?; - let mut narrowed = evidence.clone(); - narrowed - .routes - .retain(|(_, page)| page.starts_with(&format!("{dir}/"))); - narrowed.app_dir = app.path(app.app_router.as_deref().unwrap_or("src/app")); - narrowed.target_app = Some(app.clone()); - Some((dir.clone(), narrowed)) - }) - .collect::>() - } else { - BTreeMap::new() - }; - let evidence_for = |frames: &[Frame]| -> &ProjectEvidence { - frames - .first() - .and_then(|frame| screen_apps.get(&frame.node_id)) - .and_then(|app| per_app.get(&app.dir)) - .unwrap_or(evidence) - }; - for asked in stack_questions(evidence) { - questions.push(with_when(asked, &base_when)); - } - let defaults = [ - app_default.into_iter().collect::>(), - stack_defaults(evidence), - site_defaults(evidence, &kept), - ] - .concat(); - let cover = kept - .first() - .map(|screen| (screen.node_id.clone(), screen.name.clone())); - let (site, site_asked) = site_questions( - evidence, - &site_assets, - cover - .as_ref() - .map(|(node, name)| (node.as_str(), name.as_str())), - ); - for asked in site_asked { - questions.push(with_when(asked, &base_when)); - } - let mut drafts = group_screens(&kept, &evidence.config); - for draft in &mut drafts { - draft.extra_when.extend(base_when.iter().cloned()); - } - for oddity in structure_questions(&drafts) { - let first = drafts[oddity.screens[0]].id().to_owned(); - let id = format!("structure:{}:{first}", oddity.kind); - let frames = oddity - .screens - .iter() - .flat_map(|index| drafts[*index].frames.iter().map(Frame::json)) - .collect::>(); - let mut asked = question( - id.clone(), - header("Figma structure", drafts[oddity.screens[0]].name()), - format!( - "{} Is that intended, or should the Figma file be fixed? This plan does not decide which frame is right.", - oddity.facts - ), - vec![ - choice( - oddity.keep, - oddity.keep_description.clone(), - json!({"structure": "keep"}), - ), - choice( - FIX_FIGMA, - "These frames are not built. They are listed under Design fixes requested; everything else goes on.", - json!({"designFix": true}), - ), - ], - "structure", - ); - asked["fix"] = json!({"frames": frames, "text": oddity.fix}); - questions.push(with_when(asked, &base_when)); - for index in &oddity.screens { - drafts[*index] - .extra_when - .push(when(&id, oddity.keep, json!({"structure": "keep"}))); - drafts[*index].structure.push(id.clone()); - } - } - let mut screen_notes = vec![Vec::new(); drafts.len()]; - let mut decided = drafts - .iter() - .enumerate() - .map(|(index, screen)| { - let hits = route_hits( - &screen.frames, - &screen.words, - targets, - evidence_for(&screen.frames), - &mut screen_notes[index], - ); - let proposed = agreed(&hits).map(|hit| (hit.route.clone(), hit.kind)); - (hits, proposed) - }) - .collect::>(); - // Two screens on one URL is a decision, not a fact: neither keeps it unasked. - let mut owners = BTreeMap::>::new(); - for (index, (_, proposed)) in decided.iter().enumerate() { - if let Some((route, _)) = proposed { - owners.entry(route.clone()).or_default().push(index); - } - } - for indices in owners.values().filter(|indices| indices.len() > 1) { - for index in indices { - if let Some((route, _)) = decided[*index].1.take() { - screen_notes[*index].push(format!( - "{route} is also the route of another screen in this plan." - )); - } - } - } - let claimed = decided - .iter() - .filter_map(|(_, proposed)| proposed.as_ref().map(|(route, _)| route.clone())) - .collect::>(); - let modal_words = evidence - .config - .modal_keywords - .clone() - .unwrap_or_else(|| MODAL_WORDS.iter().map(|word| (*word).to_owned()).collect()); - let kind_evidence = if evidence.config.modal_keywords.is_some() { - "config" - } else { - NAME_MATCH - }; - let is_modal = |name: &str| { - let lowered = name.to_lowercase(); - modal_words - .iter() - .any(|word| lowered.contains(&word.to_lowercase())) - }; - let page_screens = drafts - .iter() - .enumerate() - .filter(|(_, screen)| !is_modal(screen.name())) - .map(|(index, _)| index) - .collect::>(); - let frame_owner = drafts - .iter() - .enumerate() - .flat_map(|(index, screen)| { - screen - .frames - .iter() - .map(move |frame| (frame.node_id.clone(), index)) - }) - .collect::>(); - let route_choice = |evidence: &ProjectEvidence, route: &str, why: &str, extra: Value| { - let description = match existing_page(route, evidence) { - Some(page) => format!("{why} Exists at {page}; the screen is merged into it."), - None => format!( - "{why} New page at {}.", - planned_page(route, &evidence.app_dir) - ), - }; - let mut action = json!({"route": route}); - if let (Some(object), Some(extra)) = (action.as_object_mut(), extra.as_object()) { - object.extend(extra.clone()); - } - choice(route, description, action) - }; - let unclaimed_for = - |evidence: &ProjectEvidence, words: &[String], exclude: &BTreeSet| { - let mut unclaimed = evidence - .routes - .iter() - .filter(|(route, _)| !claimed.contains(route) && !exclude.contains(route)) - .map(|(route, _)| { - let score = route - .trim_matches('/') - .split(['/', '-']) - .filter(|segment| words.iter().any(|word| word == segment)) - .count(); - (score, route.clone()) - }) - .collect::>(); - unclaimed.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); - unclaimed - }; - let mut rows = Vec::new(); - let mut heuristics = Vec::new(); - let mut part_screens = BTreeMap::>::new(); - for (index, screen) in drafts.iter().enumerate() { - let evidence = evidence_for(&screen.frames); - let (hits, proposed) = &decided[index]; - let modal = is_modal(screen.name()); - let id = screen.id().to_owned(); - let name = screen.name().to_owned(); - let widths = screen.widths(); - let mut decisions = Vec::new(); - let candidates = hits - .iter() - .map(|hit| hit.route.clone()) - .collect::>(); - let screen_slug = slug(&screen.words); - let new_route = (!screen_slug.is_empty()).then(|| format!("/{screen_slug}")); - // Questions about this screen as one page apply only if the group is - // one page; when that is still being asked, they carry the condition. - let group_id = screen.grouping_question().unwrap_or_default(); - let (single_label, separate_label) = match screen.grouping { - Grouping::Sections { .. } => (SECTIONS_SINGLE, SECTIONS_SEPARATE), - _ => (SINGLE, SEPARATE), - }; - let mut single_when = screen.extra_when.clone(); - if !matches!(screen.grouping, Grouping::Fixed(_)) { - single_when.push(when(&group_id, single_label, json!({"group": "single"}))); - } - let push = |questions: &mut Vec, mut question: Value, extra: &[Value]| { - let conditions = single_when.iter().chain(extra).cloned().collect::>(); - if !conditions.is_empty() { - question["when"] = json!(conditions); - } - questions.push(question); - }; - let grouping_question = match &screen.grouping { - Grouping::Fixed(_) => None, - Grouping::Ask { - recommend_single, - reason, - } => { - let single = choice( - SINGLE, - "One URL; the widths become devup-ui responsive arrays (responsiveTsx).", - json!({"group": "single"}), - ); - let separate = choice( - SEPARATE, - "Each frame is its own page and gets its own route, asked next.", - json!({"group": "separate"}), - ); - Some(question( - group_id.clone(), - header("Pages", &name), - format!( - "These frames share the name \"{name}\" and are {reason}: {}. One responsive page, or separate pages? A custom answer may list one route per frame, e.g. \"/a, /b\".", - describe_frames(&screen.frames) - ), - if *recommend_single { - vec![single, separate] - } else { - vec![separate, single] - }, - "group", - )) - } - Grouping::Sections { - all_device, - sections, - why, - } => { - let separate = choice( - SECTIONS_SEPARATE, - "Each frame is its own page; its URL is asked next.", - json!({"group": "separate"}), - ); - let single = choice( - SECTIONS_SINGLE, - if *all_device { - format!( - "Inferred from the section names ({}), which name only devices.", - sections.join(", ") - ) - } else { - "The sections would only split one page by device.".to_owned() - }, - json!({"group": "single"}), - ); - let fix = choice( - FIX_FIGMA, - "These frames are not built. They are listed under Design fixes requested; everything else goes on.", - json!({"designFix": true}), - ); - let mut asked = question( - group_id.clone(), - header("Sections", &name), - format!( - "Frames that look like the same screen sit in different sections ({}): {}. They look related because of {why}. Which is it? A custom answer may list one route per frame, e.g. \"/a, /b\".", - sections.join(", "), - describe_frames(&screen.frames) - ), - if *all_device { - vec![single, separate, fix] - } else { - vec![separate, single, fix] - }, - "group", - ); - asked["fix"] = json!({ - "frames": screen.frames.iter().map(Frame::json).collect::>(), - "text": format!( - "Frames {} share the name \"{name}\" but sit in sections {}. Move them into one section if they are one page, or rename them if they are different pages.", - describe_frames(&screen.frames), - sections.join(", ") - ), - }); - Some(asked) - } - }; - if let Some(asked) = grouping_question { - decisions.push("one-page-or-separate"); - questions.push(with_when(asked, &screen.extra_when)); - for frame in &screen.frames { - let mut frame_notes = Vec::new(); - let frame_hits = route_hits( - std::slice::from_ref(frame), - &screen.words, - targets, - evidence, - &mut frame_notes, - ); - let mut choices = Vec::new(); - for hit in frame_hits.iter().filter(|hit| hit.kind != NAME_MATCH) { - choices.push(route_choice( - evidence, - &hit.route, - &format!("From {}.", hit.detail), - json!({}), - )); - } - for hit in frame_hits.iter().filter(|hit| hit.kind == NAME_MATCH) { - choices.push(route_choice( - evidence, - &hit.route, - "Inferred from the layer name; only one frame can take it.", - json!({}), - )); - } - let section_slug = frame - .section_name - .as_deref() - .map(|section| slug(&base_words(section))) - .unwrap_or_default(); - if !section_slug.is_empty() && !screen_slug.is_empty() { - choices.push(route_choice( - evidence, - &format!("/{section_slug}/{screen_slug}"), - "Named after its Figma section and layer.", - json!({}), - )); - } - for (_, route) in unclaimed_for(evidence, &screen.words, &candidates) - .iter() - .filter(|(score, _)| *score > 0) - { - choices.push(route_choice( - evidence, - route, - "Existing route with a similar name.", - json!({}), - )); - } - choices.truncate(MAX_OPTIONS - 1); - choices.push(choice( - SKIP, - "Leave this frame out of this build.", - json!({"skip": true}), - )); - if choices.len() < 2 { - choices.push(choice( - NOT_A_PAGE, - "It is a section or state of another screen; it gets no URL.", - json!({"notPage": true}), - )); - } - let asked = question( - format!("split-route:{}", frame.node_id), - header("Route", &format!("{} {}", frame.name, frame.width.round())), - format!( - "As a separate page, which URL should \"{}\" ({}, {}px) be served at?", - frame.name, - frame.node_id, - frame.width.round() - ), - choices, - "route", - ); - let separate_when = [ - screen.extra_when.clone(), - vec![when( - &group_id, - separate_label, - json!({"group": "separate"}), - )], - ] - .concat(); - questions.push(with_when(asked, &separate_when)); - } - } - let route_evidence = match proposed { - Some((_, kind)) if *kind == NAME_MATCH => json!(NAME_MATCH), - Some(_) => json!( - hits.iter() - .map(|hit| hit.detail.clone()) - .collect::>() - .join("; ") - ), - None => json!("UNVERIFIED"), - }; - if modal { - decisions.push("modal-or-route"); - let mut hosts = page_screens - .iter() - .filter(|other| **other != index) - .map(|other| &drafts[*other]) - .map(|host| { - let shared = host - .words - .iter() - .filter(|word| screen.words.contains(word)) - .count(); - (shared, host) - }) - .collect::>(); - hosts.sort_by_key(|host| std::cmp::Reverse(host.0)); - let state_choices = hosts - .iter() - .take(2) - .map(|(_, host)| { - choice( - format!("State on {}", truncate(host.name(), 40)), - format!( - "Open/close state inside the page built from {} ({}); no URL of its own.", - host.name(), - host.id() - ), - json!({"state": host.id()}), - ) - }) - .collect::>(); - let own_route = proposed - .as_ref() - .map(|(route, _)| { - route_choice(evidence, route, "A page with its own URL.", json!({})) - }) - .or_else(|| { - new_route.as_ref().map(|route| { - route_choice( - evidence, - route, - "A page with its own URL, named after the layer.", - json!({}), - ) - }) - }); - let mut choices = Vec::new(); - if proposed - .as_ref() - .is_some_and(|(_, kind)| *kind != NAME_MATCH) - { - choices.extend(own_route); - choices.extend(state_choices); - } else { - choices.extend(state_choices); - choices.extend(own_route); - } - choices.push(choice( - SKIP, - "Leave it out of this build.", - json!({"skip": true}), - )); - let why = if kind_evidence == NAME_MATCH { - "Its layer name suggests a modal or overlay (name-match)" - } else { - "Its layer name matches the project's modalKeywords" - }; - push( - &mut questions, - question( - format!("modal:{id}"), - header("Modal", &name), - format!( - "\"{name}\" ({id}): {why}. Is it a state of another screen, or a page with its own URL?" - ), - choices, - "modal", - ), - &[], - ); - if kind_evidence == NAME_MATCH { - heuristics.push(json!({"screen": id, "name": name, "decision": "kind: modal", "evidence": NAME_MATCH, "askedIn": format!("modal:{id}")})); - } - } else if let Some((route, NAME_MATCH)) = proposed { - decisions.push("route-from-name-match"); - heuristics.push(json!({"screen": id, "name": name, "decision": format!("route: {route}"), "evidence": NAME_MATCH, "askedIn": format!("review:{id}")})); - let mut choices = vec![ - route_choice( - evidence, - route, - "Keep the inferred route.", - json!({"collision": "merge"}), - ), - choice( - format!("Overwrite {route}"), - "Keep the inferred route, but replace the existing page file outright.", - json!({"route": route, "collision": "overwrite"}), - ), - ]; - if let Some(alternative) = new_route - .as_ref() - .filter(|candidate| *candidate != route && !claimed.contains(*candidate)) - { - choices.push(route_choice( - evidence, - alternative, - "Named after the layer.", - json!({}), - )); - } - choices.push(choice( - SKIP, - "Leave it out of this build.", - json!({"skip": true}), - )); - push( - &mut questions, - question( - format!("review:{id}"), - header("Check route", &name), - format!( - "\"{name}\" ({id}) was matched to {route} by its layer name only. Keep it?" - ), - choices, - "route", - ), - &[when("heuristics", REVIEW, json!({"heuristics": "review"}))], - ); - } else if proposed.is_none() { - decisions.push(if candidates.len() > 1 { - "conflicting-route-evidence" - } else { - "no-route-evidence" - }); - let mut choices = Vec::new(); - for route in &candidates { - choices.push(route_choice( - evidence, - route, - "Named by conflicting evidence.", - json!({}), - )); - } - let unclaimed = unclaimed_for(evidence, &screen.words, &candidates); - for (_, route) in unclaimed.iter().filter(|(score, _)| *score > 0) { - choices.push(route_choice( - evidence, - route, - "Existing route with a similar name.", - json!({}), - )); - } - if let Some(route) = new_route.as_ref().filter(|route| !claimed.contains(*route)) { - choices.push(route_choice( - evidence, - route, - "Named after the layer.", - json!({}), - )); - } - for (_, route) in unclaimed.iter().filter(|(score, _)| *score == 0).take(1) { - choices.push(route_choice( - evidence, - route, - "Existing route not claimed by another screen.", - json!({}), - )); - } - choices.truncate(MAX_OPTIONS - 1); - choices.push(choice( - SKIP, - "Leave it out of this build.", - json!({"skip": true}), - )); - if choices.len() < 2 { - choices.push(choice( - NOT_A_PAGE, - "It is a section or state of another screen; it gets no URL.", - json!({"notPage": true}), - )); - } - let reason = if candidates.len() > 1 { - format!( - "The evidence names {} different routes.", - candidates.iter().cloned().collect::>().join(", ") - ) - } else { - "No existing page, nav link, navigationTargets entry or routeAliases entry names it." - .to_owned() - }; - push( - &mut questions, - question( - format!("route:{id}"), - header("Route", &name), - format!( - "Which URL should the Figma screen \"{name}\" ({id}, drawn at {}) be served at? {reason}", - width_list(&widths) - ), - choices, - "route", - ), - &[], - ); - } - let route = proposed.as_ref().map(|(route, _)| route.as_str()); - let existing = route.and_then(|route| existing_page(route, evidence)); - let name_matched = proposed - .as_ref() - .is_some_and(|(_, kind)| *kind == NAME_MATCH); - if !modal - && !name_matched - && let (Some(route), Some(page)) = (route, existing) - { - decisions.push("collides-with-existing-page"); - push( - &mut questions, - question( - format!("collision:{id}"), - header("Existing page", route), - format!( - "{route} already has a page ({page}). What should building \"{name}\" ({id}) do to it?" - ), - vec![ - choice( - "Merge into the existing page", - "Keep the page file and its data wiring; replace its markup with the screen.", - json!({"collision": "merge"}), - ), - choice( - "Overwrite the existing page", - "Replace the page file outright; existing wiring is lost.", - json!({"collision": "overwrite"}), - ), - choice( - SKIP, - "Leave the existing page as it is.", - json!({"collision": "skip"}), - ), - ], - "collision", - ), - &[], - ); - } - if let Some(route) = route.filter(|route| route.contains('[')) { - decisions.push("dynamic-segment"); - push( - &mut questions, - question( - format!("dynamic:{id}"), - header("Dynamic route", route), - format!( - "{route} has a dynamic segment. Where does \"{name}\" get the record it shows?" - ), - vec![ - choice( - "Sample data from the design", - "Render the Figma content as fixture data for one id; wire real data later.", - json!({"data": "sample"}), - ), - choice( - "Wire to the API now", - "Fetch the record by the segment value; needs the endpoint from devup_project_context scope api.", - json!({"data": "api"}), - ), - ], - "dynamic", - ), - &[], - ); - } - if widths.len() > 1 { - decisions.push("several-widths"); - } - let mut links = Vec::new(); - for (link_index, link) in screen.links.iter().enumerate() { - let resolved = link - .url - .as_deref() - .map(|url| json!({"url": url, "evidence": "figma-url"})) - .or_else(|| { - let destination = link.destination_node_id.as_deref()?; - targets - .get(destination) - .map(|url| json!({"url": url, "evidence": format!("navigationTargets[{destination}]")})) - .or_else(|| { - frame_owner.get(destination).map(|owner| { - json!({"screen": drafts[*owner].id(), "evidence": "destination frame in this plan"}) - }) - }) - }); - if resolved.is_none() { - decisions.push("unresolved-link"); - let mut choices = vec![choice( - "Leave unlinked", - "Render it without an href; wire it once the destination exists.", - json!({"link": null}), - )]; - for (route, _) in evidence.routes.iter().take(MAX_OPTIONS - 1) { - choices.push(choice( - route.as_str(), - format!("Link to the existing route {route}."), - json!({"link": route}), - )); - } - if choices.len() < 2 { - choices.push(choice( - "Remove the link", - "Render the label as plain text.", - json!({"link": "remove"}), - )); - } - questions.push(with_when(question( - format!("link:{id}:{link_index}"), - header("Link", &link.label), - format!( - "\"{}\" on \"{name}\" ({id}) links somewhere this plan cannot see{}. Where should it go?", - link.label, - link.destination_node_id - .as_deref() - .map(|node| format!(" (Figma destination {node})")) - .unwrap_or_default() - ), - choices, - "link", - ), &screen.extra_when)); - } - links.push(json!({"label": link.label, "destinationNodeId": link.destination_node_id, "resolved": resolved})); - } - let mut shared = evidence.layout_parts.clone(); - for part in &screen.parts { - part_screens - .entry(part.clone()) - .or_default() - .push(id.clone()); - if !shared.contains(part) { - shared.push(part.clone()); - } - } - rows.push(json!({ - "id": id, - "name": name, - "kind": if modal { "modal" } else { "page" }, - "kindEvidence": if modal { json!(kind_evidence) } else { Value::Null }, - "frames": screen.frames.iter().map(Frame::json).collect::>(), - "drawnWidths": widths, - "app": screen_apps.get(screen.id()).map(|app| app.dir.clone()), - "appDir": screen_apps.get(screen.id()).map(|app| app.path(app.app_router.as_deref().unwrap_or("src/app"))), - "groupingQuestion": screen.grouping_question(), - "structureQuestions": screen.structure, - "grouping": match &screen.grouping { - Grouping::Fixed(evidence) => json!({"mode": "decided", "evidence": evidence}), - Grouping::Sections { all_device, sections, why } => json!({"mode": "ask-sections", "question": group_id, - "sections": sections, "allDeviceNames": all_device, "why": why}), - Grouping::Ask { recommend_single, reason } => json!({"mode": "ask", "question": group_id, - "recommended": if *recommend_single { "single" } else { "separate" }, "reason": reason}), - }, - "proposedRoute": route, - "routeEvidence": route_evidence, - "routeCandidates": candidates, - "existingFile": existing, - "sharedParts": shared, - "links": links, - "openDecisions": decisions, - "notes": screen_notes[index], - })); - } - let route_heuristics = heuristics - .iter() - .filter(|item| text(&item["askedIn"]).starts_with("review:")) - .collect::>(); - if !route_heuristics.is_empty() { - let listed = route_heuristics - .iter() - .map(|item| format!("{} -> {}", text(&item["name"]), text(&item["decision"]))) - .collect::>() - .join(", "); - questions.push(question( - "heuristics".into(), - "Inferred from layer names".into(), - format!( - "These were inferred from layer names only: {listed}. Accept all (merged into the existing pages), or review each one?" - ), - vec![ - choice( - ACCEPT_ALL, - "Use every inferred route; existing pages are merged into, not overwritten.", - json!({"heuristics": "accept"}), - ), - choice( - REVIEW, - "Ask one question per inferred route, in the next round.", - json!({"heuristics": "review"}), - ), - ], - "heuristics", - )); - } - for (part, owners) in part_screens.iter().filter(|(part, owners)| { - owners.len() > 1 - && !evidence - .layout_parts - .iter() - .any(|existing| compact(existing) == compact(part)) - }) { - questions.push(question( - format!("layout:{}", compact(part)), - header("Shared part", part), - format!( - "\"{part}\" appears on {} screens and no layout renders it yet. Extract it once into a shared layout?", - owners.len() - ), - vec![ - choice( - "Extract into a shared layout", - "Build it once and render it from the App Router layout.", - json!({"layout": "shared"}), - ), - choice( - "Keep it in each page", - "Every page renders its own copy.", - json!({"layout": "per-page"}), - ), - ], - "layout", - )); - } - // Questions about the whole plan wait on the linked area being planned. - for asked in questions - .iter_mut() - .filter(|asked| asked["id"] == "heuristics" || text(&asked["id"]).starts_with("layout:")) - { - *asked = with_when(asked.clone(), &base_when); - } - let body = json!({"source": source, "screens": rows, "questions": questions, "notes": notes}); - let plan_id = format!( - "sp-{}", - &sha256(&serde_json::to_vec(&body).unwrap_or_default())[..16] - ); - Ok(json!({ - "kind": PLAN_KIND, - "schemaVersion": SCHEMA_VERSION, - "planId": plan_id, - "status": "draft", - "source": body["source"], - "notes": notes, - "appDir": evidence.app_dir, - "inventoryNote": evidence.inventory_note, - "config": evidence.config_file, - "screens": body["screens"], - "heuristics": heuristics, - "excluded": excluded_rows, - "outOfScope": out_of_scope, - "site": site, - "defaults": defaults, - "apps": evidence.apps.iter().map(super::framework::App::json).collect::>(), - "targetApp": evidence.target_app.as_ref().map(super::framework::App::json), - "siteAssets": site_assets.iter().map(|(node, name, kind)| json!({"nodeId": node, "name": name, "kind": kind})).collect::>(), - "questions": body["questions"], - "answers": {}, - "routes": [], - "assumptions": [], - })) -} - -/// How deep a question sits behind the answers it depends on. A question is -/// asked in a round after every question it depends on. -fn depth(question: &Value, questions: &[Value]) -> usize { - array(&question["when"]) - .iter() - .filter_map(|condition| { - questions - .iter() - .find(|other| other["id"] == condition["question"]) - }) - .map(|parent| depth(parent, questions) + 1) - .max() - .unwrap_or(0) -} - -/// The questions split into rounds a question tool takes in one call each: -/// at most four questions of at most four options, every question exactly -/// once, and a conditional question always in a later round than the one it -/// depends on. -pub(super) fn question_rounds(questions: &[Value], per_round: usize) -> Vec { - let mut by_depth = BTreeMap::>::new(); - for question in questions { - by_depth - .entry(depth(question, questions)) - .or_default() - .push(question); - } - let mut rounds = Vec::new(); - for level in by_depth.values() { - for chunk in level.chunks(per_round.max(1)) { - let shaped = ask_shape_with( - questions, - &chunk - .iter() - .map(|question| (*question).clone()) - .collect::>(), - ); - rounds.push(json!({"round": rounds.len() + 1, "questions": shaped})); - } - } - rounds -} - -fn plan_path(project_root: &str, relative: &str) -> String { - Path::new(project_root) - .join(relative) - .to_string_lossy() - .into_owned() -} - -/// Writes the plan JSON and its Markdown twin in one transaction, so the two -/// never describe different plans. -fn write_plan( - policy: &OutputPolicy, - project_root: &str, - plan: &Value, -) -> Result { - let json_target = policy.resolve(&plan_path(project_root, PLAN_FILE))?; - let markdown_target = policy.resolve(&plan_path(project_root, PLAN_MARKDOWN))?; - let written = json!([ - json_target.display_path().display().to_string(), - markdown_target.display_path().display().to_string(), - ]); - let bytes = serde_json::to_vec_pretty(plan) - .map_err(|error| invalid(format!("Cannot serialize the screen plan: {error}")))?; - let mut transaction = OutputTransaction::new(); - transaction.stage("screenPlan", json_target, &bytes)?; - transaction.stage( - "screenPlanMarkdown", - markdown_target, - markdown(plan).as_bytes(), - )?; - transaction.commit()?; - Ok(written) -} - -/// Reads `/.devup/screen-plan.json`, or `None` when there is -/// none. A file that exists but is not a plan is an error, never "no plan". -pub(super) fn read_plan(project_root: &str) -> Result, DevupError> { - let path = Path::new(project_root).join(PLAN_FILE); - let Ok(metadata) = std::fs::metadata(&path) else { - return Ok(None); - }; - if metadata.len() > MAX_PLAN_BYTES as u64 { - return Err(invalid(format!( - "{} exceeds the plan size limit.", - path.display() - ))); - } - let bytes = std::fs::read(&path) - .map_err(|error| invalid(format!("Cannot read {}: {error}", path.display())))?; - let plan: Value = serde_json::from_slice(&bytes) - .map_err(|error| invalid(format!("{} is not valid JSON: {error}", path.display())))?; - if plan["kind"] != PLAN_KIND || plan["schemaVersion"] != SCHEMA_VERSION { - return Err(invalid(format!( - "{} is not a devup screen plan; create one with devup_screen_plan action draft.", - path.display() - ))); - } - Ok(Some(plan)) -} - -fn require_root(input: &ScreenPlanInput) -> Result<&str, DevupError> { - input - .project_root - .as_deref() - .filter(|root| !root.trim().is_empty()) - .ok_or_else(|| invalid(format!("action {} needs projectRoot.", input.action))) -} - -/// `draft`: plan the given screens against the project and write both files. -#[cfg(test)] -pub(super) async fn draft( - input: &ScreenPlanInput, - screens: &[PlanScreenInput], - source: Value, - policy: &OutputPolicy, -) -> Result { - draft_with(input, screens, &input.assets, source, policy).await -} - -/// [`draft`] with the Figma asset candidates an explore found beside the -/// screens. -pub(super) async fn draft_with( - input: &ScreenPlanInput, - screens: &[PlanScreenInput], - assets: &[PlanAssetInput], - source: Value, - policy: &OutputPolicy, -) -> Result { - let root = require_root(input)?; - let evidence = project_evidence(root).await?; - let mut plan = draft_plan_with( - screens, - assets, - &input.extra_questions, - &input.navigation_targets, - &evidence, - source, - input.notes.as_deref(), - )?; - let per_round = input - .max_questions_per_round - .unwrap_or(MAX_QUESTIONS_PER_ROUND); - if !(1..=50).contains(&per_round) { - return Err(invalid("maxQuestionsPerRound must be between 1 and 50.")); - } - plan["maxQuestionsPerRound"] = json!(per_round); - plan["createdAt"] = json!(now()); - let replaced = read_plan(root) - .ok() - .flatten() - .filter(|previous| previous["status"] == "confirmed") - .map(|previous| previous["planId"].clone()); - let written = write_plan(policy, root, &plan)?; - let questions = ask_shape(array(&plan["questions"])); - let rounds = question_rounds(array(&plan["questions"]), per_round); - let unverified = array(&plan["screens"]) - .iter() - .filter(|screen| screen["routeEvidence"] == "UNVERIFIED") - .count(); - Ok(json!({ - "status": "draft", - "planId": plan["planId"], - "screens": screen_table(&plan), - "questionRounds": rounds, - "questions": questions, - "questionCount": questions.len(), - "askInstruction": ASK_INSTRUCTION, - "maxQuestionsPerRound": per_round, - "site": plan["site"], - "defaults": plan["defaults"], - "heuristics": plan["heuristics"], - "excluded": plan["excluded"], - "outOfScope": plan["outOfScope"], - "config": plan["config"], - "unverifiedRoutes": unverified, - "inventoryNote": plan["inventoryNote"], - "writtenTo": written, - "replacedConfirmedPlan": replaced, - "nextAction": if questions.is_empty() { - json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": {}}, - "how": "Nothing is open. Show the user the screens table, then confirm with empty answers."}) - } else { - json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": " chosen label, for every question that applies>"}, - "how": ASK_INSTRUCTION}) - }, - })) -} - -fn strip_recommended(label: &str) -> &str { - label - .trim() - .strip_suffix(RECOMMENDED) - .unwrap_or(label.trim()) -} - -/// The action an answer selects, or the custom text when it matches no -/// option. A custom answer to a question that places a screen must itself be -/// a route; anything else is recorded and the recommended action applies. -fn resolve_answer( - question: &Value, - answer: &AnswerInput, -) -> Result<(Value, Option), DevupError> { - let id = text(&question["id"]); - let values = answer.values(); - if question["multiple"] == true { - let labels = values - .iter() - .map(|value| strip_recommended(value)) - .collect::>(); - return Ok((json!({"extra": labels}), None)); - } - let [value] = values.as_slice() else { - return Err(invalid(format!("{id} takes exactly one answer."))); - }; - let label = strip_recommended(value); - if let Some(found) = array(&question["resolve"]) - .iter() - .find(|option| text(&option["label"]) == label) - { - return Ok((found["action"].clone(), None)); - } - let route_of = |value: &str| normalize_route(value).filter(|route| valid_route(route)); - match text(&question["kind"]) { - "route" | "modal" => route_of(label) - .map(|route| (json!({"route": route}), Some(label.to_owned()))) - .ok_or_else(|| { - invalid(format!( - "{id}: a custom answer must be an app route such as /about, not {label:?}." - )) - }), - // One route is one page at that URL; one route per frame, in frame - // order, is separate pages at those URLs. - "group" => { - let routes = label - .split([',', ';', '\n']) - .map(str::trim) - .filter(|part| !part.is_empty()) - .map(|part| route_of(part.rsplit('=').next().unwrap_or(part).trim())) - .collect::>>() - .ok_or_else(|| { - invalid(format!( - "{id}: a custom answer must be one route, or one route per frame such as \"/a, /b\"; not {label:?}." - )) - })?; - match routes.as_slice() { - [route] => Ok(( - json!({"group": "single", "route": route}), - Some(label.to_owned()), - )), - _ => Ok(( - json!({"group": "separate", "routes": routes}), - Some(label.to_owned()), - )), - } - } - "link" => Ok(( - json!({"link": normalize_route(label)}), - Some(label.to_owned()), - )), - "extra" => Ok((json!({"extra": label}), Some(label.to_owned()))), - // A custom site answer is the file, name or URL itself. - "site" => { - let field = text(&question["field"]); - match field { - "favicon" | "ogImage" => Ok(( - json!({field: {"source": "provide", "path": label}}), - Some(label.to_owned()), - )), - "siteName" | "appName" => Ok(( - json!({field: {"source": "answer", "value": label}}), - Some(label.to_owned()), - )), - "app" => Ok(( - json!({field: {"dir": label, "appDir": format!("{}/src/app", label.trim_end_matches('/'))}}), - Some(label.to_owned()), - )), - "siteUrl" if label.starts_with("https://") || label.starts_with("http://") => Ok(( - json!({field: {"source": "answer", "value": label}}), - Some(label.to_owned()), - )), - "siteUrl" => Err(invalid(format!( - "{id}: a custom answer must be an http(s) URL, not {label:?}." - ))), - _ => Ok(( - array(&question["resolve"]) - .first() - .map(|option| option["action"].clone()) - .unwrap_or(Value::Null), - Some(label.to_owned()), - )), - } - } - _ => Ok(( - array(&question["resolve"]) - .first() - .map(|option| option["action"].clone()) - .unwrap_or(Value::Null), - Some(label.to_owned()), - )), - } -} - -fn per_round(plan: &Value) -> usize { - plan["maxQuestionsPerRound"] - .as_u64() - .and_then(|n| usize::try_from(n).ok()) - .unwrap_or(MAX_QUESTIONS_PER_ROUND) -} - -/// Whether a question has to be answered, given the answers before it. -fn required(question: &Value, actions: &BTreeMap) -> bool { - array(&question["when"]) - .iter() - .all(|condition| actions.get(text(&condition["question"])) == Some(&condition["action"])) -} - -/// `confirm`: every question that applies answered, in one call, or nothing -/// changes. -pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { - let root = require_root(input)?; - let mut plan = read_plan(root)?.ok_or_else(|| { - invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") - })?; - let plan_id = input - .plan_id - .as_deref() - .ok_or_else(|| invalid("action confirm needs planId."))?; - if plan["planId"] != plan_id { - return Err(DevupError::with_details( - ErrorCode::DevupInvalidInput, - "planId does not match the plan on disk; it was redrafted. Ask the questions of the current draft.", - false, - json!({"planId": plan_id, "currentPlanId": plan["planId"]}), - )); - } - let mut questions = array(&plan["questions"]).to_vec(); - let ordered = questions.clone(); - questions.sort_by_key(|question| depth(question, &ordered)); - let known = questions - .iter() - .map(|question| text(&question["id"]).to_owned()) - .collect::>(); - let unknown = input - .answers - .keys() - .filter(|id| !known.contains(*id)) - .cloned() - .collect::>(); - let mut actions = BTreeMap::new(); - let mut custom = Vec::new(); - let mut missing = Vec::new(); - for question in &questions { - let id = text(&question["id"]).to_owned(); - if !required(question, &actions) { - continue; - } - let Some(answer) = input.answers.get(&id) else { - missing.push(id); - continue; - }; - let (action, note) = resolve_answer(question, answer)?; - if let Some(note) = note { - custom.push(json!({"question": id, "answer": note})); - } - actions.insert(id, action); - } - if !missing.is_empty() || !unknown.is_empty() { - let pending = questions - .iter() - .filter(|question| missing.iter().any(|id| question["id"] == id.as_str())) - .cloned() - .collect::>(); - return Err(DevupError::with_details( - ErrorCode::DevupInvalidInput, - format!( - "confirm needs an answer to every question that applies. Missing: {}.", - if missing.is_empty() { - "none".to_owned() - } else { - missing.join(", ") - } - ), - false, - json!({"missing": missing, "unknown": unknown, "askInstruction": ASK_INSTRUCTION, - "questionRounds": question_rounds(&pending, per_round(&plan))}), - )); - } - let accepted = actions - .get("heuristics") - .is_none_or(|action| action["heuristics"] == "accept"); - let nothing_here = actions - .get("scope") - .is_some_and(|action| action["scope"] == "none"); - // Every "the Figma structure is wrong" answer: its frames are not built, - // and the fix is reported. The work goes on without them. - let design_fixes = questions - .iter() - .filter(|question| { - actions - .get(text(&question["id"])) - .is_some_and(|action| action["designFix"] == true) - }) - .map(|question| { - json!({"question": question["id"], "frames": question["fix"]["frames"], - "fix": question["fix"]["text"]}) - }) - .collect::>(); - let fixed = |ids: &[&str]| { - ids.iter().any(|id| { - actions - .get(*id) - .is_some_and(|action| action["designFix"] == true) - }) - }; - let app_dir = actions - .get("site:app") - .and_then(|action| action["app"]["appDir"].as_str()) - .unwrap_or(text(&plan["appDir"])) - .to_owned(); - plan["appDir"] = json!(app_dir); - let mut routes = Vec::new(); - let mut screen_routes = BTreeMap::new(); - let screens = array(&plan["screens"]).to_vec(); - for screen in &screens { - let id = text(&screen["id"]).to_owned(); - let frames = array(&screen["frames"]).to_vec(); - let mut deciding = array(&screen["structureQuestions"]) - .iter() - .map(text) - .collect::>(); - deciding.extend(screen["groupingQuestion"].as_str()); - if nothing_here || fixed(&deciding) { - continue; - } - let chosen_app = actions - .get(&format!("app:{id}")) - .or_else(|| actions.get("site:app")) - .map(|action| action["app"].clone()); - let screen_app = chosen_app - .as_ref() - .and_then(|app| app["dir"].as_str()) - .or(screen["app"].as_str()) - .map(str::to_owned); - let screen_app_dir = chosen_app - .as_ref() - .and_then(|app| app["appDir"].as_str()) - .or(screen["appDir"].as_str()) - .unwrap_or(&app_dir) - .to_owned(); - let group = screen["groupingQuestion"] - .as_str() - .and_then(|question| actions.get(question)); - let separate = group.is_some_and(|action| action["group"] == "separate"); - let data = actions - .get(&format!("dynamic:{id}")) - .map(|action| action["data"].clone()); - // Each placement is (frames, implementation, route, host, collision). - let mut placements = Vec::new(); - if separate { - let explicit = group.and_then(|action| action["routes"].as_array()); - if explicit.is_some_and(|routes| routes.len() != frames.len()) { - return Err(invalid(format!( - "{}: list exactly one route per frame ({} frames).", - text(&screen["groupingQuestion"]), - frames.len() - ))); - } - for (index, frame) in frames.iter().enumerate() { - let node = text(&frame["nodeId"]); - let action = explicit - .map(|routes| json!({"route": routes[index]})) - .or_else(|| actions.get(&format!("split-route:{node}")).cloned()) - .unwrap_or(Value::Null); - placements.push((vec![frame.clone()], action, None)); - } - } else { - let placed = group - .filter(|action| action["route"].is_string()) - .or_else(|| actions.get(&format!("route:{id}"))) - .or_else(|| actions.get(&format!("modal:{id}"))) - .or_else(|| actions.get(&format!("review:{id}"))) - .cloned(); - let action = match placed { - Some(action) => action, - None if screen["routeEvidence"] == NAME_MATCH && accepted => { - json!({"route": screen["proposedRoute"], "collision": "merge", "acceptedHeuristic": true}) - } - None => json!({"route": screen["proposedRoute"]}), - }; - let collision = actions - .get(&format!("collision:{id}")) - .map(|action| text(&action["collision"]).to_owned()); - placements.push((frames.clone(), action, collision)); - } - for (group_frames, action, collision) in placements { - let collision = collision.or_else(|| action["collision"].as_str().map(str::to_owned)); - let (implementation, route, host) = - if action["skip"] == true || collision.as_deref() == Some("skip") { - ("skip", None, None) - } else if action["notPage"] == true { - ("not-a-page", None, None) - } else if let Some(host) = action["state"].as_str() { - ("state", None, Some(host.to_owned())) - } else { - ("page", action["route"].as_str().map(str::to_owned), None) - }; - let first = text(&group_frames[0]["nodeId"]).to_owned(); - if implementation == "page" && route.is_none() { - return Err(invalid(format!( - "{first} has no route after the answers; answer its route question with a route." - ))); - } - if let Some(url) = &route { - screen_routes - .entry(id.clone()) - .or_insert_with(|| url.clone()); - } - let existing = route.as_deref().and_then(|url| { - (screen["proposedRoute"] == url && !screen["existingFile"].is_null()) - .then(|| screen["existingFile"].clone()) - }); - routes.push(json!({ - "url": route, - "screen": if separate { first.clone() } else { id.clone() }, - "name": group_frames[0]["name"], - "nodeIds": group_frames.iter().map(|frame| frame["nodeId"].clone()).collect::>(), - "drawnWidths": group_frames.iter().filter_map(|frame| frame["width"].as_f64()).map(|width| width.round() as i64).collect::>(), - "implementation": implementation, - "hostScreen": host, - "app": screen_app, - "file": existing.clone().unwrap_or_else(|| route.as_deref().map_or(Value::Null, |url| json!(planned_page(url, &screen_app_dir)))), - "fileExists": existing.is_some(), - "collisionPolicy": collision, - "data": data, - "routeEvidence": if action["acceptedHeuristic"] == true { json!("name-match (accepted)") } else { screen["routeEvidence"].clone() }, - })); - } - } - let mut excluded = Vec::new(); - for row in array(&plan["excluded"]) { - let node = text(&row["nodeId"]).to_owned(); - let built = !nothing_here - && actions - .get(&format!("exclusion:{node}")) - .is_some_and(|action| action["build"] == true); - let action = actions.get(&format!("exclusion-route:{node}")); - match action.and_then(|action| action["route"].as_str()) { - Some(route) if built => routes.push(json!({ - "url": route, "screen": node, "name": row["name"], "nodeIds": [node], - "drawnWidths": [row["width"].as_f64().map(|width| width.round() as i64)], - "implementation": "page", "hostScreen": null, - "file": planned_page(route, &app_dir), "fileExists": false, - "collisionPolicy": null, "data": null, - "routeEvidence": "confirmed answer (was excluded)", - })), - _ => { - let mut row = row.clone(); - row["decision"] = json!("excluded"); - excluded.push(row); - } - } - } - if nothing_here { - for screen in &screens { - for frame in array(&screen["frames"]) { - excluded.push(json!({"nodeId": frame["nodeId"], "name": frame["name"], "width": frame["width"], - "section": frame["sectionName"], "reason": "the linked area is not for implementation (answered)", - "decision": "excluded"})); - } - } - } - // One URL per app: the public site and the console may both have `/`. - let mut seen = BTreeMap::<(String, String), String>::new(); - for row in &routes { - if let Some(url) = row["url"].as_str() - && let Some(other) = seen.insert( - (text(&row["app"]).to_owned(), url.to_owned()), - text(&row["screen"]).to_owned(), - ) - && other != text(&row["screen"]) - { - return Err(invalid(format!( - "Two screens resolve to {url} ({other} and {}); answer their route questions with different URLs.", - text(&row["screen"]) - ))); - } - } - for row in routes - .iter_mut() - .filter(|row| row["implementation"] == "state") - { - row["hostUrl"] = json!(screen_routes.get(text(&row["hostScreen"]))); - } - if let Some(rows) = plan["screens"].as_array_mut() { - for screen in rows { - let id = text(&screen["id"]).to_owned(); - let mut resolved = Vec::new(); - for (index, link) in array(&screen["links"]).iter().enumerate() { - let mut link = link.clone(); - if let Some(action) = actions.get(&format!("link:{id}:{index}")) { - link["resolved"] = - json!({"url": action["link"], "evidence": "confirmed answer"}); - } else if let Some(target) = link["resolved"]["screen"].as_str() { - link["resolved"]["url"] = json!(screen_routes.get(target)); - } - resolved.push(link); - } - screen["links"] = json!(resolved); - } - } - plan["answers"] = json!(input.answers); - plan["customAnswers"] = json!(custom); - plan["heuristicsAccepted"] = json!(accepted); - plan["layout"] = json!( - actions - .iter() - .filter(|(id, _)| id.starts_with("layout:")) - .map(|(id, action)| (id.clone(), action["layout"].clone())) - .collect::>() - ); - for asked in questions - .iter() - .filter(|asked| text(&asked["id"]).starts_with("site:")) - { - let field = text(&asked["field"]).to_owned(); - if let Some(action) = actions.get(text(&asked["id"])) { - plan["site"][&field]["state"] = json!("answered"); - plan["site"][&field]["answer"] = action[&field].clone(); - } - } - plan["extraAnswers"] = json!( - actions - .iter() - .filter(|(id, _)| id.starts_with("extra:")) - .map(|(id, action)| (id.clone(), action["extra"].clone())) - .collect::>() - ); - plan["routes"] = json!(routes); - plan["excluded"] = json!(excluded); - plan["designFixes"] = json!(design_fixes); - plan["status"] = json!("confirmed"); - plan["confirmedAt"] = json!(now()); - if !plan["assumptions"].is_array() { - plan["assumptions"] = json!([]); - } - let written = write_plan(policy, root, &plan)?; - Ok(json!({ - "status": "confirmed", - "planId": plan["planId"], - "routes": routes, - "markdownTable": route_markdown(&routes), - "excluded": excluded, - "designFixes": design_fixes, - "site": plan["site"], - "defaults": plan["defaults"], - "extraAnswers": plan["extraAnswers"], - "customAnswers": custom, - "rule": IMPLEMENT_INSTRUCTION, - "writtenTo": written, - "nextAction": {"how": IMPLEMENT_INSTRUCTION}, - })) -} - -/// `assume`: what implementation decided without asking, appended to the -/// confirmed plan so status and the final report carry it. -pub(super) fn assume(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { - let root = require_root(input)?; - let mut plan = read_plan(root)?.ok_or_else(|| { - invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") - })?; - if plan["status"] != "confirmed" { - return Err(invalid( - "Assumptions belong to implementation, after confirm. Before confirm, put the decision to the user in the question rounds instead.", - )); - } - if input.assumptions.is_empty() && input.dependency_update.is_none() { - return Err(invalid( - "action assume needs assumptions or dependencyUpdate.", - )); - } - let recorded_at = now(); - // An assumption about a pending decision takes it, unless it is - // destructive: that one stays unbuilt and goes into the final report. - let mut decisions = array(&plan["pendingDecisions"]).to_vec(); - for decision in &mut decisions { - let Some(taken) = input - .assumptions - .iter() - .find(|assumption| decision["id"] == assumption.about.as_str()) - else { - continue; - }; - if decision["destructive"] == true { - decision["status"] = json!("left-unbuilt"); - } else { - decision["status"] = json!("taken"); - decision["taken"] = json!(taken.decision); - } - decision["takenAt"] = json!(recorded_at); - } - if !decisions.is_empty() { - plan["pendingDecisions"] = json!(decisions); - } - if let Some(update) = &input.dependency_update { - plan["dependencies"] = json!({"updatedAt": recorded_at, "bumped": update.bumped, - "pins": update.pins, "command": UPDATE_STEP}); - } - let mut list = array(&plan["assumptions"]).to_vec(); - for pin in input - .dependency_update - .iter() - .flat_map(|update| &update.pins) - { - list.push(json!({"about": format!("dependency {}", pin.package), - "decision": format!("pinned to {}", pin.version), "reason": pin.reason, "recordedAt": recorded_at})); - } - for assumption in &input.assumptions { - if assumption.about.trim().is_empty() || assumption.decision.trim().is_empty() { - return Err(invalid("Every assumption needs about and decision.")); - } - list.push( - json!({"about": assumption.about, "decision": assumption.decision, - "reason": assumption.reason, "recordedAt": recorded_at}), - ); - } - plan["assumptions"] = json!(list); - // An assumption never builds what the plan excluded or sent back for a - // design fix; the export gate refuses those frames whatever is recorded. - let blocked = array(&plan["excluded"]) - .iter() - .map(|row| text(&row["nodeId"]).to_owned()) - .chain(array(&plan["designFixes"]).iter().flat_map(|fix| { - array(&fix["frames"]) - .iter() - .map(|frame| text(&frame["nodeId"]).to_owned()) - .collect::>() - })) - .filter(|id| { - !id.is_empty() - && input - .assumptions - .iter() - .any(|assumption| assumption.about.contains(id.as_str())) - }) - .collect::>(); - let written = write_plan(policy, root, &plan)?; - Ok(json!({ - "status": "confirmed", - "planId": plan["planId"], - "pendingDecisions": plan["pendingDecisions"], - "stillNotBuilt": blocked.iter().map(|id| json!({"frame": id, - "why": "Excluded or sent back for a design fix; an assumption cannot build it, and the export gate refuses it."})).collect::>(), - "assumptions": plan["assumptions"], - "dependencies": plan["dependencies"], - "writtenTo": written, - "how": "Keep implementing. Every assumption appears in status and must be in your final report.", - })) -} - -/// Records a decision the work needs without stopping it. -pub(super) fn defer(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { - let root = require_root(input)?; - let mut plan = read_plan(root)?.ok_or_else(|| { - invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") - })?; - if plan["status"] != "confirmed" { - return Err(invalid( - "A decision before confirm is a question: put it in extraQuestions on draft.", - )); - } - let decision = input - .decision - .as_ref() - .ok_or_else(|| invalid("action defer needs decision."))?; - for (field, value) in [ - ("id", &decision.id), - ("about", &decision.about), - ("question", &decision.question), - ("recommended", &decision.recommended), - ] { - if value.trim().is_empty() { - return Err(invalid(format!("decision.{field} is empty."))); - } - } - let mut decisions = array(&plan["pendingDecisions"]).to_vec(); - decisions.retain(|existing| existing["id"] != decision.id.as_str()); - decisions.push( - json!({"id": decision.id, "about": decision.about, "question": decision.question, - "options": decision.options, "recommended": decision.recommended, - "destructive": decision.destructive, "status": "open", "raisedAt": now()}), - ); - plan["pendingDecisions"] = json!(decisions); - let written = write_plan(policy, root, &plan)?; - Ok(json!({ - "status": "confirmed", - "pendingDecisions": plan["pendingDecisions"], - "writtenTo": written, - "how": if decision.destructive { - "Recorded. Do not build what it touches; carry on with everything else. It stays unbuilt and is listed in the final report." - } else { - "Recorded. Carry on with everything else; items it blocks move to the back of the work queue. When the rest is done, take it with action assume (about: the decision id, decision: the recommended option)." - }, - })) -} - -/// Writes one runtime-audit plan per confirmed URL and records where. -pub(super) async fn audit_plan( - input: &ScreenPlanInput, - policy: &OutputPolicy, - artifacts: &ArtifactStore, -) -> Result { - let root = require_root(input)?; - let mut plan = read_plan(root)?.ok_or_else(|| { - invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") - })?; - if plan["status"] != "confirmed" { - return Err(invalid( - "auditPlan needs the confirmed plan: answer the questions and confirm first.", - )); - } - let config = read_config(root)?.unwrap_or_default(); - let thresholds = config.visual_thresholds.clone().unwrap_or_default(); - let mut result = super::audit_plan::write( - &plan, - &super::audit_plan::AuditRequest { - root, - base_url: input.base_url.as_deref().unwrap_or("http://localhost:3000"), - base_urls: &input.base_urls, - only: &input.urls, - artifact_ids: &input.artifacts, - thresholds: &thresholds, - }, - policy, - artifacts, - ) - .await?; - let mut entries = array(&plan["audit"]["plans"]).to_vec(); - for written in array(&result["plans"]) { - entries.retain(|entry| entry["url"] != written["url"]); - entries.push(json!({"url": written["url"], "screen": written["screen"], - "planPath": written["planPath"], "reportPath": format!("{}/report.json", text(&written["reportDirectory"]).replace('\\', "/"))})); - } - // A dynamic route sampled from its Figma frame is an assumption, and is - // reported as one. - let mut assumptions = array(&plan["assumptions"]).to_vec(); - for written in array(&result["plans"]) { - if let Some(assumption) = written["dynamic"]["assumption"].as_str() - && !assumptions - .iter() - .any(|existing| existing["decision"] == assumption) - { - assumptions.push(json!({"about": written["dynamic"]["pattern"], "decision": assumption, - "reason": "no generateStaticParams and no link to the route on an audited page yet", "recordedAt": now()})); - } - } - plan["assumptions"] = json!(assumptions); - let max_fix_attempts = config.max_fix_attempts.unwrap_or(3); - plan["audit"]["plans"] = json!(entries); - plan["audit"]["maxFixAttempts"] = json!(max_fix_attempts); - plan["audit"]["generatedAt"] = json!(now()); - result["writtenTo"] = write_plan(policy, root, &plan)?; - result["maxFixAttempts"] = json!(max_fix_attempts); - result["doneWhen"] = json!(DONE_RULE); - Ok(result) -} - -pub(super) const DONE_RULE: &str = "A URL is done when every check and comparison passes at its drawn widths and health and overflow pass at every width, or each remaining finding is a converter bug with its repro filed, or unresolved with its evidence after maxFixAttempts fix-and-rerun rounds. Layout is exact: a geometry mismatch is code drift (restore the export) or a converter bug, never tuned by hand."; - -fn screen_table(plan: &Value) -> Vec { - array(&plan["screens"]) - .iter() - .map(|screen| { - json!({ - "id": screen["id"], - "name": screen["name"], - "kind": screen["kind"], - "drawnWidths": screen["drawnWidths"], - "proposedRoute": screen["proposedRoute"], - "routeEvidence": screen["routeEvidence"], - "existingFile": screen["existingFile"], - "sharedParts": screen["sharedParts"], - "openDecisions": screen["openDecisions"], - }) - }) - .collect() -} - -fn cell(value: &Value) -> String { - let rendered = match value { - Value::Null => "-".to_owned(), - Value::String(text) => text.clone(), - Value::Array(items) if items.is_empty() => "-".to_owned(), - Value::Array(items) => items.iter().map(cell).collect::>().join(", "), - other => other.to_string(), - }; - rendered.replace('|', "\\|").replace('\n', " ") -} - -fn route_markdown(routes: &[Value]) -> String { - let mut out = String::from( - "| URL | Figma screen | nodeIds | widths | file | implementation | app |\n|---|---|---|---|---|---|---|\n", - ); - for row in routes { - let url = if row["implementation"] == "state" { - format!("(state on {})", cell(&row["hostUrl"])) - } else { - cell(&row["url"]) - }; - out.push_str(&format!( - "| {url} | {} | {} | {} | {} | {} | {} |\n", - cell(&row["name"]), - cell(&row["nodeIds"]), - cell(&row["drawnWidths"]), - cell(&row["file"]), - cell(&row["implementation"]), - cell(&row["app"]), - )); - } - out -} - -/// The Markdown twin of the plan: the same content, for a person. -fn markdown(plan: &Value) -> String { - let mut out = format!( - "# Screen plan {}\n\nStatus: **{}**. Source: {}.\n", - cell(&plan["planId"]), - cell(&plan["status"]), - cell(&plan["source"]["url"]), - ); - if let Some(notes) = plan["notes"].as_str() { - out.push_str(&format!("\nNotes: {notes}\n")); - } - if let Some(note) = plan["inventoryNote"].as_str() { - out.push_str(&format!("\n> {note}\n")); - } - out.push_str("\n## Screens\n\n| Figma screen | nodeId | widths | proposed route | evidence | existing file | shared parts | open decisions |\n|---|---|---|---|---|---|---|---|\n"); - for screen in array(&plan["screens"]) { - out.push_str(&format!( - "| {} | {} | {} | {} | {} | {} | {} | {} |\n", - cell(&screen["name"]), - cell(&screen["id"]), - cell(&screen["drawnWidths"]), - cell(&screen["proposedRoute"]), - cell(&screen["routeEvidence"]), - cell(&screen["existingFile"]), - cell(&screen["sharedParts"]), - cell(&screen["openDecisions"]), - )); - } - let questions = array(&plan["questions"]); - if !questions.is_empty() { - out.push_str(&format!("\n## Open questions\n\n{ASK_INSTRUCTION}\n\n")); - for question in questions { - let id = text(&question["id"]); - out.push_str(&format!( - "- **{}** (`{id}`): {}\n", - cell(&question["header"]), - text(&question["question"]) - )); - for option in array(&question["options"]) { - out.push_str(&format!( - " - {}: {}\n", - text(&option["label"]), - text(&option["description"]) - )); - } - if let Some(answer) = plan["answers"].get(id) { - out.push_str(&format!(" - Answer: {}\n", cell(answer))); - } - } - } - let routes = array(&plan["routes"]); - if !routes.is_empty() { - out.push_str("\n## Confirmed routes\n\n"); - out.push_str(&route_markdown(routes)); - } - let lists = trailing_lists(plan); - if !lists.is_empty() { - out.push_str("\n## Not built, and why\n"); - out.push_str(&lists); - } - out -} - -fn worst(a: &str, b: &str) -> &'static str { - let rank = |status: &str| match status { - "fail" => 3, - "inconclusive" => 2, - "not-run" => 1, - _ => 0, - }; - match rank(a).max(rank(b)) { - 3 => "fail", - 2 => "inconclusive", - 1 => "not-run", - _ => "pass", - } -} - -/// `status`: the confirmed plan read against the routes on disk and, when -/// given, a runtime audit. A width without an audit item is `not-run`. -pub(super) async fn status( - input: &ScreenPlanInput, - policy: &OutputPolicy, - artifacts: &ArtifactStore, -) -> Result { - let root = require_root(input)?; - let plan = read_plan(root)?.ok_or_else(|| { - invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") - })?; - if plan["status"] != "confirmed" { - return Ok(json!({ - "status": "not-confirmed", - "planId": plan["planId"], - "questionRounds": question_rounds(array(&plan["questions"]), per_round(&plan)), - "questions": ask_shape(array(&plan["questions"])), - "askInstruction": ASK_INSTRUCTION, - "nextAction": {"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"]}, "how": ASK_INSTRUCTION}, - })); - } - let mut plan = plan; - let audit = match ( - &input.audit_plan_path, - &input.audit_report_path, - &input.tooling_unavailable, - ) { - (Some(plan_path), Some(report_path), None) => Some(( - super::runtime_audit::ingest_with_artifacts(policy, plan_path, report_path, artifacts) - .await?, - sha256(&policy.resolve(report_path)?.read_bounded(8 * 1024 * 1024)?), - )), - (Some(plan_path), None, Some(reason)) => { - let bytes = policy.resolve(plan_path)?.read_bounded(MAX_PLAN_BYTES)?; - let audit_plan: Value = serde_json::from_slice(&bytes) - .map_err(|_| invalid("auditPlanPath is not a runtime audit plan."))?; - for screen in array(&audit_plan["plan"]["screens"]) { - if let Some(route) = screen["routePath"].as_str() { - plan["audit"]["inconclusive"][route] = json!(reason); - } - } - write_plan(policy, root, &plan)?; - None - } - (None, None, None) => None, - _ => { - return Err(invalid( - "status takes auditPlanPath with auditReportPath, or auditPlanPath with toolingUnavailable, or neither.", - )); - } - }; - let evidence = project_evidence(root).await?; - let mut audit = audit; - if let Some((audit, _)) = &mut audit { - super::audit_plan::classify_geometry(audit, root, &evidence.sources, policy); - } - let round = match &audit { - Some((audit, report_sha)) if audit["verdict"] != "rejected" => { - let routes = array(&audit["screenTable"]) - .iter() - .filter_map(|row| row["routePath"].as_str()) - .map(str::to_owned) - .collect::>(); - if let Some(inconclusive) = plan["audit"]["inconclusive"].as_object_mut() { - inconclusive.retain(|route, _| !routes.contains(route)); - } - // The links each page holds, for auditPlan's next pass over the - // dynamic routes. - for (route, hrefs) in audit["links"].as_object().into_iter().flatten() { - plan["audit"]["links"][route] = hrefs.clone(); - } - let max = plan["audit"]["maxFixAttempts"] - .as_u64() - .and_then(|max| u32::try_from(max).ok()) - .or(evidence.config.max_fix_attempts) - .unwrap_or(3); - let round = super::audit_plan::record_round( - &mut plan, - audit, - report_sha, - &input.visual_results, - &evidence.figma_nodes, - max, - ); - write_plan(policy, root, &plan)?; - Some(round) - } - _ => None, - }; - let mut report = status_report(&plan, &evidence, audit.as_ref().map(|(audit, _)| audit)); - if let Some((audit, _)) = &audit { - report["visualComparisons"] = audit["visualComparisons"].clone(); - report["missingNodes"] = audit["missingNodes"].clone(); - } - if let Some(round) = round { - report["pendingComparisons"] = round["pendingComparisons"].clone(); - report["inconclusiveComparisons"] = round["inconclusiveComparisons"].clone(); - } - Ok(report) -} - -/// The pure half of `status`, so the not-run rule is tested on its own. -pub(super) fn status_report( - plan: &Value, - evidence: &ProjectEvidence, - audit: Option<&Value>, -) -> Value { - let audit_rows = audit - .map(|audit| array(&audit["screenTable"])) - .unwrap_or_default(); - let mut widths = audit - .map(|audit| { - array(&audit["widthTable"]) - .iter() - .filter_map(|row| row["width"].as_u64()) - .collect::>() - }) - .unwrap_or_default(); - let audited = !widths.is_empty(); - let mut rows = Vec::new(); - let mut states = Vec::new(); - for route in array(&plan["routes"]) { - if route["implementation"] == "state" { - states.push(json!({"screen": route["screen"], "name": route["name"], "hostUrl": route["hostUrl"]})); - continue; - } - let Some(url) = route["url"].as_str() else { - continue; - }; - let page = evidence - .routes - .iter() - .find(|(pattern, _)| pattern == url || super::navigation::matches_route(pattern, url)) - .map(|(_, page)| page.clone()); - let mut row_widths = if audited { - widths.clone() - } else { - DEFAULT_WIDTHS - .iter() - .map(|width| u64::from(*width)) - .chain( - array(&route["drawnWidths"]) - .iter() - .filter_map(Value::as_u64), - ) - .collect::>() - }; - row_widths.sort_unstable_by(|a, b| b.cmp(a)); - row_widths.dedup(); - let per_width = row_widths - .iter() - .map(|width| { - let matching = audit_rows - .iter() - .filter(|row| { - row["width"] == *width - && row["routePath"] - .as_str() - .and_then(normalize_route) - .is_some_and(|path| path == url) - }) - .collect::>(); - if matching.is_empty() { - return json!({"width": width, "result": "not-run", - "reasons": [if audited { "no runtime audit item for this URL at this width" } else { "no runtime audit report supplied" }]}); - } - let result = matching - .iter() - .fold("pass", |acc, row| worst(acc, text(&row["status"]))); - json!({ - "width": width, - "result": result, - "columns": matching[0]["columns"], - "reasons": matching.iter().flat_map(|row| array(&row["reasons"]).to_vec()).collect::>(), - "screenshots": matching.iter().filter_map(|row| row["screenshot"].as_str()).collect::>(), - }) - }) - .collect::>(); - rows.push(json!({ - "url": url, - "screen": route["screen"], - "figmaScreen": route["name"], - "nodeIds": route["nodeIds"], - "file": page.clone().map_or_else(|| route["file"].clone(), Value::from), - "implemented": if page.is_some() { "yes" } else { "no" }, - "widths": per_width, - })); - } - if !audited { - widths = DEFAULT_WIDTHS - .iter() - .map(|width| u64::from(*width)) - .collect(); - } - let implemented = rows - .iter() - .filter(|row| row["implemented"] == "yes") - .count(); - let verified = rows - .iter() - .filter(|row| { - array(&row["widths"]) - .iter() - .all(|width| width["result"] == "pass") - }) - .count(); - let mut problems = Vec::new(); - for row in &rows { - let mut by_kind = BTreeMap::<&str, Vec>::new(); - for width in array(&row["widths"]) { - if width["result"] != "fail" { - continue; - } - let reasons = array(&width["reasons"]) - .iter() - .map(text) - .collect::>(); - let kind = if reasons - .iter() - .any(|reason| reason.starts_with("horizontal overflow")) - { - "overflows" - } else if reasons.iter().any(|reason| { - reason.starts_with("page error") || reason.starts_with("screen not reached") - }) { - "fails to load" - } else { - "fails checks" - }; - by_kind - .entry(kind) - .or_default() - .push(width["width"].to_string()); - } - for (kind, at) in by_kind { - problems.push(format!("{} {kind} at {}", text(&row["url"]), at.join(", "))); - } - } - let mut summary = format!( - "{} URLs: {implemented} implemented, {verified} verified at all widths", - rows.len() - ); - for problem in &problems { - summary.push_str(&format!(", {problem}")); - } - if !audited { - summary.push_str("; no runtime audit report, so every width is not-run"); - } - let assumptions = array(&plan["assumptions"]); - if !assumptions.is_empty() { - summary.push_str(&format!( - "; {} assumption{} made during implementation", - assumptions.len(), - if assumptions.len() == 1 { "" } else { "s" } - )); - } - let design_fixes = array(&plan["designFixes"]); - if !design_fixes.is_empty() { - summary.push_str(&format!( - "; {} design fix{} requested, not built", - design_fixes.len(), - if design_fixes.len() == 1 { "" } else { "es" } - )); - } - let excluded = array(&plan["excluded"]) - .iter() - .filter(|row| row["decision"] == "excluded") - .cloned() - .collect::>(); - if !excluded.is_empty() { - summary.push_str(&format!("; {} frames excluded, not built", excluded.len())); - } - let mut table = String::from( - "| URL | Figma screen | file | status | widths verified | screenshots |\n|---|---|---|---|---|---|\n", - ); - for row in &rows { - let row_widths = array(&row["widths"]); - let passed = row_widths - .iter() - .filter(|width| width["result"] == "pass") - .map(|width| width["width"].to_string()) - .collect::>(); - let failing = row_widths - .iter() - .filter(|width| width["result"] == "fail") - .map(|width| width["width"].to_string()) - .collect::>(); - let state = if row["implemented"] != "yes" { - "not implemented".to_owned() - } else if !failing.is_empty() { - format!("fails at {}", failing.join(", ")) - } else if passed.len() == row_widths.len() { - "verified".to_owned() - } else { - "implemented, not verified".to_owned() - }; - let shots = row_widths - .iter() - .flat_map(|width| array(&width["screenshots"]).to_vec()) - .collect::>(); - table.push_str(&format!( - "| {} | {} | {} | {state} | {} of {} | {} |\n", - cell(&row["url"]), - cell(&row["figmaScreen"]), - cell(&row["file"]), - if passed.is_empty() { - "none".to_owned() - } else { - passed.join(", ") - }, - row_widths - .iter() - .map(|width| width["width"].to_string()) - .collect::>() - .join("/"), - cell(&json!(shots)), - )); - } - let completeness = site_completeness(plan, evidence); - let incomplete = completeness - .iter() - .filter(|item| item["state"] == "missing") - .count(); - if incomplete > 0 { - summary.push_str(&format!("; site completeness: {incomplete} missing")); - } - table.push_str("\nSite completeness:\n"); - for item in &completeness { - table.push_str(&format!( - "- {}: {} ({})\n", - text(&item["item"]), - text(&item["state"]), - cell(&item["detail"]) - )); - } - let trailing = trailing_lists(plan); - table.push_str(&trailing); - // Visual failures and fixed findings change a width's verdict after the - // run: a failing comparison fails its width, and the columns say which - // level failed. - let open = super::audit_plan::findings_by_status(plan, "open"); - let unresolved = super::audit_plan::findings_by_status(plan, "unresolved"); - for row in &mut rows { - let url = text(&row["url"]).to_owned(); - for width in row["widths"].as_array_mut().into_iter().flatten() { - let at = width["width"].clone(); - for finding in open.iter().chain(&unresolved).filter(|finding| { - finding["kind"] == "visual" - && finding["routePath"] == url.as_str() - && finding["width"] == at - }) { - width["result"] = json!("fail"); - let column = match text(&finding["class"]) { - "asset-mismatch" => "asset", - "section-visual" => "section", - _ => "page", - }; - width["columns"][column] = json!("fail"); - if let Some(reasons) = width["reasons"].as_array_mut() { - reasons.push(json!(format!( - "{}: {}", - text(&finding["class"]), - text(&finding["reason"]) - ))); - } - } - } - } - let queue = super::audit_plan::work_queue(plan, &rows, &[]); - let final_report = super::audit_plan::final_report(plan, &rows, &trailing); - let converter_bugs = plan["audit"]["converterBugs"] - .as_object() - .map(|bugs| bugs.values().cloned().collect::>()) - .unwrap_or_default(); - json!({ - "findings": open, - "unresolved": unresolved, - "converterBugs": converter_bugs, - "workQueue": queue, - "pendingDecisions": plan["pendingDecisions"], - "finalReport": final_report, - "doneWhen": DONE_RULE, - "status": "confirmed", - "planId": plan["planId"], - "summary": summary, - "rows": rows, - "states": states, - "widths": widths, - "audit": audit.map(|audit| json!({"verdict": audit["verdict"], "planId": audit["planId"], "reason": audit["reason"]})), - "markdownTable": table, - "assumptions": assumptions, - "siteCompleteness": completeness, - "defaults": plan["defaults"], - "designFixes": design_fixes, - "excluded": excluded, - "inventoryNote": evidence.inventory_note, - "rule": "A width without a runtime audit item is not-run, never pass. Implemented means an App Router page exists for the URL, not that it renders.", - }) -} - -/// The lists every final report ends with: design fixes requested, frames -/// excluded, and assumptions made during implementation. Shared by the plan -/// file and `status`, so the two never disagree about what was not built. -fn trailing_lists(plan: &Value) -> String { - let mut out = String::new(); - let defaults = array(&plan["defaults"]); - if !defaults.is_empty() { - out.push_str("\nFilled in by default (no question):\n"); - for default in defaults { - out.push_str(&format!( - "- {}: {}\n", - text(&default["item"]), - text(&default["decision"]) - )); - } - } - let fixes = array(&plan["designFixes"]); - if !fixes.is_empty() { - out.push_str("\nDesign fixes requested (not built):\n"); - for fix in fixes { - out.push_str(&format!("- {}\n", text(&fix["fix"]))); - } - } - let excluded = array(&plan["excluded"]) - .iter() - .filter(|row| row["decision"] == "excluded" || row["decision"].is_null()) - .collect::>(); - if !excluded.is_empty() { - out.push_str("\nExcluded (never built):\n"); - for row in excluded { - out.push_str(&format!( - "- \"{}\" ({}): {}\n", - text(&row["name"]), - text(&row["nodeId"]), - text(&row["reason"]) - )); - } - } - let assumptions = array(&plan["assumptions"]); - if !assumptions.is_empty() { - out.push_str("\nAssumptions made during implementation:\n"); - for assumption in assumptions { - out.push_str(&format!( - "- {}: {}{}\n", - text(&assumption["about"]), - text(&assumption["decision"]), - assumption["reason"] - .as_str() - .map(|reason| format!(" ({reason})")) - .unwrap_or_default() - )); - } - } - out -} - -/// `app-icon` for `devup_ui_validate` with a projectRoot: an App Router app -/// with no icon or favicon. Read from disk here because the devup-ui checks -/// open no file. -pub(super) fn missing_app_icon( - project_root: &str, -) -> Option { - let root = Path::new(project_root); - let app = ["src/app", "app"] - .into_iter() - .find(|app| join_relative(root, app).is_dir())?; - let mut evidence = ProjectEvidence { - app_dir: app.to_owned(), - ..ProjectEvidence::default() - }; - read_site_facts(root, &mut evidence); - if !evidence.icon_files.is_empty() { - return None; - } - let mut context = serde_json::Map::new(); - context.insert("appDir".into(), json!(app)); - Some(devup_mcp_devup_ui::ui_validate::Violation { - rule: "app-icon", - severity: devup_mcp_devup_ui::ui_validate::Severity::Warning, - byte_range: [0, 0], - context, - message: format!("{app} has no icon.* or favicon, and public/ has no favicon either, so the browser tab shows a blank icon."), - suggestion: Some("Add app/icon.svg (or favicon.ico) from a file the user provides or a Figma logo node exported as SVG; devup_screen_plan asks which. Never draw one.".into()), - }) -} - -/// What the export gate decided. -pub(super) enum Gate { - /// Not a page write, or no plan applies. - Open, - /// A confirmed plan covers the frame and names this route for pageScaffold. - Route(String), - /// Not written: the response to return instead. - Blocked(Value), -} - -/// The facts of one export call the gate decides on. -pub(super) struct ExportRequest<'a> { - pub project_root: &'a str, - pub url: Option<&'a str>, - /// Frame ids, or the one node the url or artifact names when there are none. - pub frames: Vec, - pub all_screens: bool, - pub scaffold_write: bool, - pub scaffold_route: Option<&'a str>, - pub output_paths: Vec<&'a str>, -} - -/// Whether a write lands in the App Router tree. Judged below the project -/// root, so a checkout that happens to sit in a folder named `app` is not one. -fn under_app(project_root: &str, path: &str) -> bool { - let path = Path::new(path); - path.strip_prefix(project_root) - .unwrap_or(path) - .components() - .any(|component| component.as_os_str() == "app") -} - -fn plan_required(request: &ExportRequest<'_>, reason: &str, details: Value) -> Gate { - let mut arguments = json!({"action": "draft", "projectRoot": request.project_root}); - if let Some(url) = request.url { - arguments["url"] = json!(url); - } - Gate::Blocked(json!({ - "status": "plan_required", - "written": false, - "planRequired": {"reason": reason, "details": details}, - "nextAction": {"tool": "devup_screen_plan", "arguments": arguments, - "how": "Draft the screen plan, ask every round of its questions back to back before implementing, confirm, then repeat this export. Nothing was collected or written."}, - })) -} - -/// The soft gate in front of `devup_figma_export`. A call that would write a -/// page - a pageScaffold write, an outputPath under app/, or several screens -/// with outputPaths - needs a confirmed plan covering its frames. A single -/// component, or a preview that writes nothing, is never blocked. -pub(super) fn export_gate(request: &ExportRequest<'_>) -> Result { - let several = request.all_screens || request.frames.len() > 1; - let writes = request.scaffold_write || !request.output_paths.is_empty(); - let gated = request.scaffold_write - || request - .output_paths - .iter() - .any(|path| under_app(request.project_root, path)) - || (several && writes); - if !gated && request.scaffold_route.is_none() { - return Ok(Gate::Open); - } - let plan = match read_plan(request.project_root)? { - Some(plan) if plan["status"] == "confirmed" => plan, - Some(plan) if gated => { - return Ok(plan_required( - request, - "The screen plan is a draft; its questions are unanswered.", - json!({"planId": plan["planId"], "askInstruction": ASK_INSTRUCTION}), - )); - } - None if gated => { - return Ok(plan_required( - request, - "This call writes a page and the project has no confirmed screen plan.", - json!({"planFile": PLAN_FILE}), - )); - } - _ => return Ok(Gate::Open), - }; - // Never built, whatever else the call says: not by this export, and not - // by an assumption recorded during implementation. - let refused = request - .frames - .iter() - .filter_map(|frame| { - let excluded = array(&plan["excluded"]) - .iter() - .find(|row| row["nodeId"] == frame.as_str()) - .map(|row| format!("excluded: {}", text(&row["reason"]))); - let fix = array(&plan["designFixes"]) - .iter() - .find(|fix| { - array(&fix["frames"]) - .iter() - .any(|f| f["nodeId"] == frame.as_str()) - }) - .map(|fix| format!("design fix requested: {}", text(&fix["fix"]))); - excluded - .or(fix) - .map(|reason| json!({"frame": frame, "reason": reason})) - }) - .collect::>(); - if !refused.is_empty() && (gated || request.scaffold_route.is_some()) { - return Ok(Gate::Blocked(json!({ - "status": "refused", - "written": false, - "refused": refused, - "how": "Do not build these frames and do not record an assumption to build them. They are listed in devup_screen_plan status; continue with the rest of the plan.", - }))); - } - let routes = array(&plan["routes"]); - let row_of = |frame: &str| { - routes - .iter() - .find(|row| array(&row["nodeIds"]).iter().any(|id| id == frame)) - }; - if request.all_screens { - let section = request - .url - .and_then(|url| FigmaTarget::parse(url).ok()) - .and_then(|target| target.node_id); - let planned = plan["source"]["nodeId"].as_str(); - if section.is_some() && planned.is_some() && section.as_deref() != planned { - return Ok(plan_required( - request, - "allScreens names a Section the confirmed plan was not drafted from.", - json!({"requested": section, "planned": planned}), - )); - } - return Ok(Gate::Open); - } else if request.frames.is_empty() { - if gated { - return Ok(plan_required( - request, - "The frames this call writes are not named, so the plan cannot be checked. Pass frameIds or a frame url.", - json!({}), - )); - } - return Ok(Gate::Open); - } - let uncovered = request - .frames - .iter() - .filter(|frame| { - row_of(frame) - .is_none_or(|row| !matches!(text(&row["implementation"]), "page" | "state")) - }) - .cloned() - .collect::>(); - if !uncovered.is_empty() { - if !gated { - return Ok(Gate::Open); - } - // The plan is confirmed, so this is implementation: nobody is asked - // now. The conservative option is to leave the frame unbuilt. - return Ok(Gate::Blocked(json!({ - "status": "plan_required", - "written": false, - "planRequired": {"reason": "The confirmed screen plan does not place these frames on a page.", - "details": {"uncoveredFrames": uncovered, "planId": plan["planId"]}}, - "nextAction": {"tool": "devup_screen_plan", - "arguments": {"action": "assume", "projectRoot": request.project_root, - "assumptions": [{"about": uncovered.join(", "), "decision": "Not built: outside the confirmed screen plan."}]}, - "how": "Do not ask the user during implementation. Leave these frames unbuilt, record that with action assume, keep going, and list it in the final report."}, - }))); - } - let Some(requested) = request.scaffold_route else { - return Ok(Gate::Open); - }; - let [frame] = request.frames.as_slice() else { - return Ok(Gate::Open); - }; - let Some(row) = row_of(frame) else { - return Ok(Gate::Open); - }; - let Some(planned) = row["url"].as_str() else { - return Err(DevupError::with_details( - ErrorCode::DevupInvalidInput, - format!( - "The confirmed plan builds {frame} as a state of another page, not as its own route; export it without pageScaffold." - ), - false, - json!({"planId": plan["planId"], "hostUrl": row["hostUrl"]}), - )); - }; - if !requested.is_empty() && normalize_route(requested).as_deref() != Some(planned) { - return Err(DevupError::with_details( - ErrorCode::DevupInvalidInput, - format!( - "pageScaffold.route {requested} does not match the confirmed plan, which serves {frame} at {planned}." - ), - false, - json!({"routeMismatch": {"requested": requested, "planned": planned, "frame": frame, "planId": plan["planId"]}, - "how": "Omit pageScaffold.route to take the plan's route, or redraft and confirm the plan if the URL should change."}), - )); - } - Ok(Gate::Route(planned.to_owned())) -} - -#[cfg(test)] -#[path = "screen_plan_tests.rs"] -mod tests; +// Source sections share this module to preserve privacy and test names. +include!("screen_plan/state.rs"); +include!("screen_plan/routes.rs"); +include!("screen_plan/app_select.rs"); +include!("screen_plan/draft.rs"); +include!("screen_plan/actions.rs"); +include!("screen_plan/status.rs"); +include!("screen_plan/draft_plan_with.rs"); +include!("screen_plan/plan_route_group.rs"); diff --git a/crates/devup-mcp/src/server/screen_plan/actions.rs b/crates/devup-mcp/src/server/screen_plan/actions.rs new file mode 100644 index 00000000..5a65548a --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/actions.rs @@ -0,0 +1,785 @@ + + +/// `confirm`: every question that applies answered, in one call, or nothing +/// changes. +pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let root = require_root(input)?; + let mut plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + let plan_id = input + .plan_id + .as_deref() + .ok_or_else(|| invalid("action confirm needs planId."))?; + if plan["planId"] != plan_id { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + "planId does not match the plan on disk; it was redrafted. Ask the questions of the current draft.", + false, + json!({"planId": plan_id, "currentPlanId": plan["planId"]}), + )); + } + let mut questions = array(&plan["questions"]).to_vec(); + let ordered = questions.clone(); + questions.sort_by_key(|question| depth(question, &ordered)); + let known = questions + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect::>(); + let unknown = input + .answers + .keys() + .filter(|id| !known.contains(*id)) + .cloned() + .collect::>(); + let mut actions = BTreeMap::new(); + let mut custom = Vec::new(); + let mut missing = Vec::new(); + for question in &questions { + let id = text(&question["id"]).to_owned(); + if !required(question, &actions) { + continue; + } + let Some(answer) = input.answers.get(&id) else { + missing.push(id); + continue; + }; + let (action, note) = resolve_answer(question, answer)?; + if let Some(note) = note { + custom.push(json!({"question": id, "answer": note})); + } + actions.insert(id, action); + } + if !missing.is_empty() || !unknown.is_empty() { + let pending = questions + .iter() + .filter(|question| missing.iter().any(|id| question["id"] == id.as_str())) + .cloned() + .collect::>(); + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "confirm needs an answer to every question that applies. Missing: {}.", + if missing.is_empty() { + "none".to_owned() + } else { + missing.join(", ") + } + ), + false, + json!({"missing": missing, "unknown": unknown, "askInstruction": ASK_INSTRUCTION, + "questionRounds": question_rounds(&pending, per_round(&plan))}), + )); + } + let accepted = actions + .get("heuristics") + .is_none_or(|action| action["heuristics"] == "accept"); + let nothing_here = actions + .get("scope") + .is_some_and(|action| action["scope"] == "none"); + // Every "the Figma structure is wrong" answer: its frames are not built, + // and the fix is reported. The work goes on without them. + let design_fixes = questions + .iter() + .filter(|question| { + actions + .get(text(&question["id"])) + .is_some_and(|action| action["designFix"] == true) + }) + .map(|question| { + json!({"question": question["id"], "frames": question["fix"]["frames"], + "fix": question["fix"]["text"]}) + }) + .collect::>(); + let fixed = |ids: &[&str]| { + ids.iter().any(|id| { + actions + .get(*id) + .is_some_and(|action| action["designFix"] == true) + }) + }; + let app_dir = actions + .get("site:app") + .and_then(|action| action["app"]["appDir"].as_str()) + .unwrap_or(text(&plan["appDir"])) + .to_owned(); + plan["appDir"] = json!(app_dir); + let mut routes = Vec::new(); + let mut screen_routes = BTreeMap::new(); + let screens = array(&plan["screens"]).to_vec(); + for screen in &screens { + let id = text(&screen["id"]).to_owned(); + let frames = array(&screen["frames"]).to_vec(); + let mut deciding = array(&screen["structureQuestions"]) + .iter() + .map(text) + .collect::>(); + deciding.extend(screen["groupingQuestion"].as_str()); + if nothing_here || fixed(&deciding) { + continue; + } + let chosen_app = actions + .get(&format!("app:{id}")) + .or_else(|| actions.get("site:app")) + .map(|action| action["app"].clone()); + let screen_app = chosen_app + .as_ref() + .and_then(|app| app["dir"].as_str()) + .or(screen["app"].as_str()) + .map(str::to_owned); + let screen_app_dir = chosen_app + .as_ref() + .and_then(|app| app["appDir"].as_str()) + .or(screen["appDir"].as_str()) + .unwrap_or(&app_dir) + .to_owned(); + let group = screen["groupingQuestion"] + .as_str() + .and_then(|question| actions.get(question)); + let separate = group.is_some_and(|action| action["group"] == "separate"); + let data = actions + .get(&format!("dynamic:{id}")) + .map(|action| action["data"].clone()); + // Each placement is (frames, implementation, route, host, collision). + let mut placements = Vec::new(); + if separate { + let explicit = group.and_then(|action| action["routes"].as_array()); + if explicit.is_some_and(|routes| routes.len() != frames.len()) { + return Err(invalid(format!( + "{}: list exactly one route per frame ({} frames).", + text(&screen["groupingQuestion"]), + frames.len() + ))); + } + for (index, frame) in frames.iter().enumerate() { + let node = text(&frame["nodeId"]); + let action = explicit + .map(|routes| json!({"route": routes[index]})) + .or_else(|| actions.get(&format!("split-route:{node}")).cloned()) + .unwrap_or(Value::Null); + placements.push((vec![frame.clone()], action, None)); + } + } else { + let placed = group + .filter(|action| action["route"].is_string()) + .or_else(|| actions.get(&format!("route:{id}"))) + .or_else(|| actions.get(&format!("modal:{id}"))) + .or_else(|| actions.get(&format!("review:{id}"))) + .cloned(); + let action = match placed { + Some(action) => action, + None if screen["routeEvidence"] == NAME_MATCH && accepted => { + json!({"route": screen["proposedRoute"], "collision": "merge", "acceptedHeuristic": true}) + } + None => json!({"route": screen["proposedRoute"]}), + }; + let collision = actions + .get(&format!("collision:{id}")) + .map(|action| text(&action["collision"]).to_owned()); + placements.push((frames.clone(), action, collision)); + } + for (group_frames, action, collision) in placements { + let collision = collision.or_else(|| action["collision"].as_str().map(str::to_owned)); + let (implementation, route, host) = + if action["skip"] == true || collision.as_deref() == Some("skip") { + ("skip", None, None) + } else if action["notPage"] == true { + ("not-a-page", None, None) + } else if let Some(host) = action["state"].as_str() { + ("state", None, Some(host.to_owned())) + } else { + ("page", action["route"].as_str().map(str::to_owned), None) + }; + let first = text(&group_frames[0]["nodeId"]).to_owned(); + if implementation == "page" && route.is_none() { + return Err(invalid(format!( + "{first} has no route after the answers; answer its route question with a route." + ))); + } + if let Some(url) = &route { + screen_routes + .entry(id.clone()) + .or_insert_with(|| url.clone()); + } + let existing = route.as_deref().and_then(|url| { + (screen["proposedRoute"] == url && !screen["existingFile"].is_null()) + .then(|| screen["existingFile"].clone()) + }); + routes.push(json!({ + "url": route, + "screen": if separate { first.clone() } else { id.clone() }, + "name": group_frames[0]["name"], + "nodeIds": group_frames.iter().map(|frame| frame["nodeId"].clone()).collect::>(), + "drawnWidths": group_frames.iter().filter_map(|frame| frame["width"].as_f64()).map(|width| width.round() as i64).collect::>(), + "implementation": implementation, + "hostScreen": host, + "app": screen_app, + "file": existing.clone().unwrap_or_else(|| route.as_deref().map_or(Value::Null, |url| json!(planned_page(url, &screen_app_dir)))), + "fileExists": existing.is_some(), + "collisionPolicy": collision, + "data": data, + "routeEvidence": if action["acceptedHeuristic"] == true { json!("name-match (accepted)") } else { screen["routeEvidence"].clone() }, + })); + } + } + let mut excluded = Vec::new(); + for row in array(&plan["excluded"]) { + let node = text(&row["nodeId"]).to_owned(); + let built = !nothing_here + && actions + .get(&format!("exclusion:{node}")) + .is_some_and(|action| action["build"] == true); + let action = actions.get(&format!("exclusion-route:{node}")); + match action.and_then(|action| action["route"].as_str()) { + Some(route) if built => routes.push(json!({ + "url": route, "screen": node, "name": row["name"], "nodeIds": [node], + "drawnWidths": [row["width"].as_f64().map(|width| width.round() as i64)], + "implementation": "page", "hostScreen": null, + "file": planned_page(route, &app_dir), "fileExists": false, + "collisionPolicy": null, "data": null, + "routeEvidence": "confirmed answer (was excluded)", + })), + _ => { + let mut row = row.clone(); + row["decision"] = json!("excluded"); + excluded.push(row); + } + } + } + if nothing_here { + for screen in &screens { + for frame in array(&screen["frames"]) { + excluded.push(json!({"nodeId": frame["nodeId"], "name": frame["name"], "width": frame["width"], + "section": frame["sectionName"], "reason": "the linked area is not for implementation (answered)", + "decision": "excluded"})); + } + } + } + // One URL per app: the public site and the console may both have `/`. + let mut seen = BTreeMap::<(String, String), String>::new(); + for row in &routes { + if let Some(url) = row["url"].as_str() + && let Some(other) = seen.insert( + (text(&row["app"]).to_owned(), url.to_owned()), + text(&row["screen"]).to_owned(), + ) + && other != text(&row["screen"]) + { + return Err(invalid(format!( + "Two screens resolve to {url} ({other} and {}); answer their route questions with different URLs.", + text(&row["screen"]) + ))); + } + } + for row in routes + .iter_mut() + .filter(|row| row["implementation"] == "state") + { + row["hostUrl"] = json!(screen_routes.get(text(&row["hostScreen"]))); + } + if let Some(rows) = plan["screens"].as_array_mut() { + for screen in rows { + let id = text(&screen["id"]).to_owned(); + let mut resolved = Vec::new(); + for (index, link) in array(&screen["links"]).iter().enumerate() { + let mut link = link.clone(); + if let Some(action) = actions.get(&format!("link:{id}:{index}")) { + link["resolved"] = + json!({"url": action["link"], "evidence": "confirmed answer"}); + } else if let Some(target) = link["resolved"]["screen"].as_str() { + link["resolved"]["url"] = json!(screen_routes.get(target)); + } + resolved.push(link); + } + screen["links"] = json!(resolved); + } + } + plan["answers"] = json!(input.answers); + plan["customAnswers"] = json!(custom); + plan["heuristicsAccepted"] = json!(accepted); + plan["layout"] = json!( + actions + .iter() + .filter(|(id, _)| id.starts_with("layout:")) + .map(|(id, action)| (id.clone(), action["layout"].clone())) + .collect::>() + ); + for asked in questions + .iter() + .filter(|asked| text(&asked["id"]).starts_with("site:")) + { + let field = text(&asked["field"]).to_owned(); + if let Some(action) = actions.get(text(&asked["id"])) { + plan["site"][&field]["state"] = json!("answered"); + plan["site"][&field]["answer"] = action[&field].clone(); + } + } + plan["extraAnswers"] = json!( + actions + .iter() + .filter(|(id, _)| id.starts_with("extra:")) + .map(|(id, action)| (id.clone(), action["extra"].clone())) + .collect::>() + ); + plan["routes"] = json!(routes); + plan["excluded"] = json!(excluded); + plan["designFixes"] = json!(design_fixes); + plan["status"] = json!("confirmed"); + plan["confirmedAt"] = json!(now()); + if !plan["assumptions"].is_array() { + plan["assumptions"] = json!([]); + } + let written = write_plan(policy, root, &plan)?; + Ok(json!({ + "status": "confirmed", + "planId": plan["planId"], + "routes": routes, + "markdownTable": route_markdown(&routes), + "excluded": excluded, + "designFixes": design_fixes, + "site": plan["site"], + "defaults": plan["defaults"], + "extraAnswers": plan["extraAnswers"], + "customAnswers": custom, + "rule": IMPLEMENT_INSTRUCTION, + "writtenTo": written, + "nextAction": {"how": IMPLEMENT_INSTRUCTION}, + })) +} + +/// `assume`: what implementation decided without asking, appended to the +/// confirmed plan so status and the final report carry it. +pub(super) fn assume(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let root = require_root(input)?; + let mut plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + if plan["status"] != "confirmed" { + return Err(invalid( + "Assumptions belong to implementation, after confirm. Before confirm, put the decision to the user in the question rounds instead.", + )); + } + if input.assumptions.is_empty() && input.dependency_update.is_none() { + return Err(invalid( + "action assume needs assumptions or dependencyUpdate.", + )); + } + let recorded_at = now(); + // An assumption about a pending decision takes it, unless it is + // destructive: that one stays unbuilt and goes into the final report. + let mut decisions = array(&plan["pendingDecisions"]).to_vec(); + for decision in &mut decisions { + let Some(taken) = input + .assumptions + .iter() + .find(|assumption| decision["id"] == assumption.about.as_str()) + else { + continue; + }; + if decision["destructive"] == true { + decision["status"] = json!("left-unbuilt"); + } else { + decision["status"] = json!("taken"); + decision["taken"] = json!(taken.decision); + } + decision["takenAt"] = json!(recorded_at); + } + if !decisions.is_empty() { + plan["pendingDecisions"] = json!(decisions); + } + if let Some(update) = &input.dependency_update { + plan["dependencies"] = json!({"updatedAt": recorded_at, "bumped": update.bumped, + "pins": update.pins, "command": UPDATE_STEP}); + } + let mut list = array(&plan["assumptions"]).to_vec(); + for pin in input + .dependency_update + .iter() + .flat_map(|update| &update.pins) + { + list.push(json!({"about": format!("dependency {}", pin.package), + "decision": format!("pinned to {}", pin.version), "reason": pin.reason, "recordedAt": recorded_at})); + } + for assumption in &input.assumptions { + if assumption.about.trim().is_empty() || assumption.decision.trim().is_empty() { + return Err(invalid("Every assumption needs about and decision.")); + } + list.push( + json!({"about": assumption.about, "decision": assumption.decision, + "reason": assumption.reason, "recordedAt": recorded_at}), + ); + } + plan["assumptions"] = json!(list); + // An assumption never builds what the plan excluded or sent back for a + // design fix; the export gate refuses those frames whatever is recorded. + let blocked = array(&plan["excluded"]) + .iter() + .map(|row| text(&row["nodeId"]).to_owned()) + .chain(array(&plan["designFixes"]).iter().flat_map(|fix| { + array(&fix["frames"]) + .iter() + .map(|frame| text(&frame["nodeId"]).to_owned()) + .collect::>() + })) + .filter(|id| { + !id.is_empty() + && input + .assumptions + .iter() + .any(|assumption| assumption.about.contains(id.as_str())) + }) + .collect::>(); + let written = write_plan(policy, root, &plan)?; + Ok(json!({ + "status": "confirmed", + "planId": plan["planId"], + "pendingDecisions": plan["pendingDecisions"], + "stillNotBuilt": blocked.iter().map(|id| json!({"frame": id, + "why": "Excluded or sent back for a design fix; an assumption cannot build it, and the export gate refuses it."})).collect::>(), + "assumptions": plan["assumptions"], + "dependencies": plan["dependencies"], + "writtenTo": written, + "how": "Keep implementing. Every assumption appears in status and must be in your final report.", + })) +} + +/// Records a decision the work needs without stopping it. +pub(super) fn defer(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let root = require_root(input)?; + let mut plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + if plan["status"] != "confirmed" { + return Err(invalid( + "A decision before confirm is a question: put it in extraQuestions on draft.", + )); + } + let decision = input + .decision + .as_ref() + .ok_or_else(|| invalid("action defer needs decision."))?; + for (field, value) in [ + ("id", &decision.id), + ("about", &decision.about), + ("question", &decision.question), + ("recommended", &decision.recommended), + ] { + if value.trim().is_empty() { + return Err(invalid(format!("decision.{field} is empty."))); + } + } + let mut decisions = array(&plan["pendingDecisions"]).to_vec(); + decisions.retain(|existing| existing["id"] != decision.id.as_str()); + decisions.push( + json!({"id": decision.id, "about": decision.about, "question": decision.question, + "options": decision.options, "recommended": decision.recommended, + "destructive": decision.destructive, "status": "open", "raisedAt": now()}), + ); + plan["pendingDecisions"] = json!(decisions); + let written = write_plan(policy, root, &plan)?; + Ok(json!({ + "status": "confirmed", + "pendingDecisions": plan["pendingDecisions"], + "writtenTo": written, + "how": if decision.destructive { + "Recorded. Do not build what it touches; carry on with everything else. It stays unbuilt and is listed in the final report." + } else { + include_str!("../text/screen_plan-defer-3281174c.md") + }, + })) +} + +/// Writes one runtime-audit plan per confirmed URL and records where. +pub(super) async fn audit_plan( + input: &ScreenPlanInput, + policy: &OutputPolicy, + artifacts: &ArtifactStore, +) -> Result { + let root = require_root(input)?; + let mut plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + if plan["status"] != "confirmed" { + return Err(invalid( + "auditPlan needs the confirmed plan: answer the questions and confirm first.", + )); + } + let config = read_config(root)?.unwrap_or_default(); + let thresholds = config.visual_thresholds.clone().unwrap_or_default(); + let mut result = super::audit_plan::write( + &plan, + &super::audit_plan::AuditRequest { + root, + base_url: input.base_url.as_deref().unwrap_or("http://localhost:3000"), + base_urls: &input.base_urls, + only: &input.urls, + artifact_ids: &input.artifacts, + thresholds: &thresholds, + }, + policy, + artifacts, + ) + .await?; + let mut entries = array(&plan["audit"]["plans"]).to_vec(); + for written in array(&result["plans"]) { + entries.retain(|entry| entry["url"] != written["url"]); + entries.push(json!({"url": written["url"], "screen": written["screen"], + "planPath": written["planPath"], "reportPath": format!("{}/report.json", text(&written["reportDirectory"]).replace('\\', "/"))})); + } + // A dynamic route sampled from its Figma frame is an assumption, and is + // reported as one. + let mut assumptions = array(&plan["assumptions"]).to_vec(); + for written in array(&result["plans"]) { + if let Some(assumption) = written["dynamic"]["assumption"].as_str() + && !assumptions + .iter() + .any(|existing| existing["decision"] == assumption) + { + assumptions.push(json!({"about": written["dynamic"]["pattern"], "decision": assumption, + "reason": "no generateStaticParams and no link to the route on an audited page yet", "recordedAt": now()})); + } + } + plan["assumptions"] = json!(assumptions); + let max_fix_attempts = config.max_fix_attempts.unwrap_or(3); + plan["audit"]["plans"] = json!(entries); + plan["audit"]["maxFixAttempts"] = json!(max_fix_attempts); + plan["audit"]["generatedAt"] = json!(now()); + result["writtenTo"] = write_plan(policy, root, &plan)?; + result["maxFixAttempts"] = json!(max_fix_attempts); + result["doneWhen"] = json!(DONE_RULE); + Ok(result) +} + +pub(super) const DONE_RULE: &str = include_str!("../text/screen_plan-DONE_RULE-95793a55.md"); + +fn screen_table(plan: &Value) -> Vec { + array(&plan["screens"]) + .iter() + .map(|screen| { + json!({ + "id": screen["id"], + "name": screen["name"], + "kind": screen["kind"], + "drawnWidths": screen["drawnWidths"], + "proposedRoute": screen["proposedRoute"], + "routeEvidence": screen["routeEvidence"], + "existingFile": screen["existingFile"], + "sharedParts": screen["sharedParts"], + "openDecisions": screen["openDecisions"], + }) + }) + .collect() +} + +fn cell(value: &Value) -> String { + let rendered = match value { + Value::Null => "-".to_owned(), + Value::String(text) => text.clone(), + Value::Array(items) if items.is_empty() => "-".to_owned(), + Value::Array(items) => items.iter().map(cell).collect::>().join(", "), + other => other.to_string(), + }; + rendered.replace('|', "\\|").replace('\n', " ") +} + +fn route_markdown(routes: &[Value]) -> String { + let mut out = String::from( + "| URL | Figma screen | nodeIds | widths | file | implementation | app |\n|---|---|---|---|---|---|---|\n", + ); + for row in routes { + let url = if row["implementation"] == "state" { + format!("(state on {})", cell(&row["hostUrl"])) + } else { + cell(&row["url"]) + }; + out.push_str(&format!( + "| {url} | {} | {} | {} | {} | {} | {} |\n", + cell(&row["name"]), + cell(&row["nodeIds"]), + cell(&row["drawnWidths"]), + cell(&row["file"]), + cell(&row["implementation"]), + cell(&row["app"]), + )); + } + out +} + +/// The Markdown twin of the plan: the same content, for a person. +fn markdown(plan: &Value) -> String { + let mut out = format!( + "# Screen plan {}\n\nStatus: **{}**. Source: {}.\n", + cell(&plan["planId"]), + cell(&plan["status"]), + cell(&plan["source"]["url"]), + ); + if let Some(notes) = plan["notes"].as_str() { + out.push_str(&format!("\nNotes: {notes}\n")); + } + if let Some(note) = plan["inventoryNote"].as_str() { + out.push_str(&format!("\n> {note}\n")); + } + out.push_str("\n## Screens\n\n| Figma screen | nodeId | widths | proposed route | evidence | existing file | shared parts | open decisions |\n|---|---|---|---|---|---|---|---|\n"); + for screen in array(&plan["screens"]) { + out.push_str(&format!( + "| {} | {} | {} | {} | {} | {} | {} | {} |\n", + cell(&screen["name"]), + cell(&screen["id"]), + cell(&screen["drawnWidths"]), + cell(&screen["proposedRoute"]), + cell(&screen["routeEvidence"]), + cell(&screen["existingFile"]), + cell(&screen["sharedParts"]), + cell(&screen["openDecisions"]), + )); + } + let questions = array(&plan["questions"]); + if !questions.is_empty() { + out.push_str(&format!("\n## Open questions\n\n{ASK_INSTRUCTION}\n\n")); + for question in questions { + let id = text(&question["id"]); + out.push_str(&format!( + "- **{}** (`{id}`): {}\n", + cell(&question["header"]), + text(&question["question"]) + )); + for option in array(&question["options"]) { + out.push_str(&format!( + " - {}: {}\n", + text(&option["label"]), + text(&option["description"]) + )); + } + if let Some(answer) = plan["answers"].get(id) { + out.push_str(&format!(" - Answer: {}\n", cell(answer))); + } + } + } + let routes = array(&plan["routes"]); + if !routes.is_empty() { + out.push_str("\n## Confirmed routes\n\n"); + out.push_str(&route_markdown(routes)); + } + let lists = trailing_lists(plan); + if !lists.is_empty() { + out.push_str("\n## Not built, and why\n"); + out.push_str(&lists); + } + out +} + +fn worst(a: &str, b: &str) -> &'static str { + let rank = |status: &str| match status { + "fail" => 3, + "inconclusive" => 2, + "not-run" => 1, + _ => 0, + }; + match rank(a).max(rank(b)) { + 3 => "fail", + 2 => "inconclusive", + 1 => "not-run", + _ => "pass", + } +} + +/// `status`: the confirmed plan read against the routes on disk and, when +/// given, a runtime audit. A width without an audit item is `not-run`. +pub(super) async fn status( + input: &ScreenPlanInput, + policy: &OutputPolicy, + artifacts: &ArtifactStore, +) -> Result { + let root = require_root(input)?; + let plan = read_plan(root)?.ok_or_else(|| { + invalid("No screen plan exists in this project; run devup_screen_plan action draft first.") + })?; + if plan["status"] != "confirmed" { + return Ok(json!({ + "status": "not-confirmed", + "planId": plan["planId"], + "questionRounds": question_rounds(array(&plan["questions"]), per_round(&plan)), + "questions": ask_shape(array(&plan["questions"])), + "askInstruction": ASK_INSTRUCTION, + "nextAction": {"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"]}, "how": ASK_INSTRUCTION}, + })); + } + let mut plan = plan; + let audit = match ( + &input.audit_plan_path, + &input.audit_report_path, + &input.tooling_unavailable, + ) { + (Some(plan_path), Some(report_path), None) => Some(( + super::runtime_audit::ingest_with_artifacts(policy, plan_path, report_path, artifacts) + .await?, + sha256(&policy.resolve(report_path)?.read_bounded(8 * 1024 * 1024)?), + )), + (Some(plan_path), None, Some(reason)) => { + let bytes = policy.resolve(plan_path)?.read_bounded(MAX_PLAN_BYTES)?; + let audit_plan: Value = serde_json::from_slice(&bytes) + .map_err(|_| invalid("auditPlanPath is not a runtime audit plan."))?; + for screen in array(&audit_plan["plan"]["screens"]) { + if let Some(route) = screen["routePath"].as_str() { + plan["audit"]["inconclusive"][route] = json!(reason); + } + } + write_plan(policy, root, &plan)?; + None + } + (None, None, None) => None, + _ => { + return Err(invalid( + "status takes auditPlanPath with auditReportPath, or auditPlanPath with toolingUnavailable, or neither.", + )); + } + }; + let evidence = project_evidence(root).await?; + let mut audit = audit; + if let Some((audit, _)) = &mut audit { + super::audit_plan::classify_geometry(audit, root, &evidence.sources, policy); + } + let round = match &audit { + Some((audit, report_sha)) if audit["verdict"] != "rejected" => { + let routes = array(&audit["screenTable"]) + .iter() + .filter_map(|row| row["routePath"].as_str()) + .map(str::to_owned) + .collect::>(); + if let Some(inconclusive) = plan["audit"]["inconclusive"].as_object_mut() { + inconclusive.retain(|route, _| !routes.contains(route)); + } + // The links each page holds, for auditPlan's next pass over the + // dynamic routes. + for (route, hrefs) in audit["links"].as_object().into_iter().flatten() { + plan["audit"]["links"][route] = hrefs.clone(); + } + let max = plan["audit"]["maxFixAttempts"] + .as_u64() + .and_then(|max| u32::try_from(max).ok()) + .or(evidence.config.max_fix_attempts) + .unwrap_or(3); + let round = super::audit_plan::record_round( + &mut plan, + audit, + report_sha, + &input.visual_results, + &evidence.figma_nodes, + max, + ); + write_plan(policy, root, &plan)?; + Some(round) + } + _ => None, + }; + let mut report = status_report(&plan, &evidence, audit.as_ref().map(|(audit, _)| audit)); + if let Some((audit, _)) = &audit { + report["visualComparisons"] = audit["visualComparisons"].clone(); + report["missingNodes"] = audit["missingNodes"].clone(); + } + if let Some(round) = round { + report["pendingComparisons"] = round["pendingComparisons"].clone(); + report["inconclusiveComparisons"] = round["inconclusiveComparisons"].clone(); + } + Ok(report) +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/screen_plan/app_select.rs b/crates/devup-mcp/src/server/screen_plan/app_select.rs new file mode 100644 index 00000000..7a045346 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/app_select.rs @@ -0,0 +1,856 @@ + + +/// The defaults that come before any page: start from the template when there +/// is no app, which framework runs the app, and the dependency update. +fn stack_defaults(evidence: &ProjectEvidence) -> Vec { + let mut defaults = Vec::new(); + if is_new_project(evidence) { + defaults.push(json!({"item": "project-start", + "decision": format!(include_str!("../text/screen_plan-stack_defaults-d534196c.md"), UPDATE_STEP = UPDATE_STEP), + "evidence": "no app, route or framework in the project"})); + } else if let Some(app) = &evidence.target_app { + defaults.push( + json!({"item": "framework", "decision": format!("{} runs {}", app.dir, app.framework), + "evidence": app.evidence}), + ); + } + defaults.push(if evidence.config.update_dependencies == Some(false) { + json!({"item": "dependencies", "mechanism": "skipped", + "decision": "Dependencies are not updated: the project config forbids it.", + "evidence": format!("updateDependencies: false in {CONFIG_FILE}")}) + } else { + json!({"item": "dependencies", "mechanism": "update", + "decision": format!(include_str!("../text/screen_plan-stack_defaults-c2140f37.md"), UPDATE_STEP = UPDATE_STEP), + "evidence": "DevFive works on the latest versions; API knowledge from memory is assumed stale"}) + }); + defaults +} + +/// Words that mark an app, or a screen, as the public site or the admin +/// console. An app named `admin` is the admin console; a Figma design for a +/// public site is almost never for it. +static PUBLIC_APP_WORDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-public-app-words.json")) + .expect("vendored keyword JSON") +}); +static ADMIN_WORDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-admin-words.json")) + .expect("vendored keyword JSON") +}); +/// Screen names a public site has and an admin console does not. +static PUBLIC_SCREEN_WORDS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-public-screen-words.json")) + .expect("vendored keyword JSON") + }); +/// Routes that say an app is a console: a sign-in gate and a dashboard. +static ADMIN_ROUTE_SEGMENTS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-admin-route-segments.json")) + .expect("vendored keyword JSON") + }); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Side { + Public, + Admin, + Unclear, +} + +fn selection_words<'a>(config: &'a PlanConfig, key: &str, defaults: &[&'a str]) -> Vec<&'a str> { + config + .app_selection_keywords + .get(key) + .map(|words| words.iter().map(String::as_str).collect()) + .unwrap_or_else(|| defaults.to_vec()) +} +fn words_side(names: &[&str], public: &[&str], config: &PlanConfig) -> (usize, usize) { + let admin_words = selection_words(config, "admin", &ADMIN_WORDS); + let admin = names + .iter() + .filter(|name| admin_words.iter().any(|word| keyword_in(name, word))) + .count(); + let public = names + .iter() + .filter(|name| public.iter().any(|word| keyword_in(name, word))) + .count(); + (public, admin) +} + +/// Which side an app serves, and the evidence: its directory and package +/// name, then its routes. +fn app_side(app: &super::framework::App, evidence: &ProjectEvidence) -> (Side, Vec) { + let base = app.dir.rsplit('/').next().unwrap_or(&app.dir).to_owned(); + let mut names = vec![base.as_str()]; + if let Some(name) = &app.name { + names.push(name.as_str()); + } + let (mut public, mut admin) = words_side( + &names, + &selection_words(&evidence.config, "publicApps", &PUBLIC_APP_WORDS), + &evidence.config, + ); + let mut why = Vec::new(); + if public > 0 { + why.push(format!("{} is named like the public site", app.dir)); + } + if admin > 0 { + why.push(format!("{} is named like an admin console", app.dir)); + } + let console_routes = evidence + .routes + .iter() + .filter(|(_, page)| page.starts_with(&format!("{}/", app.dir))) + .filter(|(route, _)| { + route.trim_matches('/').split('/').any(|segment| { + selection_words(&evidence.config, "adminRoutes", &ADMIN_ROUTE_SEGMENTS) + .contains(&segment) + }) + }) + .map(|(route, _)| route.clone()) + .collect::>(); + if !console_routes.is_empty() { + admin += 1; + why.push(format!( + "{} has console routes ({})", + app.dir, + console_routes.join(", ") + )); + } + if public == admin { + public = 0; + admin = 0; + } + let side = match public.cmp(&admin) { + std::cmp::Ordering::Greater => Side::Public, + std::cmp::Ordering::Less => Side::Admin, + std::cmp::Ordering::Equal => Side::Unclear, + }; + (side, why) +} + +/// Which side one Figma frame belongs to, from its own name and its +/// section's. Admin markers win: a "관리자 - 메인" frame is the console's. +fn screen_side(screen: &PlanScreenInput, config: &PlanConfig) -> Side { + let mut names = vec![screen.name.as_str()]; + names.extend(screen.section_name.as_deref()); + names.extend(screen.breadcrumb.iter().map(String::as_str)); + match words_side( + &names, + &selection_words(config, "publicScreens", &PUBLIC_SCREEN_WORDS), + config, + ) { + (_, admin) if admin > 0 => Side::Admin, + (public, _) if public > 0 => Side::Public, + _ => Side::Unclear, + } +} + +fn app_action(app: &super::framework::App) -> Value { + json!({"app": {"dir": app.dir, "appDir": app.path(app.app_router.as_deref().unwrap_or("src/app"))}}) +} + +/// Which app each screen is built in, by meaning rather than by name order. +/// +/// With one app, or a projectRoot that is an app, there is nothing to pick. +/// Otherwise the screens' own names decide: all public screens go to the +/// public app and all admin screens to the admin app without a question; a +/// file with both maps each screen to its side and asks only about screens +/// whose side is unclear; a file whose screens say nothing either way is the +/// one genuinely ambiguous case, asked once - recommending the public app, +/// never whichever comes first alphabetically. +/// +/// Returns the app per screen (by node id), the questions, and the default +/// that records the evidence. +fn assign_apps( + evidence: &ProjectEvidence, + screens: &[PlanScreenInput], +) -> ( + BTreeMap, + Vec, + Option, +) { + let mut assigned = BTreeMap::new(); + if evidence.target_app.is_some() || evidence.apps.len() < 2 { + return (assigned, Vec::new(), None); + } + let sides = evidence + .apps + .iter() + .map(|app| (app, app_side(app, evidence))) + .collect::>(); + let public_app = sides + .iter() + .find(|(_, (side, _))| *side == Side::Public) + .or_else(|| sides.iter().find(|(_, (side, _))| *side != Side::Admin)) + .map(|(app, _)| (*app).clone()) + .unwrap_or_else(|| evidence.apps[0].clone()); + let admin_app = sides + .iter() + .find(|(_, (side, _))| *side == Side::Admin) + .map(|(app, _)| (*app).clone()); + let app_evidence = sides + .iter() + .flat_map(|(_, (_, why))| why.clone()) + .collect::>(); + let screen_sides = screens + .iter() + .map(|screen| (screen, screen_side(screen, &evidence.config))) + .collect::>(); + let admin_screens = screen_sides + .iter() + .filter(|(_, side)| *side == Side::Admin) + .count(); + let public_screens = screen_sides + .iter() + .filter(|(_, side)| *side == Side::Public) + .count(); + let mut questions = Vec::new(); + let options = || { + let mut ordered = vec![public_app.clone()]; + ordered.extend( + evidence + .apps + .iter() + .filter(|app| app.dir != public_app.dir) + .cloned(), + ); + ordered + .iter() + .take(MAX_OPTIONS) + .map(|app| { + let side = sides + .iter() + .find(|(candidate, _)| candidate.dir == app.dir) + .map(|(_, (side, _))| *side); + choice( + app.dir.as_str(), + format!( + "{} - {} app{}", + match side { + Some(Side::Public) => "the public site", + Some(Side::Admin) => "the admin console", + _ => "an app", + }, + app.framework, + app.name + .as_deref() + .map(|name| format!(" ({name})")) + .unwrap_or_default() + ), + app_action(app), + ) + }) + .collect::>() + }; + let has_public = sides.iter().any(|(_, (side, _))| *side == Side::Public); + let decision = match (admin_screens, public_screens, &admin_app) { + // Nothing says admin, so the public site: the bias the user chose. + (0, 0, _) if has_public => { + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), public_app.clone()); + } + json!({"item": "target-app", "mechanism": "decided", "decision": public_app.dir, + "evidence": ([app_evidence, vec!["the frames name neither side; with no admin evidence the public app is taken".to_owned()]].concat())}) + } + (0, 0, _) => { + let mut asked = question( + "site:app".into(), + "Which app".into(), + format!( + "This repository has {} apps ({}), and the frames' names say nothing about which one they are for. Which app does this design build? A custom answer is its directory.", + evidence.apps.len(), + evidence + .apps + .iter() + .map(|app| app.dir.as_str()) + .collect::>() + .join(", ") + ), + options(), + "site", + ); + asked["field"] = json!("app"); + questions.push(asked); + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), public_app.clone()); + } + json!({"item": "target-app", "mechanism": "asked", "question": "site:app", + "decision": format!("Asked; {} is recommended as the public app.", public_app.dir), "evidence": app_evidence}) + } + (admin, public, Some(admin_app)) if admin > 0 && public > 0 => { + for (screen, side) in &screen_sides { + let app = if *side == Side::Admin { + admin_app.clone() + } else { + public_app.clone() + }; + assigned.insert(screen.node_id.clone(), app); + } + json!({"item": "target-app", "mechanism": "per-screen", + "decision": format!("Admin screens go to {}; public screens and screens with no admin evidence go to {}.", admin_app.dir, public_app.dir), + "evidence": app_evidence}) + } + (admin, 0, Some(admin_app)) if admin > 0 => { + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), admin_app.clone()); + } + json!({"item": "target-app", "mechanism": "decided", "decision": admin_app.dir, + "evidence": ([app_evidence, vec![format!("{admin} of {} frames are named for the admin console", screens.len())]].concat())}) + } + _ => { + for (screen, _) in &screen_sides { + assigned.insert(screen.node_id.clone(), public_app.clone()); + } + json!({"item": "target-app", "mechanism": "decided", "decision": public_app.dir, + "evidence": ([app_evidence, vec![format!("{public_screens} of {} frames are public-site screens and none is named for an admin console", screens.len())]].concat())}) + } + }; + (assigned, questions, Some(decision)) +} + +/// The questions about the stack itself: migrating off the `next` CLI, and +/// for a new project its name and parts. Which app is [`assign_apps`]'s. +fn stack_questions(evidence: &ProjectEvidence) -> Vec { + let mut questions = Vec::new(); + if evidence.framework() == "next" && evidence.config.framework.is_none() { + let app = evidence.target_app.as_ref(); + let mut asked = question( + "site:framework".into(), + "Framework".into(), + format!( + "{} runs on the next CLI ({}). DevFive's default is vinext, which runs the same App Router on Vite. Migrate it, or keep next?", + app.map_or(".", |app| app.dir.as_str()), + app.map(|app| app.evidence.join("; ")).unwrap_or_default() + ), + vec![ + choice( + "Migrate to vinext", + include_str!("../text/screen_plan-stack_questions-eb6d40b2.md"), + json!({"framework": {"source": "vinext"}}), + ), + choice( + "Keep next", + "Stay on the next CLI; view transitions follow its version.", + json!({"framework": {"source": "next"}}), + ), + ], + "site", + ); + asked["field"] = json!("framework"); + questions.push(asked); + } + if is_new_project(evidence) { + let mut name = question( + "site:app-name".into(), + "App name".into(), + "The project starts from dev-five-git/service-template. What is the app called? A custom answer is the name.".into(), + vec![ + choice("front", "The template's own app name (apps/front).", json!({"appName": {"source": "template", "value": "front"}})), + choice("Use the site name", "Named after the site name asked with the other site facts.", json!({"appName": {"source": "site-name"}})), + ], + "site", + ); + name["field"] = json!("appName"); + questions.push(name); + let mut parts = question( + "site:parts".into(), + "Template parts".into(), + "Which parts of the template does this project keep?".into(), + vec![ + choice( + "Front only", + "apps/front alone; admin and the Rust API are removed.", + json!({"parts": {"source": "front"}}), + ), + choice( + "Front + admin + API", + "apps/front, apps/admin and apis/api, as the template ships them.", + json!({"parts": {"source": "all"}}), + ), + ], + "site", + ); + parts["field"] = json!("parts"); + questions.push(parts); + } + questions +} + +/// What the agent fills in by itself, without a question. Recorded in the +/// plan's `defaults` and the final report, so nothing it added is a surprise. +fn site_defaults(evidence: &ProjectEvidence, screens: &[PlanScreenInput]) -> Vec { + let lang = evidence.lang.clone().unwrap_or_else(|| { + let korean = screens + .iter() + .any(|screen| screen.name.chars().any(|c| ('가'..='힣').contains(&c))); + if korean { + ( + "ko".into(), + "Korean text in the design's layer names".into(), + ) + } else { + ( + "en".into(), + "no Korean text in the design's layer names".into(), + ) + } + }); + let mut defaults = vec![ + json!({"item": "interaction-recipes", + "decision": "Each interactive element gets its role's recipe; status counts every role against it.", + "recipes": INTERACTION_RECIPES.iter().map(|(role, recipe)| json!({"role": role, "recipe": recipe})).collect::>(), + "restraint": RESTRAINT_RULES, + "evidence": "devfive-frontend critical-rules 4 (interactive states); devup-ui pseudo-selectors; the devup-implementation-plan recipe table"}), + json!({"item": "transition", + "decision": "One shared transition, 150-250ms, ease or ease-out, on the properties that change (at most two per state), defined once as a token or shared constant and not per element.", + "evidence": "devfive-frontend critical-rules 4 (transition 0.15-0.3s ease)"}), + json!({"item": "reduced-motion", + "decision": "_motionReduce removes transforms and transitions (or one globalCss rule does), so prefers-reduced-motion gets no movement.", + "evidence": "devup-ui _motionReduce"}), + json!({"item": "metadata", + "decision": include_str!("../text/screen_plan-site_defaults-a7c1a186.md"), + "evidence": "devfive-frontend critical-rules 2 (SEO metadata with openGraph)"}), + json!({"item": "lang", "decision": format!("", lang.0), "evidence": lang.1}), + json!({"item": "alt-text", "decision": "alt text from the Figma layer name or its text content on every image.", + "evidence": "devfive-frontend critical-rules 3 (image alt required)"}), + json!({"item": "image-dimensions", "decision": "width and height on every image, so nothing shifts as it loads.", + "evidence": "no layout shift"}), + ]; + let transitions = view_transition_default(evidence); + defaults.push(if evidence.motion_libraries.is_empty() { + transitions + } else { + json!({"item": "view-transitions", "mechanism": "asked", "question": "site:view-transitions", + "decision": "Asked: the project already animates with its own library.", "ifAdded": transitions, + "evidence": evidence.motion_libraries.join(", ")}) + }); + defaults.push(match &evidence.not_found { + Some(path) => { + json!({"item": "not-found", "decision": format!("Keep {path}."), "evidence": path}) + } + None => json!({"item": "not-found", + "decision": format!("Add {}/not-found.tsx in the site's own style.", evidence.app_dir), + "evidence": "no not-found page in the project"}), + }); + defaults +} + +/// One site fact that needs a file or a fact only the user has. +fn site_question( + field: &str, + id: &str, + header: &str, + prompt: String, + choices: Vec, +) -> Value { + let mut asked = question(id.into(), header.into(), prompt, choices, "site"); + asked["field"] = json!(field); + asked +} + +/// The site-level questions and the state of each fact, asked in the same +/// up-front rounds as everything else. Never asked when the project already +/// says it, or the project config pre-answers it. +fn site_questions( + evidence: &ProjectEvidence, + assets: &[(String, String, &'static str)], + cover: Option<(&str, &str)>, +) -> (Value, Vec) { + let config = &evidence.config; + let mut site = serde_json::Map::new(); + let mut questions = Vec::new(); + let mut file_question = |field: &str, + id: &str, + header: &str, + what: &str, + files: &[String], + kind: &'static str| { + let mut choices = Vec::new(); + for file in files.iter().take(2) { + choices.push(choice( + format!("Use {file}"), + "Already in the project.", + json!({field: {"source": "file", "path": file}}), + )); + } + let (format, target) = if kind == "logo" { + ("svg", format!("{}/icon.svg", evidence.app_dir)) + } else { + ("png", format!("{}/opengraph-image.png", evidence.app_dir)) + }; + for (node, name, _) in assets.iter().filter(|(_, _, found)| *found == kind).take(2) { + choices.push(choice( + format!("Export \"{}\" ({node}) as {}", truncate(name, 30), format.to_uppercase()), + format!("A Figma node in the linked scope; exported with devup_figma_export assetRequests (format {format}) to {target}."), + json!({field: {"source": "figma", "nodeId": node, "format": format, "path": target}}), + )); + } + if kind == "og" + && let Some((node, name)) = cover + { + choices.push(choice( + format!("Use the top of \"{}\" ({node})", truncate(name, 24)), + format!("Export the first screen as PNG and crop it to 1200x630 for {target}."), + json!({field: {"source": "figma-cover", "nodeId": node, "format": "png", "path": target}}), + )); + } + choices.truncate(MAX_OPTIONS - 2); + choices.push(choice( + "I will provide a file", + "Answer with its path, e.g. public/favicon.ico. Nothing is generated or drawn.", + json!({field: {"source": "provide"}}), + )); + choices.push(choice( + "Leave it for later", + "Not added now; status reports it missing.", + json!({field: {"source": "later"}}), + )); + let evidence_text = + if files.is_empty() && !assets.iter().any(|(_, _, found)| *found == kind) { + "Nothing in the project or the linked Figma scope looks like one.".to_owned() + } else { + "The options come from the project's files and the linked Figma scope.".to_owned() + }; + questions.push(site_question( + field, + id, + header, + format!("Which file should be the site's {what}? {evidence_text}"), + choices, + )); + }; + match &config.favicon { + Some(value) => { + site.insert( + "favicon".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + None => { + site.insert( + "favicon".into(), + json!({"state": "asked", "question": "site:favicon", "found": evidence.icon_files}), + ); + file_question( + "favicon", + "site:favicon", + "Favicon / app icon", + "favicon and app icon", + &evidence.icon_files, + "logo", + ); + } + } + match &config.og_image { + Some(value) => { + site.insert( + "ogImage".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + None => { + site.insert( + "ogImage".into(), + json!({"state": "asked", "question": "site:og", "found": evidence.og_files}), + ); + file_question( + "ogImage", + "site:og", + "Share (OG) image", + "share image (Open Graph)", + &evidence.og_files, + "og", + ); + } + } + match (&config.site_name, &evidence.site_name) { + (Some(value), _) => { + site.insert( + "siteName".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + (None, Some((value, from))) => { + site.insert( + "siteName".into(), + json!({"state": "found", "value": value, "evidence": from}), + ); + } + (None, None) => { + site.insert( + "siteName".into(), + json!({"state": "asked", "question": "site:name"}), + ); + let mut choices = Vec::new(); + if let Some(package) = &evidence.package_name { + choices.push(choice( + package.as_str(), + "The package.json name; it may not be the name people see.", + json!({"siteName": {"source": "package", "value": package}}), + )); + } + choices.push(choice( + "Use the main page's heading", + "Taken from the first page's heading text in Figma while building.", + json!({"siteName": {"source": "heading"}}), + )); + choices.push(choice( + "Leave it for later", + "Titles go without a site suffix until it is known.", + json!({"siteName": {"source": "later"}}), + )); + questions.push(site_question( + "siteName", + "site:name", + "Site name", + "What is the site's name, for the title template (\"Page | Name\")? It is not in the project's metadata or environment. A custom answer is the name itself.".into(), + choices, + )); + } + } + match (&config.site_url, &evidence.site_url) { + (Some(value), _) => { + site.insert( + "siteUrl".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + (None, Some((value, from))) => { + site.insert( + "siteUrl".into(), + json!({"state": "found", "value": value, "evidence": from}), + ); + } + (None, None) => { + site.insert( + "siteUrl".into(), + json!({"state": "asked", "question": "site:url"}), + ); + questions.push(site_question( + "siteUrl", + "site:url", + "Production URL", + "What is the production URL, for metadataBase and absolute share links? It is not in the project's metadata or .env files. A custom answer is the URL itself.".into(), + vec![ + choice( + "No production URL yet", + "metadataBase is left out; share images use relative URLs until it is set.", + json!({"siteUrl": {"source": "none"}}), + ), + choice( + "Read it from NEXT_PUBLIC_SITE_URL", + "metadataBase comes from that environment variable, set per deployment.", + json!({"siteUrl": {"source": "env", "name": "NEXT_PUBLIC_SITE_URL"}}), + ), + ], + )); + } + } + if !evidence.analytics.is_empty() { + site.insert( + "analytics".into(), + json!({"state": "asked", "question": "site:analytics", "found": evidence.analytics}), + ); + questions.push(site_question( + "analytics", + "site:analytics", + "Analytics / verification", + format!( + "The project shows analytics or verification setup: {}. Wire it into the new root layout?", + evidence.analytics.join(", ") + ), + vec![ + choice("Keep the existing setup", "Carried into the new layout as it is configured.", json!({"analytics": {"source": "keep"}})), + choice("Leave it out", "The new layout has no analytics or verification tags.", json!({"analytics": {"source": "none"}})), + ], + )); + } else { + site.insert("analytics".into(), json!({"state": "not-needed", "evidence": "no analytics or verification signs in the project"})); + } + if !evidence.motion_libraries.is_empty() { + let libraries = evidence.motion_libraries.join(", "); + site.insert("viewTransitions".into(), json!({"state": "asked", "question": "site:view-transitions", "found": evidence.motion_libraries})); + questions.push(site_question( + "viewTransitions", + "site:view-transitions", + "View transitions", + format!("The project already animates with {libraries}. Add view transitions between pages as well, or keep page motion to {libraries}?"), + vec![ + choice(format!("Keep {libraries} only"), "No view transitions; the existing motion stays the only page motion.", json!({"viewTransitions": {"source": "keep-library"}})), + choice("Add view transitions too", "A short cross-fade between pages, beside the existing library.", json!({"viewTransitions": {"source": "add"}})), + ], + )); + } + match &config.motion { + Some(value) => { + site.insert( + "motion".into(), + json!({"state": "config", "value": value, "evidence": CONFIG_FILE}), + ); + } + None => { + site.insert( + "motion".into(), + json!({"state": "asked", "question": "site:motion"}), + ); + questions.push(site_question( + "motion", + "site:motion", + "Motion beyond hover", + "Beyond hover, focus and page transitions, should sections reveal on scroll? Nothing in the plan shows designed motion.".into(), + vec![ + choice("None", "Recommended unless the design shows motion; hover and focus transitions are still added.", json!({"motion": {"source": "none"}})), + choice("Subtle reveal on scroll", "Sections fade and slide in once as they enter; off under prefers-reduced-motion.", json!({"motion": {"source": "reveal"}})), + ], + )); + } + } + (Value::Object(site), questions) +} + +/// The site completeness block of `status`: each item filled, asked and +/// answered, or missing, read from the files as they are now. +fn site_completeness(plan: &Value, evidence: &ProjectEvidence) -> Vec { + let site = &plan["site"]; + let decided = |field: &str| { + let entry = &site[field]; + let source = entry["answer"][field]["source"] + .as_str() + .or_else(|| entry["answer"]["source"].as_str()); + entry["state"] == "config" || (entry["state"] == "answered" && source != Some("later")) + }; + let file_item = |item: &str, field: &str, files: &[String]| { + if !files.is_empty() { + json!({"item": item, "state": "filled", "detail": files.join(", ")}) + } else if decided(field) { + json!({"item": item, "state": "asked-and-answered", "detail": "decided, but no file is in place yet"}) + } else { + json!({"item": item, "state": "missing", "detail": "no file, and no answer that provides one"}) + } + }; + let mut items = vec![ + file_item("favicon", "favicon", &evidence.icon_files), + file_item("og-image", "ogImage", &evidence.og_files), + ]; + for row in array(&plan["routes"]) + .iter() + .filter(|row| row["implementation"] == "page") + { + let url = text(&row["url"]); + let page = evidence + .routes + .iter() + .find(|(pattern, _)| pattern == url) + .map(|(_, page)| page.clone()); + let state = match &page { + Some(page) => match read_small(&join_relative(&evidence.root, page)) { + Some(source) + if devup_mcp_devup_ui::ui_validate::site::exports_metadata(&source) => + { + "filled" + } + _ => "missing", + }, + None => "missing", + }; + items.push(json!({"item": format!("metadata {url}"), "state": state, + "detail": page.unwrap_or_else(|| "page not implemented".into())})); + } + let mut roles = BTreeMap::::new(); + let mut motion = false; + let mut reduced = false; + let mut transitions = false; + for relative in &evidence.sources { + let Some(source) = read_small(&join_relative(&evidence.root, relative)) else { + continue; + }; + for row in devup_mcp_devup_ui::ui_validate::site::role_coverage(relative, &source) { + let entry = roles.entry(row.role).or_default(); + entry.0 += row.total; + entry.1 += row.complete; + } + motion |= source.contains("transition") + || source.contains("transform") + || source.contains("animation"); + reduced |= source.contains("_motionReduce") || source.contains("prefers-reduced-motion"); + transitions |= source.contains("ViewTransition") + || source.contains("viewTransitionName") + || source.contains("@view-transition"); + } + let complete = roles.values().all(|(total, done)| total == done); + items.push(json!({"item": "interaction-roles", + "state": if roles.is_empty() { "not-applicable" } else if complete { "filled" } else { "missing" }, + "detail": if roles.is_empty() { + "no interactive elements in the sources".to_owned() + } else { + roles.iter().map(|(role, (total, done))| format!("{} {done}/{total} {}", role.as_str(), role.requirement())).collect::>().join(", ") + }})); + items.push(json!({"item": "reduced-motion", + "state": if reduced { "filled" } else if motion { "missing" } else { "not-applicable" }, + "detail": if reduced { "prefers-reduced-motion is handled" } else if motion { "transitions or transforms with no prefers-reduced-motion handling" } else { "no motion in the sources" }})); + // Decided again from what is installed now: the plan was drafted before + // the dependency update, and the update is what makes it possible. + let planned = view_transition_default(evidence); + let mechanism = text(&planned["mechanism"]).to_owned(); + let declined = plan["site"]["viewTransitions"]["answer"]["source"] == "keep-library"; + items.push(match mechanism.as_str() { + "skipped" | "" => json!({"item": "view-transitions", "state": "not-applicable", + "detail": format!("skipped: {}", text(&planned["evidence"]))}), + _ if declined => json!({"item": "view-transitions", "state": "not-applicable", + "detail": "skipped: the user kept the project's own page motion"}), + _ if transitions => json!({"item": "view-transitions", "state": "filled", + "detail": format!("enabled ({mechanism}); the runtime audit's view-transition check proves it runs")}), + _ => json!({"item": "view-transitions", "state": "missing", + "detail": format!("planned ({mechanism}) but not in the sources yet")}), + }); + let skipped_update = array(&plan["defaults"]) + .iter() + .any(|default| default["item"] == "dependencies" && default["mechanism"] == "skipped"); + let record = &plan["dependencies"]; + items.push(if skipped_update { + json!({"item": "dependencies", "state": "not-applicable", "detail": format!("not updated: updateDependencies is false in {CONFIG_FILE}")}) + } else if record.is_object() { + let pins = array(&record["pins"]) + .iter() + .map(|pin| format!("{}@{} ({})", text(&pin["package"]), text(&pin["version"]), text(&pin["reason"]))) + .collect::>(); + json!({"item": "dependencies", "state": "filled", + "detail": format!("updated {}: {} packages bumped, {}", text(&record["updatedAt"]), record["bumped"], + if pins.is_empty() { "no pins".to_owned() } else { format!("pinned {}", pins.join(", ")) })}) + } else { + json!({"item": "dependencies", "state": "missing", + "detail": format!("not updated yet: run {UPDATE_STEP}, then record it with assume (dependencyUpdate)")}) + }); + for (item, field) in [("site-name", "siteName"), ("site-url", "siteUrl")] { + let entry = &site[field]; + let state = match entry["state"].as_str() { + Some("found" | "config") => "filled", + Some("answered") if decided(field) => "asked-and-answered", + _ => "missing", + }; + items.push(json!({"item": item, "state": state, "detail": entry.get("value").or(entry.get("answer")).cloned()})); + } + items +} + +/// Names that mark a page, section or frame as not for implementation. Words +/// in ASCII match whole words; the rest match anywhere in the name. +static EXCLUDE_WORDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-exclude-words.json")) + .expect("vendored keyword JSON") +}); + +fn keyword_in(name: &str, keyword: &str) -> bool { + let name = name.to_lowercase(); + let keyword = keyword.trim().to_lowercase(); + if keyword.is_empty() { + return false; + } + if keyword.chars().all(|c| c.is_ascii_alphanumeric()) { + name.split(|c: char| !c.is_alphanumeric()) + .any(|word| word == keyword) + } else { + name.contains(&keyword) + } +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/screen_plan/draft.rs b/crates/devup-mcp/src/server/screen_plan/draft.rs new file mode 100644 index 00000000..5629a7c3 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/draft.rs @@ -0,0 +1,587 @@ +/// The first exclude keyword in any of `names`, unless an include keyword +/// forces them in. +fn excluded_by<'a>(names: &[(&'a str, &'a str)], config: &PlanConfig) -> Option { + let include = config.include_keywords.as_deref().unwrap_or_default(); + if names + .iter() + .any(|(_, name)| include.iter().any(|keyword| keyword_in(name, keyword))) + { + return None; + } + let defaults = EXCLUDE_WORDS + .iter() + .map(|word| (*word).to_owned()) + .collect::>(); + let keywords = config.exclude_keywords.as_ref().unwrap_or(&defaults); + names.iter().find_map(|(place, name)| { + keywords + .iter() + .find(|keyword| keyword_in(name, keyword)) + .map(|keyword| format!("{place} \"{name}\" contains \"{keyword}\"")) + }) +} + +/// Why a frame is not for implementation, or `None`. The names of the +/// linked node and its ancestors are left out: the user pointed there, which +/// is asked once rather than excluded (see the `scope` question). +fn exclusion_reason( + screen: &PlanScreenInput, + linked: &[String], + config: &PlanConfig, +) -> Option { + if screen.visible == Some(false) { + return Some("hidden in Figma".into()); + } + if screen.opacity.is_some_and(|opacity| opacity <= 0.0) { + return Some("zero opacity in Figma".into()); + } + let mut names = vec![("frame", screen.name.as_str())]; + if let Some(section) = screen.section_name.as_deref() { + names.push(("section", section)); + } + for ancestor in screen + .breadcrumb + .split_last() + .map(|(_, ancestors)| ancestors) + .unwrap_or_default() + { + names.push(("parent", ancestor.as_str())); + } + names.retain(|(place, name)| *place == "frame" || !linked.iter().any(|link| link == name)); + let mut seen = BTreeSet::new(); + names.retain(|(_, name)| seen.insert(*name)); + excluded_by(&names, config) +} + +/// One devup_figma_explore candidate node as a screen to plan. Its parent +/// counts as its section only when the breadcrumb shows one between the page +/// and the frame; a frame drawn straight on the page is in no section. +pub(super) fn screen_from_explore(node: &Value) -> Option { + let breadcrumb = array(&node["breadcrumb"]) + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>(); + let parent = node["parentId"].as_str().map(str::to_owned); + let in_section = breadcrumb.len() >= 3; + Some(PlanScreenInput { + node_id: node["nodeId"].as_str()?.to_owned(), + name: text(&node["name"]).to_owned(), + width: node["bounds"]["width"].as_f64()?, + height: node["bounds"]["height"].as_f64(), + section: parent.clone().filter(|_| in_section), + section_name: in_section.then(|| breadcrumb[breadcrumb.len() - 2].clone()), + ancestors: parent.into_iter().collect(), + visible: node["visible"].as_bool(), + opacity: node["opacity"].as_f64(), + breadcrumb, + ..PlanScreenInput::default() + }) +} + +/// Whether a frame is under the node the user linked. A frame that carries +/// no ancestry at all came from the caller's own list and is taken as given; +/// one whose ancestry is known and does not reach the link is never a +/// candidate. +pub(super) fn in_scope( + screen: &PlanScreenInput, + anchor_id: Option<&str>, + anchor_name: Option<&str>, +) -> bool { + let Some(anchor) = anchor_id else { + return true; + }; + if screen.node_id == anchor + || screen.section.as_deref() == Some(anchor) + || screen.ancestors.iter().any(|id| id == anchor) + { + return true; + } + let ancestor_names = screen + .breadcrumb + .split_last() + .map(|(_, ancestors)| ancestors) + .unwrap_or_default(); + if anchor_name.is_some_and(|name| ancestor_names.iter().any(|ancestor| ancestor == name)) { + return true; + } + screen.section.is_none() && screen.ancestors.is_empty() && screen.breadcrumb.is_empty() +} + +fn with_when(mut question: Value, conditions: &[Value]) -> Value { + if !conditions.is_empty() { + let mut all = array(&question["when"]).to_vec(); + all.extend(conditions.iter().cloned()); + question["when"] = json!(all); + } + question +} + +const SECTIONS_SEPARATE: &str = "Separate pages: the sections are different pages"; +const SECTIONS_SINGLE: &str = "One responsive page: the sections only split by device"; +const KEEP_EXCLUDED: &str = "Keep it excluded"; +const BUILD_IT: &str = "Build it"; +const ACCEPT_EXCLUSIONS: &str = "Accept the exclusions"; +const PLAN_ANYWAY: &str = "Plan them: this is what I linked"; +const NOTHING_HERE: &str = "Nothing here is for implementation"; + +/// One route the evidence names, what kind of evidence it is, and where. +#[derive(Debug, Clone)] +struct RouteHit { + route: String, + kind: &'static str, + detail: String, +} + +/// `name-match` is the only kind that is a guess: a layer name compared with +/// a route segment. Every other kind is something the project or the caller +/// already says. +const NAME_MATCH: &str = "name-match"; + +/// Every route the evidence supports for these frames. +fn route_hits( + frames: &[Frame], + words: &[String], + targets: &BTreeMap, + evidence: &ProjectEvidence, + notes: &mut Vec, +) -> Vec { + let mut hits = Vec::new(); + for frame in frames { + if let Some(target) = targets.get(&frame.node_id) { + match normalize_route(target) { + Some(route) => hits.push(RouteHit { + route, + kind: "navigationTargets", + detail: format!("navigationTargets[{}]", frame.node_id), + }), + None => notes.push(format!( + "navigationTargets[{}] = {target:?} is not an app route and was not used.", + frame.node_id + )), + } + } + if let Some((route, file)) = evidence.figma_nodes.get(&frame.node_id) { + hits.push(RouteHit { + route: route.clone(), + kind: "figma-node", + detail: format!("data-figma-node-id=\"{}\" in {file}", frame.node_id), + }); + } + for (alias, route) in &evidence.config.route_aliases { + if alias == &frame.name || compact(alias) == compact(&words.join("")) { + hits.push(RouteHit { + route: route.clone(), + kind: "config", + detail: format!("{CONFIG_FILE} routeAliases[{alias:?}]"), + }); + } + } + } + let name = compact(&words.join("")); + for (label, href, location) in &evidence.nav_links { + if compact(label).is_empty() || compact(label) != name { + continue; + } + if let Some((route, _)) = evidence + .routes + .iter() + .find(|(route, _)| super::navigation::matches_route(route, href)) + { + hits.push(RouteHit { + route: route.clone(), + kind: "nav-label", + detail: format!("nav-label: {label:?} -> {href} ({location})"), + }); + } + } + if hits.is_empty() { + let screen_slug = slug(words); + let is_root = words.iter().any(|word| ROOT_WORDS.contains(&word.as_str())); + for (route, page) in &evidence.routes { + // The last segment only: `/news/[id]` is a detail page, and a + // screen named News is the list, not that page. + let last = route + .trim_matches('/') + .rsplit('/') + .next() + .unwrap_or_default(); + if (!screen_slug.is_empty() && last == screen_slug) || (route == "/" && is_root) { + hits.push(RouteHit { + route: route.clone(), + kind: NAME_MATCH, + detail: format!("layer name matches the existing route {route} ({page})"), + }); + } + } + } + hits +} + +/// The route the hits agree on, if they agree. +fn agreed(hits: &[RouteHit]) -> Option<&RouteHit> { + let distinct = hits + .iter() + .map(|hit| hit.route.as_str()) + .collect::>(); + (distinct.len() == 1).then(|| &hits[0]) +} + +fn existing_page<'a>(route: &str, evidence: &'a ProjectEvidence) -> Option<&'a str> { + evidence + .routes + .iter() + .find(|(candidate, _)| candidate == route) + .map(|(_, page)| page.as_str()) +} + +fn planned_page(route: &str, app_dir: &str) -> String { + let route = route.trim_matches('/'); + if route.is_empty() { + format!("{app_dir}/page.tsx") + } else { + format!("{app_dir}/{route}/page.tsx") + } +} + +/// A condition on another question's answer: this question is asked only +/// when that one resolved to `action`. The caller sees the label. +fn when(question: &str, label: &str, action: Value) -> Value { + json!({"question": question, "answer": label, "action": action}) +} + +const SINGLE: &str = "One responsive page"; +const SEPARATE: &str = "Separate pages"; +const ACCEPT_ALL: &str = "Accept all"; +const REVIEW: &str = "Review individually"; + +/// Builds the draft plan. Pure: every input is passed in, so the rules are +/// tested without Figma or a project on disk. +#[cfg(test)] +pub(super) fn draft_plan( + screens: &[PlanScreenInput], + targets: &BTreeMap, + evidence: &ProjectEvidence, + source: Value, + notes: Option<&str>, +) -> Result { + draft_plan_with(screens, &[], &[], targets, evidence, source, notes) +} + +/// How deep a question sits behind the answers it depends on. A question is +/// asked in a round after every question it depends on. +fn depth(question: &Value, questions: &[Value]) -> usize { + array(&question["when"]) + .iter() + .filter_map(|condition| { + questions + .iter() + .find(|other| other["id"] == condition["question"]) + }) + .map(|parent| depth(parent, questions) + 1) + .max() + .unwrap_or(0) +} + +/// The questions split into rounds a question tool takes in one call each: +/// at most four questions of at most four options, every question exactly +/// once, and a conditional question always in a later round than the one it +/// depends on. +pub(super) fn question_rounds(questions: &[Value], per_round: usize) -> Vec { + let mut by_depth = BTreeMap::>::new(); + for question in questions { + by_depth + .entry(depth(question, questions)) + .or_default() + .push(question); + } + let mut rounds = Vec::new(); + for level in by_depth.values() { + for chunk in level.chunks(per_round.max(1)) { + let shaped = ask_shape_with( + questions, + &chunk + .iter() + .map(|question| (*question).clone()) + .collect::>(), + ); + rounds.push(json!({"round": rounds.len() + 1, "questions": shaped})); + } + } + rounds +} + +fn plan_path(project_root: &str, relative: &str) -> String { + Path::new(project_root) + .join(relative) + .to_string_lossy() + .into_owned() +} + +/// Writes the plan JSON and its Markdown twin in one transaction, so the two +/// never describe different plans. +fn write_plan( + policy: &OutputPolicy, + project_root: &str, + plan: &Value, +) -> Result { + let json_target = policy.resolve(&plan_path(project_root, PLAN_FILE))?; + let markdown_target = policy.resolve(&plan_path(project_root, PLAN_MARKDOWN))?; + let written = json!([ + json_target.display_path().display().to_string(), + markdown_target.display_path().display().to_string(), + ]); + let bytes = serde_json::to_vec_pretty(plan) + .map_err(|error| invalid(format!("Cannot serialize the screen plan: {error}")))?; + let mut transaction = OutputTransaction::new(); + transaction.stage("screenPlan", json_target, &bytes)?; + transaction.stage( + "screenPlanMarkdown", + markdown_target, + markdown(plan).as_bytes(), + )?; + transaction.commit()?; + Ok(written) +} + +/// Reads `/.devup/screen-plan.json`, or `None` when there is +/// none. A file that exists but is not a plan is an error, never "no plan". +pub(super) fn read_plan(project_root: &str) -> Result, DevupError> { + let path = Path::new(project_root).join(PLAN_FILE); + let Ok(metadata) = std::fs::metadata(&path) else { + return Ok(None); + }; + if metadata.len() > MAX_PLAN_BYTES as u64 { + return Err(invalid(format!( + "{} exceeds the plan size limit.", + path.display() + ))); + } + let bytes = std::fs::read(&path) + .map_err(|error| invalid(format!("Cannot read {}: {error}", path.display())))?; + let plan: Value = serde_json::from_slice(&bytes) + .map_err(|error| invalid(format!("{} is not valid JSON: {error}", path.display())))?; + if plan["kind"] != PLAN_KIND || plan["schemaVersion"] != SCHEMA_VERSION { + return Err(invalid(format!( + "{} is not a devup screen plan; create one with devup_screen_plan action draft.", + path.display() + ))); + } + Ok(Some(plan)) +} + +fn require_root(input: &ScreenPlanInput) -> Result<&str, DevupError> { + input + .project_root + .as_deref() + .filter(|root| !root.trim().is_empty()) + .ok_or_else(|| invalid(format!("action {} needs projectRoot.", input.action))) +} + +/// `draft`: plan the given screens against the project and write both files. +#[cfg(test)] +pub(super) async fn draft( + input: &ScreenPlanInput, + screens: &[PlanScreenInput], + source: Value, + policy: &OutputPolicy, +) -> Result { + draft_with(input, screens, &input.assets, source, policy).await +} + +/// [`draft`] with the Figma asset candidates an explore found beside the +/// screens. +pub(super) async fn draft_with( + input: &ScreenPlanInput, + screens: &[PlanScreenInput], + assets: &[PlanAssetInput], + source: Value, + policy: &OutputPolicy, +) -> Result { + let root = require_root(input)?; + let evidence = project_evidence(root).await?; + let mut plan = draft_plan_with( + screens, + assets, + &input.extra_questions, + &input.navigation_targets, + &evidence, + source, + input.notes.as_deref(), + )?; + let per_round = input + .max_questions_per_round + .unwrap_or(MAX_QUESTIONS_PER_ROUND); + if !(1..=50).contains(&per_round) { + return Err(invalid("maxQuestionsPerRound must be between 1 and 50.")); + } + plan["maxQuestionsPerRound"] = json!(per_round); + plan["createdAt"] = json!(now()); + let replaced = read_plan(root) + .ok() + .flatten() + .filter(|previous| previous["status"] == "confirmed") + .map(|previous| previous["planId"].clone()); + let written = write_plan(policy, root, &plan)?; + let questions = ask_shape(array(&plan["questions"])); + let rounds = question_rounds(array(&plan["questions"]), per_round); + let unverified = array(&plan["screens"]) + .iter() + .filter(|screen| screen["routeEvidence"] == "UNVERIFIED") + .count(); + Ok(json!({ + "status": "draft", + "planId": plan["planId"], + "screens": screen_table(&plan), + "questionRounds": rounds, + "questions": questions, + "questionCount": questions.len(), + "askInstruction": ASK_INSTRUCTION, + "maxQuestionsPerRound": per_round, + "site": plan["site"], + "defaults": plan["defaults"], + "heuristics": plan["heuristics"], + "excluded": plan["excluded"], + "outOfScope": plan["outOfScope"], + "config": plan["config"], + "unverifiedRoutes": unverified, + "inventoryNote": plan["inventoryNote"], + "writtenTo": written, + "replacedConfirmedPlan": replaced, + "nextAction": if questions.is_empty() { + json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": {}}, + "how": "Nothing is open. Show the user the screens table, then confirm with empty answers."}) + } else { + json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": " chosen label, for every question that applies>"}, + "how": ASK_INSTRUCTION}) + }, + })) +} + +pub(super) fn strip_recommended(label: &str) -> &str { + label + .trim() + .strip_suffix(RECOMMENDED) + .unwrap_or(label.trim()) +} + +/// The action an answer selects, or the custom text when it matches no +/// option. A custom answer to a question that places a screen must itself be +/// a route; anything else is recorded and the recommended action applies. +fn resolve_answer( + question: &Value, + answer: &AnswerInput, +) -> Result<(Value, Option), DevupError> { + let id = text(&question["id"]); + let values = answer.values(); + if question["multiple"] == true { + let labels = values + .iter() + .map(|value| strip_recommended(value)) + .collect::>(); + return Ok((json!({"extra": labels}), None)); + } + let [value] = values.as_slice() else { + return Err(invalid(format!("{id} takes exactly one answer."))); + }; + let label = strip_recommended(value); + if let Some(found) = array(&question["resolve"]) + .iter() + .find(|option| text(&option["label"]) == label) + { + return Ok((found["action"].clone(), None)); + } + let route_of = |value: &str| normalize_route(value).filter(|route| valid_route(route)); + match text(&question["kind"]) { + "route" | "modal" => route_of(label) + .map(|route| (json!({"route": route}), Some(label.to_owned()))) + .ok_or_else(|| { + invalid(format!( + "{id}: a custom answer must be an app route such as /about, not {label:?}." + )) + }), + // One route is one page at that URL; one route per frame, in frame + // order, is separate pages at those URLs. + "group" => { + let routes = label + .split([',', ';', '\n']) + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| route_of(part.rsplit('=').next().unwrap_or(part).trim())) + .collect::>>() + .ok_or_else(|| { + invalid(format!( + "{id}: a custom answer must be one route, or one route per frame such as \"/a, /b\"; not {label:?}." + )) + })?; + match routes.as_slice() { + [route] => Ok(( + json!({"group": "single", "route": route}), + Some(label.to_owned()), + )), + _ => Ok(( + json!({"group": "separate", "routes": routes}), + Some(label.to_owned()), + )), + } + } + "link" => Ok(( + json!({"link": normalize_route(label)}), + Some(label.to_owned()), + )), + "extra" => Ok((json!({"extra": label}), Some(label.to_owned()))), + // A custom site answer is the file, name or URL itself. + "site" => { + let field = text(&question["field"]); + match field { + "favicon" | "ogImage" => Ok(( + json!({field: {"source": "provide", "path": label}}), + Some(label.to_owned()), + )), + "siteName" | "appName" => Ok(( + json!({field: {"source": "answer", "value": label}}), + Some(label.to_owned()), + )), + "app" => Ok(( + json!({field: {"dir": label, "appDir": format!("{}/src/app", label.trim_end_matches('/'))}}), + Some(label.to_owned()), + )), + "siteUrl" if label.starts_with("https://") || label.starts_with("http://") => Ok(( + json!({field: {"source": "answer", "value": label}}), + Some(label.to_owned()), + )), + "siteUrl" => Err(invalid(format!( + "{id}: a custom answer must be an http(s) URL, not {label:?}." + ))), + _ => Ok(( + array(&question["resolve"]) + .first() + .map(|option| option["action"].clone()) + .unwrap_or(Value::Null), + Some(label.to_owned()), + )), + } + } + _ => Ok(( + array(&question["resolve"]) + .first() + .map(|option| option["action"].clone()) + .unwrap_or(Value::Null), + Some(label.to_owned()), + )), + } +} + +fn per_round(plan: &Value) -> usize { + plan["maxQuestionsPerRound"] + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(MAX_QUESTIONS_PER_ROUND) +} + +/// Whether a question has to be answered, given the answers before it. +fn required(question: &Value, actions: &BTreeMap) -> bool { + array(&question["when"]) + .iter() + .all(|condition| actions.get(text(&condition["question"])) == Some(&condition["action"])) +} diff --git a/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs b/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs new file mode 100644 index 00000000..b61329d0 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs @@ -0,0 +1,552 @@ +/// [`draft_plan`] with Figma nodes that may become the favicon or share +/// image. +pub(super) fn draft_plan_with( + screens: &[PlanScreenInput], + assets: &[PlanAssetInput], + extra: &[ExtraQuestionInput], + targets: &BTreeMap, + evidence: &ProjectEvidence, + source: Value, + notes: Option<&str>, +) -> Result { + if screens.is_empty() { + return Err(invalid( + "draft found no screens. Link a Section or frame, or pass screens from devup_figma_explore.", + )); + } + if screens.len() > MAX_SCREENS { + return Err(invalid(format!( + "draft plans at most {MAX_SCREENS} screens; link one Section at a time." + ))); + } + // Only what the user pointed at, and never what is marked as not for + // implementation. Both are listed, not dropped. + let anchor_id = source["nodeId"].as_str(); + let anchor_name = source["anchorName"].as_str(); + let linked = array(&source["anchorBreadcrumb"]) + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .chain(anchor_name.map(str::to_owned)) + .collect::>(); + let mut out_of_scope = Vec::new(); + let mut excluded = Vec::new(); + let mut kept = Vec::new(); + for screen in screens { + if !in_scope(screen, anchor_id, anchor_name) { + out_of_scope.push(json!({"nodeId": screen.node_id, "name": screen.name, + "reason": "not under the linked node; never a candidate"})); + continue; + } + match exclusion_reason(screen, &linked, &evidence.config) { + Some(reason) => excluded.push((screen, reason)), + None => kept.push(screen.clone()), + } + } + if kept.is_empty() && excluded.is_empty() { + return Err(invalid( + "No screen is under the linked node. Link the Section or frame to plan; the plan never widens past it.", + )); + } + let mut questions = Vec::new(); + let mut base_when = Vec::new(); + let linked_names = linked + .iter() + .map(|name| ("linked node", name.as_str())) + .collect::>(); + if let Some(reason) = excluded_by(&linked_names, &evidence.config) { + questions.push(question( + "scope".into(), + "Linked area: build it?".into(), + format!( + "The link you gave points inside an area whose name marks it as not for implementation: {reason}. Plan the screens under it anyway?" + ), + vec![ + choice( + PLAN_ANYWAY, + "You linked this area on purpose; plan its screens.", + json!({"scope": "use"}), + ), + choice( + NOTHING_HERE, + "Build nothing from this link.", + json!({"scope": "none"}), + ), + ], + "scope", + )); + base_when.push(when("scope", PLAN_ANYWAY, json!({"scope": "use"}))); + } + if !excluded.is_empty() { + let listed = excluded + .iter() + .take(8) + .map(|(screen, reason)| format!("\"{}\" ({}): {reason}", screen.name, screen.node_id)) + .collect::>() + .join("; "); + let more = if excluded.len() > 8 { + format!("; and {} more listed in the plan", excluded.len() - 8) + } else { + String::new() + }; + questions.push(with_when( + question( + "exclusions".into(), + "Excluded areas".into(), + format!( + "These {} frames were excluded as trash, archive, draft, copy or hidden, and will not be built: {listed}{more}. Accept the exclusions?", + excluded.len() + ), + vec![ + choice( + ACCEPT_EXCLUSIONS, + "None of them is built; they stay listed in the plan.", + json!({"exclusions": "accept"}), + ), + choice( + REVIEW, + "Ask about each one in the next round.", + json!({"exclusions": "review"}), + ), + ], + "exclusions", + ), + &base_when, + )); + let review = when("exclusions", REVIEW, json!({"exclusions": "review"})); + for (screen, reason) in &excluded { + let id = &screen.node_id; + let conditions = [base_when.clone(), vec![review.clone()]].concat(); + questions.push(with_when( + question( + format!("exclusion:{id}"), + header("Excluded", &screen.name), + format!( + "\"{}\" ({id}) was excluded: {reason}. Keep it out of the build?", + screen.name + ), + vec![ + choice( + KEEP_EXCLUDED, + "Not built; listed as excluded.", + json!({"build": false}), + ), + choice( + BUILD_IT, + "Build it; its URL is asked next.", + json!({"build": true}), + ), + ], + "exclusion", + ), + &conditions, + )); + let screen_slug = slug(&base_words(&screen.name)); + let mut choices = Vec::new(); + if !screen_slug.is_empty() { + choices.push(choice( + format!("/{screen_slug}"), + "Named after the layer.", + json!({"route": format!("/{screen_slug}")}), + )); + } + choices.push(choice( + SKIP, + "Leave it out after all.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let built = [ + conditions, + vec![when( + &format!("exclusion:{id}"), + BUILD_IT, + json!({"build": true}), + )], + ] + .concat(); + questions.push(with_when( + question( + format!("exclusion-route:{id}"), + header("Route", &screen.name), + format!("Which URL should \"{}\" ({id}) be served at?", screen.name), + choices, + "route", + ), + &built, + )); + } + } + let excluded_rows = excluded + .iter() + .map(|(screen, reason)| { + json!({"nodeId": screen.node_id, "name": screen.name, "width": screen.width, + "section": screen.section_name.clone().or_else(|| screen.section.clone()), + "reason": reason}) + }) + .collect::>(); + // A frame named like a logo or a share image is a site asset, not a page. + let mut site_assets = assets + .iter() + .filter_map(|asset| { + asset_kind(&asset.name).map(|kind| (asset.node_id.clone(), asset.name.clone(), kind)) + }) + .collect::>(); + kept.retain(|screen| match asset_kind(&screen.name) { + Some(kind) => { + site_assets.push((screen.node_id.clone(), screen.name.clone(), kind)); + false + } + None => true, + }); + for asked in extra_questions(extra)? { + questions.push(with_when(asked, &base_when)); + } + let (screen_apps, app_questions, app_default) = assign_apps(evidence, &kept); + for asked in app_questions { + questions.push(with_when(asked, &base_when)); + } + // One app for the whole plan: its routes are the only route evidence. + let chosen = screen_apps + .values() + .map(|app| app.dir.clone()) + .collect::>(); + let scoped; + let evidence = match (chosen.len(), screen_apps.values().next()) { + (1, Some(app)) + if app_default + .as_ref() + .is_some_and(|item| item["mechanism"] == "decided") => + { + let mut narrowed = evidence.clone(); + narrowed + .routes + .retain(|(_, page)| page.starts_with(&format!("{}/", app.dir))); + narrowed.app_dir = app.path(app.app_router.as_deref().unwrap_or("src/app")); + narrowed.target_app = Some(app.clone()); + scoped = narrowed; + &scoped + } + _ => evidence, + }; + // A mixed file builds into several apps: each screen's routes come from + // its own app, never from another app's pages. + let per_app = if chosen.len() > 1 { + chosen + .iter() + .filter_map(|dir| { + let app = screen_apps.values().find(|app| &app.dir == dir)?; + let mut narrowed = evidence.clone(); + narrowed + .routes + .retain(|(_, page)| page.starts_with(&format!("{dir}/"))); + narrowed.app_dir = app.path(app.app_router.as_deref().unwrap_or("src/app")); + narrowed.target_app = Some(app.clone()); + Some((dir.clone(), narrowed)) + }) + .collect::>() + } else { + BTreeMap::new() + }; + let evidence_for = |frames: &[Frame]| -> &ProjectEvidence { + frames + .first() + .and_then(|frame| screen_apps.get(&frame.node_id)) + .and_then(|app| per_app.get(&app.dir)) + .unwrap_or(evidence) + }; + for asked in stack_questions(evidence) { + questions.push(with_when(asked, &base_when)); + } + let defaults = [ + app_default.into_iter().collect::>(), + stack_defaults(evidence), + site_defaults(evidence, &kept), + ] + .concat(); + let cover = kept + .first() + .map(|screen| (screen.node_id.clone(), screen.name.clone())); + let (site, site_asked) = site_questions( + evidence, + &site_assets, + cover + .as_ref() + .map(|(node, name)| (node.as_str(), name.as_str())), + ); + for asked in site_asked { + questions.push(with_when(asked, &base_when)); + } + let mut drafts = group_screens(&kept, &evidence.config); + for draft in &mut drafts { + draft.extra_when.extend(base_when.iter().cloned()); + } + for oddity in structure_questions(&drafts) { + let first = drafts[oddity.screens[0]].id().to_owned(); + let id = format!("structure:{}:{first}", oddity.kind); + let frames = oddity + .screens + .iter() + .flat_map(|index| drafts[*index].frames.iter().map(Frame::json)) + .collect::>(); + let mut asked = question( + id.clone(), + header("Figma structure", drafts[oddity.screens[0]].name()), + format!( + "{} Is that intended, or should the Figma file be fixed? This plan does not decide which frame is right.", + oddity.facts + ), + vec![ + choice( + oddity.keep, + oddity.keep_description.clone(), + json!({"structure": "keep"}), + ), + choice( + FIX_FIGMA, + "These frames are not built. They are listed under Design fixes requested; everything else goes on.", + json!({"designFix": true}), + ), + ], + "structure", + ); + asked["fix"] = json!({"frames": frames, "text": oddity.fix}); + questions.push(with_when(asked, &base_when)); + for index in &oddity.screens { + drafts[*index] + .extra_when + .push(when(&id, oddity.keep, json!({"structure": "keep"}))); + drafts[*index].structure.push(id.clone()); + } + } + let mut screen_notes = vec![Vec::new(); drafts.len()]; + let mut decided = drafts + .iter() + .enumerate() + .map(|(index, screen)| { + let hits = route_hits( + &screen.frames, + &screen.words, + targets, + evidence_for(&screen.frames), + &mut screen_notes[index], + ); + let proposed = agreed(&hits).map(|hit| (hit.route.clone(), hit.kind)); + (hits, proposed) + }) + .collect::>(); + // Two screens on one URL is a decision, not a fact: neither keeps it unasked. + let mut owners = BTreeMap::>::new(); + for (index, (_, proposed)) in decided.iter().enumerate() { + if let Some((route, _)) = proposed { + owners.entry(route.clone()).or_default().push(index); + } + } + for indices in owners.values().filter(|indices| indices.len() > 1) { + for index in indices { + if let Some((route, _)) = decided[*index].1.take() { + screen_notes[*index].push(format!( + "{route} is also the route of another screen in this plan." + )); + } + } + } + let claimed = decided + .iter() + .filter_map(|(_, proposed)| proposed.as_ref().map(|(route, _)| route.clone())) + .collect::>(); + let modal_words = evidence + .config + .modal_keywords + .clone() + .unwrap_or_else(|| MODAL_WORDS.iter().map(|word| (*word).to_owned()).collect()); + let kind_evidence = if evidence.config.modal_keywords.is_some() { + "config" + } else { + NAME_MATCH + }; + let is_modal = |name: &str| { + let lowered = name.to_lowercase(); + modal_words + .iter() + .any(|word| lowered.contains(&word.to_lowercase())) + }; + let page_screens = drafts + .iter() + .enumerate() + .filter(|(_, screen)| !is_modal(screen.name())) + .map(|(index, _)| index) + .collect::>(); + let frame_owner = drafts + .iter() + .enumerate() + .flat_map(|(index, screen)| { + screen + .frames + .iter() + .map(move |frame| (frame.node_id.clone(), index)) + }) + .collect::>(); + let route_choice = |evidence: &ProjectEvidence, route: &str, why: &str, extra: Value| { + let description = match existing_page(route, evidence) { + Some(page) => format!("{why} Exists at {page}; the screen is merged into it."), + None => format!( + "{why} New page at {}.", + planned_page(route, &evidence.app_dir) + ), + }; + let mut action = json!({"route": route}); + if let (Some(object), Some(extra)) = (action.as_object_mut(), extra.as_object()) { + object.extend(extra.clone()); + } + choice(route, description, action) + }; + let unclaimed_for = + |evidence: &ProjectEvidence, words: &[String], exclude: &BTreeSet| { + let mut unclaimed = evidence + .routes + .iter() + .filter(|(route, _)| !claimed.contains(route) && !exclude.contains(route)) + .map(|(route, _)| { + let score = route + .trim_matches('/') + .split(['/', '-']) + .filter(|segment| words.iter().any(|word| word == segment)) + .count(); + (score, route.clone()) + }) + .collect::>(); + unclaimed.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + unclaimed + }; + let mut rows = Vec::new(); + let mut heuristics = Vec::new(); + let mut part_screens = BTreeMap::>::new(); + for (index, screen) in drafts.iter().enumerate() { + plan_route_group( + RouteGroupPlanning { + evidence_for: &evidence_for, + is_modal: &is_modal, + route_choice: &route_choice, + unclaimed_for: &unclaimed_for, + decided: &decided, + claimed: &claimed, + drafts: &drafts, + targets, + screen_apps: &screen_apps, + kind_evidence, + page_screens: &page_screens, + frame_owner: &frame_owner, + screen_notes: &screen_notes, + questions: &mut questions, + rows: &mut rows, + heuristics: &mut heuristics, + part_screens: &mut part_screens, + }, + index, + screen, + ); + } + let route_heuristics = heuristics + .iter() + .filter(|item| text(&item["askedIn"]).starts_with("review:")) + .collect::>(); + if !route_heuristics.is_empty() { + let listed = route_heuristics + .iter() + .map(|item| format!("{} -> {}", text(&item["name"]), text(&item["decision"]))) + .collect::>() + .join(", "); + questions.push(question( + "heuristics".into(), + "Inferred from layer names".into(), + format!( + "These were inferred from layer names only: {listed}. Accept all (merged into the existing pages), or review each one?" + ), + vec![ + choice( + ACCEPT_ALL, + "Use every inferred route; existing pages are merged into, not overwritten.", + json!({"heuristics": "accept"}), + ), + choice( + REVIEW, + "Ask one question per inferred route, in the next round.", + json!({"heuristics": "review"}), + ), + ], + "heuristics", + )); + } + for (part, owners) in part_screens.iter().filter(|(part, owners)| { + owners.len() > 1 + && !evidence + .layout_parts + .iter() + .any(|existing| compact(existing) == compact(part)) + }) { + questions.push(question( + format!("layout:{}", compact(part)), + header("Shared part", part), + format!( + "\"{part}\" appears on {} screens and no layout renders it yet. Extract it once into a shared layout?", + owners.len() + ), + vec![ + choice( + "Extract into a shared layout", + "Build it once and render it from the App Router layout.", + json!({"layout": "shared"}), + ), + choice( + "Keep it in each page", + "Every page renders its own copy.", + json!({"layout": "per-page"}), + ), + ], + "layout", + )); + } + // Questions about the whole plan wait on the linked area being planned. + for asked in questions + .iter_mut() + .filter(|asked| asked["id"] == "heuristics" || text(&asked["id"]).starts_with("layout:")) + { + *asked = with_when(asked.clone(), &base_when); + } + let body = json!({"source": source, "screens": rows, "questions": questions, "notes": notes}); + let plan_id = format!( + "sp-{}", + &sha256(&serde_json::to_vec(&body).unwrap_or_default())[..16] + ); + Ok(json!({ + "kind": PLAN_KIND, + "schemaVersion": SCHEMA_VERSION, + "planId": plan_id, + "status": "draft", + "source": body["source"], + "notes": notes, + "appDir": evidence.app_dir, + "inventoryNote": evidence.inventory_note, + "config": evidence.config_file, + "screens": body["screens"], + "heuristics": heuristics, + "excluded": excluded_rows, + "outOfScope": out_of_scope, + "site": site, + "defaults": defaults, + "apps": evidence.apps.iter().map(super::framework::App::json).collect::>(), + "targetApp": evidence.target_app.as_ref().map(super::framework::App::json), + "siteAssets": site_assets.iter().map(|(node, name, kind)| json!({"nodeId": node, "name": name, "kind": kind})).collect::>(), + "questions": body["questions"], + "answers": {}, + "routes": [], + "assumptions": [], + })) +} diff --git a/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs b/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs new file mode 100644 index 00000000..93838988 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs @@ -0,0 +1,628 @@ +type RouteDecision = (Vec, Option<(String, &'static str)>); +type RouteChoiceFn<'a> = dyn Fn(&ProjectEvidence, &str, &str, Value) -> Choice + 'a; +type UnclaimedRoutesFn<'a> = + dyn Fn(&ProjectEvidence, &[String], &BTreeSet) -> Vec<(usize, String)> + 'a; + +struct RouteGroupPlanning<'a, 'e> { + evidence_for: &'a (dyn Fn(&[Frame]) -> &'e ProjectEvidence + 'a), + is_modal: &'a (dyn Fn(&str) -> bool + 'a), + route_choice: &'a RouteChoiceFn<'a>, + unclaimed_for: &'a UnclaimedRoutesFn<'a>, + decided: &'a [RouteDecision], + claimed: &'a BTreeSet, + drafts: &'a [DraftScreen], + targets: &'a BTreeMap, + screen_apps: &'a BTreeMap, + kind_evidence: &'a str, + page_screens: &'a [usize], + frame_owner: &'a BTreeMap, + screen_notes: &'a [Vec], + questions: &'a mut Vec, + rows: &'a mut Vec, + heuristics: &'a mut Vec, + part_screens: &'a mut BTreeMap>, +} + +fn plan_route_group(context: RouteGroupPlanning<'_, '_>, index: usize, screen: &DraftScreen) { + let RouteGroupPlanning { + evidence_for, + is_modal, + route_choice, + unclaimed_for, + decided, + claimed, + drafts, + targets, + screen_apps, + kind_evidence, + page_screens, + frame_owner, + screen_notes, + questions, + rows, + heuristics, + part_screens, + } = context; + + let evidence = evidence_for(&screen.frames); + let (hits, proposed) = &decided[index]; + let modal = is_modal(screen.name()); + let id = screen.id().to_owned(); + let name = screen.name().to_owned(); + let widths = screen.widths(); + let mut decisions = Vec::new(); + let candidates = hits + .iter() + .map(|hit| hit.route.clone()) + .collect::>(); + let screen_slug = slug(&screen.words); + let new_route = (!screen_slug.is_empty()).then(|| format!("/{screen_slug}")); + // Questions about this screen as one page apply only if the group is + // one page; when that is still being asked, they carry the condition. + let group_id = screen.grouping_question().unwrap_or_default(); + let (single_label, separate_label) = match screen.grouping { + Grouping::Sections { .. } => (SECTIONS_SINGLE, SECTIONS_SEPARATE), + _ => (SINGLE, SEPARATE), + }; + let mut single_when = screen.extra_when.clone(); + if !matches!(screen.grouping, Grouping::Fixed(_)) { + single_when.push(when(&group_id, single_label, json!({"group": "single"}))); + } + let push = |questions: &mut Vec, mut question: Value, extra: &[Value]| { + let conditions = single_when.iter().chain(extra).cloned().collect::>(); + if !conditions.is_empty() { + question["when"] = json!(conditions); + } + questions.push(question); + }; + let grouping_question = match &screen.grouping { + Grouping::Fixed(_) => None, + Grouping::Ask { + recommend_single, + reason, + } => { + let single = choice( + SINGLE, + "One URL; the widths become devup-ui responsive arrays (responsiveTsx).", + json!({"group": "single"}), + ); + let separate = choice( + SEPARATE, + "Each frame is its own page and gets its own route, asked next.", + json!({"group": "separate"}), + ); + Some(question( + group_id.clone(), + header("Pages", &name), + format!( + "These frames share the name \"{name}\" and are {reason}: {}. One responsive page, or separate pages? A custom answer may list one route per frame, e.g. \"/a, /b\".", + describe_frames(&screen.frames) + ), + if *recommend_single { + vec![single, separate] + } else { + vec![separate, single] + }, + "group", + )) + } + Grouping::Sections { + all_device, + sections, + why, + } => { + let separate = choice( + SECTIONS_SEPARATE, + "Each frame is its own page; its URL is asked next.", + json!({"group": "separate"}), + ); + let single = choice( + SECTIONS_SINGLE, + if *all_device { + format!( + "Inferred from the section names ({}), which name only devices.", + sections.join(", ") + ) + } else { + "The sections would only split one page by device.".to_owned() + }, + json!({"group": "single"}), + ); + let fix = choice( + FIX_FIGMA, + "These frames are not built. They are listed under Design fixes requested; everything else goes on.", + json!({"designFix": true}), + ); + let mut asked = question( + group_id.clone(), + header("Sections", &name), + format!( + include_str!("../text/screen_plan-draft_plan_with-140b58b5.md"), + sections.join(", "), + describe_frames(&screen.frames), + why = why + ), + if *all_device { + vec![single, separate, fix] + } else { + vec![separate, single, fix] + }, + "group", + ); + asked["fix"] = json!({ + "frames": screen.frames.iter().map(Frame::json).collect::>(), + "text": format!( + "Frames {} share the name \"{name}\" but sit in sections {}. Move them into one section if they are one page, or rename them if they are different pages.", + describe_frames(&screen.frames), + sections.join(", ") + ), + }); + Some(asked) + } + }; + if let Some(asked) = grouping_question { + decisions.push("one-page-or-separate"); + questions.push(with_when(asked, &screen.extra_when)); + for frame in &screen.frames { + let mut frame_notes = Vec::new(); + let frame_hits = route_hits( + std::slice::from_ref(frame), + &screen.words, + targets, + evidence, + &mut frame_notes, + ); + let mut choices = Vec::new(); + for hit in frame_hits.iter().filter(|hit| hit.kind != NAME_MATCH) { + choices.push(route_choice( + evidence, + &hit.route, + &format!("From {}.", hit.detail), + json!({}), + )); + } + for hit in frame_hits.iter().filter(|hit| hit.kind == NAME_MATCH) { + choices.push(route_choice( + evidence, + &hit.route, + "Inferred from the layer name; only one frame can take it.", + json!({}), + )); + } + let section_slug = frame + .section_name + .as_deref() + .map(|section| slug(&base_words(section))) + .unwrap_or_default(); + if !section_slug.is_empty() && !screen_slug.is_empty() { + choices.push(route_choice( + evidence, + &format!("/{section_slug}/{screen_slug}"), + "Named after its Figma section and layer.", + json!({}), + )); + } + for (_, route) in unclaimed_for(evidence, &screen.words, &candidates) + .iter() + .filter(|(score, _)| *score > 0) + { + choices.push(route_choice( + evidence, + route, + "Existing route with a similar name.", + json!({}), + )); + } + choices.truncate(MAX_OPTIONS - 1); + choices.push(choice( + SKIP, + "Leave this frame out of this build.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let asked = question( + format!("split-route:{}", frame.node_id), + header("Route", &format!("{} {}", frame.name, frame.width.round())), + format!( + "As a separate page, which URL should \"{}\" ({}, {}px) be served at?", + frame.name, + frame.node_id, + frame.width.round() + ), + choices, + "route", + ); + let separate_when = [ + screen.extra_when.clone(), + vec![when( + &group_id, + separate_label, + json!({"group": "separate"}), + )], + ] + .concat(); + questions.push(with_when(asked, &separate_when)); + } + } + let route_evidence = match proposed { + Some((_, kind)) if *kind == NAME_MATCH => json!(NAME_MATCH), + Some(_) => json!( + hits.iter() + .map(|hit| hit.detail.clone()) + .collect::>() + .join("; ") + ), + None => json!("UNVERIFIED"), + }; + if modal { + decisions.push("modal-or-route"); + let mut hosts = page_screens + .iter() + .filter(|other| **other != index) + .map(|other| &drafts[*other]) + .map(|host| { + let shared = host + .words + .iter() + .filter(|word| screen.words.contains(word)) + .count(); + (shared, host) + }) + .collect::>(); + hosts.sort_by_key(|host| std::cmp::Reverse(host.0)); + let state_choices = hosts + .iter() + .take(2) + .map(|(_, host)| { + choice( + format!("State on {}", truncate(host.name(), 40)), + format!( + "Open/close state inside the page built from {} ({}); no URL of its own.", + host.name(), + host.id() + ), + json!({"state": host.id()}), + ) + }) + .collect::>(); + let own_route = proposed + .as_ref() + .map(|(route, _)| route_choice(evidence, route, "A page with its own URL.", json!({}))) + .or_else(|| { + new_route.as_ref().map(|route| { + route_choice( + evidence, + route, + "A page with its own URL, named after the layer.", + json!({}), + ) + }) + }); + let mut choices = Vec::new(); + if proposed + .as_ref() + .is_some_and(|(_, kind)| *kind != NAME_MATCH) + { + choices.extend(own_route); + choices.extend(state_choices); + } else { + choices.extend(state_choices); + choices.extend(own_route); + } + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + let why = if kind_evidence == NAME_MATCH { + "Its layer name suggests a modal or overlay (name-match)" + } else { + "Its layer name matches the project's modalKeywords" + }; + push( + questions, + question( + format!("modal:{id}"), + header("Modal", &name), + format!( + "\"{name}\" ({id}): {why}. Is it a state of another screen, or a page with its own URL?" + ), + choices, + "modal", + ), + &[], + ); + if kind_evidence == NAME_MATCH { + heuristics.push(json!({"screen": id, "name": name, "decision": "kind: modal", "evidence": NAME_MATCH, "askedIn": format!("modal:{id}")})); + } + } else if let Some((route, NAME_MATCH)) = proposed { + decisions.push("route-from-name-match"); + heuristics.push(json!({"screen": id, "name": name, "decision": format!("route: {route}"), "evidence": NAME_MATCH, "askedIn": format!("review:{id}")})); + let mut choices = vec![ + route_choice( + evidence, + route, + "Keep the inferred route.", + json!({"collision": "merge"}), + ), + choice( + format!("Overwrite {route}"), + "Keep the inferred route, but replace the existing page file outright.", + json!({"route": route, "collision": "overwrite"}), + ), + ]; + if let Some(alternative) = new_route + .as_ref() + .filter(|candidate| *candidate != route && !claimed.contains(*candidate)) + { + choices.push(route_choice( + evidence, + alternative, + "Named after the layer.", + json!({}), + )); + } + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + push( + questions, + question( + format!("review:{id}"), + header("Check route", &name), + format!( + "\"{name}\" ({id}) was matched to {route} by its layer name only. Keep it?" + ), + choices, + "route", + ), + &[when("heuristics", REVIEW, json!({"heuristics": "review"}))], + ); + } else if proposed.is_none() { + decisions.push(if candidates.len() > 1 { + "conflicting-route-evidence" + } else { + "no-route-evidence" + }); + let mut choices = Vec::new(); + for route in &candidates { + choices.push(route_choice( + evidence, + route, + "Named by conflicting evidence.", + json!({}), + )); + } + let unclaimed = unclaimed_for(evidence, &screen.words, &candidates); + for (_, route) in unclaimed.iter().filter(|(score, _)| *score > 0) { + choices.push(route_choice( + evidence, + route, + "Existing route with a similar name.", + json!({}), + )); + } + if let Some(route) = new_route.as_ref().filter(|route| !claimed.contains(*route)) { + choices.push(route_choice( + evidence, + route, + "Named after the layer.", + json!({}), + )); + } + for (_, route) in unclaimed.iter().filter(|(score, _)| *score == 0).take(1) { + choices.push(route_choice( + evidence, + route, + "Existing route not claimed by another screen.", + json!({}), + )); + } + choices.truncate(MAX_OPTIONS - 1); + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let reason = if candidates.len() > 1 { + format!( + "The evidence names {} different routes.", + candidates.iter().cloned().collect::>().join(", ") + ) + } else { + "No existing page, nav link, navigationTargets entry or routeAliases entry names it." + .to_owned() + }; + push( + questions, + question( + format!("route:{id}"), + header("Route", &name), + format!( + "Which URL should the Figma screen \"{name}\" ({id}, drawn at {}) be served at? {reason}", + width_list(&widths) + ), + choices, + "route", + ), + &[], + ); + } + let route = proposed.as_ref().map(|(route, _)| route.as_str()); + let existing = route.and_then(|route| existing_page(route, evidence)); + let name_matched = proposed + .as_ref() + .is_some_and(|(_, kind)| *kind == NAME_MATCH); + if !modal + && !name_matched + && let (Some(route), Some(page)) = (route, existing) + { + decisions.push("collides-with-existing-page"); + push( + questions, + question( + format!("collision:{id}"), + header("Existing page", route), + format!( + "{route} already has a page ({page}). What should building \"{name}\" ({id}) do to it?" + ), + vec![ + choice( + "Merge into the existing page", + "Keep the page file and its data wiring; replace its markup with the screen.", + json!({"collision": "merge"}), + ), + choice( + "Overwrite the existing page", + "Replace the page file outright; existing wiring is lost.", + json!({"collision": "overwrite"}), + ), + choice( + SKIP, + "Leave the existing page as it is.", + json!({"collision": "skip"}), + ), + ], + "collision", + ), + &[], + ); + } + if let Some(route) = route.filter(|route| route.contains('[')) { + decisions.push("dynamic-segment"); + push( + questions, + question( + format!("dynamic:{id}"), + header("Dynamic route", route), + format!( + "{route} has a dynamic segment. Where does \"{name}\" get the record it shows?" + ), + vec![ + choice( + "Sample data from the design", + "Render the Figma content as fixture data for one id; wire real data later.", + json!({"data": "sample"}), + ), + choice( + "Wire to the API now", + "Fetch the record by the segment value; needs the endpoint from devup_project_context scope api.", + json!({"data": "api"}), + ), + ], + "dynamic", + ), + &[], + ); + } + if widths.len() > 1 { + decisions.push("several-widths"); + } + let mut links = Vec::new(); + for (link_index, link) in screen.links.iter().enumerate() { + let resolved = link + .url + .as_deref() + .map(|url| json!({"url": url, "evidence": "figma-url"})) + .or_else(|| { + let destination = link.destination_node_id.as_deref()?; + targets + .get(destination) + .map(|url| json!({"url": url, "evidence": format!("navigationTargets[{destination}]")})) + .or_else(|| { + frame_owner.get(destination).map(|owner| { + json!({"screen": drafts[*owner].id(), "evidence": "destination frame in this plan"}) + }) + }) + }); + if resolved.is_none() { + decisions.push("unresolved-link"); + let mut choices = vec![choice( + "Leave unlinked", + "Render it without an href; wire it once the destination exists.", + json!({"link": null}), + )]; + for (route, _) in evidence.routes.iter().take(MAX_OPTIONS - 1) { + choices.push(choice( + route.as_str(), + format!("Link to the existing route {route}."), + json!({"link": route}), + )); + } + if choices.len() < 2 { + choices.push(choice( + "Remove the link", + "Render the label as plain text.", + json!({"link": "remove"}), + )); + } + questions.push(with_when(question( + format!("link:{id}:{link_index}"), + header("Link", &link.label), + format!( + "\"{}\" on \"{name}\" ({id}) links somewhere this plan cannot see{}. Where should it go?", + link.label, + link.destination_node_id + .as_deref() + .map(|node| format!(" (Figma destination {node})")) + .unwrap_or_default() + ), + choices, + "link", + ), &screen.extra_when)); + } + links.push(json!({"label": link.label, "destinationNodeId": link.destination_node_id, "resolved": resolved})); + } + let mut shared = evidence.layout_parts.clone(); + for part in &screen.parts { + part_screens + .entry(part.clone()) + .or_default() + .push(id.clone()); + if !shared.contains(part) { + shared.push(part.clone()); + } + } + rows.push(json!({ + "id": id, + "name": name, + "kind": if modal { "modal" } else { "page" }, + "kindEvidence": if modal { json!(kind_evidence) } else { Value::Null }, + "frames": screen.frames.iter().map(Frame::json).collect::>(), + "drawnWidths": widths, + "app": screen_apps.get(screen.id()).map(|app| app.dir.clone()), + "appDir": screen_apps.get(screen.id()).map(|app| app.path(app.app_router.as_deref().unwrap_or("src/app"))), + "groupingQuestion": screen.grouping_question(), + "structureQuestions": screen.structure, + "grouping": match &screen.grouping { + Grouping::Fixed(evidence) => json!({"mode": "decided", "evidence": evidence}), + Grouping::Sections { all_device, sections, why } => json!({"mode": "ask-sections", "question": group_id, + "sections": sections, "allDeviceNames": all_device, "why": why}), + Grouping::Ask { recommend_single, reason } => json!({"mode": "ask", "question": group_id, + "recommended": if *recommend_single { "single" } else { "separate" }, "reason": reason}), + }, + "proposedRoute": route, + "routeEvidence": route_evidence, + "routeCandidates": candidates, + "existingFile": existing, + "sharedParts": shared, + "links": links, + "openDecisions": decisions, + "notes": screen_notes[index], +})); +} diff --git a/crates/devup-mcp/src/server/screen_plan/routes.rs b/crates/devup-mcp/src/server/screen_plan/routes.rs new file mode 100644 index 00000000..87676176 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/routes.rs @@ -0,0 +1,867 @@ + + +/// As [`ask_shape`], with `all` the whole plan's questions so a conditional +/// question can say in its own text when it applies - the flat list may be +/// asked in one call, where the answer it depends on is not yet known. +fn ask_shape_with(all: &[Value], questions: &[Value]) -> Vec { + questions + .iter() + .map(|question| { + let mut shaped = question.clone(); + let conditions = array(&question["when"]) + .iter() + .map(|condition| { + let parent = all + .iter() + .find(|other| other["id"] == condition["question"]) + .map_or_else( + || text(&condition["question"]).to_owned(), + |other| text(&other["header"]).to_owned(), + ); + format!("\"{parent}\" is \"{}\"", text(&condition["answer"])) + }) + .collect::>(); + if !conditions.is_empty() { + shaped["question"] = json!(format!( + "{} (Only if {}.)", + text(&question["question"]), + conditions.join(" and ") + )); + } + if let Some(object) = shaped.as_object_mut() { + object.remove("resolve"); + object.remove("kind"); + object.remove("fix"); + if let Some(conditions) = object.get_mut("when").and_then(Value::as_array_mut) { + for condition in conditions { + if let Some(condition) = condition.as_object_mut() { + condition.remove("action"); + } + } + } + } + shaped + }) + .collect() +} + +#[derive(Debug, Clone)] +struct Frame { + node_id: String, + name: String, + width: f64, + height: Option, + section: Option, + section_name: Option, +} + +impl Frame { + fn json(&self) -> Value { + json!({"nodeId": self.node_id, "name": self.name, "width": self.width, "height": self.height, + "section": self.section, "sectionName": self.section_name}) + } +} + +/// How the frames of one name are turned into pages. +#[derive(Debug, Clone, PartialEq)] +enum Grouping { + /// Decided: one page, from a single frame, project config, or evidence. + Fixed(&'static str), + /// Several widths in one section: asked, one responsive page recommended. + Ask { + recommend_single: bool, + reason: String, + }, + /// The same screen name in different sections. A designer usually does + /// that for a reason, so this is never resolved here: the question names + /// the sections and frames and offers to have the Figma fixed instead. + Sections { + all_device: bool, + sections: Vec, + why: String, + }, +} + +#[derive(Debug, Clone)] +struct DraftScreen { + frames: Vec, + words: Vec, + parts: Vec, + links: Vec, + grouping: Grouping, + /// Conditions every question about this screen carries: a structure + /// question about it must have been answered with "keep" for any of them + /// to apply. + extra_when: Vec, + /// The structure questions this screen is part of, so confirm can tell + /// whether the user asked for a Figma fix instead. + structure: Vec, +} + +impl DraftScreen { + fn id(&self) -> &str { + &self.frames[0].node_id + } + fn name(&self) -> &str { + &self.frames[0].name + } + fn widths(&self) -> Vec { + widths_of(&self.frames) + } + /// The question that decides how this screen's frames become pages. + fn grouping_question(&self) -> Option { + match self.grouping { + Grouping::Fixed(_) => None, + Grouping::Ask { .. } => Some(format!("group:{}", self.id())), + Grouping::Sections { .. } => Some(format!("sections:{}", self.id())), + } + } +} + +fn widths_of(frames: &[Frame]) -> Vec { + let mut widths = frames + .iter() + .map(|frame| frame.width.round().max(0.0) as u32) + .collect::>(); + widths.sort_unstable_by(|a, b| b.cmp(a)); + widths.dedup(); + widths +} + +fn width_list(widths: &[u32]) -> String { + widths + .iter() + .map(u32::to_string) + .collect::>() + .join("/") +} + +/// Whether a section name says only which device it is for: `PC`, +/// `Mobile`, `Tablet`, `모바일`. +fn device_name(name: &str) -> bool { + let lowered = name.to_lowercase(); + let words = lowered + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .collect::>(); + !words.is_empty() + && words.iter().all(|word| { + DEVICE_WORDS.contains(word) + || SECTION_DEVICE_WORDS.contains(word) + || word.chars().all(|c| c.is_ascii_digit()) + }) +} + +fn section_label(frame: &Frame) -> String { + frame + .section_name + .clone() + .or_else(|| frame.section.clone()) + .unwrap_or_else(|| "(no section)".to_owned()) +} + +fn describe_frames(frames: &[Frame]) -> String { + frames + .iter() + .map(|frame| { + format!( + "\"{}\" ({}, {}px, section {})", + frame.name, + frame.node_id, + frame.width.round(), + section_label(frame) + ) + }) + .collect::>() + .join("; ") +} + +/// Frames that share a base name are candidates for one page. Whether they +/// are one is not assumed: +/// +/// - several widths in one section that can merge into one responsive tree +/// are asked, recommending one responsive page; `widthGrouping` in the +/// project config decides this case instead of asking; +/// - the same name in different sections - device-named sections included - +/// is always asked, with the facts and a "fix the Figma" option, because +/// that is a designer's structure and not this server's to correct; +/// - the same name at the same width in one section is not grouped; it is +/// flagged as a possible duplicate by [`structure_questions`]. +fn group_screens(screens: &[PlanScreenInput], config: &PlanConfig) -> Vec { + let mut order = Vec::::new(); + let mut groups = BTreeMap::>::new(); + for screen in screens { + let key = base_words(&screen.name).join(" "); + if !groups.contains_key(&key) { + order.push(key.clone()); + } + groups.entry(key).or_default().push(screen); + } + let make = |members: &[&PlanScreenInput], grouping: Grouping| { + let mut frames = members + .iter() + .map(|screen| Frame { + node_id: screen.node_id.clone(), + name: screen.name.clone(), + width: screen.width, + height: screen.height, + section: screen.section.clone(), + section_name: screen.section_name.clone(), + }) + .collect::>(); + frames.sort_by(|a, b| b.width.total_cmp(&a.width)); + let mut parts = Vec::::new(); + let mut links = Vec::new(); + for screen in members { + for part in &screen.parts { + if !parts.contains(part) { + parts.push(part.clone()); + } + } + links.extend(screen.links.iter().cloned()); + } + DraftScreen { + frames, + words: base_words(&members[0].name), + parts, + links, + grouping, + extra_when: Vec::new(), + structure: Vec::new(), + } + }; + let mut result = Vec::new(); + for key in order { + let members = &groups[&key]; + if members.len() == 1 { + result.push(make(members, Grouping::Fixed("single-frame"))); + continue; + } + // Asked of the base name, not the layer name: `Main - 1440` and + // `Main - 390` are one name across two widths once the width is gone. + let named = members + .iter() + .map(|screen| (key.as_str(), screen.width)) + .collect::>(); + let family = devup_mcp_devup_ui::codegen::responsive::breakpoint_family_is_possible(&named); + let sections = members + .iter() + .map(|screen| screen.section.as_deref()) + .collect::>(); + if sections.len() > 1 { + let probe = make(members, Grouping::Fixed("")); + let mut names = Vec::::new(); + for frame in &probe.frames { + let label = section_label(frame); + if !names.contains(&label) { + names.push(label); + } + } + let all_device = probe + .frames + .iter() + .all(|frame| frame.section_name.as_deref().is_some_and(device_name)); + let mut why = vec!["the same name once widths and device words are removed".to_owned()]; + if family { + why.push(format!( + "their widths ({}) form a desktop-to-mobile pattern", + width_list(&probe.widths()) + )); + } + result.push(make( + members, + Grouping::Sections { + all_device, + sections: names, + why: why.join(", and "), + }, + )); + continue; + } + if !family { + for member in members { + result.push(make(&[member], Grouping::Fixed("single-frame"))); + } + continue; + } + match config.width_grouping.as_deref() { + Some("responsive") => { + result.push(make(members, Grouping::Fixed("config:widthGrouping"))); + } + Some("separate") => { + for member in members { + result.push(make(&[member], Grouping::Fixed("config:widthGrouping"))); + } + } + _ => result.push(make( + members, + Grouping::Ask { + recommend_single: true, + reason: "drawn at several widths in one section with matching names".into(), + }, + )), + } + } + result +} + +/// The base name without a trailing part number: `Main 1` and `Main (2)` +/// are parts of one screen if anything is. +fn part_key(words: &[String]) -> Option { + let (last, rest) = words.split_last()?; + let numbered = + last.chars().all(|c| c.is_ascii_digit()) && last.parse::().is_ok_and(|n| n < 100); + let rest = rest + .iter() + .filter(|word| !matches!(word.as_str(), "part" | "p")) + .cloned() + .collect::>(); + (numbered && !rest.is_empty()).then(|| rest.join(" ")) +} + +const KEEP_SEPARATE: &str = "They are different screens"; +const KEEP_OUTSIDE: &str = "It is its own screen"; +pub(super) const FIX_FIGMA: &str = "The Figma structure is wrong: I will fix it"; + +/// One structural oddity: the frames involved, what looks wrong, and the fix +/// the designer would make. +struct Oddity { + kind: &'static str, + screens: Vec, + facts: String, + keep: &'static str, + keep_description: String, + fix: String, +} + +/// Figma structure that a designer may not have meant, flagged in the same +/// batch with a "fix it" option. Nothing here decides which frame is right. +fn structure_questions(drafts: &[DraftScreen]) -> Vec { + let mut found = Vec::new(); + let singles = drafts + .iter() + .enumerate() + .filter(|(_, screen)| screen.grouping == Grouping::Fixed("single-frame")) + .collect::>(); + // The same name and width in one section. + let mut duplicates = BTreeMap::<(String, Option, u32), Vec>::new(); + for (index, screen) in &singles { + let frame = &screen.frames[0]; + duplicates + .entry(( + screen.words.join(" "), + frame.section.clone(), + widths_of(&screen.frames)[0], + )) + .or_default() + .push(*index); + } + for indices in duplicates.values().filter(|indices| indices.len() > 1) { + let frames = indices + .iter() + .map(|index| drafts[*index].frames[0].clone()) + .collect::>(); + found.push(Oddity { + kind: "duplicate", + screens: indices.clone(), + facts: format!( + "{} frames share one name and one width in one section: {}.", + frames.len(), + describe_frames(&frames) + ), + keep: KEEP_SEPARATE, + keep_description: "Each frame is its own screen and gets its own route.".into(), + fix: format!( + "Delete the stale copy or rename the frames so each screen has one name: {}.", + describe_frames(&frames) + ), + }); + } + // One screen split into numbered parts across sections. + let mut parts = BTreeMap::>::new(); + for (index, screen) in drafts.iter().enumerate() { + if let Some(key) = part_key(&screen.words) { + parts.entry(key).or_default().push(index); + } + } + for indices in parts.values().filter(|indices| indices.len() > 1) { + let frames = indices + .iter() + .flat_map(|index| drafts[*index].frames.clone()) + .collect::>(); + let sections = frames + .iter() + .map(|frame| frame.section.clone()) + .collect::>(); + if sections.len() < 2 { + continue; + } + found.push(Oddity { + kind: "split", + screens: indices.clone(), + facts: format!( + "These numbered frames look like parts of one screen spread across sections: {}.", + describe_frames(&frames) + ), + keep: KEEP_SEPARATE, + keep_description: "Each part is its own screen and gets its own route.".into(), + fix: format!( + "Put the parts of one screen into one frame in one section: {}.", + describe_frames(&frames) + ), + }); + } + // A frame outside any section beside frames that are in sections. + let sectioned = drafts + .iter() + .flat_map(|screen| &screen.frames) + .filter_map(|frame| frame.section.as_ref().map(|_| section_label(frame))) + .collect::>(); + if !sectioned.is_empty() { + for (index, screen) in &singles { + let frame = &screen.frames[0]; + if frame.section.is_some() { + continue; + } + found.push(Oddity { + kind: "loose", + screens: vec![*index], + facts: format!( + "\"{}\" ({}, {}px) sits outside any section, while the other frames are in {}.", + frame.name, + frame.node_id, + frame.width.round(), + sectioned.iter().cloned().collect::>().join(", ") + ), + keep: KEEP_OUTSIDE, + keep_description: "Plan it as a screen of its own, outside the sections.".into(), + fix: format!( + "Move \"{}\" ({}) into the section it belongs to, or out of this area.", + frame.name, frame.node_id + ), + }); + } + } + found +} + +/// Layer names that mark a Figma node as the site's logo or icon, and as its +/// share image. Frames named like this are site assets, not pages. +static LOGO_WORDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-logo-words.json")) + .expect("vendored keyword JSON") +}); +/// Narrow on purpose: a bare "share" or "thumbnail" names real UI - a share +/// sheet, a video thumbnail - and those are screens. +static OG_WORDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-og-words.json")) + .expect("vendored keyword JSON") +}); +/// Package names that say a project already measures or verifies something. +static ANALYTICS_PACKAGES: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-analytics-packages.json")) + .expect("vendored keyword JSON") + }); +static ANALYTICS_ENV: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-analytics-env.json")) + .expect("vendored keyword JSON") +}); + +fn asset_kind(name: &str) -> Option<&'static str> { + if LOGO_WORDS.iter().any(|word| keyword_in(name, word)) || keyword_in(name, "app icon") { + Some("logo") + } else if OG_WORDS.iter().any(|word| keyword_in(name, word)) { + Some("og") + } else { + None + } +} + +/// A Figma node that may become the favicon or the share image. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PlanAssetInput { + pub node_id: String, + pub name: String, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, +} + +/// Files under `directory` (relative to `root`) whose names start with one of +/// `prefixes`, as relative paths. +fn files_named(root: &Path, directory: &str, prefixes: &[&str]) -> Vec { + let Ok(entries) = std::fs::read_dir(join_relative(root, directory)) else { + return Vec::new(); + }; + let mut found = entries + .flatten() + .filter(|entry| entry.path().is_file()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| { + let lowered = name.to_lowercase(); + prefixes.iter().any(|prefix| lowered.starts_with(prefix)) + }) + .map(|name| format!("{directory}/{name}")) + .collect::>(); + found.sort(); + found +} + +fn join_relative(root: &Path, relative: &str) -> PathBuf { + relative + .split('/') + .filter(|part| !part.is_empty()) + .fold(root.to_path_buf(), |path, part| path.join(part)) +} + +fn read_small_up_to(path: &Path, limit: u64) -> Option { + let metadata = std::fs::metadata(path).ok()?; + if metadata.len() > limit { + return None; + } + std::fs::read_to_string(path).ok() +} + +fn read_small(path: &Path) -> Option { + let metadata = std::fs::metadata(path).ok()?; + if metadata.len() > MAX_SOURCE_BYTES { + return None; + } + std::fs::read_to_string(path).ok() +} + +/// The quoted value after `marker` in `source`, e.g. the URL in +/// `metadataBase: new URL("...")`. +fn quoted_after(source: &str, marker: &str) -> Option { + let rest = &source[source.find(marker)? + marker.len()..]; + let quote_at = rest.find(['"', '\'', '`'])?; + let quote = rest[quote_at..].chars().next()?; + let value = &rest[quote_at + 1..]; + let end = value.find(quote)?; + Some(value[..end].to_owned()).filter(|value| !value.is_empty()) +} + +/// What the project already says about the site as a whole: its icon, share +/// image, name, URL, analytics, language and not-found page. +fn read_site_facts(root: &Path, evidence: &mut ProjectEvidence) { + let app = evidence.app_dir.clone(); + let app_root = evidence.app_root().to_owned(); + let public = if app_root == "." { + "public".to_owned() + } else { + format!("{app_root}/public") + }; + evidence.icon_files = [ + files_named(root, &app, &["icon.", "favicon.", "apple-icon."]), + files_named(root, &public, &["favicon", "icon.", "apple-touch-icon"]), + ] + .concat(); + evidence.og_files = [ + files_named(root, &app, &["opengraph-image.", "twitter-image."]), + files_named(root, &public, &["og", "share", "opengraph"]), + ] + .concat(); + evidence.not_found = ["tsx", "jsx", "js"] + .iter() + .map(|ext| format!("{app}/not-found.{ext}")) + .find(|path| join_relative(root, path).is_file()); + for ext in ["tsx", "jsx", "js"] { + let layout = format!("{app}/layout.{ext}"); + let Some(source) = read_small(&join_relative(root, &layout)) else { + continue; + }; + evidence.lang = quoted_after(&source, "(&text).ok()); + for (name, slot) in [ + ("next", &mut evidence.next_version), + ("react", &mut evidence.react_version), + ("vinext", &mut evidence.vinext_version), + ] { + if let Some(version) = bun_lock.as_deref().and_then(|lock| bun_locked(lock, name)) { + *slot = Some((version, "bun.lock".into())); + } else if let Some(version) = npm_lock + .as_ref() + .and_then(|lock| lock["packages"][format!("node_modules/{name}")]["version"].as_str()) + { + *slot = Some((version.to_owned(), "package-lock.json".into())); + } + } + let env_dirs = if app_root == "." { + vec![".".to_owned()] + } else { + vec![app_root.clone(), ".".to_owned()] + }; + for file in env_dirs.iter().flat_map(|dir| { + [".env", ".env.local", ".env.production"] + .map(|name| join_relative(&PathBuf::from(dir), name)) + }) { + let Some(source) = read_small(&root.join(&file)) else { + continue; + }; + let file = file.display().to_string().replace('\\', "/"); + for line in source.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim(); + let value = value.trim().trim_matches(['"', '\'']); + if evidence.site_url.is_none() + && matches!( + key, + "NEXT_PUBLIC_SITE_URL" | "SITE_URL" | "NEXT_PUBLIC_BASE_URL" + ) + && (value.starts_with("http://") || value.starts_with("https://")) + { + evidence.site_url = Some((value.to_owned(), format!("{key} in {file}"))); + } + if evidence.site_name.is_none() + && matches!(key, "NEXT_PUBLIC_SITE_NAME" | "SITE_NAME") + && !value.is_empty() + { + evidence.site_name = Some((value.to_owned(), format!("{key} in {file}"))); + } + if ANALYTICS_ENV.iter().any(|prefix| key.contains(prefix)) { + evidence.analytics.push(format!("{key} in {file}")); + } + } + } + // The app's own package.json first: in a workspace the root one holds + // tooling, the app's holds its framework and React. + for manifest in env_dirs + .iter() + .map(|dir| join_relative(root, dir).join("package.json")) + { + let Some(package) = + read_small(&manifest).and_then(|text| serde_json::from_str::(&text).ok()) + else { + continue; + }; + let shown = manifest + .strip_prefix(root) + .unwrap_or(&manifest) + .display() + .to_string() + .replace('\\', "/"); + if evidence.package_name.is_none() { + evidence.package_name = package["name"].as_str().map(str::to_owned); + } + for section in ["dependencies", "devDependencies"] { + for (name, slot) in [ + ("next", &mut evidence.next_version), + ("react", &mut evidence.react_version), + ("vinext", &mut evidence.vinext_version), + ] { + if slot.is_none() + && let Some(version) = package[section][name].as_str() + { + *slot = Some((version.to_owned(), format!("{shown} {section}"))); + } + } + for peer in ["react-dom", "react-server-dom-webpack"] { + if let Some(version) = package[section][peer].as_str() { + evidence + .react_peers + .push((version.to_owned(), format!("{peer} in {shown} {section}"))); + } + } + for name in MOTION_LIBRARIES.iter() { + if package[section].get(*name).is_some() + && !evidence.motion_libraries.iter().any(|known| known == name) + { + evidence.motion_libraries.push((*name).to_owned()); + } + } + } + for section in ["dependencies", "devDependencies"] { + for name in ANALYTICS_PACKAGES.iter() { + if package[section].get(*name).is_some() { + evidence.analytics.push(format!("{name} in {shown}")); + } + } + } + } +} + +/// The version bun.lock resolved a package to: its entry reads +/// `"react": ["react@19.3.0", ...]`. +fn bun_locked(lock: &str, name: &str) -> Option { + let marker = format!("\"{name}\": [\"{name}@"); + let start = lock.find(&marker)? + marker.len(); + let end = lock[start..].find('"')? + start; + Some(lock[start..end].to_owned()) + .filter(|version| version.starts_with(|c: char| c.is_ascii_digit())) +} + +/// Libraries that already animate page changes. With one present, adding +/// view transitions is a question, not a default. +static MOTION_LIBRARIES: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-motion-libraries.json")) + .expect("vendored keyword JSON") +}); + +/// The interaction recipe per element role: what each one gets on hover, +/// press and focus. The devup-implementation-plan skill carries the same +/// table with the reasoning; status counts elements against it per role. +const INTERACTION_RECIPES: &[(&str, &str)] = &[ + ( + "primary button", + include_str!("../text/screen_plan-INTERACTION_RECIPES-703799bb.md"), + ), + ( + "secondary / ghost / outline button", + "hover a subtle tinted background; active a slightly stronger tint; the same focus ring and disabled state", + ), + ( + "text link / \"더 보기 >\" link", + "hover a colour change or underline; a trailing chevron moves 2px; focus ring", + ), + ( + "card that links", + "hover a 2-4px lift and a shadow token; the image inside zooms to 1.02-1.04 with overflow hidden; active drops the lift; focus ring on the card", + ), + ( + "nav item / tab", + "hover colour; the current route marked with aria-current=\"page\" and the Figma active style (e.g. the underline the header draws); focus ring", + ), + ( + "icon button", + "hover a background circle or tint; active press; aria-label required", + ), + ( + "input / select / textarea", + "hover border; focus border and ring; invalid state with its message; disabled", + ), + ( + "accordion / disclosure / dropdown", + "aria-expanded; the chevron rotates 180deg", + ), + ( + "mobile menu / drawer", + "an open and close transition, when the design has one", + ), +]; + +const RESTRAINT_RULES: &str = include_str!("../text/screen_plan-RESTRAINT_RULES-cef67bed.md"); + +/// `major.minor.patch` from a version or range as package.json writes it: +/// `^16.1.0`, `~19.3.0-canary-...`, `16`. `None` for `latest`, +/// `workspace:*`, `catalog:` and the like, which say nothing about the API. +fn version_triple(value: &str) -> Option<(u64, u64, u64)> { + let start = value.find(|c: char| c.is_ascii_digit())?; + if !value[..start] + .chars() + .all(|c| matches!(c, '^' | '~' | '>' | '=' | '<' | 'v' | ' ')) + { + return None; + } + let mut parts = value[start..] + .split(|c: char| !c.is_ascii_digit()) + .take(3) + .map(|part| part.parse::().unwrap_or(0)); + Some(( + parts.next()?, + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + )) +} + +/// The dependency step every job starts with, word for word as the skills +/// give it. It updates every manifest in the repository - every bun +/// workspace and every Cargo.toml - to the latest versions: `-d` finds them +/// all, `-u` applies the updates as one recoverable batch, and `--rm` removes +/// the lockfiles and installed directories next to each (Cargo.lock and +/// target/ included) so the next install resolves everything fresh. +pub(super) const UPDATE_STEP: &str = "bunx @dependency-check-updates/cli -d -u --rm"; + +/// The documents the view-transition decision rests on. Read when it was +/// written; the decision names them so a reader can check them again. +/// +/// - https://nextjs.org/docs/app/guides/view-transitions - App Router, no +/// config, `import { ViewTransition } from 'react'`, React 19.3. +/// - https://react.dev/reference/react/ViewTransition - the component. +/// - https://vinext.dev/compatibility - Next.js's own +/// app-dir/view-transitions e2e suite passes under vinext (App Router). +/// - : +/// vinext's own example imports `ViewTransition` from 'react' and sets +/// `experimental.viewTransition: true` in its next config. +/// - : +/// `vinext check` labels that import "partial (passthrough fallback)" +/// whatever the React version; the runtime audit's view-transition check is +/// what settles whether it animates. +/// - https://github.com/cloudflare/vinext#readme - peer dependency react ^19.3.1. +static VIEW_TRANSITION_DOCS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-view-transition-docs.json")) + .expect("vendored keyword JSON") + }); + +/// The view-transition default: always on. DevFive runs the latest versions +/// (the dependency step comes first), and the latest Next App Router and +/// React support `` on vinext and on the `next` CLI alike, so +/// no version is checked. Whether it really animates is not taken on trust +/// either way: the runtime audit's `view-transition` check follows a link and +/// fails when no transition ran. The only "skipped" results are a project +/// that opts out (`viewTransitions: false`) and one that forbids the update +/// (`updateDependencies: false`) while its installed React predates 19.3. +fn view_transition_default(evidence: &ProjectEvidence) -> Value { + let skipped = |why: String| { + json!({"item": "view-transitions", "mechanism": "skipped", + "decision": "No view transitions.", "evidence": why, "docs": *VIEW_TRANSITION_DOCS}) + }; + if evidence.config.view_transitions == Some(false) { + return skipped(format!("viewTransitions: false in {CONFIG_FILE}")); + } + if evidence.config.update_dependencies == Some(false) { + let react = std::iter::once(&evidence.react_version) + .flatten() + .chain(evidence.react_peers.iter()) + .filter_map(|(text, _)| version_triple(text)) + .max(); + if react.is_none_or(|react| react < (19, 3, 0)) { + return skipped(format!( + "updateDependencies: false in {CONFIG_FILE} keeps React below 19.3, which has no ViewTransition" + )); + } + } + let runner = if evidence.framework() == "next" + && evidence.config.framework.as_deref() != Some("vinext") + { + "the next CLI" + } else { + "vinext" + }; + json!({"item": "view-transitions", "mechanism": "react-view-transition", + "decision": include_str!("../text/screen_plan-view_transition_default-81ca7ab7.md"), + "evidence": format!("runs on {runner} at the latest versions"), "app": evidence.app_root(), "docs": *VIEW_TRANSITION_DOCS}) +} + +/// No app, no routes and no framework: a project still to be created. +fn is_new_project(evidence: &ProjectEvidence) -> bool { + evidence.apps.is_empty() + && evidence.routes.is_empty() + && evidence.next_version.is_none() + && evidence.vinext_version.is_none() +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/screen_plan/state.rs b/crates/devup-mcp/src/server/screen_plan/state.rs new file mode 100644 index 00000000..1151fc0d --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/state.rs @@ -0,0 +1,871 @@ + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use devup_mcp_figma::{DevupError, ErrorCode, FigmaTarget}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; + +use super::artifacts::ArtifactStore; +use super::output::{OutputPolicy, OutputTransaction}; + +pub(super) const PLAN_KIND: &str = "devup-screen-plan"; +const SCHEMA_VERSION: u64 = 1; +/// Where the plan lives, relative to the project root. The export gate reads +/// it from the same place, so the two cannot disagree about which plan holds. +pub(super) const PLAN_FILE: &str = ".devup/screen-plan.json"; +const PLAN_MARKDOWN: &str = ".devup/screen-plan.md"; +pub(super) const ASK_INSTRUCTION: &str = + include_str!("../text/screen_plan-ASK_INSTRUCTION-0285b268.md"); +/// What implementation does instead of asking. Someone may have walked away +/// from the keyboard three hours ago; a question then stops all the work. +pub(super) const IMPLEMENT_INSTRUCTION: &str = + include_str!("../text/screen_plan-IMPLEMENT_INSTRUCTION-0f8204de.md"); +/// The optional project file whose entries count as evidence. +pub(super) const CONFIG_FILE: &str = ".devup/screen-plan.config.json"; +/// The question tools this is shaped for cut headers short. OpenCode's has no +/// hard limit; 30 characters stays readable in all of them. +pub(super) const HEADER_LIMIT: usize = 30; +/// Claude Code's AskUserQuestion takes two to four options and adds its own +/// "Other" for a custom answer. Four is the most every runtime accepts. +const MAX_OPTIONS: usize = 4; +/// AskUserQuestion also takes at most four questions per call. +pub(super) const MAX_QUESTIONS_PER_ROUND: usize = 4; +const RECOMMENDED: &str = " (Recommended)"; +const MAX_SCREENS: usize = 100; +const MAX_PLAN_BYTES: usize = 2 * 1024 * 1024; +/// The widths `devup_runtime_audit` plans by default. Without a report they +/// are what `status` lists as not-run, because the failures this tool exists +/// to surface were at widths nobody drew. +const DEFAULT_WIDTHS: [u32; 4] = [1440, 1100, 858, 390]; +const MAX_SCANNED_SOURCES: usize = 400; +const MAX_SOURCE_BYTES: u64 = 512 * 1024; +const SKIP: &str = "Skip this screen"; +const NOT_A_PAGE: &str = "Not a page (part of another screen)"; + +static MODAL_WORDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-modal-words.json")) + .expect("vendored keyword JSON") +}); +static DEVICE_WORDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-device-words.json")) + .expect("vendored keyword JSON") +}); +/// Device words that name a whole section rather than a frame suffix. +static SECTION_DEVICE_WORDS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-section-device-words.json")) + .expect("vendored keyword JSON") + }); +static ROOT_WORDS: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + serde_json::from_str(include_str!("../text/screen-plan-root-words.json")) + .expect("vendored keyword JSON") +}); + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ScreenPlanInput { + /// draft: map screens to routes and return every open question. + /// confirm: apply all answers at once. status: URL x width report, the + /// audit findings, the work queue and the final report. assume: after + /// confirm, record a decision implementation made without asking, or + /// take a pending decision. auditPlan: write the runtime-audit plan for + /// every confirmed URL. defer: record a decision the work needs as + /// pending, with its recommended option, and keep working. + #[schemars(extend("enum" = ["draft", "confirm", "status", "assume", "auditPlan", "defer"]))] + pub action: String, + /// The Next App Router project. The plan is written to + /// /.devup/screen-plan.json and .md, under an allowed root. + pub project_root: Option, + /// draft: Figma Section or frame link. Omitted while one Devup Bridge + /// plugin is attached, the Figma selection is the anchor. + #[serde(default)] + pub url: Option, + /// draft: screens already listed by devup_figma_explore or search. When + /// given, Figma is not read again. + #[serde(default)] + pub screens: Vec, + /// draft: explicit Figma node id -> route bindings. The only way to name a + /// route that no page or nav link already shows. + #[serde(default)] + pub navigation_targets: BTreeMap, + /// draft: free text recorded in the plan, never interpreted. + #[serde(default)] + pub notes: Option, + /// confirm/status: the planId draft returned. + #[serde(default)] + pub plan_id: Option, + /// confirm: every question id -> the chosen option label (or labels when + /// multiple), or custom text. + #[serde(default)] + pub answers: BTreeMap, + /// status: the runtime audit plan JSON, together with auditReportPath. + #[serde(default)] + pub audit_plan_path: Option, + /// status: the report.json the runtime audit executor wrote. + #[serde(default)] + pub audit_report_path: Option, + /// draft: re-read Figma instead of reusing a cached exploration. + #[serde(default)] + pub refresh: bool, + /// assume: what was decided during implementation without asking. + #[serde(default)] + pub assumptions: Vec, + /// assume: the outcome of the dependency step. Each pin is also recorded + /// as an assumption. + #[serde(default)] + pub dependency_update: Option, + /// draft: how many questions one question-tool call takes; default 4 + /// (Claude Code AskUserQuestion). A tool with no cap asks the flat + /// `questions` list in one call instead. + #[serde(default)] + #[schemars(range(min = 1, max = 50))] + pub max_questions_per_round: Option, + /// draft: Figma nodes that may be the favicon or share image (a logo, a + /// frame named og or share), beside the screens. + #[serde(default)] + pub assets: Vec, + /// draft: the agent's own questions - anything it would otherwise guess + /// during the job (form target, contact details, external links, legal + /// pages, data source, language) - asked in the same up-front rounds. + #[serde(default)] + pub extra_questions: Vec, + /// auditPlan: where the app is served; default http://localhost:3000. + #[serde(default)] + pub base_url: Option, + /// auditPlan: app name -> base URL, when the plan spans several apps. + #[serde(default)] + pub base_urls: BTreeMap, + /// auditPlan: every devup_figma_export artifactId of this plan's frames + /// (the TSX export, the referencePng export, the native renders). They + /// are searched for each frame's geometry and references. + #[serde(default)] + pub artifacts: Vec, + /// auditPlan: only these URLs; default every confirmed URL. + #[serde(default)] + pub urls: Vec, + /// status: the devup_visual_compare result for each visualComparisons + /// call the last status returned. + #[serde(default)] + pub visual_results: Vec, + /// status, with auditPlanPath and no report: the runner could not be + /// installed or started, and why. That URL's audit is inconclusive; the + /// work goes on. + #[serde(default)] + pub tooling_unavailable: Option, + /// defer: the decision the work needs. + #[serde(default)] + pub decision: Option, +} + +/// A decision the work hit after confirm. It never stops the work: it is +/// recorded with the option the agent recommends, the rest goes on, and it +/// is taken with assume at the end - except a destructive one, which is +/// left unbuilt. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PendingDecisionInput { + /// A short id, e.g. contact-form-target. + pub id: String, + /// What it is about: a URL, a frame id, a file. + pub about: String, + pub question: String, + #[serde(default)] + pub options: Vec, + /// The option to take when the work is otherwise done. + pub recommended: String, + /// It deletes or overwrites something, or reaches outside the project. + #[serde(default)] + pub destructive: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ExtraQuestionInput { + /// Becomes `extra:`. + pub id: String, + /// 30 characters or fewer. + pub header: String, + pub question: String, + /// Two to four, the recommended one first. + pub options: Vec, + #[serde(default)] + pub multiple: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct ExtraOptionInput { + pub label: String, + pub description: String, +} + +/// The agent's own questions in the plan's shape, or why one cannot be asked +/// as given. The recommended option is the first, as everywhere else. +pub(super) fn extra_questions(extra: &[ExtraQuestionInput]) -> Result, DevupError> { + let mut seen = BTreeSet::new(); + extra + .iter() + .map(|asked| { + let id = format!("extra:{}", asked.id.trim().trim_start_matches("extra:")); + if id == "extra:" || !seen.insert(id.clone()) { + return Err(invalid(format!( + "extraQuestions ids must be non-empty and unique; {:?} is not.", + asked.id + ))); + } + if asked.header.trim().is_empty() || asked.header.chars().count() > HEADER_LIMIT { + return Err(invalid(format!( + "{id}: header must be 1 to {HEADER_LIMIT} characters." + ))); + } + if !(2..=MAX_OPTIONS).contains(&asked.options.len()) { + return Err(invalid(format!("{id}: give 2 to {MAX_OPTIONS} options."))); + } + let choices = asked + .options + .iter() + .map(|option| { + let label = strip_recommended(&option.label).to_owned(); + choice( + label.clone(), + option.description.clone(), + json!({"extra": label}), + ) + }) + .collect::>(); + let mut built = question( + id, + asked.header.clone(), + asked.question.clone(), + choices, + "extra", + ); + built["multiple"] = json!(asked.multiple); + Ok(built) + }) + .collect() +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct AssumptionInput { + /// What it is about, e.g. a frame id or a URL. + pub about: String, + /// The option taken: the plan's recommended one or the most conservative. + pub decision: String, + #[serde(default)] + pub reason: Option, +} + +/// The outcome of the dependency step, recorded once it ran. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct DependencyUpdateInput { + /// How many packages the update bumped. + pub bumped: usize, + /// Packages held back because no code fix exists, with the reason. + #[serde(default)] + pub pins: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PinInput { + pub package: String, + pub version: String, + pub reason: String, +} + +/// `.devup/screen-plan.config.json`: how this project's designers name +/// things. Each entry present counts as evidence and replaces the question +/// it would otherwise take. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct PlanConfig { + /// Replaces the built-in modal words (modal, popup, dialog, ...). + #[serde(default)] + pub modal_keywords: Option>, + /// Overrides app-selection keyword categories; omitted categories retain defaults. + #[serde(default)] + pub app_selection_keywords: BTreeMap>, + /// Layer name -> route. + #[serde(default)] + pub route_aliases: BTreeMap, + /// responsive | separate | ask (the default). Applies to several widths + /// in one section; frames in different sections are always asked. + #[serde(default)] + pub width_grouping: Option, + /// Replaces the built-in words that mark pages, sections and frames as + /// not for implementation (trash, archive, draft, copy, ...). + #[serde(default)] + pub exclude_keywords: Option>, + /// Names containing one of these are never excluded by keyword. + #[serde(default)] + pub include_keywords: Option>, + /// Pre-answers for the site questions: the favicon and share image (a + /// path, or figma:), the site name, its production URL, and + /// motion beyond hover (none | reveal). + #[serde(default)] + pub favicon: Option, + #[serde(default)] + pub og_image: Option, + #[serde(default)] + pub site_name: Option, + #[serde(default)] + pub site_url: Option, + #[serde(default)] + pub motion: Option, + /// false for a project that forbids the dependency update step. + #[serde(default)] + pub update_dependencies: Option, + /// vinext | next: pre-answers the migrate-to-vinext question. + #[serde(default)] + pub framework: Option, + /// false opts the project out of view transitions. + #[serde(default)] + pub view_transitions: Option, + /// false stops exports into this project from writing + /// data-figma-node-id on the page, section and asset elements. + #[serde(default)] + pub figma_node_ids: Option, + /// How many fix-and-rerun rounds one audit finding gets before it is + /// recorded as unresolved with its evidence; default 3. + #[serde(default)] + pub max_fix_attempts: Option, + /// Changed-pixel ratios for the visual comparisons: page, section, + /// asset, and nodes keyed by Figma node id for one asset that needs its + /// own. + #[serde(default)] + pub visual_thresholds: Option, +} + +/// Whether exports into this project mark their elements with +/// data-figma-node-id: on unless the project config says false. An +/// unreadable config leaves it on; draft reports the config error. +pub(super) fn node_ids_enabled(project_root: &str) -> bool { + !matches!( + read_config(project_root), + Ok(Some(PlanConfig { + figma_node_ids: Some(false), + .. + })) + ) +} + +fn read_config(project_root: &str) -> Result, DevupError> { + let path = Path::new(project_root).join(CONFIG_FILE); + let Ok(bytes) = std::fs::read(&path) else { + return Ok(None); + }; + let mut config: PlanConfig = serde_json::from_slice(&bytes).map_err(|error| { + invalid(format!( + "{} is not a valid screen plan config: {error}", + path.display() + )) + })?; + if let Some(mode) = config.width_grouping.as_deref() + && !matches!(mode, "responsive" | "separate" | "ask") + { + return Err(invalid(format!( + "{}: widthGrouping must be responsive, separate or ask, not {mode:?}.", + path.display() + ))); + } + if let Some(framework) = config.framework.as_deref() + && !matches!(framework, "vinext" | "next") + { + return Err(invalid(format!( + "{}: framework must be vinext or next, not {framework:?}.", + path.display() + ))); + } + for (alias, route) in &mut config.route_aliases { + *route = normalize_route(route) + .filter(|route| valid_route(route)) + .ok_or_else(|| { + invalid(format!( + "{}: routeAliases[{alias:?}] = {route:?} is not an app route.", + path.display() + )) + })?; + } + Ok(Some(config)) +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum AnswerInput { + One(String), + Many(Vec), +} + +impl AnswerInput { + pub(super) fn values(&self) -> Vec<&str> { + match self { + Self::One(value) => vec![value.as_str()], + Self::Many(values) => values.iter().map(String::as_str).collect(), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PlanScreenInput { + pub node_id: String, + pub name: String, + pub width: f64, + #[serde(default)] + pub height: Option, + /// The Figma Section (or other parent) the frame sits in, by id or name. + /// devup_figma_explore fills it from the frame's parent. + #[serde(default)] + pub section: Option, + /// The section's layer name, when `section` is an id. + #[serde(default)] + pub section_name: Option, + /// Layer names from the page down to this frame, as devup_figma_explore + /// reports them. Used to keep the plan inside the linked node and out of + /// trash, archive and draft areas. + #[serde(default)] + pub breadcrumb: Vec, + /// Ancestor node ids, nearest first, when known. + #[serde(default)] + pub ancestors: Vec, + /// false when the frame is hidden in Figma. + #[serde(default)] + pub visible: Option, + #[serde(default)] + pub opacity: Option, + /// Shared part names the caller saw in this screen, e.g. Header, Footer. + #[serde(default)] + pub parts: Vec, + /// Links the caller saw in this screen, e.g. prototype destinations. + #[serde(default)] + pub links: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = json!({"not": {}})))] +pub struct PlanLinkInput { + pub label: String, + #[serde(default)] + pub destination_node_id: Option, + #[serde(default)] + pub url: Option, +} + +pub(super) fn invalid(message: impl Into) -> DevupError { + DevupError::new(ErrorCode::DevupInvalidInput, message, false) +} + +pub(super) fn text(value: &Value) -> &str { + value.as_str().unwrap_or_default() +} + +pub(super) fn array(value: &Value) -> &[Value] { + value.as_array().map(Vec::as_slice).unwrap_or_default() +} + +pub(super) fn sha256(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub(super) fn now() -> String { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default(); + super::format_epoch_rfc3339(seconds) +} + +/// What the project already says about its routes, gathered once per call. +#[derive(Debug, Clone, Default)] +pub(super) struct ProjectEvidence { + /// (route pattern, page file) from the App Router inventory. + pub routes: Vec<(String, String)>, + /// (link label, href, file:line) for every static link in scanned sources. + pub nav_links: Vec<(String, String, String)>, + /// Components the app's layouts import, so every page already shares them. + pub layout_parts: Vec, + /// `src/app` or `app`, whichever the existing pages use. + pub app_dir: String, + pub inventory_note: Option, + /// Figma node id -> (route, file) for every `data-figma-node-id` already + /// in one route's sources: that page was built from that frame. + pub figma_nodes: BTreeMap, + pub config: PlanConfig, + /// The config file, when one was read. + pub config_file: Option, + /// The project root the relative paths here are under. + pub root: PathBuf, + /// Route pages and the components they render, relative to `root`. + pub sources: Vec, + pub icon_files: Vec, + pub og_files: Vec, + /// (value, where it was found). + pub site_name: Option<(String, String)>, + pub site_url: Option<(String, String)>, + pub package_name: Option, + pub analytics: Vec, + pub not_found: Option, + pub lang: Option<(String, String)>, + /// (version as declared or resolved, where it was read). + pub next_version: Option<(String, String)>, + pub react_version: Option<(String, String)>, + /// Page-transition and animation libraries the project depends on. + pub motion_libraries: Vec, + pub vinext_version: Option<(String, String)>, + /// react-dom and react-server-dom-webpack, which a vinext app pins to + /// the React it runs while `react` itself may say only `^19`. + pub react_peers: Vec<(String, String)>, + /// Every app the project holds, and what runs each. + pub apps: Vec, + /// The app this plan builds, relative to the root (`.` for the root). + /// `None` with several apps: which one is asked. + pub target_app: Option, +} + +impl ProjectEvidence { + /// The target app's directory, or `.`. + fn app_root(&self) -> &str { + self.target_app.as_ref().map_or(".", |app| app.dir.as_str()) + } + + /// The target app's framework, or `unknown`. + fn framework(&self) -> &'static str { + self.target_app + .as_ref() + .map_or("unknown", |app| app.framework) + } +} + +/// Picks the app a plan builds and points `app_dir` at its App Router. +fn choose_app(root: &Path, evidence: &mut ProjectEvidence) { + evidence.apps = super::framework::detect(root); + evidence.target_app = match evidence.apps.as_slice() { + [only] => Some(only.clone()), + apps => apps.iter().find(|app| app.dir == ".").cloned(), + }; + let first = evidence + .target_app + .clone() + .or_else(|| evidence.apps.first().cloned()); + if let Some(app) = first { + evidence.app_dir = app.path(app.app_router.as_deref().unwrap_or("src/app")); + } +} + +/// The `data-figma-node-id="..."` values in one source file. +fn figma_node_ids(source: &str) -> Vec { + let mut ids = Vec::new(); + let mut rest = source; + while let Some(start) = rest.find("data-figma-node-id=") { + rest = &rest[start + "data-figma-node-id=".len()..]; + let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else { + continue; + }; + rest = &rest[1..]; + if let Some(end) = rest.find(quote) { + ids.push(rest[..end].to_owned()); + rest = &rest[end..]; + } + } + ids +} + +/// Reads routes, layout components and static links through the same +/// inventory `devup_project_context scope:"ui"` returns, then the navigation +/// parser `devup_ui_validate` uses on each scanned file. +pub(super) async fn project_evidence(project_root: &str) -> Result { + let inventory = super::project_context::run("ui", Some(project_root), None).await?; + let config = read_config(project_root)?; + let mut evidence = ProjectEvidence { + app_dir: "src/app".into(), + config_file: config.is_some().then(|| CONFIG_FILE.to_owned()), + config: config.unwrap_or_default(), + ..ProjectEvidence::default() + }; + evidence.root = PathBuf::from(project_root); + if inventory["found"] != true { + choose_app(&PathBuf::from(project_root), &mut evidence); + read_site_facts(&PathBuf::from(project_root), &mut evidence); + evidence.inventory_note = Some( + "No App Router source was found; every route is unverified until pages exist or navigationTargets name them." + .into(), + ); + return Ok(evidence); + } + if inventory["truncation"]["truncated"] == true || inventory["unparsedFiles"] != 0 { + evidence.inventory_note = Some( + "The route inventory is truncated or has unparsed files; a route missing here may still exist.".into(), + ); + } + let root = PathBuf::from(text(&inventory["projectRoot"])); + evidence.root = root.clone(); + let mut sources = BTreeSet::new(); + let mut owner = BTreeMap::>::new(); + for route in array(&inventory["routes"]) { + let path = text(&route["route"]).to_owned(); + evidence + .routes + .push((path.clone(), text(&route["page"]).to_owned())); + sources.insert(text(&route["page"]).to_owned()); + owner + .entry(text(&route["page"]).to_owned()) + .or_default() + .insert(path.clone()); + for component in array(&route["components"]) { + sources.insert(text(component).to_owned()); + owner + .entry(text(component).to_owned()) + .or_default() + .insert(path.clone()); + } + } + if evidence + .routes + .iter() + .any(|(_, page)| page.starts_with("app/")) + && !evidence + .routes + .iter() + .any(|(_, page)| page.starts_with("src/app/")) + { + evidence.app_dir = "app".into(); + } + choose_app(&root, &mut evidence); + for component in array(&inventory["components"]) { + sources.insert(text(&component["path"]).to_owned()); + let in_layout = array(&component["importedBy"]).iter().any(|site| { + let path = text(&site["path"]); + path.ends_with("layout.tsx") || path.ends_with("layout.jsx") + }); + if in_layout { + for name in array(&component["exports"]) { + let name = text(name); + if !name.is_empty() + && name != "default" + && !evidence.layout_parts.iter().any(|part| part == name) + { + evidence.layout_parts.push(name.to_owned()); + } + } + } + } + for relative in sources + .iter() + .filter(|path| !path.is_empty()) + .take(MAX_SCANNED_SOURCES) + { + let path = root.join(relative); + if std::fs::metadata(&path).map_or(true, |meta| meta.len() > MAX_SOURCE_BYTES) { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + // A frame id in exactly one route's files says that route was built + // from it; in several, it is a shared part and says nothing. + if let Some(routes) = owner.get(relative).filter(|routes| routes.len() == 1) { + for id in figma_node_ids(&source) { + evidence.figma_nodes.entry(id).or_insert_with(|| { + ( + routes.iter().next().cloned().unwrap_or_default(), + relative.clone(), + ) + }); + } + } + for element in array(&super::navigation::source_evidence(&source)["elements"]) { + if element["isLink"] == true + && let Some(href) = element["href"].as_str() + && href.starts_with('/') + && !text(&element["name"]).is_empty() + { + evidence.nav_links.push(( + text(&element["name"]).to_owned(), + href.to_owned(), + format!("{relative}:{}", element["line"]), + )); + } + } + } + evidence.sources = sources + .iter() + .filter(|path| !path.is_empty()) + .take(MAX_SCANNED_SOURCES) + .cloned() + .collect(); + read_site_facts(&root, &mut evidence); + Ok(evidence) +} + +/// Lowercased words of a layer name with widths and device words removed, so +/// `About - 1440`, `[MO] About_390` and `about` are one screen. +fn base_words(name: &str) -> Vec { + let lowered = name.to_lowercase(); + let words = lowered + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_owned) + .collect::>(); + let kept = words + .iter() + .filter(|word| { + let digits = word.trim_end_matches("px"); + let width = !digits.is_empty() + && digits.chars().all(|c| c.is_ascii_digit()) + && digits + .parse::() + .is_ok_and(|n| (240..=4000).contains(&n)); + !width && !DEVICE_WORDS.contains(&word.as_str()) + }) + .cloned() + .collect::>(); + if kept.is_empty() { words } else { kept } +} + +/// The URL slug a name would have, from its ASCII words only. Empty for a +/// name with none, which is why a slug is offered as an option and never +/// proposed on its own. +fn slug(words: &[String]) -> String { + words + .iter() + .filter(|word| word.chars().all(|c| c.is_ascii_alphanumeric())) + .cloned() + .collect::>() + .join("-") +} + +fn compact(value: &str) -> String { + value + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric()) + .collect() +} + +/// The same rule `pageScaffold.route` is held to, so a confirmed route is one +/// the export will accept. +pub(super) fn valid_route(route: &str) -> bool { + let trimmed = route.trim_matches('/'); + route.starts_with('/') + && !route.starts_with("//") + && !route.ends_with("//") + && trimmed.split('/').all(|segment| { + (trimmed.is_empty() || !segment.is_empty()) + && segment != "." + && segment != ".." + && segment + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.[]()@".contains(c)) + }) +} + +fn normalize_route(href: &str) -> Option { + let path = href.split(['?', '#']).next().unwrap_or_default().trim(); + if !path.starts_with('/') || path.starts_with("//") { + return None; + } + let trimmed = path.trim_end_matches('/'); + Some(if trimmed.is_empty() { + "/".to_owned() + } else { + trimmed.to_owned() + }) +} + +fn truncate(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + return value.to_owned(); + } + let mut cut = value.chars().take(limit - 1).collect::(); + cut.push('…'); + cut +} + +pub(super) fn header(prefix: &str, name: &str) -> String { + truncate(&format!("{prefix}: {name}"), HEADER_LIMIT) +} + +/// One option as the question tool shows it, and what choosing it does. +pub(super) struct Choice { + label: String, + description: String, + action: Value, +} + +pub(super) fn choice( + label: impl Into, + description: impl Into, + action: Value, +) -> Choice { + Choice { + label: label.into(), + description: description.into(), + action, + } +} + +/// A question in the shape every runtime's question tool takes: the +/// recommended option first with " (Recommended)" on its label, two to four +/// options, and custom answers allowed. `resolve` stays in the plan file and is +/// stripped from what the caller is asked. +pub(super) fn question( + id: String, + header: String, + prompt: String, + choices: Vec, + kind: &str, +) -> Value { + let mut seen = BTreeSet::new(); + let choices = choices + .into_iter() + .filter(|choice| seen.insert(choice.label.clone())) + .take(MAX_OPTIONS) + .collect::>(); + let options = choices + .iter() + .enumerate() + .map(|(index, choice)| { + json!({ + "label": if index == 0 { format!("{}{RECOMMENDED}", choice.label) } else { choice.label.clone() }, + "description": choice.description, + }) + }) + .collect::>(); + let resolve = choices + .iter() + .map(|choice| json!({"label": choice.label, "action": choice.action})) + .collect::>(); + json!({ + "id": id, + "header": header, + "question": prompt, + "options": options, + "multiple": false, + "custom": true, + "kind": kind, + "resolve": resolve, + }) +} + +/// The questions as they go to the question tool: no resolution table. +pub(super) fn ask_shape(questions: &[Value]) -> Vec { + ask_shape_with(questions, questions) +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/screen_plan/status.rs b/crates/devup-mcp/src/server/screen_plan/status.rs new file mode 100644 index 00000000..1825ec64 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/status.rs @@ -0,0 +1,590 @@ + + +/// The pure half of `status`, so the not-run rule is tested on its own. +pub(super) fn status_report( + plan: &Value, + evidence: &ProjectEvidence, + audit: Option<&Value>, +) -> Value { + let audit_rows = audit + .map(|audit| array(&audit["screenTable"])) + .unwrap_or_default(); + let mut widths = audit + .map(|audit| { + array(&audit["widthTable"]) + .iter() + .filter_map(|row| row["width"].as_u64()) + .collect::>() + }) + .unwrap_or_default(); + let audited = !widths.is_empty(); + let mut rows = Vec::new(); + let mut states = Vec::new(); + for route in array(&plan["routes"]) { + if route["implementation"] == "state" { + states.push(json!({"screen": route["screen"], "name": route["name"], "hostUrl": route["hostUrl"]})); + continue; + } + let Some(url) = route["url"].as_str() else { + continue; + }; + let page = evidence + .routes + .iter() + .find(|(pattern, _)| pattern == url || super::navigation::matches_route(pattern, url)) + .map(|(_, page)| page.clone()); + let mut row_widths = if audited { + widths.clone() + } else { + DEFAULT_WIDTHS + .iter() + .map(|width| u64::from(*width)) + .chain( + array(&route["drawnWidths"]) + .iter() + .filter_map(Value::as_u64), + ) + .collect::>() + }; + row_widths.sort_unstable_by(|a, b| b.cmp(a)); + row_widths.dedup(); + let per_width = row_widths + .iter() + .map(|width| { + let matching = audit_rows + .iter() + .filter(|row| { + row["width"] == *width + && row["routePath"] + .as_str() + .and_then(normalize_route) + .is_some_and(|path| path == url) + }) + .collect::>(); + if matching.is_empty() { + return json!({"width": width, "result": "not-run", + "reasons": [if audited { "no runtime audit item for this URL at this width" } else { "no runtime audit report supplied" }]}); + } + let result = matching + .iter() + .fold("pass", |acc, row| worst(acc, text(&row["status"]))); + json!({ + "width": width, + "result": result, + "columns": matching[0]["columns"], + "reasons": matching.iter().flat_map(|row| array(&row["reasons"]).to_vec()).collect::>(), + "screenshots": matching.iter().filter_map(|row| row["screenshot"].as_str()).collect::>(), + }) + }) + .collect::>(); + rows.push(json!({ + "url": url, + "screen": route["screen"], + "figmaScreen": route["name"], + "nodeIds": route["nodeIds"], + "file": page.clone().map_or_else(|| route["file"].clone(), Value::from), + "implemented": if page.is_some() { "yes" } else { "no" }, + "widths": per_width, + })); + } + if !audited { + widths = DEFAULT_WIDTHS + .iter() + .map(|width| u64::from(*width)) + .collect(); + } + let implemented = rows + .iter() + .filter(|row| row["implemented"] == "yes") + .count(); + let verified = rows + .iter() + .filter(|row| { + array(&row["widths"]) + .iter() + .all(|width| width["result"] == "pass") + }) + .count(); + let mut problems = Vec::new(); + for row in &rows { + let mut by_kind = BTreeMap::<&str, Vec>::new(); + for width in array(&row["widths"]) { + if width["result"] != "fail" { + continue; + } + let reasons = array(&width["reasons"]) + .iter() + .map(text) + .collect::>(); + let kind = if reasons + .iter() + .any(|reason| reason.starts_with("horizontal overflow")) + { + "overflows" + } else if reasons.iter().any(|reason| { + reason.starts_with("page error") || reason.starts_with("screen not reached") + }) { + "fails to load" + } else { + "fails checks" + }; + by_kind + .entry(kind) + .or_default() + .push(width["width"].to_string()); + } + for (kind, at) in by_kind { + problems.push(format!("{} {kind} at {}", text(&row["url"]), at.join(", "))); + } + } + let mut summary = format!( + "{} URLs: {implemented} implemented, {verified} verified at all widths", + rows.len() + ); + for problem in &problems { + summary.push_str(&format!(", {problem}")); + } + if !audited { + summary.push_str("; no runtime audit report, so every width is not-run"); + } + let assumptions = array(&plan["assumptions"]); + if !assumptions.is_empty() { + summary.push_str(&format!( + "; {} assumption{} made during implementation", + assumptions.len(), + if assumptions.len() == 1 { "" } else { "s" } + )); + } + let design_fixes = array(&plan["designFixes"]); + if !design_fixes.is_empty() { + summary.push_str(&format!( + "; {} design fix{} requested, not built", + design_fixes.len(), + if design_fixes.len() == 1 { "" } else { "es" } + )); + } + let excluded = array(&plan["excluded"]) + .iter() + .filter(|row| row["decision"] == "excluded") + .cloned() + .collect::>(); + if !excluded.is_empty() { + summary.push_str(&format!("; {} frames excluded, not built", excluded.len())); + } + let mut table = String::from( + "| URL | Figma screen | file | status | widths verified | screenshots |\n|---|---|---|---|---|---|\n", + ); + for row in &rows { + let row_widths = array(&row["widths"]); + let passed = row_widths + .iter() + .filter(|width| width["result"] == "pass") + .map(|width| width["width"].to_string()) + .collect::>(); + let failing = row_widths + .iter() + .filter(|width| width["result"] == "fail") + .map(|width| width["width"].to_string()) + .collect::>(); + let state = if row["implemented"] != "yes" { + "not implemented".to_owned() + } else if !failing.is_empty() { + format!("fails at {}", failing.join(", ")) + } else if passed.len() == row_widths.len() { + "verified".to_owned() + } else { + "implemented, not verified".to_owned() + }; + let shots = row_widths + .iter() + .flat_map(|width| array(&width["screenshots"]).to_vec()) + .collect::>(); + table.push_str(&format!( + "| {} | {} | {} | {state} | {} of {} | {} |\n", + cell(&row["url"]), + cell(&row["figmaScreen"]), + cell(&row["file"]), + if passed.is_empty() { + "none".to_owned() + } else { + passed.join(", ") + }, + row_widths + .iter() + .map(|width| width["width"].to_string()) + .collect::>() + .join("/"), + cell(&json!(shots)), + )); + } + let completeness = site_completeness(plan, evidence); + let incomplete = completeness + .iter() + .filter(|item| item["state"] == "missing") + .count(); + if incomplete > 0 { + summary.push_str(&format!("; site completeness: {incomplete} missing")); + } + table.push_str("\nSite completeness:\n"); + for item in &completeness { + table.push_str(&format!( + "- {}: {} ({})\n", + text(&item["item"]), + text(&item["state"]), + cell(&item["detail"]) + )); + } + let trailing = trailing_lists(plan); + table.push_str(&trailing); + // Visual failures and fixed findings change a width's verdict after the + // run: a failing comparison fails its width, and the columns say which + // level failed. + let open = super::audit_plan::findings_by_status(plan, "open"); + let unresolved = super::audit_plan::findings_by_status(plan, "unresolved"); + for row in &mut rows { + let url = text(&row["url"]).to_owned(); + for width in row["widths"].as_array_mut().into_iter().flatten() { + let at = width["width"].clone(); + for finding in open.iter().chain(&unresolved).filter(|finding| { + finding["kind"] == "visual" + && finding["routePath"] == url.as_str() + && finding["width"] == at + }) { + width["result"] = json!("fail"); + let column = match text(&finding["class"]) { + "asset-mismatch" => "asset", + "section-visual" => "section", + _ => "page", + }; + width["columns"][column] = json!("fail"); + if let Some(reasons) = width["reasons"].as_array_mut() { + reasons.push(json!(format!( + "{}: {}", + text(&finding["class"]), + text(&finding["reason"]) + ))); + } + } + } + } + let queue = super::audit_plan::work_queue(plan, &rows, &[]); + let final_report = super::audit_plan::final_report(plan, &rows, &trailing); + let converter_bugs = plan["audit"]["converterBugs"] + .as_object() + .map(|bugs| bugs.values().cloned().collect::>()) + .unwrap_or_default(); + json!({ + "findings": open, + "unresolved": unresolved, + "converterBugs": converter_bugs, + "workQueue": queue, + "pendingDecisions": plan["pendingDecisions"], + "finalReport": final_report, + "doneWhen": DONE_RULE, + "status": "confirmed", + "planId": plan["planId"], + "summary": summary, + "rows": rows, + "states": states, + "widths": widths, + "audit": audit.map(|audit| json!({"verdict": audit["verdict"], "planId": audit["planId"], "reason": audit["reason"]})), + "markdownTable": table, + "assumptions": assumptions, + "siteCompleteness": completeness, + "defaults": plan["defaults"], + "designFixes": design_fixes, + "excluded": excluded, + "inventoryNote": evidence.inventory_note, + "rule": "A width without a runtime audit item is not-run, never pass. Implemented means an App Router page exists for the URL, not that it renders.", + }) +} + +/// The lists every final report ends with: design fixes requested, frames +/// excluded, and assumptions made during implementation. Shared by the plan +/// file and `status`, so the two never disagree about what was not built. +fn trailing_lists(plan: &Value) -> String { + let mut out = String::new(); + let defaults = array(&plan["defaults"]); + if !defaults.is_empty() { + out.push_str("\nFilled in by default (no question):\n"); + for default in defaults { + out.push_str(&format!( + "- {}: {}\n", + text(&default["item"]), + text(&default["decision"]) + )); + } + } + let fixes = array(&plan["designFixes"]); + if !fixes.is_empty() { + out.push_str("\nDesign fixes requested (not built):\n"); + for fix in fixes { + out.push_str(&format!("- {}\n", text(&fix["fix"]))); + } + } + let excluded = array(&plan["excluded"]) + .iter() + .filter(|row| row["decision"] == "excluded" || row["decision"].is_null()) + .collect::>(); + if !excluded.is_empty() { + out.push_str("\nExcluded (never built):\n"); + for row in excluded { + out.push_str(&format!( + "- \"{}\" ({}): {}\n", + text(&row["name"]), + text(&row["nodeId"]), + text(&row["reason"]) + )); + } + } + let assumptions = array(&plan["assumptions"]); + if !assumptions.is_empty() { + out.push_str("\nAssumptions made during implementation:\n"); + for assumption in assumptions { + out.push_str(&format!( + "- {}: {}{}\n", + text(&assumption["about"]), + text(&assumption["decision"]), + assumption["reason"] + .as_str() + .map(|reason| format!(" ({reason})")) + .unwrap_or_default() + )); + } + } + out +} + +/// `app-icon` for `devup_ui_validate` with a projectRoot: an App Router app +/// with no icon or favicon. Read from disk here because the devup-ui checks +/// open no file. +pub(super) fn missing_app_icon( + project_root: &str, +) -> Option { + let root = Path::new(project_root); + let app = ["src/app", "app"] + .into_iter() + .find(|app| join_relative(root, app).is_dir())?; + let mut evidence = ProjectEvidence { + app_dir: app.to_owned(), + ..ProjectEvidence::default() + }; + read_site_facts(root, &mut evidence); + if !evidence.icon_files.is_empty() { + return None; + } + let mut context = serde_json::Map::new(); + context.insert("appDir".into(), json!(app)); + Some(devup_mcp_devup_ui::ui_validate::Violation { + rule: "app-icon", + severity: devup_mcp_devup_ui::ui_validate::Severity::Warning, + byte_range: [0, 0], + context, + message: format!("{app} has no icon.* or favicon, and public/ has no favicon either, so the browser tab shows a blank icon."), + suggestion: Some("Add app/icon.svg (or favicon.ico) from a file the user provides or a Figma logo node exported as SVG; devup_screen_plan asks which. Never draw one.".into()), + }) +} + +/// What the export gate decided. +pub(super) enum Gate { + /// Not a page write, or no plan applies. + Open, + /// A confirmed plan covers the frame and names this route for pageScaffold. + Route(String), + /// Not written: the response to return instead. + Blocked(Value), +} + +/// The facts of one export call the gate decides on. +pub(super) struct ExportRequest<'a> { + pub project_root: &'a str, + pub url: Option<&'a str>, + /// Frame ids, or the one node the url or artifact names when there are none. + pub frames: Vec, + pub all_screens: bool, + pub scaffold_write: bool, + pub scaffold_route: Option<&'a str>, + pub output_paths: Vec<&'a str>, +} + +/// Whether a write lands in the App Router tree. Judged below the project +/// root, so a checkout that happens to sit in a folder named `app` is not one. +fn under_app(project_root: &str, path: &str) -> bool { + let path = Path::new(path); + path.strip_prefix(project_root) + .unwrap_or(path) + .components() + .any(|component| component.as_os_str() == "app") +} + +fn plan_required(request: &ExportRequest<'_>, reason: &str, details: Value) -> Gate { + let mut arguments = json!({"action": "draft", "projectRoot": request.project_root}); + if let Some(url) = request.url { + arguments["url"] = json!(url); + } + Gate::Blocked(json!({ + "status": "plan_required", + "written": false, + "planRequired": {"reason": reason, "details": details}, + "nextAction": {"tool": "devup_screen_plan", "arguments": arguments, + "how": "Draft the screen plan, ask every round of its questions back to back before implementing, confirm, then repeat this export. Nothing was collected or written."}, + })) +} + +/// The soft gate in front of `devup_figma_export`. A call that would write a +/// page - a pageScaffold write, an outputPath under app/, or several screens +/// with outputPaths - needs a confirmed plan covering its frames. A single +/// component, or a preview that writes nothing, is never blocked. +pub(super) fn export_gate(request: &ExportRequest<'_>) -> Result { + let several = request.all_screens || request.frames.len() > 1; + let writes = request.scaffold_write || !request.output_paths.is_empty(); + let gated = request.scaffold_write + || request + .output_paths + .iter() + .any(|path| under_app(request.project_root, path)) + || (several && writes); + if !gated && request.scaffold_route.is_none() { + return Ok(Gate::Open); + } + let plan = match read_plan(request.project_root)? { + Some(plan) if plan["status"] == "confirmed" => plan, + Some(plan) if gated => { + return Ok(plan_required( + request, + "The screen plan is a draft; its questions are unanswered.", + json!({"planId": plan["planId"], "askInstruction": ASK_INSTRUCTION}), + )); + } + None if gated => { + return Ok(plan_required( + request, + "This call writes a page and the project has no confirmed screen plan.", + json!({"planFile": PLAN_FILE}), + )); + } + _ => return Ok(Gate::Open), + }; + // Never built, whatever else the call says: not by this export, and not + // by an assumption recorded during implementation. + let refused = request + .frames + .iter() + .filter_map(|frame| { + let excluded = array(&plan["excluded"]) + .iter() + .find(|row| row["nodeId"] == frame.as_str()) + .map(|row| format!("excluded: {}", text(&row["reason"]))); + let fix = array(&plan["designFixes"]) + .iter() + .find(|fix| { + array(&fix["frames"]) + .iter() + .any(|f| f["nodeId"] == frame.as_str()) + }) + .map(|fix| format!("design fix requested: {}", text(&fix["fix"]))); + excluded + .or(fix) + .map(|reason| json!({"frame": frame, "reason": reason})) + }) + .collect::>(); + if !refused.is_empty() && (gated || request.scaffold_route.is_some()) { + return Ok(Gate::Blocked(json!({ + "status": "refused", + "written": false, + "refused": refused, + "how": "Do not build these frames and do not record an assumption to build them. They are listed in devup_screen_plan status; continue with the rest of the plan.", + }))); + } + let routes = array(&plan["routes"]); + let row_of = |frame: &str| { + routes + .iter() + .find(|row| array(&row["nodeIds"]).iter().any(|id| id == frame)) + }; + if request.all_screens { + let section = request + .url + .and_then(|url| FigmaTarget::parse(url).ok()) + .and_then(|target| target.node_id); + let planned = plan["source"]["nodeId"].as_str(); + if section.is_some() && planned.is_some() && section.as_deref() != planned { + return Ok(plan_required( + request, + "allScreens names a Section the confirmed plan was not drafted from.", + json!({"requested": section, "planned": planned}), + )); + } + return Ok(Gate::Open); + } else if request.frames.is_empty() { + if gated { + return Ok(plan_required( + request, + "The frames this call writes are not named, so the plan cannot be checked. Pass frameIds or a frame url.", + json!({}), + )); + } + return Ok(Gate::Open); + } + let uncovered = request + .frames + .iter() + .filter(|frame| { + row_of(frame) + .is_none_or(|row| !matches!(text(&row["implementation"]), "page" | "state")) + }) + .cloned() + .collect::>(); + if !uncovered.is_empty() { + if !gated { + return Ok(Gate::Open); + } + // The plan is confirmed, so this is implementation: nobody is asked + // now. The conservative option is to leave the frame unbuilt. + return Ok(Gate::Blocked(json!({ + "status": "plan_required", + "written": false, + "planRequired": {"reason": "The confirmed screen plan does not place these frames on a page.", + "details": {"uncoveredFrames": uncovered, "planId": plan["planId"]}}, + "nextAction": {"tool": "devup_screen_plan", + "arguments": {"action": "assume", "projectRoot": request.project_root, + "assumptions": [{"about": uncovered.join(", "), "decision": "Not built: outside the confirmed screen plan."}]}, + "how": "Do not ask the user during implementation. Leave these frames unbuilt, record that with action assume, keep going, and list it in the final report."}, + }))); + } + let Some(requested) = request.scaffold_route else { + return Ok(Gate::Open); + }; + let [frame] = request.frames.as_slice() else { + return Ok(Gate::Open); + }; + let Some(row) = row_of(frame) else { + return Ok(Gate::Open); + }; + let Some(planned) = row["url"].as_str() else { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "The confirmed plan builds {frame} as a state of another page, not as its own route; export it without pageScaffold." + ), + false, + json!({"planId": plan["planId"], "hostUrl": row["hostUrl"]}), + )); + }; + if !requested.is_empty() && normalize_route(requested).as_deref() != Some(planned) { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "pageScaffold.route {requested} does not match the confirmed plan, which serves {frame} at {planned}." + ), + false, + json!({"routeMismatch": {"requested": requested, "planned": planned, "frame": frame, "planId": plan["planId"]}, + "how": "Omit pageScaffold.route to take the plan's route, or redraft and confirm the plan if the URL should change."}), + )); + } + Ok(Gate::Route(planned.to_owned())) +} + +#[cfg(test)] +#[path = "../screen_plan_tests.rs"] +mod tests; diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs index 719760b4..0ed79910 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -1,2263 +1,5 @@ -use super::*; -use serde_json::json; -use std::sync::atomic::{AtomicUsize, Ordering}; -struct Fixture(std::path::PathBuf); - -impl Fixture { - fn new() -> Self { - static NEXT: AtomicUsize = AtomicUsize::new(0); - let root = std::env::temp_dir().join(format!( - "screen-plan-{}-{}", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir_all(&root).unwrap(); - let fixture = Self(dunce::canonicalize(&root).unwrap()); - fixture.write("package.json", "{}"); - fixture.write(CONFIG_FILE, SITE_CONFIG); - fixture.write( - "src/app/layout.tsx", - "import { Header } from '../components/Header';\nexport default function RootLayout({ children }) { return
{children}; }", - ); - fixture.write( - "src/components/Header.tsx", - "import Link from 'next/link';\nexport function Header() { return ; }", - ); - fixture.write( - "src/app/page.tsx", - "export default function Page() { return
; }", - ); - fixture.write( - "src/app/company/page.tsx", - "export default function Page() { return
; }", - ); - fixture - } - - fn write(&self, path: &str, text: &str) { - let target = self.0.join(path); - std::fs::create_dir_all(target.parent().unwrap()).unwrap(); - std::fs::write(target, text).unwrap(); - } - - fn root(&self) -> &str { - self.0.to_str().unwrap() - } - - fn policy(&self) -> OutputPolicy { - OutputPolicy::from_roots(vec![self.0.clone()]).unwrap() - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn screen(node_id: &str, name: &str, width: f64) -> PlanScreenInput { - PlanScreenInput { - node_id: node_id.into(), - name: name.into(), - width, - height: Some(900.0), - ..PlanScreenInput::default() - } -} - -fn evidence() -> ProjectEvidence { - ProjectEvidence { - routes: vec![ - ("/".into(), "src/app/page.tsx".into()), - ("/company".into(), "src/app/company/page.tsx".into()), - ("/news/[id]".into(), "src/app/news/[id]/page.tsx".into()), - ], - nav_links: vec![( - "회사소개".into(), - "/company".into(), - "src/components/Header.tsx:2".into(), - )], - layout_parts: vec!["Header".into()], - app_dir: "src/app".into(), - // The site questions are pre-answered here so each test below is - // about its own questions; `site_*` tests start from bare evidence. - config: answered_site(), - ..ProjectEvidence::default() - } -} - -fn answered_site() -> PlanConfig { - PlanConfig { - favicon: Some("app/icon.svg".into()), - og_image: Some("public/og.png".into()), - site_name: Some("Prime".into()), - site_url: Some("https://example.com".into()), - motion: Some("none".into()), - ..PlanConfig::default() - } -} - -/// The site config the fixtures write, matching [`answered_site`]. -const SITE_CONFIG: &str = "{\"favicon\": \"app/icon.svg\", \"ogImage\": \"public/og.png\", \"siteName\": \"Prime\", \"siteUrl\": \"https://example.com\", \"motion\": \"none\"}"; - -fn sectioned(node_id: &str, name: &str, width: f64, section: &str) -> PlanScreenInput { - PlanScreenInput { - section: Some(format!("section-{section}")), - section_name: Some(section.into()), - ..screen(node_id, name, width) - } -} - -fn find<'a>(plan: &'a Value, id: &str) -> &'a Value { - array(&plan["questions"]) - .iter() - .find(|question| question["id"] == id) - .unwrap_or_else(|| panic!("{id} is asked: {}", plan["questions"])) -} - -fn first_label(question: &Value) -> &str { - text(&question["options"][0]["label"]) -} - -fn question_ids(plan: &Value) -> Vec { - array(&plan["questions"]) - .iter() - .map(|question| text(&question["id"]).to_owned()) - .collect() -} - -fn input(fixture: &Fixture, action: &str) -> ScreenPlanInput { - ScreenPlanInput { - action: action.into(), - project_root: Some(fixture.root().into()), - ..ScreenPlanInput::default() - } -} - -/// The route the user never saw being chosen is the failure this tool exists -/// for, so a screen nothing names gets no route and a question instead. -#[test] -fn an_unverified_route_is_null_and_becomes_a_question() { - let plan = draft_plan( - &[screen("1:2", "Pricing", 1440.0)], - &BTreeMap::new(), - &evidence(), - json!({}), - None, - ) - .unwrap(); - let row = &plan["screens"][0]; - assert_eq!(row["proposedRoute"], Value::Null); - assert_eq!(row["routeEvidence"], "UNVERIFIED"); - let question = array(&plan["questions"]) - .iter() - .find(|question| question["id"] == "route:1:2") - .expect("an unverified route is asked"); - let labels = array(&question["options"]) - .iter() - .map(|option| text(&option["label"])) - .collect::>(); - assert_eq!(labels[0], "/pricing (Recommended)"); - assert!(labels.contains(&SKIP)); -} - -/// Each kind of evidence proposes the route on its own, and a screen it -/// proposes is not asked about its route. -#[test] -fn an_evidence_backed_route_is_proposed_without_a_route_question() { - let targets = BTreeMap::from([("2:1".to_owned(), "/contact?from=nav".to_owned())]); - let plan = draft_plan( - &[ - screen("2:1", "Contact", 1440.0), - screen("2:2", "회사소개", 1440.0), - screen("2:3", "Home", 1440.0), - ], - &targets, - &evidence(), - json!({}), - None, - ) - .unwrap(); - let rows = array(&plan["screens"]); - assert_eq!(rows[0]["proposedRoute"], "/contact"); - assert_eq!(rows[0]["routeEvidence"], "navigationTargets[2:1]"); - assert_eq!(rows[1]["proposedRoute"], "/company"); - assert!(text(&rows[1]["routeEvidence"]).starts_with("nav-label")); - assert_eq!(rows[2]["proposedRoute"], "/"); - // Home -> / is a guess from the layer name, so it is shown and put to - // the user in one compact question rather than decided silently. - assert_eq!(rows[2]["routeEvidence"], NAME_MATCH); - assert_eq!(plan["heuristics"][0]["screen"], "2:3"); - let ids = question_ids(&plan); - assert!(ids.contains(&"heuristics".to_owned()), "{ids:?}"); - assert!(ids.contains(&"review:2:3".to_owned()), "{ids:?}"); - assert_eq!( - first_label(find(&plan, "heuristics")), - "Accept all (Recommended)" - ); - assert!( - !ids.iter() - .any(|id| id == "route:2:1" || id.ends_with(":2:1")), - "{ids:?}" - ); - assert!(!ids.iter().any(|id| id.starts_with("route:")), "{ids:?}"); - // An existing page is a different question: what building over it does. - assert!(ids.iter().any(|id| id == "collision:2:2")); - assert_eq!(rows[1]["existingFile"], "src/app/company/page.tsx"); - assert_eq!(rows[0]["sharedParts"], json!(["Header"])); -} - -/// Two screens the evidence puts on one URL leave neither decided. -#[test] -fn two_screens_on_one_evidence_route_are_both_asked() { - let targets = BTreeMap::from([ - ("3:1".to_owned(), "/about".to_owned()), - ("3:2".to_owned(), "/about".to_owned()), - ]); - let plan = draft_plan( - &[ - screen("3:1", "About A", 1440.0), - screen("3:2", "About B", 1440.0), - ], - &targets, - &evidence(), - json!({}), - None, - ) - .unwrap(); - let ids = question_ids(&plan); - assert!(ids.contains(&"route:3:1".to_owned()) && ids.contains(&"route:3:2".to_owned())); -} - -#[test] -fn widths_in_one_section_recommend_one_page_and_modals_are_asked() { - let plan = draft_plan( - &[ - screen("4:1", "Main - 1440", 1440.0), - screen("4:2", "Main - 390", 390.0), - screen("4:3", "Login Modal", 480.0), - ], - &BTreeMap::new(), - &evidence(), - json!({}), - None, - ) - .unwrap(); - let rows = array(&plan["screens"]); - assert_eq!(rows.len(), 2); - assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); - assert_eq!(rows[0]["proposedRoute"], "/"); - assert_eq!(rows[0]["grouping"]["recommended"], "single"); - let group = find(&plan, "group:4:1"); - assert_eq!(first_label(group), "One responsive page (Recommended)"); - // Separate pages need their own routes, asked in the same up-front batch - // and only if separate is chosen. No URL is made up for the 390 frame. - let split = find(&plan, "split-route:4:2"); - assert_eq!(split["when"][0]["question"], "group:4:1"); - assert_eq!(split["when"][0]["answer"], "Separate pages"); - assert!(!plan.to_string().contains("/390")); - let modal = find(&plan, "modal:4:3"); - assert!(first_label(modal).starts_with("State on Main")); - assert_eq!(rows[1]["kindEvidence"], NAME_MATCH); - assert!(text(&modal["question"]).contains("name-match")); -} - -/// Every question must be accepted as-is by a structured question tool. -#[test] -fn every_question_has_the_question_tool_shape() { - let mut linked = screen("5:1", "Gallery", 1440.0); - linked.links = vec![PlanLinkInput { - label: "More".into(), - destination_node_id: Some("9:9".into()), - url: None, - }]; - linked.parts = vec!["Footer".into()]; - let mut other = screen("5:2", "News", 1440.0); - other.parts = vec!["Footer".into()]; - let targets = BTreeMap::from([ - ("5:3".to_owned(), "/news/[id]".to_owned()), - ("5:7".to_owned(), "/".to_owned()), - ]); - let plan = draft_plan( - &[ - linked, - other, - screen("5:3", "News detail", 1440.0), - screen("5:4", "공지 팝업", 400.0), - screen("5:5", "Company - 1440", 1440.0), - screen("5:6", "Company - 390", 390.0), - screen("5:7", "Landing", 1440.0), - ], - &targets, - &evidence(), - json!({}), - None, - ) - .unwrap(); - let questions = ask_shape(array(&plan["questions"])); - let ids = questions - .iter() - .map(|question| text(&question["id"])) - .collect::>(); - assert_eq!(ids.len(), questions.len(), "question ids are unique"); - for kind in [ - "route:", - "link:", - "layout:", - "dynamic:", - "modal:", - "group:", - "split-route:", - "heuristics", - "review:", - "collision:", - ] { - assert!( - ids.iter().any(|id| id.starts_with(kind)), - "{kind} missing from {ids:?}" - ); - } - for question in &questions { - let header = text(&question["header"]); - assert!( - !header.is_empty() && header.chars().count() <= HEADER_LIMIT, - "{header}" - ); - assert!(!text(&question["question"]).is_empty()); - assert_eq!(question["multiple"], false); - assert!( - question.get("resolve").is_none(), - "resolution stays in the plan file" - ); - let options = array(&question["options"]); - assert!((2..=MAX_OPTIONS).contains(&options.len()), "{question}"); - assert!( - text(&options[0]["label"]).ends_with(RECOMMENDED), - "{question}" - ); - for option in &options[1..] { - assert!( - !text(&option["label"]).contains("(Recommended)"), - "{question}" - ); - } - assert!( - options - .iter() - .all(|option| !text(&option["description"]).is_empty()) - ); - for condition in array(&question["when"]) { - assert!(condition.get("action").is_none(), "{question}"); - assert!(ids.contains(text(&condition["question"])), "{question}"); - } - } - assert_rounds_cover(&plan); -} - -/// Every question in exactly one round, rounds within the tool's limits, and -/// a conditional question always after the question it depends on. -fn assert_rounds_cover(plan: &Value) { - let questions = array(&plan["questions"]); - let rounds = question_rounds(questions, MAX_QUESTIONS_PER_ROUND); - let mut seen = Vec::new(); - let mut round_of = BTreeMap::new(); - for (index, round) in rounds.iter().enumerate() { - assert_eq!(round["round"], index + 1); - let asked = array(&round["questions"]); - assert!( - (1..=MAX_QUESTIONS_PER_ROUND).contains(&asked.len()), - "{round}" - ); - for question in asked { - assert!(array(&question["options"]).len() <= MAX_OPTIONS); - let id = text(&question["id"]).to_owned(); - round_of.insert(id.clone(), index); - seen.push(id); - } - } - let mut expected = questions - .iter() - .map(|question| text(&question["id"]).to_owned()) - .collect::>(); - let mut got = seen.clone(); - expected.sort(); - got.sort(); - assert_eq!(got, expected, "every question exactly once"); - for question in questions { - for condition in array(&question["when"]) { - assert!( - round_of[text(&condition["question"])] < round_of[text(&question["id"])], - "{} must come after {}", - question["id"], - condition["question"] - ); - } - } -} - -#[test] -fn rounds_split_many_questions_into_fours() { - let screens = (0..11) - .map(|index| screen(&format!("8:{index}"), &format!("Page{index}"), 1440.0)) - .collect::>(); - let plan = draft_plan(&screens, &BTreeMap::new(), &evidence(), json!({}), None).unwrap(); - let rounds = question_rounds(array(&plan["questions"]), MAX_QUESTIONS_PER_ROUND); - assert_eq!(rounds.len(), 3); - assert_rounds_cover(&plan); - // A runtime with a larger cap gets fewer, larger rounds of the same - // questions; one with no cap asks the flat list. - let wide = question_rounds(array(&plan["questions"]), 10); - assert_eq!(wide.len(), 2); - assert_eq!(array(&wide[0]["questions"]).len(), 10); - assert_eq!(question_rounds(array(&plan["questions"]), 50).len(), 1); -} - -/// Frames of one name in different sections are never resolved here: the -/// question states the sections, frames, widths and ids, and offers a Figma -/// fix. Separate pages are recommended unless every section names a device. -#[test] -fn sections_are_asked_explicitly_with_a_fix_option() { - let plan = draft_plan( - &[ - sectioned("9:1", "About", 1440.0, "Company"), - sectioned("9:2", "About", 390.0, "Recruit"), - sectioned("9:3", "Pricing", 1440.0, "PC"), - sectioned("9:4", "Pricing", 390.0, "Mobile"), - ], - &BTreeMap::new(), - &evidence(), - json!({}), - None, - ) - .unwrap(); - let company = find(&plan, "sections:9:1"); - assert_eq!( - first_label(company), - "Separate pages: the sections are different pages (Recommended)" - ); - let device = find(&plan, "sections:9:3"); - assert_eq!( - first_label(device), - "One responsive page: the sections only split by device (Recommended)" - ); - let prompt = text(&device["question"]); - for fact in ["PC", "Mobile", "9:3", "9:4", "1440px", "390px", "Pricing"] { - assert!(prompt.contains(fact), "{fact} missing from {prompt}"); - } - assert!( - text(&device["options"][0]["description"]) - .contains("Inferred from the section names (PC, Mobile)") - ); - let labels = array(&device["options"]) - .iter() - .map(|option| text(&option["label"])) - .collect::>(); - assert!(labels.contains(&FIX_FIGMA), "{labels:?}"); - assert!( - !question_ids(&plan) - .iter() - .any(|id| id.starts_with("group:")) - ); - let split = find(&plan, "split-route:9:2"); - assert!( - array(&split["options"]) - .iter() - .any(|option| text(&option["label"]).starts_with("/recruit/about")), - "{split}" - ); - assert_rounds_cover(&plan); -} - -#[test] -fn project_config_counts_as_evidence_and_suppresses_its_questions() { - let mut evidence = evidence(); - evidence.config = PlanConfig { - modal_keywords: Some(vec!["sheet".into()]), - route_aliases: BTreeMap::from([("요금제".to_owned(), "/pricing".to_owned())]), - width_grouping: Some("responsive".into()), - ..answered_site() - }; - let plan = draft_plan( - &[ - screen("10:1", "요금제 - 1440", 1440.0), - screen("10:2", "요금제 - 390", 390.0), - screen("10:3", "Share sheet", 400.0), - screen("10:4", "Login popup", 400.0), - ], - &BTreeMap::new(), - &evidence, - json!({}), - None, - ) - .unwrap(); - let rows = array(&plan["screens"]); - assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); - assert_eq!(rows[0]["grouping"]["evidence"], "config:widthGrouping"); - assert_eq!(rows[0]["proposedRoute"], "/pricing"); - assert!(text(&rows[0]["routeEvidence"]).contains("routeAliases")); - assert_eq!(rows[1]["kind"], "modal"); - assert_eq!(rows[1]["kindEvidence"], "config"); - // The project's keywords replace the built-in ones. - assert_eq!(rows[2]["kind"], "page"); - let ids = question_ids(&plan); - assert!( - !ids.iter().any(|id| id.starts_with("group:") - || id.starts_with("route:10:1") - || id == "heuristics"), - "{ids:?}" - ); -} - -#[test] -fn a_frame_id_already_in_a_page_is_evidence() { - let mut evidence = evidence(); - evidence.figma_nodes.insert( - "11:1".into(), - ("/company".into(), "src/app/company/page.tsx".into()), - ); - let plan = draft_plan( - &[screen("11:1", "Intro", 1440.0)], - &BTreeMap::new(), - &evidence, - json!({}), - None, - ) - .unwrap(); - assert_eq!(plan["screens"][0]["proposedRoute"], "/company"); - assert!(text(&plan["screens"][0]["routeEvidence"]).contains("data-figma-node-id")); - assert_eq!(question_ids(&plan), ["collision:11:1"]); - assert_eq!( - figma_node_ids(""), - ["1:2", "3:4"] - ); -} - -#[tokio::test] -async fn confirm_rejects_missing_answers_by_id_and_accepts_a_complete_set() { - let fixture = Fixture::new(); - let mut draft_input = input(&fixture, "draft"); - draft_input.navigation_targets = BTreeMap::from([("6:3".to_owned(), "/contact".to_owned())]); - let drafted = draft( - &draft_input, - &[ - screen("6:1", "Pricing", 1440.0), - screen("6:2", "Careers", 1440.0), - screen("6:3", "Contact", 1440.0), - ], - json!({"from": "caller"}), - &fixture.policy(), - ) - .await - .unwrap(); - assert_eq!(drafted["askInstruction"], ASK_INSTRUCTION); - assert!(fixture.0.join(".devup/screen-plan.json").is_file()); - assert!(fixture.0.join(".devup/screen-plan.md").is_file()); - let plan_id = text(&drafted["planId"]).to_owned(); - - let mut confirm_input = input(&fixture, "confirm"); - confirm_input.plan_id = Some(plan_id.clone()); - confirm_input.answers = BTreeMap::from([( - "route:6:1".to_owned(), - AnswerInput::One("/pricing (Recommended)".into()), - )]); - let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); - assert_eq!(error.details["missing"], json!(["route:6:2"])); - assert!(error.message.contains("route:6:2")); - assert_eq!( - read_plan(fixture.root()).unwrap().unwrap()["status"], - "draft" - ); - - confirm_input - .answers - .insert("route:6:2".into(), AnswerInput::One("/jobs".into())); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - let urls = array(&confirmed["routes"]) - .iter() - .map(|row| row["url"].clone()) - .collect::>(); - assert_eq!(urls, [json!("/pricing"), json!("/jobs"), json!("/contact")]); - assert_eq!(confirmed["customAnswers"][0]["answer"], "/jobs"); - let saved = read_plan(fixture.root()).unwrap().unwrap(); - assert_eq!(saved["status"], "confirmed"); - let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); - assert!(markdown.contains("| /jobs | Careers |"), "{markdown}"); -} - -#[tokio::test] -async fn a_custom_route_answer_must_be_a_route() { - let fixture = Fixture::new(); - let drafted = draft( - &input(&fixture, "draft"), - &[screen("7:1", "Pricing", 1440.0)], - json!({}), - &fixture.policy(), - ) - .await - .unwrap(); - let mut confirm_input = input(&fixture, "confirm"); - confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); - confirm_input.answers = BTreeMap::from([( - "route:7:1".to_owned(), - AnswerInput::One("the pricing page".into()), - )]); - let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); - assert!(error.message.contains("custom answer must be an app route")); -} - -fn confirmed_plan() -> Value { - json!({ - "kind": PLAN_KIND, "schemaVersion": SCHEMA_VERSION, "planId": "sp-test", "status": "confirmed", - "source": {"nodeId": "10:0"}, - "routes": [ - {"url": "/", "screen": "10:1", "name": "Main", "nodeIds": ["10:1", "10:2"], "drawnWidths": [1440, 390], "implementation": "page", "file": "src/app/page.tsx"}, - {"url": "/about", "screen": "10:3", "name": "About", "nodeIds": ["10:3"], "drawnWidths": [1440], "implementation": "page", "file": "src/app/about/page.tsx"}, - {"url": null, "screen": "10:4", "name": "Login modal", "nodeIds": ["10:4"], "drawnWidths": [480], "implementation": "state", "hostUrl": "/"}, - ], - }) -} - -/// A width the audit has no item for is not-run, and a URL with a failing -/// width is never counted as verified. -#[test] -fn status_marks_widths_without_an_audit_item_not_run() { - let audit = json!({ - "verdict": "fail", - "widthTable": [{"width": 1440}, {"width": 390}], - "screenTable": [ - {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "screenshot": "1440x900@1-main.png"}, - {"screen": "about", "routePath": "/about", "width": 1440, "status": "pass", "reasons": []}, - {"screen": "about", "routePath": "/about/", "width": 390, "status": "fail", "reasons": ["horizontal overflow: 42px past the viewport"]}, - ], - }); - let report = status_report(&confirmed_plan(), &evidence(), Some(&audit)); - let rows = array(&report["rows"]); - assert_eq!(rows.len(), 2); - assert_eq!(rows[0]["implemented"], "yes"); - assert_eq!(rows[1]["implemented"], "no"); - assert_eq!(rows[0]["widths"][0]["result"], "pass"); - assert_eq!(rows[0]["widths"][1]["result"], "not-run"); - assert_eq!(rows[1]["widths"][1]["result"], "fail"); - assert!( - text(&report["summary"]).starts_with( - "2 URLs: 1 implemented, 0 verified at all widths, /about overflows at 390" - ), - "{}", - report["summary"] - ); - assert_eq!(report["states"][0]["hostUrl"], "/"); - assert!(text(&report["markdownTable"]).contains("1440x900@1-main.png")); - - let without = status_report(&confirmed_plan(), &evidence(), None); - assert!( - array(&without["rows"]) - .iter() - .flat_map(|row| array(&row["widths"])) - .all(|width| width["result"] == "not-run") - ); - assert!(text(&without["summary"]).contains("every width is not-run")); -} - -fn write_plan_file(fixture: &Fixture, plan: &Value) { - fixture.write(PLAN_FILE, &serde_json::to_string(plan).unwrap()); -} - -fn export<'a>(fixture: &'a Fixture, frames: &[&str]) -> ExportRequest<'a> { - ExportRequest { - project_root: fixture.root(), - url: None, - frames: frames.iter().map(|frame| (*frame).to_owned()).collect(), - all_screens: false, - scaffold_write: false, - scaffold_route: None, - output_paths: Vec::new(), - } -} - -#[test] -fn the_export_gate_blocks_page_writes_without_a_confirmed_plan() { - let fixture = Fixture::new(); - let mut scaffold = export(&fixture, &["10:3"]); - scaffold.scaffold_write = true; - scaffold.scaffold_route = Some("/about"); - let Gate::Blocked(response) = export_gate(&scaffold).unwrap() else { - panic!("a pageScaffold write without a plan is blocked"); - }; - assert_eq!(response["status"], "plan_required"); - assert_eq!(response["nextAction"]["tool"], "devup_screen_plan"); - assert_eq!(response["nextAction"]["arguments"]["action"], "draft"); - - let mut app_path = export(&fixture, &["10:3"]); - app_path.output_paths = vec!["src/app/about/page.tsx"]; - assert!(matches!(export_gate(&app_path).unwrap(), Gate::Blocked(_))); - - let mut several = export(&fixture, &["10:1", "10:3"]); - several.output_paths = vec!["src/components/Screens.tsx"]; - assert!(matches!(export_gate(&several).unwrap(), Gate::Blocked(_))); - - let mut draft_only = confirmed_plan(); - draft_only["status"] = json!("draft"); - write_plan_file(&fixture, &draft_only); - assert!(matches!(export_gate(&scaffold).unwrap(), Gate::Blocked(_))); -} - -#[test] -fn the_export_gate_never_blocks_a_component_or_a_preview() { - let fixture = Fixture::new(); - let mut component = export(&fixture, &["10:3"]); - component.output_paths = vec!["src/components/Card.tsx"]; - assert!(matches!(export_gate(&component).unwrap(), Gate::Open)); - let preview = export(&fixture, &["10:1", "10:3"]); - assert!(matches!(export_gate(&preview).unwrap(), Gate::Open)); - let mut scaffold_preview = export(&fixture, &["10:3"]); - scaffold_preview.scaffold_route = Some("/about"); - assert!(matches!( - export_gate(&scaffold_preview).unwrap(), - Gate::Open - )); -} - -#[test] -fn a_confirmed_plan_supplies_the_scaffold_route_and_refuses_another() { - let fixture = Fixture::new(); - write_plan_file(&fixture, &confirmed_plan()); - let mut scaffold = export(&fixture, &["10:2"]); - scaffold.scaffold_write = true; - scaffold.scaffold_route = Some(""); - let Gate::Route(route) = export_gate(&scaffold).unwrap() else { - panic!("the plan covers 10:2"); - }; - assert_eq!(route, "/"); - scaffold.scaffold_route = Some("/home"); - let error = export_gate(&scaffold) - .err() - .expect("a different route is a mismatch"); - assert_eq!(error.details["routeMismatch"]["planned"], "/"); - - let mut uncovered = export(&fixture, &["99:1"]); - uncovered.scaffold_write = true; - uncovered.scaffold_route = Some("/x"); - let Gate::Blocked(response) = export_gate(&uncovered).unwrap() else { - panic!("a frame the plan does not place is blocked"); - }; - assert_eq!( - response["planRequired"]["details"]["uncoveredFrames"], - json!(["99:1"]) - ); - - let mut several = export(&fixture, &["10:1", "10:3"]); - several.output_paths = vec!["src/components/Screens.tsx"]; - assert!(matches!(export_gate(&several).unwrap(), Gate::Open)); -} - -#[tokio::test] -async fn project_evidence_reads_routes_layout_parts_and_nav_links() { - let fixture = Fixture::new(); - let evidence = project_evidence(fixture.root()).await.unwrap(); - let routes = evidence - .routes - .iter() - .map(|(route, _)| route.as_str()) - .collect::>(); - assert!( - routes.contains(&"/") && routes.contains(&"/company"), - "{routes:?}" - ); - assert_eq!(evidence.app_dir, "src/app"); - assert!(evidence.layout_parts.contains(&"Header".to_owned())); - assert!( - evidence - .nav_links - .iter() - .any(|(label, href, _)| label == "회사소개" && href == "/company"), - "{:?}", - evidence.nav_links - ); -} - -fn answers(pairs: &[(&str, &str)]) -> BTreeMap { - pairs - .iter() - .map(|(id, answer)| ((*id).to_owned(), AnswerInput::One((*answer).to_owned()))) - .collect() -} - -async fn drafted(fixture: &Fixture, screens: &[PlanScreenInput]) -> ScreenPlanInput { - let plan = draft( - &input(fixture, "draft"), - screens, - json!({}), - &fixture.policy(), - ) - .await - .unwrap(); - let mut confirm_input = input(fixture, "confirm"); - confirm_input.plan_id = plan["planId"].as_str().map(str::to_owned); - confirm_input -} - -fn main_widths() -> [PlanScreenInput; 2] { - [ - screen("12:1", "Main - 1440", 1440.0), - screen("12:2", "Main - 390", 390.0), - ] -} - -/// Separate pages bring their own route questions; the answers decide the -/// URLs, and nothing is derived from a width. -#[tokio::test] -async fn separate_pages_need_a_route_per_frame_and_get_exactly_those() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &main_widths()).await; - confirm_input.answers = answers(&[ - ("group:12:1", "Separate pages"), - ("split-route:12:1", "/"), - ("heuristics", "Accept all (Recommended)"), - ]); - let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); - assert_eq!(error.details["missing"], json!(["split-route:12:2"])); - assert!(error.details["questionRounds"][0]["questions"][0]["id"] == "split-route:12:2"); - - confirm_input - .answers - .insert("split-route:12:2".into(), AnswerInput::One("/m".into())); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - let urls = array(&confirmed["routes"]) - .iter() - .map(|row| (row["url"].clone(), row["nodeIds"].clone())) - .collect::>(); - assert_eq!( - urls, - [ - (json!("/"), json!(["12:1"])), - (json!("/m"), json!(["12:2"])) - ] - ); -} - -#[tokio::test] -async fn one_responsive_page_skips_the_split_routes_and_accepts_the_inferred_route() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &main_widths()).await; - confirm_input.answers = answers(&[ - ("group:12:1", "One responsive page (Recommended)"), - ("heuristics", "Accept all (Recommended)"), - ]); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - let route = &confirmed["routes"][0]; - assert_eq!(route["url"], "/"); - assert_eq!(route["nodeIds"], json!(["12:1", "12:2"])); - assert_eq!(route["collisionPolicy"], "merge"); - assert_eq!(route["routeEvidence"], "name-match (accepted)"); -} - -#[tokio::test] -async fn reviewing_heuristics_asks_each_one_in_the_same_batch() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &main_widths()).await; - confirm_input.answers = answers(&[ - ("group:12:1", "One responsive page (Recommended)"), - ("heuristics", "Review individually"), - ]); - let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); - assert_eq!(error.details["missing"], json!(["review:12:1"])); - confirm_input - .answers - .insert("review:12:1".into(), AnswerInput::One("/home".into())); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - assert_eq!(confirmed["routes"][0]["url"], "/home"); -} - -#[tokio::test] -async fn a_custom_group_answer_can_name_one_route_per_frame() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &main_widths()).await; - confirm_input.answers = answers(&[ - ("group:12:1", "/desktop, /mobile"), - ("heuristics", "Accept all (Recommended)"), - ]); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - let urls = array(&confirmed["routes"]) - .iter() - .map(|row| row["url"].clone()) - .collect::>(); - assert_eq!(urls, [json!("/desktop"), json!("/mobile")]); -} - -/// After confirm nothing is asked: what implementation decides on its own is -/// recorded, and status and the final table carry it. -#[tokio::test] -async fn assumptions_are_recorded_after_confirm_and_reported_by_status() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &[screen("13:1", "Pricing", 1440.0)]).await; - let mut assume_input = input(&fixture, "assume"); - assume_input.assumptions = vec![AssumptionInput { - about: "13:9".into(), - decision: "Not built: outside the confirmed screen plan.".into(), - reason: Some("found during implementation".into()), - }]; - let early = assume(&assume_input, &fixture.policy()).unwrap_err(); - assert!(early.message.contains("after confirm")); - - confirm_input.answers = answers(&[("route:13:1", "/pricing (Recommended)")]); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - assert!(text(&confirmed["rule"]).contains("without asking")); - let recorded = assume(&assume_input, &fixture.policy()).unwrap(); - assert_eq!(recorded["assumptions"][0]["about"], "13:9"); - - let plan = read_plan(fixture.root()).unwrap().unwrap(); - let report = status_report(&plan, &evidence(), None); - assert_eq!( - report["assumptions"][0]["decision"], - "Not built: outside the confirmed screen plan." - ); - assert!(text(&report["summary"]).contains("; 1 assumption made during implementation")); - assert!(text(&report["markdownTable"]).contains("- 13:9: Not built")); - let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); - assert!(markdown.contains("Assumptions made during implementation:")); - - let mut uncovered = export(&fixture, &["13:9"]); - uncovered.scaffold_write = true; - uncovered.scaffold_route = Some("/x"); - let Gate::Blocked(response) = export_gate(&uncovered).unwrap() else { - panic!("a frame outside the confirmed plan is not written"); - }; - assert_eq!(response["nextAction"]["arguments"]["action"], "assume"); - assert!(text(&response["nextAction"]["how"]).contains("Do not ask the user")); -} - -#[tokio::test] -async fn an_invalid_config_is_refused_rather_than_ignored() { - let fixture = Fixture::new(); - fixture.write(CONFIG_FILE, r#"{"widthGrouping": "sometimes"}"#); - let error = project_evidence(fixture.root()).await.unwrap_err(); - assert!(error.message.contains("widthGrouping")); - fixture.write( - CONFIG_FILE, - r#"{"routeAliases": {"메인": "/"}, "widthGrouping": "ask", "siteName": "Prime"}"#, - ); - let evidence = project_evidence(fixture.root()).await.unwrap(); - assert_eq!(evidence.config_file.as_deref(), Some(CONFIG_FILE)); - assert_eq!(evidence.config.route_aliases["메인"], "/"); -} - -fn in_trash(node_id: &str, name: &str, page: &str, area: &str) -> PlanScreenInput { - PlanScreenInput { - section: Some(format!("section-{area}")), - section_name: Some(area.into()), - breadcrumb: vec![page.into(), area.into(), name.into()], - ..screen(node_id, name, 1440.0) - } -} - -fn page_screen(node_id: &str, name: &str, page: &str, area: &str) -> PlanScreenInput { - in_trash(node_id, name, page, area) -} - -/// Picking the Figma fix marks the group: its frames are not built, the rest -/// goes on, and status reports exactly what to move or rename. -#[tokio::test] -async fn a_requested_design_fix_is_reported_not_built() { - let fixture = Fixture::new(); - let mut confirm_input = drafted( - &fixture, - &[ - sectioned("14:1", "Pricing", 1440.0, "PC"), - sectioned("14:2", "Pricing", 390.0, "Mobile"), - sectioned("14:3", "Careers", 1440.0, "PC"), - ], - ) - .await; - confirm_input.answers = answers(&[ - ("sections:14:1", FIX_FIGMA), - ("route:14:3", "/careers (Recommended)"), - ]); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - let built = array(&confirmed["routes"]) - .iter() - .flat_map(|row| array(&row["nodeIds"]).to_vec()) - .collect::>(); - assert_eq!(built, [json!("14:3")]); - let fix = &confirmed["designFixes"][0]; - assert_eq!(fix["question"], "sections:14:1"); - assert_eq!(array(&fix["frames"]).len(), 2); - assert!(text(&fix["fix"]).contains("PC, Mobile"), "{fix}"); - - let plan = read_plan(fixture.root()).unwrap().unwrap(); - let report = status_report(&plan, &evidence(), None); - assert!(text(&report["summary"]).contains("1 design fix requested, not built")); - let table = text(&report["markdownTable"]); - assert!( - table.contains("Design fixes requested (not built):"), - "{table}" - ); - assert!(table.contains("14:1") && table.contains("14:2"), "{table}"); - let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); - assert!(markdown.contains("Design fixes requested"), "{markdown}"); - - let mut fixed = export(&fixture, &["14:2"]); - fixed.scaffold_write = true; - let Gate::Blocked(response) = export_gate(&fixed).unwrap() else { - panic!("a frame marked for a design fix is never written"); - }; - assert_eq!(response["status"], "refused"); -} - -/// Trash, archive and copies are never built and never silently dropped. -#[test] -fn trash_and_archive_frames_are_excluded_and_asked_about_once() { - let plan = draft_plan( - &[ - page_screen("15:1", "Home", "Site", "Pages"), - in_trash("15:2", "Home", "Site", "휴지통"), - in_trash("15:3", "About", "Site", "Archive"), - page_screen("15:4", "Home copy", "Site", "Pages"), - ], - &BTreeMap::new(), - &evidence(), - json!({"nodeId": "page-1", "anchorName": "Site", "anchorBreadcrumb": ["Site"]}), - None, - ) - .unwrap(); - let excluded = array(&plan["excluded"]); - let ids = excluded - .iter() - .map(|row| text(&row["nodeId"])) - .collect::>(); - assert_eq!(ids, ["15:2", "15:3", "15:4"]); - assert!(text(&excluded[0]["reason"]).contains("휴지통")); - assert!(text(&excluded[1]["reason"]).contains("\"archive\"")); - assert!(text(&excluded[2]["reason"]).contains("\"copy\"")); - let question = find(&plan, "exclusions"); - assert_eq!(first_label(question), "Accept the exclusions (Recommended)"); - for id in ["15:2", "15:3", "15:4"] { - assert!(text(&question["question"]).contains(id)); - } - assert_eq!(array(&plan["screens"]).len(), 1); - assert!(!question_ids(&plan).iter().any(|id| id == "route:15:3")); - assert_rounds_cover(&plan); -} - -#[test] -fn the_config_can_change_and_override_the_exclusion_words() { - let mut evidence = evidence(); - evidence.config.exclude_keywords = Some(vec!["legacy".into()]); - evidence.config.include_keywords = None; - evidence.config.include_keywords = Some(vec!["test drive".into()]); - let plan = draft_plan( - &[ - page_screen("16:1", "Archive", "Site", "Pages"), - page_screen("16:2", "Legacy home", "Site", "Pages"), - page_screen("16:3", "Test drive", "Site", "Legacy"), - ], - &BTreeMap::new(), - &evidence, - json!({}), - None, - ) - .unwrap(); - let excluded = array(&plan["excluded"]) - .iter() - .map(|row| text(&row["nodeId"])) - .collect::>(); - assert_eq!(excluded, ["16:2"]); -} - -/// A link that itself points into an archive is asked once, not excluded. -#[test] -fn a_link_into_an_archive_is_asked_once_instead_of_excluded() { - let plan = draft_plan( - &[in_trash("17:1", "Home", "Site", "Archive")], - &BTreeMap::new(), - &evidence(), - json!({"nodeId": "section-Archive", "anchorName": "Archive", "anchorBreadcrumb": ["Site", "Archive"]}), - None, - ) - .unwrap(); - assert!(array(&plan["excluded"]).is_empty()); - let scope = find(&plan, "scope"); - assert!(text(&scope["question"]).contains("Archive")); - assert_eq!(find(&plan, "heuristics")["when"][0]["question"], "scope"); -} - -/// Never wider than the link: a frame whose ancestry does not reach the -/// linked node is not a candidate. -#[test] -fn frames_outside_the_linked_node_are_never_candidates() { - let plan = draft_plan( - &[ - page_screen("18:1", "About", "Site", "Company"), - page_screen("18:2", "Pricing", "Site", "Sales"), - ], - &BTreeMap::new(), - &evidence(), - json!({"nodeId": "section-Company", "anchorName": "Company", "anchorBreadcrumb": ["Site", "Company"]}), - None, - ) - .unwrap(); - let rows = array(&plan["screens"]); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0]["id"], "18:1"); - assert_eq!(plan["outOfScope"][0]["nodeId"], "18:2"); - assert!(!plan["questions"].to_string().contains("18:2")); - // Explore's own answer maps the same way: a frame straight on the page - // is in no section, and ancestry keeps it in or out of scope. - let on_page = screen_from_explore(&json!({"nodeId": "19:1", "name": "Loose", "parentId": "0:1", - "bounds": {"width": 1440.0, "height": 900.0}, "breadcrumb": ["Site", "Loose"], "visible": true})) - .unwrap(); - assert_eq!(on_page.section, None); - assert!(in_scope(&on_page, Some("0:1"), Some("Site"))); - assert!(!in_scope( - &on_page, - Some("section-Company"), - Some("Company") - )); - let hidden = screen_from_explore(&json!({"nodeId": "19:2", "name": "Old", "parentId": "s", - "bounds": {"width": 1440.0}, "breadcrumb": ["Site", "Pages", "Old"], "visible": false})) - .unwrap(); - assert_eq!(hidden.section.as_deref(), Some("s")); - assert_eq!( - exclusion_reason(&hidden, &[], &PlanConfig::default()).as_deref(), - Some("hidden in Figma") - ); -} - -#[test] -fn other_structural_oddities_are_flagged_with_the_same_fix_option() { - let plan = draft_plan( - &[ - sectioned("20:1", "Notice", 1440.0, "Board"), - sectioned("20:2", "Notice", 1440.0, "Board"), - sectioned("20:3", "Guide 1", 1440.0, "Help"), - sectioned("20:4", "Guide 2", 1440.0, "Support"), - screen("20:5", "Contact", 1440.0), - ], - &BTreeMap::new(), - &evidence(), - json!({}), - None, - ) - .unwrap(); - for id in [ - "structure:duplicate:20:1", - "structure:split:20:3", - "structure:loose:20:5", - ] { - let question = find(&plan, id); - let labels = array(&question["options"]) - .iter() - .map(|option| text(&option["label"])) - .collect::>(); - assert!(labels.contains(&FIX_FIGMA), "{id}: {labels:?}"); - } - // The duplicates' own route questions wait on the structure answer. - let route = find(&plan, "route:20:2"); - assert!( - array(&route["when"]) - .iter() - .any(|condition| condition["question"] == "structure:duplicate:20:1"), - "{route}" - ); - assert_rounds_cover(&plan); -} - -#[tokio::test] -async fn the_gate_refuses_an_excluded_frame_even_with_an_assumption() { - let fixture = Fixture::new(); - let plan = draft( - &input(&fixture, "draft"), - &[ - page_screen("21:1", "Pricing", "Site", "Pages"), - in_trash("21:2", "Pricing", "Site", "Trash"), - ], - json!({}), - &fixture.policy(), - ) - .await - .unwrap(); - let mut confirm_input = input(&fixture, "confirm"); - confirm_input.plan_id = plan["planId"].as_str().map(str::to_owned); - confirm_input.answers = answers(&[ - ("route:21:1", "/pricing (Recommended)"), - ("exclusions", "Accept the exclusions (Recommended)"), - ]); - confirm(&confirm_input, &fixture.policy()).unwrap(); - let mut assume_input = input(&fixture, "assume"); - assume_input.assumptions = vec![AssumptionInput { - about: "21:2".into(), - decision: "Build it as /pricing-old".into(), - reason: None, - }]; - let recorded = assume(&assume_input, &fixture.policy()).unwrap(); - assert_eq!(recorded["stillNotBuilt"][0]["frame"], "21:2"); - let mut excluded = export(&fixture, &["21:2"]); - excluded.scaffold_write = true; - let Gate::Blocked(response) = export_gate(&excluded).unwrap() else { - panic!("an excluded frame is never written"); - }; - assert_eq!(response["status"], "refused"); - assert!(text(&response["refused"][0]["reason"]).starts_with("excluded")); -} - -fn bare_site() -> ProjectEvidence { - ProjectEvidence { - config: PlanConfig::default(), - ..evidence() - } -} - -fn labels(question: &Value) -> Vec<&str> { - array(&question["options"]) - .iter() - .map(|option| text(&option["label"])) - .collect() -} - -/// The favicon and share image are files only the user can pick, so they -/// are asked - with the candidates the project and the linked Figma scope -/// actually hold, and never a generated one. -#[test] -fn site_files_are_asked_with_evidence_backed_candidates() { - let mut evidence = bare_site(); - evidence.icon_files = vec!["src/app/icon.svg".into()]; - evidence.package_name = Some("prime-web".into()); - let plan = draft_plan_with( - &[ - screen("30:3", "Home", 1440.0), - screen("30:2", "OG image", 1200.0), - ], - &[PlanAssetInput { - node_id: "30:1".into(), - name: "Logo".into(), - ..PlanAssetInput::default() - }], - &[], - &BTreeMap::new(), - &evidence, - json!({}), - None, - ) - .unwrap(); - let favicon = find(&plan, "site:favicon"); - assert_eq!( - labels(favicon), - [ - "Use src/app/icon.svg (Recommended)", - "Export \"Logo\" (30:1) as SVG", - "I will provide a file", - "Leave it for later" - ] - ); - assert!(text(&favicon["options"][2]["description"]).contains("Nothing is generated or drawn")); - let og = find(&plan, "site:og"); - assert_eq!( - labels(og)[..2], - [ - "Export \"OG image\" (30:2) as PNG (Recommended)", - "Use the top of \"Home\" (30:3)" - ] - ); - // A frame named like a share image is an asset, not a page. - assert_eq!(array(&plan["screens"]).len(), 1); - assert_eq!( - labels(find(&plan, "site:name"))[0], - "prime-web (Recommended)" - ); - assert_eq!(labels(find(&plan, "site:motion"))[0], "None (Recommended)"); - assert!(!question_ids(&plan).contains(&"site:analytics".to_owned())); - let defaults = array(&plan["defaults"]) - .iter() - .map(|default| text(&default["item"])) - .collect::>(); - for item in [ - "interaction-recipes", - "view-transitions", - "transition", - "reduced-motion", - "metadata", - "lang", - "alt-text", - "not-found", - "image-dimensions", - ] { - assert!(defaults.contains(&item), "{item} missing from {defaults:?}"); - } - assert_eq!(plan["site"]["favicon"]["state"], "asked"); - assert_rounds_cover(&plan); -} - -#[test] -fn the_config_and_the_project_pre_answer_the_site_questions() { - let plan = draft_plan( - &[screen("31:1", "Home", 1440.0)], - &BTreeMap::new(), - &evidence(), - json!({}), - None, - ) - .unwrap(); - assert!( - !question_ids(&plan).iter().any(|id| id.starts_with("site:")), - "{:?}", - question_ids(&plan) - ); - assert_eq!(plan["site"]["siteName"]["state"], "config"); - - let mut found = bare_site(); - found.site_name = Some(( - "Prime".into(), - "title template in src/app/layout.tsx".into(), - )); - found.site_url = Some(( - "https://prime.example".into(), - "NEXT_PUBLIC_SITE_URL in .env".into(), - )); - found.analytics = vec!["@vercel/analytics in package.json".into()]; - let plan = draft_plan( - &[screen("31:1", "Home", 1440.0)], - &BTreeMap::new(), - &found, - json!({}), - None, - ) - .unwrap(); - let ids = question_ids(&plan); - assert!( - !ids.contains(&"site:name".to_owned()) && !ids.contains(&"site:url".to_owned()), - "{ids:?}" - ); - assert_eq!( - plan["site"]["siteUrl"]["evidence"], - "NEXT_PUBLIC_SITE_URL in .env" - ); - let analytics = find(&plan, "site:analytics"); - assert!(text(&analytics["question"]).contains("@vercel/analytics")); -} - -#[tokio::test] -async fn project_evidence_reads_the_site_facts() { - let fixture = Fixture::new(); - fixture.write("public/favicon.ico", "ico"); - fixture.write( - ".env", - "NEXT_PUBLIC_SITE_URL=https://prime.example\nNEXT_PUBLIC_GA_ID=G-1\n", - ); - fixture.write( - "src/app/layout.tsx", - "export const metadata = { title: { template: '%s | Prime' } };\nexport default function RootLayout({ children }) { return {children}; }", - ); - let evidence = project_evidence(fixture.root()).await.unwrap(); - assert_eq!(evidence.icon_files, ["public/favicon.ico"]); - assert_eq!( - evidence.site_url.as_ref().unwrap().0, - "https://prime.example" - ); - assert_eq!(evidence.site_name.as_ref().unwrap().0, "Prime"); - assert_eq!(evidence.lang.as_ref().unwrap().0, "ko"); - assert!( - evidence - .analytics - .iter() - .any(|sign| sign.contains("NEXT_PUBLIC_GA_ID")) - ); - assert!(evidence.not_found.is_none()); -} - -/// Status says, per item, whether it is filled, decided but not in place, -/// or missing - read from the files as they are now. -#[tokio::test] -async fn status_reports_site_completeness() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &[screen("32:1", "Company", 1440.0)]).await; - confirm_input.answers = answers(&[("heuristics", "Accept all (Recommended)")]); - confirm(&confirm_input, &fixture.policy()).unwrap(); - let plan = read_plan(fixture.root()).unwrap().unwrap(); - let evidence = project_evidence(fixture.root()).await.unwrap(); - let report = status_report(&plan, &evidence, None); - let item = |name: &str| { - array(&report["siteCompleteness"]) - .iter() - .find(|item| item["item"] == name) - .cloned() - .unwrap_or_else(|| panic!("{name} missing: {}", report["siteCompleteness"])) - }; - assert_eq!(item("favicon")["state"], "asked-and-answered"); - assert_eq!(item("metadata /company")["state"], "missing"); - // The fixture header has two bare links in a nav. - assert_eq!(item("interaction-roles")["state"], "missing"); - assert_eq!( - item("interaction-roles")["detail"], - "nav items 0/2 hover+focus+aria-current" - ); - // Always planned; missing until the sources carry it. - assert_eq!(item("view-transitions")["state"], "missing"); - assert_eq!( - item("view-transitions")["detail"], - "planned (react-view-transition) but not in the sources yet" - ); - assert_eq!(item("site-name")["state"], "filled"); - let table = text(&report["markdownTable"]); - assert!(table.contains("Site completeness:"), "{table}"); - assert!( - table.contains("Filled in by default (no question):"), - "{table}" - ); - - fixture.write("src/app/icon.svg", ""); - fixture.write( - "src/app/company/page.tsx", - "export const metadata = { title: 'Company' };\nexport default function Page() { return
; }", - ); - let evidence = project_evidence(fixture.root()).await.unwrap(); - let report = status_report(&plan, &evidence, None); - let states = array(&report["siteCompleteness"]) - .iter() - .map(|item| { - ( - text(&item["item"]).to_owned(), - text(&item["state"]).to_owned(), - ) - }) - .collect::>(); - assert_eq!(states["favicon"], "filled"); - assert_eq!(states["metadata /company"], "filled"); -} - -#[test] -fn a_missing_app_icon_is_a_warning() { - let fixture = Fixture::new(); - let finding = missing_app_icon(fixture.root()).expect("the fixture app has no icon"); - assert_eq!(finding.rule, "app-icon"); - fixture.write("src/app/favicon.ico", "ico"); - assert!(missing_app_icon(fixture.root()).is_none()); -} - -fn with_package(package: &str, lock: Option<&str>) -> Fixture { - let fixture = Fixture::new(); - fixture.write("package.json", package); - if let Some(lock) = lock { - fixture.write("package-lock.json", lock); - } - fixture -} - -async fn transition_default(fixture: &Fixture) -> Value { - let evidence = project_evidence(fixture.root()).await.unwrap(); - let plan = draft_plan( - &[screen("40:1", "Home", 1440.0)], - &BTreeMap::new(), - &evidence, - json!({}), - None, - ) - .unwrap(); - array(&plan["defaults"]) - .iter() - .find(|default| default["item"] == "view-transitions") - .cloned() - .expect("a view-transitions default") -} - -/// View transitions are always on: the dependency step brings every project -/// to the latest Next and React, so no version is checked. Only an explicit -/// opt-out, or a project that forbids the update while on an old React, -/// skips them; an existing page-motion library makes them a question. -#[tokio::test] -async fn view_transitions_are_always_on_unless_the_project_opts_out() { - for package in [ - r#"{"dependencies": {"next": "^16.1.0", "react": "19.3.0"}}"#, - r#"{"dependencies": {"next": "15.3.0", "react": "19.1.0"}}"#, - r#"{"dependencies": {"next": "^14.2.0", "react": "^18.3.0"}}"#, - r#"{"dependencies": {"next": "latest"}}"#, - ] { - let fixture = with_package(package, None); - let chosen = transition_default(&fixture).await; - assert_eq!( - chosen["mechanism"], "react-view-transition", - "{package}: {chosen}" - ); - assert!(text(&chosen["decision"]).contains("import { ViewTransition } from 'react'")); - assert!(text(&chosen["decision"]).contains("prefers-reduced-motion")); - assert!(text(&chosen["decision"]).contains("view-transition check")); - assert!(!array(&chosen["docs"]).is_empty()); - } - - let opted_out = with_package( - r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, - None, - ); - opted_out.write( - CONFIG_FILE, - &SITE_CONFIG.replace('}', r#", "viewTransitions": false}"#), - ); - let chosen = transition_default(&opted_out).await; - assert_eq!(chosen["mechanism"], "skipped"); - assert!(text(&chosen["evidence"]).contains("viewTransitions: false")); - - let frozen_old = with_package( - r#"{"dependencies": {"next": "14.2.0", "react": "18.3.1"}}"#, - None, - ); - frozen_old.write( - CONFIG_FILE, - &SITE_CONFIG.replace('}', r#", "updateDependencies": false}"#), - ); - assert_eq!( - transition_default(&frozen_old).await["mechanism"], - "skipped" - ); - let frozen_new = with_package( - r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, - None, - ); - frozen_new.write( - CONFIG_FILE, - &SITE_CONFIG.replace('}', r#", "updateDependencies": false}"#), - ); - assert_eq!( - transition_default(&frozen_new).await["mechanism"], - "react-view-transition" - ); - - let animated = with_package( - r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0", "framer-motion": "^11.0.0"}}"#, - None, - ); - let chosen = transition_default(&animated).await; - assert_eq!(chosen["mechanism"], "asked"); - assert_eq!(chosen["ifAdded"]["mechanism"], "react-view-transition"); - let evidence = project_evidence(animated.root()).await.unwrap(); - let plan = draft_plan( - &[screen("40:1", "Home", 1440.0)], - &BTreeMap::new(), - &evidence, - json!({}), - None, - ) - .unwrap(); - assert_eq!( - first_label(find(&plan, "site:view-transitions")), - "Keep framer-motion only (Recommended)" - ); -} - -#[test] -fn the_recipe_default_names_every_role_and_the_restraint_rules() { - let plan = draft_plan( - &[screen("41:1", "Home", 1440.0)], - &BTreeMap::new(), - &evidence(), - json!({}), - None, - ) - .unwrap(); - let recipes = array(&plan["defaults"]) - .iter() - .find(|default| default["item"] == "interaction-recipes") - .cloned() - .unwrap(); - let roles = array(&recipes["recipes"]) - .iter() - .map(|row| text(&row["role"])) - .collect::>(); - for role in [ - "primary button", - "card that links", - "nav item / tab", - "icon button", - "input / select / textarea", - ] { - assert!(roles.contains(&role), "{role} missing from {roles:?}"); - } - let restraint = text(&recipes["restraint"]); - for rule in [ - "at most two properties", - "150-250ms", - "0.97 and 1.05", - "_motionReduce", - "no hover", - ] { - assert!(restraint.contains(rule), "{rule} missing"); - } -} - -/// The agent's own questions go into the same up-front rounds, and their -/// answers come back from confirm. -#[tokio::test] -async fn extra_questions_are_asked_up_front_and_answered_at_confirm() { - let fixture = Fixture::new(); - let mut draft_input = input(&fixture, "draft"); - draft_input.extra_questions = vec![ - ExtraQuestionInput { - id: "contact-target".into(), - header: "Contact form target".into(), - question: "Where should the contact form send its messages?".into(), - options: vec![ - ExtraOptionInput { - label: "Email to the address in the footer (Recommended)".into(), - description: "A server action sending to the footer address.".into(), - }, - ExtraOptionInput { - label: "An existing API endpoint".into(), - description: "Named in openapi.json.".into(), - }, - ], - multiple: false, - }, - ExtraQuestionInput { - id: "legal".into(), - header: "Legal pages".into(), - question: "Which legal pages does the site need?".into(), - options: vec![ - ExtraOptionInput { - label: "Privacy policy".into(), - description: "/privacy".into(), - }, - ExtraOptionInput { - label: "Terms of service".into(), - description: "/terms".into(), - }, - ], - multiple: true, - }, - ]; - let drafted = draft( - &draft_input, - &[screen("42:1", "Pricing", 1440.0)], - json!({}), - &fixture.policy(), - ) - .await - .unwrap(); - let ids = array(&drafted["questions"]) - .iter() - .map(|q| text(&q["id"])) - .collect::>(); - assert!( - ids.contains(&"extra:contact-target") && ids.contains(&"extra:legal"), - "{ids:?}" - ); - let contact = array(&drafted["questions"]) - .iter() - .find(|q| q["id"] == "extra:contact-target") - .unwrap(); - assert_eq!( - first_label(contact), - "Email to the address in the footer (Recommended)" - ); - - let mut confirm_input = input(&fixture, "confirm"); - confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); - confirm_input.answers = answers(&[ - ("route:42:1", "/pricing (Recommended)"), - ("extra:contact-target", "contact@prime.example"), - ]); - confirm_input.answers.insert( - "extra:legal".into(), - AnswerInput::Many(vec!["Privacy policy".into(), "Terms of service".into()]), - ); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - assert_eq!( - confirmed["extraAnswers"]["extra:contact-target"], - "contact@prime.example" - ); - assert_eq!( - confirmed["extraAnswers"]["extra:legal"], - json!(["Privacy policy", "Terms of service"]) - ); - - draft_input.extra_questions[0].header = "A header far longer than thirty characters".into(); - let error = draft( - &draft_input, - &[screen("42:1", "Pricing", 1440.0)], - json!({}), - &fixture.policy(), - ) - .await - .unwrap_err(); - assert!(error.message.contains("header"), "{}", error.message); -} - -fn template_fixture() -> Fixture { - let fixture = Fixture::new(); - for stale in ["package.json", "src"] { - let path = fixture.0.join(stale); - if path.is_dir() { - std::fs::remove_dir_all(&path).unwrap(); - } else { - std::fs::remove_file(&path).ok(); - } - } - crate::server::framework::tests::service_template(&fixture.0); - fixture -} - -fn default_item(plan: &Value, item: &str) -> Value { - array(&plan["defaults"]) - .iter() - .find(|default| default["item"] == item) - .cloned() - .unwrap_or_else(|| panic!("no {item} default: {}", plan["defaults"])) -} - -async fn plan_for(root: &str) -> (ProjectEvidence, Value) { - let evidence = project_evidence(root).await.unwrap(); - let plan = draft_plan( - &[screen("50:1", "Home", 1440.0)], - &BTreeMap::new(), - &evidence, - json!({}), - None, - ) - .unwrap(); - (evidence, plan) -} - -/// service-template: bun workspaces, vinext + React 19.3, no next -/// dependency. Its front app is an App Router app with view transitions -/// enabled, and the repository root, holding two apps, asks which one. -#[tokio::test] -async fn a_service_template_project_is_vinext_with_view_transitions_enabled() { - let fixture = template_fixture(); - let front = fixture.0.join("apps").join("front"); - let (evidence, plan) = plan_for(front.to_str().unwrap()).await; - assert_eq!(evidence.framework(), "vinext"); - let chosen = default_item(&plan, "view-transitions"); - assert_eq!(chosen["mechanism"], "react-view-transition", "{chosen}"); - assert!(text(&chosen["evidence"]).contains("vinext"), "{chosen}"); - assert!( - array(&chosen["docs"]) - .iter() - .any(|url| text(url).contains("vinext")) - ); - assert!(!question_ids(&plan).contains(&"site:framework".to_owned())); - - let (evidence, plan) = plan_for(fixture.root()).await; - assert_eq!(evidence.apps.len(), 2); - assert!(evidence.target_app.is_none()); - // "Home" is a public-site screen: apps/front, without asking. - assert_eq!(default_item(&plan, "target-app")["decision"], "apps/front"); - assert!(!question_ids(&plan).contains(&"site:app".to_owned())); - - let routes = super::super::project_context::run("routes", Some(fixture.root()), None) - .await - .unwrap(); - let apps = array(&routes["frameworks"]["apps"]) - .iter() - .map(|app| { - ( - text(&app["dir"]).to_owned(), - text(&app["framework"]).to_owned(), - ) - }) - .collect::>(); - assert_eq!( - apps, - [ - ("apps/admin".to_owned(), "next".to_owned()), - ("apps/front".to_owned(), "vinext".to_owned()) - ] - ); -} - -/// An existing project on the next CLI is asked once whether to migrate, -/// never migrated silently; the config can answer for it. -#[tokio::test] -async fn a_next_cli_project_is_asked_once_about_vinext() { - let fixture = with_package( - r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, - None, - ); - let (_, plan) = plan_for(fixture.root()).await; - let asked = find(&plan, "site:framework"); - assert_eq!(first_label(asked), "Migrate to vinext (Recommended)"); - assert!(text(&asked["question"]).contains("next 16.1.0")); - assert_eq!( - default_item(&plan, "view-transitions")["mechanism"], - "react-view-transition" - ); - - fixture.write( - CONFIG_FILE, - r#"{"framework": "next", "favicon": "app/icon.svg", "ogImage": "public/og.png", "siteName": "Prime", "siteUrl": "https://example.com", "motion": "none"}"#, - ); - let (_, plan) = plan_for(fixture.root()).await; - assert!(!question_ids(&plan).contains(&"site:framework".to_owned())); -} - -/// No app yet: start from the template without asking, ask only its name -/// and parts. -#[tokio::test] -async fn a_new_project_starts_from_the_template() { - let root = std::env::temp_dir().join(format!("screen-plan-new-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - let (_, plan) = plan_for(root.to_str().unwrap()).await; - assert!( - text(&default_item(&plan, "project-start")["decision"]) - .contains("dev-five-git/service-template") - ); - assert_eq!( - default_item(&plan, "view-transitions")["mechanism"], - "react-view-transition" - ); - let ids = question_ids(&plan); - assert!( - ids.contains(&"site:app-name".to_owned()) && ids.contains(&"site:parts".to_owned()), - "{ids:?}" - ); - assert_eq!( - first_label(find(&plan, "site:parts")), - "Front only (Recommended)" - ); - std::fs::remove_dir_all(root).ok(); -} - -#[tokio::test] -async fn the_dependency_step_is_a_default_and_its_outcome_is_reported() { - let fixture = Fixture::new(); - let (_, plan) = plan_for(fixture.root()).await; - let step = default_item(&plan, "dependencies"); - assert!(text(&step["decision"]).contains("bunx @dependency-check-updates/cli -d -u --rm")); - assert!(text(&step["decision"]).contains("Cargo.lock and target/")); - - let mut confirm_input = drafted(&fixture, &[screen("51:1", "Pricing", 1440.0)]).await; - confirm_input.answers = answers(&[("route:51:1", "/pricing (Recommended)")]); - confirm(&confirm_input, &fixture.policy()).unwrap(); - let report = status_report( - &read_plan(fixture.root()).unwrap().unwrap(), - &evidence(), - None, - ); - let line = array(&report["siteCompleteness"]) - .iter() - .find(|item| item["item"] == "dependencies") - .cloned() - .unwrap(); - assert_eq!(line["state"], "missing"); - - let mut record = input(&fixture, "assume"); - record.dependency_update = Some(DependencyUpdateInput { - bumped: 14, - pins: vec![PinInput { - package: "oxlint".into(), - version: "1.86.0".into(), - reason: "eslint-plugin-devup does not load on 1.87 yet".into(), - }], - }); - let recorded = assume(&record, &fixture.policy()).unwrap(); - assert_eq!(recorded["assumptions"][0]["about"], "dependency oxlint"); - let report = status_report( - &read_plan(fixture.root()).unwrap().unwrap(), - &evidence(), - None, - ); - let line = array(&report["siteCompleteness"]) - .iter() - .find(|item| item["item"] == "dependencies") - .cloned() - .unwrap(); - assert_eq!(line["state"], "filled"); - assert!( - text(&line["detail"]).contains("14 packages bumped, pinned oxlint@1.86.0"), - "{line}" - ); - - fixture.write(CONFIG_FILE, r#"{"updateDependencies": false, "favicon": "app/icon.svg", "ogImage": "public/og.png", "siteName": "Prime", "siteUrl": "https://example.com", "motion": "none"}"#); - let (_, plan) = plan_for(fixture.root()).await; - assert_eq!(default_item(&plan, "dependencies")["mechanism"], "skipped"); -} - -#[test] -fn bun_lock_versions_are_read() { - let lock = r#"{ - "packages": { - "react": ["react@19.3.1", "", {}, "sha512-x"], - "vinext": ["vinext@1.0.2", "", {}, "sha512-y"], - } -}"#; - assert_eq!(bun_locked(lock, "react").as_deref(), Some("19.3.1")); - assert_eq!(bun_locked(lock, "vinext").as_deref(), Some("1.0.2")); - assert_eq!(bun_locked(lock, "next"), None); -} - -async fn plan_screens_at(root: &str, screens: &[PlanScreenInput]) -> Value { - let evidence = project_evidence(root).await.unwrap(); - draft_plan(screens, &BTreeMap::new(), &evidence, json!({}), None).unwrap() -} - -fn rows_app(plan: &Value) -> Vec<(String, String)> { - array(&plan["screens"]) - .iter() - .map(|row| (text(&row["id"]).to_owned(), text(&row["app"]).to_owned())) - .collect() -} - -/// apps/front and apps/admin: the app is picked by what the frames are, not -/// by name order. Marketing frames build the public site without a question. -#[tokio::test] -async fn marketing_frames_go_to_the_public_app_without_a_question() { - let fixture = template_fixture(); - let plan = plan_screens_at( - fixture.root(), - &[ - screen("60:1", "Main", 1440.0), - screen("60:2", "회사소개", 1440.0), - screen("60:3", "Contact", 1440.0), - ], - ) - .await; - assert!( - !question_ids(&plan) - .iter() - .any(|id| id.starts_with("site:app") || id.starts_with("app:")), - "{:?}", - question_ids(&plan) - ); - let chosen = default_item(&plan, "target-app"); - assert_eq!(chosen["decision"], "apps/front"); - assert_eq!(chosen["mechanism"], "decided"); - assert!( - chosen["evidence"] - .to_string() - .contains("named like the public site"), - "{chosen}" - ); - assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/front")); - assert_eq!(plan["appDir"], "apps/front/src/app"); -} - -#[tokio::test] -async fn admin_frames_go_to_the_admin_app() { - let fixture = template_fixture(); - let plan = plan_screens_at( - fixture.root(), - &[ - screen("61:1", "Admin - 회원관리", 1440.0), - screen("61:2", "Admin - 상품 목록", 1440.0), - ], - ) - .await; - assert_eq!(default_item(&plan, "target-app")["decision"], "apps/admin"); - assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/admin")); - assert!( - !question_ids(&plan) - .iter() - .any(|id| id.starts_with("site:app")) - ); -} - -/// A file with both maps each screen to its side; a screen with no admin -/// evidence goes to the public app without a question, and each screen's -/// route options point into its own app. -#[tokio::test] -async fn a_mixed_file_maps_each_screen_and_sends_the_unclear_to_the_public_app() { - let fixture = template_fixture(); - let screens = [ - screen("62:1", "Home", 1440.0), - screen("62:2", "관리자 - 대시보드", 1440.0), - screen("62:3", "Settings", 1440.0), - ]; - let plan = plan_screens_at(fixture.root(), &screens).await; - let chosen = default_item(&plan, "target-app"); - assert_eq!(chosen["mechanism"], "per-screen"); - assert!( - text(&chosen["decision"]).contains("no admin evidence go to apps/front"), - "{chosen}" - ); - let apps = rows_app(&plan).into_iter().collect::>(); - assert_eq!(apps["62:1"], "apps/front"); - assert_eq!(apps["62:2"], "apps/admin"); - assert_eq!(apps["62:3"], "apps/front"); - assert!( - !question_ids(&plan) - .iter() - .any(|id| id.starts_with("app:") || id == "site:app"), - "{:?}", - question_ids(&plan) - ); - let settings = find(&plan, "route:62:3"); - assert!( - text(&settings["options"][0]["description"]) - .contains("apps/front/src/app/settings/page.tsx"), - "{settings}" - ); - // Home is offered nothing from the admin app; the dashboard is offered - // the admin app's own pages. - for question in array(&plan["questions"]) { - let about_home = text(&question["id"]).ends_with("62:1"); - let mentions_admin = question["options"].to_string().contains("apps/admin/"); - assert!(!(about_home && mentions_admin), "{question}"); - } - assert!( - find(&plan, "route:62:2")["options"] - .to_string() - .contains("apps/admin/src/app/page.tsx") - ); -} - -/// Frames that say nothing either way go to the public app: with no admin -/// evidence that is the default, recorded rather than asked. -#[tokio::test] -async fn frames_that_say_nothing_go_to_the_public_app_without_a_question() { - let fixture = template_fixture(); - let screens = [ - screen("63:1", "Frame 12", 1440.0), - screen("63:2", "Frame 13", 1440.0), - ]; - let plan = plan_screens_at(fixture.root(), &screens).await; - assert!(!question_ids(&plan).iter().any(|id| id == "site:app")); - let chosen = default_item(&plan, "target-app"); - assert_eq!(chosen["mechanism"], "decided"); - assert_eq!(chosen["decision"], "apps/front"); - assert!( - chosen["evidence"].to_string().contains("no admin evidence"), - "{chosen}" - ); - assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/front")); -} - -/// Only when no app looks like the public site is the app asked - and the -/// answer still decides where each route is written. -#[tokio::test] -async fn the_app_is_asked_only_when_no_app_is_the_public_site() { - let fixture = template_fixture(); - for (from, to) in [("apps/front", "apps/alpha"), ("apps/admin", "apps/beta")] { - std::fs::rename(fixture.0.join(from), fixture.0.join(to)).unwrap(); - } - fixture.write( - "apps/alpha/package.json", - r#"{"name": "alpha", "dependencies": {"next": "16.1.0"}}"#, - ); - fixture.write( - "apps/beta/package.json", - r#"{"name": "beta", "dependencies": {"next": "16.1.0"}}"#, - ); - let screens = [ - screen("63:1", "Frame 12", 1440.0), - screen("63:2", "Frame 13", 1440.0), - ]; - let plan = plan_screens_at(fixture.root(), &screens).await; - let which = find(&plan, "site:app"); - assert_eq!(labels(which).len(), 2, "{which}"); - - let draft_input = ScreenPlanInput { - action: "draft".into(), - project_root: Some(fixture.root().into()), - ..ScreenPlanInput::default() - }; - let drafted = draft(&draft_input, &screens, json!({}), &fixture.policy()) - .await - .unwrap(); - let mut confirm_input = input(&fixture, "confirm"); - confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); - let mut pairs = vec![("site:app", "apps/beta")]; - for question in array(&drafted["questions"]) { - let id = text(&question["id"]); - if id.starts_with("route:") { - pairs.push(( - id, - if id.ends_with("63:1") { - "/members" - } else { - "/orders" - }, - )); - } - } - let owned = pairs - .iter() - .map(|(id, answer)| (id.to_string(), answer.to_string())) - .collect::>(); - confirm_input.answers = answers( - &owned - .iter() - .map(|(id, answer)| (id.as_str(), answer.as_str())) - .collect::>(), - ); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - let files = array(&confirmed["routes"]) - .iter() - .map(|row| (text(&row["app"]).to_owned(), text(&row["file"]).to_owned())) - .collect::>(); - assert_eq!( - files[0], - ( - "apps/beta".to_owned(), - "apps/beta/src/app/members/page.tsx".to_owned() - ), - "{files:?}" - ); -} - -/// auditPlan writes one runtime-audit plan per confirmed URL and the runner -/// once, records them, and reports the frames it has no export for instead -/// of skipping them. -#[tokio::test] -async fn audit_plan_writes_a_plan_per_url_and_reports_what_it_cannot_compare() { - let fixture = Fixture::new(); - write_plan_file(&fixture, &confirmed_plan()); - let store = ArtifactStore::default(); - let mut request = input(&fixture, "auditPlan"); - request.artifacts = vec!["expired-artifact".into()]; - let result = audit_plan(&request, &fixture.policy(), &store) - .await - .unwrap(); - let plans = array(&result["plans"]); - assert_eq!( - plans - .iter() - .map(|plan| text(&plan["url"])) - .collect::>(), - ["/", "/about"] - ); - assert!( - fixture - .0 - .join(".devup/runtime-audit/home/plan.json") - .exists() - ); - assert!( - fixture - .0 - .join(".devup/runtime-audit/about/plan.json") - .exists() - ); - assert!( - fixture - .0 - .join(".devup/runtime-audit/runner/run.mjs") - .exists() - ); - assert_eq!(result["runner"]["state"], "written"); - // The plugin that strips the audit ids from production builds sits next - // to the app's vite.config, and the config is not wired to it yet. - let strip = &result["productionStrip"][0]; - assert!( - fixture - .0 - .join("vite-plugin-strip-figma-node-ids.mjs") - .exists() - ); - assert_eq!(strip["written"], true); - assert_eq!(strip["wired"], false); - assert!(text(&strip["verify"]).contains("grep -rl data-figma-node-id")); - assert_eq!( - result["expiredArtifacts"][0]["artifactId"], - "expired-artifact" - ); - assert_eq!(result["maxFixAttempts"], 3); - let frames = array(&result["missingReferences"]) - .iter() - .filter(|item| item["kind"] == "frame") - .map(|item| { - ( - text(&item["nodeId"]).to_owned(), - item["width"].as_u64().unwrap(), - ) - }) - .collect::>(); - assert_eq!( - frames, - [ - ("10:1".to_owned(), 1440), - ("10:2".to_owned(), 390), - ("10:3".to_owned(), 1440) - ] - ); - let home = &plans[0]; - assert_eq!(home["checks"]["view-transition"], 1); - assert_eq!(home["checks"]["page-health"], 1); - let written = read_plan(fixture.root()).unwrap().unwrap(); - assert_eq!(array(&written["audit"]["plans"]).len(), 2); - assert!(text(&written["audit"]["plans"][0]["reportPath"]).ends_with("home/report/report.json")); - - // The runner is written once; a second run leaves it alone. - let again = audit_plan(&request, &fixture.policy(), &store) - .await - .unwrap(); - assert_eq!(again["runner"]["state"], "current"); -} - -/// A decision mid-run never stops the work: it is pending, its URL's other -/// work comes first, and assume takes it at the end - except a destructive -/// one, which stays unbuilt and is reported. -#[tokio::test] -async fn a_pending_decision_is_taken_at_the_end_and_a_destructive_one_is_left_unbuilt() { - let fixture = Fixture::new(); - write_plan_file(&fixture, &confirmed_plan()); - let mut request = input(&fixture, "defer"); - request.decision = Some(PendingDecisionInput { - id: "about-form".into(), - about: "/about contact form".into(), - question: "Where does the contact form post?".into(), - options: vec!["mailto".into(), "an API route".into()], - recommended: "mailto".into(), - destructive: false, - }); - let deferred = defer(&request, &fixture.policy()).unwrap(); - assert_eq!(deferred["pendingDecisions"][0]["status"], "open"); - request.decision = Some(PendingDecisionInput { - id: "drop-old-page".into(), - about: "src/app/old/page.tsx".into(), - question: "Delete the old page?".into(), - options: Vec::new(), - recommended: "keep it".into(), - destructive: true, - }); - defer(&request, &fixture.policy()).unwrap(); - - let plan = read_plan(fixture.root()).unwrap().unwrap(); - let report = status_report(&plan, &evidence(), None); - let about = array(&report["workQueue"]) - .iter() - .find(|row| row["url"] == "/about") - .unwrap() - .clone(); - let order = array(&about["items"]) - .iter() - .map(|item| text(&item["do"]).to_owned()) - .collect::>(); - assert_eq!( - order.last().map(String::as_str), - Some("blocked"), - "{order:?}" - ); - - let mut take = input(&fixture, "assume"); - take.assumptions = vec![ - AssumptionInput { - about: "about-form".into(), - decision: "mailto".into(), - reason: Some("recommended".into()), - }, - AssumptionInput { - about: "drop-old-page".into(), - decision: "delete".into(), - reason: None, - }, - ]; - let taken = assume(&take, &fixture.policy()).unwrap(); - let decisions = array(&taken["pendingDecisions"]); - assert_eq!(decisions[0]["status"], "taken"); - assert_eq!(decisions[0]["taken"], "mailto"); - assert_eq!(decisions[1]["status"], "left-unbuilt"); - let plan = read_plan(fixture.root()).unwrap().unwrap(); - let report = status_report(&plan, &evidence(), None); - let text_report = text(&report["finalReport"]); - assert!( - text_report - .contains("about-form: Where does the contact form post? -> mailto (taken at the end)"), - "{text_report}" - ); - assert!( - text_report - .contains("drop-old-page: Delete the old page? -> keep it (destructive: left unbuilt)") - ); - assert!( - text_report - .contains("| URL | width | health | geometry | drift / converter bugs | page | section | asset | interaction |") - ); - assert!(text_report.contains("Dependencies: the update step has not been recorded.")); -} +// Source sections share this module to preserve privacy and test names. +include!("screen_plan_tests/fixtures_and_cases.rs"); +include!("screen_plan_tests/a_custom_group_answer_can_name_one_route_per_frame.rs"); +include!("screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs"); diff --git a/crates/devup-mcp/src/server/screen_plan_tests/a_custom_group_answer_can_name_one_route_per_frame.rs b/crates/devup-mcp/src/server/screen_plan_tests/a_custom_group_answer_can_name_one_route_per_frame.rs new file mode 100644 index 00000000..9d096db7 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan_tests/a_custom_group_answer_can_name_one_route_per_frame.rs @@ -0,0 +1,846 @@ + + +#[tokio::test] +async fn a_custom_group_answer_can_name_one_route_per_frame() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "/desktop, /mobile"), + ("heuristics", "Accept all (Recommended)"), + ]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| row["url"].clone()) + .collect::>(); + assert_eq!(urls, [json!("/desktop"), json!("/mobile")]); +} + +/// After confirm nothing is asked: what implementation decides on its own is +/// recorded, and status and the final table carry it. +#[tokio::test] +async fn assumptions_are_recorded_after_confirm_and_reported_by_status() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &[screen("13:1", "Pricing", 1440.0)]).await; + let mut assume_input = input(&fixture, "assume"); + assume_input.assumptions = vec![AssumptionInput { + about: "13:9".into(), + decision: "Not built: outside the confirmed screen plan.".into(), + reason: Some("found during implementation".into()), + }]; + let early = assume(&assume_input, &fixture.policy()).unwrap_err(); + assert!(early.message.contains("after confirm")); + + confirm_input.answers = answers(&[("route:13:1", "/pricing (Recommended)")]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert!(text(&confirmed["rule"]).contains("without asking")); + let recorded = assume(&assume_input, &fixture.policy()).unwrap(); + assert_eq!(recorded["assumptions"][0]["about"], "13:9"); + + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let report = status_report(&plan, &evidence(), None); + assert_eq!( + report["assumptions"][0]["decision"], + "Not built: outside the confirmed screen plan." + ); + assert!(text(&report["summary"]).contains("; 1 assumption made during implementation")); + assert!(text(&report["markdownTable"]).contains("- 13:9: Not built")); + let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); + assert!(markdown.contains("Assumptions made during implementation:")); + + let mut uncovered = export(&fixture, &["13:9"]); + uncovered.scaffold_write = true; + uncovered.scaffold_route = Some("/x"); + let Gate::Blocked(response) = export_gate(&uncovered).unwrap() else { + panic!("a frame outside the confirmed plan is not written"); + }; + assert_eq!(response["nextAction"]["arguments"]["action"], "assume"); + assert!(text(&response["nextAction"]["how"]).contains("Do not ask the user")); +} + +#[tokio::test] +async fn an_invalid_config_is_refused_rather_than_ignored() { + let fixture = Fixture::new(); + fixture.write(CONFIG_FILE, r#"{"widthGrouping": "sometimes"}"#); + let error = project_evidence(fixture.root()).await.unwrap_err(); + assert!(error.message.contains("widthGrouping")); + fixture.write( + CONFIG_FILE, + r#"{"routeAliases": {"메인": "/"}, "widthGrouping": "ask", "siteName": "Prime"}"#, + ); + let evidence = project_evidence(fixture.root()).await.unwrap(); + assert_eq!(evidence.config_file.as_deref(), Some(CONFIG_FILE)); + assert_eq!(evidence.config.route_aliases["메인"], "/"); +} + +fn in_trash(node_id: &str, name: &str, page: &str, area: &str) -> PlanScreenInput { + PlanScreenInput { + section: Some(format!("section-{area}")), + section_name: Some(area.into()), + breadcrumb: vec![page.into(), area.into(), name.into()], + ..screen(node_id, name, 1440.0) + } +} + +fn page_screen(node_id: &str, name: &str, page: &str, area: &str) -> PlanScreenInput { + in_trash(node_id, name, page, area) +} + +/// Picking the Figma fix marks the group: its frames are not built, the rest +/// goes on, and status reports exactly what to move or rename. +#[tokio::test] +async fn a_requested_design_fix_is_reported_not_built() { + let fixture = Fixture::new(); + let mut confirm_input = drafted( + &fixture, + &[ + sectioned("14:1", "Pricing", 1440.0, "PC"), + sectioned("14:2", "Pricing", 390.0, "Mobile"), + sectioned("14:3", "Careers", 1440.0, "PC"), + ], + ) + .await; + confirm_input.answers = answers(&[ + ("sections:14:1", FIX_FIGMA), + ("route:14:3", "/careers (Recommended)"), + ]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let built = array(&confirmed["routes"]) + .iter() + .flat_map(|row| array(&row["nodeIds"]).to_vec()) + .collect::>(); + assert_eq!(built, [json!("14:3")]); + let fix = &confirmed["designFixes"][0]; + assert_eq!(fix["question"], "sections:14:1"); + assert_eq!(array(&fix["frames"]).len(), 2); + assert!(text(&fix["fix"]).contains("PC, Mobile"), "{fix}"); + + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let report = status_report(&plan, &evidence(), None); + assert!(text(&report["summary"]).contains("1 design fix requested, not built")); + let table = text(&report["markdownTable"]); + assert!( + table.contains("Design fixes requested (not built):"), + "{table}" + ); + assert!(table.contains("14:1") && table.contains("14:2"), "{table}"); + let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); + assert!(markdown.contains("Design fixes requested"), "{markdown}"); + + let mut fixed = export(&fixture, &["14:2"]); + fixed.scaffold_write = true; + let Gate::Blocked(response) = export_gate(&fixed).unwrap() else { + panic!("a frame marked for a design fix is never written"); + }; + assert_eq!(response["status"], "refused"); +} + +/// Trash, archive and copies are never built and never silently dropped. +#[test] +fn trash_and_archive_frames_are_excluded_and_asked_about_once() { + let plan = draft_plan( + &[ + page_screen("15:1", "Home", "Site", "Pages"), + in_trash("15:2", "Home", "Site", "휴지통"), + in_trash("15:3", "About", "Site", "Archive"), + page_screen("15:4", "Home copy", "Site", "Pages"), + ], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "page-1", "anchorName": "Site", "anchorBreadcrumb": ["Site"]}), + None, + ) + .unwrap(); + let excluded = array(&plan["excluded"]); + let ids = excluded + .iter() + .map(|row| text(&row["nodeId"])) + .collect::>(); + assert_eq!(ids, ["15:2", "15:3", "15:4"]); + assert!(text(&excluded[0]["reason"]).contains("휴지통")); + assert!(text(&excluded[1]["reason"]).contains("\"archive\"")); + assert!(text(&excluded[2]["reason"]).contains("\"copy\"")); + let question = find(&plan, "exclusions"); + assert_eq!(first_label(question), "Accept the exclusions (Recommended)"); + for id in ["15:2", "15:3", "15:4"] { + assert!(text(&question["question"]).contains(id)); + } + assert_eq!(array(&plan["screens"]).len(), 1); + assert!(!question_ids(&plan).iter().any(|id| id == "route:15:3")); + assert_rounds_cover(&plan); +} + +#[test] +fn the_config_can_change_and_override_the_exclusion_words() { + let mut evidence = evidence(); + evidence.config.exclude_keywords = Some(vec!["legacy".into()]); + evidence.config.include_keywords = None; + evidence.config.include_keywords = Some(vec!["test drive".into()]); + let plan = draft_plan( + &[ + page_screen("16:1", "Archive", "Site", "Pages"), + page_screen("16:2", "Legacy home", "Site", "Pages"), + page_screen("16:3", "Test drive", "Site", "Legacy"), + ], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + let excluded = array(&plan["excluded"]) + .iter() + .map(|row| text(&row["nodeId"])) + .collect::>(); + assert_eq!(excluded, ["16:2"]); +} + +/// A link that itself points into an archive is asked once, not excluded. +#[test] +fn a_link_into_an_archive_is_asked_once_instead_of_excluded() { + let plan = draft_plan( + &[in_trash("17:1", "Home", "Site", "Archive")], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "section-Archive", "anchorName": "Archive", "anchorBreadcrumb": ["Site", "Archive"]}), + None, + ) + .unwrap(); + assert!(array(&plan["excluded"]).is_empty()); + let scope = find(&plan, "scope"); + assert!(text(&scope["question"]).contains("Archive")); + assert_eq!(find(&plan, "heuristics")["when"][0]["question"], "scope"); +} + +/// Never wider than the link: a frame whose ancestry does not reach the +/// linked node is not a candidate. +#[test] +fn frames_outside_the_linked_node_are_never_candidates() { + let plan = draft_plan( + &[ + page_screen("18:1", "About", "Site", "Company"), + page_screen("18:2", "Pricing", "Site", "Sales"), + ], + &BTreeMap::new(), + &evidence(), + json!({"nodeId": "section-Company", "anchorName": "Company", "anchorBreadcrumb": ["Site", "Company"]}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["id"], "18:1"); + assert_eq!(plan["outOfScope"][0]["nodeId"], "18:2"); + assert!(!plan["questions"].to_string().contains("18:2")); + // Explore's own answer maps the same way: a frame straight on the page + // is in no section, and ancestry keeps it in or out of scope. + let on_page = screen_from_explore(&json!({"nodeId": "19:1", "name": "Loose", "parentId": "0:1", + "bounds": {"width": 1440.0, "height": 900.0}, "breadcrumb": ["Site", "Loose"], "visible": true})) + .unwrap(); + assert_eq!(on_page.section, None); + assert!(in_scope(&on_page, Some("0:1"), Some("Site"))); + assert!(!in_scope( + &on_page, + Some("section-Company"), + Some("Company") + )); + let hidden = screen_from_explore(&json!({"nodeId": "19:2", "name": "Old", "parentId": "s", + "bounds": {"width": 1440.0}, "breadcrumb": ["Site", "Pages", "Old"], "visible": false})) + .unwrap(); + assert_eq!(hidden.section.as_deref(), Some("s")); + assert_eq!( + exclusion_reason(&hidden, &[], &PlanConfig::default()).as_deref(), + Some("hidden in Figma") + ); +} + +#[test] +fn other_structural_oddities_are_flagged_with_the_same_fix_option() { + let plan = draft_plan( + &[ + sectioned("20:1", "Notice", 1440.0, "Board"), + sectioned("20:2", "Notice", 1440.0, "Board"), + sectioned("20:3", "Guide 1", 1440.0, "Help"), + sectioned("20:4", "Guide 2", 1440.0, "Support"), + screen("20:5", "Contact", 1440.0), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + for id in [ + "structure:duplicate:20:1", + "structure:split:20:3", + "structure:loose:20:5", + ] { + let question = find(&plan, id); + let labels = array(&question["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert!(labels.contains(&FIX_FIGMA), "{id}: {labels:?}"); + } + // The duplicates' own route questions wait on the structure answer. + let route = find(&plan, "route:20:2"); + assert!( + array(&route["when"]) + .iter() + .any(|condition| condition["question"] == "structure:duplicate:20:1"), + "{route}" + ); + assert_rounds_cover(&plan); +} + +#[tokio::test] +async fn the_gate_refuses_an_excluded_frame_even_with_an_assumption() { + let fixture = Fixture::new(); + let plan = draft( + &input(&fixture, "draft"), + &[ + page_screen("21:1", "Pricing", "Site", "Pages"), + in_trash("21:2", "Pricing", "Site", "Trash"), + ], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = plan["planId"].as_str().map(str::to_owned); + confirm_input.answers = answers(&[ + ("route:21:1", "/pricing (Recommended)"), + ("exclusions", "Accept the exclusions (Recommended)"), + ]); + confirm(&confirm_input, &fixture.policy()).unwrap(); + let mut assume_input = input(&fixture, "assume"); + assume_input.assumptions = vec![AssumptionInput { + about: "21:2".into(), + decision: "Build it as /pricing-old".into(), + reason: None, + }]; + let recorded = assume(&assume_input, &fixture.policy()).unwrap(); + assert_eq!(recorded["stillNotBuilt"][0]["frame"], "21:2"); + let mut excluded = export(&fixture, &["21:2"]); + excluded.scaffold_write = true; + let Gate::Blocked(response) = export_gate(&excluded).unwrap() else { + panic!("an excluded frame is never written"); + }; + assert_eq!(response["status"], "refused"); + assert!(text(&response["refused"][0]["reason"]).starts_with("excluded")); +} + +fn bare_site() -> ProjectEvidence { + ProjectEvidence { + config: PlanConfig::default(), + ..evidence() + } +} + +fn labels(question: &Value) -> Vec<&str> { + array(&question["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect() +} + +/// The favicon and share image are files only the user can pick, so they +/// are asked - with the candidates the project and the linked Figma scope +/// actually hold, and never a generated one. +#[test] +fn site_files_are_asked_with_evidence_backed_candidates() { + let mut evidence = bare_site(); + evidence.icon_files = vec!["src/app/icon.svg".into()]; + evidence.package_name = Some("prime-web".into()); + let plan = draft_plan_with( + &[ + screen("30:3", "Home", 1440.0), + screen("30:2", "OG image", 1200.0), + ], + &[PlanAssetInput { + node_id: "30:1".into(), + name: "Logo".into(), + ..PlanAssetInput::default() + }], + &[], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + let favicon = find(&plan, "site:favicon"); + assert_eq!( + labels(favicon), + [ + "Use src/app/icon.svg (Recommended)", + "Export \"Logo\" (30:1) as SVG", + "I will provide a file", + "Leave it for later" + ] + ); + assert!(text(&favicon["options"][2]["description"]).contains("Nothing is generated or drawn")); + let og = find(&plan, "site:og"); + assert_eq!( + labels(og)[..2], + [ + "Export \"OG image\" (30:2) as PNG (Recommended)", + "Use the top of \"Home\" (30:3)" + ] + ); + // A frame named like a share image is an asset, not a page. + assert_eq!(array(&plan["screens"]).len(), 1); + assert_eq!( + labels(find(&plan, "site:name"))[0], + "prime-web (Recommended)" + ); + assert_eq!(labels(find(&plan, "site:motion"))[0], "None (Recommended)"); + assert!(!question_ids(&plan).contains(&"site:analytics".to_owned())); + let defaults = array(&plan["defaults"]) + .iter() + .map(|default| text(&default["item"])) + .collect::>(); + for item in [ + "interaction-recipes", + "view-transitions", + "transition", + "reduced-motion", + "metadata", + "lang", + "alt-text", + "not-found", + "image-dimensions", + ] { + assert!(defaults.contains(&item), "{item} missing from {defaults:?}"); + } + assert_eq!(plan["site"]["favicon"]["state"], "asked"); + assert_rounds_cover(&plan); +} + +#[test] +fn the_config_and_the_project_pre_answer_the_site_questions() { + let plan = draft_plan( + &[screen("31:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + assert!( + !question_ids(&plan).iter().any(|id| id.starts_with("site:")), + "{:?}", + question_ids(&plan) + ); + assert_eq!(plan["site"]["siteName"]["state"], "config"); + + let mut found = bare_site(); + found.site_name = Some(( + "Prime".into(), + "title template in src/app/layout.tsx".into(), + )); + found.site_url = Some(( + "https://prime.example".into(), + "NEXT_PUBLIC_SITE_URL in .env".into(), + )); + found.analytics = vec!["@vercel/analytics in package.json".into()]; + let plan = draft_plan( + &[screen("31:1", "Home", 1440.0)], + &BTreeMap::new(), + &found, + json!({}), + None, + ) + .unwrap(); + let ids = question_ids(&plan); + assert!( + !ids.contains(&"site:name".to_owned()) && !ids.contains(&"site:url".to_owned()), + "{ids:?}" + ); + assert_eq!( + plan["site"]["siteUrl"]["evidence"], + "NEXT_PUBLIC_SITE_URL in .env" + ); + let analytics = find(&plan, "site:analytics"); + assert!(text(&analytics["question"]).contains("@vercel/analytics")); +} + +#[tokio::test] +async fn project_evidence_reads_the_site_facts() { + let fixture = Fixture::new(); + fixture.write("public/favicon.ico", "ico"); + fixture.write( + ".env", + "NEXT_PUBLIC_SITE_URL=https://prime.example\nNEXT_PUBLIC_GA_ID=G-1\n", + ); + fixture.write( + "src/app/layout.tsx", + "export const metadata = { title: { template: '%s | Prime' } };\nexport default function RootLayout({ children }) { return {children}; }", + ); + let evidence = project_evidence(fixture.root()).await.unwrap(); + assert_eq!(evidence.icon_files, ["public/favicon.ico"]); + assert_eq!( + evidence.site_url.as_ref().unwrap().0, + "https://prime.example" + ); + assert_eq!(evidence.site_name.as_ref().unwrap().0, "Prime"); + assert_eq!(evidence.lang.as_ref().unwrap().0, "ko"); + assert!( + evidence + .analytics + .iter() + .any(|sign| sign.contains("NEXT_PUBLIC_GA_ID")) + ); + assert!(evidence.not_found.is_none()); +} + +/// Status says, per item, whether it is filled, decided but not in place, +/// or missing - read from the files as they are now. +#[tokio::test] +async fn status_reports_site_completeness() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &[screen("32:1", "Company", 1440.0)]).await; + confirm_input.answers = answers(&[("heuristics", "Accept all (Recommended)")]); + confirm(&confirm_input, &fixture.policy()).unwrap(); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let evidence = project_evidence(fixture.root()).await.unwrap(); + let report = status_report(&plan, &evidence, None); + let item = |name: &str| { + array(&report["siteCompleteness"]) + .iter() + .find(|item| item["item"] == name) + .cloned() + .unwrap_or_else(|| panic!("{name} missing: {}", report["siteCompleteness"])) + }; + assert_eq!(item("favicon")["state"], "asked-and-answered"); + assert_eq!(item("metadata /company")["state"], "missing"); + // The fixture header has two bare links in a nav. + assert_eq!(item("interaction-roles")["state"], "missing"); + assert_eq!( + item("interaction-roles")["detail"], + "nav items 0/2 hover+focus+aria-current" + ); + // Always planned; missing until the sources carry it. + assert_eq!(item("view-transitions")["state"], "missing"); + assert_eq!( + item("view-transitions")["detail"], + "planned (react-view-transition) but not in the sources yet" + ); + assert_eq!(item("site-name")["state"], "filled"); + let table = text(&report["markdownTable"]); + assert!(table.contains("Site completeness:"), "{table}"); + assert!( + table.contains("Filled in by default (no question):"), + "{table}" + ); + + fixture.write("src/app/icon.svg", ""); + fixture.write( + "src/app/company/page.tsx", + "export const metadata = { title: 'Company' };\nexport default function Page() { return
; }", + ); + let evidence = project_evidence(fixture.root()).await.unwrap(); + let report = status_report(&plan, &evidence, None); + let states = array(&report["siteCompleteness"]) + .iter() + .map(|item| { + ( + text(&item["item"]).to_owned(), + text(&item["state"]).to_owned(), + ) + }) + .collect::>(); + assert_eq!(states["favicon"], "filled"); + assert_eq!(states["metadata /company"], "filled"); +} + +#[test] +fn a_missing_app_icon_is_a_warning() { + let fixture = Fixture::new(); + let finding = missing_app_icon(fixture.root()).expect("the fixture app has no icon"); + assert_eq!(finding.rule, "app-icon"); + fixture.write("src/app/favicon.ico", "ico"); + assert!(missing_app_icon(fixture.root()).is_none()); +} + +fn with_package(package: &str, lock: Option<&str>) -> Fixture { + let fixture = Fixture::new(); + fixture.write("package.json", package); + if let Some(lock) = lock { + fixture.write("package-lock.json", lock); + } + fixture +} + +async fn transition_default(fixture: &Fixture) -> Value { + let evidence = project_evidence(fixture.root()).await.unwrap(); + let plan = draft_plan( + &[screen("40:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + array(&plan["defaults"]) + .iter() + .find(|default| default["item"] == "view-transitions") + .cloned() + .expect("a view-transitions default") +} + +/// View transitions are always on: the dependency step brings every project +/// to the latest Next and React, so no version is checked. Only an explicit +/// opt-out, or a project that forbids the update while on an old React, +/// skips them; an existing page-motion library makes them a question. +#[tokio::test] +async fn view_transitions_are_always_on_unless_the_project_opts_out() { + for package in [ + r#"{"dependencies": {"next": "^16.1.0", "react": "19.3.0"}}"#, + r#"{"dependencies": {"next": "15.3.0", "react": "19.1.0"}}"#, + r#"{"dependencies": {"next": "^14.2.0", "react": "^18.3.0"}}"#, + r#"{"dependencies": {"next": "latest"}}"#, + ] { + let fixture = with_package(package, None); + let chosen = transition_default(&fixture).await; + assert_eq!( + chosen["mechanism"], "react-view-transition", + "{package}: {chosen}" + ); + assert!(text(&chosen["decision"]).contains("import { ViewTransition } from 'react'")); + assert!(text(&chosen["decision"]).contains("prefers-reduced-motion")); + assert!(text(&chosen["decision"]).contains("view-transition check")); + assert!(!array(&chosen["docs"]).is_empty()); + } + + let opted_out = with_package( + r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, + None, + ); + opted_out.write( + CONFIG_FILE, + &SITE_CONFIG.replace('}', r#", "viewTransitions": false}"#), + ); + let chosen = transition_default(&opted_out).await; + assert_eq!(chosen["mechanism"], "skipped"); + assert!(text(&chosen["evidence"]).contains("viewTransitions: false")); + + let frozen_old = with_package( + r#"{"dependencies": {"next": "14.2.0", "react": "18.3.1"}}"#, + None, + ); + frozen_old.write( + CONFIG_FILE, + &SITE_CONFIG.replace('}', r#", "updateDependencies": false}"#), + ); + assert_eq!( + transition_default(&frozen_old).await["mechanism"], + "skipped" + ); + let frozen_new = with_package( + r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, + None, + ); + frozen_new.write( + CONFIG_FILE, + &SITE_CONFIG.replace('}', r#", "updateDependencies": false}"#), + ); + assert_eq!( + transition_default(&frozen_new).await["mechanism"], + "react-view-transition" + ); + + let animated = with_package( + r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0", "framer-motion": "^11.0.0"}}"#, + None, + ); + let chosen = transition_default(&animated).await; + assert_eq!(chosen["mechanism"], "asked"); + assert_eq!(chosen["ifAdded"]["mechanism"], "react-view-transition"); + let evidence = project_evidence(animated.root()).await.unwrap(); + let plan = draft_plan( + &[screen("40:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + assert_eq!( + first_label(find(&plan, "site:view-transitions")), + "Keep framer-motion only (Recommended)" + ); +} + +#[test] +fn the_recipe_default_names_every_role_and_the_restraint_rules() { + let plan = draft_plan( + &[screen("41:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let recipes = array(&plan["defaults"]) + .iter() + .find(|default| default["item"] == "interaction-recipes") + .cloned() + .unwrap(); + let roles = array(&recipes["recipes"]) + .iter() + .map(|row| text(&row["role"])) + .collect::>(); + for role in [ + "primary button", + "card that links", + "nav item / tab", + "icon button", + "input / select / textarea", + ] { + assert!(roles.contains(&role), "{role} missing from {roles:?}"); + } + let restraint = text(&recipes["restraint"]); + for rule in [ + "at most two properties", + "150-250ms", + "0.97 and 1.05", + "_motionReduce", + "no hover", + ] { + assert!(restraint.contains(rule), "{rule} missing"); + } +} + +/// The agent's own questions go into the same up-front rounds, and their +/// answers come back from confirm. +#[tokio::test] +async fn extra_questions_are_asked_up_front_and_answered_at_confirm() { + let fixture = Fixture::new(); + let mut draft_input = input(&fixture, "draft"); + draft_input.extra_questions = vec![ + ExtraQuestionInput { + id: "contact-target".into(), + header: "Contact form target".into(), + question: "Where should the contact form send its messages?".into(), + options: vec![ + ExtraOptionInput { + label: "Email to the address in the footer (Recommended)".into(), + description: "A server action sending to the footer address.".into(), + }, + ExtraOptionInput { + label: "An existing API endpoint".into(), + description: "Named in openapi.json.".into(), + }, + ], + multiple: false, + }, + ExtraQuestionInput { + id: "legal".into(), + header: "Legal pages".into(), + question: "Which legal pages does the site need?".into(), + options: vec![ + ExtraOptionInput { + label: "Privacy policy".into(), + description: "/privacy".into(), + }, + ExtraOptionInput { + label: "Terms of service".into(), + description: "/terms".into(), + }, + ], + multiple: true, + }, + ]; + let drafted = draft( + &draft_input, + &[screen("42:1", "Pricing", 1440.0)], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let ids = array(&drafted["questions"]) + .iter() + .map(|q| text(&q["id"])) + .collect::>(); + assert!( + ids.contains(&"extra:contact-target") && ids.contains(&"extra:legal"), + "{ids:?}" + ); + let contact = array(&drafted["questions"]) + .iter() + .find(|q| q["id"] == "extra:contact-target") + .unwrap(); + assert_eq!( + first_label(contact), + "Email to the address in the footer (Recommended)" + ); + + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); + confirm_input.answers = answers(&[ + ("route:42:1", "/pricing (Recommended)"), + ("extra:contact-target", "contact@prime.example"), + ]); + confirm_input.answers.insert( + "extra:legal".into(), + AnswerInput::Many(vec!["Privacy policy".into(), "Terms of service".into()]), + ); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!( + confirmed["extraAnswers"]["extra:contact-target"], + "contact@prime.example" + ); + assert_eq!( + confirmed["extraAnswers"]["extra:legal"], + json!(["Privacy policy", "Terms of service"]) + ); + + draft_input.extra_questions[0].header = "A header far longer than thirty characters".into(); + let error = draft( + &draft_input, + &[screen("42:1", "Pricing", 1440.0)], + json!({}), + &fixture.policy(), + ) + .await + .unwrap_err(); + assert!(error.message.contains("header"), "{}", error.message); +} + +fn template_fixture() -> Fixture { + let fixture = Fixture::new(); + for stale in ["package.json", "src"] { + let path = fixture.0.join(stale); + if path.is_dir() { + std::fs::remove_dir_all(&path).unwrap(); + } else { + std::fs::remove_file(&path).ok(); + } + } + crate::server::framework::tests::service_template(&fixture.0); + fixture +} + +fn default_item(plan: &Value, item: &str) -> Value { + array(&plan["defaults"]) + .iter() + .find(|default| default["item"] == item) + .cloned() + .unwrap_or_else(|| panic!("no {item} default: {}", plan["defaults"])) +} + +async fn plan_for(root: &str) -> (ProjectEvidence, Value) { + let evidence = project_evidence(root).await.unwrap(); + let plan = draft_plan( + &[screen("50:1", "Home", 1440.0)], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + (evidence, plan) +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs b/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs new file mode 100644 index 00000000..f98b4f10 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs @@ -0,0 +1,576 @@ + + +/// service-template: bun workspaces, vinext + React 19.3, no next +/// dependency. Its front app is an App Router app with view transitions +/// enabled, and the repository root, holding two apps, asks which one. +#[tokio::test] +async fn a_service_template_project_is_vinext_with_view_transitions_enabled() { + let fixture = template_fixture(); + let front = fixture.0.join("apps").join("front"); + let (evidence, plan) = plan_for(front.to_str().unwrap()).await; + assert_eq!(evidence.framework(), "vinext"); + let chosen = default_item(&plan, "view-transitions"); + assert_eq!(chosen["mechanism"], "react-view-transition", "{chosen}"); + assert!(text(&chosen["evidence"]).contains("vinext"), "{chosen}"); + assert!( + array(&chosen["docs"]) + .iter() + .any(|url| text(url).contains("vinext")) + ); + assert!(!question_ids(&plan).contains(&"site:framework".to_owned())); + + let (evidence, plan) = plan_for(fixture.root()).await; + assert_eq!(evidence.apps.len(), 2); + assert!(evidence.target_app.is_none()); + // "Home" is a public-site screen: apps/front, without asking. + assert_eq!(default_item(&plan, "target-app")["decision"], "apps/front"); + assert!(!question_ids(&plan).contains(&"site:app".to_owned())); + + let routes = super::super::project_context::run("routes", Some(fixture.root()), None) + .await + .unwrap(); + let apps = array(&routes["frameworks"]["apps"]) + .iter() + .map(|app| { + ( + text(&app["dir"]).to_owned(), + text(&app["framework"]).to_owned(), + ) + }) + .collect::>(); + assert_eq!( + apps, + [ + ("apps/admin".to_owned(), "next".to_owned()), + ("apps/front".to_owned(), "vinext".to_owned()) + ] + ); +} + +/// An existing project on the next CLI is asked once whether to migrate, +/// never migrated silently; the config can answer for it. +#[tokio::test] +async fn a_next_cli_project_is_asked_once_about_vinext() { + let fixture = with_package( + r#"{"dependencies": {"next": "16.1.0", "react": "19.3.0"}}"#, + None, + ); + let (_, plan) = plan_for(fixture.root()).await; + let asked = find(&plan, "site:framework"); + assert_eq!(first_label(asked), "Migrate to vinext (Recommended)"); + assert!(text(&asked["question"]).contains("next 16.1.0")); + assert_eq!( + default_item(&plan, "view-transitions")["mechanism"], + "react-view-transition" + ); + + fixture.write( + CONFIG_FILE, + r#"{"framework": "next", "favicon": "app/icon.svg", "ogImage": "public/og.png", "siteName": "Prime", "siteUrl": "https://example.com", "motion": "none"}"#, + ); + let (_, plan) = plan_for(fixture.root()).await; + assert!(!question_ids(&plan).contains(&"site:framework".to_owned())); +} + +/// No app yet: start from the template without asking, ask only its name +/// and parts. +#[tokio::test] +async fn a_new_project_starts_from_the_template() { + let root = std::env::temp_dir().join(format!("screen-plan-new-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let (_, plan) = plan_for(root.to_str().unwrap()).await; + assert!( + text(&default_item(&plan, "project-start")["decision"]) + .contains("dev-five-git/service-template") + ); + assert_eq!( + default_item(&plan, "view-transitions")["mechanism"], + "react-view-transition" + ); + let ids = question_ids(&plan); + assert!( + ids.contains(&"site:app-name".to_owned()) && ids.contains(&"site:parts".to_owned()), + "{ids:?}" + ); + assert_eq!( + first_label(find(&plan, "site:parts")), + "Front only (Recommended)" + ); + std::fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn the_dependency_step_is_a_default_and_its_outcome_is_reported() { + let fixture = Fixture::new(); + let (_, plan) = plan_for(fixture.root()).await; + let step = default_item(&plan, "dependencies"); + assert!(text(&step["decision"]).contains("bunx @dependency-check-updates/cli -d -u --rm")); + assert!(text(&step["decision"]).contains("Cargo.lock and target/")); + + let mut confirm_input = drafted(&fixture, &[screen("51:1", "Pricing", 1440.0)]).await; + confirm_input.answers = answers(&[("route:51:1", "/pricing (Recommended)")]); + confirm(&confirm_input, &fixture.policy()).unwrap(); + let report = status_report( + &read_plan(fixture.root()).unwrap().unwrap(), + &evidence(), + None, + ); + let line = array(&report["siteCompleteness"]) + .iter() + .find(|item| item["item"] == "dependencies") + .cloned() + .unwrap(); + assert_eq!(line["state"], "missing"); + + let mut record = input(&fixture, "assume"); + record.dependency_update = Some(DependencyUpdateInput { + bumped: 14, + pins: vec![PinInput { + package: "oxlint".into(), + version: "1.86.0".into(), + reason: "eslint-plugin-devup does not load on 1.87 yet".into(), + }], + }); + let recorded = assume(&record, &fixture.policy()).unwrap(); + assert_eq!(recorded["assumptions"][0]["about"], "dependency oxlint"); + let report = status_report( + &read_plan(fixture.root()).unwrap().unwrap(), + &evidence(), + None, + ); + let line = array(&report["siteCompleteness"]) + .iter() + .find(|item| item["item"] == "dependencies") + .cloned() + .unwrap(); + assert_eq!(line["state"], "filled"); + assert!( + text(&line["detail"]).contains("14 packages bumped, pinned oxlint@1.86.0"), + "{line}" + ); + + fixture.write(CONFIG_FILE, r#"{"updateDependencies": false, "favicon": "app/icon.svg", "ogImage": "public/og.png", "siteName": "Prime", "siteUrl": "https://example.com", "motion": "none"}"#); + let (_, plan) = plan_for(fixture.root()).await; + assert_eq!(default_item(&plan, "dependencies")["mechanism"], "skipped"); +} + +#[test] +fn bun_lock_versions_are_read() { + let lock = r#"{ + "packages": { + "react": ["react@19.3.1", "", {}, "sha512-x"], + "vinext": ["vinext@1.0.2", "", {}, "sha512-y"], + } +}"#; + assert_eq!(bun_locked(lock, "react").as_deref(), Some("19.3.1")); + assert_eq!(bun_locked(lock, "vinext").as_deref(), Some("1.0.2")); + assert_eq!(bun_locked(lock, "next"), None); +} + +async fn plan_screens_at(root: &str, screens: &[PlanScreenInput]) -> Value { + let evidence = project_evidence(root).await.unwrap(); + draft_plan(screens, &BTreeMap::new(), &evidence, json!({}), None).unwrap() +} + +fn rows_app(plan: &Value) -> Vec<(String, String)> { + array(&plan["screens"]) + .iter() + .map(|row| (text(&row["id"]).to_owned(), text(&row["app"]).to_owned())) + .collect() +} + +/// apps/front and apps/admin: the app is picked by what the frames are, not +/// by name order. Marketing frames build the public site without a question. +#[tokio::test] +async fn marketing_frames_go_to_the_public_app_without_a_question() { + let config: PlanConfig = serde_json::from_value(json!({"appSelectionKeywords": { + "publicApps": ["storefront"], "admin": ["controlroom"], + "publicScreens": ["catalogue"], "adminRoutes": ["control"] + }})) + .unwrap(); + assert_eq!( + selection_words(&config, "publicApps", &PUBLIC_APP_WORDS), + vec!["storefront"] + ); + assert_eq!( + words_side(&["controlroom"], &["storefront"], &config), + (0, 1) + ); + assert_eq!( + screen_side( + &PlanScreenInput { + name: "catalogue".into(), + ..Default::default() + }, + &config + ), + Side::Public + ); + assert_eq!( + selection_words(&PlanConfig::default(), "admin", &ADMIN_WORDS), + *ADMIN_WORDS + ); + let fixture = template_fixture(); + let plan = plan_screens_at( + fixture.root(), + &[ + screen("60:1", "Main", 1440.0), + screen("60:2", "회사소개", 1440.0), + screen("60:3", "Contact", 1440.0), + ], + ) + .await; + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("site:app") || id.starts_with("app:")), + "{:?}", + question_ids(&plan) + ); + let chosen = default_item(&plan, "target-app"); + assert_eq!(chosen["decision"], "apps/front"); + assert_eq!(chosen["mechanism"], "decided"); + assert!( + chosen["evidence"] + .to_string() + .contains("named like the public site"), + "{chosen}" + ); + assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/front")); + assert_eq!(plan["appDir"], "apps/front/src/app"); +} + +#[tokio::test] +async fn admin_frames_go_to_the_admin_app() { + let fixture = template_fixture(); + let plan = plan_screens_at( + fixture.root(), + &[ + screen("61:1", "Admin - 회원관리", 1440.0), + screen("61:2", "Admin - 상품 목록", 1440.0), + ], + ) + .await; + assert_eq!(default_item(&plan, "target-app")["decision"], "apps/admin"); + assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/admin")); + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("site:app")) + ); +} + +/// A file with both maps each screen to its side; a screen with no admin +/// evidence goes to the public app without a question, and each screen's +/// route options point into its own app. +#[tokio::test] +async fn a_mixed_file_maps_each_screen_and_sends_the_unclear_to_the_public_app() { + let fixture = template_fixture(); + let screens = [ + screen("62:1", "Home", 1440.0), + screen("62:2", "관리자 - 대시보드", 1440.0), + screen("62:3", "Settings", 1440.0), + ]; + let plan = plan_screens_at(fixture.root(), &screens).await; + let chosen = default_item(&plan, "target-app"); + assert_eq!(chosen["mechanism"], "per-screen"); + assert!( + text(&chosen["decision"]).contains("no admin evidence go to apps/front"), + "{chosen}" + ); + let apps = rows_app(&plan).into_iter().collect::>(); + assert_eq!(apps["62:1"], "apps/front"); + assert_eq!(apps["62:2"], "apps/admin"); + assert_eq!(apps["62:3"], "apps/front"); + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("app:") || id == "site:app"), + "{:?}", + question_ids(&plan) + ); + let settings = find(&plan, "route:62:3"); + assert!( + text(&settings["options"][0]["description"]) + .contains("apps/front/src/app/settings/page.tsx"), + "{settings}" + ); + // Home is offered nothing from the admin app; the dashboard is offered + // the admin app's own pages. + for question in array(&plan["questions"]) { + let about_home = text(&question["id"]).ends_with("62:1"); + let mentions_admin = question["options"].to_string().contains("apps/admin/"); + assert!(!(about_home && mentions_admin), "{question}"); + } + assert!( + find(&plan, "route:62:2")["options"] + .to_string() + .contains("apps/admin/src/app/page.tsx") + ); +} + +/// Frames that say nothing either way go to the public app: with no admin +/// evidence that is the default, recorded rather than asked. +#[tokio::test] +async fn frames_that_say_nothing_go_to_the_public_app_without_a_question() { + let fixture = template_fixture(); + let screens = [ + screen("63:1", "Frame 12", 1440.0), + screen("63:2", "Frame 13", 1440.0), + ]; + let plan = plan_screens_at(fixture.root(), &screens).await; + assert!(!question_ids(&plan).iter().any(|id| id == "site:app")); + let chosen = default_item(&plan, "target-app"); + assert_eq!(chosen["mechanism"], "decided"); + assert_eq!(chosen["decision"], "apps/front"); + assert!( + chosen["evidence"].to_string().contains("no admin evidence"), + "{chosen}" + ); + assert!(rows_app(&plan).iter().all(|(_, app)| app == "apps/front")); +} + +/// Only when no app looks like the public site is the app asked - and the +/// answer still decides where each route is written. +#[tokio::test] +async fn the_app_is_asked_only_when_no_app_is_the_public_site() { + let fixture = template_fixture(); + for (from, to) in [("apps/front", "apps/alpha"), ("apps/admin", "apps/beta")] { + std::fs::rename(fixture.0.join(from), fixture.0.join(to)).unwrap(); + } + fixture.write( + "apps/alpha/package.json", + r#"{"name": "alpha", "dependencies": {"next": "16.1.0"}}"#, + ); + fixture.write( + "apps/beta/package.json", + r#"{"name": "beta", "dependencies": {"next": "16.1.0"}}"#, + ); + let screens = [ + screen("63:1", "Frame 12", 1440.0), + screen("63:2", "Frame 13", 1440.0), + ]; + let plan = plan_screens_at(fixture.root(), &screens).await; + let which = find(&plan, "site:app"); + assert_eq!(labels(which).len(), 2, "{which}"); + + let draft_input = ScreenPlanInput { + action: "draft".into(), + project_root: Some(fixture.root().into()), + ..ScreenPlanInput::default() + }; + let drafted = draft(&draft_input, &screens, json!({}), &fixture.policy()) + .await + .unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); + let mut pairs = vec![("site:app", "apps/beta")]; + for question in array(&drafted["questions"]) { + let id = text(&question["id"]); + if id.starts_with("route:") { + pairs.push(( + id, + if id.ends_with("63:1") { + "/members" + } else { + "/orders" + }, + )); + } + } + let owned = pairs + .iter() + .map(|(id, answer)| (id.to_string(), answer.to_string())) + .collect::>(); + confirm_input.answers = answers( + &owned + .iter() + .map(|(id, answer)| (id.as_str(), answer.as_str())) + .collect::>(), + ); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let files = array(&confirmed["routes"]) + .iter() + .map(|row| (text(&row["app"]).to_owned(), text(&row["file"]).to_owned())) + .collect::>(); + assert_eq!( + files[0], + ( + "apps/beta".to_owned(), + "apps/beta/src/app/members/page.tsx".to_owned() + ), + "{files:?}" + ); +} + +/// auditPlan writes one runtime-audit plan per confirmed URL and the runner +/// once, records them, and reports the frames it has no export for instead +/// of skipping them. +#[tokio::test] +async fn audit_plan_writes_a_plan_per_url_and_reports_what_it_cannot_compare() { + let fixture = Fixture::new(); + write_plan_file(&fixture, &confirmed_plan()); + let store = ArtifactStore::default(); + let mut request = input(&fixture, "auditPlan"); + request.artifacts = vec!["expired-artifact".into()]; + let result = audit_plan(&request, &fixture.policy(), &store) + .await + .unwrap(); + let plans = array(&result["plans"]); + assert_eq!( + plans + .iter() + .map(|plan| text(&plan["url"])) + .collect::>(), + ["/", "/about"] + ); + assert!( + fixture + .0 + .join(".devup/runtime-audit/home/plan.json") + .exists() + ); + assert!( + fixture + .0 + .join(".devup/runtime-audit/about/plan.json") + .exists() + ); + assert!( + fixture + .0 + .join(".devup/runtime-audit/runner/run.mjs") + .exists() + ); + assert_eq!(result["runner"]["state"], "written"); + // The plugin that strips the audit ids from production builds sits next + // to the app's vite.config, and the config is not wired to it yet. + let strip = &result["productionStrip"][0]; + assert!( + fixture + .0 + .join("vite-plugin-strip-figma-node-ids.mjs") + .exists() + ); + assert_eq!(strip["written"], true); + assert_eq!(strip["wired"], false); + assert!(text(&strip["verify"]).contains("grep -rl data-figma-node-id")); + assert_eq!( + result["expiredArtifacts"][0]["artifactId"], + "expired-artifact" + ); + assert_eq!(result["maxFixAttempts"], 3); + let frames = array(&result["missingReferences"]) + .iter() + .filter(|item| item["kind"] == "frame") + .map(|item| { + ( + text(&item["nodeId"]).to_owned(), + item["width"].as_u64().unwrap(), + ) + }) + .collect::>(); + assert_eq!( + frames, + [ + ("10:1".to_owned(), 1440), + ("10:2".to_owned(), 390), + ("10:3".to_owned(), 1440) + ] + ); + let home = &plans[0]; + assert_eq!(home["checks"]["view-transition"], 1); + assert_eq!(home["checks"]["page-health"], 1); + let written = read_plan(fixture.root()).unwrap().unwrap(); + assert_eq!(array(&written["audit"]["plans"]).len(), 2); + assert!(text(&written["audit"]["plans"][0]["reportPath"]).ends_with("home/report/report.json")); + + // The runner is written once; a second run leaves it alone. + let again = audit_plan(&request, &fixture.policy(), &store) + .await + .unwrap(); + assert_eq!(again["runner"]["state"], "current"); +} + +/// A decision mid-run never stops the work: it is pending, its URL's other +/// work comes first, and assume takes it at the end - except a destructive +/// one, which stays unbuilt and is reported. +#[tokio::test] +async fn a_pending_decision_is_taken_at_the_end_and_a_destructive_one_is_left_unbuilt() { + let fixture = Fixture::new(); + write_plan_file(&fixture, &confirmed_plan()); + let mut request = input(&fixture, "defer"); + request.decision = Some(PendingDecisionInput { + id: "about-form".into(), + about: "/about contact form".into(), + question: "Where does the contact form post?".into(), + options: vec!["mailto".into(), "an API route".into()], + recommended: "mailto".into(), + destructive: false, + }); + let deferred = defer(&request, &fixture.policy()).unwrap(); + assert_eq!(deferred["pendingDecisions"][0]["status"], "open"); + request.decision = Some(PendingDecisionInput { + id: "drop-old-page".into(), + about: "src/app/old/page.tsx".into(), + question: "Delete the old page?".into(), + options: Vec::new(), + recommended: "keep it".into(), + destructive: true, + }); + defer(&request, &fixture.policy()).unwrap(); + + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let report = status_report(&plan, &evidence(), None); + let about = array(&report["workQueue"]) + .iter() + .find(|row| row["url"] == "/about") + .unwrap() + .clone(); + let order = array(&about["items"]) + .iter() + .map(|item| text(&item["do"]).to_owned()) + .collect::>(); + assert_eq!( + order.last().map(String::as_str), + Some("blocked"), + "{order:?}" + ); + + let mut take = input(&fixture, "assume"); + take.assumptions = vec![ + AssumptionInput { + about: "about-form".into(), + decision: "mailto".into(), + reason: Some("recommended".into()), + }, + AssumptionInput { + about: "drop-old-page".into(), + decision: "delete".into(), + reason: None, + }, + ]; + let taken = assume(&take, &fixture.policy()).unwrap(); + let decisions = array(&taken["pendingDecisions"]); + assert_eq!(decisions[0]["status"], "taken"); + assert_eq!(decisions[0]["taken"], "mailto"); + assert_eq!(decisions[1]["status"], "left-unbuilt"); + let plan = read_plan(fixture.root()).unwrap().unwrap(); + let report = status_report(&plan, &evidence(), None); + let text_report = text(&report["finalReport"]); + assert!( + text_report + .contains("about-form: Where does the contact form post? -> mailto (taken at the end)"), + "{text_report}" + ); + assert!( + text_report + .contains("drop-old-page: Delete the old page? -> keep it (destructive: left unbuilt)") + ); + assert!( + text_report + .contains("| URL | width | health | geometry | drift / converter bugs | page | section | asset | interaction |") + ); + assert!(text_report.contains("Dependencies: the update step has not been recorded.")); +} diff --git a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs new file mode 100644 index 00000000..a08c111d --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs @@ -0,0 +1,870 @@ +use super::*; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct Fixture(std::path::PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "screen-plan-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).unwrap(); + let fixture = Self(dunce::canonicalize(&root).unwrap()); + fixture.write("package.json", "{}"); + fixture.write(CONFIG_FILE, SITE_CONFIG); + fixture.write( + "src/app/layout.tsx", + "import { Header } from '../components/Header';\nexport default function RootLayout({ children }) { return
{children}; }", + ); + fixture.write( + "src/components/Header.tsx", + "import Link from 'next/link';\nexport function Header() { return ; }", + ); + fixture.write( + "src/app/page.tsx", + "export default function Page() { return
; }", + ); + fixture.write( + "src/app/company/page.tsx", + "export default function Page() { return
; }", + ); + fixture + } + + fn write(&self, path: &str, text: &str) { + let target = self.0.join(path); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(target, text).unwrap(); + } + + fn root(&self) -> &str { + self.0.to_str().unwrap() + } + + fn policy(&self) -> OutputPolicy { + OutputPolicy::from_roots(vec![self.0.clone()]).unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn screen(node_id: &str, name: &str, width: f64) -> PlanScreenInput { + PlanScreenInput { + node_id: node_id.into(), + name: name.into(), + width, + height: Some(900.0), + ..PlanScreenInput::default() + } +} + +fn evidence() -> ProjectEvidence { + ProjectEvidence { + routes: vec![ + ("/".into(), "src/app/page.tsx".into()), + ("/company".into(), "src/app/company/page.tsx".into()), + ("/news/[id]".into(), "src/app/news/[id]/page.tsx".into()), + ], + nav_links: vec![( + "회사소개".into(), + "/company".into(), + "src/components/Header.tsx:2".into(), + )], + layout_parts: vec!["Header".into()], + app_dir: "src/app".into(), + // The site questions are pre-answered here so each test below is + // about its own questions; `site_*` tests start from bare evidence. + config: answered_site(), + ..ProjectEvidence::default() + } +} + +fn answered_site() -> PlanConfig { + PlanConfig { + favicon: Some("app/icon.svg".into()), + og_image: Some("public/og.png".into()), + site_name: Some("Prime".into()), + site_url: Some("https://example.com".into()), + motion: Some("none".into()), + ..PlanConfig::default() + } +} + +/// The site config the fixtures write, matching [`answered_site`]. +const SITE_CONFIG: &str = "{\"favicon\": \"app/icon.svg\", \"ogImage\": \"public/og.png\", \"siteName\": \"Prime\", \"siteUrl\": \"https://example.com\", \"motion\": \"none\"}"; + +fn sectioned(node_id: &str, name: &str, width: f64, section: &str) -> PlanScreenInput { + PlanScreenInput { + section: Some(format!("section-{section}")), + section_name: Some(section.into()), + ..screen(node_id, name, width) + } +} + +fn find<'a>(plan: &'a Value, id: &str) -> &'a Value { + array(&plan["questions"]) + .iter() + .find(|question| question["id"] == id) + .unwrap_or_else(|| panic!("{id} is asked: {}", plan["questions"])) +} + +fn first_label(question: &Value) -> &str { + text(&question["options"][0]["label"]) +} + +fn question_ids(plan: &Value) -> Vec { + array(&plan["questions"]) + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect() +} + +fn input(fixture: &Fixture, action: &str) -> ScreenPlanInput { + ScreenPlanInput { + action: action.into(), + project_root: Some(fixture.root().into()), + ..ScreenPlanInput::default() + } +} + +/// The route the user never saw being chosen is the failure this tool exists +/// for, so a screen nothing names gets no route and a question instead. +#[test] +fn an_unverified_route_is_null_and_becomes_a_question() { + let plan = draft_plan( + &[screen("1:2", "Pricing", 1440.0)], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let row = &plan["screens"][0]; + assert_eq!(row["proposedRoute"], Value::Null); + assert_eq!(row["routeEvidence"], "UNVERIFIED"); + let question = array(&plan["questions"]) + .iter() + .find(|question| question["id"] == "route:1:2") + .expect("an unverified route is asked"); + let labels = array(&question["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert_eq!(labels[0], "/pricing (Recommended)"); + assert!(labels.contains(&SKIP)); +} + +/// Each kind of evidence proposes the route on its own, and a screen it +/// proposes is not asked about its route. +#[test] +fn an_evidence_backed_route_is_proposed_without_a_route_question() { + let targets = BTreeMap::from([("2:1".to_owned(), "/contact?from=nav".to_owned())]); + let plan = draft_plan( + &[ + screen("2:1", "Contact", 1440.0), + screen("2:2", "회사소개", 1440.0), + screen("2:3", "Home", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows[0]["proposedRoute"], "/contact"); + assert_eq!(rows[0]["routeEvidence"], "navigationTargets[2:1]"); + assert_eq!(rows[1]["proposedRoute"], "/company"); + assert!(text(&rows[1]["routeEvidence"]).starts_with("nav-label")); + assert_eq!(rows[2]["proposedRoute"], "/"); + // Home -> / is a guess from the layer name, so it is shown and put to + // the user in one compact question rather than decided silently. + assert_eq!(rows[2]["routeEvidence"], NAME_MATCH); + assert_eq!(plan["heuristics"][0]["screen"], "2:3"); + let ids = question_ids(&plan); + assert!(ids.contains(&"heuristics".to_owned()), "{ids:?}"); + assert!(ids.contains(&"review:2:3".to_owned()), "{ids:?}"); + assert_eq!( + first_label(find(&plan, "heuristics")), + "Accept all (Recommended)" + ); + assert!( + !ids.iter() + .any(|id| id == "route:2:1" || id.ends_with(":2:1")), + "{ids:?}" + ); + assert!(!ids.iter().any(|id| id.starts_with("route:")), "{ids:?}"); + // An existing page is a different question: what building over it does. + assert!(ids.iter().any(|id| id == "collision:2:2")); + assert_eq!(rows[1]["existingFile"], "src/app/company/page.tsx"); + assert_eq!(rows[0]["sharedParts"], json!(["Header"])); +} + +/// Two screens the evidence puts on one URL leave neither decided. +#[test] +fn two_screens_on_one_evidence_route_are_both_asked() { + let targets = BTreeMap::from([ + ("3:1".to_owned(), "/about".to_owned()), + ("3:2".to_owned(), "/about".to_owned()), + ]); + let plan = draft_plan( + &[ + screen("3:1", "About A", 1440.0), + screen("3:2", "About B", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let ids = question_ids(&plan); + assert!(ids.contains(&"route:3:1".to_owned()) && ids.contains(&"route:3:2".to_owned())); +} + +#[test] +fn widths_in_one_section_recommend_one_page_and_modals_are_asked() { + let plan = draft_plan( + &[ + screen("4:1", "Main - 1440", 1440.0), + screen("4:2", "Main - 390", 390.0), + screen("4:3", "Login Modal", 480.0), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); + assert_eq!(rows[0]["proposedRoute"], "/"); + assert_eq!(rows[0]["grouping"]["recommended"], "single"); + let group = find(&plan, "group:4:1"); + assert_eq!(first_label(group), "One responsive page (Recommended)"); + // Separate pages need their own routes, asked in the same up-front batch + // and only if separate is chosen. No URL is made up for the 390 frame. + let split = find(&plan, "split-route:4:2"); + assert_eq!(split["when"][0]["question"], "group:4:1"); + assert_eq!(split["when"][0]["answer"], "Separate pages"); + assert!(!plan.to_string().contains("/390")); + let modal = find(&plan, "modal:4:3"); + assert!(first_label(modal).starts_with("State on Main")); + assert_eq!(rows[1]["kindEvidence"], NAME_MATCH); + assert!(text(&modal["question"]).contains("name-match")); +} + +/// Every question must be accepted as-is by a structured question tool. +#[test] +fn every_question_has_the_question_tool_shape() { + let mut linked = screen("5:1", "Gallery", 1440.0); + linked.links = vec![PlanLinkInput { + label: "More".into(), + destination_node_id: Some("9:9".into()), + url: None, + }]; + linked.parts = vec!["Footer".into()]; + let mut other = screen("5:2", "News", 1440.0); + other.parts = vec!["Footer".into()]; + let targets = BTreeMap::from([ + ("5:3".to_owned(), "/news/[id]".to_owned()), + ("5:7".to_owned(), "/".to_owned()), + ]); + let plan = draft_plan( + &[ + linked, + other, + screen("5:3", "News detail", 1440.0), + screen("5:4", "공지 팝업", 400.0), + screen("5:5", "Company - 1440", 1440.0), + screen("5:6", "Company - 390", 390.0), + screen("5:7", "Landing", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let questions = ask_shape(array(&plan["questions"])); + let ids = questions + .iter() + .map(|question| text(&question["id"])) + .collect::>(); + assert_eq!(ids.len(), questions.len(), "question ids are unique"); + for kind in [ + "route:", + "link:", + "layout:", + "dynamic:", + "modal:", + "group:", + "split-route:", + "heuristics", + "review:", + "collision:", + ] { + assert!( + ids.iter().any(|id| id.starts_with(kind)), + "{kind} missing from {ids:?}" + ); + } + for question in &questions { + let header = text(&question["header"]); + assert!( + !header.is_empty() && header.chars().count() <= HEADER_LIMIT, + "{header}" + ); + assert!(!text(&question["question"]).is_empty()); + assert_eq!(question["multiple"], false); + assert!( + question.get("resolve").is_none(), + "resolution stays in the plan file" + ); + let options = array(&question["options"]); + assert!((2..=MAX_OPTIONS).contains(&options.len()), "{question}"); + assert!( + text(&options[0]["label"]).ends_with(RECOMMENDED), + "{question}" + ); + for option in &options[1..] { + assert!( + !text(&option["label"]).contains("(Recommended)"), + "{question}" + ); + } + assert!( + options + .iter() + .all(|option| !text(&option["description"]).is_empty()) + ); + for condition in array(&question["when"]) { + assert!(condition.get("action").is_none(), "{question}"); + assert!(ids.contains(text(&condition["question"])), "{question}"); + } + } + assert_rounds_cover(&plan); +} + +/// Every question in exactly one round, rounds within the tool's limits, and +/// a conditional question always after the question it depends on. +fn assert_rounds_cover(plan: &Value) { + let questions = array(&plan["questions"]); + let rounds = question_rounds(questions, MAX_QUESTIONS_PER_ROUND); + let mut seen = Vec::new(); + let mut round_of = BTreeMap::new(); + for (index, round) in rounds.iter().enumerate() { + assert_eq!(round["round"], index + 1); + let asked = array(&round["questions"]); + assert!( + (1..=MAX_QUESTIONS_PER_ROUND).contains(&asked.len()), + "{round}" + ); + for question in asked { + assert!(array(&question["options"]).len() <= MAX_OPTIONS); + let id = text(&question["id"]).to_owned(); + round_of.insert(id.clone(), index); + seen.push(id); + } + } + let mut expected = questions + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect::>(); + let mut got = seen.clone(); + expected.sort(); + got.sort(); + assert_eq!(got, expected, "every question exactly once"); + for question in questions { + for condition in array(&question["when"]) { + assert!( + round_of[text(&condition["question"])] < round_of[text(&question["id"])], + "{} must come after {}", + question["id"], + condition["question"] + ); + } + } +} + +#[test] +fn rounds_split_many_questions_into_fours() { + let screens = (0..11) + .map(|index| screen(&format!("8:{index}"), &format!("Page{index}"), 1440.0)) + .collect::>(); + let plan = draft_plan(&screens, &BTreeMap::new(), &evidence(), json!({}), None).unwrap(); + let rounds = question_rounds(array(&plan["questions"]), MAX_QUESTIONS_PER_ROUND); + assert_eq!(rounds.len(), 3); + assert_rounds_cover(&plan); + // A runtime with a larger cap gets fewer, larger rounds of the same + // questions; one with no cap asks the flat list. + let wide = question_rounds(array(&plan["questions"]), 10); + assert_eq!(wide.len(), 2); + assert_eq!(array(&wide[0]["questions"]).len(), 10); + assert_eq!(question_rounds(array(&plan["questions"]), 50).len(), 1); +} + +/// Frames of one name in different sections are never resolved here: the +/// question states the sections, frames, widths and ids, and offers a Figma +/// fix. Separate pages are recommended unless every section names a device. +#[test] +fn sections_are_asked_explicitly_with_a_fix_option() { + let plan = draft_plan( + &[ + sectioned("9:1", "About", 1440.0, "Company"), + sectioned("9:2", "About", 390.0, "Recruit"), + sectioned("9:3", "Pricing", 1440.0, "PC"), + sectioned("9:4", "Pricing", 390.0, "Mobile"), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let company = find(&plan, "sections:9:1"); + assert_eq!( + first_label(company), + "Separate pages: the sections are different pages (Recommended)" + ); + let device = find(&plan, "sections:9:3"); + assert_eq!( + first_label(device), + "One responsive page: the sections only split by device (Recommended)" + ); + let prompt = text(&device["question"]); + for fact in ["PC", "Mobile", "9:3", "9:4", "1440px", "390px", "Pricing"] { + assert!(prompt.contains(fact), "{fact} missing from {prompt}"); + } + assert!( + text(&device["options"][0]["description"]) + .contains("Inferred from the section names (PC, Mobile)") + ); + let labels = array(&device["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert!(labels.contains(&FIX_FIGMA), "{labels:?}"); + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("group:")) + ); + let split = find(&plan, "split-route:9:2"); + assert!( + array(&split["options"]) + .iter() + .any(|option| text(&option["label"]).starts_with("/recruit/about")), + "{split}" + ); + assert_rounds_cover(&plan); +} + +#[test] +fn project_config_counts_as_evidence_and_suppresses_its_questions() { + let mut evidence = evidence(); + evidence.config = PlanConfig { + modal_keywords: Some(vec!["sheet".into()]), + route_aliases: BTreeMap::from([("요금제".to_owned(), "/pricing".to_owned())]), + width_grouping: Some("responsive".into()), + ..answered_site() + }; + let plan = draft_plan( + &[ + screen("10:1", "요금제 - 1440", 1440.0), + screen("10:2", "요금제 - 390", 390.0), + screen("10:3", "Share sheet", 400.0), + screen("10:4", "Login popup", 400.0), + ], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); + assert_eq!(rows[0]["grouping"]["evidence"], "config:widthGrouping"); + assert_eq!(rows[0]["proposedRoute"], "/pricing"); + assert!(text(&rows[0]["routeEvidence"]).contains("routeAliases")); + assert_eq!(rows[1]["kind"], "modal"); + assert_eq!(rows[1]["kindEvidence"], "config"); + // The project's keywords replace the built-in ones. + assert_eq!(rows[2]["kind"], "page"); + let ids = question_ids(&plan); + assert!( + !ids.iter().any(|id| id.starts_with("group:") + || id.starts_with("route:10:1") + || id == "heuristics"), + "{ids:?}" + ); +} + +#[test] +fn a_frame_id_already_in_a_page_is_evidence() { + let mut evidence = evidence(); + evidence.figma_nodes.insert( + "11:1".into(), + ("/company".into(), "src/app/company/page.tsx".into()), + ); + let plan = draft_plan( + &[screen("11:1", "Intro", 1440.0)], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + assert_eq!(plan["screens"][0]["proposedRoute"], "/company"); + assert!(text(&plan["screens"][0]["routeEvidence"]).contains("data-figma-node-id")); + assert_eq!(question_ids(&plan), ["collision:11:1"]); + assert_eq!( + figma_node_ids(""), + ["1:2", "3:4"] + ); +} + +#[tokio::test] +async fn confirm_rejects_missing_answers_by_id_and_accepts_a_complete_set() { + let fixture = Fixture::new(); + let mut draft_input = input(&fixture, "draft"); + draft_input.navigation_targets = BTreeMap::from([("6:3".to_owned(), "/contact".to_owned())]); + let drafted = draft( + &draft_input, + &[ + screen("6:1", "Pricing", 1440.0), + screen("6:2", "Careers", 1440.0), + screen("6:3", "Contact", 1440.0), + ], + json!({"from": "caller"}), + &fixture.policy(), + ) + .await + .unwrap(); + assert_eq!(drafted["askInstruction"], ASK_INSTRUCTION); + assert!(fixture.0.join(".devup/screen-plan.json").is_file()); + assert!(fixture.0.join(".devup/screen-plan.md").is_file()); + let plan_id = text(&drafted["planId"]).to_owned(); + + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = Some(plan_id.clone()); + confirm_input.answers = BTreeMap::from([( + "route:6:1".to_owned(), + AnswerInput::One("/pricing (Recommended)".into()), + )]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["route:6:2"])); + assert!(error.message.contains("route:6:2")); + assert_eq!( + read_plan(fixture.root()).unwrap().unwrap()["status"], + "draft" + ); + + confirm_input + .answers + .insert("route:6:2".into(), AnswerInput::One("/jobs".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| row["url"].clone()) + .collect::>(); + assert_eq!(urls, [json!("/pricing"), json!("/jobs"), json!("/contact")]); + assert_eq!(confirmed["customAnswers"][0]["answer"], "/jobs"); + let saved = read_plan(fixture.root()).unwrap().unwrap(); + assert_eq!(saved["status"], "confirmed"); + let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); + assert!(markdown.contains("| /jobs | Careers |"), "{markdown}"); +} + +#[tokio::test] +async fn a_custom_route_answer_must_be_a_route() { + let fixture = Fixture::new(); + let drafted = draft( + &input(&fixture, "draft"), + &[screen("7:1", "Pricing", 1440.0)], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); + confirm_input.answers = BTreeMap::from([( + "route:7:1".to_owned(), + AnswerInput::One("the pricing page".into()), + )]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert!(error.message.contains("custom answer must be an app route")); +} + +fn confirmed_plan() -> Value { + json!({ + "kind": PLAN_KIND, "schemaVersion": SCHEMA_VERSION, "planId": "sp-test", "status": "confirmed", + "source": {"nodeId": "10:0"}, + "routes": [ + {"url": "/", "screen": "10:1", "name": "Main", "nodeIds": ["10:1", "10:2"], "drawnWidths": [1440, 390], "implementation": "page", "file": "src/app/page.tsx"}, + {"url": "/about", "screen": "10:3", "name": "About", "nodeIds": ["10:3"], "drawnWidths": [1440], "implementation": "page", "file": "src/app/about/page.tsx"}, + {"url": null, "screen": "10:4", "name": "Login modal", "nodeIds": ["10:4"], "drawnWidths": [480], "implementation": "state", "hostUrl": "/"}, + ], + }) +} + +/// A width the audit has no item for is not-run, and a URL with a failing +/// width is never counted as verified. +#[test] +fn status_marks_widths_without_an_audit_item_not_run() { + let audit = json!({ + "verdict": "fail", + "widthTable": [{"width": 1440}, {"width": 390}], + "screenTable": [ + {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "screenshot": "1440x900@1-main.png"}, + {"screen": "about", "routePath": "/about", "width": 1440, "status": "pass", "reasons": []}, + {"screen": "about", "routePath": "/about/", "width": 390, "status": "fail", "reasons": ["horizontal overflow: 42px past the viewport"]}, + ], + }); + let report = status_report(&confirmed_plan(), &evidence(), Some(&audit)); + let rows = array(&report["rows"]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["implemented"], "yes"); + assert_eq!(rows[1]["implemented"], "no"); + assert_eq!(rows[0]["widths"][0]["result"], "pass"); + assert_eq!(rows[0]["widths"][1]["result"], "not-run"); + assert_eq!(rows[1]["widths"][1]["result"], "fail"); + assert!( + text(&report["summary"]).starts_with( + "2 URLs: 1 implemented, 0 verified at all widths, /about overflows at 390" + ), + "{}", + report["summary"] + ); + assert_eq!(report["states"][0]["hostUrl"], "/"); + assert!(text(&report["markdownTable"]).contains("1440x900@1-main.png")); + + let without = status_report(&confirmed_plan(), &evidence(), None); + assert!( + array(&without["rows"]) + .iter() + .flat_map(|row| array(&row["widths"])) + .all(|width| width["result"] == "not-run") + ); + assert!(text(&without["summary"]).contains("every width is not-run")); +} + +fn write_plan_file(fixture: &Fixture, plan: &Value) { + fixture.write(PLAN_FILE, &serde_json::to_string(plan).unwrap()); +} + +fn export<'a>(fixture: &'a Fixture, frames: &[&str]) -> ExportRequest<'a> { + ExportRequest { + project_root: fixture.root(), + url: None, + frames: frames.iter().map(|frame| (*frame).to_owned()).collect(), + all_screens: false, + scaffold_write: false, + scaffold_route: None, + output_paths: Vec::new(), + } +} + +#[test] +fn the_export_gate_blocks_page_writes_without_a_confirmed_plan() { + let fixture = Fixture::new(); + let mut scaffold = export(&fixture, &["10:3"]); + scaffold.scaffold_write = true; + scaffold.scaffold_route = Some("/about"); + let Gate::Blocked(response) = export_gate(&scaffold).unwrap() else { + panic!("a pageScaffold write without a plan is blocked"); + }; + assert_eq!(response["status"], "plan_required"); + assert_eq!(response["nextAction"]["tool"], "devup_screen_plan"); + assert_eq!(response["nextAction"]["arguments"]["action"], "draft"); + + let mut app_path = export(&fixture, &["10:3"]); + app_path.output_paths = vec!["src/app/about/page.tsx"]; + assert!(matches!(export_gate(&app_path).unwrap(), Gate::Blocked(_))); + + let mut several = export(&fixture, &["10:1", "10:3"]); + several.output_paths = vec!["src/components/Screens.tsx"]; + assert!(matches!(export_gate(&several).unwrap(), Gate::Blocked(_))); + + let mut draft_only = confirmed_plan(); + draft_only["status"] = json!("draft"); + write_plan_file(&fixture, &draft_only); + assert!(matches!(export_gate(&scaffold).unwrap(), Gate::Blocked(_))); +} + +#[test] +fn the_export_gate_never_blocks_a_component_or_a_preview() { + let fixture = Fixture::new(); + let mut component = export(&fixture, &["10:3"]); + component.output_paths = vec!["src/components/Card.tsx"]; + assert!(matches!(export_gate(&component).unwrap(), Gate::Open)); + let preview = export(&fixture, &["10:1", "10:3"]); + assert!(matches!(export_gate(&preview).unwrap(), Gate::Open)); + let mut scaffold_preview = export(&fixture, &["10:3"]); + scaffold_preview.scaffold_route = Some("/about"); + assert!(matches!( + export_gate(&scaffold_preview).unwrap(), + Gate::Open + )); +} + +#[test] +fn a_confirmed_plan_supplies_the_scaffold_route_and_refuses_another() { + let fixture = Fixture::new(); + write_plan_file(&fixture, &confirmed_plan()); + let mut scaffold = export(&fixture, &["10:2"]); + scaffold.scaffold_write = true; + scaffold.scaffold_route = Some(""); + let Gate::Route(route) = export_gate(&scaffold).unwrap() else { + panic!("the plan covers 10:2"); + }; + assert_eq!(route, "/"); + scaffold.scaffold_route = Some("/home"); + let error = export_gate(&scaffold) + .err() + .expect("a different route is a mismatch"); + assert_eq!(error.details["routeMismatch"]["planned"], "/"); + + let mut uncovered = export(&fixture, &["99:1"]); + uncovered.scaffold_write = true; + uncovered.scaffold_route = Some("/x"); + let Gate::Blocked(response) = export_gate(&uncovered).unwrap() else { + panic!("a frame the plan does not place is blocked"); + }; + assert_eq!( + response["planRequired"]["details"]["uncoveredFrames"], + json!(["99:1"]) + ); + + let mut several = export(&fixture, &["10:1", "10:3"]); + several.output_paths = vec!["src/components/Screens.tsx"]; + assert!(matches!(export_gate(&several).unwrap(), Gate::Open)); +} + +#[tokio::test] +async fn project_evidence_reads_routes_layout_parts_and_nav_links() { + let fixture = Fixture::new(); + let evidence = project_evidence(fixture.root()).await.unwrap(); + let routes = evidence + .routes + .iter() + .map(|(route, _)| route.as_str()) + .collect::>(); + assert!( + routes.contains(&"/") && routes.contains(&"/company"), + "{routes:?}" + ); + assert_eq!(evidence.app_dir, "src/app"); + assert!(evidence.layout_parts.contains(&"Header".to_owned())); + assert!( + evidence + .nav_links + .iter() + .any(|(label, href, _)| label == "회사소개" && href == "/company"), + "{:?}", + evidence.nav_links + ); +} + +fn answers(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(id, answer)| ((*id).to_owned(), AnswerInput::One((*answer).to_owned()))) + .collect() +} + +async fn drafted(fixture: &Fixture, screens: &[PlanScreenInput]) -> ScreenPlanInput { + let plan = draft( + &input(fixture, "draft"), + screens, + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(fixture, "confirm"); + confirm_input.plan_id = plan["planId"].as_str().map(str::to_owned); + confirm_input +} + +fn main_widths() -> [PlanScreenInput; 2] { + [ + screen("12:1", "Main - 1440", 1440.0), + screen("12:2", "Main - 390", 390.0), + ] +} + +/// Separate pages bring their own route questions; the answers decide the +/// URLs, and nothing is derived from a width. +#[tokio::test] +async fn separate_pages_need_a_route_per_frame_and_get_exactly_those() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "Separate pages"), + ("split-route:12:1", "/"), + ("heuristics", "Accept all (Recommended)"), + ]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["split-route:12:2"])); + assert!(error.details["questionRounds"][0]["questions"][0]["id"] == "split-route:12:2"); + + confirm_input + .answers + .insert("split-route:12:2".into(), AnswerInput::One("/m".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| (row["url"].clone(), row["nodeIds"].clone())) + .collect::>(); + assert_eq!( + urls, + [ + (json!("/"), json!(["12:1"])), + (json!("/m"), json!(["12:2"])) + ] + ); +} + +#[tokio::test] +async fn one_responsive_page_skips_the_split_routes_and_accepts_the_inferred_route() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "One responsive page (Recommended)"), + ("heuristics", "Accept all (Recommended)"), + ]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let route = &confirmed["routes"][0]; + assert_eq!(route["url"], "/"); + assert_eq!(route["nodeIds"], json!(["12:1", "12:2"])); + assert_eq!(route["collisionPolicy"], "merge"); + assert_eq!(route["routeEvidence"], "name-match (accepted)"); +} + +#[tokio::test] +async fn reviewing_heuristics_asks_each_one_in_the_same_batch() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "One responsive page (Recommended)"), + ("heuristics", "Review individually"), + ]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["review:12:1"])); + confirm_input + .answers + .insert("review:12:1".into(), AnswerInput::One("/home".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!(confirmed["routes"][0]["url"], "/home"); +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/screenshot_observations.rs b/crates/devup-mcp/src/server/screenshot_observations.rs index 47c7c93c..5faf0265 100644 --- a/crates/devup-mcp/src/server/screenshot_observations.rs +++ b/crates/devup-mcp/src/server/screenshot_observations.rs @@ -87,7 +87,7 @@ pub fn rank(result: &mut Value, hints: Option<&ScreenshotObservations>) { *candidates = ranked.into_iter().map(|(_, candidate)| candidate).collect(); result["screenshotGuidance"] = json!({"source":"caller-declared-screenshot-observations","observations":hints, "rankingBasis":"literal captured names/text previews and geometric aspect ratio; stable original order breaks ties", - "evidenceLimit":"No screenshot pixels or visual features were inspected. Text previews can be partial; matching observations only nominates real candidates and never verifies or automatically exports a selection."}); + "evidenceLimit":include_str!("text/screenshot_observations-rank-2c5a4662.md")}); } #[cfg(test)] diff --git a/crates/devup-mcp/src/server/section_checklist.rs b/crates/devup-mcp/src/server/section_checklist.rs index d703b9dc..844a3c7a 100644 --- a/crates/devup-mcp/src/server/section_checklist.rs +++ b/crates/devup-mcp/src/server/section_checklist.rs @@ -73,7 +73,7 @@ pub(super) fn checklist(payload: &CollectedPayload, roots: &[String]) -> Value { } json!({"sections":records,"totalSections":sections.len(),"sectionsTruncated":sections.len()>20, "scope":"selected captured roots with at most 100 descendants examined per root","implementationState":"unverified", - "evidenceLimit":"These are review prompts from actual captured fields. No screenshot was interpreted, browser was rendered, design intent was inferred or missing source image resolution was verified."}) + "evidenceLimit":include_str!("text/section_checklist-checklist-500ab77e.md")}) } #[cfg(test)] diff --git a/crates/devup-mcp/src/server/self_update.rs b/crates/devup-mcp/src/server/self_update.rs index 9b3f87e1..f0509c45 100644 --- a/crates/devup-mcp/src/server/self_update.rs +++ b/crates/devup-mcp/src/server/self_update.rs @@ -102,7 +102,7 @@ impl Ownership { fn note(self) -> &'static str { match self { Self::Owned => { - "A newer release is downloaded in the background, proved by running --self-check on it, and renamed into place at the next start. This process is never replaced under its own stdio pipes." + include_str!("text/self_update-note-02a038b0.md") } Self::Disabled => { "Staging is off (DEVUP_MCP_NO_AUTO_UPDATE). The release check still reports a difference." @@ -423,11 +423,7 @@ pub fn report() -> Value { // say 0.9.0-dev while the binary inside it reports 0.10.1 after a // self-update replaced the file in place. That is the mechanism // working, not a mismatch, and it reads like one. - "installedPathNote": "The path of the file, not a statement about the version. A \ - directory named after a version is the host's, fixed when it \ - installed; self-update replaces the file inside it and never \ - renames the directory. Read server.commit/buildId for what is \ - actually running.", + "installedPathNote": include_str!("text/self_update-report-28b64040.md"), "note": ownership.note(), "disableWith": DISABLE_ENV, }) diff --git a/crates/devup-mcp/src/server/skills.rs b/crates/devup-mcp/src/server/skills.rs index 23b30309..086081c1 100644 --- a/crates/devup-mcp/src/server/skills.rs +++ b/crates/devup-mcp/src/server/skills.rs @@ -46,2558 +46,7 @@ //! compromised registry entry into arbitrary execution on every machine that //! ever exported a screen. -use std::path::{Path, PathBuf}; -use std::sync::LazyLock; - -use crate::skills::{RawGithubSkillUpstream, SkillFetchError, SkillProvenance, SkillUpstream}; -use serde::Deserialize; - -const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); -const OFFLINE_ENV: &str = "DEVUP_MCP_SKILLS_OFFLINE"; - -// Read once per process. Client construction and fetching happen only on an -// explicit install, never while constructing a server or running self-check. -static OFFLINE: LazyLock = LazyLock::new(|| { - std::env::var(OFFLINE_ENV).is_ok_and(|value| { - !matches!( - value.trim().to_ascii_lowercase().as_str(), - "" | "0" | "false" - ) - }) -}); - -struct InstallUpstream; - -#[async_trait::async_trait] -impl SkillUpstream for InstallUpstream { - async fn fetch( - &self, - source_url: &str, - ) -> Result { - if *OFFLINE { - return Err(SkillFetchError::Network(format!( - "{OFFLINE_ENV} disables fetching" - ))); - } - static UPSTREAM: LazyLock> = LazyLock::new(|| { - RawGithubSkillUpstream::with_timeout(FETCH_TIMEOUT).map_err(|error| error.to_string()) - }); - UPSTREAM - .as_ref() - .map_err(|error| SkillFetchError::Network(error.clone()))? - .fetch(source_url) - .await - } -} - -pub const MIME_TYPE: &str = "text/markdown"; - -/// The registry. Parsed rather than duplicated into consts so that refreshing a -/// skill touches one file a script can write, instead of a JSON file and a Rust -/// literal that can disagree about which commit is in the binary. -pub const MANIFEST_JSON: &str = include_str!("skills/manifest.json"); - -/// Skill name to its documents, for the origins that carry text. `include_str!` -/// needs a literal path, so this is the one place the set is spelled out; -/// [`tests::every_origin_is_well_formed`] holds it to the manifest in both -/// directions. -/// -/// The inner key is the document's path relative to the skill directory, and it -/// is deliberately the same string on both sides: `references/theme-colors.md` -/// is read from `skills/devfive-frontend/references/theme-colors.md` here and -/// written to `/devfive-frontend/references/theme-colors.md` on install. -/// A skill whose list is one `SKILL.md` is the single-file case, not a -/// different one. -type Documents = &'static [(&'static str, &'static str)]; - -const EMBEDDED: &[(&str, Documents)] = &[ - ( - "devup-ui", - &[("SKILL.md", include_str!("skills/devup-ui/SKILL.md"))], - ), - ( - "devfive-frontend", - &[ - ("SKILL.md", include_str!("skills/devfive-frontend/SKILL.md")), - ( - "references/critical-rules.md", - include_str!("skills/devfive-frontend/references/critical-rules.md"), - ), - ( - "references/anti-patterns.md", - include_str!("skills/devfive-frontend/references/anti-patterns.md"), - ), - ( - "references/common-patterns.md", - include_str!("skills/devfive-frontend/references/common-patterns.md"), - ), - ( - "references/theme-colors.md", - include_str!("skills/devfive-frontend/references/theme-colors.md"), - ), - ], - ), - ( - "devup-implementation-plan", - &[( - "SKILL.md", - include_str!("skills/devup-implementation-plan/SKILL.md"), - )], - ), - ( - "devup", - &[("SKILL.md", include_str!("skills/devup/SKILL.md"))], - ), - ( - "changepacks", - &[("SKILL.md", include_str!("skills/changepacks/SKILL.md"))], - ), - ( - "vespera", - &[("SKILL.md", include_str!("skills/vespera/SKILL.md"))], - ), - ( - "vespertide", - &[("SKILL.md", include_str!("skills/vespertide/SKILL.md"))], - ), -]; - -/// The document every skill loader opens first. A skill is installed when this -/// file is on disk; the rest are what it links to. -pub const ENTRY_DOCUMENT: &str = "SKILL.md"; - -/// How each provenance note opens. Named so the writer and the reader cannot -/// drift apart: [`Skill::provenance_note`] builds them and -/// [`Skill::freshness_at`] recognises a file by them. -const FETCHED_MARKER: &str = "", - FETCHED_MARKER, - p.source_url, - p.fetched_at, - p.etag.replace("--", "--"), - p.sha256, - r.used_for, - ); - } - if r.origin == Origin::Own { - return format!( - "{marker}{repo}, at {path}.\n \ - Part of this devup-mcp build - there is no separate upstream for it to fall \ - behind.\n \ - Current revision: {latest}\n \ - Why this matters here: {used} -->", - marker = AUTHORED_MARKER, - repo = r.repo, - path = r.path, - latest = r.latest_url, - used = r.used_for, - ); - } - format!( - "{marker}{repo}/{path} at {commit} ({at}).\n \ - Embedded in this devup-mcp build; no network was used to read it.\n \ - This revision: {source}\n \ - Newer revisions, if any: {latest}\n \ - Why this matters here: {used} -->", - marker = VENDORED_MARKER, - repo = r.repo, - path = r.path, - commit = r.commit.as_deref().unwrap_or("unknown"), - at = r.committed_at.as_deref().unwrap_or("unknown"), - source = r.source_url, - latest = r.latest_url, - used = r.used_for, - ) - } - - /// The one action that closes the gap, in the imperative, with everything - /// needed to carry it out. - /// The freshness of the worst copy this runtime would load, or `None` when - /// there is no copy at all. - /// - /// The worst one decides. A machine with a current copy in one root and a - /// stale copy in another is loading both, and the stale one is the one - /// that will be wrong. - pub fn installed_freshness(&self, lookup: &Lookup) -> Option { - lookup - .installed_paths(&self.record.name) - .iter() - .map(|path| self.freshness_at(path)) - .max_by_key(|state| match state { - Freshness::Current => 0, - Freshness::Older => 1, - Freshness::Incomplete => 2, - Freshness::Foreign => 3, - }) - } - - pub fn install_action(&self, lookup: &Lookup) -> serde_json::Value { - let installed = lookup.installed_paths(&self.record.name); - if !installed.is_empty() { - let paths = installed - .iter() - .map(|path| path.display().to_string()) - .collect::>(); - let revision = self - .installed_freshness(lookup) - .unwrap_or(Freshness::Current); - if revision.should_refresh() { - return serde_json::json!({ - "installed": true, - "paths": paths, - "revision": revision.as_str(), - "action": "devup_skills", - "arguments": {"action": "install", "names": [self.record.name]}, - "why": if revision == Freshness::Incomplete { - "The SKILL.md on disk is this build's, but a document it links to is \ - missing or has different bytes. It looks installed and its links go \ - nowhere." - } else { - "The copy on disk was written by a different devup-mcp build. It still \ - loads, and it describes conventions this binary no longer emits." - }, - "how": "Call devup_skills with action \"install\". A copy devup-mcp wrote is \ - rewritten in place; nothing else is touched.", - }); - } - return serde_json::json!({ - "installed": true, - "paths": paths, - "revision": revision.as_str(), - "action": null, - "how": if revision == Freshness::Foreign { - "Installed, but this document is not one devup-mcp wrote - no provenance \ - note. It is left exactly as it is, because replacing it would destroy work \ - this server did not do. Delete it first if you want devup-mcp's copy." - } else { - "Already installed and current. Your skill loader picks it up by its own \ - triggers; nothing to do." - }, - }); - } - match self.record.origin { - Origin::Embedded | Origin::Own => { - let roots = lookup.existing_roots(); - let targets = lookup.target_roots(); - let writes = targets - .iter() - .flat_map(|target| { - self.record.documents.iter().map(move |document| { - document_path(target, &self.record.name, &document.path) - .display() - .to_string() - }) - }) - .collect::>(); - serde_json::json!({ - "installed": false, - "paths": [], - "action": "devup_skills", - "arguments": {"action": "install", "names": [self.record.name]}, - "writesTo": writes, - "how": "Call devup_skills with action \"install\". Embedded skills prefer current upstream documents with offline fallback; own skills use the binary. Then load it the way your runtime loads \ - a project skill.", - "runtime": lookup.runtime.as_str(), - "rootChoice": if !roots.is_empty() { - format!("Using the existing root {}.", targets[0].display()) - } else if lookup.runtime == Runtime::Unknown { - "No skill root exists yet and the MCP client did not identify itself, \ - so every known convention is written. One of them is the one your \ - runtime reads." - .to_owned() - } else { - format!( - "No skill root exists yet, so {} is created - the convention {} \ - loads from.", - targets[0].display(), - lookup.runtime.as_str() - ) - }, - }) - } - Origin::External => serde_json::json!({ - "installed": false, - "paths": [], - "action": "run-this-yourself", - "command": self.record.install_command, - "how": "devup-mcp does not vendor or run this. Run the command yourself, then load \ - the skill the way your runtime loads an installed skill.", - "whyNotBundled": self.record.license_note, - "source": self.record.source_url, - }), - } - } -} - -/// The explicit entry point: `/devup [figma url] [notes]`. -/// -/// An MCP server cannot start a skill or a slash command itself, so the user -/// gets one to type. Claude Code turns every project skill into a slash -/// command of the same name, so the `devup` skill installed with the others -/// already is `/devup` there. OpenCode keeps slash commands in their own -/// directory, so it gets a command file as well. Every other runtime is -/// reported unsupported rather than given a path nobody verified. -pub const COMMAND_NAME: &str = "devup"; -pub const COMMAND_INVOCATION: &str = "/devup [figma url] [notes]"; -/// Marks a command file devup-mcp wrote, so a later install may rewrite it -/// and leaves anyone else's alone. -const COMMAND_MARKER: &str = "", + FETCHED_MARKER, + p.source_url, + p.fetched_at, + p.etag.replace("--", "--"), + p.sha256, + r.used_for, + ); + } + if r.origin == Origin::Own { + return format!( + include_str!("../text/skills-provenance_note-ca003bce.md"), + marker = AUTHORED_MARKER, + repo = r.repo, + path = r.path, + latest = r.latest_url, + used = r.used_for, + ); + } + format!( + include_str!("../text/skills-provenance_note-fa8096ea.md"), + marker = VENDORED_MARKER, + repo = r.repo, + path = r.path, + commit = r.commit.as_deref().unwrap_or("unknown"), + at = r.committed_at.as_deref().unwrap_or("unknown"), + source = r.source_url, + latest = r.latest_url, + used = r.used_for, + ) + } + + /// The one action that closes the gap, in the imperative, with everything + /// needed to carry it out. + /// The freshness of the worst copy this runtime would load, or `None` when + /// there is no copy at all. + /// + /// The worst one decides. A machine with a current copy in one root and a + /// stale copy in another is loading both, and the stale one is the one + /// that will be wrong. + pub fn installed_freshness(&self, lookup: &Lookup) -> Option { + lookup + .installed_paths(&self.record.name) + .iter() + .map(|path| self.freshness_at(path)) + .max_by_key(|state| match state { + Freshness::Current => 0, + Freshness::Older => 1, + Freshness::Incomplete => 2, + Freshness::Foreign => 3, + }) + } + + pub fn install_action(&self, lookup: &Lookup) -> serde_json::Value { + let installed = lookup.installed_paths(&self.record.name); + if !installed.is_empty() { + let paths = installed + .iter() + .map(|path| path.display().to_string()) + .collect::>(); + let revision = self + .installed_freshness(lookup) + .unwrap_or(Freshness::Current); + if revision.should_refresh() { + return serde_json::json!({ + "installed": true, + "paths": paths, + "revision": revision.as_str(), + "action": "devup_skills", + "arguments": {"action": "install", "names": [self.record.name]}, + "why": if revision == Freshness::Incomplete { + include_str!("../text/skills-install_action-510f4ba8.md") + } else { + "The copy on disk was written by a different devup-mcp build. It still \ + loads, and it describes conventions this binary no longer emits." + }, + "how": "Call devup_skills with action \"install\". A copy devup-mcp wrote is \ + rewritten in place; nothing else is touched.", + }); + } + return serde_json::json!({ + "installed": true, + "paths": paths, + "revision": revision.as_str(), + "action": null, + "how": if revision == Freshness::Foreign { + include_str!("../text/skills-install_action-50ca51e1.md") + } else { + "Already installed and current. Your skill loader picks it up by its own \ + triggers; nothing to do." + }, + }); + } + match self.record.origin { + Origin::Embedded | Origin::Own => { + let roots = lookup.existing_roots(); + let targets = lookup.target_roots(); + let writes = targets + .iter() + .flat_map(|target| { + self.record.documents.iter().map(move |document| { + document_path(target, &self.record.name, &document.path) + .display() + .to_string() + }) + }) + .collect::>(); + serde_json::json!({ + "installed": false, + "paths": [], + "action": "devup_skills", + "arguments": {"action": "install", "names": [self.record.name]}, + "writesTo": writes, + "how": include_str!("../text/skills-install_action-e5981815.md"), + "runtime": lookup.runtime.as_str(), + "rootChoice": if !roots.is_empty() { + format!("Using the existing root {}.", targets[0].display()) + } else if lookup.runtime == Runtime::Unknown { + include_str!("../text/skills-install_action-13d23996.md") + .to_owned() + } else { + format!( + "No skill root exists yet, so {} is created - the convention {} \ + loads from.", + targets[0].display(), + lookup.runtime.as_str() + ) + }, + }) + } + Origin::External => serde_json::json!({ + "installed": false, + "paths": [], + "action": "run-this-yourself", + "command": self.record.install_command, + "how": "devup-mcp does not vendor or run this. Run the command yourself, then load \ + the skill the way your runtime loads an installed skill.", + "whyNotBundled": self.record.license_note, + "source": self.record.source_url, + }), + } + } +} + +/// The explicit entry point: `/devup [figma url] [notes]`. +/// +/// An MCP server cannot start a skill or a slash command itself, so the user +/// gets one to type. Claude Code turns every project skill into a slash +/// command of the same name, so the `devup` skill installed with the others +/// already is `/devup` there. OpenCode keeps slash commands in their own +/// directory, so it gets a command file as well. Every other runtime is +/// reported unsupported rather than given a path nobody verified. +pub const COMMAND_NAME: &str = "devup"; +pub const COMMAND_INVOCATION: &str = "/devup [figma url] [notes]"; +/// Marks a command file devup-mcp wrote, so a later install may rewrite it +/// and leaves anyone else's alone. +const COMMAND_MARKER: &str = " \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-provenance_note-fa8096ea.md b/crates/devup-mcp/src/server/text/skills-provenance_note-fa8096ea.md new file mode 100644 index 00000000..bfd12b5a --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-provenance_note-fa8096ea.md @@ -0,0 +1,5 @@ +{marker}{repo}/{path} at {commit} ({at}). + Embedded in this devup-mcp build; no network was used to read it. + This revision: {source} + Newer revisions, if any: {latest} + Why this matters here: {used} --> \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-report-071c67f6.md b/crates/devup-mcp/src/server/text/skills-report-071c67f6.md new file mode 100644 index 00000000..f5a80768 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-report-071c67f6.md @@ -0,0 +1 @@ +Runtimes do not read the same directory. Installing into one this runtime never opens reports success and still never loads, which is the failure this field exists to make visible. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-report-1a0ab5f8.md b/crates/devup-mcp/src/server/text/skills-report-1a0ab5f8.md new file mode 100644 index 00000000..2eeb2071 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-report-1a0ab5f8.md @@ -0,0 +1 @@ +Authored in devup-mcp itself, so there is no upstream revision to pin and nothing for it to fall behind. It moves when this binary does. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-report-2e1e5387.md b/crates/devup-mcp/src/server/text/skills-report-2e1e5387.md new file mode 100644 index 00000000..bf1832ea --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-report-2e1e5387.md @@ -0,0 +1 @@ +devup-mcp installs only carried skills: embedded skills prefer current upstream documents with offline fallback, and own skills use the binary. External skills are never fetched or written; their install command is yours to run. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-report-3e1e10ee.md b/crates/devup-mcp/src/server/text/skills-report-3e1e10ee.md new file mode 100644 index 00000000..03627cc6 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-report-3e1e10ee.md @@ -0,0 +1 @@ +`known` is every root this runtime *reads*, so an install someone else made is found rather than duplicated. `writeConventions` is the narrower list an install may create, and `wouldUse` is what this call would actually write. For an unidentified client the first is every convention and the second is the three broad ones, because writing a copy into each of a dozen editor directories is worse than the problem it solves. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-report-55e539f1.md b/crates/devup-mcp/src/server/text/skills-report-55e539f1.md new file mode 100644 index 00000000..45800823 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-report-55e539f1.md @@ -0,0 +1 @@ +It looks identical to a client devup-mcp does know whose name has since changed. If `clientName` above is a client with its own skill directory, that directory is missing from devup-mcp's table and reporting the name is how it gets added. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-report-d3cac7a1.md b/crates/devup-mcp/src/server/text/skills-report-d3cac7a1.md new file mode 100644 index 00000000..1367cdd6 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-report-d3cac7a1.md @@ -0,0 +1 @@ +Installed, loading, and not what this build writes. `installed` means the file is there; `installState.revision` says whether it is current, and a skill listed here is teaching an agent rules this binary no longer emits. Installing again rewrites devup-mcp's own copies and leaves a `foreign` one - a document with no devup-mcp provenance note - alone. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-report-d4500f82.md b/crates/devup-mcp/src/server/text/skills-report-d4500f82.md new file mode 100644 index 00000000..f09ca407 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-report-d4500f82.md @@ -0,0 +1 @@ +These are the conventions for the code devup-mcp emits and reads. Install the missing ones, then let your own skill loader surface them - an installed skill keeps applying for every later session, which is the thing reading a document once does not do. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-report-eb1c9bcc.md b/crates/devup-mcp/src/server/text/skills-report-eb1c9bcc.md new file mode 100644 index 00000000..89692b8b --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-report-eb1c9bcc.md @@ -0,0 +1 @@ +Machine-wide roots are read to decide `installed`, never written: devup-mcp writes only inside an allowed output root. A skill found in one of these is already loaded by its runtime and needs nothing. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/stack_diff-attach_source_ownership-0113946e.md b/crates/devup-mcp/src/server/text/stack_diff-attach_source_ownership-0113946e.md new file mode 100644 index 00000000..032dec7f --- /dev/null +++ b/crates/devup-mcp/src/server/text/stack_diff-attach_source_ownership-0113946e.md @@ -0,0 +1 @@ +Review the human-authored frontend call and upstream route handlers or devup tags. Correct the call or route declaration, rebuild so Vespera regenerates openapi.json, then regenerate the @devup-api client from that spec. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/stack_diff-attach_source_ownership-758751d4.md b/crates/devup-mcp/src/server/text/stack_diff-attach_source_ownership-758751d4.md new file mode 100644 index 00000000..e4cefe76 --- /dev/null +++ b/crates/devup-mcp/src/server/text/stack_diff-attach_source_ownership-758751d4.md @@ -0,0 +1 @@ +Review the human-authored route shapes and whether the column is intentionally internal. Edit route handlers for API exposure; for database changes, edit models/*.json and run vespertide revision/export, then rebuild the routes and regenerate the client. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/stack_diff-openapi_client_layer-a74a750f.md b/crates/devup-mcp/src/server/text/stack_diff-openapi_client_layer-a74a750f.md new file mode 100644 index 00000000..624d3727 --- /dev/null +++ b/crates/devup-mcp/src/server/text/stack_diff-openapi_client_layer-a74a750f.md @@ -0,0 +1 @@ +{spelling_only_matches} call(s) name an endpoint that openapi.json has under a different spelling and were matched rather than reported. openapi.json carries Vespera's kebab-case paths, parameters included (`/order/{{order-number}}`), while the generated @devup-api client and the code calling it use the camelCase parameter name (`/order/{{orderNumber}}`). Case and the `_`/`-` separators are folded; the path's `/` and `{{}}` structure still has to agree exactly. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-215d8b4d.md b/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-215d8b4d.md new file mode 100644 index 00000000..6d146d65 --- /dev/null +++ b/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-215d8b4d.md @@ -0,0 +1 @@ +These route declarations depend on cfg/cfg_attr configuration and may be compiled out. They are excluded from definite route counts and missing-from-spec findings; inspect the build configuration before rebuilding the generated spec. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-3c2d71f8.md b/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-3c2d71f8.md new file mode 100644 index 00000000..0dd6dc1a --- /dev/null +++ b/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-3c2d71f8.md @@ -0,0 +1 @@ +{spelling_only_matches} route(s) exist on both sides in different spellings and were matched rather than reported. Vespera writes a handler's URL into openapi.json in kebab-case — including inside {{...}} parameters — and gives a module-root route a trailing slash, while this scan reads the file name and path attribute verbatim. Only `_` vs `-` and a trailing slash are folded; every other difference is still reported as drift. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-4d0cfa16.md b/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-4d0cfa16.md new file mode 100644 index 00000000..df470501 --- /dev/null +++ b/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-4d0cfa16.md @@ -0,0 +1 @@ +Could not resolve all route declarations in this file. These legacy text-scan candidates are unverified, may include comments or unrelated strings, and are excluded from declared route counts; review the source and build configuration before rebuilding the generated spec. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-70549f41.md b/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-70549f41.md new file mode 100644 index 00000000..26f6db98 --- /dev/null +++ b/crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-70549f41.md @@ -0,0 +1 @@ +These routes are on both drift lists in different spellings, which means the two sides write the same route differently — not that a route is missing. Treat them as unverified rather than as drift, and report the spelling difference instead. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/units-split-bf50d8a5.md b/crates/devup-mcp/src/server/text/units-split-bf50d8a5.md new file mode 100644 index 00000000..e8cdcdbd --- /dev/null +++ b/crates/devup-mcp/src/server/text/units-split-bf50d8a5.md @@ -0,0 +1,29 @@ +# Work unit {} + +Scope: {} + +Dependencies: {} + +Question owner: {}. Workers never ask the user in supervisor mode. Escalate new decisions via Orca orchestration ask/reply; supervisor uses recorded decisions or defers the recommended option while other work continues. + +## Recorded decisions and source facts +```json +{} +``` + +## ERD slice (shared entities retained for cross-feature FKs) +```mermaid +{} +``` + +## Screen-plan slice (full confirmed routing context) +```json +{} +``` + +## Acceptance and verification +```json +{} +``` + +Supervisor reruns verification before merging; return failures to this worker session. diff --git a/crates/devup-mcp/src/server/text/validation-parse_asset_requests-eecb2af1.md b/crates/devup-mcp/src/server/text/validation-parse_asset_requests-eecb2af1.md new file mode 100644 index 00000000..0358cd40 --- /dev/null +++ b/crates/devup-mcp/src/server/text/validation-parse_asset_requests-eecb2af1.md @@ -0,0 +1 @@ +renderMode fill requires scale 1: PNG/JPG preserve the matching original codec and bytes; WebP explicitly transcodes PNG/JPEG source without resizing. Use renderMode node for other codecs or visible crop/effects. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/validation-validate_artifact_projection-e8e39fca.md b/crates/devup-mcp/src/server/text/validation-validate_artifact_projection-e8e39fca.md new file mode 100644 index 00000000..0676331f --- /dev/null +++ b/crates/devup-mcp/src/server/text/validation-validate_artifact_projection-e8e39fca.md @@ -0,0 +1 @@ +This artifact cannot serve these outputs without calling Figma again. Repeat the call with this url instead of artifactId, keeping any assetRequests and outputPaths you sent; the new acquisition captures what is missing. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/validation-validate_asset_budget-915c1c5f.md b/crates/devup-mcp/src/server/text/validation-validate_asset_budget-915c1c5f.md new file mode 100644 index 00000000..ca8efdb5 --- /dev/null +++ b/crates/devup-mcp/src/server/text/validation-validate_asset_budget-915c1c5f.md @@ -0,0 +1 @@ +Export exceeds the asset batch budget. Split assetRequests into calls of 1–3 assets (maximum 6); retain each format, scale and outputPath. Poll or resume an existing assetJob instead of restarting it. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/validation-validate_asset_render_outputs-12b6f4a5.md b/crates/devup-mcp/src/server/text/validation-validate_asset_render_outputs-12b6f4a5.md new file mode 100644 index 00000000..9ffc2768 --- /dev/null +++ b/crates/devup-mcp/src/server/text/validation-validate_asset_render_outputs-12b6f4a5.md @@ -0,0 +1 @@ +Visible ancestor captures require assetManifest-only outputs; export nodeId for that parent intentionally before generating parent code. A parent bitmap cannot replace a leaf at leaf dimensions. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/validation-validate_export_budget-986a2783.md b/crates/devup-mcp/src/server/text/validation-validate_export_budget-986a2783.md new file mode 100644 index 00000000..60919f7d --- /dev/null +++ b/crates/devup-mcp/src/server/text/validation-validate_export_budget-986a2783.md @@ -0,0 +1 @@ +Export exceeds the batch budget for a 300-second client timeout. Split frameIds into smaller calls; use 1–3 frames per call and reuse artifactId when its capture covers the selected frames. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/validation-validate_outputs-166587f3.md b/crates/devup-mcp/src/server/text/validation-validate_outputs-166587f3.md new file mode 100644 index 00000000..7c5654c9 --- /dev/null +++ b/crates/devup-mcp/src/server/text/validation-validate_outputs-166587f3.md @@ -0,0 +1 @@ +{output} is the collected design in raw form, for deciding whether a screen that looks wrong is the generator's fault or the design's. It is not needed to implement anything - the tsx already carries what it carries - and requesting it by habit is most of the response. Pass debug: true to read it. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/validation_guidance-guidance-d8ac2119.md b/crates/devup-mcp/src/server/text/validation_guidance-guidance-d8ac2119.md new file mode 100644 index 00000000..98d4767c --- /dev/null +++ b/crates/devup-mcp/src/server/text/validation_guidance-guidance-d8ac2119.md @@ -0,0 +1 @@ +Edit the source using the blocking violations' byteRange, rule and suggestion, then validate again with the same projectRoot and strict setting. Repeating unchanged arguments cannot resolve these findings. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/validation.rs b/crates/devup-mcp/src/server/validation.rs index a4242235..19feaeb3 100644 --- a/crates/devup-mcp/src/server/validation.rs +++ b/crates/devup-mcp/src/server/validation.rs @@ -173,7 +173,7 @@ pub(super) fn validate_artifact_projection( "tool": "devup_figma_export", "arguments": arguments, "omitArguments": ["artifactId"], - "how": "This artifact cannot serve these outputs without calling Figma again. Repeat the call with this url instead of artifactId, keeping any assetRequests and outputPaths you sent; the new acquisition captures what is missing.", + "how": include_str!("text/validation-validate_artifact_projection-e8e39fca.md"), }, }), )) @@ -228,11 +228,8 @@ pub(super) fn validate_outputs(outputs: &[String], debug: bool) -> Result<(), De return Err(DevupError::with_details( ErrorCode::DevupInvalidInput, format!( - "{output} is the collected design in raw form, for deciding whether a \ - screen that looks wrong is the generator's fault or the design's. It is \ - not needed to implement anything - the tsx already carries what it \ - carries - and requesting it by habit is most of the response. Pass \ - debug: true to read it." + include_str!("text/validation-validate_outputs-166587f3.md"), + output = output ), false, json!({"stage":"preflight","nextAction":{"tool":"devup_figma_export", @@ -251,7 +248,7 @@ pub(super) fn validate_asset_budget(requests: &[FigmaAssetRequestInput]) -> Resu if requests.len() > MAX_ASSET_COUNT { return Err(DevupError::with_details( ErrorCode::DevupInvalidInput, - "Export exceeds the asset batch budget. Split assetRequests into calls of 1–3 assets (maximum 6); retain each format, scale and outputPath. Poll or resume an existing assetJob instead of restarting it.", + include_str!("text/validation-validate_asset_budget-915c1c5f.md"), false, json!({"requestedAssetCount":requests.len(),"maxAssetCount":MAX_ASSET_COUNT, "recommendedBatchSize":RECOMMENDED_ASSET_COUNT, @@ -345,7 +342,7 @@ pub(super) fn parse_asset_requests( { return Err(DevupError::new( ErrorCode::DevupInvalidInput, - "renderMode fill requires scale 1: PNG/JPG preserve the matching original codec and bytes; WebP explicitly transcodes PNG/JPEG source without resizing. Use renderMode node for other codecs or visible crop/effects.", + include_str!("text/validation-parse_asset_requests-eecb2af1.md"), false, )); } @@ -421,7 +418,7 @@ pub(super) fn validate_asset_render_outputs( { return Err(DevupError::new( ErrorCode::DevupInvalidInput, - "Visible ancestor captures require assetManifest-only outputs; export nodeId for that parent intentionally before generating parent code. A parent bitmap cannot replace a leaf at leaf dimensions.", + include_str!("text/validation-validate_asset_render_outputs-12b6f4a5.md"), false, )); } @@ -581,7 +578,7 @@ pub(super) fn validate_export_budget( let recommended = batch_size.min(3); return Err(DevupError::with_details( ErrorCode::DevupInvalidInput, - "Export exceeds the batch budget for a 300-second client timeout. Split frameIds into smaller calls; use 1–3 frames per call and reuse artifactId when its capture covers the selected frames.", + include_str!("text/validation-validate_export_budget-986a2783.md"), false, json!({"requestedFrameCount":frame_ids.len(),"outputCount":output_count, "frameOutputUnits":frame_ids.len().saturating_mul(output_count), diff --git a/crates/devup-mcp/src/server/validation_guidance.rs b/crates/devup-mcp/src/server/validation_guidance.rs index c4dadcba..390bbb98 100644 --- a/crates/devup-mcp/src/server/validation_guidance.rs +++ b/crates/devup-mcp/src/server/validation_guidance.rs @@ -37,7 +37,7 @@ pub(super) fn guidance( "recoveryState":"manual-fix-required", "recoveryReason":format!("Automatic source correction is unavailable. {}. This does not mean the source is unfixable or that the tool failed to execute.", reasons.into_iter().collect::>().join("; ")), "nextAction":null, - "nextActionReason":"Edit the source using the blocking violations' byteRange, rule and suggestion, then validate again with the same projectRoot and strict setting. Repeating unchanged arguments cannot resolve these findings." + "nextActionReason":include_str!("text/validation_guidance-guidance-d8ac2119.md") }) } diff --git a/crates/devup-mcp/tests/composite_export.rs b/crates/devup-mcp/tests/composite_export.rs index c30e4d45..31320323 100644 --- a/crates/devup-mcp/tests/composite_export.rs +++ b/crates/devup-mcp/tests/composite_export.rs @@ -1,1644 +1,3 @@ -mod common; - -use std::{ - fs, - path::PathBuf, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, - time::{SystemTime, UNIX_EPOCH}, -}; - -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, -}; -use rmcp::{ - ServiceExt, - model::{ - CallToolRequestParams, CallToolResult, ContentBlock, ReadResourceRequestParams, - ResourceContents, - }, -}; -use serde_json::{Map, Value, json}; - -#[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 FastFixtureUpstream { - calls: AtomicUsize, - partial: bool, - lossy: bool, -} - -impl FastFixtureUpstream { - fn complete() -> Self { - Self { - calls: AtomicUsize::new(0), - partial: false, - lossy: false, - } - } - - fn partial() -> Self { - Self { - calls: AtomicUsize::new(0), - partial: true, - lossy: false, - } - } - - fn lossy() -> Self { - Self { - calls: AtomicUsize::new(0), - partial: false, - lossy: true, - } - } -} - -#[async_trait] -impl FigmaUpstream for FastFixtureUpstream { - async fn list_tools(&self) -> Result, DevupError> { - Ok(vec!["get_screenshot".to_owned(), "use_figma".to_owned()]) - } - - async fn call_read_tool(&self, call: ReadToolCall) -> Result { - self.calls.fetch_add(1, Ordering::SeqCst); - match call { - ReadToolCall::Snapshot { - script: BuiltinScript::FastSnapshotEnvelope, - .. - } => Ok(fast_envelope_result(self.partial, self.lossy)), - ReadToolCall::AssetExport { - version, request, .. - } => Ok(asset_export_result(version.as_deref(), &request)), - ReadToolCall::Screenshot { .. } => Ok(UpstreamResult { - raw: json!({ - "content": [{ - "type": "image", - "mimeType": "image/png", - "data": reference_png_base64() - }] - }), - }), - _ => Err(DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "composite export must stay on the one-call fast path", - false, - )), - } - } -} - -#[tokio::test] -async fn reference_png_is_acquired_once_and_delivered_as_a_binary_resource() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::complete()); - 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 acquired = call( - &client, - "devup_figma_export", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs": ["referencePng"] - }), - ) - .await?; - - assert_eq!(acquired["collection"]["figmaToolCalls"], 2); - assert_eq!(acquired["referencePng"]["mimeType"], "image/png"); - assert_eq!( - acquired["referencePng"]["dataBase64"], - 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( - &client, - "devup_figma_export", - json!({ - "artifactId": artifact_id, - "outputs": ["referencePng"], - "delivery": "resource" - }), - ) - .await?; - let delivered = delivered_result - .structured_content - .as_ref() - .expect("structured compatibility summary"); - assert!(delivered.get("referencePng").is_none()); - assert_eq!(delivered["resources"][0]["mimeType"], "image/png"); - let manifest_uri = delivered["resources"][0]["uri"].as_str().unwrap(); - let link = delivered_result - .content - .iter() - .filter_map(ContentBlock::as_resource_link) - .find(|link| link.uri == manifest_uri) - .expect("native manifest resource link"); - assert_eq!(link.mime_type.as_deref(), Some("application/json")); - assert_eq!(link.size, None); - assert_eq!( - link.meta - .as_ref() - .and_then(|meta| meta.0.get("payloadMimeType")) - .and_then(Value::as_str), - Some("image/png") - ); - let wire = serde_json::to_value(&delivered_result)?; - assert!(wire["content"].as_array().is_some_and(|content| { - content - .iter() - .any(|block| block["type"] == "resource_link" && block["uri"] == manifest_uri) - })); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); - - client.cancel().await?; - task.await??; - Ok(()) -} - -async fn call( - client: &rmcp::service::RunningService, - name: &str, - arguments: Value, -) -> anyhow::Result { - Ok(call_result(client, name, arguments) - .await? - .structured_content - .expect("structured tool output")) -} - -async fn call_result( - client: &rmcp::service::RunningService, - name: &str, - arguments: Value, -) -> anyhow::Result { - let arguments: Map = arguments.as_object().cloned().unwrap(); - let result = client - .call_tool(CallToolRequestParams::new(name.to_owned()).with_arguments(arguments)) - .await?; - anyhow::ensure!( - result.is_error != Some(true), - "{}", - result - .structured_content - .as_ref() - .expect("structured error") - ); - Ok(result) -} - -/// The design in raw form is behind a door marked debug. -/// -/// `rawSnapshot` and `rawPayload` describe the design rather than the screen. -/// Nothing that implements a screen needs them, and left in the ordinary list -/// they were taken as a matter of course - the server's own instructions used -/// to say to take `rawSnapshot` every time, which on a measured screen spent -/// about eight bytes for every one of code. They stay reachable, because -/// deciding whether a screen that looks wrong is the generator's fault means -/// reading the design beside the code, and that is what `debug` is for. -#[tokio::test] -async fn the_collected_design_is_reachable_only_in_debug() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::complete()); - let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); - 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 url = "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2"; - - for output in ["rawSnapshot", "rawPayload"] { - let refused = call_result( - &client, - "devup_figma_export", - json!({ "url": url, "outputs": [output], "scope": "node" }), - ) - .await - .expect_err("the design in raw form is not part of implementing a screen"); - let message = refused.to_string(); - assert!( - message.contains("debug: true"), - "the refusal has to say which door to open: {message}" - ); - assert!( - message.contains(output), - "and which output it is about: {message}" - ); - } - - // Asking for it deliberately still works, and still costs what it costs. - let debugging = call( - &client, - "devup_figma_export", - json!({ "url": url, "outputs": ["tsx", "rawSnapshot"], "scope": "node", "debug": true }), - ) - .await?; - assert!(debugging["rawSnapshot"]["nodes"].is_object()); - assert!(debugging["tsx"].as_str().is_some_and(|tsx| !tsx.is_empty())); - - client.cancel().await?; - task.await??; - Ok(()) -} - -/// A clean conversion should carry the code and the grade, and not the -/// paperwork underneath the grade. -/// -/// `fidelity` and `completenessReport` are the drill-down under `quality`: -/// on a clean run the first reports 100% across six axes and the second six -/// empty arrays, so both only repeat what `quality` already said. Measured on -/// this fixture they were 797 bytes of the 1,911 every response carried. -/// They are still sent whenever they disagree with a clean grade, and -/// whenever the caller asks for diagnostics - which the test above covers. -#[tokio::test] -async fn a_clean_capture_reports_its_projection_shortfall_by_default() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::complete()); - let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); - 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 result = call( - &client, - "devup_figma_export", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs": ["tsx"], - "scope": "node" - }), - ) - .await?; - - assert_eq!(result["status"], "partial"); - assert!( - result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|issue| issue["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") - ); - assert_eq!(result["quality"]["projection"], "lossy"); - assert_eq!(result["quality"]["acquisition"], "complete"); - assert!(result["tsx"].as_str().unwrap().contains("$primary")); - // `completenessReport` is keyed on the capture, which is clean here. - assert!( - result.get("completenessReport").is_none(), - "a clean capture says nothing the grade has not already said" - ); - // R1: a clean acquisition does not make unclassified layout loss exact. - let fidelity = result - .get("fidelity") - .expect("a projection shortfall must be explained"); - assert_eq!(result["quality"]["projection"], "lossy"); - assert!( - fidelity["variables"]["basisPoints"].as_u64() < Some(10_000) - || fidelity["layout"]["basisPoints"].as_u64() < Some(10_000) - || fidelity["nodes"]["basisPoints"].as_u64() < Some(10_000) - || fidelity["text"]["basisPoints"].as_u64() < Some(10_000) - || fidelity["typography"]["basisPoints"].as_u64() < Some(10_000) - || fidelity["assets"]["basisPoints"].as_u64() < Some(10_000), - "it is sent because an axis is short: {fidelity}" - ); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn one_acquisition_projects_all_outputs_and_artifact_reuse_is_zero_call() -> anyhow::Result<()> -{ - let upstream = Arc::new(FastFixtureUpstream::complete()); - 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 url = "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2"; - - let first = call( - &client, - "devup_figma_export", - json!({ - "url": url, - "outputs": ["tsx", "devupJson", "rawSnapshot", "rawPayload", "sourceMap", "assetManifest"], - "debug": true, - "scope": "node", - "includeDiagnostics": true - }), - ) - .await?; - - assert_eq!(first["status"], "partial"); - assert!( - first["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|issue| issue["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") - ); - // The raw payload is the whole collection the snapshot came out of: the - // same nodes, plus the variables the token names are read from, and - // never the reference PNG, which has an output of its own. - assert_eq!(first["rawPayload"]["snapshot"], first["rawSnapshot"]); - assert!(first["rawPayload"]["variables"].is_object()); - assert!(first["rawPayload"].get("referencePng").is_none()); - assert_eq!(first["collection"]["figmaToolCalls"], 1); - assert_eq!(first["cache"]["cacheHit"], false); - assert!(first["cache"]["artifactId"].as_str().is_some()); - assert!(first["tsx"].as_str().unwrap().contains("$primary")); - assert_eq!(first["status"], "partial"); - assert!( - first["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|issue| issue["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") - ); - assert_eq!(first["quality"]["projection"], "lossy"); - // This call asks for diagnostics, so the drill-down under `quality` is - // exactly what it should get. - assert!(first["fidelity"].is_object()); - assert!(first["completenessReport"].is_object()); - // The code is available for inspection but is not a final exact output. - assert_eq!(first["deliverable"]["isFinal"], false); - assert!( - !first["deliverable"]["availableOutputs"] - .as_array() - .unwrap() - .is_empty() - ); - // These restate something already in the response whether diagnostics - // were asked for or not: `imports` and `usedTokens` restate the tsx's own - // import line and its `$token`s. Neither is sent any more. - for restated in ["imports", "usedTokens", "componentImports"] { - assert!( - first.get(restated).is_none(), - "{restated} restates something already in the response and must not be sent" - ); - } - assert!(first["devupJson"].as_str().unwrap().contains("\"primary\"")); - assert_eq!(first["rawSnapshot"]["roots"], json!(["1:2"])); - assert_eq!(first["sourceMap"]["version"], 2); - // Both pictures the generated code points at: the child drawn from its - // own image fill, and the root's second fill, which the code paints as a - // background. A container is a layout box rather than an asset, but the - // picture on it still has to be listed or nothing could deliver it. - assert_eq!( - asset_ids(&first["assetManifest"]), - vec!["1:2:fills:1", "1:3:fills:0"] - ); - assert_eq!( - asset_by_id(&first["assetManifest"], "1:3:fills:0")["status"], - "available" - ); - assert!(first["sourceMap"]["tsx"].as_array().is_some_and(|entries| { - entries.iter().any(|entry| { - entry["nodeId"] == "1:2" && entry["property"] == "fills" && entry["variableId"] == "v" - }) - })); - assert!( - first["sourceMap"]["devupJson"] - .as_array() - .is_some_and(|entries| entries.iter().any(|entry| { - entry["jsonPointer"] == "/theme/colors/default/primary" - && entry["variableId"] == "v" - })) - ); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); - - let artifact_id = first["cache"]["artifactId"].as_str().unwrap(); - let projected = call( - &client, - "devup_figma_export", - json!({ - "artifactId": artifact_id, - "outputs": ["tsx"], - "rootLayout": "embedded" - }), - ) - .await?; - assert_eq!(projected["cache"]["cacheHit"], true); - assert_eq!(projected["cache"]["artifactId"], artifact_id); - assert!(projected.get("devupJson").is_none()); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); - - let resource = call( - &client, - "devup_figma_export", - json!({ - "artifactId": artifact_id, - "outputs": ["tsx"], - "delivery": "resource" - }), - ) - .await?; - assert!(resource.get("tsx").is_none()); - let manifest_uri = resource["resources"][0]["uri"].as_str().unwrap(); - let manifest = client - .read_resource(ReadResourceRequestParams::new(manifest_uri)) - .await?; - let ResourceContents::TextResourceContents { text, .. } = &manifest.contents[0] else { - panic!("resource manifest must be text") - }; - let manifest: Value = serde_json::from_str(text)?; - assert_eq!(manifest["name"], "tsx"); - assert_eq!(manifest["mimeType"], "text/typescript"); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); - - // The same acquisition answers a tsx-only and a theme-only projection - // without going back to Figma, which is what devup_figma_to_ui and - // devup_figma_to_json used to be for before they were folded into this - // one tool as `outputs`. - let ui_wrapper = call( - &client, - "devup_figma_export", - json!({"url": url, "outputs": ["tsx"]}), - ) - .await?; - let json_wrapper = call( - &client, - "devup_figma_export", - json!({"url": url, "outputs": ["devupJson"], "scope": "node"}), - ) - .await?; - assert_eq!(ui_wrapper["cache"]["cacheHit"], true); - assert_eq!(json_wrapper["cache"]["cacheHit"], true); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); - - let refreshed = call( - &client, - "devup_figma_export", - json!({ - "url": url, - "outputs": ["rawSnapshot"], - "debug": true, - "scope": "node", - "refresh": true - }), - ) - .await?; - assert_eq!(refreshed["cache"]["cacheHit"], false); - assert_ne!(refreshed["cache"]["artifactId"], artifact_id); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn artifact_reuse_rejects_file_theme_beyond_captured_scope() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::complete()); - let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); - 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 acquired = call( - &client, - "devup_figma_export", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs": ["tsx"], - "scope": "node" - }), - ) - .await?; - let artifact_id = acquired["cache"]["artifactId"].as_str().unwrap(); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); - - let incompatible = client - .call_tool( - CallToolRequestParams::new("devup_figma_export").with_arguments( - json!({ - "artifactId": artifact_id, - "outputs": ["devupJson"], - "scope": "file" - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await; - - let error = incompatible - .map(common::tool_error) - .expect("tool result must report failure"); - assert!( - error.to_string().contains("DEVUP_FIGMA_HANDOFF_INVALID"), - "unexpected capability error: {error}" - ); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn explicit_asset_request_exports_once_and_returns_validated_binary() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::complete()); - 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 result = call( - &client, - "devup_figma_export", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs": ["tsx", "assetManifest"], - "assetRequests": [{"assetId":"1:3:fills:0","renderMode":"node","format":"png","scale":2}] - }), - ) - .await?; - - assert_eq!(result["status"], "partial"); - assert!( - result["projectionIssues"] - .as_array() - .unwrap() - .iter() - .any(|issue| issue["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") - ); - assert_eq!(result["collection"]["figmaToolCalls"], 2); - let exported = asset_by_id(&result["assetManifest"], "1:3:fills:0"); - assert_eq!(exported["status"], "exported"); - assert_eq!(exported["dataBase64"], STANDARD.encode(b"synthetic-png")); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::complete()); - let root = unique_temp_dir("asset-resource")?; - let output_path = root.join("asset.png"); - let server = DevupServer::with_output_roots( - Services::new(Arc::new(ConnectedAuth), upstream.clone()), - vec![root.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 result = call_result( - &client, - "devup_figma_export", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs": ["assetManifest"], - "delivery": "resource", - "assetRequests": [{ - "assetId":"1:3:fills:0","renderMode":"node", - "format":"png", - "scale":2, - "outputPath": output_path.to_string_lossy() - }] - }), - ) - .await?; - let structured = result - .structured_content - .as_ref() - .expect("structured compatibility summary"); - let summaries = structured["resources"].as_array().unwrap(); - assert_eq!( - result.structured_content.as_ref().unwrap()["exportJob"]["assets"][0]["status"], - "exported" - ); - assert_eq!( - result.structured_content.as_ref().unwrap()["exportJob"]["assets"][0]["fileState"], - "written" - ); - assert_eq!( - summaries.len(), - 3, - "asset manifest, image-fill audit and one binary asset" - ); - assert!( - summaries - .iter() - .any(|r| r["name"] == "image-fill-audit.json") - ); - let manifest_uri = summaries - .iter() - .find(|item| item["name"] == "asset-manifest.json") - .and_then(|item| item["uri"].as_str()) - .unwrap(); - let manifest_bytes = read_resource_bytes(&client, manifest_uri).await?; - let manifest: Value = serde_json::from_slice(&manifest_bytes)?; - let exported = asset_by_id(&manifest, "1:3:fills:0").clone(); - let resource = &exported["resource"]; - let asset_uri = resource["uri"].as_str().expect("asset resource URI"); - assert_eq!(resource["mimeType"], "image/png"); - assert_eq!(resource["byteLength"], b"synthetic-png".len()); - assert_eq!( - resource["sha256"], - "294ad7145322ec19f8250cca8480a933f1ce8c9e2ad1038e7ae8930d55a6598a" - ); - assert!(exported.get("dataBase64").is_none()); - - let resource_bytes = read_resource_bytes(&client, asset_uri).await?; - assert_eq!(resource_bytes, b"synthetic-png"); - assert_eq!(fs::read(&output_path)?, resource_bytes); - let asset_links = result - .content - .iter() - .filter_map(ContentBlock::as_resource_link) - .filter(|link| { - link.meta - .as_ref() - .and_then(|meta| meta.0.get("payloadMimeType")) - .and_then(Value::as_str) - == Some("image/png") - }) - .collect::>(); - assert_eq!(asset_links.len(), 1); - assert_eq!(asset_links[0].uri, asset_uri); - assert_eq!( - asset_links[0].mime_type.as_deref(), - Some("application/json") - ); - assert_eq!(asset_links[0].size, None); - assert_eq!( - asset_links[0] - .meta - .as_ref() - .and_then(|meta| meta.0.get("payloadSha256")) - .and_then(Value::as_str), - Some("294ad7145322ec19f8250cca8480a933f1ce8c9e2ad1038e7ae8930d55a6598a") - ); - let calls_after_first_export = upstream.calls.load(Ordering::SeqCst); - - let reused = call_result( - &client, - "devup_figma_export", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs": ["assetManifest"], - "delivery": "resource", - "assetRequests": [{ - "assetId":"1:3:fills:0","renderMode":"node", - "format":"png", - "scale":2, - "outputPath": output_path.to_string_lossy() - }] - }), - ) - .await?; - let reused_structured = reused - .structured_content - .as_ref() - .expect("reused structured compatibility summary"); - let reused_resources = reused_structured["resources"].as_array().unwrap(); - let reused_manifest_uri = reused_resources - .iter() - .find(|resource| resource["name"] == "asset-manifest.json") - .and_then(|resource| resource["uri"].as_str()) - .expect("reused asset manifest URI"); - let reused_manifest: Value = - serde_json::from_slice(&read_resource_bytes(&client, reused_manifest_uri).await?)?; - let reused_asset_uri = asset_by_id(&reused_manifest, "1:3:fills:0")["resource"]["uri"] - .as_str() - .expect("reused asset resource URI"); - assert!( - reused_resources - .iter() - .any(|resource| resource["uri"] == reused_asset_uri), - "the reused manifest must reference the actually reused resource" - ); - assert_eq!( - read_resource_bytes(&client, reused_asset_uri).await?, - resource_bytes - ); - assert_eq!( - upstream.calls.load(Ordering::SeqCst), - calls_after_first_export, - "resource projection reuse must not reacquire the Figma payload" - ); - - client.cancel().await?; - task.await??; - fs::remove_dir_all(root)?; - Ok(()) -} - -async fn read_resource_bytes( - client: &rmcp::service::RunningService, - manifest_uri: &str, -) -> anyhow::Result> { - let manifest = client - .read_resource(ReadResourceRequestParams::new(manifest_uri)) - .await?; - let ResourceContents::TextResourceContents { text, .. } = &manifest.contents[0] else { - anyhow::bail!("resource manifest must be JSON text") - }; - let manifest: Value = serde_json::from_str(text)?; - let mut bytes = Vec::new(); - for index in 0..manifest["chunkCount"].as_u64().unwrap() { - let chunk_uri = format!( - "devup://artifact/{}/outputs/{}/chunks/{index}", - manifest["artifactId"].as_str().unwrap(), - manifest["outputId"].as_str().unwrap() - ); - let chunk = client - .read_resource(ReadResourceRequestParams::new(chunk_uri)) - .await?; - match &chunk.contents[0] { - ResourceContents::TextResourceContents { text, .. } => { - bytes.extend_from_slice(text.as_bytes()); - } - ResourceContents::BlobResourceContents { blob, .. } => { - bytes.extend_from_slice(&STANDARD.decode(blob.as_bytes())?); - } - _ => anyhow::bail!("unsupported resource content"), - } - } - Ok(bytes) -} - -/// One asset of a manifest, by the id it is known under. The manifest lists -/// every picture the generated code points at, so a test that wants a -/// particular one asks for it by name rather than by where it happens to sit. -fn asset_by_id<'a>(manifest: &'a Value, asset_id: &str) -> &'a Value { - manifest["assets"] - .as_array() - .expect("manifest assets") - .iter() - .find(|asset| asset["assetId"] == asset_id) - .unwrap_or_else(|| panic!("no asset {asset_id} in {:?}", asset_ids(manifest))) -} - -fn asset_ids(manifest: &Value) -> Vec { - manifest["assets"] - .as_array() - .expect("manifest assets") - .iter() - .map(|asset| asset["assetId"].as_str().unwrap_or_default().to_owned()) - .collect() -} - -fn unique_temp_dir(label: &str) -> anyhow::Result { - let path = std::env::temp_dir().join(format!( - "devup-mcp-{label}-{}-{}", - std::process::id(), - SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() - )); - fs::create_dir_all(&path)?; - Ok(path) -} - -#[tokio::test] -async fn artifact_reuse_rejects_a_different_asset_format_or_scale() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::complete()); - 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 acquired = call( - &client, - "devup_figma_export", - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs": ["assetManifest"], - "assetRequests": [{"assetId":"1:3:fills:0","renderMode":"node","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:3:fills:0")); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); - - for request in [ - json!({"assetId":"1:3:fills:0","renderMode":"node","format":"svg","scale":2}), - json!({"assetId":"1:3:fills:0","renderMode":"node","format":"png","scale":1}), - ] { - let reused = client - .call_tool( - CallToolRequestParams::new("devup_figma_export").with_arguments( - json!({ - "artifactId": artifact_id, - "outputs": ["assetManifest"], - "assetRequests": [request] - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await; - let error = reused - .map(common::tool_error) - .expect("tool result must report failure"); - assert!( - error.to_string().contains("DEVUP_FIGMA_HANDOFF_INVALID"), - "unexpected capture mismatch error: {error}" - ); - } - assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); - - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn strict_export_rejects_partial_payload_before_projection() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::partial()); - 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 result = client - .call_tool( - CallToolRequestParams::new("devup_figma_export").with_arguments( - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs": ["rawSnapshot"], - "debug": true, - "strict": true - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await; - - let error = result - .map(common::tool_error) - .expect("tool result must report failure"); - assert!( - error.to_string().contains("partial"), - "unexpected strict error: {error}" - ); - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn strict_tsx_export_rejects_lossy_projection() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::lossy()); - // Path validation now precedes collection: use an allowed path so this - // test still isolates strict projection rejection and no-write behavior. - let server = DevupServer::with_output_roots( - Services::new(Arc::new(ConnectedAuth), upstream), - vec![std::env::temp_dir()], - )?; - 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 output_path = std::env::temp_dir().join(format!( - "devup-mcp-strict-lossy-{}-{}.tsx", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_nanos() - )); - - let result = client - .call_tool( - CallToolRequestParams::new("devup_figma_export").with_arguments( - json!({ - "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs": ["tsx"], - "strict": true, - "outputPaths": {"tsx": output_path.to_string_lossy()} - }) - .as_object() - .cloned() - .unwrap(), - ), - ) - .await; - - let error = result - .map(common::tool_error) - .expect("tool result must report failure"); - assert!( - error.to_string().contains("lossy"), - "unexpected strict error: {error}" - ); - let wrote_rejected_output = output_path.exists(); - if wrote_rejected_output { - std::fs::remove_file(&output_path)?; - } - assert!( - !wrote_rejected_output, - "strict rejection must not write output files" - ); - client.cancel().await?; - task.await??; - Ok(()) -} - -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": ["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": {} - }, - { - "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": { - "collections": [{ - "id": "c", "name": "Theme", "defaultModeId": "m", - "modes": [{"modeId": "m", "name": "Default"}] - }], - "variables": [{ - "id": "v", "name": "primary", "resolvedType": "COLOR", - "variableCollectionId": "c", "codeSyntax": {"WEB": "primary"}, - "valuesByMode": {"m": {"r": 0, "g": 0.4, "b": 1, "a": 1}} - }], - "styles": [], - "usedVariableIds": ["v"], - "usedStyleIds": [], - "usedRemoteVariables": [], - "localComplete": false, - "usedRemoteComplete": true, - "unresolved": [] - }, - "integrity": { - "nodeCount": 2, - "variableRefCount": 1, - "styleRefCount": 0, - "utf8Bytes": 0 - } - }); - if partial { - envelope["resources"]["variables"] = json!([]); - envelope["resources"]["unresolved"] = json!([{ - "id": "v", "kind": "variable", "reason": "fixture-unresolved" - }]); - } - if lossy { - envelope["snapshot"]["nodes"][0]["fields"]["isMask"] = json!(true); - envelope["snapshot"]["nodes"][0]["fields"]["effects"] = - json!([{"type": "BACKGROUND_BLUR"}]); - } - let envelope_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 _ = envelope_bytes; - - // 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": envelope.to_string()} - ]}), - } -} - -fn asset_export_result( - version: Option<&str>, - request: &devup_mcp_figma::AssetRequest, -) -> UpstreamResult { - let bytes = b"synthetic-png"; - let descriptor = json!({ - "kind":"devupAssetExport","fileKey":"FileKey123","version":version, - "assetId":request.asset_id,"nodeId":request.node_id,"field":request.field, - "imageHash":request.image_hash,"format":request.format,"scale":request.scale,"renderMode":request.render_mode, - "status":"exported","byteLength":bytes.len(), - "sha256":"294ad7145322ec19f8250cca8480a933f1ce8c9e2ad1038e7ae8930d55a6598a" - }); - UpstreamResult { - raw: json!({"content":[ - {"type":"text","text":descriptor.to_string()}, - {"type":"image","data":STANDARD.encode(bytes),"mimeType":"image/png"} - ]}), - } -} - -fn reference_png_base64() -> &'static str { - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" -} - -#[tokio::test] -async fn p3_public_root_mapping_survives_resource_delivery_and_file_writes() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::complete()); - let root = unique_temp_dir("p3-public-root")?; - let output_path = root.join("icons").join("BI icon.png"); - let code_path = root.join("Screen.tsx"); - let server = DevupServer::with_output_roots( - Services::new(Arc::new(ConnectedAuth), upstream), - vec![root.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 result = call_result(&client, "devup_figma_export", json!({ - "url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs":["tsx","assetManifest"], "delivery":"resource", "assetPublicRoot":root, - "outputPaths":{"tsx":code_path}, - "assetRequests":[{"assetId":"1:3:fills:0","renderMode":"node","format":"png","scale":2,"outputPath":output_path}] - })).await?; - let resources = result.structured_content.as_ref().unwrap()["resources"] - .as_array() - .unwrap(); - let manifest_uri = resources - .iter() - .find(|item| item["name"] == "asset-manifest.json") - .unwrap()["uri"] - .as_str() - .unwrap(); - let manifest: Value = - serde_json::from_slice(&read_resource_bytes(&client, manifest_uri).await?)?; - assert_eq!( - asset_by_id(&manifest, "1:3:fills:0")["path"], - "/icons/BI%20icon.png" - ); - let code_uri = resources.iter().find(|item| item["name"] == "tsx").unwrap()["uri"] - .as_str() - .unwrap(); - let code = String::from_utf8(read_resource_bytes(&client, code_uri).await?)?; - assert!(code.contains("/icons/BI%20icon.png"), "{code}"); - assert_eq!(fs::read_to_string(&code_path)?, code); - assert_eq!(fs::read(&output_path)?, b"synthetic-png"); - client.cancel().await?; - task.await??; - fs::remove_dir_all(root)?; - Ok(()) -} - -#[derive(Default)] -struct R3InterruptedUpstream { - timeout_first: bool, - snapshots: AtomicUsize, - first_asset: AtomicUsize, - second_asset: AtomicUsize, -} -#[async_trait] -impl FigmaUpstream for R3InterruptedUpstream { - async fn list_tools(&self) -> Result, DevupError> { - Ok(vec!["use_figma".into()]) - } - async fn call_read_tool(&self, call: ReadToolCall) -> Result { - match call { - ReadToolCall::Snapshot { .. } => { - self.snapshots.fetch_add(1, Ordering::SeqCst); - Ok(fast_envelope_result(false, false)) - } - ReadToolCall::AssetExport { - version, request, .. - } => { - let counter = if request.asset_id == "1:2:fills:1" { - &self.first_asset - } else { - &self.second_asset - }; - let attempt = counter.fetch_add(1, Ordering::SeqCst); - if request.asset_id == "1:3:fills:0" && attempt == 0 { - if self.timeout_first { - std::future::pending::<()>().await; - } - return Err(DevupError::new( - ErrorCode::DevupFigmaDirectUnavailable, - "synthetic interrupted asset read", - true, - )); - } - Ok(asset_export_result(version.as_deref(), &request)) - } - _ => unreachable!(), - } - } -} - -#[tokio::test] -async fn r3_asset_job_resumes_only_pending_read_and_recovers_identical_request() --> anyhow::Result<()> { - r3_assert_resume(false).await -} - -#[tokio::test(start_paused = true)] -async fn r3_asset_job_timeout_keeps_first_asset_and_resumes_pending_read() -> anyhow::Result<()> { - r3_assert_resume(true).await -} - -async fn r3_assert_resume(timeout_first: bool) -> anyhow::Result<()> { - let upstream = Arc::new(R3InterruptedUpstream { - timeout_first, - ..Default::default() - }); - let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); - let (st, ct) = tokio::io::duplex(256 * 1024); - let task = tokio::spawn(async move { - server.serve(st).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(ct).await?; - let args = json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", "outputs":["assetManifest"], - "assetRequests":[{"assetId":"1:2:fills:1","renderMode":"node"},{"assetId":"1:3:fills:0","renderMode":"node"}]}); - let mut paused = call(&client, "devup_figma_export", args.clone()).await?; - if timeout_first { - assert_eq!(paused["assetJob"]["state"], "running"); - let timeout_ms = paused["exportJob"]["calls"] - .as_array() - .unwrap() - .last() - .unwrap()["timeoutMs"] - .as_u64() - .unwrap(); - assert!(timeout_ms >= paused["exportJob"]["assetTimeoutMs"].as_u64().unwrap()); - tokio::time::advance(std::time::Duration::from_millis(timeout_ms + 1000)).await; - paused = call( - &client, - "devup_figma_export", - json!({"jobId":paused["assetJob"]["jobId"]}), - ) - .await?; - } - assert_eq!(paused["assetJob"]["state"], "paused", "{paused}"); - let id = paused["assetJob"]["jobId"].as_str().unwrap(); - assert_eq!(paused["exportJob"]["assets"][0]["status"], "exported"); - assert_eq!( - paused["exportJob"]["assets"][0]["fileState"], - "not-requested" - ); - // Polls answer compact rows, and the alias no longer repeats the job. - assert!(paused["exportJob"]["assets"][0].get("renderInfo").is_none()); - assert_eq!(paused["assetJob"]["aliasOf"], "exportJob"); - // responseFields on a poll answers only what it selected. - let picked = call( - &client, - "devup_figma_export", - json!({"jobId":id,"responseFields":["exportJob.assets.renderInfo"]}), - ) - .await?; - assert_eq!(picked["selections"][0]["matched"], 1, "{picked}"); - assert!(picked["selections"][0]["values"][0].is_object()); - assert_eq!(picked["exportJob"]["state"], "paused"); - assert!(picked.get("assetJob").is_none(), "{picked}"); - let recovered = call(&client, "devup_figma_export", args).await?; - assert_eq!(recovered["assetJob"]["jobId"], id); - let resumed = call( - &client, - "devup_figma_export", - json!({"jobId":id,"jobAction":"resume"}), - ) - .await?; - let mut result = resumed; - for _ in 0..50 { - if result["assetJob"]["state"] == "complete" { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - result = call(&client, "devup_figma_export", json!({"jobId":id})).await?; - } - assert_eq!(result["assetJob"]["state"], "complete", "{result}"); - assert_eq!( - asset_by_id(&result["assetManifest"], "1:3:fills:0")["status"], - "exported" - ); - assert_eq!(upstream.snapshots.load(Ordering::SeqCst), 1); - assert_eq!(upstream.first_asset.load(Ordering::SeqCst), 1); - assert_eq!(upstream.second_asset.load(Ordering::SeqCst), 2); - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn r3_oversized_assets_rejected_before_upstream() -> anyhow::Result<()> { - let upstream = Arc::new(FastFixtureUpstream::complete()); - let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); - let (st, ct) = tokio::io::duplex(256 * 1024); - let task = tokio::spawn(async move { - server.serve(st).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(ct).await?; - let assets: Vec<_> = (0..16) - .map(|i| json!({"assetId":format!("1:{i}:node")})) - .collect(); - let result=client.call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(json!({ - "url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["assetManifest"],"assetRequests":assets - }).as_object().unwrap().clone())).await; - common::tool_error(result?); - assert_eq!(upstream.calls.load(Ordering::SeqCst), 0); - client.cancel().await?; - task.await??; - Ok(()) -} - -#[derive(Default)] -struct R3BatchUpstream { - snapshots: AtomicUsize, - assets: AtomicUsize, -} -#[async_trait] -impl FigmaUpstream for R3BatchUpstream { - async fn list_tools(&self) -> Result, DevupError> { - Ok(vec!["use_figma".into()]) - } - async fn call_read_tool(&self, call: ReadToolCall) -> Result { - match call { - ReadToolCall::Snapshot { .. } => { - self.snapshots.fetch_add(1, Ordering::SeqCst); - let raw = fast_envelope_result(false, false).raw; - let mut envelope: Value = - serde_json::from_str(raw["content"][0]["text"].as_str().unwrap()).unwrap(); - let mut nodes = vec![envelope["snapshot"]["nodes"][0].clone()]; - nodes[0]["fields"]["childrenIds"] = - json!((10..26).map(|i| format!("1:{i}")).collect::>()); - nodes[0]["fields"]["fills"] - .as_array_mut() - .unwrap() - .truncate(1); - for i in 10..26 { - let mut node = envelope["snapshot"]["nodes"][1].clone(); - node["id"] = json!(format!("1:{i}")); - nodes.push(node); - } - envelope["snapshot"]["nodes"] = json!(nodes); - envelope["integrity"]["nodeCount"] = json!(17); - loop { - let size = serde_json::to_vec(&envelope).unwrap().len(); - if envelope["integrity"]["utf8Bytes"] == size { - break; - } - envelope["integrity"]["utf8Bytes"] = json!(size); - } - Ok(UpstreamResult { - raw: json!({"content":[{"type":"text","text":envelope.to_string()}]}), - }) - } - ReadToolCall::AssetExport { - version, request, .. - } => { - self.assets.fetch_add(1, Ordering::SeqCst); - Ok(asset_export_result(version.as_deref(), &request)) - } - _ => unreachable!(), - } - } -} - -#[tokio::test(start_paused = true)] -async fn r3_sixteen_assets_succeed_in_recommended_batches() -> anyhow::Result<()> { - let upstream = Arc::new(R3BatchUpstream::default()); - let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); - let (st, ct) = tokio::io::duplex(256 * 1024); - let task = tokio::spawn(async move { - server.serve(st).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(ct).await?; - let assets: Vec<_> = (10..26) - .map(|i| json!({"assetId":format!("1:{i}:fills:0"),"renderMode":"node"})) - .collect(); - for batch in assets.chunks(3) { - let mut result = call( - &client, - "devup_figma_export", - json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", - "outputs":["assetManifest"],"assetRequests":batch}), - ) - .await?; - for _ in 0..120 { - if result["assetJob"]["state"] == "complete" { - break; - } - tokio::time::advance(std::time::Duration::from_secs(1)).await; - result = call( - &client, - "devup_figma_export", - json!({"jobId":result["assetJob"]["jobId"]}), - ) - .await?; - } - assert_eq!(result["assetJob"]["state"], "complete", "{result}"); - for asset in batch { - assert_eq!( - asset_by_id(&result["assetManifest"], asset["assetId"].as_str().unwrap())["status"], - "exported" - ); - } - } - assert_eq!(upstream.assets.load(Ordering::SeqCst), 16); - assert_eq!(upstream.snapshots.load(Ordering::SeqCst), 6); - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn r3_different_output_job_does_not_wait_on_unresumable_owner() -> anyhow::Result<()> { - let upstream = Arc::new(R3InterruptedUpstream::default()); - let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); - let (st, ct) = tokio::io::duplex(256 * 1024); - let task = tokio::spawn(async move { - server.serve(st).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(ct).await?; - let mut args = json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["assetManifest"], - "assetRequests":[{"assetId":"1:2:fills:1","renderMode":"node"},{"assetId":"1:3:fills:0","renderMode":"node"}]}); - let owner = call(&client, "devup_figma_export", args.clone()).await?; - assert_eq!(owner["assetJob"]["state"], "paused"); - args["outputs"] = json!(["tsx", "assetManifest"]); - let independent = call(&client, "devup_figma_export", args).await?; - assert_eq!( - independent["assetJob"]["state"], "complete", - "{independent}" - ); - assert_ne!(owner["assetJob"]["jobId"], independent["assetJob"]["jobId"]); - let resumed = call( - &client, - "devup_figma_export", - json!({"jobId":owner["assetJob"]["jobId"],"jobAction":"resume"}), - ) - .await?; - assert_eq!(resumed["assetJob"]["state"], "complete"); - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test(start_paused = true)] -async fn r3_cancelled_client_recovers_retained_asset_job() -> anyhow::Result<()> { - let upstream = Arc::new(R3InterruptedUpstream { - timeout_first: true, - ..Default::default() - }); - let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); - let (st, ct) = tokio::io::duplex(256 * 1024); - let task = tokio::spawn(async move { - server.serve(st).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(ct).await?; - let args = json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["assetManifest"], - "assetRequests":[{"assetId":"1:2:fills:1","renderMode":"node"},{"assetId":"1:3:fills:0","renderMode":"node"}]}); - let dropped = tokio::time::timeout( - std::time::Duration::from_millis(100), - call(&client, "devup_figma_export", args.clone()), - ) - .await; - assert!(dropped.is_err()); - let recovered = call(&client, "devup_figma_export", args).await?; - let id = &recovered["assetJob"]["jobId"]; - assert!(id.is_string()); - assert_eq!(upstream.snapshots.load(Ordering::SeqCst), 1); - let timeout_ms = recovered["exportJob"]["calls"] - .as_array() - .unwrap() - .last() - .unwrap()["timeoutMs"] - .as_u64() - .unwrap(); - tokio::time::advance(std::time::Duration::from_millis(timeout_ms + 1000)).await; - let paused = call(&client, "devup_figma_export", json!({"jobId":id})).await?; - assert_eq!(paused["assetJob"]["state"], "paused"); - let complete = call( - &client, - "devup_figma_export", - json!({"jobId":id,"jobAction":"resume"}), - ) - .await?; - assert_eq!(complete["assetJob"]["state"], "complete"); - assert_eq!(upstream.first_asset.load(Ordering::SeqCst), 1); - assert_eq!(upstream.second_asset.load(Ordering::SeqCst), 2); - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn r3_distinct_resource_jobs_keep_both_manifests_readable() -> anyhow::Result<()> { - let upstream = Arc::new(R3InterruptedUpstream::default()); - let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); - let (st, ct) = tokio::io::duplex(256 * 1024); - let task = tokio::spawn(async move { - server.serve(st).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(ct).await?; - let mut args = json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["assetManifest"],"delivery":"resource", - "assetRequests":[{"assetId":"1:2:fills:1","renderMode":"node"},{"assetId":"1:3:fills:0","renderMode":"node"}]}); - let owner = call(&client, "devup_figma_export", args.clone()).await?; - assert_eq!(owner["assetJob"]["state"], "paused"); - args["outputs"] = json!(["tsx", "assetManifest"]); - let second = call(&client, "devup_figma_export", args).await?; - let first = call( - &client, - "devup_figma_export", - json!({"jobId":owner["assetJob"]["jobId"],"jobAction":"resume"}), - ) - .await?; - for value in [first, second] { - assert_eq!(value["assetJob"]["state"], "complete"); - assert!( - value["exportJob"]["assets"] - .as_array() - .unwrap() - .iter() - .all(|a| a["status"] == "exported") - ); - let uri = value["resources"] - .as_array() - .unwrap() - .iter() - .find(|r| r["name"] == "asset-manifest.json") - .unwrap()["uri"] - .as_str() - .unwrap(); - let bytes = read_resource_bytes(&client, uri).await?; - let manifest: Value = serde_json::from_slice(&bytes)?; - assert_eq!(asset_by_id(&manifest, "1:3:fills:0")["status"], "exported"); - } - client.cancel().await?; - task.await??; - Ok(()) -} - -#[derive(Debug)] -struct R8SlowSnapshot; -#[async_trait] -impl FigmaUpstream for R8SlowSnapshot { - async fn list_tools(&self) -> Result, DevupError> { - Ok(vec!["use_figma".into()]) - } - async fn call_read_tool(&self, _: ReadToolCall) -> Result { - tokio::time::sleep(std::time::Duration::from_secs(3)).await; - Ok(fast_envelope_result(false, false)) - } -} -#[tokio::test] -async fn r8_code_only_slow_capture_returns_pollable_job_before_client_deadline() --> anyhow::Result<()> { - let server = DevupServer::with_output_roots( - Services::new(Arc::new(ConnectedAuth), Arc::new(R8SlowSnapshot)), - vec![std::env::temp_dir()], - )?; - let (st, ct) = tokio::io::duplex(256 * 1024); - let task = tokio::spawn(async move { - server.serve(st).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(ct).await?; - let began = std::time::Instant::now(); - let response=call(&client,"devup_figma_export",json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true,"delivery":"inline"})).await?; - assert!( - began.elapsed() < std::time::Duration::from_secs(2), - "client blocked for {:?}", - began.elapsed() - ); - let id = response["exportJob"]["jobId"] - .as_str() - .expect("code-only collection needs a resumable job"); - assert!(response["exportJob"]["calls"][0]["detail"]["nodeId"].is_string()); - let mut result = response.clone(); - for _ in 0..8 { - result = call(&client, "devup_figma_export", json!({"jobId":id})).await?; - if result["exportJob"]["state"] == "complete" { - break; - } - } - assert_eq!(result["exportJob"]["state"], "complete"); - assert!(result["tsx"].is_string()); - assert!( - result["exportJob"]["calls"][0]["elapsedMs"] - .as_u64() - .unwrap() - >= 2900 - ); - client.cancel().await?; - task.await??; - Ok(()) -} - -#[tokio::test] -async fn r8_completed_exports_do_not_exhaust_active_job_capacity() -> anyhow::Result<()> { - let server = DevupServer::with_output_roots( - Services::new( - Arc::new(ConnectedAuth), - Arc::new(FastFixtureUpstream::complete()), - ), - vec![std::env::temp_dir()], - )?; - let (st, ct) = tokio::io::duplex(256 * 1024); - let task = tokio::spawn(async move { - server.serve(st).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(ct).await?; - for index in 0..10 { - let result=call(&client,"devup_figma_export",json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["tsx"],"componentName":format!("Screen{index}"),"delivery":"inline"})).await?; - assert!( - result["tsx"].is_string(), - "completed export {index} must not consume active capacity: {result}" - ); - } - client.cancel().await?; - task.await??; - Ok(()) -} +// Source sections share this module to preserve privacy and test names. +include!("composite_export/common.rs"); +include!("composite_export/strict_tsx_export_rejects_lossy_projection.rs"); diff --git a/crates/devup-mcp/tests/composite_export/common.rs b/crates/devup-mcp/tests/composite_export/common.rs new file mode 100644 index 00000000..13eabdc3 --- /dev/null +++ b/crates/devup-mcp/tests/composite_export/common.rs @@ -0,0 +1,938 @@ +#[path = "../common/mod.rs"] +mod common; + +use std::{ + fs, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +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, +}; +use rmcp::{ + ServiceExt, + model::{ + CallToolRequestParams, CallToolResult, ContentBlock, ReadResourceRequestParams, + ResourceContents, + }, +}; +use serde_json::{Map, Value, json}; + +#[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 FastFixtureUpstream { + calls: AtomicUsize, + partial: bool, + lossy: bool, +} + +impl FastFixtureUpstream { + fn complete() -> Self { + Self { + calls: AtomicUsize::new(0), + partial: false, + lossy: false, + } + } + + fn partial() -> Self { + Self { + calls: AtomicUsize::new(0), + partial: true, + lossy: false, + } + } + + fn lossy() -> Self { + Self { + calls: AtomicUsize::new(0), + partial: false, + lossy: true, + } + } +} + +#[async_trait] +impl FigmaUpstream for FastFixtureUpstream { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["get_screenshot".to_owned(), "use_figma".to_owned()]) + } + + async fn call_read_tool(&self, call: ReadToolCall) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + match call { + ReadToolCall::Snapshot { + script: BuiltinScript::FastSnapshotEnvelope, + .. + } => Ok(fast_envelope_result(self.partial, self.lossy)), + ReadToolCall::AssetExport { + version, request, .. + } => Ok(asset_export_result(version.as_deref(), &request)), + ReadToolCall::Screenshot { .. } => Ok(UpstreamResult { + raw: json!({ + "content": [{ + "type": "image", + "mimeType": "image/png", + "data": reference_png_base64() + }] + }), + }), + _ => Err(DevupError::new( + ErrorCode::DevupSnapshotUnsupported, + "composite export must stay on the one-call fast path", + false, + )), + } + } +} + +#[tokio::test] +async fn reference_png_is_acquired_once_and_delivered_as_a_binary_resource() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::complete()); + 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 acquired = call( + &client, + "devup_figma_export", + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs": ["referencePng"] + }), + ) + .await?; + + assert_eq!(acquired["collection"]["figmaToolCalls"], 2); + assert_eq!(acquired["referencePng"]["mimeType"], "image/png"); + assert_eq!( + acquired["referencePng"]["dataBase64"], + 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( + &client, + "devup_figma_export", + json!({ + "artifactId": artifact_id, + "outputs": ["referencePng"], + "delivery": "resource" + }), + ) + .await?; + let delivered = delivered_result + .structured_content + .as_ref() + .expect("structured compatibility summary"); + assert!(delivered.get("referencePng").is_none()); + assert_eq!(delivered["resources"][0]["mimeType"], "image/png"); + let manifest_uri = delivered["resources"][0]["uri"].as_str().unwrap(); + let link = delivered_result + .content + .iter() + .filter_map(ContentBlock::as_resource_link) + .find(|link| link.uri == manifest_uri) + .expect("native manifest resource link"); + assert_eq!(link.mime_type.as_deref(), Some("application/json")); + assert_eq!(link.size, None); + assert_eq!( + link.meta + .as_ref() + .and_then(|meta| meta.0.get("payloadMimeType")) + .and_then(Value::as_str), + Some("image/png") + ); + let wire = serde_json::to_value(&delivered_result)?; + assert!(wire["content"].as_array().is_some_and(|content| { + content + .iter() + .any(|block| block["type"] == "resource_link" && block["uri"] == manifest_uri) + })); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); + + client.cancel().await?; + task.await??; + Ok(()) +} + +async fn call( + client: &rmcp::service::RunningService, + name: &str, + arguments: Value, +) -> anyhow::Result { + Ok(call_result(client, name, arguments) + .await? + .structured_content + .expect("structured tool output")) +} + +async fn call_result( + client: &rmcp::service::RunningService, + name: &str, + arguments: Value, +) -> anyhow::Result { + let arguments: Map = arguments.as_object().cloned().unwrap(); + let result = client + .call_tool(CallToolRequestParams::new(name.to_owned()).with_arguments(arguments)) + .await?; + anyhow::ensure!( + result.is_error != Some(true), + "{}", + result + .structured_content + .as_ref() + .expect("structured error") + ); + Ok(result) +} + +/// The design in raw form is behind a door marked debug. +/// +/// `rawSnapshot` and `rawPayload` describe the design rather than the screen. +/// Nothing that implements a screen needs them, and left in the ordinary list +/// they were taken as a matter of course - the server's own instructions used +/// to say to take `rawSnapshot` every time, which on a measured screen spent +/// about eight bytes for every one of code. They stay reachable, because +/// deciding whether a screen that looks wrong is the generator's fault means +/// reading the design beside the code, and that is what `debug` is for. +#[tokio::test] +async fn the_collected_design_is_reachable_only_in_debug() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::complete()); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); + 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 url = "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2"; + + for output in ["rawSnapshot", "rawPayload"] { + let refused = call_result( + &client, + "devup_figma_export", + json!({ "url": url, "outputs": [output], "scope": "node" }), + ) + .await + .expect_err("the design in raw form is not part of implementing a screen"); + let message = refused.to_string(); + assert!( + message.contains("debug: true"), + "the refusal has to say which door to open: {message}" + ); + assert!( + message.contains(output), + "and which output it is about: {message}" + ); + } + + // Asking for it deliberately still works, and still costs what it costs. + let debugging = call( + &client, + "devup_figma_export", + json!({ "url": url, "outputs": ["tsx", "rawSnapshot"], "scope": "node", "debug": true }), + ) + .await?; + assert!(debugging["rawSnapshot"]["nodes"].is_object()); + assert!(debugging["tsx"].as_str().is_some_and(|tsx| !tsx.is_empty())); + + client.cancel().await?; + task.await??; + Ok(()) +} + +/// A clean conversion should carry the code and the grade, and not the +/// paperwork underneath the grade. +/// +/// `fidelity` and `completenessReport` are the drill-down under `quality`: +/// on a clean run the first reports 100% across six axes and the second six +/// empty arrays, so both only repeat what `quality` already said. Measured on +/// this fixture they were 797 bytes of the 1,911 every response carried. +/// They are still sent whenever they disagree with a clean grade, and +/// whenever the caller asks for diagnostics - which the test above covers. +#[tokio::test] +async fn a_clean_capture_reports_its_projection_shortfall_by_default() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::complete()); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); + 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 result = call( + &client, + "devup_figma_export", + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs": ["tsx"], + "scope": "node" + }), + ) + .await?; + + assert_eq!(result["status"], "partial"); + assert!( + result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|issue| issue["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") + ); + assert_eq!(result["quality"]["projection"], "lossy"); + assert_eq!(result["quality"]["acquisition"], "complete"); + assert!(result["tsx"].as_str().unwrap().contains("$primary")); + // `completenessReport` is keyed on the capture, which is clean here. + assert!( + result.get("completenessReport").is_none(), + "a clean capture says nothing the grade has not already said" + ); + // R1: a clean acquisition does not make unclassified layout loss exact. + let fidelity = result + .get("fidelity") + .expect("a projection shortfall must be explained"); + assert_eq!(result["quality"]["projection"], "lossy"); + assert!( + fidelity["variables"]["basisPoints"].as_u64() < Some(10_000) + || fidelity["layout"]["basisPoints"].as_u64() < Some(10_000) + || fidelity["nodes"]["basisPoints"].as_u64() < Some(10_000) + || fidelity["text"]["basisPoints"].as_u64() < Some(10_000) + || fidelity["typography"]["basisPoints"].as_u64() < Some(10_000) + || fidelity["assets"]["basisPoints"].as_u64() < Some(10_000), + "it is sent because an axis is short: {fidelity}" + ); + + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn one_acquisition_projects_all_outputs_and_artifact_reuse_is_zero_call() -> anyhow::Result<()> +{ + let upstream = Arc::new(FastFixtureUpstream::complete()); + 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 url = "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2"; + + let first = call( + &client, + "devup_figma_export", + json!({ + "url": url, + "outputs": ["tsx", "devupJson", "rawSnapshot", "rawPayload", "sourceMap", "assetManifest"], + "debug": true, + "scope": "node", + "includeDiagnostics": true + }), + ) + .await?; + + assert_eq!(first["status"], "partial"); + assert!( + first["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|issue| issue["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") + ); + // The raw payload is the whole collection the snapshot came out of: the + // same nodes, plus the variables the token names are read from, and + // never the reference PNG, which has an output of its own. + assert_eq!(first["rawPayload"]["snapshot"], first["rawSnapshot"]); + assert!(first["rawPayload"]["variables"].is_object()); + assert!(first["rawPayload"].get("referencePng").is_none()); + assert_eq!(first["collection"]["figmaToolCalls"], 1); + assert_eq!(first["cache"]["cacheHit"], false); + assert!(first["cache"]["artifactId"].as_str().is_some()); + assert!(first["tsx"].as_str().unwrap().contains("$primary")); + assert_eq!(first["status"], "partial"); + assert!( + first["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|issue| issue["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") + ); + assert_eq!(first["quality"]["projection"], "lossy"); + // This call asks for diagnostics, so the drill-down under `quality` is + // exactly what it should get. + assert!(first["fidelity"].is_object()); + assert!(first["completenessReport"].is_object()); + // The code is available for inspection but is not a final exact output. + assert_eq!(first["deliverable"]["isFinal"], false); + assert!( + !first["deliverable"]["availableOutputs"] + .as_array() + .unwrap() + .is_empty() + ); + // These restate something already in the response whether diagnostics + // were asked for or not: `imports` and `usedTokens` restate the tsx's own + // import line and its `$token`s. Neither is sent any more. + for restated in ["imports", "usedTokens", "componentImports"] { + assert!( + first.get(restated).is_none(), + "{restated} restates something already in the response and must not be sent" + ); + } + assert!(first["devupJson"].as_str().unwrap().contains("\"primary\"")); + assert_eq!(first["rawSnapshot"]["roots"], json!(["1:2"])); + assert_eq!(first["sourceMap"]["version"], 2); + // Both pictures the generated code points at: the child drawn from its + // own image fill, and the root's second fill, which the code paints as a + // background. A container is a layout box rather than an asset, but the + // picture on it still has to be listed or nothing could deliver it. + assert_eq!( + asset_ids(&first["assetManifest"]), + vec!["1:2:fills:1", "1:3:fills:0"] + ); + assert_eq!( + asset_by_id(&first["assetManifest"], "1:3:fills:0")["status"], + "available" + ); + assert!(first["sourceMap"]["tsx"].as_array().is_some_and(|entries| { + entries.iter().any(|entry| { + entry["nodeId"] == "1:2" && entry["property"] == "fills" && entry["variableId"] == "v" + }) + })); + assert!( + first["sourceMap"]["devupJson"] + .as_array() + .is_some_and(|entries| entries.iter().any(|entry| { + entry["jsonPointer"] == "/theme/colors/default/primary" + && entry["variableId"] == "v" + })) + ); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); + + let artifact_id = first["cache"]["artifactId"].as_str().unwrap(); + let projected = call( + &client, + "devup_figma_export", + json!({ + "artifactId": artifact_id, + "outputs": ["tsx"], + "rootLayout": "embedded" + }), + ) + .await?; + assert_eq!(projected["cache"]["cacheHit"], true); + assert_eq!(projected["cache"]["artifactId"], artifact_id); + assert!(projected.get("devupJson").is_none()); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); + + let resource = call( + &client, + "devup_figma_export", + json!({ + "artifactId": artifact_id, + "outputs": ["tsx"], + "delivery": "resource" + }), + ) + .await?; + assert!(resource.get("tsx").is_none()); + let manifest_uri = resource["resources"][0]["uri"].as_str().unwrap(); + let manifest = client + .read_resource(ReadResourceRequestParams::new(manifest_uri)) + .await?; + let ResourceContents::TextResourceContents { text, .. } = &manifest.contents[0] else { + panic!("resource manifest must be text") + }; + let manifest: Value = serde_json::from_str(text)?; + assert_eq!(manifest["name"], "tsx"); + assert_eq!(manifest["mimeType"], "text/typescript"); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); + + // The same acquisition answers a tsx-only and a theme-only projection + // without going back to Figma, which is what devup_figma_to_ui and + // devup_figma_to_json used to be for before they were folded into this + // one tool as `outputs`. + let ui_wrapper = call( + &client, + "devup_figma_export", + json!({"url": url, "outputs": ["tsx"]}), + ) + .await?; + let json_wrapper = call( + &client, + "devup_figma_export", + json!({"url": url, "outputs": ["devupJson"], "scope": "node"}), + ) + .await?; + assert_eq!(ui_wrapper["cache"]["cacheHit"], true); + assert_eq!(json_wrapper["cache"]["cacheHit"], true); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); + + let refreshed = call( + &client, + "devup_figma_export", + json!({ + "url": url, + "outputs": ["rawSnapshot"], + "debug": true, + "scope": "node", + "refresh": true + }), + ) + .await?; + assert_eq!(refreshed["cache"]["cacheHit"], false); + assert_ne!(refreshed["cache"]["artifactId"], artifact_id); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); + + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn artifact_reuse_rejects_file_theme_beyond_captured_scope() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::complete()); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); + 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 acquired = call( + &client, + "devup_figma_export", + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs": ["tsx"], + "scope": "node" + }), + ) + .await?; + let artifact_id = acquired["cache"]["artifactId"].as_str().unwrap(); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); + + let incompatible = client + .call_tool( + CallToolRequestParams::new("devup_figma_export").with_arguments( + json!({ + "artifactId": artifact_id, + "outputs": ["devupJson"], + "scope": "file" + }) + .as_object() + .cloned() + .unwrap(), + ), + ) + .await; + + let error = incompatible + .map(common::tool_error) + .expect("tool result must report failure"); + assert!( + error.to_string().contains("DEVUP_FIGMA_HANDOFF_INVALID"), + "unexpected capability error: {error}" + ); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 1); + + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn explicit_asset_request_exports_once_and_returns_validated_binary() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::complete()); + 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 result = call( + &client, + "devup_figma_export", + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs": ["tsx", "assetManifest"], + "assetRequests": [{"assetId":"1:3:fills:0","renderMode":"node","format":"png","scale":2}] + }), + ) + .await?; + + assert_eq!(result["status"], "partial"); + assert!( + result["projectionIssues"] + .as_array() + .unwrap() + .iter() + .any(|issue| issue["code"] == "DEVUP_CODEGEN_LAYOUT_UNCOVERED") + ); + assert_eq!(result["collection"]["figmaToolCalls"], 2); + let exported = asset_by_id(&result["assetManifest"], "1:3:fills:0"); + assert_eq!(exported["status"], "exported"); + assert_eq!(exported["dataBase64"], STANDARD.encode(b"synthetic-png")); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); + + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn resource_asset_manifest_reconstructs_the_exact_independent_binary() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::complete()); + let root = unique_temp_dir("asset-resource")?; + let output_path = root.join("asset.png"); + let server = DevupServer::with_output_roots( + Services::new(Arc::new(ConnectedAuth), upstream.clone()), + vec![root.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 result = call_result( + &client, + "devup_figma_export", + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs": ["assetManifest"], + "delivery": "resource", + "assetRequests": [{ + "assetId":"1:3:fills:0","renderMode":"node", + "format":"png", + "scale":2, + "outputPath": output_path.to_string_lossy() + }] + }), + ) + .await?; + let structured = result + .structured_content + .as_ref() + .expect("structured compatibility summary"); + let summaries = structured["resources"].as_array().unwrap(); + assert_eq!( + result.structured_content.as_ref().unwrap()["exportJob"]["assets"][0]["status"], + "exported" + ); + assert_eq!( + result.structured_content.as_ref().unwrap()["exportJob"]["assets"][0]["fileState"], + "written" + ); + assert_eq!( + summaries.len(), + 3, + "asset manifest, image-fill audit and one binary asset" + ); + assert!( + summaries + .iter() + .any(|r| r["name"] == "image-fill-audit.json") + ); + let manifest_uri = summaries + .iter() + .find(|item| item["name"] == "asset-manifest.json") + .and_then(|item| item["uri"].as_str()) + .unwrap(); + let manifest_bytes = read_resource_bytes(&client, manifest_uri).await?; + let manifest: Value = serde_json::from_slice(&manifest_bytes)?; + let exported = asset_by_id(&manifest, "1:3:fills:0").clone(); + let resource = &exported["resource"]; + let asset_uri = resource["uri"].as_str().expect("asset resource URI"); + assert_eq!(resource["mimeType"], "image/png"); + assert_eq!(resource["byteLength"], b"synthetic-png".len()); + assert_eq!( + resource["sha256"], + "294ad7145322ec19f8250cca8480a933f1ce8c9e2ad1038e7ae8930d55a6598a" + ); + assert!(exported.get("dataBase64").is_none()); + + let resource_bytes = read_resource_bytes(&client, asset_uri).await?; + assert_eq!(resource_bytes, b"synthetic-png"); + assert_eq!(fs::read(&output_path)?, resource_bytes); + let asset_links = result + .content + .iter() + .filter_map(ContentBlock::as_resource_link) + .filter(|link| { + link.meta + .as_ref() + .and_then(|meta| meta.0.get("payloadMimeType")) + .and_then(Value::as_str) + == Some("image/png") + }) + .collect::>(); + assert_eq!(asset_links.len(), 1); + assert_eq!(asset_links[0].uri, asset_uri); + assert_eq!( + asset_links[0].mime_type.as_deref(), + Some("application/json") + ); + assert_eq!(asset_links[0].size, None); + assert_eq!( + asset_links[0] + .meta + .as_ref() + .and_then(|meta| meta.0.get("payloadSha256")) + .and_then(Value::as_str), + Some("294ad7145322ec19f8250cca8480a933f1ce8c9e2ad1038e7ae8930d55a6598a") + ); + let calls_after_first_export = upstream.calls.load(Ordering::SeqCst); + + let reused = call_result( + &client, + "devup_figma_export", + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs": ["assetManifest"], + "delivery": "resource", + "assetRequests": [{ + "assetId":"1:3:fills:0","renderMode":"node", + "format":"png", + "scale":2, + "outputPath": output_path.to_string_lossy() + }] + }), + ) + .await?; + let reused_structured = reused + .structured_content + .as_ref() + .expect("reused structured compatibility summary"); + let reused_resources = reused_structured["resources"].as_array().unwrap(); + let reused_manifest_uri = reused_resources + .iter() + .find(|resource| resource["name"] == "asset-manifest.json") + .and_then(|resource| resource["uri"].as_str()) + .expect("reused asset manifest URI"); + let reused_manifest: Value = + serde_json::from_slice(&read_resource_bytes(&client, reused_manifest_uri).await?)?; + let reused_asset_uri = asset_by_id(&reused_manifest, "1:3:fills:0")["resource"]["uri"] + .as_str() + .expect("reused asset resource URI"); + assert!( + reused_resources + .iter() + .any(|resource| resource["uri"] == reused_asset_uri), + "the reused manifest must reference the actually reused resource" + ); + assert_eq!( + read_resource_bytes(&client, reused_asset_uri).await?, + resource_bytes + ); + assert_eq!( + upstream.calls.load(Ordering::SeqCst), + calls_after_first_export, + "resource projection reuse must not reacquire the Figma payload" + ); + + client.cancel().await?; + task.await??; + fs::remove_dir_all(root)?; + Ok(()) +} + +async fn read_resource_bytes( + client: &rmcp::service::RunningService, + manifest_uri: &str, +) -> anyhow::Result> { + let manifest = client + .read_resource(ReadResourceRequestParams::new(manifest_uri)) + .await?; + let ResourceContents::TextResourceContents { text, .. } = &manifest.contents[0] else { + anyhow::bail!("resource manifest must be JSON text") + }; + let manifest: Value = serde_json::from_str(text)?; + let mut bytes = Vec::new(); + for index in 0..manifest["chunkCount"].as_u64().unwrap() { + let chunk_uri = format!( + "devup://artifact/{}/outputs/{}/chunks/{index}", + manifest["artifactId"].as_str().unwrap(), + manifest["outputId"].as_str().unwrap() + ); + let chunk = client + .read_resource(ReadResourceRequestParams::new(chunk_uri)) + .await?; + match &chunk.contents[0] { + ResourceContents::TextResourceContents { text, .. } => { + bytes.extend_from_slice(text.as_bytes()); + } + ResourceContents::BlobResourceContents { blob, .. } => { + bytes.extend_from_slice(&STANDARD.decode(blob.as_bytes())?); + } + _ => anyhow::bail!("unsupported resource content"), + } + } + Ok(bytes) +} + +/// One asset of a manifest, by the id it is known under. The manifest lists +/// every picture the generated code points at, so a test that wants a +/// particular one asks for it by name rather than by where it happens to sit. +fn asset_by_id<'a>(manifest: &'a Value, asset_id: &str) -> &'a Value { + manifest["assets"] + .as_array() + .expect("manifest assets") + .iter() + .find(|asset| asset["assetId"] == asset_id) + .unwrap_or_else(|| panic!("no asset {asset_id} in {:?}", asset_ids(manifest))) +} + +fn asset_ids(manifest: &Value) -> Vec { + manifest["assets"] + .as_array() + .expect("manifest assets") + .iter() + .map(|asset| asset["assetId"].as_str().unwrap_or_default().to_owned()) + .collect() +} + +fn unique_temp_dir(label: &str) -> anyhow::Result { + let path = std::env::temp_dir().join(format!( + "devup-mcp-{label}-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() + )); + fs::create_dir_all(&path)?; + Ok(path) +} + +#[tokio::test] +async fn artifact_reuse_rejects_a_different_asset_format_or_scale() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::complete()); + 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 acquired = call( + &client, + "devup_figma_export", + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs": ["assetManifest"], + "assetRequests": [{"assetId":"1:3:fills:0","renderMode":"node","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:3:fills:0")); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); + + for request in [ + json!({"assetId":"1:3:fills:0","renderMode":"node","format":"svg","scale":2}), + json!({"assetId":"1:3:fills:0","renderMode":"node","format":"png","scale":1}), + ] { + let reused = client + .call_tool( + CallToolRequestParams::new("devup_figma_export").with_arguments( + json!({ + "artifactId": artifact_id, + "outputs": ["assetManifest"], + "assetRequests": [request] + }) + .as_object() + .cloned() + .unwrap(), + ), + ) + .await; + let error = reused + .map(common::tool_error) + .expect("tool result must report failure"); + assert!( + error.to_string().contains("DEVUP_FIGMA_HANDOFF_INVALID"), + "unexpected capture mismatch error: {error}" + ); + } + assert_eq!(upstream.calls.load(Ordering::SeqCst), 2); + + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn strict_export_rejects_partial_payload_before_projection() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::partial()); + 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 result = client + .call_tool( + CallToolRequestParams::new("devup_figma_export").with_arguments( + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs": ["rawSnapshot"], + "debug": true, + "strict": true + }) + .as_object() + .cloned() + .unwrap(), + ), + ) + .await; + + let error = result + .map(common::tool_error) + .expect("tool result must report failure"); + assert!( + error.to_string().contains("partial"), + "unexpected strict error: {error}" + ); + client.cancel().await?; + task.await??; + Ok(()) +} \ No newline at end of file diff --git a/crates/devup-mcp/tests/composite_export/strict_tsx_export_rejects_lossy_projection.rs b/crates/devup-mcp/tests/composite_export/strict_tsx_export_rejects_lossy_projection.rs new file mode 100644 index 00000000..9a85b49b --- /dev/null +++ b/crates/devup-mcp/tests/composite_export/strict_tsx_export_rejects_lossy_projection.rs @@ -0,0 +1,708 @@ + + +#[tokio::test] +async fn strict_tsx_export_rejects_lossy_projection() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::lossy()); + // Path validation now precedes collection: use an allowed path so this + // test still isolates strict projection rejection and no-write behavior. + let server = DevupServer::with_output_roots( + Services::new(Arc::new(ConnectedAuth), upstream), + vec![std::env::temp_dir()], + )?; + 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 output_path = std::env::temp_dir().join(format!( + "devup-mcp-strict-lossy-{}-{}.tsx", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + + let result = client + .call_tool( + CallToolRequestParams::new("devup_figma_export").with_arguments( + json!({ + "url": "https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs": ["tsx"], + "strict": true, + "outputPaths": {"tsx": output_path.to_string_lossy()} + }) + .as_object() + .cloned() + .unwrap(), + ), + ) + .await; + + let error = result + .map(common::tool_error) + .expect("tool result must report failure"); + assert!( + error.to_string().contains("lossy"), + "unexpected strict error: {error}" + ); + let wrote_rejected_output = output_path.exists(); + if wrote_rejected_output { + std::fs::remove_file(&output_path)?; + } + assert!( + !wrote_rejected_output, + "strict rejection must not write output files" + ); + client.cancel().await?; + task.await??; + Ok(()) +} + +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": ["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": {} + }, + { + "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": { + "collections": [{ + "id": "c", "name": "Theme", "defaultModeId": "m", + "modes": [{"modeId": "m", "name": "Default"}] + }], + "variables": [{ + "id": "v", "name": "primary", "resolvedType": "COLOR", + "variableCollectionId": "c", "codeSyntax": {"WEB": "primary"}, + "valuesByMode": {"m": {"r": 0, "g": 0.4, "b": 1, "a": 1}} + }], + "styles": [], + "usedVariableIds": ["v"], + "usedStyleIds": [], + "usedRemoteVariables": [], + "localComplete": false, + "usedRemoteComplete": true, + "unresolved": [] + }, + "integrity": { + "nodeCount": 2, + "variableRefCount": 1, + "styleRefCount": 0, + "utf8Bytes": 0 + } + }); + if partial { + envelope["resources"]["variables"] = json!([]); + envelope["resources"]["unresolved"] = json!([{ + "id": "v", "kind": "variable", "reason": "fixture-unresolved" + }]); + } + if lossy { + envelope["snapshot"]["nodes"][0]["fields"]["isMask"] = json!(true); + envelope["snapshot"]["nodes"][0]["fields"]["effects"] = + json!([{"type": "BACKGROUND_BLUR"}]); + } + let envelope_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 _ = envelope_bytes; + + // 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": envelope.to_string()} + ]}), + } +} + +fn asset_export_result( + version: Option<&str>, + request: &devup_mcp_figma::AssetRequest, +) -> UpstreamResult { + let bytes = b"synthetic-png"; + let descriptor = json!({ + "kind":"devupAssetExport","fileKey":"FileKey123","version":version, + "assetId":request.asset_id,"nodeId":request.node_id,"field":request.field, + "imageHash":request.image_hash,"format":request.format,"scale":request.scale,"renderMode":request.render_mode, + "status":"exported","byteLength":bytes.len(), + "sha256":"294ad7145322ec19f8250cca8480a933f1ce8c9e2ad1038e7ae8930d55a6598a" + }); + UpstreamResult { + raw: json!({"content":[ + {"type":"text","text":descriptor.to_string()}, + {"type":"image","data":STANDARD.encode(bytes),"mimeType":"image/png"} + ]}), + } +} + +fn reference_png_base64() -> &'static str { + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +} + +#[tokio::test] +async fn p3_public_root_mapping_survives_resource_delivery_and_file_writes() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::complete()); + let root = unique_temp_dir("p3-public-root")?; + let output_path = root.join("icons").join("BI icon.png"); + let code_path = root.join("Screen.tsx"); + let server = DevupServer::with_output_roots( + Services::new(Arc::new(ConnectedAuth), upstream), + vec![root.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 result = call_result(&client, "devup_figma_export", json!({ + "url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs":["tsx","assetManifest"], "delivery":"resource", "assetPublicRoot":root, + "outputPaths":{"tsx":code_path}, + "assetRequests":[{"assetId":"1:3:fills:0","renderMode":"node","format":"png","scale":2,"outputPath":output_path}] + })).await?; + let resources = result.structured_content.as_ref().unwrap()["resources"] + .as_array() + .unwrap(); + let manifest_uri = resources + .iter() + .find(|item| item["name"] == "asset-manifest.json") + .unwrap()["uri"] + .as_str() + .unwrap(); + let manifest: Value = + serde_json::from_slice(&read_resource_bytes(&client, manifest_uri).await?)?; + assert_eq!( + asset_by_id(&manifest, "1:3:fills:0")["path"], + "/icons/BI%20icon.png" + ); + let code_uri = resources.iter().find(|item| item["name"] == "tsx").unwrap()["uri"] + .as_str() + .unwrap(); + let code = String::from_utf8(read_resource_bytes(&client, code_uri).await?)?; + assert!(code.contains("/icons/BI%20icon.png"), "{code}"); + assert_eq!(fs::read_to_string(&code_path)?, code); + assert_eq!(fs::read(&output_path)?, b"synthetic-png"); + client.cancel().await?; + task.await??; + fs::remove_dir_all(root)?; + Ok(()) +} + +#[derive(Default)] +struct R3InterruptedUpstream { + timeout_first: bool, + snapshots: AtomicUsize, + first_asset: AtomicUsize, + second_asset: AtomicUsize, +} +#[async_trait] +impl FigmaUpstream for R3InterruptedUpstream { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".into()]) + } + async fn call_read_tool(&self, call: ReadToolCall) -> Result { + match call { + ReadToolCall::Snapshot { .. } => { + self.snapshots.fetch_add(1, Ordering::SeqCst); + Ok(fast_envelope_result(false, false)) + } + ReadToolCall::AssetExport { + version, request, .. + } => { + let counter = if request.asset_id == "1:2:fills:1" { + &self.first_asset + } else { + &self.second_asset + }; + let attempt = counter.fetch_add(1, Ordering::SeqCst); + if request.asset_id == "1:3:fills:0" && attempt == 0 { + if self.timeout_first { + std::future::pending::<()>().await; + } + return Err(DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "synthetic interrupted asset read", + true, + )); + } + Ok(asset_export_result(version.as_deref(), &request)) + } + _ => unreachable!(), + } + } +} + +#[tokio::test] +async fn r3_asset_job_resumes_only_pending_read_and_recovers_identical_request() +-> anyhow::Result<()> { + r3_assert_resume(false).await +} + +#[tokio::test(start_paused = true)] +async fn r3_asset_job_timeout_keeps_first_asset_and_resumes_pending_read() -> anyhow::Result<()> { + r3_assert_resume(true).await +} + +async fn r3_assert_resume(timeout_first: bool) -> anyhow::Result<()> { + let upstream = Arc::new(R3InterruptedUpstream { + timeout_first, + ..Default::default() + }); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); + let (st, ct) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(st).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(ct).await?; + let args = json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", "outputs":["assetManifest"], + "assetRequests":[{"assetId":"1:2:fills:1","renderMode":"node"},{"assetId":"1:3:fills:0","renderMode":"node"}]}); + let mut paused = call(&client, "devup_figma_export", args.clone()).await?; + if timeout_first { + assert_eq!(paused["assetJob"]["state"], "running"); + let timeout_ms = paused["exportJob"]["calls"] + .as_array() + .unwrap() + .last() + .unwrap()["timeoutMs"] + .as_u64() + .unwrap(); + assert!(timeout_ms >= paused["exportJob"]["assetTimeoutMs"].as_u64().unwrap()); + tokio::time::advance(std::time::Duration::from_millis(timeout_ms + 1000)).await; + paused = call( + &client, + "devup_figma_export", + json!({"jobId":paused["assetJob"]["jobId"]}), + ) + .await?; + } + assert_eq!(paused["assetJob"]["state"], "paused", "{paused}"); + let id = paused["assetJob"]["jobId"].as_str().unwrap(); + assert_eq!(paused["exportJob"]["assets"][0]["status"], "exported"); + assert_eq!( + paused["exportJob"]["assets"][0]["fileState"], + "not-requested" + ); + // Polls answer compact rows, and the alias no longer repeats the job. + assert!(paused["exportJob"]["assets"][0].get("renderInfo").is_none()); + assert_eq!(paused["assetJob"]["aliasOf"], "exportJob"); + // responseFields on a poll answers only what it selected. + let picked = call( + &client, + "devup_figma_export", + json!({"jobId":id,"responseFields":["exportJob.assets.renderInfo"]}), + ) + .await?; + assert_eq!(picked["selections"][0]["matched"], 1, "{picked}"); + assert!(picked["selections"][0]["values"][0].is_object()); + assert_eq!(picked["exportJob"]["state"], "paused"); + assert!(picked.get("assetJob").is_none(), "{picked}"); + let recovered = call(&client, "devup_figma_export", args).await?; + assert_eq!(recovered["assetJob"]["jobId"], id); + let resumed = call( + &client, + "devup_figma_export", + json!({"jobId":id,"jobAction":"resume"}), + ) + .await?; + let mut result = resumed; + for _ in 0..50 { + if result["assetJob"]["state"] == "complete" { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + result = call(&client, "devup_figma_export", json!({"jobId":id})).await?; + } + assert_eq!(result["assetJob"]["state"], "complete", "{result}"); + assert_eq!( + asset_by_id(&result["assetManifest"], "1:3:fills:0")["status"], + "exported" + ); + assert_eq!(upstream.snapshots.load(Ordering::SeqCst), 1); + assert_eq!(upstream.first_asset.load(Ordering::SeqCst), 1); + assert_eq!(upstream.second_asset.load(Ordering::SeqCst), 2); + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn r3_oversized_assets_rejected_before_upstream() -> anyhow::Result<()> { + let upstream = Arc::new(FastFixtureUpstream::complete()); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); + let (st, ct) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(st).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(ct).await?; + let assets: Vec<_> = (0..16) + .map(|i| json!({"assetId":format!("1:{i}:node")})) + .collect(); + let result=client.call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(json!({ + "url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["assetManifest"],"assetRequests":assets + }).as_object().unwrap().clone())).await; + common::tool_error(result?); + assert_eq!(upstream.calls.load(Ordering::SeqCst), 0); + client.cancel().await?; + task.await??; + Ok(()) +} + +#[derive(Default)] +struct R3BatchUpstream { + snapshots: AtomicUsize, + assets: AtomicUsize, +} +#[async_trait] +impl FigmaUpstream for R3BatchUpstream { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".into()]) + } + async fn call_read_tool(&self, call: ReadToolCall) -> Result { + match call { + ReadToolCall::Snapshot { .. } => { + self.snapshots.fetch_add(1, Ordering::SeqCst); + let raw = fast_envelope_result(false, false).raw; + let mut envelope: Value = + serde_json::from_str(raw["content"][0]["text"].as_str().unwrap()).unwrap(); + let mut nodes = vec![envelope["snapshot"]["nodes"][0].clone()]; + nodes[0]["fields"]["childrenIds"] = + json!((10..26).map(|i| format!("1:{i}")).collect::>()); + nodes[0]["fields"]["fills"] + .as_array_mut() + .unwrap() + .truncate(1); + for i in 10..26 { + let mut node = envelope["snapshot"]["nodes"][1].clone(); + node["id"] = json!(format!("1:{i}")); + nodes.push(node); + } + envelope["snapshot"]["nodes"] = json!(nodes); + envelope["integrity"]["nodeCount"] = json!(17); + loop { + let size = serde_json::to_vec(&envelope).unwrap().len(); + if envelope["integrity"]["utf8Bytes"] == size { + break; + } + envelope["integrity"]["utf8Bytes"] = json!(size); + } + Ok(UpstreamResult { + raw: json!({"content":[{"type":"text","text":envelope.to_string()}]}), + }) + } + ReadToolCall::AssetExport { + version, request, .. + } => { + self.assets.fetch_add(1, Ordering::SeqCst); + Ok(asset_export_result(version.as_deref(), &request)) + } + _ => unreachable!(), + } + } +} + +#[tokio::test(start_paused = true)] +async fn r3_sixteen_assets_succeed_in_recommended_batches() -> anyhow::Result<()> { + let upstream = Arc::new(R3BatchUpstream::default()); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); + let (st, ct) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(st).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(ct).await?; + let assets: Vec<_> = (10..26) + .map(|i| json!({"assetId":format!("1:{i}:fills:0"),"renderMode":"node"})) + .collect(); + for batch in assets.chunks(3) { + let mut result = call( + &client, + "devup_figma_export", + json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2", + "outputs":["assetManifest"],"assetRequests":batch}), + ) + .await?; + for _ in 0..120 { + if result["assetJob"]["state"] == "complete" { + break; + } + tokio::time::advance(std::time::Duration::from_secs(1)).await; + result = call( + &client, + "devup_figma_export", + json!({"jobId":result["assetJob"]["jobId"]}), + ) + .await?; + } + assert_eq!(result["assetJob"]["state"], "complete", "{result}"); + for asset in batch { + assert_eq!( + asset_by_id(&result["assetManifest"], asset["assetId"].as_str().unwrap())["status"], + "exported" + ); + } + } + assert_eq!(upstream.assets.load(Ordering::SeqCst), 16); + assert_eq!(upstream.snapshots.load(Ordering::SeqCst), 6); + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn r3_different_output_job_does_not_wait_on_unresumable_owner() -> anyhow::Result<()> { + let upstream = Arc::new(R3InterruptedUpstream::default()); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); + let (st, ct) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(st).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(ct).await?; + let mut args = json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["assetManifest"], + "assetRequests":[{"assetId":"1:2:fills:1","renderMode":"node"},{"assetId":"1:3:fills:0","renderMode":"node"}]}); + let owner = call(&client, "devup_figma_export", args.clone()).await?; + assert_eq!(owner["assetJob"]["state"], "paused"); + args["outputs"] = json!(["tsx", "assetManifest"]); + let independent = call(&client, "devup_figma_export", args).await?; + assert_eq!( + independent["assetJob"]["state"], "complete", + "{independent}" + ); + assert_ne!(owner["assetJob"]["jobId"], independent["assetJob"]["jobId"]); + let resumed = call( + &client, + "devup_figma_export", + json!({"jobId":owner["assetJob"]["jobId"],"jobAction":"resume"}), + ) + .await?; + assert_eq!(resumed["assetJob"]["state"], "complete"); + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test(start_paused = true)] +async fn r3_cancelled_client_recovers_retained_asset_job() -> anyhow::Result<()> { + let upstream = Arc::new(R3InterruptedUpstream { + timeout_first: true, + ..Default::default() + }); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream.clone())); + let (st, ct) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(st).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(ct).await?; + let args = json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["assetManifest"], + "assetRequests":[{"assetId":"1:2:fills:1","renderMode":"node"},{"assetId":"1:3:fills:0","renderMode":"node"}]}); + let dropped = tokio::time::timeout( + std::time::Duration::from_millis(100), + call(&client, "devup_figma_export", args.clone()), + ) + .await; + assert!(dropped.is_err()); + let recovered = call(&client, "devup_figma_export", args).await?; + let id = &recovered["assetJob"]["jobId"]; + assert!(id.is_string()); + assert_eq!(upstream.snapshots.load(Ordering::SeqCst), 1); + let timeout_ms = recovered["exportJob"]["calls"] + .as_array() + .unwrap() + .last() + .unwrap()["timeoutMs"] + .as_u64() + .unwrap(); + tokio::time::advance(std::time::Duration::from_millis(timeout_ms + 1000)).await; + let paused = call(&client, "devup_figma_export", json!({"jobId":id})).await?; + assert_eq!(paused["assetJob"]["state"], "paused"); + let complete = call( + &client, + "devup_figma_export", + json!({"jobId":id,"jobAction":"resume"}), + ) + .await?; + assert_eq!(complete["assetJob"]["state"], "complete"); + assert_eq!(upstream.first_asset.load(Ordering::SeqCst), 1); + assert_eq!(upstream.second_asset.load(Ordering::SeqCst), 2); + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn r3_distinct_resource_jobs_keep_both_manifests_readable() -> anyhow::Result<()> { + let upstream = Arc::new(R3InterruptedUpstream::default()); + let server = DevupServer::new(Services::new(Arc::new(ConnectedAuth), upstream)); + let (st, ct) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(st).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(ct).await?; + let mut args = json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["assetManifest"],"delivery":"resource", + "assetRequests":[{"assetId":"1:2:fills:1","renderMode":"node"},{"assetId":"1:3:fills:0","renderMode":"node"}]}); + let owner = call(&client, "devup_figma_export", args.clone()).await?; + assert_eq!(owner["assetJob"]["state"], "paused"); + args["outputs"] = json!(["tsx", "assetManifest"]); + let second = call(&client, "devup_figma_export", args).await?; + let first = call( + &client, + "devup_figma_export", + json!({"jobId":owner["assetJob"]["jobId"],"jobAction":"resume"}), + ) + .await?; + for value in [first, second] { + assert_eq!(value["assetJob"]["state"], "complete"); + assert!( + value["exportJob"]["assets"] + .as_array() + .unwrap() + .iter() + .all(|a| a["status"] == "exported") + ); + let uri = value["resources"] + .as_array() + .unwrap() + .iter() + .find(|r| r["name"] == "asset-manifest.json") + .unwrap()["uri"] + .as_str() + .unwrap(); + let bytes = read_resource_bytes(&client, uri).await?; + let manifest: Value = serde_json::from_slice(&bytes)?; + assert_eq!(asset_by_id(&manifest, "1:3:fills:0")["status"], "exported"); + } + client.cancel().await?; + task.await??; + Ok(()) +} + +#[derive(Debug)] +struct R8SlowSnapshot; +#[async_trait] +impl FigmaUpstream for R8SlowSnapshot { + async fn list_tools(&self) -> Result, DevupError> { + Ok(vec!["use_figma".into()]) + } + async fn call_read_tool(&self, _: ReadToolCall) -> Result { + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + Ok(fast_envelope_result(false, false)) + } +} +#[tokio::test] +async fn r8_code_only_slow_capture_returns_pollable_job_before_client_deadline() +-> anyhow::Result<()> { + let server = DevupServer::with_output_roots( + Services::new(Arc::new(ConnectedAuth), Arc::new(R8SlowSnapshot)), + vec![std::env::temp_dir()], + )?; + let (st, ct) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(st).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(ct).await?; + let began = std::time::Instant::now(); + let response=call(&client,"devup_figma_export",json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["tsx","rawSnapshot","sourceMap"],"debug":true,"delivery":"inline"})).await?; + assert!( + began.elapsed() < std::time::Duration::from_secs(2), + "client blocked for {:?}", + began.elapsed() + ); + let id = response["exportJob"]["jobId"] + .as_str() + .expect("code-only collection needs a resumable job"); + assert!(response["exportJob"]["calls"][0]["detail"]["nodeId"].is_string()); + let mut result = response.clone(); + for _ in 0..8 { + result = call(&client, "devup_figma_export", json!({"jobId":id})).await?; + if result["exportJob"]["state"] == "complete" { + break; + } + } + assert_eq!(result["exportJob"]["state"], "complete"); + assert!(result["tsx"].is_string()); + assert!( + result["exportJob"]["calls"][0]["elapsedMs"] + .as_u64() + .unwrap() + >= 2900 + ); + client.cancel().await?; + task.await??; + Ok(()) +} + +#[tokio::test] +async fn r8_completed_exports_do_not_exhaust_active_job_capacity() -> anyhow::Result<()> { + let server = DevupServer::with_output_roots( + Services::new( + Arc::new(ConnectedAuth), + Arc::new(FastFixtureUpstream::complete()), + ), + vec![std::env::temp_dir()], + )?; + let (st, ct) = tokio::io::duplex(256 * 1024); + let task = tokio::spawn(async move { + server.serve(st).await?.waiting().await?; + anyhow::Ok(()) + }); + let client = ().serve(ct).await?; + for index in 0..10 { + let result=call(&client,"devup_figma_export",json!({"url":"https://www.figma.com/design/FileKey123/Fixture?node-id=1-2","outputs":["tsx"],"componentName":format!("Screen{index}"),"delivery":"inline"})).await?; + assert!( + result["tsx"].is_string(), + "completed export {index} must not consume active capacity: {result}" + ); + } + client.cancel().await?; + task.await??; + Ok(()) +} diff --git a/crates/devup-mcp/tests/fixtures/project-plan/figma.json b/crates/devup-mcp/tests/fixtures/project-plan/figma.json new file mode 100644 index 00000000..1498da4a --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/project-plan/figma.json @@ -0,0 +1,21 @@ +{ + "source": "Figma fixture signup/post", + "facts": { + "goal": "Build a post service", + "screens": [ + { + "name": "Signup", + "fields": [ + "email", + "display name" + ] + }, + { + "name": "Post list" + }, + { + "name": "Post detail" + } + ] + } +} diff --git a/crates/devup-mcp/tests/fixtures/project-plan/post.json b/crates/devup-mcp/tests/fixtures/project-plan/post.json new file mode 100644 index 00000000..27687101 --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/project-plan/post.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json", + "name": "post", + "columns": [ + { + "name": "id", + "type": "integer", + "nullable": false, + "primary_key": { + "auto_increment": true + } + }, + { + "name": "title", + "type": "text", + "nullable": false + }, + { + "name": "body", + "type": "text", + "nullable": false + }, + { + "name": "user_id", + "type": "integer", + "nullable": false, + "index": true, + "foreign_key": { + "ref_table": "user", + "ref_columns": [ + "id" + ], + "on_delete": "restrict" + } + } + ] +} diff --git a/crates/devup-mcp/tests/fixtures/vespertide/model.schema.json b/crates/devup-mcp/tests/fixtures/vespertide/model.schema.json new file mode 100644 index 00000000..6baa3264 --- /dev/null +++ b/crates/devup-mcp/tests/fixtures/vespertide/model.schema.json @@ -0,0 +1,852 @@ +{ + "$defs": { + "CheckViolationStrategy": { + "description": "How `AddConstraint(Check)` should handle pre-existing rows that\nviolate the CHECK predicate.\n\n\"Violation\" is computed statically by the narrow-shape parser in\n`vespertide-planner::validate::check_default` and the cleanup SQL is\nemitted by `vespertide-query::sql::add_constraint::check`. CHECK\nexpressions that exceed the narrow shape (function calls, AND/OR\ncomposition, subqueries) are *not* covered by either side ? the\ndetector skips them and no pre-cleanup is emitted.", + "oneOf": [ + { + "description": "Set the named column of every violating row to `NULL` ahead of\nthe `ADD CONSTRAINT`. The row is preserved; only the failing\nvalue is cleared.\n\nOnly applicable when `column` is nullable in the baseline; the\nSQL generator emits a runtime SQL error otherwise (UPDATE ...\nSET col = NULL violates the existing NOT NULL). The revision\nCLI's narrow-shape parser identifies the target column\nautomatically; the user rarely sets this directly in the model\nJSON.", + "properties": { + "column": { + "$ref": "#/$defs/ColumnName", + "description": "Which column receives `SET = NULL`. Narrow-shape CHECKs\nalways reduce to a single column, so this is the only\ncolumn the cleanup affects." + }, + "kind": { + "const": "nullify_violating_column", + "type": "string" + } + }, + "required": [ + "kind", + "column" + ], + "type": "object" + }, + { + "description": "Delete the violating rows outright ahead of the\n`ADD CONSTRAINT`. Use when the violating rows are themselves\ninvalid (logically dangling records).\n\nThis is also the canonical default for the wire format: v0.1.x\nmodels carry no `strategy` field and so deserialize to\n`DeleteViolatingRows`. The revision CLI re-prompts for an\nexplicit choice, and the prompt offers\n`NullifyViolatingColumn { column }` when the narrow-shape\nparser can identify a nullable target column.", + "properties": { + "kind": { + "const": "delete_violating_rows", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "ColumnDef": { + "description": "Definition of a single table column, including its type, nullability, and inline constraints.\n\nInline constraints (`primary_key`, `unique`, `index`, `foreign_key`) are the preferred way to\ndeclare constraints in model JSON files. Call [`TableDef::normalize`] to convert them into\ntable-level [`TableConstraint`] entries before diffing or SQL generation.\n\nUse [`ColumnDef::new`] to construct a column programmatically, then chain the setter methods\n(`.primary_key()`, `.unique()`, `.index()`, `.foreign_key()`, `.default()`, `.comment()`) to\nattach optional fields.\n\n[`TableDef::normalize`]: crate::schema::TableDef::normalize\n[`TableConstraint`]: crate::schema::TableConstraint", + "properties": { + "comment": { + "type": [ + "string", + "null" + ] + }, + "default": { + "anyOf": [ + { + "$ref": "#/$defs/DefaultValue" + }, + { + "type": "null" + } + ] + }, + "foreign_key": { + "anyOf": [ + { + "$ref": "#/$defs/ForeignKeySyntax" + }, + { + "type": "null" + } + ] + }, + "index": { + "anyOf": [ + { + "$ref": "#/$defs/StrOrBoolOrArray" + }, + { + "type": "null" + } + ] + }, + "name": { + "$ref": "#/$defs/ColumnName" + }, + "nullable": { + "type": "boolean" + }, + "primary_key": { + "anyOf": [ + { + "$ref": "#/$defs/PrimaryKeySyntax" + }, + { + "type": "null" + } + ] + }, + "type": { + "$ref": "#/$defs/ColumnType" + }, + "unique": { + "anyOf": [ + { + "$ref": "#/$defs/StrOrBoolOrArray" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "type", + "nullable" + ], + "type": "object" + }, + "ColumnName": { + "description": "The name of a table column, always in `snake_case` by convention.\n\nConstruction and serde round-trip:\n\n```rust\nuse vespertide_core::schema::names::ColumnName;\n\nlet col: ColumnName = ColumnName::new(\"email\");\nlet via_from: ColumnName = \"email\".into();\n\nassert_eq!(col.as_str(), \"email\");\nassert!(col == \"email\");\nassert_eq!(col, via_from);\n\n// Wire format is byte-identical to a plain JSON string.\nlet json = serde_json::to_string(&col).unwrap();\nassert_eq!(json, r#\"\"email\"\"#);\nlet back: ColumnName = serde_json::from_str(&json).unwrap();\nassert_eq!(back, col);\n```", + "type": "string" + }, + "ColumnType": { + "anyOf": [ + { + "$ref": "#/$defs/SimpleColumnType", + "description": "A parameter-free SQL type such as `INTEGER`, `TEXT`, or `UUID`." + }, + { + "$ref": "#/$defs/ComplexColumnType", + "description": "A parameterised SQL type such as `VARCHAR(n)`, `NUMERIC(p,s)`, or a named enum." + } + ], + "description": "The SQL type of a column, either a parameter-free simple type or a parameterised complex type.\n\nIn JSON model files a simple type is written as a plain string (`\"integer\"`, `\"text\"`, etc.)\nwhile a complex type is written as an object with a `\"kind\"` discriminant\n(`{\"kind\": \"varchar\", \"length\": 255}`).\n\nAlways construct via the wrapped variants:\n```\nuse vespertide_core::{ColumnType, SimpleColumnType, ComplexColumnType};\nlet t1 = ColumnType::Simple(SimpleColumnType::Integer);\nlet t2 = ColumnType::Complex(ComplexColumnType::Varchar { length: 255 });\n```" + }, + "ComplexColumnType": { + "description": "Parameterised SQL column types that require additional configuration beyond a simple keyword.\n\nIn JSON model files these are written as objects with a `\"kind\"` discriminant, for example\n`{\"kind\": \"varchar\", \"length\": 255}` or `{\"kind\": \"enum\", \"name\": \"status\", \"values\": [...]}`.\n\nUse these via [`ColumnType::Complex`].\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.", + "oneOf": [ + { + "description": "Variable-length character string with a maximum byte length (`VARCHAR(n)`).", + "properties": { + "kind": { + "const": "varchar", + "type": "string" + }, + "length": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "length" + ], + "type": "object" + }, + { + "description": "Exact fixed-point number with configurable precision and scale (`NUMERIC(p, s)`).", + "properties": { + "kind": { + "const": "numeric", + "type": "string" + }, + "precision": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "scale": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "precision", + "scale" + ], + "type": "object" + }, + { + "description": "Fixed-length character string padded with spaces (`CHAR(n)`).", + "properties": { + "kind": { + "const": "char", + "type": "string" + }, + "length": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "length" + ], + "type": "object" + }, + { + "description": "Escape hatch for database-specific types not covered by other variants.\nBreaks cross-database portability; avoid unless absolutely necessary.", + "properties": { + "custom_type": { + "type": "string" + }, + "kind": { + "const": "custom", + "type": "string" + } + }, + "required": [ + "kind", + "custom_type" + ], + "type": "object" + }, + { + "description": "Named enum type. String enums map to a native DB enum; integer enums store as `INTEGER`.\nSee [`EnumValues`] for the distinction.", + "properties": { + "kind": { + "const": "enum", + "type": "string" + }, + "name": { + "type": "string" + }, + "values": { + "$ref": "#/$defs/EnumValues" + } + }, + "required": [ + "kind", + "name", + "values" + ], + "type": "object" + } + ] + }, + "DefaultValue": { + "anyOf": [ + { + "description": "A boolean default (`true` or `false`).", + "type": "boolean" + }, + { + "description": "An integer default value.", + "format": "int64", + "type": "integer" + }, + { + "description": "A floating-point default value.", + "format": "double", + "type": "number" + }, + { + "description": "A SQL expression or string literal default (e.g. `\"'pending'\"` or `\"NOW()\"`).", + "type": "string" + } + ], + "description": "A column default value that can be a boolean, integer, float, or SQL expression string.\n\nIn JSON model files the `\"default\"` field accepts any of these forms:\n- `true` / `false` — boolean literal.\n- `0`, `42` — integer literal.\n- `0.0`, `1.5` — floating-point literal.\n- `\"'pending'\"` — SQL string literal (note the inner single quotes).\n- `\"NOW()\"` — SQL function call (no surrounding quotes).\n\nUse [`DefaultValue::to_sql`] to convert to the SQL representation for DDL generation.\n\n`StringOrBool` is a backwards-compatibility alias for this type.\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm." + }, + "EnumValues": { + "anyOf": [ + { + "description": "String enum: each variant is a plain string stored in a native DB enum type.", + "items": { + "type": "string" + }, + "type": "array" + }, + { + "description": "Integer enum: each variant has an explicit numeric value stored as `INTEGER`.", + "items": { + "$ref": "#/$defs/NumValue" + }, + "type": "array" + } + ], + "description": "The set of allowed values for an enum column, either string-based or integer-based.\n\n**String enums** map to a native `PostgreSQL` `ENUM` type. Adding or removing values requires a\ndatabase migration (`ALTER TYPE`).\n\n**Integer enums** are stored as `INTEGER`. New variants can be added to the model without any\ndatabase migration because the underlying column type never changes.\n\nChoose integer enums for expandable value sets (roles, priorities) and string enums for\nstable, human-readable status fields." + }, + "ForeignKeyDef": { + "description": "Full foreign key definition used in the normalized table representation.\n\nSpecifies the referenced table and columns along with optional referential actions for\n`ON DELETE` and `ON UPDATE`. This is the canonical form produced after parsing any of the\nthree [`ForeignKeySyntax`] variants.\n\nAlways add `\"index\": true` on the column carrying the foreign key for query performance.", + "properties": { + "on_delete": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ], + "description": "Action to take on child rows when the parent row is deleted." + }, + "on_update": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ], + "description": "Action to take on child rows when the referenced column(s) in the parent row are updated." + }, + "ref_columns": { + "description": "The column(s) in the referenced table that this foreign key points to.", + "items": { + "$ref": "#/$defs/ColumnName" + }, + "type": "array" + }, + "ref_table": { + "$ref": "#/$defs/TableName", + "description": "The table being referenced (the \"parent\" side of the relationship)." + } + }, + "required": [ + "ref_table", + "ref_columns" + ], + "type": "object" + }, + "ForeignKeyOrphanStrategy": { + "description": "How `AddConstraint(ForeignKey)` should handle pre-existing orphan\nrows ? child rows whose FK column(s) reference a value that does\nnot exist in the parent table.\n\n\"Orphan\" is computed via the standard SQL `NOT EXISTS` correlated\nsubquery against the parent table; see\n`vespertide-query::sql::add_constraint::foreign_key` for the emitted\nSQL pattern.", + "oneOf": [ + { + "description": "Set the FK column(s) of orphan child rows to `NULL` ahead of the\n`ADD CONSTRAINT`. The row is preserved; only the dangling\nreference is cleared.\n\nOnly applicable when **every** column of the FK is nullable; the\nSQL generator falls back to a runtime error if the user picks\nthis strategy on a NOT NULL column set.", + "properties": { + "kind": { + "const": "nullify_orphans", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "description": "Delete the orphan child rows outright ahead of the\n`ADD CONSTRAINT`. Use when the orphan rows are themselves\ninvalid (logically dangling records).", + "properties": { + "kind": { + "const": "delete_orphans", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, + "ForeignKeySyntax": { + "anyOf": [ + { + "description": "Shorthand string in `\"table.column\"` format, e.g. `\"user.id\"`.", + "type": "string" + }, + { + "$ref": "#/$defs/ReferenceSyntaxDef", + "description": "Object with a `\"references\"` key in `\"table.column\"` format plus optional actions." + }, + { + "$ref": "#/$defs/ForeignKeyDef", + "description": "Full object with explicit `ref_table`, `ref_columns`, and optional actions." + } + ], + "description": "Inline foreign key declaration on a [`ColumnDef`], supporting three levels of verbosity.\n\nIn JSON model files you can write:\n- `\"foreign_key\": \"user.id\"` (string shorthand)\n- `\"foreign_key\": {\"references\": \"user.id\", \"on_delete\": \"cascade\"}` (reference object)\n- `\"foreign_key\": {\"ref_table\": \"user\", \"ref_columns\": [\"id\"], \"on_delete\": \"cascade\"}` (full object)\n\nAll three forms are normalized into a [`ForeignKeyDef`] by the planner.\n\n[`ColumnDef`]: crate::schema::ColumnDef" + }, + "KeepPolicy": { + "description": "Which row of a duplicate group is kept when `DeleteDuplicates` runs.\n\n\"First\" / \"Last\" are defined by the table's PRIMARY KEY ordering:\n`First = MIN(pk)`, `Last = MAX(pk)`. Requires a single-column PK.", + "oneOf": [ + { + "const": "first", + "description": "Keep the row with the smallest primary-key value.", + "type": "string" + }, + { + "const": "last", + "description": "Keep the row with the largest primary-key value.", + "type": "string" + } + ] + }, + "NumValue": { + "description": "A single variant of an integer-backed enum, pairing a Rust-friendly name with its stored value.\n\nUsed inside [`EnumValues::Integer`] to define enums that are stored as `INTEGER` in the\ndatabase. Leave gaps between values (e.g. 0, 10, 20) so new variants can be inserted later\nwithout renumbering.", + "properties": { + "name": { + "description": "The variant name used in generated code (e.g. `\"active\"`).", + "type": "string" + }, + "value": { + "description": "The integer value stored in the database column.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + }, + "PrimaryKeyAdditionStrategy": { + "description": "How `AddConstraint(PrimaryKey)` should handle pre-existing\nduplicate rows in the chosen column set.\n\n`KeepPolicy` is reused from `UniqueConstraintStrategy` (F2) -\n\"First\" / \"Last\" are defined by the table's surviving primary-key\nor row identity ordering. The cleanup SQL is emitted by\n`vespertide-query::sql::add_constraint::primary_key`.", + "oneOf": [ + { + "description": "Emit a `DELETE` statement ahead of `ADD CONSTRAINT PRIMARY KEY`\nso only one row per group of new-PK duplicates survives.", + "properties": { + "keep": { + "$ref": "#/$defs/KeepPolicy", + "description": "Which row of each duplicate group is preserved." + }, + "kind": { + "const": "delete_duplicates", + "type": "string" + } + }, + "required": [ + "kind", + "keep" + ], + "type": "object" + } + ] + }, + "PrimaryKeyDef": { + "description": "Configuration for a primary key declared with the object syntax.\n\nWhen `auto_increment` is `true` the column is generated as a serial / identity column\n(`PostgreSQL` `SERIAL`, `MySQL` `AUTO_INCREMENT`, `SQLite` `AUTOINCREMENT`).\nUse [`PrimaryKeySyntax::Object`] to pass this to a [`ColumnDef`].\n\n[`ColumnDef`]: crate::schema::ColumnDef", + "properties": { + "auto_increment": { + "default": false, + "description": "When `true`, the column value is generated automatically by the database on insert.", + "type": "boolean" + } + }, + "type": "object" + }, + "PrimaryKeySyntax": { + "anyOf": [ + { + "description": "Shorthand: `true` marks the column as a primary key without auto-increment.", + "type": "boolean" + }, + { + "$ref": "#/$defs/PrimaryKeyDef", + "description": "Full object syntax allowing `auto_increment` to be configured." + } + ], + "description": "Inline primary key declaration on a [`ColumnDef`], supporting both shorthand and full syntax.\n\nIn JSON model files you can write either:\n- `\"primary_key\": true` (shorthand, no auto-increment)\n- `\"primary_key\": {\"auto_increment\": true}` (full object syntax)\n\n[`ColumnDef`]: crate::schema::ColumnDef" + }, + "ReferenceAction": { + "description": "The referential action taken on child rows when the referenced parent row changes.\n\nUsed in `ForeignKeyDef::on_delete` and `ForeignKeyDef::on_update` to control cascading\nbehaviour. In JSON model files these are written in `snake_case`\n(e.g. `\"on_delete\": \"cascade\"`).", + "oneOf": [ + { + "const": "cascade", + "description": "Automatically delete or update child rows when the parent row is deleted or updated (`CASCADE`).", + "type": "string" + }, + { + "const": "restrict", + "description": "Prevent the parent row from being deleted or updated if child rows exist (`RESTRICT`).", + "type": "string" + }, + { + "const": "set_null", + "description": "Set the foreign key column(s) in child rows to `NULL` when the parent changes (`SET NULL`).\nThe column must be nullable.", + "type": "string" + }, + { + "const": "set_default", + "description": "Set the foreign key column(s) in child rows to their column default when the parent changes (`SET DEFAULT`).", + "type": "string" + }, + { + "const": "no_action", + "description": "Do nothing to child rows; the database defers enforcement or raises an error (`NO ACTION`).", + "type": "string" + } + ] + }, + "ReferenceSyntaxDef": { + "description": "Compact foreign key syntax using a `\"references\"` string in `\"table.column\"` format.\n\nUseful when you only need to specify the target and optionally an `on_delete` action without\nlisting columns explicitly. The planner resolves the column from the `\"table.column\"` string.", + "properties": { + "on_delete": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "on_update": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "references": { + "description": "Reference target in `\"table.column\"` format, e.g. `\"user.id\"`.", + "type": "string" + } + }, + "required": [ + "references" + ], + "type": "object" + }, + "SimpleColumnType": { + "description": "Parameter-free SQL column types supported across all backends.\n\nEach variant maps directly to a standard SQL type. Use these via\n[`ColumnType::Simple`] when no length, precision, or scale is needed.", + "oneOf": [ + { + "const": "small_int", + "description": "16-bit signed integer (`SMALLINT`).", + "type": "string" + }, + { + "const": "integer", + "description": "32-bit signed integer (`INTEGER`). Supports `auto_increment`.", + "type": "string" + }, + { + "const": "big_int", + "description": "64-bit signed integer (`BIGINT`). Supports `auto_increment`.", + "type": "string" + }, + { + "const": "real", + "description": "32-bit floating-point number (`REAL`).", + "type": "string" + }, + { + "const": "double_precision", + "description": "64-bit floating-point number (`DOUBLE PRECISION`).", + "type": "string" + }, + { + "const": "text", + "description": "Unbounded Unicode text (`TEXT`).", + "type": "string" + }, + { + "const": "boolean", + "description": "Boolean true/false value (`BOOLEAN`).", + "type": "string" + }, + { + "const": "date", + "description": "Calendar date without time (`DATE`).", + "type": "string" + }, + { + "const": "time", + "description": "Time of day without date (`TIME`).", + "type": "string" + }, + { + "const": "timestamp", + "description": "Date and time without timezone (`TIMESTAMP`).", + "type": "string" + }, + { + "const": "timestamptz", + "description": "Date and time with timezone (`TIMESTAMPTZ`). Prefer this over `Timestamp`.", + "type": "string" + }, + { + "const": "interval", + "description": "Time span / duration (`INTERVAL`).", + "type": "string" + }, + { + "const": "bytea", + "description": "Variable-length binary data (`BYTEA`).", + "type": "string" + }, + { + "const": "uuid", + "description": "Universally unique identifier (`UUID`).", + "type": "string" + }, + { + "const": "json", + "description": "JSON value stored as text (`JSON`). Cross-backend compatible; prefer over `jsonb`.", + "type": "string" + }, + { + "const": "inet", + "description": "IPv4 or IPv6 host address (`INET`). PostgreSQL-specific.", + "type": "string" + }, + { + "const": "cidr", + "description": "IPv4 or IPv6 network address (`CIDR`). PostgreSQL-specific.", + "type": "string" + }, + { + "const": "macaddr", + "description": "MAC address (`MACADDR`). PostgreSQL-specific.", + "type": "string" + }, + { + "const": "xml", + "description": "XML document (`XML`). PostgreSQL-specific.", + "type": "string" + } + ] + }, + "StrOrBoolOrArray": { + "anyOf": [ + { + "description": "A named constraint or group identifier.", + "type": "string" + }, + { + "description": "Multiple constraint group names for multi-group membership.", + "items": { + "type": "string" + }, + "type": "array" + }, + { + "description": "`true` to enable with an auto-generated name; `false` to disable.", + "type": "boolean" + } + ], + "description": "A JSON value that can be a string, an array of strings, or a boolean.\n\nUsed for inline `\"unique\"` and `\"index\"` declarations on a [`ColumnDef`]:\n- `true` / `false` — enable or disable the constraint with an auto-generated name.\n- A single string — the constraint name (used to group columns into a composite constraint).\n- An array of strings — a list of constraint names for multi-group membership.\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.\n\n[`ColumnDef`]: crate::schema::ColumnDef" + }, + "TableConstraint": { + "description": "A table-level constraint produced by [`TableDef::normalize`].\n\nInline column constraints (`primary_key`, `unique`, `index`, `foreign_key`) declared in model\nJSON files are converted into `TableConstraint` variants during normalization. You rarely\nconstruct these directly; the planner and SQL generator consume them.\n\nThis enum is `#[non_exhaustive]`: new variants may be added in future releases.\nDownstream `match` expressions should include a wildcard arm.\n\n[`TableDef::normalize`]: crate::schema::TableDef::normalize", + "oneOf": [ + { + "description": "Primary key constraint, optionally with auto-increment (serial / identity) semantics.\n\n`strategy` controls how pre-existing duplicate rows in the\nchosen column set are handled when this constraint is added to\nan already-populated table. The canonical default is\n[`PrimaryKeyAdditionStrategy::DeleteDuplicates { keep: KeepPolicy::First }`]\n(omitted from the JSON wire format). NULL violations on PK\ncolumns are handled separately by the F1 `fill_with` mechanism;\nthe revision CLI prompts for fill values on every nullable PK\ncolumn.\n\n`strategy` is **stripped from `model.schema.json`** but\n**preserved in `migration.schema.json`**.", + "properties": { + "auto_increment": { + "default": false, + "type": "boolean" + }, + "columns": { + "items": { + "$ref": "#/$defs/ColumnName" + }, + "type": "array" + }, + "type": { + "const": "primary_key", + "type": "string" + } + }, + "required": [ + "type", + "columns" + ], + "type": "object" + }, + { + "description": "Unique constraint ensuring no two rows share the same value(s) in the listed columns.\n\n`strategy` controls how pre-existing duplicate rows are handled when\nthis constraint is added to an already-populated table. The\ncanonical default is [`UniqueConstraintStrategy::DeleteDuplicates { keep: KeepPolicy::First }`],\nwhich matches v0.1.x behaviour and is omitted from the JSON wire\nformat. Other strategies (e.g. `DeleteDuplicates`) emit a pre-cleanup\nstep ahead of `ADD CONSTRAINT` so the migration succeeds even when\nproduction data has duplicates.\n\n`strategy` is **stripped from `model.schema.json`** by the\nschema generator (`vespertide-schema-gen`), but **preserved in\n`migration.schema.json`** since migration files carry the\nrevision CLI's stamped choice.", + "properties": { + "columns": { + "items": { + "$ref": "#/$defs/ColumnName" + }, + "type": "array" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "unique", + "type": "string" + } + }, + "required": [ + "type", + "columns" + ], + "type": "object" + }, + { + "description": "Foreign key constraint linking columns in this table to columns in another table.\n\n`orphan_strategy` controls how pre-existing orphan rows are\nhandled when this constraint is added to an already-populated\ntable. The canonical default is\n[`ForeignKeyOrphanStrategy::NullifyOrphans`] (omitted from the\nJSON wire format). The revision CLI re-prompts the user for an\nexplicit choice; the default exists only so v0.1.x model files\ncontinue to deserialize.\n\n`orphan_strategy` is **stripped from `model.schema.json`** by\nthe schema generator but **preserved in `migration.schema.json`**.", + "properties": { + "columns": { + "items": { + "$ref": "#/$defs/ColumnName" + }, + "type": "array" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "on_delete": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "on_update": { + "anyOf": [ + { + "$ref": "#/$defs/ReferenceAction" + }, + { + "type": "null" + } + ] + }, + "ref_columns": { + "items": { + "$ref": "#/$defs/ColumnName" + }, + "type": "array" + }, + "ref_table": { + "$ref": "#/$defs/TableName" + }, + "type": { + "const": "foreign_key", + "type": "string" + } + }, + "required": [ + "type", + "columns", + "ref_table", + "ref_columns" + ], + "type": "object" + }, + { + "description": "Arbitrary SQL CHECK expression enforced by the database on every write.\n\n`strategy` controls how pre-existing violating rows are handled when\nthis constraint is added to an already-populated table. The\ncanonical default is [`CheckViolationStrategy::NullifyViolatingColumn`]\n(omitted from the JSON wire format). The revision CLI re-prompts\nthe user for an explicit choice; the default exists only so v0.1.x\nmodel files continue to deserialize.\n\nCleanup SQL is emitted only when the expression matches a narrow\nrecognisable shape (` ` or ` IN (...)`);\nmore complex expressions skip pre-cleanup and rely on the database\nto validate at apply time.\n\n`strategy` is **stripped from `model.schema.json`** but\n**preserved in `migration.schema.json`**.", + "properties": { + "expr": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "const": "check", + "type": "string" + } + }, + "required": [ + "type", + "name", + "expr" + ], + "type": "object" + }, + { + "description": "Non-unique index to speed up queries on the listed columns.", + "properties": { + "columns": { + "items": { + "$ref": "#/$defs/ColumnName" + }, + "type": "array" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "type": { + "const": "index", + "type": "string" + } + }, + "required": [ + "type", + "columns" + ], + "type": "object" + } + ] + }, + "TableName": { + "description": "The name of a database table, always in `snake_case` by convention.\n\nConstruction:\n\n```rust\nuse vespertide_core::schema::names::TableName;\n\nlet via_new: TableName = TableName::new(\"user\");\nlet via_from: TableName = \"user\".into();\n\nassert_eq!(via_new.as_str(), \"user\");\nassert!(via_new == \"user\");\nassert_eq!(via_new.to_string(), \"user\");\nassert_eq!(via_new, via_from);\n```\n\nJSON wire format is byte-identical to a plain `String` thanks to\n`#[serde(transparent)]`. See [`ColumnName`] for a serde round-trip example.", + "type": "string" + }, + "UniqueConstraintStrategy": { + "description": "How `AddConstraint(Unique)` should handle pre-existing duplicate rows.", + "oneOf": [ + { + "description": "Emit a `DELETE` statement just before the `ADD CONSTRAINT` so only\none row per group of duplicates survives. `keep` picks which one.\nThis is the only strategy in v0.2 — see module docs.", + "properties": { + "keep": { + "$ref": "#/$defs/KeepPolicy", + "description": "Which row of each duplicate group is preserved. See\n[`KeepPolicy`]." + }, + "kind": { + "const": "delete_duplicates", + "type": "string" + } + }, + "required": [ + "kind", + "keep" + ], + "type": "object" + } + ] + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "columns": { + "items": { + "$ref": "#/$defs/ColumnDef" + }, + "type": "array" + }, + "constraints": { + "items": { + "$ref": "#/$defs/TableConstraint" + }, + "type": "array" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "$ref": "#/$defs/TableName" + } + }, + "required": [ + "name", + "columns" + ], + "title": "TableDef", + "type": "object" +} \ No newline at end of file diff --git a/crates/devup-mcp/tests/module_boundaries.rs b/crates/devup-mcp/tests/module_boundaries.rs index b8b6c164..a773d441 100644 --- a/crates/devup-mcp/tests/module_boundaries.rs +++ b/crates/devup-mcp/tests/module_boundaries.rs @@ -1,13 +1,26 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +fn module_sections(path: &Path) -> String { + let source = std::fs::read_to_string(path).expect("module source"); + let mut combined = source.clone(); + for included in source.split("include!(").skip(1) { + let Some(literal) = included.trim_start().strip_prefix('"') else { + continue; + }; + let relative = literal.split('"').next().unwrap(); + if relative.ends_with(".rs") { + combined.push_str(&module_sections(&path.parent().unwrap().join(relative))); + } + } + combined +} #[test] fn router_keeps_projection_and_validation_implementation_in_dedicated_modules() { let server = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/server"); - let router = std::fs::read_to_string(server.join("mod.rs")).expect("server router source"); - let projection = - std::fs::read_to_string(server.join("projection.rs")).expect("projection module source"); - let validation = - std::fs::read_to_string(server.join("validation.rs")).expect("validation module source"); + let router = module_sections(&server.join("mod.rs")); + let projection = module_sections(&server.join("projection.rs")); + let validation = module_sections(&server.join("validation.rs")); for implementation in [ "fn projected_outputs_from_result", diff --git a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs index e6994179..358445ed 100644 --- a/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs +++ b/crates/devup-mcp/tests/stdio_schema_compat_smoke.rs @@ -211,8 +211,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(), - 13, - "expected all 13 devup-mcp tools (5 devup_figma_* + devup_skills + devup_project_context + devup_ui_validate + devup_stack_diff + devup_visual_compare + devup_feature_trace + devup_runtime_audit + devup_screen_plan) to be listed: {tools:?}" + 14, + "expected all 14 devup-mcp tools (5 devup_figma_* + devup_skills + devup_project_context + devup_ui_validate + devup_stack_diff + devup_visual_compare + devup_feature_trace + devup_runtime_audit + devup_screen_plan + devup_project_plan) to be listed: {tools:?}" ); let mut boolean_schema_hits = Vec::new(); diff --git a/crates/devup-mcp/tests/stdio_smoke.rs b/crates/devup-mcp/tests/stdio_smoke.rs index 73101372..0e2e32c5 100644 --- a/crates/devup-mcp/tests/stdio_smoke.rs +++ b/crates/devup-mcp/tests/stdio_smoke.rs @@ -83,7 +83,7 @@ async fn fresh_binary_initializes_lists_tools_and_reports_auth_status() -> anyho assert!(names.contains(&"devup_runtime_audit")); assert!(names.contains(&"devup_figma_measure")); assert!(names.contains(&"devup_screen_plan")); - assert_eq!(names.len(), 13); + assert_eq!(names.len(), 14); send( &mut stdin, diff --git a/crates/devup-mcp/tests/stdio_tools.rs b/crates/devup-mcp/tests/stdio_tools.rs index df7edb6d..52caeca1 100644 --- a/crates/devup-mcp/tests/stdio_tools.rs +++ b/crates/devup-mcp/tests/stdio_tools.rs @@ -174,6 +174,7 @@ async fn exposes_the_seven_read_only_devup_figma_tools() -> anyhow::Result<()> { "devup_figma_measure", "devup_figma_search", "devup_project_context", + "devup_project_plan", "devup_runtime_audit", "devup_screen_plan", "devup_skills", diff --git a/crates/devup-mcp/tests/ui_validate_response.rs b/crates/devup-mcp/tests/ui_validate_response.rs index cc4645a8..da1e1dde 100644 --- a/crates/devup-mcp/tests/ui_validate_response.rs +++ b/crates/devup-mcp/tests/ui_validate_response.rs @@ -8,1244 +8,6 @@ //! `availableTokenCount: 81` read as "4 of 81 checked". These lock the //! response to the meaning `UiValidation` documents. -use std::sync::Arc; - -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}; -use serde_json::{Map, Value, json}; - -struct Auth; - -#[async_trait] -impl DevupAuth for Auth { - async fn status(&self) -> Result { - Ok(AuthStatus::Connected) - } - async fn login(&self) -> Result { - Ok(AuthStatus::Connected) - } - async fn logout(&self) -> Result { - Ok(AuthStatus::Disconnected) - } -} - -struct NoFigma; - -#[async_trait] -impl FigmaUpstream for NoFigma { - async fn list_tools(&self) -> Result, DevupError> { - Ok(vec![]) - } - async fn call_read_tool(&self, _call: ReadToolCall) -> Result { - Err(DevupError::new( - ErrorCode::DevupSnapshotUnsupported, - "this tool does not reach Figma", - false, - )) - } -} - -fn fixture_project_root() -> String { - format!( - "{}/tests/fixtures/ground-truth-project", - env!("CARGO_MANIFEST_DIR") - ) -} - -async fn validate(arguments: Value) -> anyhow::Result { - let server = DevupServer::new(Services::new(Arc::new(Auth), Arc::new(NoFigma))); - 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 = arguments.as_object().cloned().unwrap(); - let result = client - .call_tool(CallToolRequestParams::new("devup_ui_validate").with_arguments(arguments)) - .await?; - let content = serde_json::to_value(&result.content[0])?; - assert_eq!( - serde_json::from_str::(content["text"].as_str().unwrap())?, - result.structured_content.clone().unwrap() - ); - client.cancel().await?; - task.await??; - Ok(result.structured_content.unwrap()) -} - -#[tokio::test] -async fn r12_failed_validation_explains_why_source_cannot_be_corrected_automatically() --> anyhow::Result<()> { - for tsx in [ - r#""#, - r#""#, - " anyhow::Result<()> -{ - // The original fixture's primaryColor differs in dark mode. R17 forbids - // auto-correcting that partial match; exercise R12's safe correction with - // an otherwise identical fixture that matches in every mode. - let root = - std::env::temp_dir().join(format!("devup-r17-safe-correction-{}", std::process::id())); - std::fs::create_dir_all(&root)?; - let mut theme: Value = serde_json::from_str(&std::fs::read_to_string( - std::path::Path::new(&fixture_project_root()).join("devup.json"), - )?)?; - theme["theme"]["colors"]["dark"]["primaryColor"] = json!("#3366ff"); - std::fs::write(root.join("devup.json"), serde_json::to_vec(&theme)?)?; - let output = validate(json!({"tsx":r##"<>한글"##,"strict":true,"projectRoot":root.to_string_lossy()})).await?; - assert_eq!(output["ok"], false); - assert_eq!(output["recoveryState"], "available"); - assert_eq!(output["nextAction"]["tool"], "devup_ui_validate"); - let arguments = output["nextAction"]["arguments"].clone(); - assert_eq!(arguments["strict"], true); - assert_eq!(arguments["projectRoot"], root.to_string_lossy().as_ref()); - assert_eq!( - arguments["tsx"], - r##"<>한글"## - ); - assert_eq!(validate(arguments).await?["ok"], true); - std::fs::remove_file(root.join("devup.json"))?; - std::fs::remove_dir(root)?; - Ok(()) -} - -#[tokio::test] -async fn r12_strict_warning_with_error_requires_source_edit_not_partial_retry() -> anyhow::Result<()> -{ - let output = validate(json!({"tsx":r##""##,"strict":true,"projectRoot":fixture_project_root()})).await?; - assert_eq!(output["okReason"], "error-violations"); - assert_eq!(output["recoveryState"], "manual-fix-required"); - assert!(output["nextAction"].is_null()); - assert!(output["recoveryReason"].is_string()); - Ok(()) -} - -#[tokio::test] -async fn r12_ambiguous_exact_tokens_require_a_source_decision() -> anyhow::Result<()> { - let root = std::env::temp_dir().join(format!( - "devup-r12-ambiguous-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_nanos() - )); - std::fs::create_dir_all(&root)?; - std::fs::write( - root.join("devup.json"), - r##"{"theme":{"colors":{"default":{"primary":"#3366ff","accent":"#3366ff"}}}}"##, - )?; - let result = validate(json!({"tsx":r##""##,"strict":true,"projectRoot":root.to_string_lossy()})).await; - std::fs::remove_file(root.join("devup.json"))?; - std::fs::remove_dir(&root)?; - let output = result?; - assert_eq!(output["ok"], false); - assert_eq!(output["okReason"], "strict-warnings"); - assert_eq!(output["strict"], true); - assert_eq!(output["recoveryState"], "manual-fix-required"); - assert!(output["nextAction"].is_null()); - assert!( - output["recoveryReason"] - .as_str() - .unwrap() - .contains("matching theme tokens") - ); - Ok(()) -} - -/// `ok: true` next to a non-empty `violations` is correct output, and the -/// response now carries the reason it is correct: none of them is an -/// error. Reading the two fields alone, without the struct's rustdoc, has -/// to reach that conclusion. -#[tokio::test] -async fn exact_token_matches_are_actionable_warnings() -> anyhow::Result<()> { - let output = validate(json!({ - "tsx": r##"export const S = () => ;"##, - "projectRoot": fixture_project_root() - })) - .await?; - - assert_eq!(output["ok"], true); - assert_eq!(output["okReason"], "warnings-only"); - assert_eq!( - output["violationCounts"], - json!({"error":0,"warning":3,"info":0}) - ); - assert_eq!(output["strict"], false); - assert_eq!(output["violationCounts"]["error"], 0); - assert!( - output["violationCounts"]["warning"].as_u64().unwrap() > 0, - "the fixture TSX is meant to produce warnings: {output}" - ); - assert_eq!( - output["violationCounts"]["warning"].as_u64().unwrap() as usize, - output["violations"].as_array().unwrap().len(), - "every violation here is a warning, so the counts must add up" - ); - - // The `bg` color is one of them. Through this tool - which is where it - // was observed missing - a generated `bg="#752E2E"` used to produce no - // finding at all, and the silence was mistaken for a rule. - assert!( - output["violations"] - .as_array() - .unwrap() - .iter() - .any(|violation| violation["rule"] == "hardcoded-color" - && violation["message"] - .as_str() - .is_some_and(|message| message.contains("bg") && message.contains("#3366ff"))), - "the hardcoded bg color must be reported: {output}" - ); - Ok(()) -} - -/// The same TSX under `strict` fails, and says that strictness is why - -/// not that new problems appeared. -#[tokio::test] -async fn strict_mode_fails_on_actionable_warnings() -> anyhow::Result<()> { - let output = validate(json!({ - "tsx": r##"export const S = () => ;"##, - "projectRoot": fixture_project_root(), - "strict": true - })) - .await?; - - assert_eq!(output["ok"], false); - assert_eq!(output["okReason"], "strict-warnings"); - assert_eq!(output["strict"], true); - assert_eq!(output["violationCounts"]["error"], 0); - Ok(()) -} - -/// A real error names itself as one, so `ok: false` is never ambiguous -/// between "this is broken" and "you asked for strict". -#[tokio::test] -async fn an_error_violation_is_distinguished_from_a_strict_failure() -> anyhow::Result<()> { - let output = validate(json!({ - "tsx": r##"export const S = () => ;"##, - "projectRoot": fixture_project_root() - })) - .await?; - - assert_eq!(output["ok"], false); - assert_eq!(output["okReason"], "error-violations"); - assert!(output["violationCounts"]["error"].as_u64().unwrap() > 0); - Ok(()) -} - -/// Clean TSX is distinguishable from TSX that merely has no errors. -#[tokio::test] -async fn clean_tsx_reports_clean() -> anyhow::Result<()> { - let output = validate(json!({ - "tsx": r##"export const S = () => ;"##, - "projectRoot": fixture_project_root() - })) - .await?; - - assert_eq!(output["ok"], true); - assert_eq!(output["okReason"], "clean"); - assert_eq!(output["violationCounts"]["info"], 0); - assert_eq!(output["violations"].as_array().unwrap().len(), 0); - assert_eq!(output["violationCounts"]["error"], 0); - assert_eq!(output["violationCounts"]["warning"], 0); - Ok(()) -} - -/// `checkedTokens` counts references the code makes; `availableTokenCount` -/// counts definitions the theme holds. They are not a ratio, and `tokens` -/// says so by naming each one after what it counts. -#[tokio::test] -async fn the_two_token_counts_name_what_they_count() -> anyhow::Result<()> { - let output = validate(json!({ - "tsx": r##"export const S = () => ;"##, - "projectRoot": fixture_project_root() - })) - .await?; - - assert_eq!( - output["checkedTokens"], 2, - "two $token references: {output}" - ); - assert_eq!(output["tokens"]["referencedByTsx"], output["checkedTokens"]); - assert_eq!( - output["tokens"]["definedByTheme"], - output["availableTokenCount"] - ); - assert!( - output["availableTokenCount"].as_u64().unwrap() > 2, - "the fixture theme defines more tokens than this TSX references, \ - which is exactly why the two numbers must not read as a ratio: {output}" - ); - assert_eq!(output["tokens"]["unknownTokenCheckRan"], true); - assert_eq!(output["themeAvailable"], true); - Ok(()) -} - -/// With no theme the token check is skipped, not passed. `checkedTokens` -/// still counts the references, so it alone cannot tell the two apart - -/// `unknownTokenCheckRan` is what does. -#[tokio::test] -async fn a_skipped_token_check_does_not_read_as_a_passed_one() -> anyhow::Result<()> { - let empty_root = std::env::temp_dir().join(format!( - "devup-mcp-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)?; - - let output = validate(json!({ - "tsx": r##"export const S = () => ;"##, - "projectRoot": empty_root.to_string_lossy() - })) - .await?; - - assert_eq!(output["checkedTokens"], 1, "the reference is still counted"); - assert_eq!(output["tokens"]["unknownTokenCheckRan"], false); - assert_eq!(output["themeAvailable"], false); - assert_eq!(output["availableTokenCount"], 0); - assert_eq!(output["tokens"]["definedByTheme"], 0); - - std::fs::remove_dir(&empty_root)?; - Ok(()) -} - -/// The description is read before the call, so the `ok` rule belongs there -/// too - a caller who plans around "ok means no findings" has already -/// chosen wrong by the time they see the response. -#[tokio::test] -async fn the_tool_description_states_the_ok_rule() -> anyhow::Result<()> { - let server = DevupServer::new(Services::new(Arc::new(Auth), Arc::new(NoFigma))); - 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 tools = client.list_all_tools().await?; - let validate = tools - .iter() - .find(|tool| tool.name == "devup_ui_validate") - .expect("devup_ui_validate is published"); - let description = validate.description.clone().unwrap_or_default(); - - assert!( - description.contains("severity"), - "the description must say severity decides ok: {description}" - ); - assert!( - description.contains("checkedTokens") && description.contains("availableTokenCount"), - "the description must say the two token counts are different things: {description}" - ); - client.cancel().await?; - task.await??; - Ok(()) -} - -/// Nonmatching values stay visible; info never becomes a strict failure. -#[tokio::test] -async fn unmatched_literals_are_info_only_in_both_modes() -> anyhow::Result<()> { - for strict in [false, true] { - let output = validate(json!({"tsx": r##"export const S = () => ;"##, "projectRoot": fixture_project_root(), "strict": strict})).await?; - assert_eq!(output["ok"], true); - assert_eq!(output["okReason"], "info-only"); - assert_eq!( - output["violationCounts"], - json!({"error":0,"warning":0,"info":3}) - ); - assert_eq!(output["violations"].as_array().unwrap().len(), 3); - assert_eq!(output["themeNotes"], json!([])); - for finding in output["violations"].as_array().unwrap() { - assert_eq!(finding["severity"], "info"); - assert!(finding.get("suggestion").is_none()); - assert!( - finding["message"] - .as_str() - .unwrap() - .contains("no matching token") - ); - } - } - Ok(()) -} - -/// Info must not inflate warning counts or override errors/strict warnings. -#[tokio::test] -async fn mixed_severities_keep_counts_and_failure_reasons_consistent() -> anyhow::Result<()> { - for (strict, token, ok, reason, errors) in [ - (false, "$primaryColor", true, "warnings-and-info", 0), - (true, "$primaryColor", false, "strict-warnings", 0), - (false, "$missingToken", false, "error-violations", 1), - (true, "$missingToken", false, "error-violations", 1), - ] { - let output = validate(json!({"tsx":format!(r##"export const S = () => ;"##),"projectRoot":fixture_project_root(),"strict":strict})).await?; - assert_eq!(output["ok"], ok); - assert_eq!(output["okReason"], reason); - assert_eq!( - output["violationCounts"], - json!({"error":errors,"warning":1,"info":1}) - ); - } - Ok(()) -} - -/// A sparse theme reports each literal while explaining the missing category once. -#[tokio::test] -async fn empty_length_category_is_summarized_once_in_the_response() -> anyhow::Result<()> { - let root = std::env::temp_dir().join(format!( - "devup-mcp-ui-empty-length-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_nanos() - )); - std::fs::create_dir_all(&root)?; - std::fs::write( - root.join("devup.json"), - r##"{"theme":{"colors":{"default":{"primary":"#3366ff"}},"length":{}}}"##, - )?; - let result = validate(json!({ - "tsx": r##"export const S = () => ;"##, - "projectRoot": root.to_string_lossy(), "strict":true - })) - .await; - std::fs::remove_file(root.join("devup.json"))?; - std::fs::remove_dir(&root)?; - let output = result?; - assert_eq!(output["ok"], true); - assert_eq!(output["okReason"], "info-only"); - assert_eq!( - output["violationCounts"], - json!({"error":0,"warning":0,"info":2}) - ); - assert_eq!( - output["themeNotes"], - json!(["The theme defines no length tokens."]) - ); - for finding in output["violations"].as_array().unwrap() { - assert!(finding.get("suggestion").is_none()); - assert!( - !finding["message"] - .as_str() - .unwrap() - .contains("defines no length") - ); - } - Ok(()) -} - -#[tokio::test] -async fn r17_build_identity_and_manual_source_recovery() -> anyhow::Result<()> { - let out = validate(json!({"tsx":"","projectRoot":fixture_project_root(),"sourceName":"Modal.tsx"})).await?; - assert_eq!(out["recoveryState"], "manual-fix-required"); - assert_eq!(out["violations"][0]["sourceName"], "Modal.tsx"); - assert!( - out["server"]["displayVersion"] - .as_str() - .unwrap_or("") - .contains('+') - ); - assert!( - out["server"]["identityGuidance"] - .as_str() - .unwrap_or("") - .contains("commit") - ); - Ok(()) -} - -#[tokio::test] -async fn r17_reference_png_recovery_has_frame_url() -> anyhow::Result<()> { - let server = DevupServer::new(Services::new(Arc::new(Auth), Arc::new(NoFigma))); - let (st, ct) = tokio::io::duplex(256 * 1024); - let task = tokio::spawn(async move { - server.serve(st).await?.waiting().await?; - anyhow::Ok(()) - }); - let client = ().serve(ct).await?; - let result = client.call_tool(CallToolRequestParams::new("devup_figma_export").with_arguments(json!({"url":"https://www.figma.com/design/Fixture/Test?node-id=1-1","frameIds":["2:3"],"outputs":["referencePng"]}).as_object().unwrap().clone())).await?; - let out = result.structured_content.unwrap(); - let text = out.to_string(); - assert!(text.contains("node-id=2-3"), "{out}"); - client.cancel().await?; - task.await??; - Ok(()) -} -#[tokio::test] -async fn r17_readable_deployment_identity() -> anyhow::Result<()> { - let out = validate(json!({"tsx":""})).await?; - assert!( - out["server"]["displayVersion"] - .as_str() - .unwrap_or("") - .contains('+') - ); - assert!( - out["server"]["identityGuidance"] - .as_str() - .unwrap_or("") - .contains("commit") - ); - Ok(()) -} -#[tokio::test] -async fn r17_source_name_reaches_diagnostics() -> anyhow::Result<()> { - let out = validate(json!({"tsx":"","sourceName":"Modal.tsx"})).await?; - assert_eq!(out["violations"][0]["sourceName"], "Modal.tsx"); - Ok(()) -} -#[tokio::test] -async fn r17_partial_mode_match_never_auto_replaces_literal() -> anyhow::Result<()> { - let root = std::env::temp_dir().join(format!("devup-r17-mode-{}", std::process::id())); - std::fs::create_dir_all(&root)?; - std::fs::write( - root.join("devup.json"), - r##"{"theme":{"colors":{"light":{"base":"#FFF"},"dark":{"base":"#000"}}}}"##, - )?; - let result = validate( - json!({"tsx":r##""##,"strict":true,"projectRoot":root.to_string_lossy()}), - ) - .await; - std::fs::remove_file(root.join("devup.json"))?; - std::fs::remove_dir(root)?; - let out = result?; - assert!(out["nextAction"].is_null(), "{out}"); - Ok(()) -} - -async fn structural(content: &str, path: &str) -> anyhow::Result { - validate(json!({"tsx":"", "files":[{"path":path,"content":content}],"strict":true,"projectRoot":fixture_project_root()})).await -} -fn has_rule(output: &Value, rule: &str) -> bool { - output["violations"] - .as_array() - .unwrap() - .iter() - .any(|v| v["rule"] == rule) -} -macro_rules! structural_case { - ($name:ident, $rule:literal, $path:literal, $source:literal) => { - #[tokio::test] - async fn $name() -> anyhow::Result<()> { - let output = structural($source, $path).await?; - assert!(has_rule(&output, $rule), "{output}"); - for finding in output["violations"].as_array().unwrap() { - assert_eq!(finding["path"], $path); - assert!(finding["line"].is_number()); - assert!(finding["column"].is_number()); - assert!(finding["snippet"].as_str().unwrap().chars().count() <= 120); - } - Ok(()) - } - }; -} -structural_case!( - bundle_wrapped_prop_handler, - "client-boundary", - "src/components/Button.tsx", - "export function Button({onClick}) { return