From 374cf60a912236194fdf67c1d069d1facb2b0062 Mon Sep 17 00:00:00 2001 From: leader Date: Sun, 18 Oct 2026 18:14:31 +0900 Subject: [PATCH 01/19] feat: add project intake ERD planning and supervisor work units --- .changepacks/project-intake.json | 7 + Cargo.lock | 255 +++++ FOLLOWUP-9-REVIEW.md | 12 + README.md | 87 ++ ...w_151__wquw_151_proofread_diagnostics.snap | 4 +- crates/devup-mcp/Cargo.toml | 1 + .../src/server/commands/opencode-devup.md | 88 ++ crates/devup-mcp/src/server/guide.rs | 106 +- crates/devup-mcp/src/server/mod.rs | 14 + .../devup-mcp/src/server/project_plan/erd.rs | 349 +++++++ .../devup-mcp/src/server/project_plan/mod.rs | 334 ++++++ .../src/server/project_plan/rounds.rs | 173 ++++ .../src/server/project_plan/tests.rs | 207 ++++ .../src/server/project_plan/units.rs | 171 +++ crates/devup-mcp/src/server/screen_plan.rs | 36 +- .../skills/devup-implementation-plan/SKILL.md | 975 ++++++++++-------- .../src/server/skills/devup/SKILL.md | 309 ++++-- .../devup-mcp/src/server/skills/manifest.json | 376 +++---- .../fixtures/vespertide/model.schema.json | 852 +++++++++++++++ .../tests/stdio_schema_compat_smoke.rs | 4 +- crates/devup-mcp/tests/stdio_smoke.rs | 2 +- crates/devup-mcp/tests/stdio_tools.rs | 1 + 22 files changed, 3584 insertions(+), 779 deletions(-) create mode 100644 .changepacks/project-intake.json create mode 100644 FOLLOWUP-9-REVIEW.md create mode 100644 crates/devup-mcp/src/server/project_plan/erd.rs create mode 100644 crates/devup-mcp/src/server/project_plan/mod.rs create mode 100644 crates/devup-mcp/src/server/project_plan/rounds.rs create mode 100644 crates/devup-mcp/src/server/project_plan/tests.rs create mode 100644 crates/devup-mcp/src/server/project_plan/units.rs create mode 100644 crates/devup-mcp/tests/fixtures/vespertide/model.schema.json diff --git a/.changepacks/project-intake.json b/.changepacks/project-intake.json new file mode 100644 index 00000000..523590e3 --- /dev/null +++ b/.changepacks/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/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..4ebfabeb 100644 --- a/README.md +++ b/README.md @@ -1225,3 +1225,90 @@ 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. + diff --git a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap index 1d62aaaf..464a58f8 100644 --- a/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap +++ b/crates/devup-mcp-devup-ui/tests/snapshots/wquw_151__wquw_151_proofread_diagnostics.snap @@ -210,7 +210,7 @@ expression: json!(output.diagnostics) } }, "color": { - "b": 0.9244915843009948, + "b": 0.9244915843009949, "g": 0.9529501795768738, "r": 0.9695512056350708 }, @@ -552,7 +552,7 @@ expression: json!(output.diagnostics) "color": { "b": 0.9586742520332336, "g": 0.96465665102005, - "r": 0.9695513844490052 + "r": 0.9695513844490051 }, "opacity": 1, "type": "SOLID", 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/server/commands/opencode-devup.md b/crates/devup-mcp/src/server/commands/opencode-devup.md index 41247cf5..2321c340 100644 --- a/crates/devup-mcp/src/server/commands/opencode-devup.md +++ b/crates/devup-mcp/src/server/commands/opencode-devup.md @@ -11,6 +11,94 @@ Devup Bridge plugin is attached) followed by notes for the plan. 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/guide.rs b/crates/devup-mcp/src/server/guide.rs index c841cf57..c50bc7ae 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -26,30 +26,100 @@ 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 = "Build identity: use server.commit/buildId, not version; reconnect if stale. server.updateAvailable unknown never blocks.\n1. devup-mcp is the primary Figma-to-code source. Connection: bridge -> direct; call devup_figma_auth status first, one Bridge needs no login or url.\n2b. /devup starts devup_project_plan intake for any project/feature/fix/refactor. Read Jira/spec/Figma and devup_project_context first; ask every round up front, draft ERD, confirm, then split work units. devup_screen_plan handles the Figma branch. Load devup_skills and devup-implementation-plan; after confirm never wait: defer and continue, assume at the end.\n3. For Figma implementation use devup_figma_export tsx, the deliverable. TSX links/events need wiring: resolve interactiveCandidates, devup_ui_validate, report wired/tested counts. deliverable.isFinal describes export readiness. Audit every width and exact layout. Backend completion needs devup_stack_diff zero drift.\n4. Request only outputs you read. Before your second call read devup://guide/usage for sizing, verification, batching, assets and delivery."; /// 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 +## 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. + + + 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. diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index a8dc66e4..21bd283c 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -21,6 +21,7 @@ pub mod operation; pub mod output; mod pacing; mod project_context; +mod project_plan; mod project_root; mod projection; mod quality; @@ -2632,6 +2633,19 @@ impl DevupServer { .map_err(to_mcp_error)?; Ok(tool_result(result)) } + #[tool( + description = "Project intake and supervisor: intake | draft | confirm | status | assume | defer | split. Read Jira/epic children, Figma and devup_project_context all scopes first; pass source-labelled evidence. intake always asks work type, optional sources, goal and parts through the runtime question tool. draft records feature-specific rounds and ERD; confirm requires all answers and the Figma screen plan when applicable, writes vespertide models/*.json transactionally. split creates work units with a dependency DAG and .devup/units/.md briefs, one per Jira issue or feature/layer. Workers escalate to supervisor by default (unitQuestions: supervisor|worker). After confirm never wait: defer recommendations and continue, assume at the end. status reports ERD Mermaid, unit DAG/states and decisions; unitUpdates record supervisor verification. State: .devup/project-plan.json. Follow /devup skill for backend commands, latest-first dependencies, exact layout/visual audits, orchestration and zero-drift completion.", + output_schema = permissive_object_output_schema() + )] + 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)) + } #[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() 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..e9b9548f --- /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.take(101) { + 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().unwrap_or("enum"), + 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..c81843ed --- /dev/null +++ b/crates/devup-mcp/src/server/project_plan/mod.rs @@ -0,0 +1,334 @@ +//! 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" { "Never wait. Workers escalate to supervisor; defer and continue; assume pending recommendations at the end. Run backendCommands in modelsWorkingDirectory, feature routes, OpenAPI/client generation, then devup_stack_diff with zero drift before done." } 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 { + 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..5324d837 --- /dev/null +++ b/crates/devup-mcp/src/server/project_plan/rounds.rs @@ -0,0 +1,173 @@ +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()) { + 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..111ed4a0 --- /dev/null +++ b/crates/devup-mcp/src/server/project_plan/tests.rs @@ -0,0 +1,207 @@ +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_value(json!({"source":"Figma fixture signup/post","facts":{ + "goal":"Build a post service", "screens":[{"name":"Signup", "fields":["email","display name"]},{"name":"Post list"},{"name":"Post detail"}]}})).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!("Build a post service")), + ( + "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()], + }]; + let r = run(&i, &policy).await.unwrap(); + 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 (i, policy, draft) = drafted(dir.path(), true).await; + assert!( + draft["erdMermaid"] + .as_str() + .unwrap() + .contains("user ||--o{ post") + ); + let r = run(&i, &policy).await.unwrap(); + assert_eq!(r["state"], "confirmed"); + 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}"); + } + assert!(model["$schema"].is_string()); + } + assert!( + r["plan"]["entities"][0]["columns"] + .as_array() + .unwrap() + .iter() + .any(|c| c["name"] == "display_name") + ); +} +#[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["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..829bb7ec --- /dev/null +++ b/crates/devup-mcp/src/server/project_plan/units.rs @@ -0,0 +1,171 @@ +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)?; + for u in &units { + let brief = format!( + "# Work unit {}\n\nScope: {}\n\nDependencies: {}\n\nQuestion 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.\n\n## Recorded decisions and source facts\n```json\n{}\n```\n\n## ERD slice (shared entities retained for cross-feature FKs)\n```mermaid\n{}\n```\n\n## Screen-plan slice (full confirmed routing context)\n```json\n{}\n```\n\n## Acceptance and verification\n```json\n{}\n```\n\nSupervisor reruns verification before merging; return failures to this worker session.\n", + 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"])), + plan["screenPlan"], + 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/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index bd230fd4..18c8b4f5 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -236,7 +236,7 @@ pub struct ExtraOptionInput { /// 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> { +pub(super) fn extra_questions(extra: &[ExtraQuestionInput]) -> Result, DevupError> { let mut seen = BTreeSet::new(); extra .iter() @@ -435,7 +435,7 @@ pub enum AnswerInput { } impl AnswerInput { - fn values(&self) -> Vec<&str> { + 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(), @@ -491,26 +491,26 @@ pub struct PlanLinkInput { pub url: Option, } -fn invalid(message: impl Into) -> DevupError { +pub(super) fn invalid(message: impl Into) -> DevupError { DevupError::new(ErrorCode::DevupInvalidInput, message, false) } -fn text(value: &Value) -> &str { +pub(super) fn text(value: &Value) -> &str { value.as_str().unwrap_or_default() } -fn array(value: &Value) -> &[Value] { +pub(super) fn array(value: &Value) -> &[Value] { value.as_array().map(Vec::as_slice).unwrap_or_default() } -fn sha256(bytes: &[u8]) -> String { +pub(super) fn sha256(bytes: &[u8]) -> String { Sha256::digest(bytes) .iter() .map(|byte| format!("{byte:02x}")) .collect() } -fn now() -> String { +pub(super) fn now() -> String { let seconds = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|elapsed| elapsed.as_secs()) @@ -824,18 +824,22 @@ fn truncate(value: &str, limit: usize) -> String { cut } -fn header(prefix: &str, name: &str) -> String { +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. -struct Choice { +pub(super) struct Choice { label: String, description: String, action: Value, } -fn choice(label: impl Into, description: impl Into, action: Value) -> Choice { +pub(super) fn choice( + label: impl Into, + description: impl Into, + action: Value, +) -> Choice { Choice { label: label.into(), description: description.into(), @@ -847,7 +851,13 @@ fn choice(label: impl Into, description: impl Into, action: Valu /// 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 { +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() @@ -881,7 +891,7 @@ fn question(id: String, header: String, prompt: String, choices: Vec, ki } /// The questions as they go to the question tool: no resolution table. -fn ask_shape(questions: &[Value]) -> Vec { +pub(super) fn ask_shape(questions: &[Value]) -> Vec { ask_shape_with(questions, questions) } @@ -4228,7 +4238,7 @@ pub(super) async fn draft_with( })) } -fn strip_recommended(label: &str) -> &str { +pub(super) fn strip_recommended(label: &str) -> &str { label .trim() .strip_suffix(RECOMMENDED) diff --git a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md index 40d98396..9cc6db96 100644 --- a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md @@ -1,444 +1,531 @@ ---- -name: devup-implementation-plan -description: | - Plan which Figma screen becomes which URL, confirm it with the user in one - batch of questions, then implement URL by URL and finish with a URL x width - status table. Uses devup-mcp's devup_screen_plan, devup_figma_export and - devup_runtime_audit. - - TRIGGER WHEN: - - Asked to implement, build or convert a Figma design, Section or several - screens into pages - - Given a Figma Section link, or several frames, for a Next App Router project - - Asked what was built from a design, where it lives, or whether it works - - Start it even when nobody typed /devup: a request to build pages or a site - from Figma is the trigger. /devup only makes it explicit. - - Trigger keywords: implement this design, Figma to pages, build the site, - screens, routes, URL, screen plan, devup_screen_plan, /devup ---- - -# Screen plan first, confirm once, then implement - -A design handed over as "implement this" is several screens, and each one has -to land on a URL. If you pick those URLs yourself and start writing pages, the -user cannot tell afterwards what was built, where it lives, or whether it -works. On one real site the pages were written, some widths showed "This page -couldn't load", narrow widths were cut off on the right, and nobody had a -URL-by-width table on which to notice. - -So the order is fixed, and none of it is optional. - -## Hard rules - -- **No implementation before confirm.** No page, layout or component file is - written until `devup_screen_plan` `confirm` has succeeded. -- **All questions up front, back to back.** Ask every question before any - implementation starts. If your question 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 `questionRounds` in consecutive turns with no other work in between; - pass `maxQuestionsPerRound` to draft when your cap is different. Never one - decision at a time spread through the work. -- **Ask what the whole job needs, now.** Every question you would need at - any point - screens and routes, site files and facts, and your own - (`extraQuestions`) - goes into the up-front rounds. Then finish the site - without stopping. -- **Never ask during implementation.** After `confirm`, ask the user nothing: - someone may have walked away from the keyboard, and a question three hours - in stops everything. When something new comes up, take the plan's - recommended or the most conservative option (leaving a frame unbuilt is - conservative; inventing a URL is not), record it with `devup_screen_plan` - `action: "assume"`, and keep going. -- **Ask with the question tool, never in prose.** Pass each round's - `questions` to your runtime's structured question tool (`question` in - OpenCode, `AskUserQuestion` in Claude Code, whatever yours is called). They - are already shaped for it - `id`, `header` (30 characters or fewer), - `question`, `options[{label, description}]` with the recommended option first - and marked " (Recommended)", and `multiple`. Custom answers are allowed. -- **Skip what does not apply.** A question with `when` is asked only if the - named question got that answer, e.g. a per-frame route only after "Separate - pages". Rounds are ordered so the answer it depends on is always already in - hand. -- **Stay inside the scope the user gave.** Only frames under the linked - Section, frame or page (or the Bridge selection) are candidates. Never widen - to the whole file or other pages on your own, and never build from trash, - archive, old, draft, test, copy or hidden areas: draft lists them under - `excluded` and asks once whether to accept that. -- **Never resolve a cross-section ambiguity yourself.** Frames that look like - one screen in different sections - device-named ones such as PC / Mobile - included - are a designer's structure. Ask the `sections:` question as - returned; it names the sections, frames, widths and node ids. -- **Frames marked for a design fix are reported, not built.** When the user - answers "The Figma structure is wrong: I will fix it", those frames are left - out, listed under "Design fixes requested" with what to move or rename, and - the rest of the work goes on without waiting. -- **The stack is DevFive's default.** Next App Router run by vinext, bun - workspaces, devup-ui, oxlint, the latest versions. A project with no app - starts from `dev-five-git/service-template` (a default, not a question); one - on the `next` CLI is asked once, up front, whether to migrate to vinext - - never migrated silently. -- **Pick the app by meaning, not by name order.** In a repository with - several apps, `apps/admin` is the admin console and a Figma design for a - public site is almost never for it. The plan decides from the app's name - (`front`, `web`, `site`, `www`, `landing`, `home` for the public site; - `admin`, `dashboard`, `console`, `backoffice`, `cms`, `manager`, `관리자` for - the console), its routes (sign-in, dashboard) and the frames' own names - (Home, About, 회사소개 vs Admin, 관리자, 회원관리, 대시보드). When the - evidence points one way it takes that app without asking and records why in - `defaults`. The bias is the public app unless there is clear admin - evidence: a file with both maps admin screens to the console and every other - screen to the public app (the route table's `app` column), and frames that - say nothing either way go to the public app too. Only when no app looks like - the public site is the app asked. -- **Never invent a route.** A route comes from an existing page built from the - frame, a nav link already bound to an href, an explicit `navigationTargets` - entry, the project's `routeAliases`, or the user's answer. A route inferred - from a layer name is `routeEvidence: "name-match"` and is put to the user. - `proposedRoute: null` with `routeEvidence: "UNVERIFIED"` stays null until the - user answers. - -## Site completeness - -A site built only from what Figma draws feels dead: no hover, no focus, no -pressed or current state, no transitions, no metadata, no icon. Hover plus a -transition is still not enough. The plan splits what a finished site takes in -two. - -**Filled in by you, without a question** - draft lists these as `defaults`, -and the final report repeats them: - -- the interaction recipe for each element's role (table below), on every - interactive element `interactiveCandidates` and the navigation checks find; -- one shared `transition`, 150-250ms, ease or ease-out, on the properties that - change, defined once (a token or shared constant), not per element; -- `_motionReduce` so `prefers-reduced-motion` gets no transform or animation; -- view transitions between pages, by the mechanism the plan chose from the - project's versions (below); -- root `metadata` (title template, description, `metadataBase` when the URL is - known) and per-page `title`, `description` and `openGraph` from the page's - Figma heading and lead text - the devfive-frontend SEO rule; `lang` on - ``; -- `alt` from the layer name or text, width and height on every image, and a - `not-found` page when there is none. - -### Interaction recipe per role - -| Role | Hover | Active / pressed | Focus | Also | -| --- | --- | --- | --- | --- | -| Primary button | one token step darker | `scale(0.98)` or one more step darker | 2px ring with offset (`_focusVisible`) | disabled: reduced opacity, `cursor: not-allowed`; loading (disabled + spinner) while the form it submits is pending | -| Secondary / ghost / outline button | subtle tinted background | slightly stronger tint | same ring | same disabled | -| Text link / "더 보기 >" | colour change or underline | - | ring | a trailing chevron moves 2px on hover | -| Card that links | 2-4px lift + shadow token; inner image zooms 1.02-1.04 (overflow hidden) | lift removed | ring on the card | - | -| Nav item / tab | colour | - | ring | current route: `aria-current="page"` and the Figma active style (e.g. the underline the header draws) | -| Icon button | background circle or tint | press | ring | `aria-label` required | -| Input / select / textarea | border | - | border + ring | invalid state with its message; disabled | -| Accordion / disclosure / dropdown | - | - | ring | `aria-expanded`; the chevron rotates 180deg | -| Mobile menu / drawer | - | - | - | an open/close transition, when the design has one | - -**Restraint - hard rules.** Rich is not busy: - -- Figma variant states first; otherwise existing theme tokens only. No new - colours - record an assumption when nothing fits. -- At most two properties change per state. -- 150-250ms, ease or ease-out, one shared definition. -- No bounce, 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 effect. - -`status` counts every role against its recipe ("buttons 6/6 -hover+active+focus, cards 4/6 hover+focus"); that table is the record of which -recipe each element got. - -### View transitions - -Always on. DevFive runs the latest versions - the dependency step comes -first - and the latest Next App Router and React support React's -`` on vinext and on the `next` CLI alike, so no version is -checked. View Transitions are a web standard: browsers without them simply -navigate. - -- `import { ViewTransition } from 'react'`, wrapping each page's content in - its `page.tsx` (not the layout, which persists across navigations). -- A short cross-fade (about 250ms), no big slides, `animation-duration: 0s` - under `prefers-reduced-motion`, and `::view-transition { pointer-events: - none }`. -- Name shared elements only where the same element is on both pages - a page - title, a hero image, a card image and its detail page: a static name as the - devup-ui `viewTransitionName` prop, a per-record name on `` (devup-ui extracts style props at build time, so a runtime name - cannot be a style prop). -- **Verify it, do not doubt it.** auditPlan gives each URL one - `view-transition` check following its link to the next URL, placed after - every other check (the executor also returns to the URL afterwards). It - passes only when a view transition ran (`document.startViewTransition` - called, or a cross-document `pagereveal` carrying one). A failure is a bug to - fix and re-run, not an assumption. `experimental.viewTransition` goes into - the next config only if that check fails without it (vinext's own example - sets it). - -Skipped only when `.devup/screen-plan.config.json` opts out -(`viewTransitions: false`), or `updateDependencies: false` leaves the project -on a React older than 19.3. If the project already animates pages with its own -library (framer-motion, next-view-transitions, ...), the plan asks instead. -The "motion beyond hover" question is about scroll reveal only. - -**Asked, in the same up-front rounds** - only what needs a file or a fact: -the favicon and the share image (offered from existing files and Figma logo -or og nodes in scope; never generate or draw one), the site name and -production URL when the project does not say, analytics when the project -shows signs of it, scroll reveal ("None" unless the design shows motion), and -**your own questions**: pass `extraQuestions` to draft (same shape: `id`, -`header` of 30 characters or fewer, `question`, two to four `options` with the -recommended one first, `multiple`) for anything you would otherwise guess at -any point in the job - where a form submits, contact details, external links, -legal pages, a CMS or data source, the site's language. Collect every question -the whole job will need now; after confirm, never ask - use `assume`. - -**Verified** - `devup_ui_validate` with `projectRoot` warns on interactive -elements with no focus style, on too much motion (over 400ms, scale above -1.05, infinite animation) and on icon buttons without `aria-label`, and -reports buttons without a pressed state, motion without `_motionReduce` and -nav links without `aria-current`; pages without metadata and an app without an -icon are warnings too. `status` reports site completeness item by item, -including a "view transitions: enabled (mechanism) / skipped (reason)" line. - -## Steps - -1. **List the screens.** If you only have a Section link, `devup_figma_explore` - lists them; if you already hold the frame ids, you have the list. Then - `devup_project_context` with `scope: "routes"` shows the existing pages. -2. **Draft the plan.** Call `devup_screen_plan` with `action: "draft"`, - `projectRoot`, and the Section or frame `url` (or nothing, while one Devup - Bridge plugin is attached and the Section is selected). Pass `screens` from - step 1 (with each frame's `section`) to skip a second Figma read, and - `navigationTargets` for any Figma node -> route binding you were given - explicitly. It writes `.devup/screen-plan.json` and `.devup/screen-plan.md`. -3. **Show the plan, then ask everything.** Show the user the `screens` table - (Figma screen, widths, proposed route, evidence, existing file, shared - parts, open decisions), the `heuristics` list and the `excluded` list, then - ask the questions - the flat list in one call, or the rounds back to back - - exactly as returned, the site questions included. Do not reword the options; the labels are what confirm - reads. -4. **Confirm.** Call `devup_screen_plan` with `action: "confirm"`, the - `planId`, and `answers` keyed by question id (the chosen label, with or - without " (Recommended)", or the user's custom text) for every question - that applies. A refusal names the missing ids and returns them as rounds: - ask those right away, still before any implementation. -5. **Latest first, before implementing, without asking.** - a. At the repository root, run `bunx @dependency-check-updates/cli -d -u - --rm`. It updates every manifest in the repository to the latest - versions - every bun workspace and every Cargo.toml - in one recoverable - batch (`--recover rollback` restores the originals), and `--rm` removes - each lockfile and installed directory (`bun.lock`, `node_modules/`, - `Cargo.lock`, `target/`) so the next install resolves everything fresh. - b. `bun install`, then build, lint and typecheck. If the update breaks - something, fix the code to the new APIs. Pin a version back only when - no fix exists, and record the pin with its reason. - c. Before using a library API, check its current docs (context7 or the - library's site), not memory: what you remember is assumed stale. - Record the outcome with `devup_screen_plan` `assume` (`dependencyUpdate: - { bumped, pins }`); status shows it as the dependencies line. Skip only - when `.devup/screen-plan.config.json` says `updateDependencies: false`. -6. **Implement URL by URL, without asking.** For each confirmed route, call - `devup_figma_export` with `projectRoot` and that screen's `frameIds`. With - `pageScaffold`, omit `route`: the export takes it from the plan and refuses - a different one. Add the plan's `defaults` (states, transition, reduced - motion, metadata, lang, alt, image sizes, not-found) as you go, and run - `devup_ui_validate` with `projectRoot` on what you wrote. Answer anything - new yourself with the recommended or most conservative option and record it - with `assume`. Screens confirmed as a state of another page are built - inside that page. An export of a frame the confirmed plan does not place - answers `plan_required` and writes nothing: leave it unbuilt and record an - assumption - do not ask. An excluded or design-fix frame answers `refused`: - it is never built, and no assumption changes that. -7. **Plan the audit.** `devup_screen_plan` `action: "auditPlan"` with - `projectRoot`, `baseUrl` (default `http://localhost:3000`) and `artifacts`: - every `devup_figma_export` artifactId you hold for the plan's frames. It - writes one runtime-audit plan per URL under `.devup/runtime-audit//` - and the runner once under `.devup/runtime-audit/runner/`. Each plan checks, - at every width plus each drawn width (its viewport as tall as the frame): - page health (no page error, console error, error-boundary text, overflow - or clipped text), and at the drawn widths the page against its - referencePng, every section and asset against its own Figma render, and - the size and position of every section, card and asset - **exactly**. - Layout is converted statically, so at a drawn width it is either right or - wrong: only half a pixel of rounding is allowed (Chromium measures in - 1/64 px, unsnapped at deviceScaleFactor 1, and Figma's fractional values - are written rounded), and no tolerance can be set. The frames' fonts are - loaded first, so text boxes are exact too; a missing font is an - environment error. Between the drawn widths there is no design, so only - health, overflow, containment, clipped text and the nav are checked. A - page merged from one frame per width is found per width through - `data-figma-node-ids` (`"1440:12:34 390:56:78"`), and an element a width - does not draw is checked hidden there. A dynamic route (`[slug]`, `[id]`, - catch-all) is audited at concrete URLs: its `generateStaticParams` - (literal params, or the local data file it maps), else the links the - audited pages hold into it (run once, then auditPlan again), else a sample - from its Figma detail frame, recorded as an assumption - up to ten. Hover and focus - sweep every link and button; one view-transition check follows a link to - the next URL - placed last by code, so check order cannot break a run. - `missingReferences` lists every referencePng, native render or frame - export it lacks, with the export call that makes it: make them and run - auditPlan again. Nothing is skipped silently. -8. **Run it, URL by URL.** In the runner, `npm ci`, `npx playwright install - chromium`, then `node run.mjs --plan --out ` - with the app running. If the runner cannot be installed, call status with - `auditPlanPath` and `toolingUnavailable: ""`: that URL's audit is - inconclusive and the rest of the work goes on. -9. **Read the status, fix, re-run.** `devup_screen_plan` `action: "status"` - with `auditPlanPath` and `auditReportPath`. Run every `visualComparisons` - call (`devup_visual_compare`, each with its `threshold` and `diffPath`) - and pass the results back as `visualResults` (`id`, `verdict`, - `changedRatio`, `diffPath`). Every failure comes back as a finding with - its class, Figma node, code file and fix hint: - - | class | what failed | first step | - | --- | --- | --- | - | `runtime-error` | the page threw, logged an error or showed an error page | read the first error; fix the component or the data state | - | `overflow` | content wider than the viewport, or text cut off by its box | `maxW="100%"`, wrap or `minW={0}`; let the box grow | - | `code-drift` | a box differs and its code no longer writes what the export wrote | restore the exported values; never hand-tune layout | - | `converter-bug` | a box differs and its code still equals the export | a devup-mcp defect: its repro is in `.devup/converter-bugs/.json`; file it. A local override needs a comment naming that file | - | `layout-position` / `layout-size` | a box differs and there is no export record to compare | re-export with `projectRoot`, then the class is one of the two above | - | `asset-mismatch` | an asset differs from its Figma render | a mask on a multi-colour SVG (use ``), the wrong file, crop or objectFit | - | `section-visual` | a section differs from its render | the diff PNG's highlighted area: font, background, spacing | - | `page-visual` | the page differs from its frame | fix sections first; then page background and section spacing | - | `interaction` | hover or focus changes nothing | the role's recipe from the defaults, and `_focusVisible` | - | `view-transition` | no transition ran on the navigation | `` around routed content, framework `Link` | - - Layout is exact; a mismatch is drift or a converter bug. Every export into - the project records what it wrote per node in `.devup/exports/`, and - status compares the element's code with it: a missing or changed exported - prop, or a layout prop the export did not write, is drift; extra behaviour - (handlers, hover styles, links) is not. A converter bug is reported in - `converterBugs` and the final report and never retried - a deterministic - result does not change. - - Fix every finding and re-run the URL, in the `workQueue` order. Pixels are - not layout: anti-aliasing does not fail, comparisons allow 3% changed pixels for a page or - section and 5% for an asset, and `.devup/screen-plan.config.json` - `visualThresholds` tunes them (`page`, `section`, `asset`, or `nodes` - keyed by node id for one asset) - raise one only for edge noise you have - looked at in its diff, never to pass a wrong shape. After - `maxFixAttempts` (default 3) fix-and-rerun rounds a finding is recorded as - `unresolved` with its screenshot and diff, and leaves the queue. -10. **Never stop to wait; reorder the work.** A decision the work needs after - confirm goes to `action: "defer"` (`decision: {id, about, question, - options, recommended, destructive}`). Keep working: items it blocks move to - the back of their URL's queue. When everything else is done, take each one - with `assume` (`about`: the decision id, `decision`: the recommended - option). A destructive one (deleting, overwriting, reaching outside the - project) stays unbuilt and is listed in the report. -11. **Done means zero fails.** A URL is done when every check and comparison - passes at its drawn widths and health and overflow pass at every width - - or when each remaining finding is a converter bug with its repro filed, or - listed as unresolved with its evidence. Before reporting done, also: - - - every confirmed URL is implemented; - - the build passes: `bun run build` (`vinext build` for every app); - - lint and typecheck pass: `bun run lint` (its prelint runs cargo clippy, fmt - and check, then oxlint), `bunx oxlint --deny-warnings`, and `bun tsc - --noEmit` inside each app you changed (TS 7); `bun test` passes. `bun run - test` also runs `cargo tarpaulin` afterwards, which does not work on - Windows - there, run `bun test` and `cargo test`; - - `devup_ui_validate` reports no errors; - - the production build carries no audit ids: each app's vite.config uses - the `vite-plugin-strip-figma-node-ids.mjs` auditPlan put beside it - (`plugins: [stripFigmaNodeIds(), vinext()]`), and after `bun run build` - `grep -rl data-figma-node-id dist` finds nothing. The audit itself runs - against `bun run dev` or an audit build, `DEVUP_AUDIT=1 bun run build`; - - the site completeness block is filled. -12. **Finish with the final report.** End your reply with status's - `finalReport`: the URL x width table (health, geometry, drift / converter - bugs, page, section, asset, interaction, screenshot, diffs), the converter - bugs with their repro files, the unresolved findings with their - evidence, the pending decisions and how each was taken, the defaults, the - design fixes requested, the excluded frames, the assumptions and the - dependencies line - and its one-line `summary`. Report every `fail`, - `not-run`, unresolved finding and assumption as what it is. - -Hard rules, after confirm: - -- Visually verify every screen, section and asset at every drawn width. -- Never stop to wait; reorder the work. -- Done = zero fails, or listed as unresolved with evidence. -- Layout is exact; a mismatch is drift or a converter bug, never tuned. - -## What the questions mean - -| id | asked when | resolves to | -| --- | --- | --- | -| `scope` | the link itself points into a trash, archive or draft area | plan it anyway, or build nothing | -| `exclusions` | frames were excluded by name or because they are hidden | accept, or review each (`exclusion:` / `exclusion-route:`) | -| `group:` | one name drawn at several widths in one section | one responsive page, separate pages, or one route per frame (custom `/a, /b`) | -| `sections:` | one name in different sections | separate pages, one responsive page, or the Figma structure is wrong | -| `structure::` | a duplicate, a screen split across sections, or a frame outside any section | keep as is, or the Figma structure is wrong | -| `split-route:` | `when` its group is separate pages | that frame's URL, skip, or not a page | -| `route:` | no evidence names a route, or evidence conflicts | the URL, skip, or not a page | -| `heuristics` | a route was inferred from a layer name | accept all, or review individually | -| `review:` | `when` heuristics is review | keep (merge or overwrite), another URL, or skip | -| `modal:` | the layer name looks like a modal or overlay | a state on a host page, or its own URL | -| `collision:` | an evidence-backed route already has a page | merge, overwrite or skip | -| `dynamic:` | the route has a `[segment]` | sample data, or wire the API now | -| `link::` | a link's destination is not in the plan | a route, or unlinked | -| `layout:` | a part appears on several screens and no layout renders it | shared layout, or per page | - -A `group` question recommends one responsive page: the widths are in one -section with matching names. A `sections` question recommends separate pages, -unless every section name is a device name (`PC`, `Mobile`, `Tablet`, -`모바일`) - and then it says the recommendation was inferred from those names. - -## Project config - -Designers name things differently, so a project can say how its own do it in -an optional `.devup/screen-plan.config.json`. Each entry present counts as -evidence and replaces the question it would otherwise take: - -```json -{ - "modalKeywords": ["popup", "sheet"], - "routeAliases": { "메인": "/", "회사소개": "/company" }, - "widthGrouping": "responsive", - "excludeKeywords": ["trash", "archive", "legacy"], - "includeKeywords": ["test drive"], - "favicon": "app/icon.svg", - "ogImage": "public/og.png", - "siteName": "Prime", - "siteUrl": "https://prime.example", - "motion": "none", - "framework": "vinext", - "updateDependencies": true, - "maxFixAttempts": 3, - "visualThresholds": { "asset": 0.05 } -} -``` - -- `modalKeywords` replaces the built-in modal words. -- `routeAliases` maps a layer name to a route. -- `widthGrouping` is `responsive` (merge same-named widths in one section - into one page), `separate` (never merge), or `ask` (the default: ask per - group). Frames in different sections are always asked. -- `excludeKeywords` replaces the built-in words that mark an area as not for - implementation (trash/휴지통, archive/아카이브, old/구버전/이전, - deprecated/사용안함/미사용, backup/백업, draft/WIP/작업중, test/테스트, - sandbox, playground, copy/사본). -- `includeKeywords` forces names containing them back in. -- `favicon`, `ogImage` (a path, or `figma:`), `siteName`, `siteUrl` - and `motion` (`none` | `reveal`) pre-answer the site questions. -- `framework` (`vinext` | `next`) answers the migrate-to-vinext question, - `updateDependencies: false` skips the dependency step for a project that - forbids it, and `viewTransitions: false` opts out of view transitions. -- `figmaNodeIds: false` stops exports from writing `data-figma-node-id(s)` - on the page, section, card and asset elements. The audit finds them by - it, so keep it on. Production builds drop them through the Vite plugin - auditPlan writes beside vite.config (`vite build` strips them unless - `DEVUP_AUDIT=1`); on the `next` CLI use `compiler.reactRemoveProperties: - process.env.DEVUP_AUDIT === '1' ? false : { properties: - ['^data-figma-node-ids?$'] }`. -- `maxFixAttempts` (default 3) is how many fix-and-rerun rounds a finding gets - before it is unresolved. -- Geometry has no tolerance to configure; it is exact. -- `visualThresholds` sets the changed-pixel ratio the comparisons allow: - `{ "page": 0.03, "section": 0.03, "asset": 0.05, "nodes": { "12:34": 0.08 } }`. +--- +name: devup-implementation-plan +description: | + Plan which Figma screen becomes which URL, confirm it with the user in one + batch of questions, then implement URL by URL and finish with a URL x width + status table. Uses devup-mcp's devup_screen_plan, devup_figma_export and + devup_runtime_audit. + + TRIGGER WHEN: + - Asked to implement, build or convert a Figma design, Section or several + screens into pages + - Given a Figma Section link, or several frames, for a Next App Router project + - Asked what was built from a design, where it lives, or whether it works + + Start it even when nobody typed /devup: a request to build pages or a site + from Figma is the trigger. /devup only makes it explicit. + + Trigger keywords: implement this design, Figma to pages, build the site, + screens, routes, URL, screen plan, devup_screen_plan, /devup +--- + +## 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. + + + +# Screen plan first, confirm once, then implement + +A design handed over as "implement this" is several screens, and each one has +to land on a URL. If you pick those URLs yourself and start writing pages, the +user cannot tell afterwards what was built, where it lives, or whether it +works. On one real site the pages were written, some widths showed "This page +couldn't load", narrow widths were cut off on the right, and nobody had a +URL-by-width table on which to notice. + +So the order is fixed, and none of it is optional. + +## Hard rules + +- **No implementation before confirm.** No page, layout or component file is + written until `devup_screen_plan` `confirm` has succeeded. +- **All questions up front, back to back.** Ask every question before any + implementation starts. If your question 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 `questionRounds` in consecutive turns with no other work in between; + pass `maxQuestionsPerRound` to draft when your cap is different. Never one + decision at a time spread through the work. +- **Ask what the whole job needs, now.** Every question you would need at + any point - screens and routes, site files and facts, and your own + (`extraQuestions`) - goes into the up-front rounds. Then finish the site + without stopping. +- **Never ask during implementation.** After `confirm`, ask the user nothing: + someone may have walked away from the keyboard, and a question three hours + in stops everything. When something new comes up, take the plan's + recommended or the most conservative option (leaving a frame unbuilt is + conservative; inventing a URL is not), record it with `devup_screen_plan` + `action: "assume"`, and keep going. +- **Ask with the question tool, never in prose.** Pass each round's + `questions` to your runtime's structured question tool (`question` in + OpenCode, `AskUserQuestion` in Claude Code, whatever yours is called). They + are already shaped for it - `id`, `header` (30 characters or fewer), + `question`, `options[{label, description}]` with the recommended option first + and marked " (Recommended)", and `multiple`. Custom answers are allowed. +- **Skip what does not apply.** A question with `when` is asked only if the + named question got that answer, e.g. a per-frame route only after "Separate + pages". Rounds are ordered so the answer it depends on is always already in + hand. +- **Stay inside the scope the user gave.** Only frames under the linked + Section, frame or page (or the Bridge selection) are candidates. Never widen + to the whole file or other pages on your own, and never build from trash, + archive, old, draft, test, copy or hidden areas: draft lists them under + `excluded` and asks once whether to accept that. +- **Never resolve a cross-section ambiguity yourself.** Frames that look like + one screen in different sections - device-named ones such as PC / Mobile + included - are a designer's structure. Ask the `sections:` question as + returned; it names the sections, frames, widths and node ids. +- **Frames marked for a design fix are reported, not built.** When the user + answers "The Figma structure is wrong: I will fix it", those frames are left + out, listed under "Design fixes requested" with what to move or rename, and + the rest of the work goes on without waiting. +- **The stack is DevFive's default.** Next App Router run by vinext, bun + workspaces, devup-ui, oxlint, the latest versions. A project with no app + starts from `dev-five-git/service-template` (a default, not a question); one + on the `next` CLI is asked once, up front, whether to migrate to vinext - + never migrated silently. +- **Pick the app by meaning, not by name order.** In a repository with + several apps, `apps/admin` is the admin console and a Figma design for a + public site is almost never for it. The plan decides from the app's name + (`front`, `web`, `site`, `www`, `landing`, `home` for the public site; + `admin`, `dashboard`, `console`, `backoffice`, `cms`, `manager`, `관리자` for + the console), its routes (sign-in, dashboard) and the frames' own names + (Home, About, 회사소개 vs Admin, 관리자, 회원관리, 대시보드). When the + evidence points one way it takes that app without asking and records why in + `defaults`. The bias is the public app unless there is clear admin + evidence: a file with both maps admin screens to the console and every other + screen to the public app (the route table's `app` column), and frames that + say nothing either way go to the public app too. Only when no app looks like + the public site is the app asked. +- **Never invent a route.** A route comes from an existing page built from the + frame, a nav link already bound to an href, an explicit `navigationTargets` + entry, the project's `routeAliases`, or the user's answer. A route inferred + from a layer name is `routeEvidence: "name-match"` and is put to the user. + `proposedRoute: null` with `routeEvidence: "UNVERIFIED"` stays null until the + user answers. + +## Site completeness + +A site built only from what Figma draws feels dead: no hover, no focus, no +pressed or current state, no transitions, no metadata, no icon. Hover plus a +transition is still not enough. The plan splits what a finished site takes in +two. + +**Filled in by you, without a question** - draft lists these as `defaults`, +and the final report repeats them: + +- the interaction recipe for each element's role (table below), on every + interactive element `interactiveCandidates` and the navigation checks find; +- one shared `transition`, 150-250ms, ease or ease-out, on the properties that + change, defined once (a token or shared constant), not per element; +- `_motionReduce` so `prefers-reduced-motion` gets no transform or animation; +- view transitions between pages, by the mechanism the plan chose from the + project's versions (below); +- root `metadata` (title template, description, `metadataBase` when the URL is + known) and per-page `title`, `description` and `openGraph` from the page's + Figma heading and lead text - the devfive-frontend SEO rule; `lang` on + ``; +- `alt` from the layer name or text, width and height on every image, and a + `not-found` page when there is none. + +### Interaction recipe per role + +| Role | Hover | Active / pressed | Focus | Also | +| --- | --- | --- | --- | --- | +| Primary button | one token step darker | `scale(0.98)` or one more step darker | 2px ring with offset (`_focusVisible`) | disabled: reduced opacity, `cursor: not-allowed`; loading (disabled + spinner) while the form it submits is pending | +| Secondary / ghost / outline button | subtle tinted background | slightly stronger tint | same ring | same disabled | +| Text link / "더 보기 >" | colour change or underline | - | ring | a trailing chevron moves 2px on hover | +| Card that links | 2-4px lift + shadow token; inner image zooms 1.02-1.04 (overflow hidden) | lift removed | ring on the card | - | +| Nav item / tab | colour | - | ring | current route: `aria-current="page"` and the Figma active style (e.g. the underline the header draws) | +| Icon button | background circle or tint | press | ring | `aria-label` required | +| Input / select / textarea | border | - | border + ring | invalid state with its message; disabled | +| Accordion / disclosure / dropdown | - | - | ring | `aria-expanded`; the chevron rotates 180deg | +| Mobile menu / drawer | - | - | - | an open/close transition, when the design has one | + +**Restraint - hard rules.** Rich is not busy: + +- Figma variant states first; otherwise existing theme tokens only. No new + colours - record an assumption when nothing fits. +- At most two properties change per state. +- 150-250ms, ease or ease-out, one shared definition. +- No bounce, 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 effect. + +`status` counts every role against its recipe ("buttons 6/6 +hover+active+focus, cards 4/6 hover+focus"); that table is the record of which +recipe each element got. + +### View transitions + +Always on. DevFive runs the latest versions - the dependency step comes +first - and the latest Next App Router and React support React's +`` on vinext and on the `next` CLI alike, so no version is +checked. View Transitions are a web standard: browsers without them simply +navigate. + +- `import { ViewTransition } from 'react'`, wrapping each page's content in + its `page.tsx` (not the layout, which persists across navigations). +- A short cross-fade (about 250ms), no big slides, `animation-duration: 0s` + under `prefers-reduced-motion`, and `::view-transition { pointer-events: + none }`. +- Name shared elements only where the same element is on both pages - a page + title, a hero image, a card image and its detail page: a static name as the + devup-ui `viewTransitionName` prop, a per-record name on `` (devup-ui extracts style props at build time, so a runtime name + cannot be a style prop). +- **Verify it, do not doubt it.** auditPlan gives each URL one + `view-transition` check following its link to the next URL, placed after + every other check (the executor also returns to the URL afterwards). It + passes only when a view transition ran (`document.startViewTransition` + called, or a cross-document `pagereveal` carrying one). A failure is a bug to + fix and re-run, not an assumption. `experimental.viewTransition` goes into + the next config only if that check fails without it (vinext's own example + sets it). + +Skipped only when `.devup/screen-plan.config.json` opts out +(`viewTransitions: false`), or `updateDependencies: false` leaves the project +on a React older than 19.3. If the project already animates pages with its own +library (framer-motion, next-view-transitions, ...), the plan asks instead. +The "motion beyond hover" question is about scroll reveal only. + +**Asked, in the same up-front rounds** - only what needs a file or a fact: +the favicon and the share image (offered from existing files and Figma logo +or og nodes in scope; never generate or draw one), the site name and +production URL when the project does not say, analytics when the project +shows signs of it, scroll reveal ("None" unless the design shows motion), and +**your own questions**: pass `extraQuestions` to draft (same shape: `id`, +`header` of 30 characters or fewer, `question`, two to four `options` with the +recommended one first, `multiple`) for anything you would otherwise guess at +any point in the job - where a form submits, contact details, external links, +legal pages, a CMS or data source, the site's language. Collect every question +the whole job will need now; after confirm, never ask - use `assume`. + +**Verified** - `devup_ui_validate` with `projectRoot` warns on interactive +elements with no focus style, on too much motion (over 400ms, scale above +1.05, infinite animation) and on icon buttons without `aria-label`, and +reports buttons without a pressed state, motion without `_motionReduce` and +nav links without `aria-current`; pages without metadata and an app without an +icon are warnings too. `status` reports site completeness item by item, +including a "view transitions: enabled (mechanism) / skipped (reason)" line. + +## Steps + +1. **List the screens.** If you only have a Section link, `devup_figma_explore` + lists them; if you already hold the frame ids, you have the list. Then + `devup_project_context` with `scope: "routes"` shows the existing pages. +2. **Draft the plan.** Call `devup_screen_plan` with `action: "draft"`, + `projectRoot`, and the Section or frame `url` (or nothing, while one Devup + Bridge plugin is attached and the Section is selected). Pass `screens` from + step 1 (with each frame's `section`) to skip a second Figma read, and + `navigationTargets` for any Figma node -> route binding you were given + explicitly. It writes `.devup/screen-plan.json` and `.devup/screen-plan.md`. +3. **Show the plan, then ask everything.** Show the user the `screens` table + (Figma screen, widths, proposed route, evidence, existing file, shared + parts, open decisions), the `heuristics` list and the `excluded` list, then + ask the questions - the flat list in one call, or the rounds back to back - + exactly as returned, the site questions included. Do not reword the options; the labels are what confirm + reads. +4. **Confirm.** Call `devup_screen_plan` with `action: "confirm"`, the + `planId`, and `answers` keyed by question id (the chosen label, with or + without " (Recommended)", or the user's custom text) for every question + that applies. A refusal names the missing ids and returns them as rounds: + ask those right away, still before any implementation. +5. **Latest first, before implementing, without asking.** + a. At the repository root, run `bunx @dependency-check-updates/cli -d -u + --rm`. It updates every manifest in the repository to the latest + versions - every bun workspace and every Cargo.toml - in one recoverable + batch (`--recover rollback` restores the originals), and `--rm` removes + each lockfile and installed directory (`bun.lock`, `node_modules/`, + `Cargo.lock`, `target/`) so the next install resolves everything fresh. + b. `bun install`, then build, lint and typecheck. If the update breaks + something, fix the code to the new APIs. Pin a version back only when + no fix exists, and record the pin with its reason. + c. Before using a library API, check its current docs (context7 or the + library's site), not memory: what you remember is assumed stale. + Record the outcome with `devup_screen_plan` `assume` (`dependencyUpdate: + { bumped, pins }`); status shows it as the dependencies line. Skip only + when `.devup/screen-plan.config.json` says `updateDependencies: false`. +6. **Implement URL by URL, without asking.** For each confirmed route, call + `devup_figma_export` with `projectRoot` and that screen's `frameIds`. With + `pageScaffold`, omit `route`: the export takes it from the plan and refuses + a different one. Add the plan's `defaults` (states, transition, reduced + motion, metadata, lang, alt, image sizes, not-found) as you go, and run + `devup_ui_validate` with `projectRoot` on what you wrote. Answer anything + new yourself with the recommended or most conservative option and record it + with `assume`. Screens confirmed as a state of another page are built + inside that page. An export of a frame the confirmed plan does not place + answers `plan_required` and writes nothing: leave it unbuilt and record an + assumption - do not ask. An excluded or design-fix frame answers `refused`: + it is never built, and no assumption changes that. +7. **Plan the audit.** `devup_screen_plan` `action: "auditPlan"` with + `projectRoot`, `baseUrl` (default `http://localhost:3000`) and `artifacts`: + every `devup_figma_export` artifactId you hold for the plan's frames. It + writes one runtime-audit plan per URL under `.devup/runtime-audit//` + and the runner once under `.devup/runtime-audit/runner/`. Each plan checks, + at every width plus each drawn width (its viewport as tall as the frame): + page health (no page error, console error, error-boundary text, overflow + or clipped text), and at the drawn widths the page against its + referencePng, every section and asset against its own Figma render, and + the size and position of every section, card and asset - **exactly**. + Layout is converted statically, so at a drawn width it is either right or + wrong: only half a pixel of rounding is allowed (Chromium measures in + 1/64 px, unsnapped at deviceScaleFactor 1, and Figma's fractional values + are written rounded), and no tolerance can be set. The frames' fonts are + loaded first, so text boxes are exact too; a missing font is an + environment error. Between the drawn widths there is no design, so only + health, overflow, containment, clipped text and the nav are checked. A + page merged from one frame per width is found per width through + `data-figma-node-ids` (`"1440:12:34 390:56:78"`), and an element a width + does not draw is checked hidden there. A dynamic route (`[slug]`, `[id]`, + catch-all) is audited at concrete URLs: its `generateStaticParams` + (literal params, or the local data file it maps), else the links the + audited pages hold into it (run once, then auditPlan again), else a sample + from its Figma detail frame, recorded as an assumption - up to ten. Hover and focus + sweep every link and button; one view-transition check follows a link to + the next URL - placed last by code, so check order cannot break a run. + `missingReferences` lists every referencePng, native render or frame + export it lacks, with the export call that makes it: make them and run + auditPlan again. Nothing is skipped silently. +8. **Run it, URL by URL.** In the runner, `npm ci`, `npx playwright install + chromium`, then `node run.mjs --plan --out ` + with the app running. If the runner cannot be installed, call status with + `auditPlanPath` and `toolingUnavailable: ""`: that URL's audit is + inconclusive and the rest of the work goes on. +9. **Read the status, fix, re-run.** `devup_screen_plan` `action: "status"` + with `auditPlanPath` and `auditReportPath`. Run every `visualComparisons` + call (`devup_visual_compare`, each with its `threshold` and `diffPath`) + and pass the results back as `visualResults` (`id`, `verdict`, + `changedRatio`, `diffPath`). Every failure comes back as a finding with + its class, Figma node, code file and fix hint: + + | class | what failed | first step | + | --- | --- | --- | + | `runtime-error` | the page threw, logged an error or showed an error page | read the first error; fix the component or the data state | + | `overflow` | content wider than the viewport, or text cut off by its box | `maxW="100%"`, wrap or `minW={0}`; let the box grow | + | `code-drift` | a box differs and its code no longer writes what the export wrote | restore the exported values; never hand-tune layout | + | `converter-bug` | a box differs and its code still equals the export | a devup-mcp defect: its repro is in `.devup/converter-bugs/.json`; file it. A local override needs a comment naming that file | + | `layout-position` / `layout-size` | a box differs and there is no export record to compare | re-export with `projectRoot`, then the class is one of the two above | + | `asset-mismatch` | an asset differs from its Figma render | a mask on a multi-colour SVG (use ``), the wrong file, crop or objectFit | + | `section-visual` | a section differs from its render | the diff PNG's highlighted area: font, background, spacing | + | `page-visual` | the page differs from its frame | fix sections first; then page background and section spacing | + | `interaction` | hover or focus changes nothing | the role's recipe from the defaults, and `_focusVisible` | + | `view-transition` | no transition ran on the navigation | `` around routed content, framework `Link` | + + Layout is exact; a mismatch is drift or a converter bug. Every export into + the project records what it wrote per node in `.devup/exports/`, and + status compares the element's code with it: a missing or changed exported + prop, or a layout prop the export did not write, is drift; extra behaviour + (handlers, hover styles, links) is not. A converter bug is reported in + `converterBugs` and the final report and never retried - a deterministic + result does not change. + + Fix every finding and re-run the URL, in the `workQueue` order. Pixels are + not layout: anti-aliasing does not fail, comparisons allow 3% changed pixels for a page or + section and 5% for an asset, and `.devup/screen-plan.config.json` + `visualThresholds` tunes them (`page`, `section`, `asset`, or `nodes` + keyed by node id for one asset) - raise one only for edge noise you have + looked at in its diff, never to pass a wrong shape. After + `maxFixAttempts` (default 3) fix-and-rerun rounds a finding is recorded as + `unresolved` with its screenshot and diff, and leaves the queue. +10. **Never stop to wait; reorder the work.** A decision the work needs after + confirm goes to `action: "defer"` (`decision: {id, about, question, + options, recommended, destructive}`). Keep working: items it blocks move to + the back of their URL's queue. When everything else is done, take each one + with `assume` (`about`: the decision id, `decision`: the recommended + option). A destructive one (deleting, overwriting, reaching outside the + project) stays unbuilt and is listed in the report. +11. **Done means zero fails.** A URL is done when every check and comparison + passes at its drawn widths and health and overflow pass at every width - + or when each remaining finding is a converter bug with its repro filed, or + listed as unresolved with its evidence. Before reporting done, also: + + - every confirmed URL is implemented; + - the build passes: `bun run build` (`vinext build` for every app); + - lint and typecheck pass: `bun run lint` (its prelint runs cargo clippy, fmt + and check, then oxlint), `bunx oxlint --deny-warnings`, and `bun tsc + --noEmit` inside each app you changed (TS 7); `bun test` passes. `bun run + test` also runs `cargo tarpaulin` afterwards, which does not work on + Windows - there, run `bun test` and `cargo test`; + - `devup_ui_validate` reports no errors; + - the production build carries no audit ids: each app's vite.config uses + the `vite-plugin-strip-figma-node-ids.mjs` auditPlan put beside it + (`plugins: [stripFigmaNodeIds(), vinext()]`), and after `bun run build` + `grep -rl data-figma-node-id dist` finds nothing. The audit itself runs + against `bun run dev` or an audit build, `DEVUP_AUDIT=1 bun run build`; + - the site completeness block is filled. +12. **Finish with the final report.** End your reply with status's + `finalReport`: the URL x width table (health, geometry, drift / converter + bugs, page, section, asset, interaction, screenshot, diffs), the converter + bugs with their repro files, the unresolved findings with their + evidence, the pending decisions and how each was taken, the defaults, the + design fixes requested, the excluded frames, the assumptions and the + dependencies line - and its one-line `summary`. Report every `fail`, + `not-run`, unresolved finding and assumption as what it is. + +Hard rules, after confirm: + +- Visually verify every screen, section and asset at every drawn width. +- Never stop to wait; reorder the work. +- Done = zero fails, or listed as unresolved with evidence. +- Layout is exact; a mismatch is drift or a converter bug, never tuned. + +## What the questions mean + +| id | asked when | resolves to | +| --- | --- | --- | +| `scope` | the link itself points into a trash, archive or draft area | plan it anyway, or build nothing | +| `exclusions` | frames were excluded by name or because they are hidden | accept, or review each (`exclusion:` / `exclusion-route:`) | +| `group:` | one name drawn at several widths in one section | one responsive page, separate pages, or one route per frame (custom `/a, /b`) | +| `sections:` | one name in different sections | separate pages, one responsive page, or the Figma structure is wrong | +| `structure::` | a duplicate, a screen split across sections, or a frame outside any section | keep as is, or the Figma structure is wrong | +| `split-route:` | `when` its group is separate pages | that frame's URL, skip, or not a page | +| `route:` | no evidence names a route, or evidence conflicts | the URL, skip, or not a page | +| `heuristics` | a route was inferred from a layer name | accept all, or review individually | +| `review:` | `when` heuristics is review | keep (merge or overwrite), another URL, or skip | +| `modal:` | the layer name looks like a modal or overlay | a state on a host page, or its own URL | +| `collision:` | an evidence-backed route already has a page | merge, overwrite or skip | +| `dynamic:` | the route has a `[segment]` | sample data, or wire the API now | +| `link::` | a link's destination is not in the plan | a route, or unlinked | +| `layout:` | a part appears on several screens and no layout renders it | shared layout, or per page | + +A `group` question recommends one responsive page: the widths are in one +section with matching names. A `sections` question recommends separate pages, +unless every section name is a device name (`PC`, `Mobile`, `Tablet`, +`모바일`) - and then it says the recommendation was inferred from those names. + +## Project config + +Designers name things differently, so a project can say how its own do it in +an optional `.devup/screen-plan.config.json`. Each entry present counts as +evidence and replaces the question it would otherwise take: + +```json +{ + "modalKeywords": ["popup", "sheet"], + "routeAliases": { "메인": "/", "회사소개": "/company" }, + "widthGrouping": "responsive", + "excludeKeywords": ["trash", "archive", "legacy"], + "includeKeywords": ["test drive"], + "favicon": "app/icon.svg", + "ogImage": "public/og.png", + "siteName": "Prime", + "siteUrl": "https://prime.example", + "motion": "none", + "framework": "vinext", + "updateDependencies": true, + "maxFixAttempts": 3, + "visualThresholds": { "asset": 0.05 } +} +``` + +- `modalKeywords` replaces the built-in modal words. +- `routeAliases` maps a layer name to a route. +- `widthGrouping` is `responsive` (merge same-named widths in one section + into one page), `separate` (never merge), or `ask` (the default: ask per + group). Frames in different sections are always asked. +- `excludeKeywords` replaces the built-in words that mark an area as not for + implementation (trash/휴지통, archive/아카이브, old/구버전/이전, + deprecated/사용안함/미사용, backup/백업, draft/WIP/작업중, test/테스트, + sandbox, playground, copy/사본). +- `includeKeywords` forces names containing them back in. +- `favicon`, `ogImage` (a path, or `figma:`), `siteName`, `siteUrl` + and `motion` (`none` | `reveal`) pre-answer the site questions. +- `framework` (`vinext` | `next`) answers the migrate-to-vinext question, + `updateDependencies: false` skips the dependency step for a project that + forbids it, and `viewTransitions: false` opts out of view transitions. +- `figmaNodeIds: false` stops exports from writing `data-figma-node-id(s)` + on the page, section, card and asset elements. The audit finds them by + it, so keep it on. Production builds drop them through the Vite plugin + auditPlan writes beside vite.config (`vite build` strips them unless + `DEVUP_AUDIT=1`); on the `next` CLI use `compiler.reactRemoveProperties: + process.env.DEVUP_AUDIT === '1' ? false : { properties: + ['^data-figma-node-ids?$'] }`. +- `maxFixAttempts` (default 3) is how many fix-and-rerun rounds a finding gets + before it is unresolved. +- Geometry has no tolerance to configure; it is exact. +- `visualThresholds` sets the changed-pixel ratio the comparisons allow: + `{ "page": 0.03, "section": 0.03, "asset": 0.05, "nodes": { "12:34": 0.08 } }`. diff --git a/crates/devup-mcp/src/server/skills/devup/SKILL.md b/crates/devup-mcp/src/server/skills/devup/SKILL.md index d6270337..632a476d 100644 --- a/crates/devup-mcp/src/server/skills/devup/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup/SKILL.md @@ -1,111 +1,198 @@ ---- -name: devup -description: | - Build pages or a whole site from a Figma design with devup-mcp, end to end: - connection, skills, project context, screen plan with every question asked - up front, implementation with interaction states and metadata, runtime - audit at every width, and a final URL x width table. - - Invoke as /devup [figma url] [notes]. Also use it, without being asked, when - the user wants pages or a site implemented from Figma: a Section link, - several frames, or "build the site/page". ---- - -# /devup [figma url] [notes] - -Arguments: $ARGUMENTS - a Figma Section, frame or page link (optional while -one Devup Bridge plugin is attached; the Figma selection is used), then any -notes, which go into the plan's `notes` untouched. - -This is the devup-implementation-plan workflow, started explicitly. Follow it -in this order and do not skip a step. The devup-implementation-plan skill has -the full rules; these are the steps. - -1. **Connection.** `devup_figma_auth` with `action: "status"`. With a Bridge - plugin attached nothing needs a login; do not ask for one. -2. **Skills.** `devup_skills` with `action: "status"` and this `projectRoot`; - if anything is missing or outdated, `action: "install"`, then load the - installed skills (devup-ui, devfive-frontend, devup-implementation-plan). -3. **Project context.** `devup_project_context` with `scope: "theme"` (the - tokens the code must use) and `scope: "routes"` (the pages that exist, and - `frameworks`: which app runs on vinext or next). No app yet: the plan - starts from `dev-five-git/service-template`. With several apps the plan - picks the one the frames are for by meaning - the public site unless the - frames clearly say admin. -4. **Screen plan.** `devup_screen_plan` with `action: "draft"`, `projectRoot`, - the link, the notes and `extraQuestions` - every question you would - otherwise have to guess at any point in the job (form target, contact - details, external links, legal pages, data source, language). It returns - the screens, the excluded areas, the defaults it will fill in, and every - open question - routes, sections and structure, the site questions - (favicon, share image, site name and URL, analytics, scroll reveal) and - yours. -5. **Ask everything now, with your question tool.** The flat `questions` list - in one call if your tool has no per-call cap; otherwise `questionRounds` - back to back. Never in prose, never one decision at a time, never later. -6. **Confirm.** `devup_screen_plan` with `action: "confirm"`, the `planId` and - every answer that applies. -7. **Latest first, before implementing, without asking.** - a. At the repository root, run `bunx @dependency-check-updates/cli -d -u - --rm`. It updates every manifest in the repository to the latest - versions - every bun workspace and every Cargo.toml - in one recoverable - batch (`--recover rollback` restores the originals), and `--rm` removes - each lockfile and installed directory (`bun.lock`, `node_modules/`, - `Cargo.lock`, `target/`) so the next install resolves everything fresh. - b. `bun install`, then build, lint and typecheck. If the update breaks - something, fix the code to the new APIs. Pin a version back only when - no fix exists, and record the pin with its reason. - c. Before using a library API, check its current docs (context7 or the - library's site), not memory: what you remember is assumed stale. - Record the outcome with `devup_screen_plan` `assume` (`dependencyUpdate: - { bumped, pins }`); status shows it as the dependencies line. Skip only - when `.devup/screen-plan.config.json` says `updateDependencies: false`. -8. **Implement URL by URL without asking anything.** `devup_figma_export` with - `projectRoot` and each screen's `frameIds`; `pageScaffold` takes its route - from the plan. Add the plan's `defaults` as you go: each interactive - element's role recipe (hover, pressed, focus ring, current page, expanded, - disabled, loading - restrained: at most two properties per state, - 150-250ms, existing tokens only), one shared transition, `_motionReduce`, - view transitions by the plan's mechanism, root and per-page `metadata` with - openGraph, `lang`, alt text, image dimensions, a not-found page. Run - `devup_ui_validate` with `projectRoot` on what you wrote. Anything new: - take the recommended or most conservative option and record it with - `action: "assume"`. -9. **Audit plan.** `devup_screen_plan` with `action: "auditPlan"`, - `baseUrl` and `artifacts` (every export artifactId of the plan's frames). - One plan per URL: 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 - - exact, half a pixel of rounding and no tolerance; merged pages per width - (`data-figma-node-ids`); dynamic routes at concrete URLs; hover - and focus; a view-transition check, last. Export whatever - `missingReferences` names and run auditPlan again. -10. **Run, status, fix - URL by URL.** Run each plan with the runner it wrote, - then `action: "status"` with `auditPlanPath` and `auditReportPath`. Run - every `visualComparisons` call and pass the results back as - `visualResults`. Fix each finding (its class, node, file and fix hint) in - `workQueue` order and re-run. A geometry mismatch is `code-drift` (restore - the export's values) or `converter-bug` (repro in `.devup/converter-bugs/`, - reported, never retried). After `maxFixAttempts` (default 3) a finding - is unresolved with its evidence. Runner cannot install: - `toolingUnavailable`, and carry on. -11. **Never stop to wait.** A decision mid-run: `action: "defer"` with the - recommended option, keep working, take it with `assume` at the end; a - destructive one stays unbuilt. -12. **Done = zero fails.** Every check and comparison passes at the drawn - widths and health and overflow pass at every width, or each remaining - finding is a filed converter bug or unresolved with evidence. Also: every - URL implemented, the production build free of `data-figma-node-id` (the - strip plugin in vite.config; `grep -rl data-figma-node-id dist` finds - nothing), `bun run build`, `bun run lint`, `bunx oxlint --deny-warnings`, `bun tsc --noEmit` - per changed app, `bun test` (on Windows `bun test` and `cargo test`), - `devup_ui_validate` without errors, site completeness filled. -13. **Final report.** End with status's `finalReport` and `summary`: URL x - width (health, geometry, page, section, asset, interaction, screenshot, - diffs), unresolved findings with evidence, pending decisions taken, - assumptions, design fixes, exclusions, defaults and the dependencies line. - -Hard rules: visually verify every screen, section and asset at every drawn -width; never stop to wait, reorder the work; done = zero fails, or listed as -unresolved with evidence; layout is exact, a mismatch is drift or a -converter bug. +--- +name: devup +description: | + Build pages or a whole site from a Figma design with devup-mcp, end to end: + connection, skills, project context, screen plan with every question asked + up front, implementation with interaction states and metadata, runtime + audit at every width, and a final URL x width table. + + Invoke as /devup [figma url] [notes]. Also use it, without being asked, when + the user wants pages or a site implemented from Figma: a Section link, + several frames, or "build the site/page". +--- + +## 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. + + + +# /devup [figma url] [notes] + +Arguments: $ARGUMENTS - a Figma Section, frame or page link (optional while +one Devup Bridge plugin is attached; the Figma selection is used), then any +notes, which go into the plan's `notes` untouched. + +This is the devup-implementation-plan workflow, started explicitly. Follow it +in this order and do not skip a step. The devup-implementation-plan skill has +the full rules; these are the steps. + +1. **Connection.** `devup_figma_auth` with `action: "status"`. With a Bridge + plugin attached nothing needs a login; do not ask for one. +2. **Skills.** `devup_skills` with `action: "status"` and this `projectRoot`; + if anything is missing or outdated, `action: "install"`, then load the + installed skills (devup-ui, devfive-frontend, devup-implementation-plan). +3. **Project context.** `devup_project_context` with `scope: "theme"` (the + tokens the code must use) and `scope: "routes"` (the pages that exist, and + `frameworks`: which app runs on vinext or next). No app yet: the plan + starts from `dev-five-git/service-template`. With several apps the plan + picks the one the frames are for by meaning - the public site unless the + frames clearly say admin. +4. **Screen plan.** `devup_screen_plan` with `action: "draft"`, `projectRoot`, + the link, the notes and `extraQuestions` - every question you would + otherwise have to guess at any point in the job (form target, contact + details, external links, legal pages, data source, language). It returns + the screens, the excluded areas, the defaults it will fill in, and every + open question - routes, sections and structure, the site questions + (favicon, share image, site name and URL, analytics, scroll reveal) and + yours. +5. **Ask everything now, with your question tool.** The flat `questions` list + in one call if your tool has no per-call cap; otherwise `questionRounds` + back to back. Never in prose, never one decision at a time, never later. +6. **Confirm.** `devup_screen_plan` with `action: "confirm"`, the `planId` and + every answer that applies. +7. **Latest first, before implementing, without asking.** + a. At the repository root, run `bunx @dependency-check-updates/cli -d -u + --rm`. It updates every manifest in the repository to the latest + versions - every bun workspace and every Cargo.toml - in one recoverable + batch (`--recover rollback` restores the originals), and `--rm` removes + each lockfile and installed directory (`bun.lock`, `node_modules/`, + `Cargo.lock`, `target/`) so the next install resolves everything fresh. + b. `bun install`, then build, lint and typecheck. If the update breaks + something, fix the code to the new APIs. Pin a version back only when + no fix exists, and record the pin with its reason. + c. Before using a library API, check its current docs (context7 or the + library's site), not memory: what you remember is assumed stale. + Record the outcome with `devup_screen_plan` `assume` (`dependencyUpdate: + { bumped, pins }`); status shows it as the dependencies line. Skip only + when `.devup/screen-plan.config.json` says `updateDependencies: false`. +8. **Implement URL by URL without asking anything.** `devup_figma_export` with + `projectRoot` and each screen's `frameIds`; `pageScaffold` takes its route + from the plan. Add the plan's `defaults` as you go: each interactive + element's role recipe (hover, pressed, focus ring, current page, expanded, + disabled, loading - restrained: at most two properties per state, + 150-250ms, existing tokens only), one shared transition, `_motionReduce`, + view transitions by the plan's mechanism, root and per-page `metadata` with + openGraph, `lang`, alt text, image dimensions, a not-found page. Run + `devup_ui_validate` with `projectRoot` on what you wrote. Anything new: + take the recommended or most conservative option and record it with + `action: "assume"`. +9. **Audit plan.** `devup_screen_plan` with `action: "auditPlan"`, + `baseUrl` and `artifacts` (every export artifactId of the plan's frames). + One plan per URL: 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 - + exact, half a pixel of rounding and no tolerance; merged pages per width + (`data-figma-node-ids`); dynamic routes at concrete URLs; hover + and focus; a view-transition check, last. Export whatever + `missingReferences` names and run auditPlan again. +10. **Run, status, fix - URL by URL.** Run each plan with the runner it wrote, + then `action: "status"` with `auditPlanPath` and `auditReportPath`. Run + every `visualComparisons` call and pass the results back as + `visualResults`. Fix each finding (its class, node, file and fix hint) in + `workQueue` order and re-run. A geometry mismatch is `code-drift` (restore + the export's values) or `converter-bug` (repro in `.devup/converter-bugs/`, + reported, never retried). After `maxFixAttempts` (default 3) a finding + is unresolved with its evidence. Runner cannot install: + `toolingUnavailable`, and carry on. +11. **Never stop to wait.** A decision mid-run: `action: "defer"` with the + recommended option, keep working, take it with `assume` at the end; a + destructive one stays unbuilt. +12. **Done = zero fails.** Every check and comparison passes at the drawn + widths and health and overflow pass at every width, or each remaining + finding is a filed converter bug or unresolved with evidence. Also: every + URL implemented, the production build free of `data-figma-node-id` (the + strip plugin in vite.config; `grep -rl data-figma-node-id dist` finds + nothing), `bun run build`, `bun run lint`, `bunx oxlint --deny-warnings`, `bun tsc --noEmit` + per changed app, `bun test` (on Windows `bun test` and `cargo test`), + `devup_ui_validate` without errors, site completeness filled. +13. **Final report.** End with status's `finalReport` and `summary`: URL x + width (health, geometry, page, section, asset, interaction, screenshot, + diffs), unresolved findings with evidence, pending decisions taken, + assumptions, design fixes, exclusions, defaults and the dependencies line. + +Hard rules: visually verify every screen, section and asset at every drawn +width; never stop to wait, reorder the work; done = zero fails, or listed as +unresolved with evidence; layout is exact, a mismatch is drift or a +converter bug. diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index 982b681e..572c19f1 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -1,188 +1,188 @@ -{ - "note": "The skills an agent needs for the code devup-mcp emits. Three origins. 'embedded' skills are DevFive's own but live in another repository: the canonical SKILL.md is vendored here so a machine with only devup-mcp installed can install them with no network, and commit/sha256 say which revision. 'own' skills are authored in this repository - there is nothing upstream to vendor from, so they carry no commit pin and refresh-skills.mjs skips them; this repository's history is their provenance. 'external' skills belong to someone else and are NOT vendored - devup-mcp reports where they come from and the command that installs them, and never runs that command itself.", - "documentNote": "Each entry's documents[] lists every file the skill installs, by path relative to the skill directory. The same relative path is used under crates/devup-mcp/src/server/skills// in this tree, so a document's source and its install location never disagree. A skill is single-file when that list has one SKILL.md in it.", - "skills": [ - { - "name": "devup-ui", - "origin": "embedded", - "title": "devup-ui conventions", - "description": "Zero-runtime CSS-in-JS for React: Box/Flex/Text primitives, css()/globalCss()/keyframes(), devup.json theme tokens, responsive arrays and pseudo-selectors.", - "usedFor": "The TSX devup_figma_export returns is devup-ui code. Without this the agent does not know its components are compile-time placeholders, that $token means devup.json, or that a style prop takes a responsive array.", - "repo": "dev-five-git/devup-ui", - "path": "SKILL.md", - "commit": "cebcfb2a3f94649f297315e702dd468026474ff6", - "committedAt": "2026-09-30T02:49:17Z", - "documents": [ - { - "path": "SKILL.md", - "bytes": 29525, - "sha256": "cd750ecb6b49340a2cf4c386ef7c69eeff4d495d115f58223b963430da9f87a2" - } - ], - "sourceUrl": "https://github.com/dev-five-git/devup-ui/blob/cebcfb2a3f94649f297315e702dd468026474ff6/SKILL.md", - "latestUrl": "https://github.com/dev-five-git/devup-ui/blob/HEAD/SKILL.md" - }, - { - "name": "devfive-frontend", - "origin": "own", - "title": "DevFive frontend conventions", - "description": "Where the generated code goes in a Next App Router project: app/ holds only layout.tsx and page.tsx, components live under components/pages//, Server Components are the default, and all styling is devup-ui props rather than an authored CSS file. Multi-file: SKILL.md plus four reference documents.", - "usedFor": "devup_figma_export returns one screen's TSX; a project is a tree of routes. This is the skill that says which file that TSX becomes and which rules it is then judged by. Without it an agent that can already write devup-ui still puts the screen in the wrong place - most visibly by switching screens with local state instead of file routing, and by authoring a .css file next to code that has globalCss().", - "repo": "dev-five-git/devup-mcp", - "path": "crates/devup-mcp/src/server/skills/devfive-frontend", - "documents": [ - { - "path": "SKILL.md", - "bytes": 24217, - "sha256": "8607c0b4816702f2f1634d8a1638a94bf69a1fad7a665325c375d632d3ab360f" - }, - { - "path": "references/critical-rules.md", - "bytes": 57175, - "sha256": "3b7694f5db108407e0b4e3736cc3018b4c72951596eb1e4feab3123a3a4d3549" - }, - { - "path": "references/anti-patterns.md", - "bytes": 12017, - "sha256": "d25adca53b74a5119b5f6e3d4d66f78c02c0771e76b2361a441c1d3a0ec5865a" - }, - { - "path": "references/common-patterns.md", - "bytes": 12110, - "sha256": "fe1d165394824ae0777a3f11dda5a47e0cf27e17872c1e83b70b18de8e29828c" - }, - { - "path": "references/theme-colors.md", - "bytes": 4610, - "sha256": "1d9bb1d7bff8e3b1c0b01433fc9a1ca521d2513d2823048dde9a4fb241399cef" - } - ], - "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend", - "latestUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend" - }, - { - "name": "devup-implementation-plan", - "origin": "own", - "title": "Screen plan first, confirm once, then implement", - "description": "The order a Figma design becomes pages in: list the screens, draft a screen-to-URL plan with devup_screen_plan, stay inside the linked scope and out of trash/archive/draft areas, ask every question before any work (cross-section ambiguities included, with a fix-the-Figma option), confirm, implement URL by URL without asking anything more (recording assumptions instead), run the runtime audit, and finish with a URL x width status table.", - "usedFor": "Without it an agent handed a Figma Section picks URLs itself and writes pages straight away, so nobody can tell afterwards which screen became which URL or whether each one works at every width. It also keeps the questions in structured rounds asked up front, instead of a trickle of prose confirmations - or a question hours into the work while nobody is at the keyboard.", - "repo": "dev-five-git/devup-mcp", - "path": "crates/devup-mcp/src/server/skills/devup-implementation-plan", - "documents": [ - { - "path": "SKILL.md", - "bytes": 28331, - "sha256": "fae99ff40f20aab49ad5b5a96d9ef33ec1ba686a60b19ebfb8e7b621c243f571" - } - ], - "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", - "latestUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan" - }, - { - "name": "devup", - "origin": "own", - "title": "/devup: build pages or a site from Figma", - "description": "The explicit entry point: /devup [figma url] [notes] runs the whole devup-mcp workflow - connection, skills, project context, screen plan with every question up front, implementation with interaction states and metadata, runtime audit, and the final URL x width table.", - "usedFor": "An MCP server cannot start a skill or a slash command itself. Claude Code offers this skill as /devup, and devup_skills install writes an OpenCode command beside it, so the user has one word to type that starts everything in the right order.", - "repo": "dev-five-git/devup-mcp", - "path": "crates/devup-mcp/src/server/skills/devup", - "documents": [ - { - "path": "SKILL.md", - "bytes": 7046, - "sha256": "1ccbb15e362ceae19ab821cc0d5820f6441a562d0cbeb07688cdb606a078d753" - } - ], - "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup", - "latestUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup" - }, - { - "name": "changepacks", - "origin": "embedded", - "title": "changepacks conventions", - "description": "Version and changelog management for multi-language monorepos: when a pull request needs a changepack log, how to create one without an interactive prompt, and how .changepacks/config.json decides which paths are tracked.", - "usedFor": "Every repository devup-mcp writes into manages its versions this way. A pull request that changes a tracked path without a changepack log never moves the version, so the change reaches the base branch and is never released - and the invocation the project documents is interactive, which hangs in the shells an agent runs in. That combination is why the step gets skipped.", - "repo": "changepacks/changepacks", - "path": "SKILL.md", - "commit": "ec1f4b025ed781856eb8650e880ece9935be3351", - "committedAt": "2026-09-15T07:25:28Z", - "documents": [ - { - "path": "SKILL.md", - "bytes": 4700, - "sha256": "5477d13f7ebaf79fdf701b7e8d5b3071e09300b9ec2e55ecd51ccd223981b071" - } - ], - "sourceUrl": "https://github.com/changepacks/changepacks/blob/ec1f4b025ed781856eb8650e880ece9935be3351/SKILL.md", - "latestUrl": "https://github.com/changepacks/changepacks/blob/HEAD/SKILL.md" - }, - { - "name": "vespera", - "origin": "embedded", - "title": "vespera conventions", - "description": "FastAPI-like DX for Rust/Axum: route handlers, Schema derivation and OpenAPI generation.", - "usedFor": "The openapi.json devup_project_context reads under scope api is generated by vespera routes.", - "repo": "dev-five-git/vespera", - "path": "SKILL.md", - "commit": "4030b6c053896fce34477b73616216c86d4b7f85", - "committedAt": "2026-06-24T08:21:03Z", - "documents": [ - { - "path": "SKILL.md", - "bytes": 23635, - "sha256": "44db39f98cbb2520ca9a610a59753f39be2e226da7ee11a940234800cf98b27f" - } - ], - "sourceUrl": "https://github.com/dev-five-git/vespera/blob/4030b6c053896fce34477b73616216c86d4b7f85/SKILL.md", - "latestUrl": "https://github.com/dev-five-git/vespera/blob/HEAD/SKILL.md" - }, - { - "name": "vespertide", - "origin": "embedded", - "title": "vespertide conventions", - "description": "JSON database schema definitions and migration plans: tables, columns, constraints and ENUM types.", - "usedFor": "The models/*.json devup_project_context reads under scope db are vespertide schemas.", - "repo": "dev-five-git/vespertide", - "path": "SKILL.md", - "commit": "586426bcfc388557fe9b916c941adb43ab04317e", - "committedAt": "2026-03-06T06:11:16Z", - "documents": [ - { - "path": "SKILL.md", - "bytes": 18057, - "sha256": "7d79f9814819d107038181628a370a11e095a7314a63a0a6c5b5a9bcab6fbfdf" - } - ], - "sourceUrl": "https://github.com/dev-five-git/vespertide/blob/586426bcfc388557fe9b916c941adb43ab04317e/SKILL.md", - "latestUrl": "https://github.com/dev-five-git/vespertide/blob/HEAD/SKILL.md" - }, - { - "name": "vercel-react-best-practices", - "origin": "external", - "title": "React/Next.js performance (Vercel Engineering)", - "description": "40+ rules across 8 categories: eliminating waterfalls, bundle size, re-render cost, server fetching. Multi-file: SKILL.md plus an AGENTS.md and ~70 rule files.", - "usedFor": "The TSX devup_figma_export returns becomes React components; these are the performance rules they are then judged by.", - "repo": "vercel-labs/agent-skills", - "path": "skills/react-best-practices", - "installCommand": "npx skills add vercel-labs/agent-skills", - "license": "none-declared", - "licenseNote": "vercel-labs/agent-skills publishes no LICENSE file, so devup-mcp does not vendor or redistribute its content. It is installed from source by the agent.", - "sourceUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-best-practices", - "latestUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-best-practices" - }, - { - "name": "vercel-react-view-transitions", - "origin": "external", - "title": "React View Transitions (Vercel Engineering)", - "description": "React's View Transition API: , addTransitionType, CSS pseudo-elements, shared element transitions, Next.js integration. Multi-file: SKILL.md plus AGENTS.md and reference files.", - "usedFor": "Figma prototype reactions become animations; this is how to implement them with the View Transition API rather than by hand.", - "repo": "vercel-labs/agent-skills", - "path": "skills/react-view-transitions", - "installCommand": "npx skills add vercel-labs/agent-skills", - "license": "none-declared", - "licenseNote": "vercel-labs/agent-skills publishes no LICENSE file, so devup-mcp does not vendor or redistribute its content. It is installed from source by the agent.", - "sourceUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-view-transitions", - "latestUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-view-transitions" - } - ] -} +{ + "note": "The skills an agent needs for the code devup-mcp emits. Three origins. 'embedded' skills are DevFive's own but live in another repository: the canonical SKILL.md is vendored here so a machine with only devup-mcp installed can install them with no network, and commit/sha256 say which revision. 'own' skills are authored in this repository - there is nothing upstream to vendor from, so they carry no commit pin and refresh-skills.mjs skips them; this repository's history is their provenance. 'external' skills belong to someone else and are NOT vendored - devup-mcp reports where they come from and the command that installs them, and never runs that command itself.", + "documentNote": "Each entry's documents[] lists every file the skill installs, by path relative to the skill directory. The same relative path is used under crates/devup-mcp/src/server/skills// in this tree, so a document's source and its install location never disagree. A skill is single-file when that list has one SKILL.md in it.", + "skills": [ + { + "name": "devup-ui", + "origin": "embedded", + "title": "devup-ui conventions", + "description": "Zero-runtime CSS-in-JS for React: Box/Flex/Text primitives, css()/globalCss()/keyframes(), devup.json theme tokens, responsive arrays and pseudo-selectors.", + "usedFor": "The TSX devup_figma_export returns is devup-ui code. Without this the agent does not know its components are compile-time placeholders, that $token means devup.json, or that a style prop takes a responsive array.", + "repo": "dev-five-git/devup-ui", + "path": "SKILL.md", + "commit": "cebcfb2a3f94649f297315e702dd468026474ff6", + "committedAt": "2026-09-30T02:49:17Z", + "documents": [ + { + "path": "SKILL.md", + "bytes": 29525, + "sha256": "cd750ecb6b49340a2cf4c386ef7c69eeff4d495d115f58223b963430da9f87a2" + } + ], + "sourceUrl": "https://github.com/dev-five-git/devup-ui/blob/cebcfb2a3f94649f297315e702dd468026474ff6/SKILL.md", + "latestUrl": "https://github.com/dev-five-git/devup-ui/blob/HEAD/SKILL.md" + }, + { + "name": "devfive-frontend", + "origin": "own", + "title": "DevFive frontend conventions", + "description": "Where the generated code goes in a Next App Router project: app/ holds only layout.tsx and page.tsx, components live under components/pages//, Server Components are the default, and all styling is devup-ui props rather than an authored CSS file. Multi-file: SKILL.md plus four reference documents.", + "usedFor": "devup_figma_export returns one screen's TSX; a project is a tree of routes. This is the skill that says which file that TSX becomes and which rules it is then judged by. Without it an agent that can already write devup-ui still puts the screen in the wrong place - most visibly by switching screens with local state instead of file routing, and by authoring a .css file next to code that has globalCss().", + "repo": "dev-five-git/devup-mcp", + "path": "crates/devup-mcp/src/server/skills/devfive-frontend", + "documents": [ + { + "path": "SKILL.md", + "bytes": 24217, + "sha256": "8607c0b4816702f2f1634d8a1638a94bf69a1fad7a665325c375d632d3ab360f" + }, + { + "path": "references/critical-rules.md", + "bytes": 57175, + "sha256": "3b7694f5db108407e0b4e3736cc3018b4c72951596eb1e4feab3123a3a4d3549" + }, + { + "path": "references/anti-patterns.md", + "bytes": 12017, + "sha256": "d25adca53b74a5119b5f6e3d4d66f78c02c0771e76b2361a441c1d3a0ec5865a" + }, + { + "path": "references/common-patterns.md", + "bytes": 12110, + "sha256": "fe1d165394824ae0777a3f11dda5a47e0cf27e17872c1e83b70b18de8e29828c" + }, + { + "path": "references/theme-colors.md", + "bytes": 4610, + "sha256": "1d9bb1d7bff8e3b1c0b01433fc9a1ca521d2513d2823048dde9a4fb241399cef" + } + ], + "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend", + "latestUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devfive-frontend" + }, + { + "name": "devup-implementation-plan", + "origin": "own", + "title": "Screen plan first, confirm once, then implement", + "description": "The order a Figma design becomes pages in: list the screens, draft a screen-to-URL plan with devup_screen_plan, stay inside the linked scope and out of trash/archive/draft areas, ask every question before any work (cross-section ambiguities included, with a fix-the-Figma option), confirm, implement URL by URL without asking anything more (recording assumptions instead), run the runtime audit, and finish with a URL x width status table.", + "usedFor": "Without it an agent handed a Figma Section picks URLs itself and writes pages straight away, so nobody can tell afterwards which screen became which URL or whether each one works at every width. It also keeps the questions in structured rounds asked up front, instead of a trickle of prose confirmations - or a question hours into the work while nobody is at the keyboard.", + "repo": "dev-five-git/devup-mcp", + "path": "crates/devup-mcp/src/server/skills/devup-implementation-plan", + "documents": [ + { + "path": "SKILL.md", + "bytes": 34991, + "sha256": "0767019ef6b8431d4e72b878f97ce592264d0cc105e208cd3cc7cc38340a652d" + } + ], + "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", + "latestUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan" + }, + { + "name": "devup", + "origin": "own", + "title": "/devup: build pages or a site from Figma", + "description": "The explicit entry point: /devup [figma url] [notes] runs the whole devup-mcp workflow - connection, skills, project context, screen plan with every question up front, implementation with interaction states and metadata, runtime audit, and the final URL x width table.", + "usedFor": "An MCP server cannot start a skill or a slash command itself. Claude Code offers this skill as /devup, and devup_skills install writes an OpenCode command beside it, so the user has one word to type that starts everything in the right order.", + "repo": "dev-five-git/devup-mcp", + "path": "crates/devup-mcp/src/server/skills/devup", + "documents": [ + { + "path": "SKILL.md", + "bytes": 13373, + "sha256": "f533de6e49301acf7f522b8e5b26914b2abade7ceaae019b50552b842f70ebc1" + } + ], + "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup", + "latestUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup" + }, + { + "name": "changepacks", + "origin": "embedded", + "title": "changepacks conventions", + "description": "Version and changelog management for multi-language monorepos: when a pull request needs a changepack log, how to create one without an interactive prompt, and how .changepacks/config.json decides which paths are tracked.", + "usedFor": "Every repository devup-mcp writes into manages its versions this way. A pull request that changes a tracked path without a changepack log never moves the version, so the change reaches the base branch and is never released - and the invocation the project documents is interactive, which hangs in the shells an agent runs in. That combination is why the step gets skipped.", + "repo": "changepacks/changepacks", + "path": "SKILL.md", + "commit": "ec1f4b025ed781856eb8650e880ece9935be3351", + "committedAt": "2026-09-15T07:25:28Z", + "documents": [ + { + "path": "SKILL.md", + "bytes": 4700, + "sha256": "5477d13f7ebaf79fdf701b7e8d5b3071e09300b9ec2e55ecd51ccd223981b071" + } + ], + "sourceUrl": "https://github.com/changepacks/changepacks/blob/ec1f4b025ed781856eb8650e880ece9935be3351/SKILL.md", + "latestUrl": "https://github.com/changepacks/changepacks/blob/HEAD/SKILL.md" + }, + { + "name": "vespera", + "origin": "embedded", + "title": "vespera conventions", + "description": "FastAPI-like DX for Rust/Axum: route handlers, Schema derivation and OpenAPI generation.", + "usedFor": "The openapi.json devup_project_context reads under scope api is generated by vespera routes.", + "repo": "dev-five-git/vespera", + "path": "SKILL.md", + "commit": "4030b6c053896fce34477b73616216c86d4b7f85", + "committedAt": "2026-06-24T08:21:03Z", + "documents": [ + { + "path": "SKILL.md", + "bytes": 23635, + "sha256": "44db39f98cbb2520ca9a610a59753f39be2e226da7ee11a940234800cf98b27f" + } + ], + "sourceUrl": "https://github.com/dev-five-git/vespera/blob/4030b6c053896fce34477b73616216c86d4b7f85/SKILL.md", + "latestUrl": "https://github.com/dev-five-git/vespera/blob/HEAD/SKILL.md" + }, + { + "name": "vespertide", + "origin": "embedded", + "title": "vespertide conventions", + "description": "JSON database schema definitions and migration plans: tables, columns, constraints and ENUM types.", + "usedFor": "The models/*.json devup_project_context reads under scope db are vespertide schemas.", + "repo": "dev-five-git/vespertide", + "path": "SKILL.md", + "commit": "586426bcfc388557fe9b916c941adb43ab04317e", + "committedAt": "2026-03-06T06:11:16Z", + "documents": [ + { + "path": "SKILL.md", + "bytes": 18057, + "sha256": "7d79f9814819d107038181628a370a11e095a7314a63a0a6c5b5a9bcab6fbfdf" + } + ], + "sourceUrl": "https://github.com/dev-five-git/vespertide/blob/586426bcfc388557fe9b916c941adb43ab04317e/SKILL.md", + "latestUrl": "https://github.com/dev-five-git/vespertide/blob/HEAD/SKILL.md" + }, + { + "name": "vercel-react-best-practices", + "origin": "external", + "title": "React/Next.js performance (Vercel Engineering)", + "description": "40+ rules across 8 categories: eliminating waterfalls, bundle size, re-render cost, server fetching. Multi-file: SKILL.md plus an AGENTS.md and ~70 rule files.", + "usedFor": "The TSX devup_figma_export returns becomes React components; these are the performance rules they are then judged by.", + "repo": "vercel-labs/agent-skills", + "path": "skills/react-best-practices", + "installCommand": "npx skills add vercel-labs/agent-skills", + "license": "none-declared", + "licenseNote": "vercel-labs/agent-skills publishes no LICENSE file, so devup-mcp does not vendor or redistribute its content. It is installed from source by the agent.", + "sourceUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-best-practices", + "latestUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-best-practices" + }, + { + "name": "vercel-react-view-transitions", + "origin": "external", + "title": "React View Transitions (Vercel Engineering)", + "description": "React's View Transition API: , addTransitionType, CSS pseudo-elements, shared element transitions, Next.js integration. Multi-file: SKILL.md plus AGENTS.md and reference files.", + "usedFor": "Figma prototype reactions become animations; this is how to implement them with the View Transition API rather than by hand.", + "repo": "vercel-labs/agent-skills", + "path": "skills/react-view-transitions", + "installCommand": "npx skills add vercel-labs/agent-skills", + "license": "none-declared", + "licenseNote": "vercel-labs/agent-skills publishes no LICENSE file, so devup-mcp does not vendor or redistribute its content. It is installed from source by the agent.", + "sourceUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-view-transitions", + "latestUrl": "https://github.com/vercel-labs/agent-skills/tree/HEAD/skills/react-view-transitions" + } + ] +} 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/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", From 2d5bf51dca35691ce027a982c733b089a14d3bfa Mon Sep 17 00:00:00 2001 From: leader Date: Sun, 18 Oct 2026 18:19:20 +0900 Subject: [PATCH 02/19] refactor: embed workflow prose and keyword data from text assets --- README.md | 15 + .../src/codegen/component.rs | 16 +- .../src/codegen/evidence.rs | 19 +- .../src/codegen/property_derivations.rs | 12 +- .../devup-mcp-devup-ui/src/codegen/style.rs | 6 +- .../devup-mcp-devup-ui/src/codegen/width.rs | 12 +- crates/devup-mcp-devup-ui/src/html_props.rs | 9 +- .../src/provenance/absolute_bounds.rs | 14 +- .../src/provenance/resolution.rs | 10 +- .../src/provenance/sizing.rs | 8 +- .../text/absolute_bounds-annotate-35afe622.md | 1 + .../text/absolute_bounds-annotate-8cf6eb51.md | 1 + .../text/absolute_bounds-annotate-9c9cb9c4.md | 1 + .../text/absolute_bounds-annotate-f08cae30.md | 1 + .../blur_layers-check_Violation-c576344a.md | 1 + ...onent-add_fallback_diagnostics-9333dc00.md | 1 + ...onent-add_fallback_diagnostics-b5ee2878.md | 1 + ...omponent_registration_snapshot-dcc3fe0a.md | 15 + ...lved_token_bindings_Diagnostic-08be2867.md | 1 + ..._layout_diagnostics_Diagnostic-79e776df.md | 1 + ..._layout_diagnostics_Diagnostic-c94c1150.md | 1 + .../evidence-placement_contract-09b061a1.md | 1 + .../evidence-placement_contract-5c61c6c3.md | 1 + .../evidence-placement_contract-7c9ff723.md | 1 + .../evidence-placement_contract-adba181a.md | 1 + ...-placement_contract_Diagnostic-d84874e6.md | 1 + .../text/html_props-is_html_prop-113d7813.md | 1 + .../text/html_props-is_html_prop-2afffb93.md | 1 + .../text/html_props-is_html_prop-9108b590.md | 1 + ...rivations-property_derivations-2ec1244f.md | 1 + ...rivations-property_derivations-691f8d10.md | 1 + ...rivations-property_derivations-a0f0555b.md | 1 + ...rivations-property_derivations-a4bd5441.md | 1 + ...rivations-property_derivations-f0db063f.md | 1 + ...rivations-property_derivations-f4103b4e.md | 1 + ...esolution-resolution_semantics-16da012c.md | 1 + ...esolution-resolution_semantics-1fe5685d.md | 1 + ...esolution-resolution_semantics-2373f8c7.md | 1 + ...esolution-resolution_semantics-2c171ecb.md | 1 + ...esolution-resolution_semantics-6489c0ec.md | 1 + .../site-interaction_findings-3f71ed85.md | 1 + .../text/site-metadata_finding-e57c4614.md | 1 + ...bsolute_component_verification-13b3029b.md | 1 + ...bsolute_component_verification-65b6e30c.md | 1 + ...bsolute_component_verification-bdf7d908.md | 1 + ...bsolute_component_verification-cfd19786.md | 1 + .../src/text/style-description-49ef22f0.md | 1 + .../src/text/style-description-5ad1a929.md | 1 + .../src/text/style-description-b7e8995c.md | 1 + .../ui_layout-check_held_left-6cb7140a.md | 1 + .../ui_layout-check_held_left-d6e0fe40.md | 1 + .../ui_layout-check_line_mask-ab57b482.md | 1 + .../ui_structure-query_findings-60bd4f27.md | 1 + ...-check_static_object_Violation-a031fb84.md | 1 + ...port_hardcoded_value_Violation-63c589c5.md | 1 + .../width-policy_horizontal_proof-6cd374a8.md | 1 + .../text/width-policy_width_proof-d2d9e821.md | 1 + .../text/width-positioned_finding-7a9e6965.md | 1 + .../src/text/width-width_behavior-8f8e0a0f.md | 1 + .../src/text/width-width_behavior-d9663795.md | 1 + .../src/text/width-width_behavior-fdf7cb4e.md | 1 + crates/devup-mcp-devup-ui/src/ui_layout.rs | 6 +- crates/devup-mcp-devup-ui/src/ui_structure.rs | 2 +- crates/devup-mcp-devup-ui/src/ui_validate.rs | 10 +- .../src/ui_validate/blur_layers.rs | 2 +- .../src/ui_validate/site.rs | 4 +- .../devup-mcp-figma/src/asset_render_info.rs | 2 +- crates/devup-mcp-figma/src/assets.rs | 16 +- crates/devup-mcp-figma/src/bridge.rs | 14 +- crates/devup-mcp-figma/src/collector.rs | 38 +-- crates/devup-mcp-figma/src/errors.rs | 11 +- crates/devup-mcp-figma/src/oauth.rs | 7 +- ...et_render_info-annotate_assets-012d4cd1.md | 1 + ...ssets-asset_export_from_result-a4ad1a7b.md | 1 + ...assets-validate_asset_requests-09bfca92.md | 1 + .../src/text/assets-visible_scope-00ec4145.md | 1 + .../bridge-bridge_only_refusal-4e141d6f.md | 1 + ...ge-no_room_on_the_metered_path-41a0802f.md | 1 + .../src/text/bridge-not_reachable-fa7318ce.md | 1 + .../bridge-wrap_as_image_result-467d2f14.md | 1 + .../text/collector-w5_tests_TREE-ae8d8ae7.md | 14 + ...e_and_nested_automatic_screens-261e1062.md | 7 + ..._candidates_or_projection_trun-90cc510e.md | 5 + .../errors-figma_unreachable_hint-347725ba.md | 1 + ...uth-callback_port_in_use_error-049191b7.md | 1 + crates/devup-mcp/src/asset_batches.rs | 4 +- crates/devup-mcp/src/server/asset_jobs.rs | 4 +- crates/devup-mcp/src/server/audit_plan.rs | 10 +- crates/devup-mcp/src/server/compact.rs | 2 +- crates/devup-mcp/src/server/delivery.rs | 8 +- crates/devup-mcp/src/server/design_drift.rs | 2 +- crates/devup-mcp/src/server/diagnostics.rs | 218 +++++++----- crates/devup-mcp/src/server/export_record.rs | 2 +- crates/devup-mcp/src/server/feature_trace.rs | 4 +- crates/devup-mcp/src/server/framework.rs | 2 +- crates/devup-mcp/src/server/guide.rs | 311 +----------------- crates/devup-mcp/src/server/interactions.rs | 4 +- crates/devup-mcp/src/server/measurement.rs | 2 +- crates/devup-mcp/src/server/mod.rs | 132 +++----- crates/devup-mcp/src/server/navigation.rs | 2 +- crates/devup-mcp/src/server/operation.rs | 12 +- .../devup-mcp/src/server/project_context.rs | 10 +- .../src/server/project_context_export.rs | 2 +- .../src/server/project_context_ui.rs | 2 +- .../devup-mcp/src/server/project_plan/mod.rs | 2 +- .../src/server/project_plan/units.rs | 2 +- crates/devup-mcp/src/server/projection.rs | 52 ++- .../devup-mcp/src/server/result_contract.rs | 2 +- crates/devup-mcp/src/server/runtime_audit.rs | 45 +-- crates/devup-mcp/src/server/screen_plan.rs | 275 +++++++--------- .../devup-mcp/src/server/screen_plan_tests.rs | 27 ++ .../src/server/screenshot_observations.rs | 2 +- .../devup-mcp/src/server/section_checklist.rs | 2 +- crates/devup-mcp/src/server/self_update.rs | 8 +- crates/devup-mcp/src/server/skills.rs | 93 ++---- crates/devup-mcp/src/server/source_limits.rs | 21 ++ crates/devup-mcp/src/server/stack_diff.rs | 23 +- .../text/asset_batches-merge-8a712496.md | 1 + .../asset_batches-unwrap_response-c1acefdb.md | 1 + .../asset_jobs-acquire_AssetJob-1e1eb717.md | 1 + .../text/asset_jobs-response_with-526ef5eb.md | 1 + .../text/audit_plan-url_plan-a1031a26.md | 1 + .../server/text/audit_plan-write-7f9aea99.md | 1 + .../server/text/audit_plan-write-925db929.md | 1 + .../server/text/compact-invalid-7f830631.md | 1 + .../delivery-inline_size_error-e8696ca0.md | 1 + .../text/delivery-server_identity-f8e03915.md | 1 + .../delivery-with_inline_recovery-01306837.md | 1 + .../delivery-with_inline_recovery-e6ab7ccd.md | 1 + .../text/design_drift-MEANING-dfdee151.md | 1 + .../src/server/text/devup-feature-trace.md | 1 + .../src/server/text/devup-figma-auth.md | 1 + .../src/server/text/devup-figma-explore.md | 1 + .../src/server/text/devup-figma-export.md | 1 + .../src/server/text/devup-figma-measure.md | 1 + .../src/server/text/devup-figma-search.md | 1 + .../src/server/text/devup-project-context.md | 1 + .../src/server/text/devup-project-plan.md | 1 + .../src/server/text/devup-runtime-audit.md | 1 + .../src/server/text/devup-screen-plan.md | 1 + .../devup-mcp/src/server/text/devup-skills.md | 1 + .../src/server/text/devup-stack-diff.md | 1 + .../src/server/text/devup-ui-validate.md | 1 + .../src/server/text/devup-visual-compare.md | 1 + ...nostics-CREDENTIAL_SOURCE_NOTE-f6bf233e.md | 1 + .../text/diagnostics-KEYLESS_NOTE-bcb2fb02.md | 1 + .../diagnostics-RUN_THE_PLUGIN-6f87e1f9.md | 1 + ...nostics-SERVED_WITH_ONE_PLUGIN-a04b8617.md | 1 + .../diagnostics-attached_file-eb5b83b1.md | 1 + .../diagnostics-bridge_option-1573849a.md | 1 + .../diagnostics-bridge_option-c5748d60.md | 1 + .../text/diagnostics-bridge_path-10545dbe.md | 1 + .../text/diagnostics-bridge_path-5051fedf.md | 1 + .../diagnostics-bridge_reason-2101bbfb.md | 1 + .../diagnostics-bridge_reason-6c5cdae3.md | 1 + .../diagnostics-bridge_reason-76cadd39.md | 1 + .../diagnostics-bridge_reason-88ec9725.md | 1 + .../diagnostics-bridge_reason-8a3715df.md | 1 + .../diagnostics-bridge_reason-938d46a6.md | 1 + .../diagnostics-bridge_reason-9a4710b8.md | 1 + .../diagnostics-bridge_reason-a9b48677.md | 1 + .../diagnostics-bridge_reason-bfc91239.md | 1 + .../diagnostics-bridge_reason-c2bc4670.md | 1 + .../diagnostics-bridge_reason-d72562c5.md | 1 + .../diagnostics-bridge_reason-d82d1e27.md | 1 + .../text/diagnostics-client_setup-4350fbba.md | 1 + .../text/diagnostics-client_setup-a528a590.md | 1 + .../text/diagnostics-client_setup-b61acd6f.md | 1 + .../text/diagnostics-client_setup-ebbeb457.md | 1 + .../diagnostics-connection_report-d61f53ae.md | 1 + .../text/diagnostics-direct_path-6f63807d.md | 1 + .../text/diagnostics-direct_path-c63b8306.md | 1 + .../diagnostics-direct_reason-3faeaac8.md | 1 + .../diagnostics-direct_reason-6336653b.md | 1 + .../diagnostics-direct_reason-6f3f9489.md | 1 + .../diagnostics-direct_reason-83c3453a.md | 1 + .../diagnostics-direct_reason-df4d5655.md | 1 + .../diagnostics-doctor_report-787ad2ad.md | 1 + .../text/diagnostics-repair-05473b24.md | 1 + .../text/diagnostics-repair-8e741ef8.md | 1 + .../text/diagnostics-repair-a70db245.md | 1 + .../text/diagnostics-repair-c2a44c5b.md | 1 + .../export_record-write_repro-ab60ad0a.md | 1 + .../text/feature_trace-ACCEPTED-59546b50.md | 1 + .../text/feature_trace-comparison-60526fe1.md | 1 + .../server/text/framework-report-6fa8f9df.md | 1 + .../src/server/text/guide-GUIDE-c682b0c5.md | 306 +++++++++++++++++ .../text/guide-INSTRUCTIONS-49bd7ec6.md | 5 + .../text/interactions-attach-1e4189e6.md | 1 + .../text/interactions-attach-cd63f312.md | 1 + .../text/measurement-calibrate-b4d6cfac.md | 1 + .../server/text/mod-CODE_GAP_WHY-6866def7.md | 1 + .../text/mod-apply_explore_limit-7dacda8e.md | 1 + .../text/mod-apply_explore_limit-f9b94793.md | 1 + ...attach_layout_and_source_links-a7298619.md | 1 + .../text/mod-bind_to_bridge-3d006854.md | 1 + .../text/mod-bind_to_bridge-6dea64b3.md | 1 + .../text/mod-devup_figma_auth-444c97f4.md | 1 + .../text/mod-devup_figma_export-1fe13d5b.md | 1 + .../text/mod-devup_ui_validate-48eac31a.md | 1 + .../src/server/text/mod-response-22d8f704.md | 1 + .../src/server/text/mod-skill_gap-bd8a6ae8.md | 1 + ...start_operation_scoped_tracked-ec551baf.md | 1 + .../server/text/mod-to_mcp_error-20941350.md | 1 + .../server/text/mod-to_mcp_error-784239dd.md | 1 + .../mod-with_context_skill_gap-fc39c774.md | 1 + .../text/mod-with_project_checks-2c4cf346.md | 1 + .../server/text/navigation-report-928037dc.md | 1 + .../operation-plugin_next_action-021029b8.md | 1 + .../operation-plugin_next_action-8366c796.md | 1 + .../operation-plugin_next_action-847e92e7.md | 1 + .../operation-plugin_next_action-d622713f.md | 1 + .../text/operation-upstream_error-3ae5dbed.md | 1 + .../text/operation-upstream_error-e5dda23e.md | 1 + ...text-ambiguous_theme_guardrail-b51035aa.md | 1 + ...ject_context-attach_scan_notes-4b303bac.md | 1 + ...t_context-theme_for_validation-ba1b05bd.md | 1 + .../project_context-theme_scope-c1299f17.md | 1 + ..._context_export-export_context-12e0ec03.md | 1 + .../text/project_context_ui-run-9d95e12b.md | 1 + .../projection-annotate_node_ids-b03e6d56.md | 1 + .../projection-apply_delivery-4796ba61.md | 1 + .../projection-apply_delivery-6938bf43.md | 1 + .../projection-apply_delivery-e5a0fbb2.md | 1 + .../text/projection-asset_summary-2538c470.md | 1 + ...rojection-attach_bridge_source-65001189.md | 1 + ...rojection-attach_bridge_source-e2f65c04.md | 1 + .../projection-project_operation-2b397164.md | 1 + .../projection-project_operation-648fa6f6.md | 1 + .../projection-project_operation-8a156710.md | 1 + .../projection-project_operation-9a2b2731.md | 1 + ...n-project_operation_Diagnostic-6ab28a84.md | 1 + ...on-resource_diagnostic_details-9b1c1ed8.md | 1 + ...is_renamed_wherever_the_code_p-90121fb1.md | 4 + ...-attach_review_and_deliverable-b1b16fb6.md | 1 + .../text/runtime_audit-fix_hint-0e677049.md | 1 + .../text/runtime_audit-fix_hint-1ca5b7eb.md | 1 + .../text/runtime_audit-fix_hint-1f874fed.md | 1 + .../text/runtime_audit-fix_hint-3a12f919.md | 1 + .../text/runtime_audit-fix_hint-516a692f.md | 1 + .../text/runtime_audit-fix_hint-5960cebc.md | 1 + .../text/runtime_audit-fix_hint-60ba4f27.md | 1 + .../text/runtime_audit-fix_hint-650b18d4.md | 1 + .../text/runtime_audit-fix_hint-7a003bba.md | 1 + .../text/runtime_audit-fix_hint-975acb57.md | 1 + .../text/runtime_audit-fix_hint-a13b2885.md | 1 + .../text/runtime_audit-fix_hint-b3941912.md | 1 + .../text/runtime_audit-fix_hint-e19dc0a1.md | 1 + ...untime_audit-ingest_references-72b6591d.md | 1 + ...untime_audit-visual_comparison-8010a061.md | 1 + ...untime_audit-visual_comparison-8418b2cf.md | 1 + .../text/runtime_audit-write_plan-2d2cc624.md | 1 + .../text/runtime_audit-write_plan-c3ca4e3c.md | 1 + .../screen-plan-admin-route-segments.json | 7 + .../server/text/screen-plan-admin-words.json | 13 + .../text/screen-plan-analytics-env.json | 7 + .../text/screen-plan-analytics-packages.json | 9 + .../server/text/screen-plan-device-words.json | 12 + .../text/screen-plan-exclude-words.json | 24 ++ .../server/text/screen-plan-logo-words.json | 9 + .../server/text/screen-plan-modal-words.json | 15 + .../text/screen-plan-motion-libraries.json | 8 + .../src/server/text/screen-plan-og-words.json | 10 + .../text/screen-plan-public-app-words.json | 10 + .../text/screen-plan-public-screen-words.json | 23 ++ .../server/text/screen-plan-root-words.json | 8 + .../screen-plan-section-device-words.json | 8 + .../screen-plan-view-transition-docs.json | 7 + .../screen_plan-ASK_INSTRUCTION-0285b268.md | 1 + .../text/screen_plan-DONE_RULE-95793a55.md | 1 + ...een_plan-IMPLEMENT_INSTRUCTION-0f8204de.md | 1 + ...creen_plan-INTERACTION_RECIPES-703799bb.md | 1 + .../screen_plan-RESTRAINT_RULES-cef67bed.md | 1 + .../server/text/screen_plan-defer-3281174c.md | 1 + .../screen_plan-draft_plan_with-140b58b5.md | 1 + .../screen_plan-site_defaults-a7c1a186.md | 1 + .../screen_plan-stack_defaults-c2140f37.md | 1 + .../screen_plan-stack_defaults-d534196c.md | 1 + .../screen_plan-stack_questions-eb6d40b2.md | 1 + ...n_plan-view_transition_default-81ca7ab7.md | 1 + .../screenshot_observations-rank-2c5a4662.md | 1 + .../section_checklist-checklist-500ab77e.md | 1 + .../server/text/self_update-note-02a038b0.md | 1 + .../text/self_update-report-28b64040.md | 1 + .../skills-changepacks_obligation-23e2c441.md | 1 + .../skills-changepacks_obligation-54540b9a.md | 1 + .../skills-changepacks_obligation-91f6f9c2.md | 1 + .../text/skills-command_state-84cd29d8.md | 1 + .../text/skills-install_action-13d23996.md | 1 + .../text/skills-install_action-50ca51e1.md | 1 + .../text/skills-install_action-510f4ba8.md | 1 + .../text/skills-install_action-e5981815.md | 1 + .../text/skills-install_with-5f02c573.md | 1 + .../text/skills-install_with-94607ffe.md | 1 + .../text/skills-machine_wide-46b2476c.md | 1 + .../text/skills-machine_wide-85410d98.md | 1 + .../text/skills-provenance_note-ca003bce.md | 4 + .../text/skills-provenance_note-fa8096ea.md | 5 + .../src/server/text/skills-report-071c67f6.md | 1 + .../src/server/text/skills-report-1a0ab5f8.md | 1 + .../src/server/text/skills-report-2e1e5387.md | 1 + .../src/server/text/skills-report-3e1e10ee.md | 1 + .../src/server/text/skills-report-55e539f1.md | 1 + .../src/server/text/skills-report-d3cac7a1.md | 1 + .../src/server/text/skills-report-d4500f82.md | 1 + .../src/server/text/skills-report-eb1c9bcc.md | 1 + ...k_diff-attach_source_ownership-0113946e.md | 1 + ...k_diff-attach_source_ownership-758751d4.md | 1 + ...tack_diff-openapi_client_layer-a74a750f.md | 1 + ...stack_diff-route_openapi_layer-215d8b4d.md | 1 + ...stack_diff-route_openapi_layer-3c2d71f8.md | 1 + ...stack_diff-route_openapi_layer-4d0cfa16.md | 1 + ...stack_diff-route_openapi_layer-70549f41.md | 1 + .../src/server/text/units-split-bf50d8a5.md | 29 ++ ...alidation-parse_asset_requests-eecb2af1.md | 1 + ...n-validate_artifact_projection-e8e39fca.md | 1 + ...lidation-validate_asset_budget-915c1c5f.md | 1 + ...-validate_asset_render_outputs-12b6f4a5.md | 1 + ...idation-validate_export_budget-986a2783.md | 1 + .../validation-validate_outputs-166587f3.md | 1 + .../validation_guidance-guidance-d8ac2119.md | 1 + crates/devup-mcp/src/server/validation.rs | 17 +- .../src/server/validation_guidance.rs | 2 +- 323 files changed, 1392 insertions(+), 954 deletions(-) create mode 100644 crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-35afe622.md create mode 100644 crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-8cf6eb51.md create mode 100644 crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-9c9cb9c4.md create mode 100644 crates/devup-mcp-devup-ui/src/text/absolute_bounds-annotate-f08cae30.md create mode 100644 crates/devup-mcp-devup-ui/src/text/blur_layers-check_Violation-c576344a.md create mode 100644 crates/devup-mcp-devup-ui/src/text/component-add_fallback_diagnostics-9333dc00.md create mode 100644 crates/devup-mcp-devup-ui/src/text/component-add_fallback_diagnostics-b5ee2878.md create mode 100644 crates/devup-mcp-devup-ui/src/text/component-render_component_registration_snapshot-dcc3fe0a.md create mode 100644 crates/devup-mcp-devup-ui/src/text/component-unresolved_token_bindings_Diagnostic-08be2867.md create mode 100644 crates/devup-mcp-devup-ui/src/text/evidence-image_layout_diagnostics_Diagnostic-79e776df.md create mode 100644 crates/devup-mcp-devup-ui/src/text/evidence-image_layout_diagnostics_Diagnostic-c94c1150.md create mode 100644 crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-09b061a1.md create mode 100644 crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-5c61c6c3.md create mode 100644 crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-7c9ff723.md create mode 100644 crates/devup-mcp-devup-ui/src/text/evidence-placement_contract-adba181a.md create mode 100644 crates/devup-mcp-devup-ui/src/text/evidence-placement_contract_Diagnostic-d84874e6.md create mode 100644 crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-113d7813.md create mode 100644 crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-2afffb93.md create mode 100644 crates/devup-mcp-devup-ui/src/text/html_props-is_html_prop-9108b590.md create mode 100644 crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-2ec1244f.md create mode 100644 crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-691f8d10.md create mode 100644 crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-a0f0555b.md create mode 100644 crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-a4bd5441.md create mode 100644 crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-f0db063f.md create mode 100644 crates/devup-mcp-devup-ui/src/text/property_derivations-property_derivations-f4103b4e.md create mode 100644 crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-16da012c.md create mode 100644 crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-1fe5685d.md create mode 100644 crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-2373f8c7.md create mode 100644 crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-2c171ecb.md create mode 100644 crates/devup-mcp-devup-ui/src/text/resolution-resolution_semantics-6489c0ec.md create mode 100644 crates/devup-mcp-devup-ui/src/text/site-interaction_findings-3f71ed85.md create mode 100644 crates/devup-mcp-devup-ui/src/text/site-metadata_finding-e57c4614.md create mode 100644 crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-13b3029b.md create mode 100644 crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-65b6e30c.md create mode 100644 crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-bdf7d908.md create mode 100644 crates/devup-mcp-devup-ui/src/text/sizing-absolute_component_verification-cfd19786.md create mode 100644 crates/devup-mcp-devup-ui/src/text/style-description-49ef22f0.md create mode 100644 crates/devup-mcp-devup-ui/src/text/style-description-5ad1a929.md create mode 100644 crates/devup-mcp-devup-ui/src/text/style-description-b7e8995c.md create mode 100644 crates/devup-mcp-devup-ui/src/text/ui_layout-check_held_left-6cb7140a.md create mode 100644 crates/devup-mcp-devup-ui/src/text/ui_layout-check_held_left-d6e0fe40.md create mode 100644 crates/devup-mcp-devup-ui/src/text/ui_layout-check_line_mask-ab57b482.md create mode 100644 crates/devup-mcp-devup-ui/src/text/ui_structure-query_findings-60bd4f27.md create mode 100644 crates/devup-mcp-devup-ui/src/text/ui_validate-check_static_object_Violation-a031fb84.md create mode 100644 crates/devup-mcp-devup-ui/src/text/ui_validate-report_hardcoded_value_Violation-63c589c5.md create mode 100644 crates/devup-mcp-devup-ui/src/text/width-policy_horizontal_proof-6cd374a8.md create mode 100644 crates/devup-mcp-devup-ui/src/text/width-policy_width_proof-d2d9e821.md create mode 100644 crates/devup-mcp-devup-ui/src/text/width-positioned_finding-7a9e6965.md create mode 100644 crates/devup-mcp-devup-ui/src/text/width-width_behavior-8f8e0a0f.md create mode 100644 crates/devup-mcp-devup-ui/src/text/width-width_behavior-d9663795.md create mode 100644 crates/devup-mcp-devup-ui/src/text/width-width_behavior-fdf7cb4e.md create mode 100644 crates/devup-mcp-figma/src/text/asset_render_info-annotate_assets-012d4cd1.md create mode 100644 crates/devup-mcp-figma/src/text/assets-asset_export_from_result-a4ad1a7b.md create mode 100644 crates/devup-mcp-figma/src/text/assets-validate_asset_requests-09bfca92.md create mode 100644 crates/devup-mcp-figma/src/text/assets-visible_scope-00ec4145.md create mode 100644 crates/devup-mcp-figma/src/text/bridge-bridge_only_refusal-4e141d6f.md create mode 100644 crates/devup-mcp-figma/src/text/bridge-no_room_on_the_metered_path-41a0802f.md create mode 100644 crates/devup-mcp-figma/src/text/bridge-not_reachable-fa7318ce.md create mode 100644 crates/devup-mcp-figma/src/text/bridge-wrap_as_image_result-467d2f14.md create mode 100644 crates/devup-mcp-figma/src/text/collector-w5_tests_TREE-ae8d8ae7.md create mode 100644 crates/devup-mcp-figma/src/text/collector-w5_tests_p4_section_keeps_long_page_and_nested_automatic_screens-261e1062.md create mode 100644 crates/devup-mcp-figma/src/text/collector-w5_tests_w5_text_preview_does_not_change_candidates_or_projection_trun-90cc510e.md create mode 100644 crates/devup-mcp-figma/src/text/errors-figma_unreachable_hint-347725ba.md create mode 100644 crates/devup-mcp-figma/src/text/oauth-callback_port_in_use_error-049191b7.md create mode 100644 crates/devup-mcp/src/server/source_limits.rs create mode 100644 crates/devup-mcp/src/server/text/asset_batches-merge-8a712496.md create mode 100644 crates/devup-mcp/src/server/text/asset_batches-unwrap_response-c1acefdb.md create mode 100644 crates/devup-mcp/src/server/text/asset_jobs-acquire_AssetJob-1e1eb717.md create mode 100644 crates/devup-mcp/src/server/text/asset_jobs-response_with-526ef5eb.md create mode 100644 crates/devup-mcp/src/server/text/audit_plan-url_plan-a1031a26.md create mode 100644 crates/devup-mcp/src/server/text/audit_plan-write-7f9aea99.md create mode 100644 crates/devup-mcp/src/server/text/audit_plan-write-925db929.md create mode 100644 crates/devup-mcp/src/server/text/compact-invalid-7f830631.md create mode 100644 crates/devup-mcp/src/server/text/delivery-inline_size_error-e8696ca0.md create mode 100644 crates/devup-mcp/src/server/text/delivery-server_identity-f8e03915.md create mode 100644 crates/devup-mcp/src/server/text/delivery-with_inline_recovery-01306837.md create mode 100644 crates/devup-mcp/src/server/text/delivery-with_inline_recovery-e6ab7ccd.md create mode 100644 crates/devup-mcp/src/server/text/design_drift-MEANING-dfdee151.md create mode 100644 crates/devup-mcp/src/server/text/devup-feature-trace.md create mode 100644 crates/devup-mcp/src/server/text/devup-figma-auth.md create mode 100644 crates/devup-mcp/src/server/text/devup-figma-explore.md create mode 100644 crates/devup-mcp/src/server/text/devup-figma-export.md create mode 100644 crates/devup-mcp/src/server/text/devup-figma-measure.md create mode 100644 crates/devup-mcp/src/server/text/devup-figma-search.md create mode 100644 crates/devup-mcp/src/server/text/devup-project-context.md create mode 100644 crates/devup-mcp/src/server/text/devup-project-plan.md create mode 100644 crates/devup-mcp/src/server/text/devup-runtime-audit.md create mode 100644 crates/devup-mcp/src/server/text/devup-screen-plan.md create mode 100644 crates/devup-mcp/src/server/text/devup-skills.md create mode 100644 crates/devup-mcp/src/server/text/devup-stack-diff.md create mode 100644 crates/devup-mcp/src/server/text/devup-ui-validate.md create mode 100644 crates/devup-mcp/src/server/text/devup-visual-compare.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-CREDENTIAL_SOURCE_NOTE-f6bf233e.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-KEYLESS_NOTE-bcb2fb02.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-RUN_THE_PLUGIN-6f87e1f9.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-SERVED_WITH_ONE_PLUGIN-a04b8617.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-attached_file-eb5b83b1.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_option-1573849a.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_option-c5748d60.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_path-10545dbe.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_path-5051fedf.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-2101bbfb.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-6c5cdae3.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-76cadd39.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-88ec9725.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-8a3715df.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-938d46a6.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-9a4710b8.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-a9b48677.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-bfc91239.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-c2bc4670.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-d72562c5.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-bridge_reason-d82d1e27.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-client_setup-4350fbba.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-client_setup-a528a590.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-client_setup-b61acd6f.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-client_setup-ebbeb457.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-connection_report-d61f53ae.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-direct_path-6f63807d.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-direct_path-c63b8306.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-direct_reason-3faeaac8.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-direct_reason-6336653b.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-direct_reason-6f3f9489.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-direct_reason-83c3453a.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-direct_reason-df4d5655.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-doctor_report-787ad2ad.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-repair-05473b24.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-repair-8e741ef8.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-repair-a70db245.md create mode 100644 crates/devup-mcp/src/server/text/diagnostics-repair-c2a44c5b.md create mode 100644 crates/devup-mcp/src/server/text/export_record-write_repro-ab60ad0a.md create mode 100644 crates/devup-mcp/src/server/text/feature_trace-ACCEPTED-59546b50.md create mode 100644 crates/devup-mcp/src/server/text/feature_trace-comparison-60526fe1.md create mode 100644 crates/devup-mcp/src/server/text/framework-report-6fa8f9df.md create mode 100644 crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md create mode 100644 crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md create mode 100644 crates/devup-mcp/src/server/text/interactions-attach-1e4189e6.md create mode 100644 crates/devup-mcp/src/server/text/interactions-attach-cd63f312.md create mode 100644 crates/devup-mcp/src/server/text/measurement-calibrate-b4d6cfac.md create mode 100644 crates/devup-mcp/src/server/text/mod-CODE_GAP_WHY-6866def7.md create mode 100644 crates/devup-mcp/src/server/text/mod-apply_explore_limit-7dacda8e.md create mode 100644 crates/devup-mcp/src/server/text/mod-apply_explore_limit-f9b94793.md create mode 100644 crates/devup-mcp/src/server/text/mod-attach_layout_and_source_links-a7298619.md create mode 100644 crates/devup-mcp/src/server/text/mod-bind_to_bridge-3d006854.md create mode 100644 crates/devup-mcp/src/server/text/mod-bind_to_bridge-6dea64b3.md create mode 100644 crates/devup-mcp/src/server/text/mod-devup_figma_auth-444c97f4.md create mode 100644 crates/devup-mcp/src/server/text/mod-devup_figma_export-1fe13d5b.md create mode 100644 crates/devup-mcp/src/server/text/mod-devup_ui_validate-48eac31a.md create mode 100644 crates/devup-mcp/src/server/text/mod-response-22d8f704.md create mode 100644 crates/devup-mcp/src/server/text/mod-skill_gap-bd8a6ae8.md create mode 100644 crates/devup-mcp/src/server/text/mod-start_operation_scoped_tracked-ec551baf.md create mode 100644 crates/devup-mcp/src/server/text/mod-to_mcp_error-20941350.md create mode 100644 crates/devup-mcp/src/server/text/mod-to_mcp_error-784239dd.md create mode 100644 crates/devup-mcp/src/server/text/mod-with_context_skill_gap-fc39c774.md create mode 100644 crates/devup-mcp/src/server/text/mod-with_project_checks-2c4cf346.md create mode 100644 crates/devup-mcp/src/server/text/navigation-report-928037dc.md create mode 100644 crates/devup-mcp/src/server/text/operation-plugin_next_action-021029b8.md create mode 100644 crates/devup-mcp/src/server/text/operation-plugin_next_action-8366c796.md create mode 100644 crates/devup-mcp/src/server/text/operation-plugin_next_action-847e92e7.md create mode 100644 crates/devup-mcp/src/server/text/operation-plugin_next_action-d622713f.md create mode 100644 crates/devup-mcp/src/server/text/operation-upstream_error-3ae5dbed.md create mode 100644 crates/devup-mcp/src/server/text/operation-upstream_error-e5dda23e.md create mode 100644 crates/devup-mcp/src/server/text/project_context-ambiguous_theme_guardrail-b51035aa.md create mode 100644 crates/devup-mcp/src/server/text/project_context-attach_scan_notes-4b303bac.md create mode 100644 crates/devup-mcp/src/server/text/project_context-theme_for_validation-ba1b05bd.md create mode 100644 crates/devup-mcp/src/server/text/project_context-theme_scope-c1299f17.md create mode 100644 crates/devup-mcp/src/server/text/project_context_export-export_context-12e0ec03.md create mode 100644 crates/devup-mcp/src/server/text/project_context_ui-run-9d95e12b.md create mode 100644 crates/devup-mcp/src/server/text/projection-annotate_node_ids-b03e6d56.md create mode 100644 crates/devup-mcp/src/server/text/projection-apply_delivery-4796ba61.md create mode 100644 crates/devup-mcp/src/server/text/projection-apply_delivery-6938bf43.md create mode 100644 crates/devup-mcp/src/server/text/projection-apply_delivery-e5a0fbb2.md create mode 100644 crates/devup-mcp/src/server/text/projection-asset_summary-2538c470.md create mode 100644 crates/devup-mcp/src/server/text/projection-attach_bridge_source-65001189.md create mode 100644 crates/devup-mcp/src/server/text/projection-attach_bridge_source-e2f65c04.md create mode 100644 crates/devup-mcp/src/server/text/projection-project_operation-2b397164.md create mode 100644 crates/devup-mcp/src/server/text/projection-project_operation-648fa6f6.md create mode 100644 crates/devup-mcp/src/server/text/projection-project_operation-8a156710.md create mode 100644 crates/devup-mcp/src/server/text/projection-project_operation-9a2b2731.md create mode 100644 crates/devup-mcp/src/server/text/projection-project_operation_Diagnostic-6ab28a84.md create mode 100644 crates/devup-mcp/src/server/text/projection-resource_diagnostic_details-9b1c1ed8.md create mode 100644 crates/devup-mcp/src/server/text/projection-tests_a_layer_name_no_file_system_takes_is_renamed_wherever_the_code_p-90121fb1.md create mode 100644 crates/devup-mcp/src/server/text/result_contract-attach_review_and_deliverable-b1b16fb6.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-0e677049.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-1ca5b7eb.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-1f874fed.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-3a12f919.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-516a692f.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-5960cebc.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-60ba4f27.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-650b18d4.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-7a003bba.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-975acb57.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-a13b2885.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-b3941912.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-fix_hint-e19dc0a1.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-ingest_references-72b6591d.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8010a061.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8418b2cf.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-write_plan-2d2cc624.md create mode 100644 crates/devup-mcp/src/server/text/runtime_audit-write_plan-c3ca4e3c.md create mode 100644 crates/devup-mcp/src/server/text/screen-plan-admin-route-segments.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-admin-words.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-analytics-env.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-analytics-packages.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-device-words.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-exclude-words.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-logo-words.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-modal-words.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-motion-libraries.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-og-words.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-public-app-words.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-public-screen-words.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-root-words.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-section-device-words.json create mode 100644 crates/devup-mcp/src/server/text/screen-plan-view-transition-docs.json create mode 100644 crates/devup-mcp/src/server/text/screen_plan-ASK_INSTRUCTION-0285b268.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-IMPLEMENT_INSTRUCTION-0f8204de.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-INTERACTION_RECIPES-703799bb.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-RESTRAINT_RULES-cef67bed.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-defer-3281174c.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-draft_plan_with-140b58b5.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-site_defaults-a7c1a186.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-stack_defaults-c2140f37.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-stack_defaults-d534196c.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-stack_questions-eb6d40b2.md create mode 100644 crates/devup-mcp/src/server/text/screen_plan-view_transition_default-81ca7ab7.md create mode 100644 crates/devup-mcp/src/server/text/screenshot_observations-rank-2c5a4662.md create mode 100644 crates/devup-mcp/src/server/text/section_checklist-checklist-500ab77e.md create mode 100644 crates/devup-mcp/src/server/text/self_update-note-02a038b0.md create mode 100644 crates/devup-mcp/src/server/text/self_update-report-28b64040.md create mode 100644 crates/devup-mcp/src/server/text/skills-changepacks_obligation-23e2c441.md create mode 100644 crates/devup-mcp/src/server/text/skills-changepacks_obligation-54540b9a.md create mode 100644 crates/devup-mcp/src/server/text/skills-changepacks_obligation-91f6f9c2.md create mode 100644 crates/devup-mcp/src/server/text/skills-command_state-84cd29d8.md create mode 100644 crates/devup-mcp/src/server/text/skills-install_action-13d23996.md create mode 100644 crates/devup-mcp/src/server/text/skills-install_action-50ca51e1.md create mode 100644 crates/devup-mcp/src/server/text/skills-install_action-510f4ba8.md create mode 100644 crates/devup-mcp/src/server/text/skills-install_action-e5981815.md create mode 100644 crates/devup-mcp/src/server/text/skills-install_with-5f02c573.md create mode 100644 crates/devup-mcp/src/server/text/skills-install_with-94607ffe.md create mode 100644 crates/devup-mcp/src/server/text/skills-machine_wide-46b2476c.md create mode 100644 crates/devup-mcp/src/server/text/skills-machine_wide-85410d98.md create mode 100644 crates/devup-mcp/src/server/text/skills-provenance_note-ca003bce.md create mode 100644 crates/devup-mcp/src/server/text/skills-provenance_note-fa8096ea.md create mode 100644 crates/devup-mcp/src/server/text/skills-report-071c67f6.md create mode 100644 crates/devup-mcp/src/server/text/skills-report-1a0ab5f8.md create mode 100644 crates/devup-mcp/src/server/text/skills-report-2e1e5387.md create mode 100644 crates/devup-mcp/src/server/text/skills-report-3e1e10ee.md create mode 100644 crates/devup-mcp/src/server/text/skills-report-55e539f1.md create mode 100644 crates/devup-mcp/src/server/text/skills-report-d3cac7a1.md create mode 100644 crates/devup-mcp/src/server/text/skills-report-d4500f82.md create mode 100644 crates/devup-mcp/src/server/text/skills-report-eb1c9bcc.md create mode 100644 crates/devup-mcp/src/server/text/stack_diff-attach_source_ownership-0113946e.md create mode 100644 crates/devup-mcp/src/server/text/stack_diff-attach_source_ownership-758751d4.md create mode 100644 crates/devup-mcp/src/server/text/stack_diff-openapi_client_layer-a74a750f.md create mode 100644 crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-215d8b4d.md create mode 100644 crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-3c2d71f8.md create mode 100644 crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-4d0cfa16.md create mode 100644 crates/devup-mcp/src/server/text/stack_diff-route_openapi_layer-70549f41.md create mode 100644 crates/devup-mcp/src/server/text/units-split-bf50d8a5.md create mode 100644 crates/devup-mcp/src/server/text/validation-parse_asset_requests-eecb2af1.md create mode 100644 crates/devup-mcp/src/server/text/validation-validate_artifact_projection-e8e39fca.md create mode 100644 crates/devup-mcp/src/server/text/validation-validate_asset_budget-915c1c5f.md create mode 100644 crates/devup-mcp/src/server/text/validation-validate_asset_render_outputs-12b6f4a5.md create mode 100644 crates/devup-mcp/src/server/text/validation-validate_export_budget-986a2783.md create mode 100644 crates/devup-mcp/src/server/text/validation-validate_outputs-166587f3.md create mode 100644 crates/devup-mcp/src/server/text/validation_guidance-guidance-d8ac2119.md diff --git a/README.md b/README.md index 4ebfabeb..69b5bf84 100644 --- a/README.md +++ b/README.md @@ -1312,3 +1312,18 @@ 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..577307c2 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component.rs @@ -675,7 +675,7 @@ pub fn render_component_registration_snapshot( } } 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}}", + include_str!("../text/component-render_component_registration_snapshot-dcc3fe0a.md"), json_string(name), node, json_string(selected.typed_view().name().unwrap_or(target_name)), @@ -1429,10 +1429,12 @@ fn unresolved_token_bindings( 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." + 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()), @@ -2114,7 +2116,7 @@ fn add_fallback_diagnostics(snapshot: &Snapshot, node: &RawNode, context: &mut C }) }), "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.", + include_str!("../text/component-add_fallback_diagnostics-9333dc00.md"), FidelityImpact::Lossy, ), ( @@ -2144,7 +2146,7 @@ fn add_fallback_diagnostics(snapshot: &Snapshot, node: &RawNode, context: &mut C .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.", + include_str!("../text/component-add_fallback_diagnostics-b5ee2878.md"), FidelityImpact::Lossy, ), ( 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/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/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index ad4abd80..98db17b3 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -480,11 +480,11 @@ impl StyleDerivation { "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.", + include_str!("../text/style-description-5ad1a929.md"), ), 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.", + include_str!("../text/style-description-49ef22f0.md"), ), Self::ImagePaintScale => ( vec!["isAsset", "fills"], @@ -498,7 +498,7 @@ impl StyleDerivation { "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.", + include_str!("../text/style-description-b7e8995c.md"), ), } } 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/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/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..460e5e40 100644 --- a/crates/devup-mcp-devup-ui/src/provenance/sizing.rs +++ b/crates/devup-mcp-devup-ui/src/provenance/sizing.rs @@ -184,7 +184,7 @@ pub(crate) fn absolute_component_verification( }; 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."}, + "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" { @@ -208,7 +208,7 @@ pub(crate) fn absolute_component_verification( } 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." + include_str!("../text/sizing-absolute_component_verification-bdf7d908.md") }); } evidence @@ -300,8 +300,8 @@ pub(crate) fn absolute_component_verification( 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."}) + "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"), 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/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..f7d37b85 100644 --- a/crates/devup-mcp-devup-ui/src/ui_validate.rs +++ b/crates/devup-mcp-devup-ui/src/ui_validate.rs @@ -418,8 +418,8 @@ impl<'t> TsxVisitor<'t> { 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." - ), + 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.")), }); } @@ -520,12 +520,14 @@ impl<'t> TsxVisitor<'t> { rule: "runtime-value", severity: Severity::Error, context: Default::default(), - byte_range: [ + 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." + include_str!("text/ui_validate-check_static_object_Violation-a031fb84.md"), + call_name = call_name, + key = key ), suggestion: None, }); 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-figma/src/asset_render_info.rs b/crates/devup-mcp-figma/src/asset_render_info.rs index ed5a74b0..acf64d92 100644 --- a/crates/devup-mcp-figma/src/asset_render_info.rs +++ b/crates/devup-mcp-figma/src/asset_render_info.rs @@ -703,7 +703,7 @@ pub fn annotate_assets(snapshot: &Snapshot, assets: &mut [AssetManifestEntry]) { info["formatRecommendationEvidence"] = json!(if asset.source_kind == "vector-node" { "Vector asset: retain SVG when the native export is usable; inspect effects and embedded rasters." } else { - "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." + include_str!("text/asset_render_info-annotate_assets-012d4cd1.md") }); if info["extraFills"].is_null() { info["extraFills"] = view diff --git a/crates/devup-mcp-figma/src/assets.rs b/crates/devup-mcp-figma/src/assets.rs index 2cad8199..cd74d409 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -406,9 +406,9 @@ fn visible_scope( 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.", - )) + Err(invalid(include_str!( + "text/assets-visible_scope-00ec4145.md" + ))) } pub fn validate_asset_options( @@ -825,9 +825,9 @@ pub fn validate_asset_requests( ) || 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.", - )); + 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 @@ -1146,9 +1146,7 @@ pub fn asset_export_from_result( 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.", + include_str!("text/assets-asset_export_from_result-a4ad1a7b.md"), request.format.extension() ), false, diff --git a/crates/devup-mcp-figma/src/bridge.rs b/crates/devup-mcp-figma/src/bridge.rs index d6914c53..fe08b94f 100644 --- a/crates/devup-mcp-figma/src/bridge.rs +++ b/crates/devup-mcp-figma/src/bridge.rs @@ -557,10 +557,7 @@ fn not_reachable(link: &LinkState) -> DevupError { {{ 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.", - ), + _ => unavailable(include_str!("text/bridge-not_reachable-fa7318ce.md")), } } @@ -1218,7 +1215,7 @@ fn wrap_as_image_result(data: &Value, served: &BridgeServed) -> Result bool { 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.", + include_str!("text/bridge-no_room_on_the_metered_path-41a0802f.md"), true, json!({ "source": "direct", "stage": "bridge-fallback" }), ) @@ -1996,9 +1992,7 @@ fn bridge_only_refusal(call: &ReadToolCall) -> DevupError { "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." + include_str!("text/bridge-bridge_only_refusal-4e141d6f.md") }; DevupError::with_details( ErrorCode::DevupFigmaDirectUnavailable, diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index b6605288..a42ea73d 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -2958,21 +2958,7 @@ mod w5_tests { 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'); }}; -"#; + const TREE: &str = include_str!("text/collector-w5_tests_TREE-ae8d8ae7.md"); fn search_output(setup: &str) -> Value { run_script( @@ -2986,14 +2972,10 @@ const figma = {fileKey:'file', getNodeByIdAsync: async () => section, #[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); -"# + 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"), @@ -3050,12 +3032,10 @@ node('6:2', 'TEXT', 'Text case', section); #[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); -}} -"# + 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] { 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/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/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/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..60efad6e 100644 --- a/crates/devup-mcp/src/server/audit_plan.rs +++ b/crates/devup-mcp/src/server/audit_plan.rs @@ -279,7 +279,11 @@ pub(super) fn url_plan( 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." + 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 } @@ -810,8 +814,8 @@ pub(super) async fn write( "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.", + "nodeIdentity": include_str!("text/audit_plan-write-7f9aea99.md"), + "nextAction": include_str!("text/audit_plan-write-925db929.md"), })) } 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..17303230 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -55,7 +55,7 @@ enum ActivePath { 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."; +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`. @@ -105,7 +105,7 @@ pub fn connection_report( "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." + 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." }, @@ -235,7 +235,7 @@ pub(super) fn ways_to_open_a_path( }) } -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."; +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. @@ -251,7 +251,7 @@ fn bridge_option(bridge: &BridgePathSnapshot, retry: &Value) -> Value { 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."), + "action": format!(include_str!("text/diagnostics-bridge_option-1573849a.md"), PLUGIN_REATTACH = PLUGIN_REATTACH), "then": status_again(), }), BridgeRole::Host | BridgeRole::Relay => json!({ @@ -261,7 +261,7 @@ fn bridge_option(bridge: &BridgePathSnapshot, retry: &Value) -> Value { }), 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."), + "action": format!(include_str!("text/diagnostics-bridge_option-c5748d60.md"), PLUGIN_REATTACH = PLUGIN_REATTACH, port = port), "then": status_again(), }), BridgeRole::Unavailable => json!({ @@ -323,22 +323,35 @@ fn repair(bridge: &BridgePathSnapshot) -> String { 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) + 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!( - "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::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)." ), @@ -361,7 +374,8 @@ fn repair(bridge: &BridgePathSnapshot) -> String { /// 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."; +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. @@ -378,9 +392,7 @@ pub async fn doctor_report( 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["preferredPathNote"] = json!(include_str!("text/diagnostics-doctor_report-787ad2ad.md")); report["clientSetup"] = client_setup(); report } @@ -396,12 +408,7 @@ fn direct_path(status: AuthStatus, direct: &DirectPathSnapshot, bridge_available 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.", - ); + reason.push_str(include_str!("text/diagnostics-direct_path-c63b8306.md")); } json!({ "available": direct_available, @@ -415,7 +422,7 @@ fn direct_path(status: AuthStatus, direct: &DirectPathSnapshot, bridge_available "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." + "note": include_str!("text/diagnostics-direct_path-6f63807d.md") }, "reason": reason }) @@ -456,7 +463,7 @@ fn bridge_path(bridge: Option<&BridgePathSnapshot>) -> Value { "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.", + "reason": include_str!("text/diagnostics-bridge_path-5051fedf.md"), }); }; let mut path = json!({ @@ -466,7 +473,7 @@ fn bridge_path(bridge: Option<&BridgePathSnapshot>) -> Value { "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.", + "attachedFilesNote": include_str!("text/diagnostics-bridge_path-10545dbe.md"), "reason": bridge_reason(bridge), }); if let Some(issue) = &bridge.issue { @@ -522,7 +529,8 @@ fn peer(peer: &BridgePeer, this_process: bool) -> Value { }) } -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."; +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); @@ -534,50 +542,98 @@ fn bridge_reason(bridge: &BridgePathSnapshot) -> String { .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::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!( - "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) + include_str!("text/diagnostics-bridge_reason-c2bc4670.md"), + owner_text(owner.as_ref(), &port), + port = 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) + 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!( - "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()) + 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::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." ), @@ -605,9 +661,7 @@ fn attached_file(file: &AttachedFile) -> Value { 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." - ); + entry["inViewNote"] = json!(include_str!("text/diagnostics-attached_file-eb5b83b1.md")); } } if file.file_key.is_none() { @@ -649,24 +703,17 @@ fn direct_reason( "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." + 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) => { - "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\" }." + include_str!("text/diagnostics-direct_reason-6336653b.md") } (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." + 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 \ @@ -679,19 +726,12 @@ fn direct_reason( }; 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/)." + include_str!("text/diagnostics-direct_reason-3faeaac8.md") } 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." + include_str!("text/diagnostics-direct_reason-df4d5655.md") } }; format!("{token} {client}") @@ -701,14 +741,14 @@ 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/", + "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": "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.", + "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": "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.", + "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\", \"\"]", @@ -720,7 +760,7 @@ fn client_setup() -> Value { "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.", + "hint": include_str!("text/diagnostics-client_setup-a528a590.md"), "example": { "mcp": { "figma": { 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..cf380ae6 100644 --- a/crates/devup-mcp/src/server/feature_trace.rs +++ b/crates/devup-mcp/src/server/feature_trace.rs @@ -24,7 +24,7 @@ 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."; +const ACCEPTED: &str = include_str!("text/feature_trace-ACCEPTED-59546b50.md"); fn text(value: &Value) -> &str { value.as_str().unwrap_or("") @@ -484,7 +484,7 @@ fn comparison(fields: &BTreeSet, contract: &BTreeSet, reasons: & 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."}) + "scope":include_str!("text/feature_trace-comparison-60526fe1.md")}) } fn compare(design: &Design, operation: &Operation) -> Value { let mut request = BTreeSet::new(); 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 c50bc7ae..62a5b79d 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -26,318 +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 = "Build identity: use server.commit/buildId, not version; reconnect if stale. server.updateAvailable unknown never blocks.\n1. devup-mcp is the primary Figma-to-code source. Connection: bridge -> direct; call devup_figma_auth status first, one Bridge needs no login or url.\n2b. /devup starts devup_project_plan intake for any project/feature/fix/refactor. Read Jira/spec/Figma and devup_project_context first; ask every round up front, draft ERD, confirm, then split work units. devup_screen_plan handles the Figma branch. Load devup_skills and devup-implementation-plan; after confirm never wait: defer and continue, assume at the end.\n3. For Figma implementation use devup_figma_export tsx, the deliverable. TSX links/events need wiring: resolve interactiveCandidates, devup_ui_validate, report wired/tested counts. deliverable.isFinal describes export readiness. Audit every width and exact layout. Backend completion needs devup_stack_diff zero drift.\n4. Request only outputs you read. Before your second call read devup://guide/usage for sizing, verification, 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 - -## 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. - - - -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 { @@ -375,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/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 21bd283c..52d9de67 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -35,6 +35,8 @@ mod screenshot_observations; mod section_checklist; pub mod self_update; mod skills; +#[cfg(test)] +mod source_limits; mod stack_diff; mod tools; mod validation; @@ -540,10 +542,7 @@ impl DevupServer { 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.", + include_str!("text/mod-start_operation_scoped_tracked-ec551baf.md"), false, json!({ "source": "direct", @@ -1005,9 +1004,7 @@ impl DevupServer { 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.", + include_str!("text/mod-bind_to_bridge-3d006854.md"), false, json!({ "stage": "target-resolution", @@ -1112,13 +1109,7 @@ impl DevupServer { 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() - } + 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." @@ -1432,18 +1423,12 @@ fn apply_explore_limit(mut response: Value, limit: usize) -> Value { "{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, false) => include_str!("text/mod-apply_explore_limit-f9b94793.md").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." + include_str!("text/mod-apply_explore_limit-7dacda8e.md"), + found = found, + limit = limit, + returned = returned ), }; let mut truncation = json!({ @@ -1477,7 +1462,9 @@ fn apply_explore_limit(mut response: Value, limit: usize) -> Value { #[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())] + #[tool( + output_schema = permissive_object_output_schema())] + #[doc = include_str!("text/devup-figma-measure.md")] async fn devup_figma_measure( &self, Parameters(input): Parameters, @@ -1489,13 +1476,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-skills.md")] async fn devup_skills( &self, Parameters(input): Parameters, @@ -1527,13 +1510,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-figma-auth.md")] async fn devup_figma_auth( &self, Parameters(input): Parameters, @@ -1583,9 +1562,8 @@ impl DevupServer { 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." - ); + report["warning"] = + json!(include_str!("text/mod-devup_figma_auth-444c97f4.md")); } return Ok(tool_result(report)); } @@ -1606,12 +1584,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-figma-search.md")] async fn devup_figma_search( &self, Parameters(input): Parameters, @@ -1678,13 +1653,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-figma-explore.md")] async fn devup_figma_explore( &self, Parameters(input): Parameters, @@ -1696,20 +1667,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-figma-export.md")] async fn devup_figma_export( &self, Parameters(workflow): Parameters, @@ -1976,7 +1936,7 @@ impl DevupServer { "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."}}), + "how":include_str!("text/mod-devup_figma_export-1fe13d5b.md")}}), ))); } let root_layout = parse_root_layout(&input.root_layout).map_err(to_mcp_error)?; @@ -2313,9 +2273,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-project-context.md")] async fn devup_project_context( &self, Parameters(input): Parameters, @@ -2339,11 +2299,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-ui-validate.md")] async fn devup_ui_validate( &self, Parameters(input): Parameters, @@ -2533,9 +2491,7 @@ impl DevupServer { { 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.", + "why": include_str!("text/mod-devup_ui_validate-48eac31a.md"), "install": skill.install_action(&lookup), }); } @@ -2543,9 +2499,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-stack-diff.md")] async fn devup_stack_diff( &self, Parameters(input): Parameters, @@ -2557,9 +2513,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-visual-compare.md")] async fn devup_visual_compare( &self, Parameters(input): Parameters, @@ -2571,9 +2527,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-feature-trace.md")] async fn devup_feature_trace( &self, Parameters(input): Parameters, @@ -2585,11 +2541,9 @@ impl DevupServer { } #[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() )] + #[doc = include_str!("text/devup-runtime-audit.md")] async fn devup_runtime_audit( &self, Parameters(input): Parameters, @@ -2634,9 +2588,9 @@ impl DevupServer { Ok(tool_result(result)) } #[tool( - description = "Project intake and supervisor: intake | draft | confirm | status | assume | defer | split. Read Jira/epic children, Figma and devup_project_context all scopes first; pass source-labelled evidence. intake always asks work type, optional sources, goal and parts through the runtime question tool. draft records feature-specific rounds and ERD; confirm requires all answers and the Figma screen plan when applicable, writes vespertide models/*.json transactionally. split creates work units with a dependency DAG and .devup/units/.md briefs, one per Jira issue or feature/layer. Workers escalate to supervisor by default (unitQuestions: supervisor|worker). After confirm never wait: defer recommendations and continue, assume at the end. status reports ERD Mermaid, unit DAG/states and decisions; unitUpdates record supervisor verification. State: .devup/project-plan.json. Follow /devup skill for backend commands, latest-first dependencies, exact layout/visual audits, orchestration and zero-drift completion.", output_schema = permissive_object_output_schema() )] + #[doc = include_str!("text/devup-project-plan.md")] async fn devup_project_plan( &self, Parameters(input): Parameters, @@ -2647,9 +2601,9 @@ impl DevupServer { 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() )] + #[doc = include_str!("text/devup-screen-plan.md")] async fn devup_screen_plan( &self, Parameters(input): Parameters, @@ -2806,9 +2760,9 @@ fn attach_layout_and_source_links(result: &mut Value, input: &UiValidateInput) { 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." - ); + result["sourceMapLink"]["unlinkedReason"] = json!(include_str!( + "text/mod-attach_layout_and_source_links-a7298619.md" + )); } } @@ -2844,10 +2798,7 @@ fn with_skill_gap(mut result: Value, lookup: &skills::Lookup) -> Value { 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."; +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. @@ -2895,10 +2846,7 @@ fn skill_gap<'a>( "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.", + "how": include_str!("text/mod-skill_gap-bd8a6ae8.md"), "runtime": lookup.runtime.as_str(), "workspace": lookup.project.display().to_string(), })) @@ -2943,9 +2891,7 @@ fn with_context_skill_gap(mut result: Value, scope: &str, lookup: &skills::Looku 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.", + include_str!("text/mod-with_context_skill_gap-fc39c774.md"), ) { result["skillGap"] = gap; } @@ -3087,7 +3033,7 @@ fn with_project_checks( "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." + include_str!("text/mod-with_project_checks-2c4cf346.md") } else { "Every token the generated TSX references exists in this project's devup.json." }, @@ -3240,7 +3186,7 @@ fn to_mcp_error(mut error: DevupError) -> ErrorData { 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." + 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." }); @@ -3258,7 +3204,7 @@ fn to_mcp_error(mut error: DevupError) -> ErrorData { }); 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."}); + "how":include_str!("text/mod-to_mcp_error-20941350.md")}); if let Some(arguments) = recovery { error.details["nextAction"]["arguments"] = arguments; } 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..63457507 100644 --- a/crates/devup-mcp/src/server/project_context.rs +++ b/crates/devup-mcp/src/server/project_context.rs @@ -65,7 +65,9 @@ fn attach_scan_notes(response: &mut Value, excluded: Vec, kept: usize, fi 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." + include_str!("text/project_context-attach_scan_notes-4b303bac.md"), + filename = filename, + kept = kept )), ); } @@ -122,7 +124,7 @@ fn ambiguous_theme_guardrail(root: &Path, candidates: &[PathBuf], excluded: Vec< .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.", + include_str!("text/project_context-ambiguous_theme_guardrail-b51035aa.md"), candidates.len() ), candidates.iter().map(|file| display_path(file)).collect(), @@ -191,7 +193,7 @@ pub fn theme_for_validation(project_root: Option<&str>) -> Result) -> Value { "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.", + "tokenCountScope":include_str!("text/project_context-theme_scope-c1299f17.md"), "categories": categories, "colors": colors, "typography": typography, 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/mod.rs b/crates/devup-mcp/src/server/project_plan/mod.rs index c81843ed..1515267c 100644 --- a/crates/devup-mcp/src/server/project_plan/mod.rs +++ b/crates/devup-mcp/src/server/project_plan/mod.rs @@ -177,7 +177,7 @@ fn response(plan: Value) -> Value { 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" { "Never wait. Workers escalate to supervisor; defer and continue; assume pending recommendations at the end. Run backendCommands in modelsWorkingDirectory, feature routes, OpenAPI/client generation, then devup_stack_diff with zero drift before done." } else { screen_plan::ASK_INSTRUCTION }, + "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"}, diff --git a/crates/devup-mcp/src/server/project_plan/units.rs b/crates/devup-mcp/src/server/project_plan/units.rs index 829bb7ec..e7e9a02e 100644 --- a/crates/devup-mcp/src/server/project_plan/units.rs +++ b/crates/devup-mcp/src/server/project_plan/units.rs @@ -100,7 +100,7 @@ pub(super) fn split( validate(&units)?; for u in &units { let brief = format!( - "# Work unit {}\n\nScope: {}\n\nDependencies: {}\n\nQuestion 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.\n\n## Recorded decisions and source facts\n```json\n{}\n```\n\n## ERD slice (shared entities retained for cross-feature FKs)\n```mermaid\n{}\n```\n\n## Screen-plan slice (full confirmed routing context)\n```json\n{}\n```\n\n## Acceptance and verification\n```json\n{}\n```\n\nSupervisor reruns verification before merging; return failures to this worker session.\n", + include_str!("../text/units-split-bf50d8a5.md"), text(&u["id"]), text(&u["scope"]), u["dependencies"], diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index ef3bbd07..5b3cb573 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -479,7 +479,7 @@ fn resource_diagnostic_details( } } 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."}); + "note":include_str!("text/projection-resource_diagnostic_details-9b1c1ed8.md")}); outputs.push(output); Ok(()) } @@ -593,14 +593,14 @@ pub(super) async fn apply_delivery( } 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." } + "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 { "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." }, + 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":"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."}}); + "basis":include_str!("text/projection-apply_delivery-e5a0fbb2.md")}}); } resource_diagnostic_details(result, &mut outputs, &artifact.artifact_id)?; let projection_key = projection_key(&outputs); @@ -1218,7 +1218,7 @@ fn asset_summary( "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." + include_str!("text/projection-asset_summary-2538c470.md") } "collected" => "All discovered non-hidden deliverable assets have collected binary bytes.", _ => unreachable!(), @@ -1513,7 +1513,7 @@ fn annotate_node_ids( "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.", + "note": include_str!("text/projection-annotate_node_ids-b03e6d56.md"), }) } @@ -1626,12 +1626,12 @@ fn attach_bridge_source(source: &mut Value, payload: &CollectedPayload) { 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.", - ), + "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(); @@ -1906,7 +1906,7 @@ async fn project_operation( 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}"#, + include_str!("text/projection-project_operation-648fa6f6.md"), )); } // Capture the accepted projection before fields are consumed by @@ -2180,7 +2180,7 @@ async fn project_operation( "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.", + "how": include_str!("text/projection-project_operation-2b397164.md"), "doNot": "Do not try to collect the whole Section at once." }), ); @@ -2664,9 +2664,9 @@ async fn project_operation( 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." - ); + 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 @@ -2754,7 +2754,7 @@ async fn project_operation( "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." + include_str!("text/projection-project_operation-9a2b2731.md") }}, "_propertyContract":merged.contract}); @@ -3835,10 +3835,11 @@ async fn project_operation( 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() + include_str!( + "text/projection-project_operation_Diagnostic-6ab28a84.md" + ), + others.len(), + path = path ), severity: Some(DiagnosticSeverity::Warning), resource_kind: Some("asset".to_owned()), @@ -4073,11 +4074,8 @@ mod tests { #[test] fn a_layer_name_no_file_system_takes_is_renamed_wherever_the_code_points_at_it() { - let code = concat!( - "\n", - "\n", - "\n", - "\n", + 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), 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..4ae20ffa 100644 --- a/crates/devup-mcp/src/server/runtime_audit.rs +++ b/crates/devup-mcp/src/server/runtime_audit.rs @@ -1060,10 +1060,10 @@ fn write_plan( "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."}); + "note": include_str!("text/runtime_audit-write_plan-2d2cc624.md")}); 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.", + "how": include_str!("text/runtime_audit-write_plan-c3ca4e3c.md"), }); Ok(result) } @@ -1505,7 +1505,7 @@ fn ingest_references( .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."})) + "note": include_str!("text/runtime_audit-ingest_references-72b6591d.md")})) }) .collect::>(); Ok(json!({ @@ -1885,22 +1885,29 @@ pub(crate) fn failure_class(kind: &str, reason: &str) -> &'static str { /// 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(), + "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!( - "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() + 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" => "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(), + "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(), } } @@ -2056,7 +2063,7 @@ fn visual_comparison( } 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.", + include_str!("text/runtime_audit-visual_comparison-8010a061.md"), shot.height, viewport.height, shot.width, shot.height )); } @@ -2102,7 +2109,7 @@ fn visual_comparison( "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.", + "note": include_str!("text/runtime_audit-visual_comparison-8418b2cf.md"), }) } diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index 18c8b4f5..9f1eb3df 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -44,10 +44,12 @@ const SCHEMA_VERSION: u64 = 1; /// 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."; +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 = "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."; +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 @@ -70,27 +72,24 @@ 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", -]; +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. -const SECTION_DEVICE_WORDS: &[&str] = &["phone", "모바일", "태블릿", "데스크톱", "데스크탑", "웹"]; -const ROOT_WORDS: &[&str] = &["home", "main", "index", "landing", "메인", "홈"]; +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)] @@ -323,6 +322,9 @@ 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, @@ -1342,44 +1344,26 @@ fn structure_questions(drafts: &[DraftScreen]) -> Vec { /// 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", - "로고", - "심볼", - "파비콘", -]; +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. -const OG_WORDS: &[&str] = &[ - "og", - "opengraph", - "open graph", - "share image", - "share card", - "social image", - "공유 이미지", - "대표 이미지", -]; +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. -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", -]; +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") { @@ -1600,7 +1584,7 @@ fn read_site_facts(root: &Path, evidence: &mut ProjectEvidence) { .push((version.to_owned(), format!("{peer} in {shown} {section}"))); } } - for name in MOTION_LIBRARIES { + for name in MOTION_LIBRARIES.iter() { if package[section].get(*name).is_some() && !evidence.motion_libraries.iter().any(|known| known == name) { @@ -1609,7 +1593,7 @@ fn read_site_facts(root: &Path, evidence: &mut ProjectEvidence) { } } for section in ["dependencies", "devDependencies"] { - for name in ANALYTICS_PACKAGES { + for name in ANALYTICS_PACKAGES.iter() { if package[section].get(*name).is_some() { evidence.analytics.push(format!("{name} in {shown}")); } @@ -1630,14 +1614,10 @@ fn bun_locked(lock: &str, name: &str) -> Option { /// 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", -]; +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 @@ -1645,7 +1625,7 @@ const MOTION_LIBRARIES: &[&str] = &[ 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", + include_str!("text/screen_plan-INTERACTION_RECIPES-703799bb.md"), ), ( "secondary / ghost / outline button", @@ -1681,7 +1661,7 @@ const INTERACTION_RECIPES: &[(&str, &str)] = &[ ), ]; -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."; +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`, @@ -1729,13 +1709,11 @@ pub(super) const UPDATE_STEP: &str = "bunx @dependency-check-updates/cli -d -u - /// 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", -]; +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 @@ -1748,7 +1726,7 @@ const VIEW_TRANSITION_DOCS: &[&str] = &[ 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}) + "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}")); @@ -1773,8 +1751,8 @@ fn view_transition_default(evidence: &ProjectEvidence) -> Value { "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}) + "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. @@ -1791,7 +1769,7 @@ 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})."), + "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( @@ -1805,7 +1783,7 @@ fn stack_defaults(evidence: &ProjectEvidence) -> Vec { "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)."), + "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 @@ -1814,48 +1792,26 @@ fn stack_defaults(evidence: &ProjectEvidence) -> Vec { /// 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", - "관리자", - "어드민", - "대시보드", - "회원관리", -]; +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. -const PUBLIC_SCREEN_WORDS: &[&str] = &[ - "home", - "main", - "landing", - "about", - "company", - "service", - "services", - "contact", - "portfolio", - "pricing", - "blog", - "news", - "faq", - "gnb", - "메인", - "홈", - "회사소개", - "서비스", - "문의", - "포트폴리오", - "소개", -]; +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. -const ADMIN_ROUTE_SEGMENTS: &[&str] = &["signin", "sign-in", "login", "dashboard", "admin"]; +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 { @@ -1864,10 +1820,18 @@ enum Side { Unclear, } -fn words_side(names: &[&str], public: &[&str]) -> (usize, usize) { +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))) + .filter(|name| admin_words.iter().any(|word| keyword_in(name, word))) .count(); let public = names .iter() @@ -1884,7 +1848,11 @@ fn app_side(app: &super::framework::App, evidence: &ProjectEvidence) -> (Side, V if let Some(name) = &app.name { names.push(name.as_str()); } - let (mut public, mut admin) = words_side(&names, PUBLIC_APP_WORDS); + 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)); @@ -1897,10 +1865,10 @@ fn app_side(app: &super::framework::App, evidence: &ProjectEvidence) -> (Side, V .iter() .filter(|(_, page)| page.starts_with(&format!("{}/", app.dir))) .filter(|(route, _)| { - route - .trim_matches('/') - .split('/') - .any(|segment| ADMIN_ROUTE_SEGMENTS.contains(&segment)) + route.trim_matches('/').split('/').any(|segment| { + selection_words(&evidence.config, "adminRoutes", &ADMIN_ROUTE_SEGMENTS) + .contains(&segment) + }) }) .map(|(route, _)| route.clone()) .collect::>(); @@ -1926,11 +1894,15 @@ fn app_side(app: &super::framework::App, evidence: &ProjectEvidence) -> (Side, V /// 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 { +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, PUBLIC_SCREEN_WORDS) { + 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, @@ -1986,7 +1958,7 @@ fn assign_apps( .collect::>(); let screen_sides = screens .iter() - .map(|screen| (screen, screen_side(screen))) + .map(|screen| (screen, screen_side(screen, &evidence.config))) .collect::>(); let admin_screens = screen_sides .iter() @@ -2117,7 +2089,7 @@ fn stack_questions(evidence: &ProjectEvidence) -> Vec { 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.", + include_str!("text/screen_plan-stack_questions-eb6d40b2.md"), json!({"framework": {"source": "vinext"}}), ), choice( @@ -2200,7 +2172,7 @@ fn site_defaults(evidence: &ProjectEvidence, screens: &[PlanScreenInput]) -> Vec "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.", + "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.", @@ -2627,30 +2599,10 @@ fn site_completeness(plan: &Value, evidence: &ProjectEvidence) -> Vec { /// 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", - "사본", -]; +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(); @@ -3460,9 +3412,10 @@ pub(super) fn draft_plan_with( 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\".", + include_str!("text/screen_plan-draft_plan_with-140b58b5.md"), sections.join(", "), - describe_frames(&screen.frames) + describe_frames(&screen.frames), + why = why ), if *all_device { vec![single, separate, fix] @@ -4850,7 +4803,7 @@ pub(super) fn defer(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result Vec { array(&plan["screens"]) diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs index 719760b4..a17cb60c 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -1898,6 +1898,33 @@ fn rows_app(plan: &Value) -> Vec<(String, String)> { /// 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(), 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..aacf9fe2 100644 --- a/crates/devup-mcp/src/server/skills.rs +++ b/crates/devup-mcp/src/server/skills.rs @@ -1023,11 +1023,7 @@ impl Skill { } 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} -->", + include_str!("text/skills-provenance_note-ca003bce.md"), marker = AUTHORED_MARKER, repo = r.repo, path = r.path, @@ -1036,11 +1032,7 @@ impl Skill { ); } 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} -->", + include_str!("text/skills-provenance_note-fa8096ea.md"), marker = VENDORED_MARKER, repo = r.repo, path = r.path, @@ -1091,9 +1083,7 @@ impl Skill { "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." + 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." @@ -1108,9 +1098,7 @@ impl Skill { "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." + 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." @@ -1137,15 +1125,12 @@ impl Skill { "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.", + "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 { - "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." + include_str!("text/skills-install_action-13d23996.md") .to_owned() } else { format!( @@ -1255,7 +1240,7 @@ pub fn command_state(lookup: &Lookup) -> serde_json::Value { "perRuntime": per_runtime, "how": if !supported { format!( - "devup-mcp writes no slash command for {}: no command location for it is verified. The devup skill is installed with the others and loads by its description, and the server's instructions start the workflow on a request to build pages.", + include_str!("text/skills-command_state-84cd29d8.md"), lookup.runtime.as_str() ) } else if installed { @@ -1348,9 +1333,7 @@ pub fn report(lookup: &Lookup) -> serde_json::Value { } else if r.origin == Origin::Own { entry["embeddedRevision"] = serde_json::json!({ "commit": serde_json::Value::Null, - "note": "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.", + "note": include_str!("text/skills-report-1a0ab5f8.md"), }); } if r.origin == Origin::External { @@ -1382,9 +1365,7 @@ pub fn report(lookup: &Lookup) -> serde_json::Value { "detected": lookup.runtime.as_str(), "clientName": lookup.client_name, "from": "The MCP clientInfo name sent at initialize, echoed here as `clientName`.", - "why": "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.", + "why": include_str!("text/skills-report-071c67f6.md"), // A closed-source client's name is matched against what has been // observed rather than read out of source, so a wrong guess does // not fail - it just never fires, and reads exactly like a client @@ -1394,10 +1375,7 @@ pub fn report(lookup: &Lookup) -> serde_json::Value { serde_json::json!({ "meaning": "No runtime matched this client name, so every convention is read \ and the three broad ones are written. That is safe, not wrong.", - "butAlso": "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.", + "butAlso": include_str!("text/skills-report-55e539f1.md"), "reportAt": "https://github.com/dev-five-git/devup-mcp/issues", }) } else { @@ -1419,35 +1397,20 @@ pub fn report(lookup: &Lookup) -> serde_json::Value { .iter() .map(|path| path.display().to_string()) .collect::>(), - "userRootsNote": "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.", + "userRootsNote": include_str!("text/skills-report-eb1c9bcc.md"), "writeConventions": lookup.runtime.write_roots(), "allKnownRoots": EVERY_PROJECT_ROOT, - "scopeNote": "`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.", + "scopeNote": include_str!("text/skills-report-3e1e10ee.md"), }, "installedCount": entries.len() - missing, "missingCount": missing, "outdated": stale, - "outdatedNote": "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.", + "outdatedNote": include_str!("text/skills-report-d3cac7a1.md"), "skills": entries, "command": command_state(lookup), "machineWide": machine_wide(lookup), - "how": "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.", - "boundary": "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.", + "how": include_str!("text/skills-report-d4500f82.md"), + "boundary": include_str!("text/skills-report-2e1e5387.md"), }); if let Some(obligation) = changepacks_obligation(&lookup.project) { report["repoObligations"] = serde_json::json!({ "changepacks": obligation }); @@ -1476,12 +1439,8 @@ fn machine_wide(lookup: &Lookup) -> serde_json::Value { "directory": display_path(&directory), "appliesTo": "Every project on this machine, not just this one.", "action": "run-this-yourself", - "why": "devup-mcp writes only inside an allowed output root, and the home directory is \ - not one. Widening that so a design-to-code server can write to $HOME is not a \ - trade worth making, so the path is yours to fill.", - "how": "Copy the installed skill directories there, or install them with your runtime's \ - own skill tooling. A skill found in this directory is reported as installed by \ - every later call, for every project.", + "why": include_str!("text/skills-machine_wide-46b2476c.md"), + "how": include_str!("text/skills-machine_wide-85410d98.md"), "readNotWritten": true, }) } @@ -1524,17 +1483,11 @@ fn changepacks_obligation(project: &Path) -> Option { Some(serde_json::json!({ "detected": display_path(&directory), - "obligation": "A pull request that changes a tracked path must add a changepack log. \ - Without one the version never moves, so the change reaches the base branch \ - and is never released.", + "obligation": include_str!("text/skills-changepacks_obligation-91f6f9c2.md"), "createWith": "bunx @changepacks/cli --yes --update-type --message \"\"", - "whyNotBare": "Running the tool with no flags opens an interactive selection UI, which \ - hangs or is cancelled in a non-TTY shell. That is the usual reason a pull \ - request arrives without the changepack it needed.", + "whyNotBare": include_str!("text/skills-changepacks_obligation-54540b9a.md"), "tracks": config.get("ignore").cloned().unwrap_or(serde_json::Value::Null), - "tracksNote": "Patterns from .changepacks/config.json. A leading `!` marks a tracked path; \ - everything else is ignored. Whether your change needs a changepack is decided \ - by this list, not by a general rule.", + "tracksNote": include_str!("text/skills-changepacks_obligation-23e2c441.md"), "baseBranch": config.get("baseBranch").cloned().unwrap_or(serde_json::Value::Null), "pendingLogs": pending, "skill": "changepacks", @@ -1710,12 +1663,10 @@ async fn install_with( serde_json::Value::Null } else { serde_json::json!({ - "how": "Load the newly installed skills the way your runtime loads a project \ - skill, and run any command under notInstallable yourself. Call \ - devup_skills again to confirm the state changed.", + "how": include_str!("text/skills-install_with-94607ffe.md"), }) }, - "boundary": "Only carried skills were written, using current upstream documents where available or the embedded copy. External skills were not fetched or written, and no install command was executed.", + "boundary": include_str!("text/skills-install_with-5f02c573.md"), })) } diff --git a/crates/devup-mcp/src/server/source_limits.rs b/crates/devup-mcp/src/server/source_limits.rs new file mode 100644 index 00000000..5510f1b7 --- /dev/null +++ b/crates/devup-mcp/src/server/source_limits.rs @@ -0,0 +1,21 @@ +//! Source and compiled-in data checks run by the existing size-contract test. +use std::path::Path; + +pub(super) fn check() { + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + visit(&workspace.join("crates")); +} +fn visit(dir: &Path) { + for entry in std::fs::read_dir(dir).expect("read workspace source tree") { + let path = entry.expect("source entry").path(); + if path.is_dir() { + visit(&path); + } else if path.extension().is_some_and(|s| s == "json") + && path.components().any(|c| c.as_os_str() == "text") + { + let bytes = std::fs::read(&path).expect("read embedded JSON"); + serde_json::from_slice::(&bytes) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + } + } +} diff --git a/crates/devup-mcp/src/server/stack_diff.rs b/crates/devup-mcp/src/server/stack_diff.rs index 11fcd904..e27cb1c2 100644 --- a/crates/devup-mcp/src/server/stack_diff.rs +++ b/crates/devup-mcp/src/server/stack_diff.rs @@ -106,13 +106,13 @@ pub(super) fn attach_source_ownership(result: &mut Value, layer: &str) { "Review and edit the Vespertide model in models/*.json, then run vespertide revision and vespertide export --orm seaorm to regenerate entities and migrations." } "entity-route" => { - "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." + include_str!("text/stack_diff-attach_source_ownership-758751d4.md") } "route-openapi" => { "Review the human-authored route handlers and merged app configuration, then rebuild so Vespera regenerates openapi.json and regenerate the downstream client." } "openapi-client" => { - "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." + include_str!("text/stack_diff-attach_source_ownership-0113946e.md") } _ => unreachable!("validated layer"), }; @@ -579,7 +579,7 @@ fn route_openapi_layer(root: &Path) -> Value { .unwrap_or_default(); drifts.push(json!({ "kind": "route-openapi-unresolved-fallback", - "message": "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.", + "message": include_str!("text/stack_diff-route_openapi_layer-4d0cfa16.md"), "file": display_path(&file), "routes": guesses, "confidence": "low", @@ -590,7 +590,7 @@ fn route_openapi_layer(root: &Path) -> Value { if !conditional_routes.is_empty() { drifts.push(json!({ "kind": "conditional-route-openapi", - "message": "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.", + "message": include_str!("text/stack_diff-route_openapi_layer-215d8b4d.md"), "routes": conditional_routes.iter().map(|(method, path)| json!({ "method": method, "path": path, })).collect::>(), @@ -665,7 +665,8 @@ fn route_openapi_layer(root: &Path) -> Value { let mut notes = Vec::new(); if spelling_only_matches > 0 { notes.push(json!(format!( - "{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." + include_str!("text/stack_diff-route_openapi_layer-3c2d71f8.md"), + spelling_only_matches = spelling_only_matches ))); } @@ -692,7 +693,7 @@ fn route_openapi_layer(root: &Path) -> Value { json!({ "kind": "mirrored-drift", "count": mirrored, - "message": "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.", + "message": include_str!("text/stack_diff-route_openapi_layer-70549f41.md"), "confidence": "low", }), ); @@ -1111,9 +1112,13 @@ fn openapi_client_layer(root: &Path) -> Value { if spelling_only_matches > 0 && let Some(object) = layer.as_object_mut() { - object.insert("notes".to_owned(), json!([format!( - "{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." - )])); + object.insert( + "notes".to_owned(), + json!([format!( + include_str!("text/stack_diff-openapi_client_layer-a74a750f.md"), + spelling_only_matches = spelling_only_matches + )]), + ); } attach_excluded_paths(&mut layer, excluded); layer diff --git a/crates/devup-mcp/src/server/text/asset_batches-merge-8a712496.md b/crates/devup-mcp/src/server/text/asset_batches-merge-8a712496.md new file mode 100644 index 00000000..91f61288 --- /dev/null +++ b/crates/devup-mcp/src/server/text/asset_batches-merge-8a712496.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/asset_batches-unwrap_response-c1acefdb.md b/crates/devup-mcp/src/server/text/asset_batches-unwrap_response-c1acefdb.md new file mode 100644 index 00000000..e37413a0 --- /dev/null +++ b/crates/devup-mcp/src/server/text/asset_batches-unwrap_response-c1acefdb.md @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/asset_jobs-acquire_AssetJob-1e1eb717.md b/crates/devup-mcp/src/server/text/asset_jobs-acquire_AssetJob-1e1eb717.md new file mode 100644 index 00000000..9c31cf3e --- /dev/null +++ b/crates/devup-mcp/src/server/text/asset_jobs-acquire_AssetJob-1e1eb717.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/asset_jobs-response_with-526ef5eb.md b/crates/devup-mcp/src/server/text/asset_jobs-response_with-526ef5eb.md new file mode 100644 index 00000000..8076fa13 --- /dev/null +++ b/crates/devup-mcp/src/server/text/asset_jobs-response_with-526ef5eb.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/audit_plan-url_plan-a1031a26.md b/crates/devup-mcp/src/server/text/audit_plan-url_plan-a1031a26.md new file mode 100644 index 00000000..0c8f7690 --- /dev/null +++ b/crates/devup-mcp/src/server/text/audit_plan-url_plan-a1031a26.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/audit_plan-write-7f9aea99.md b/crates/devup-mcp/src/server/text/audit_plan-write-7f9aea99.md new file mode 100644 index 00000000..b2fbba8d --- /dev/null +++ b/crates/devup-mcp/src/server/text/audit_plan-write-7f9aea99.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/audit_plan-write-925db929.md b/crates/devup-mcp/src/server/text/audit_plan-write-925db929.md new file mode 100644 index 00000000..01b903b0 --- /dev/null +++ b/crates/devup-mcp/src/server/text/audit_plan-write-925db929.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/compact-invalid-7f830631.md b/crates/devup-mcp/src/server/text/compact-invalid-7f830631.md new file mode 100644 index 00000000..e4f21525 --- /dev/null +++ b/crates/devup-mcp/src/server/text/compact-invalid-7f830631.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/delivery-inline_size_error-e8696ca0.md b/crates/devup-mcp/src/server/text/delivery-inline_size_error-e8696ca0.md new file mode 100644 index 00000000..7f9c3cc6 --- /dev/null +++ b/crates/devup-mcp/src/server/text/delivery-inline_size_error-e8696ca0.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/delivery-server_identity-f8e03915.md b/crates/devup-mcp/src/server/text/delivery-server_identity-f8e03915.md new file mode 100644 index 00000000..d431149d --- /dev/null +++ b/crates/devup-mcp/src/server/text/delivery-server_identity-f8e03915.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/delivery-with_inline_recovery-01306837.md b/crates/devup-mcp/src/server/text/delivery-with_inline_recovery-01306837.md new file mode 100644 index 00000000..0b178fd5 --- /dev/null +++ b/crates/devup-mcp/src/server/text/delivery-with_inline_recovery-01306837.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/delivery-with_inline_recovery-e6ab7ccd.md b/crates/devup-mcp/src/server/text/delivery-with_inline_recovery-e6ab7ccd.md new file mode 100644 index 00000000..c245adc4 --- /dev/null +++ b/crates/devup-mcp/src/server/text/delivery-with_inline_recovery-e6ab7ccd.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/design_drift-MEANING-dfdee151.md b/crates/devup-mcp/src/server/text/design_drift-MEANING-dfdee151.md new file mode 100644 index 00000000..421d793c --- /dev/null +++ b/crates/devup-mcp/src/server/text/design_drift-MEANING-dfdee151.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-feature-trace.md b/crates/devup-mcp/src/server/text/devup-feature-trace.md new file mode 100644 index 00000000..f8b3b69f --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-feature-trace.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-figma-auth.md b/crates/devup-mcp/src/server/text/devup-figma-auth.md new file mode 100644 index 00000000..ec10d5d0 --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-figma-auth.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-figma-explore.md b/crates/devup-mcp/src/server/text/devup-figma-explore.md new file mode 100644 index 00000000..cc9adc84 --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-figma-explore.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-figma-export.md b/crates/devup-mcp/src/server/text/devup-figma-export.md new file mode 100644 index 00000000..d82f87eb --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-figma-export.md @@ -0,0 +1 @@ +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} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-figma-measure.md b/crates/devup-mcp/src/server/text/devup-figma-measure.md new file mode 100644 index 00000000..ff0073e1 --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-figma-measure.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-figma-search.md b/crates/devup-mcp/src/server/text/devup-figma-search.md new file mode 100644 index 00000000..f3c6bc27 --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-figma-search.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-project-context.md b/crates/devup-mcp/src/server/text/devup-project-context.md new file mode 100644 index 00000000..7e696b55 --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-project-context.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-project-plan.md b/crates/devup-mcp/src/server/text/devup-project-plan.md new file mode 100644 index 00000000..5071973d --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-project-plan.md @@ -0,0 +1 @@ +Project intake and supervisor: intake | draft | confirm | status | assume | defer | split. Read Jira/epic children, Figma and devup_project_context all scopes first; pass source-labelled evidence. intake always asks work type, optional sources, goal and parts through the runtime question tool. draft records feature-specific rounds and ERD; confirm requires all answers and the Figma screen plan when applicable, writes vespertide models/*.json transactionally. split creates work units with a dependency DAG and .devup/units/.md briefs, one per Jira issue or feature/layer. Workers escalate to supervisor by default (unitQuestions: supervisor|worker). After confirm never wait: defer recommendations and continue, assume at the end. status reports ERD Mermaid, unit DAG/states and decisions; unitUpdates record supervisor verification. State: .devup/project-plan.json. Follow /devup skill for backend commands, latest-first dependencies, exact layout/visual audits, orchestration and zero-drift completion. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-runtime-audit.md b/crates/devup-mcp/src/server/text/devup-runtime-audit.md new file mode 100644 index 00000000..06e69460 --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-runtime-audit.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-screen-plan.md b/crates/devup-mcp/src/server/text/devup-screen-plan.md new file mode 100644 index 00000000..549550c1 --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-screen-plan.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-skills.md b/crates/devup-mcp/src/server/text/devup-skills.md new file mode 100644 index 00000000..6dc310da --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-skills.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-stack-diff.md b/crates/devup-mcp/src/server/text/devup-stack-diff.md new file mode 100644 index 00000000..46a7bcfb --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-stack-diff.md @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-ui-validate.md b/crates/devup-mcp/src/server/text/devup-ui-validate.md new file mode 100644 index 00000000..27715675 --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-ui-validate.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/devup-visual-compare.md b/crates/devup-mcp/src/server/text/devup-visual-compare.md new file mode 100644 index 00000000..bf57e94a --- /dev/null +++ b/crates/devup-mcp/src/server/text/devup-visual-compare.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-CREDENTIAL_SOURCE_NOTE-f6bf233e.md b/crates/devup-mcp/src/server/text/diagnostics-CREDENTIAL_SOURCE_NOTE-f6bf233e.md new file mode 100644 index 00000000..d3067a1d --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-CREDENTIAL_SOURCE_NOTE-f6bf233e.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-KEYLESS_NOTE-bcb2fb02.md b/crates/devup-mcp/src/server/text/diagnostics-KEYLESS_NOTE-bcb2fb02.md new file mode 100644 index 00000000..cc3118d5 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-KEYLESS_NOTE-bcb2fb02.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-RUN_THE_PLUGIN-6f87e1f9.md b/crates/devup-mcp/src/server/text/diagnostics-RUN_THE_PLUGIN-6f87e1f9.md new file mode 100644 index 00000000..08ff8eae --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-RUN_THE_PLUGIN-6f87e1f9.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-SERVED_WITH_ONE_PLUGIN-a04b8617.md b/crates/devup-mcp/src/server/text/diagnostics-SERVED_WITH_ONE_PLUGIN-a04b8617.md new file mode 100644 index 00000000..ef4cf827 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-SERVED_WITH_ONE_PLUGIN-a04b8617.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-attached_file-eb5b83b1.md b/crates/devup-mcp/src/server/text/diagnostics-attached_file-eb5b83b1.md new file mode 100644 index 00000000..dbe94175 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-attached_file-eb5b83b1.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_option-1573849a.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_option-1573849a.md new file mode 100644 index 00000000..23ea1ffe --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_option-1573849a.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_option-c5748d60.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_option-c5748d60.md new file mode 100644 index 00000000..f231c33f --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_option-c5748d60.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_path-10545dbe.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_path-10545dbe.md new file mode 100644 index 00000000..d142dd56 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_path-10545dbe.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_path-5051fedf.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_path-5051fedf.md new file mode 100644 index 00000000..aafb47b7 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_path-5051fedf.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-2101bbfb.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-2101bbfb.md new file mode 100644 index 00000000..7c193658 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-2101bbfb.md @@ -0,0 +1 @@ +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} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-6c5cdae3.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-6c5cdae3.md new file mode 100644 index 00000000..d8735a65 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-6c5cdae3.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-76cadd39.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-76cadd39.md new file mode 100644 index 00000000..fc976721 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-76cadd39.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-88ec9725.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-88ec9725.md new file mode 100644 index 00000000..a2a878b8 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-88ec9725.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-8a3715df.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-8a3715df.md new file mode 100644 index 00000000..c874618f --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-8a3715df.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-938d46a6.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-938d46a6.md new file mode 100644 index 00000000..cdeabd62 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-938d46a6.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-9a4710b8.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-9a4710b8.md new file mode 100644 index 00000000..733d7c39 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-9a4710b8.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-a9b48677.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-a9b48677.md new file mode 100644 index 00000000..59108c30 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-a9b48677.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-bfc91239.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-bfc91239.md new file mode 100644 index 00000000..eee57ca1 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-bfc91239.md @@ -0,0 +1 @@ +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} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-c2bc4670.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-c2bc4670.md new file mode 100644 index 00000000..dae19bd3 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-c2bc4670.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-d72562c5.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-d72562c5.md new file mode 100644 index 00000000..030c2c53 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-d72562c5.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-d82d1e27.md b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-d82d1e27.md new file mode 100644 index 00000000..8355bcce --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-bridge_reason-d82d1e27.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-client_setup-4350fbba.md b/crates/devup-mcp/src/server/text/diagnostics-client_setup-4350fbba.md new file mode 100644 index 00000000..0412b231 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-client_setup-4350fbba.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-client_setup-a528a590.md b/crates/devup-mcp/src/server/text/diagnostics-client_setup-a528a590.md new file mode 100644 index 00000000..99757531 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-client_setup-a528a590.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-client_setup-b61acd6f.md b/crates/devup-mcp/src/server/text/diagnostics-client_setup-b61acd6f.md new file mode 100644 index 00000000..dad6177f --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-client_setup-b61acd6f.md @@ -0,0 +1 @@ +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/ \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-client_setup-ebbeb457.md b/crates/devup-mcp/src/server/text/diagnostics-client_setup-ebbeb457.md new file mode 100644 index 00000000..bfdeed37 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-client_setup-ebbeb457.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-connection_report-d61f53ae.md b/crates/devup-mcp/src/server/text/diagnostics-connection_report-d61f53ae.md new file mode 100644 index 00000000..96ece505 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-connection_report-d61f53ae.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-direct_path-6f63807d.md b/crates/devup-mcp/src/server/text/diagnostics-direct_path-6f63807d.md new file mode 100644 index 00000000..169b69de --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-direct_path-6f63807d.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-direct_path-c63b8306.md b/crates/devup-mcp/src/server/text/diagnostics-direct_path-c63b8306.md new file mode 100644 index 00000000..9cc8c0f9 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-direct_path-c63b8306.md @@ -0,0 +1 @@ + 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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-direct_reason-3faeaac8.md b/crates/devup-mcp/src/server/text/diagnostics-direct_reason-3faeaac8.md new file mode 100644 index 00000000..192bedb0 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-direct_reason-3faeaac8.md @@ -0,0 +1 @@ +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/). \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-direct_reason-6336653b.md b/crates/devup-mcp/src/server/text/diagnostics-direct_reason-6336653b.md new file mode 100644 index 00000000..7763203e --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-direct_reason-6336653b.md @@ -0,0 +1 @@ +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" }. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-direct_reason-6f3f9489.md b/crates/devup-mcp/src/server/text/diagnostics-direct_reason-6f3f9489.md new file mode 100644 index 00000000..0699b45d --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-direct_reason-6f3f9489.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-direct_reason-83c3453a.md b/crates/devup-mcp/src/server/text/diagnostics-direct_reason-83c3453a.md new file mode 100644 index 00000000..df7cebcf --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-direct_reason-83c3453a.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-direct_reason-df4d5655.md b/crates/devup-mcp/src/server/text/diagnostics-direct_reason-df4d5655.md new file mode 100644 index 00000000..a4262fa7 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-direct_reason-df4d5655.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-doctor_report-787ad2ad.md b/crates/devup-mcp/src/server/text/diagnostics-doctor_report-787ad2ad.md new file mode 100644 index 00000000..267ecc15 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-doctor_report-787ad2ad.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-repair-05473b24.md b/crates/devup-mcp/src/server/text/diagnostics-repair-05473b24.md new file mode 100644 index 00000000..f048573d --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-repair-05473b24.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-repair-8e741ef8.md b/crates/devup-mcp/src/server/text/diagnostics-repair-8e741ef8.md new file mode 100644 index 00000000..9767973e --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-repair-8e741ef8.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-repair-a70db245.md b/crates/devup-mcp/src/server/text/diagnostics-repair-a70db245.md new file mode 100644 index 00000000..94dba6ab --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-repair-a70db245.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/diagnostics-repair-c2a44c5b.md b/crates/devup-mcp/src/server/text/diagnostics-repair-c2a44c5b.md new file mode 100644 index 00000000..f5abcd90 --- /dev/null +++ b/crates/devup-mcp/src/server/text/diagnostics-repair-c2a44c5b.md @@ -0,0 +1 @@ +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}. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/export_record-write_repro-ab60ad0a.md b/crates/devup-mcp/src/server/text/export_record-write_repro-ab60ad0a.md new file mode 100644 index 00000000..5b5b3555 --- /dev/null +++ b/crates/devup-mcp/src/server/text/export_record-write_repro-ab60ad0a.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/feature_trace-ACCEPTED-59546b50.md b/crates/devup-mcp/src/server/text/feature_trace-ACCEPTED-59546b50.md new file mode 100644 index 00000000..338113a7 --- /dev/null +++ b/crates/devup-mcp/src/server/text/feature_trace-ACCEPTED-59546b50.md @@ -0,0 +1 @@ +Supply at least one explicit anchor: routePath, figmaNodeId, artifactId, operationId, apiPath with method, componentPath, or tableName. Requirement prose is never interpreted as an anchor. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/feature_trace-comparison-60526fe1.md b/crates/devup-mcp/src/server/text/feature_trace-comparison-60526fe1.md new file mode 100644 index 00000000..8cbf1db6 --- /dev/null +++ b/crates/devup-mcp/src/server/text/feature_trace-comparison-60526fe1.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/framework-report-6fa8f9df.md b/crates/devup-mcp/src/server/text/framework-report-6fa8f9df.md new file mode 100644 index 00000000..d08e77f7 --- /dev/null +++ b/crates/devup-mcp/src/server/text/framework-report-6fa8f9df.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md new file mode 100644 index 00000000..b509c631 --- /dev/null +++ b/crates/devup-mcp/src/server/text/guide-GUIDE-c682b0c5.md @@ -0,0 +1,306 @@ +# Using devup-mcp + +## 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. + + + +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`. diff --git a/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md b/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md new file mode 100644 index 00000000..571634ca --- /dev/null +++ b/crates/devup-mcp/src/server/text/guide-INSTRUCTIONS-49bd7ec6.md @@ -0,0 +1,5 @@ +Build identity: use server.commit/buildId, not version; reconnect if stale. server.updateAvailable unknown never blocks. +1. devup-mcp is the primary Figma-to-code source. Connection: bridge -> direct; call devup_figma_auth status first, one Bridge needs no login or url. +2b. /devup starts devup_project_plan intake for any project/feature/fix/refactor. Read Jira/spec/Figma and devup_project_context first; ask every round up front, draft ERD, confirm, then split work units. devup_screen_plan handles the Figma branch. Load devup_skills and devup-implementation-plan; after confirm never wait: defer and continue, assume at the end. +3. For Figma implementation use devup_figma_export tsx, the deliverable. TSX links/events need wiring: resolve interactiveCandidates, devup_ui_validate, report wired/tested counts. deliverable.isFinal describes export readiness. Audit every width and exact layout. Backend completion needs devup_stack_diff zero drift. +4. Request only outputs you read. Before your second call read devup://guide/usage for sizing, verification, batching, assets and delivery. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/interactions-attach-1e4189e6.md b/crates/devup-mcp/src/server/text/interactions-attach-1e4189e6.md new file mode 100644 index 00000000..2ab0c38a --- /dev/null +++ b/crates/devup-mcp/src/server/text/interactions-attach-1e4189e6.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/interactions-attach-cd63f312.md b/crates/devup-mcp/src/server/text/interactions-attach-cd63f312.md new file mode 100644 index 00000000..59484772 --- /dev/null +++ b/crates/devup-mcp/src/server/text/interactions-attach-cd63f312.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/measurement-calibrate-b4d6cfac.md b/crates/devup-mcp/src/server/text/measurement-calibrate-b4d6cfac.md new file mode 100644 index 00000000..5fbf4531 --- /dev/null +++ b/crates/devup-mcp/src/server/text/measurement-calibrate-b4d6cfac.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-CODE_GAP_WHY-6866def7.md b/crates/devup-mcp/src/server/text/mod-CODE_GAP_WHY-6866def7.md new file mode 100644 index 00000000..4aa1c09b --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-CODE_GAP_WHY-6866def7.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-apply_explore_limit-7dacda8e.md b/crates/devup-mcp/src/server/text/mod-apply_explore_limit-7dacda8e.md new file mode 100644 index 00000000..c09547ea --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-apply_explore_limit-7dacda8e.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-apply_explore_limit-f9b94793.md b/crates/devup-mcp/src/server/text/mod-apply_explore_limit-f9b94793.md new file mode 100644 index 00000000..c5f1bc99 --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-apply_explore_limit-f9b94793.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-attach_layout_and_source_links-a7298619.md b/crates/devup-mcp/src/server/text/mod-attach_layout_and_source_links-a7298619.md new file mode 100644 index 00000000..5b659fbd --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-attach_layout_and_source_links-a7298619.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-bind_to_bridge-3d006854.md b/crates/devup-mcp/src/server/text/mod-bind_to_bridge-3d006854.md new file mode 100644 index 00000000..41ecf362 --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-bind_to_bridge-3d006854.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-bind_to_bridge-6dea64b3.md b/crates/devup-mcp/src/server/text/mod-bind_to_bridge-6dea64b3.md new file mode 100644 index 00000000..fc8386fb --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-bind_to_bridge-6dea64b3.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-devup_figma_auth-444c97f4.md b/crates/devup-mcp/src/server/text/mod-devup_figma_auth-444c97f4.md new file mode 100644 index 00000000..017b11ee --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-devup_figma_auth-444c97f4.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-devup_figma_export-1fe13d5b.md b/crates/devup-mcp/src/server/text/mod-devup_figma_export-1fe13d5b.md new file mode 100644 index 00000000..ce5053c8 --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-devup_figma_export-1fe13d5b.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-devup_ui_validate-48eac31a.md b/crates/devup-mcp/src/server/text/mod-devup_ui_validate-48eac31a.md new file mode 100644 index 00000000..18a22f14 --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-devup_ui_validate-48eac31a.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-response-22d8f704.md b/crates/devup-mcp/src/server/text/mod-response-22d8f704.md new file mode 100644 index 00000000..9bda1fd7 --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-response-22d8f704.md @@ -0,0 +1 @@ +Never wait. Workers escalate to supervisor; defer and continue; assume pending recommendations at the end. Run backendCommands in modelsWorkingDirectory, feature routes, OpenAPI/client generation, then devup_stack_diff with zero drift before done. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-skill_gap-bd8a6ae8.md b/crates/devup-mcp/src/server/text/mod-skill_gap-bd8a6ae8.md new file mode 100644 index 00000000..c4fc2419 --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-skill_gap-bd8a6ae8.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-start_operation_scoped_tracked-ec551baf.md b/crates/devup-mcp/src/server/text/mod-start_operation_scoped_tracked-ec551baf.md new file mode 100644 index 00000000..06180e48 --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-start_operation_scoped_tracked-ec551baf.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-to_mcp_error-20941350.md b/crates/devup-mcp/src/server/text/mod-to_mcp_error-20941350.md new file mode 100644 index 00000000..0c5a0f02 --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-to_mcp_error-20941350.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-to_mcp_error-784239dd.md b/crates/devup-mcp/src/server/text/mod-to_mcp_error-784239dd.md new file mode 100644 index 00000000..571e2f26 --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-to_mcp_error-784239dd.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-with_context_skill_gap-fc39c774.md b/crates/devup-mcp/src/server/text/mod-with_context_skill_gap-fc39c774.md new file mode 100644 index 00000000..ca99eeef --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-with_context_skill_gap-fc39c774.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/mod-with_project_checks-2c4cf346.md b/crates/devup-mcp/src/server/text/mod-with_project_checks-2c4cf346.md new file mode 100644 index 00000000..13b3be85 --- /dev/null +++ b/crates/devup-mcp/src/server/text/mod-with_project_checks-2c4cf346.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/navigation-report-928037dc.md b/crates/devup-mcp/src/server/text/navigation-report-928037dc.md new file mode 100644 index 00000000..d2c9b62f --- /dev/null +++ b/crates/devup-mcp/src/server/text/navigation-report-928037dc.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/operation-plugin_next_action-021029b8.md b/crates/devup-mcp/src/server/text/operation-plugin_next_action-021029b8.md new file mode 100644 index 00000000..b29afa31 --- /dev/null +++ b/crates/devup-mcp/src/server/text/operation-plugin_next_action-021029b8.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/operation-plugin_next_action-8366c796.md b/crates/devup-mcp/src/server/text/operation-plugin_next_action-8366c796.md new file mode 100644 index 00000000..dd979104 --- /dev/null +++ b/crates/devup-mcp/src/server/text/operation-plugin_next_action-8366c796.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/operation-plugin_next_action-847e92e7.md b/crates/devup-mcp/src/server/text/operation-plugin_next_action-847e92e7.md new file mode 100644 index 00000000..53daa30b --- /dev/null +++ b/crates/devup-mcp/src/server/text/operation-plugin_next_action-847e92e7.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/operation-plugin_next_action-d622713f.md b/crates/devup-mcp/src/server/text/operation-plugin_next_action-d622713f.md new file mode 100644 index 00000000..90e2ddad --- /dev/null +++ b/crates/devup-mcp/src/server/text/operation-plugin_next_action-d622713f.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/operation-upstream_error-3ae5dbed.md b/crates/devup-mcp/src/server/text/operation-upstream_error-3ae5dbed.md new file mode 100644 index 00000000..2ed013b7 --- /dev/null +++ b/crates/devup-mcp/src/server/text/operation-upstream_error-3ae5dbed.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/operation-upstream_error-e5dda23e.md b/crates/devup-mcp/src/server/text/operation-upstream_error-e5dda23e.md new file mode 100644 index 00000000..0140cf36 --- /dev/null +++ b/crates/devup-mcp/src/server/text/operation-upstream_error-e5dda23e.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/project_context-ambiguous_theme_guardrail-b51035aa.md b/crates/devup-mcp/src/server/text/project_context-ambiguous_theme_guardrail-b51035aa.md new file mode 100644 index 00000000..d6849017 --- /dev/null +++ b/crates/devup-mcp/src/server/text/project_context-ambiguous_theme_guardrail-b51035aa.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/project_context-attach_scan_notes-4b303bac.md b/crates/devup-mcp/src/server/text/project_context-attach_scan_notes-4b303bac.md new file mode 100644 index 00000000..81f3ad79 --- /dev/null +++ b/crates/devup-mcp/src/server/text/project_context-attach_scan_notes-4b303bac.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/project_context-theme_for_validation-ba1b05bd.md b/crates/devup-mcp/src/server/text/project_context-theme_for_validation-ba1b05bd.md new file mode 100644 index 00000000..d030c5ab --- /dev/null +++ b/crates/devup-mcp/src/server/text/project_context-theme_for_validation-ba1b05bd.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/project_context-theme_scope-c1299f17.md b/crates/devup-mcp/src/server/text/project_context-theme_scope-c1299f17.md new file mode 100644 index 00000000..7e14e3d0 --- /dev/null +++ b/crates/devup-mcp/src/server/text/project_context-theme_scope-c1299f17.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/project_context_export-export_context-12e0ec03.md b/crates/devup-mcp/src/server/text/project_context_export-export_context-12e0ec03.md new file mode 100644 index 00000000..64770da2 --- /dev/null +++ b/crates/devup-mcp/src/server/text/project_context_export-export_context-12e0ec03.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/project_context_ui-run-9d95e12b.md b/crates/devup-mcp/src/server/text/project_context_ui-run-9d95e12b.md new file mode 100644 index 00000000..1fc6804f --- /dev/null +++ b/crates/devup-mcp/src/server/text/project_context_ui-run-9d95e12b.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-annotate_node_ids-b03e6d56.md b/crates/devup-mcp/src/server/text/projection-annotate_node_ids-b03e6d56.md new file mode 100644 index 00000000..64e6a0da --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-annotate_node_ids-b03e6d56.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-apply_delivery-4796ba61.md b/crates/devup-mcp/src/server/text/projection-apply_delivery-4796ba61.md new file mode 100644 index 00000000..e9d93f18 --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-apply_delivery-4796ba61.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-apply_delivery-6938bf43.md b/crates/devup-mcp/src/server/text/projection-apply_delivery-6938bf43.md new file mode 100644 index 00000000..2ce38723 --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-apply_delivery-6938bf43.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-apply_delivery-e5a0fbb2.md b/crates/devup-mcp/src/server/text/projection-apply_delivery-e5a0fbb2.md new file mode 100644 index 00000000..90471329 --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-apply_delivery-e5a0fbb2.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-asset_summary-2538c470.md b/crates/devup-mcp/src/server/text/projection-asset_summary-2538c470.md new file mode 100644 index 00000000..750f1ff5 --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-asset_summary-2538c470.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-attach_bridge_source-65001189.md b/crates/devup-mcp/src/server/text/projection-attach_bridge_source-65001189.md new file mode 100644 index 00000000..690a7809 --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-attach_bridge_source-65001189.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-attach_bridge_source-e2f65c04.md b/crates/devup-mcp/src/server/text/projection-attach_bridge_source-e2f65c04.md new file mode 100644 index 00000000..c1b40359 --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-attach_bridge_source-e2f65c04.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-project_operation-2b397164.md b/crates/devup-mcp/src/server/text/projection-project_operation-2b397164.md new file mode 100644 index 00000000..d5e40797 --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-project_operation-2b397164.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-project_operation-648fa6f6.md b/crates/devup-mcp/src/server/text/projection-project_operation-648fa6f6.md new file mode 100644 index 00000000..2dba94d7 --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-project_operation-648fa6f6.md @@ -0,0 +1 @@ +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} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-project_operation-8a156710.md b/crates/devup-mcp/src/server/text/projection-project_operation-8a156710.md new file mode 100644 index 00000000..98203f07 --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-project_operation-8a156710.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-project_operation-9a2b2731.md b/crates/devup-mcp/src/server/text/projection-project_operation-9a2b2731.md new file mode 100644 index 00000000..478c1ffc --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-project_operation-9a2b2731.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-project_operation_Diagnostic-6ab28a84.md b/crates/devup-mcp/src/server/text/projection-project_operation_Diagnostic-6ab28a84.md new file mode 100644 index 00000000..046a695a --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-project_operation_Diagnostic-6ab28a84.md @@ -0,0 +1 @@ +{} 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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-resource_diagnostic_details-9b1c1ed8.md b/crates/devup-mcp/src/server/text/projection-resource_diagnostic_details-9b1c1ed8.md new file mode 100644 index 00000000..eec57975 --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-resource_diagnostic_details-9b1c1ed8.md @@ -0,0 +1 @@ +Diagnostic identities, counts, severity and verdicts remain inline; complete original details are in this resource. Read it before accepting projection evidence or final completeness. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/projection-tests_a_layer_name_no_file_system_takes_is_renamed_wherever_the_code_p-90121fb1.md b/crates/devup-mcp/src/server/text/projection-tests_a_layer_name_no_file_system_takes_is_renamed_wherever_the_code_p-90121fb1.md new file mode 100644 index 00000000..d3ec5fbe --- /dev/null +++ b/crates/devup-mcp/src/server/text/projection-tests_a_layer_name_no_file_system_takes_is_renamed_wherever_the_code_p-90121fb1.md @@ -0,0 +1,4 @@ + + + + diff --git a/crates/devup-mcp/src/server/text/result_contract-attach_review_and_deliverable-b1b16fb6.md b/crates/devup-mcp/src/server/text/result_contract-attach_review_and_deliverable-b1b16fb6.md new file mode 100644 index 00000000..6ade8cb8 --- /dev/null +++ b/crates/devup-mcp/src/server/text/result_contract-attach_review_and_deliverable-b1b16fb6.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-0e677049.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-0e677049.md new file mode 100644 index 00000000..aeda86cc --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-0e677049.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-1ca5b7eb.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-1ca5b7eb.md new file mode 100644 index 00000000..297ea321 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-1ca5b7eb.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-1f874fed.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-1f874fed.md new file mode 100644 index 00000000..f09bd2e5 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-1f874fed.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-3a12f919.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-3a12f919.md new file mode 100644 index 00000000..202c29d3 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-3a12f919.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-516a692f.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-516a692f.md new file mode 100644 index 00000000..f3ee1e57 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-516a692f.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-5960cebc.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-5960cebc.md new file mode 100644 index 00000000..396290f6 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-5960cebc.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-60ba4f27.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-60ba4f27.md new file mode 100644 index 00000000..e4af84b3 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-60ba4f27.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-650b18d4.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-650b18d4.md new file mode 100644 index 00000000..57907dce --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-650b18d4.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-7a003bba.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-7a003bba.md new file mode 100644 index 00000000..55c25af0 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-7a003bba.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-975acb57.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-975acb57.md new file mode 100644 index 00000000..77c877d5 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-975acb57.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-a13b2885.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-a13b2885.md new file mode 100644 index 00000000..5d52c128 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-a13b2885.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-b3941912.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-b3941912.md new file mode 100644 index 00000000..78136230 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-b3941912.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-e19dc0a1.md b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-e19dc0a1.md new file mode 100644 index 00000000..d0e6569c --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-fix_hint-e19dc0a1.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-ingest_references-72b6591d.md b/crates/devup-mcp/src/server/text/runtime_audit-ingest_references-72b6591d.md new file mode 100644 index 00000000..6370a5cf --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-ingest_references-72b6591d.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8010a061.md b/crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8010a061.md new file mode 100644 index 00000000..c675108f --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8010a061.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8418b2cf.md b/crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8418b2cf.md new file mode 100644 index 00000000..26077d83 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-visual_comparison-8418b2cf.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-write_plan-2d2cc624.md b/crates/devup-mcp/src/server/text/runtime_audit-write_plan-2d2cc624.md new file mode 100644 index 00000000..ea89b828 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-write_plan-2d2cc624.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/runtime_audit-write_plan-c3ca4e3c.md b/crates/devup-mcp/src/server/text/runtime_audit-write_plan-c3ca4e3c.md new file mode 100644 index 00000000..bb4de034 --- /dev/null +++ b/crates/devup-mcp/src/server/text/runtime_audit-write_plan-c3ca4e3c.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen-plan-admin-route-segments.json b/crates/devup-mcp/src/server/text/screen-plan-admin-route-segments.json new file mode 100644 index 00000000..0a90ba1c --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-admin-route-segments.json @@ -0,0 +1,7 @@ +[ + "signin", + "sign-in", + "login", + "dashboard", + "admin" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-admin-words.json b/crates/devup-mcp/src/server/text/screen-plan-admin-words.json new file mode 100644 index 00000000..cd64bd60 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-admin-words.json @@ -0,0 +1,13 @@ +[ + "admin", + "dashboard", + "console", + "backoffice", + "back office", + "cms", + "manager", + "관리자", + "어드민", + "대시보드", + "회원관리" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-analytics-env.json b/crates/devup-mcp/src/server/text/screen-plan-analytics-env.json new file mode 100644 index 00000000..1d9615a7 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-analytics-env.json @@ -0,0 +1,7 @@ +[ + "GA_", + "GTM", + "GOOGLE_ANALYTICS", + "GOOGLE_SITE_VERIFICATION", + "NAVER_SITE_VERIFICATION" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-analytics-packages.json b/crates/devup-mcp/src/server/text/screen-plan-analytics-packages.json new file mode 100644 index 00000000..ff65135b --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-analytics-packages.json @@ -0,0 +1,9 @@ +[ + "@vercel/analytics", + "@next/third-parties", + "react-ga4", + "react-gtm-module", + "@amplitude/analytics-browser", + "mixpanel-browser", + "posthog-js" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-device-words.json b/crates/devup-mcp/src/server/text/screen-plan-device-words.json new file mode 100644 index 00000000..d6533844 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-device-words.json @@ -0,0 +1,12 @@ +[ + "pc", + "mobile", + "mo", + "mob", + "m", + "tablet", + "tab", + "desktop", + "web", + "dt" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-exclude-words.json b/crates/devup-mcp/src/server/text/screen-plan-exclude-words.json new file mode 100644 index 00000000..c0d1423d --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-exclude-words.json @@ -0,0 +1,24 @@ +[ + "trash", + "휴지통", + "archive", + "archived", + "아카이브", + "old", + "구버전", + "이전", + "deprecated", + "사용안함", + "미사용", + "backup", + "백업", + "draft", + "wip", + "작업중", + "test", + "테스트", + "sandbox", + "playground", + "copy", + "사본" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-logo-words.json b/crates/devup-mcp/src/server/text/screen-plan-logo-words.json new file mode 100644 index 00000000..12548760 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-logo-words.json @@ -0,0 +1,9 @@ +[ + "logo", + "symbol", + "favicon", + "appicon", + "로고", + "심볼", + "파비콘" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-modal-words.json b/crates/devup-mcp/src/server/text/screen-plan-modal-words.json new file mode 100644 index 00000000..5df1bada --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-modal-words.json @@ -0,0 +1,15 @@ +[ + "modal", + "popup", + "pop-up", + "pop up", + "dialog", + "overlay", + "bottom sheet", + "bottomsheet", + "drawer", + "toast", + "팝업", + "모달", + "레이어" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-motion-libraries.json b/crates/devup-mcp/src/server/text/screen-plan-motion-libraries.json new file mode 100644 index 00000000..14bbdf21 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-motion-libraries.json @@ -0,0 +1,8 @@ +[ + "framer-motion", + "motion", + "next-view-transitions", + "react-transition-group", + "@react-spring/web", + "gsap" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-og-words.json b/crates/devup-mcp/src/server/text/screen-plan-og-words.json new file mode 100644 index 00000000..87b511f9 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-og-words.json @@ -0,0 +1,10 @@ +[ + "og", + "opengraph", + "open graph", + "share image", + "share card", + "social image", + "공유 이미지", + "대표 이미지" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-public-app-words.json b/crates/devup-mcp/src/server/text/screen-plan-public-app-words.json new file mode 100644 index 00000000..d385c2f7 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-public-app-words.json @@ -0,0 +1,10 @@ +[ + "front", + "web", + "site", + "www", + "landing", + "home", + "public", + "client" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-public-screen-words.json b/crates/devup-mcp/src/server/text/screen-plan-public-screen-words.json new file mode 100644 index 00000000..99e91090 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-public-screen-words.json @@ -0,0 +1,23 @@ +[ + "home", + "main", + "landing", + "about", + "company", + "service", + "services", + "contact", + "portfolio", + "pricing", + "blog", + "news", + "faq", + "gnb", + "메인", + "홈", + "회사소개", + "서비스", + "문의", + "포트폴리오", + "소개" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-root-words.json b/crates/devup-mcp/src/server/text/screen-plan-root-words.json new file mode 100644 index 00000000..4fc59789 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-root-words.json @@ -0,0 +1,8 @@ +[ + "home", + "main", + "index", + "landing", + "메인", + "홈" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-section-device-words.json b/crates/devup-mcp/src/server/text/screen-plan-section-device-words.json new file mode 100644 index 00000000..a25465cc --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-section-device-words.json @@ -0,0 +1,8 @@ +[ + "phone", + "모바일", + "태블릿", + "데스크톱", + "데스크탑", + "웹" +] diff --git a/crates/devup-mcp/src/server/text/screen-plan-view-transition-docs.json b/crates/devup-mcp/src/server/text/screen-plan-view-transition-docs.json new file mode 100644 index 00000000..e79dffad --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen-plan-view-transition-docs.json @@ -0,0 +1,7 @@ +[ + "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" +] diff --git a/crates/devup-mcp/src/server/text/screen_plan-ASK_INSTRUCTION-0285b268.md b/crates/devup-mcp/src/server/text/screen_plan-ASK_INSTRUCTION-0285b268.md new file mode 100644 index 00000000..dcfac860 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-ASK_INSTRUCTION-0285b268.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md b/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md new file mode 100644 index 00000000..501adb6a --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-DONE_RULE-95793a55.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-IMPLEMENT_INSTRUCTION-0f8204de.md b/crates/devup-mcp/src/server/text/screen_plan-IMPLEMENT_INSTRUCTION-0f8204de.md new file mode 100644 index 00000000..85b37ea2 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-IMPLEMENT_INSTRUCTION-0f8204de.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-INTERACTION_RECIPES-703799bb.md b/crates/devup-mcp/src/server/text/screen_plan-INTERACTION_RECIPES-703799bb.md new file mode 100644 index 00000000..71b3ba1b --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-INTERACTION_RECIPES-703799bb.md @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-RESTRAINT_RULES-cef67bed.md b/crates/devup-mcp/src/server/text/screen_plan-RESTRAINT_RULES-cef67bed.md new file mode 100644 index 00000000..1f3d00c7 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-RESTRAINT_RULES-cef67bed.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-defer-3281174c.md b/crates/devup-mcp/src/server/text/screen_plan-defer-3281174c.md new file mode 100644 index 00000000..46173924 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-defer-3281174c.md @@ -0,0 +1 @@ +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). \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-draft_plan_with-140b58b5.md b/crates/devup-mcp/src/server/text/screen_plan-draft_plan_with-140b58b5.md new file mode 100644 index 00000000..d88ca7c8 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-draft_plan_with-140b58b5.md @@ -0,0 +1 @@ +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". \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-site_defaults-a7c1a186.md b/crates/devup-mcp/src/server/text/screen_plan-site_defaults-a7c1a186.md new file mode 100644 index 00000000..f2e9e146 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-site_defaults-a7c1a186.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-stack_defaults-c2140f37.md b/crates/devup-mcp/src/server/text/screen_plan-stack_defaults-c2140f37.md new file mode 100644 index 00000000..54803bc0 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-stack_defaults-c2140f37.md @@ -0,0 +1 @@ +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). \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-stack_defaults-d534196c.md b/crates/devup-mcp/src/server/text/screen_plan-stack_defaults-d534196c.md new file mode 100644 index 00000000..dad37284 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-stack_defaults-d534196c.md @@ -0,0 +1 @@ +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}). \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-stack_questions-eb6d40b2.md b/crates/devup-mcp/src/server/text/screen_plan-stack_questions-eb6d40b2.md new file mode 100644 index 00000000..3fdf612c --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-stack_questions-eb6d40b2.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screen_plan-view_transition_default-81ca7ab7.md b/crates/devup-mcp/src/server/text/screen_plan-view_transition_default-81ca7ab7.md new file mode 100644 index 00000000..e9813509 --- /dev/null +++ b/crates/devup-mcp/src/server/text/screen_plan-view_transition_default-81ca7ab7.md @@ -0,0 +1 @@ +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). \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/screenshot_observations-rank-2c5a4662.md b/crates/devup-mcp/src/server/text/screenshot_observations-rank-2c5a4662.md new file mode 100644 index 00000000..151e63cd --- /dev/null +++ b/crates/devup-mcp/src/server/text/screenshot_observations-rank-2c5a4662.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/section_checklist-checklist-500ab77e.md b/crates/devup-mcp/src/server/text/section_checklist-checklist-500ab77e.md new file mode 100644 index 00000000..64fafa1a --- /dev/null +++ b/crates/devup-mcp/src/server/text/section_checklist-checklist-500ab77e.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/self_update-note-02a038b0.md b/crates/devup-mcp/src/server/text/self_update-note-02a038b0.md new file mode 100644 index 00000000..0889d68e --- /dev/null +++ b/crates/devup-mcp/src/server/text/self_update-note-02a038b0.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/self_update-report-28b64040.md b/crates/devup-mcp/src/server/text/self_update-report-28b64040.md new file mode 100644 index 00000000..79acac23 --- /dev/null +++ b/crates/devup-mcp/src/server/text/self_update-report-28b64040.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-changepacks_obligation-23e2c441.md b/crates/devup-mcp/src/server/text/skills-changepacks_obligation-23e2c441.md new file mode 100644 index 00000000..28948655 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-changepacks_obligation-23e2c441.md @@ -0,0 +1 @@ +Patterns from .changepacks/config.json. A leading `!` marks a tracked path; everything else is ignored. Whether your change needs a changepack is decided by this list, not by a general rule. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-changepacks_obligation-54540b9a.md b/crates/devup-mcp/src/server/text/skills-changepacks_obligation-54540b9a.md new file mode 100644 index 00000000..ac9b83c5 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-changepacks_obligation-54540b9a.md @@ -0,0 +1 @@ +Running the tool with no flags opens an interactive selection UI, which hangs or is cancelled in a non-TTY shell. That is the usual reason a pull request arrives without the changepack it needed. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-changepacks_obligation-91f6f9c2.md b/crates/devup-mcp/src/server/text/skills-changepacks_obligation-91f6f9c2.md new file mode 100644 index 00000000..a3b19957 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-changepacks_obligation-91f6f9c2.md @@ -0,0 +1 @@ +A pull request that changes a tracked path must add a changepack log. Without one the version never moves, so the change reaches the base branch and is never released. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-command_state-84cd29d8.md b/crates/devup-mcp/src/server/text/skills-command_state-84cd29d8.md new file mode 100644 index 00000000..7cfc02f1 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-command_state-84cd29d8.md @@ -0,0 +1 @@ +devup-mcp writes no slash command for {}: no command location for it is verified. The devup skill is installed with the others and loads by its description, and the server's instructions start the workflow on a request to build pages. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-install_action-13d23996.md b/crates/devup-mcp/src/server/text/skills-install_action-13d23996.md new file mode 100644 index 00000000..08e3819d --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-install_action-13d23996.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-install_action-50ca51e1.md b/crates/devup-mcp/src/server/text/skills-install_action-50ca51e1.md new file mode 100644 index 00000000..8d677146 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-install_action-50ca51e1.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-install_action-510f4ba8.md b/crates/devup-mcp/src/server/text/skills-install_action-510f4ba8.md new file mode 100644 index 00000000..027e85c2 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-install_action-510f4ba8.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-install_action-e5981815.md b/crates/devup-mcp/src/server/text/skills-install_action-e5981815.md new file mode 100644 index 00000000..6e314faa --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-install_action-e5981815.md @@ -0,0 +1 @@ +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. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-install_with-5f02c573.md b/crates/devup-mcp/src/server/text/skills-install_with-5f02c573.md new file mode 100644 index 00000000..156df2bb --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-install_with-5f02c573.md @@ -0,0 +1 @@ +Only carried skills were written, using current upstream documents where available or the embedded copy. External skills were not fetched or written, and no install command was executed. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-install_with-94607ffe.md b/crates/devup-mcp/src/server/text/skills-install_with-94607ffe.md new file mode 100644 index 00000000..7a573d46 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-install_with-94607ffe.md @@ -0,0 +1 @@ +Load the newly installed skills the way your runtime loads a project skill, and run any command under notInstallable yourself. Call devup_skills again to confirm the state changed. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-machine_wide-46b2476c.md b/crates/devup-mcp/src/server/text/skills-machine_wide-46b2476c.md new file mode 100644 index 00000000..d700f80a --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-machine_wide-46b2476c.md @@ -0,0 +1 @@ +devup-mcp writes only inside an allowed output root, and the home directory is not one. Widening that so a design-to-code server can write to $HOME is not a trade worth making, so the path is yours to fill. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-machine_wide-85410d98.md b/crates/devup-mcp/src/server/text/skills-machine_wide-85410d98.md new file mode 100644 index 00000000..6616c631 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-machine_wide-85410d98.md @@ -0,0 +1 @@ +Copy the installed skill directories there, or install them with your runtime's own skill tooling. A skill found in this directory is reported as installed by every later call, for every project. \ No newline at end of file diff --git a/crates/devup-mcp/src/server/text/skills-provenance_note-ca003bce.md b/crates/devup-mcp/src/server/text/skills-provenance_note-ca003bce.md new file mode 100644 index 00000000..07ae1030 --- /dev/null +++ b/crates/devup-mcp/src/server/text/skills-provenance_note-ca003bce.md @@ -0,0 +1,4 @@ +{marker}{repo}, at {path}. + Part of this devup-mcp build - there is no separate upstream for it to fall behind. + Current revision: {latest} + Why this matters here: {used} --> \ 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") }) } From 57753f5bbc7a7257995e732081c0f86066119cb6 Mon Sep 17 00:00:00 2001 From: leader Date: Sun, 18 Oct 2026 18:23:41 +0900 Subject: [PATCH 03/19] refactor: separate screen plan state routes actions and tests --- crates/devup-mcp/src/server/screen_plan.rs | 5672 +---------------- .../src/server/screen_plan/actions.rs | 785 +++ .../src/server/screen_plan/app_select.rs | 856 +++ .../devup-mcp/src/server/screen_plan/draft.rs | 601 ++ .../src/server/screen_plan/draft_plan_with.rs | 518 ++ .../screen_plan/draft_plan_with_block.rs | 585 ++ .../src/server/screen_plan/routes.rs | 867 +++ .../devup-mcp/src/server/screen_plan/state.rs | 871 +++ .../src/server/screen_plan/status.rs | 590 ++ .../devup-mcp/src/server/screen_plan_tests.rs | 2293 +------ ...oup_answer_can_name_one_route_per_frame.rs | 846 +++ ...is_vinext_with_view_transitions_enabled.rs | 576 ++ .../screen_plan_tests/use_declaration.rs | 870 +++ 13 files changed, 7976 insertions(+), 7954 deletions(-) create mode 100644 crates/devup-mcp/src/server/screen_plan/actions.rs create mode 100644 crates/devup-mcp/src/server/screen_plan/app_select.rs create mode 100644 crates/devup-mcp/src/server/screen_plan/draft.rs create mode 100644 crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs create mode 100644 crates/devup-mcp/src/server/screen_plan/draft_plan_with_block.rs create mode 100644 crates/devup-mcp/src/server/screen_plan/routes.rs create mode 100644 crates/devup-mcp/src/server/screen_plan/state.rs create mode 100644 crates/devup-mcp/src/server/screen_plan/status.rs create mode 100644 crates/devup-mcp/src/server/screen_plan_tests/a_custom_group_answer_can_name_one_route_per_frame.rs create mode 100644 crates/devup-mcp/src/server/screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs create mode 100644 crates/devup-mcp/src/server/screen_plan_tests/use_declaration.rs diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index 9f1eb3df..c3f57575 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -26,5668 +26,10 @@ //! 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 = - 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) -} - -/// 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() -} - -/// 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) - } -} - -/// 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!( - 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( - &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}) - }, - })) -} - -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"])) -} - -/// `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) -} - -/// 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"); 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..2a920a62 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/draft.rs @@ -0,0 +1,601 @@ + + +/// 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 { include!("draft_plan_with.rs") } + +/// 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"])) +} \ No newline at end of file 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..ff33f77e --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/draft_plan_with.rs @@ -0,0 +1,518 @@ +{ + 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() { include!("draft_plan_with_block.rs") } + 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": [], + })) +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/screen_plan/draft_plan_with_block.rs b/crates/devup-mcp/src/server/screen_plan/draft_plan_with_block.rs new file mode 100644 index 00000000..cdb0a265 --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan/draft_plan_with_block.rs @@ -0,0 +1,585 @@ +{ + 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( + &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], + })); + } \ No newline at end of file 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 a17cb60c..2474c1c3 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -1,2290 +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 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.")); -} +// Source sections share this module to preserve privacy and test names. +include!("screen_plan_tests/use_declaration.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/use_declaration.rs b/crates/devup-mcp/src/server/screen_plan_tests/use_declaration.rs new file mode 100644 index 00000000..a08c111d --- /dev/null +++ b/crates/devup-mcp/src/server/screen_plan_tests/use_declaration.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 From 225b6efa2e9ae728cf965b3027efec82494ea606 Mon Sep 17 00:00:00 2001 From: leader Date: Sun, 18 Oct 2026 18:38:20 +0900 Subject: [PATCH 04/19] refactor: extract named projection output and delivery responsibilities --- crates/devup-mcp/src/server/projection.rs | 8591 +---------------- .../server/projection/artifact_metadata.rs | 36 + .../src/server/projection/asset_paths.rs | 176 + .../projection/asset_reference_names.rs | 49 + .../src/server/projection/asset_resources.rs | 159 + .../src/server/projection/asset_summary.rs | 119 + .../src/server/projection/completion.rs | 112 + .../server/projection/component_projection.rs | 100 + .../projection/delivered_property_audit.rs | 123 + .../server/projection/diagnostic_screens.rs | 30 + .../src/server/projection/diagnostics.rs | 70 + .../exclude_hidden_asset_outputs.rs | 110 + .../projection/finalize_export_delivery.rs | 505 + .../src/server/projection/frame_identity.rs | 115 + .../src/server/projection/image_fill_audit.rs | 41 + .../projection/prepare_asset_manifest.rs | 141 + .../project_node_and_theme_outputs.rs | 257 + .../server/projection/project_operation.rs | 823 ++ .../projection/project_responsive_output.rs | 257 + .../projection/project_section_frames.rs | 333 + .../src/server/projection/regression_tests.rs | 41 + .../server/projection/resource_delivery.rs | 263 + .../src/server/projection/resource_outputs.rs | 264 + .../src/server/projection/safe_file_names.rs | 58 + .../projection/section_selection_reply.rs | 211 + .../devup-mcp/src/server/projection/setup.rs | 61 + .../summarize_projection_diagnostics.rs | 245 + .../server/projection/verification_scope.rs | 100 + .../src/server/projection/w1_regressions.rs | 972 ++ ...variant_and_css_map_resources_recover_w.rs | 973 ++ ...s_placement_contract_without_diagnostic.rs | 980 ++ ..._wquw118_layout_contracts_are_preserved.rs | 971 ++ ...is_warning_and_requested_failure_is_par.rs | 548 ++ crates/devup-mcp/tests/module_boundaries.rs | 25 +- 34 files changed, 9282 insertions(+), 8577 deletions(-) create mode 100644 crates/devup-mcp/src/server/projection/artifact_metadata.rs create mode 100644 crates/devup-mcp/src/server/projection/asset_paths.rs create mode 100644 crates/devup-mcp/src/server/projection/asset_reference_names.rs create mode 100644 crates/devup-mcp/src/server/projection/asset_resources.rs create mode 100644 crates/devup-mcp/src/server/projection/asset_summary.rs create mode 100644 crates/devup-mcp/src/server/projection/completion.rs create mode 100644 crates/devup-mcp/src/server/projection/component_projection.rs create mode 100644 crates/devup-mcp/src/server/projection/delivered_property_audit.rs create mode 100644 crates/devup-mcp/src/server/projection/diagnostic_screens.rs create mode 100644 crates/devup-mcp/src/server/projection/diagnostics.rs create mode 100644 crates/devup-mcp/src/server/projection/exclude_hidden_asset_outputs.rs create mode 100644 crates/devup-mcp/src/server/projection/finalize_export_delivery.rs create mode 100644 crates/devup-mcp/src/server/projection/frame_identity.rs create mode 100644 crates/devup-mcp/src/server/projection/image_fill_audit.rs create mode 100644 crates/devup-mcp/src/server/projection/prepare_asset_manifest.rs create mode 100644 crates/devup-mcp/src/server/projection/project_node_and_theme_outputs.rs create mode 100644 crates/devup-mcp/src/server/projection/project_operation.rs create mode 100644 crates/devup-mcp/src/server/projection/project_responsive_output.rs create mode 100644 crates/devup-mcp/src/server/projection/project_section_frames.rs create mode 100644 crates/devup-mcp/src/server/projection/regression_tests.rs create mode 100644 crates/devup-mcp/src/server/projection/resource_delivery.rs create mode 100644 crates/devup-mcp/src/server/projection/resource_outputs.rs create mode 100644 crates/devup-mcp/src/server/projection/safe_file_names.rs create mode 100644 crates/devup-mcp/src/server/projection/section_selection_reply.rs create mode 100644 crates/devup-mcp/src/server/projection/setup.rs create mode 100644 crates/devup-mcp/src/server/projection/summarize_projection_diagnostics.rs create mode 100644 crates/devup-mcp/src/server/projection/verification_scope.rs create mode 100644 crates/devup-mcp/src/server/projection/w1_regressions.rs create mode 100644 crates/devup-mcp/src/server/projection/w1_regressions_original_source_variant_and_css_map_resources_recover_w.rs create mode 100644 crates/devup-mcp/src/server/projection/w1_regressions_r4_export_exposes_placement_contract_without_diagnostic.rs create mode 100644 crates/devup-mcp/src/server/projection/w1_regressions_r7_real_wquw118_layout_contracts_are_preserved.rs create mode 100644 crates/devup-mcp/src/server/projection/w1_regressions_w1_hidden_asset_is_warning_and_requested_failure_is_par.rs diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 5b3cb573..f8344b03 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -1,8571 +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":include_str!("text/projection-resource_diagnostic_details-9b1c1ed8.md")}); - 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 { 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(); - } -} - -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" => { - 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, - } -} - -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": 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) -} - -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()), - } -} - -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", - 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"); - 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": 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::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!(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.", - )); - } - - 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!( - 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::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 = 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" - ); - } -} - -/// 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/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", From 9525e7409089632962d2079c6315ff4a2b23e2f0 Mon Sep 17 00:00:00 2001 From: leader Date: Sun, 18 Oct 2026 18:44:33 +0900 Subject: [PATCH 05/19] refactor: separate server registration source sections --- .../server/handlers/devup_feature_trace.rs | 20 + .../src/server/handlers/devup_figma_auth.rs | 80 + .../server/handlers/devup_figma_explore.rs | 20 + .../src/server/handlers/devup_figma_export.rs | 612 +++ .../server/handlers/devup_figma_measure.rs | 18 + .../src/server/handlers/devup_figma_search.rs | 75 + .../server/handlers/devup_project_context.rs | 32 + .../src/server/handlers/devup_project_plan.rs | 19 + .../server/handlers/devup_runtime_audit.rs | 54 + .../src/server/handlers/devup_screen_plan.rs | 46 + .../src/server/handlers/devup_skills.rs | 40 + .../src/server/handlers/devup_stack_diff.rs | 20 + .../src/server/handlers/devup_ui_validate.rs | 206 + .../server/handlers/devup_visual_compare.rs | 20 + crates/devup-mcp/src/server/mod.rs | 3626 +---------------- .../server/mod_parts/apply_explore_limit.rs | 961 +++++ .../src/server/mod_parts/artifacts.rs | 484 +++ crates/devup-mcp/src/server/mod_parts/impl.rs | 957 +++++ .../src/server/mod_parts/p2_recovery_tests.rs | 131 + 19 files changed, 3800 insertions(+), 3621 deletions(-) create mode 100644 crates/devup-mcp/src/server/handlers/devup_feature_trace.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_figma_auth.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_figma_explore.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_figma_export.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_figma_measure.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_figma_search.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_project_context.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_project_plan.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_runtime_audit.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_screen_plan.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_skills.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_stack_diff.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_ui_validate.rs create mode 100644 crates/devup-mcp/src/server/handlers/devup_visual_compare.rs create mode 100644 crates/devup-mcp/src/server/mod_parts/apply_explore_limit.rs create mode 100644 crates/devup-mcp/src/server/mod_parts/artifacts.rs create mode 100644 crates/devup-mcp/src/server/mod_parts/impl.rs create mode 100644 crates/devup-mcp/src/server/mod_parts/p2_recovery_tests.rs 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..75c3cf1d --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_feature_trace.rs @@ -0,0 +1,20 @@ +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..165b69ed --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_figma_auth.rs @@ -0,0 +1,80 @@ +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..3b08bc47 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_figma_explore.rs @@ -0,0 +1,20 @@ +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..20334a86 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_figma_export.rs @@ -0,0 +1,612 @@ +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..085310b5 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_figma_measure.rs @@ -0,0 +1,18 @@ +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..d283ddcd --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_figma_search.rs @@ -0,0 +1,75 @@ +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..f06b14d5 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_project_context.rs @@ -0,0 +1,32 @@ +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..3396c973 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_project_plan.rs @@ -0,0 +1,19 @@ +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..e9e2c5f5 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_runtime_audit.rs @@ -0,0 +1,54 @@ +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..07caafd5 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_screen_plan.rs @@ -0,0 +1,46 @@ +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..07298b7f --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_skills.rs @@ -0,0 +1,40 @@ +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..9106fa6e --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_stack_diff.rs @@ -0,0 +1,20 @@ +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..e01b44ee --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_ui_validate.rs @@ -0,0 +1,206 @@ +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..b7da30c2 --- /dev/null +++ b/crates/devup-mcp/src/server/handlers/devup_visual_compare.rs @@ -0,0 +1,20 @@ +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/mod.rs b/crates/devup-mcp/src/server/mod.rs index 52d9de67..cf949c4b 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -1,3621 +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_plan; -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; -#[cfg(test)] -mod source_limits; -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, - 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 -} - -/// 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 -} - -#[tool_router] -impl DevupServer { - #[tool( - output_schema = permissive_object_output_schema())] - #[doc = include_str!("text/devup-figma-measure.md")] - async fn devup_figma_measure( - &self, - Parameters(input): Parameters, - ) -> Result { - measurement::run(self, input) - .await - .map(tool_result) - .map_err(to_mcp_error) - } - - #[tool( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-skills.md")] - 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( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-figma-auth.md")] - 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)?, - )) - } - - #[tool( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-figma-search.md")] - 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( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-figma-explore.md")] - async fn devup_figma_explore( - &self, - Parameters(input): Parameters, - ) -> Result { - self.explore(input) - .await - .map(tool_result) - .map_err(to_mcp_error) - } - - #[tool( - output_schema = interactions::output_schema() - )] - #[doc = include_str!("text/devup-figma-export.md")] - 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(), - ), - ))) - } - - #[tool( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-project-context.md")] - 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( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-ui-validate.md")] - 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)) - } - - #[tool( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-stack-diff.md")] - 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( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-visual-compare.md")] - 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( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-feature-trace.md")] - 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( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-runtime-audit.md")] - 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( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-project-plan.md")] - 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)) - } - #[tool( - output_schema = permissive_object_output_schema() - )] - #[doc = include_str!("text/devup-screen-plan.md")] - 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!(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"); - } - } - } -} - -#[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/artifacts.rs"); +include!("mod_parts/impl.rs"); +include!("mod_parts/apply_explore_limit.rs"); +include!("mod_parts/p2_recovery_tests.rs"); diff --git a/crates/devup-mcp/src/server/mod_parts/apply_explore_limit.rs b/crates/devup-mcp/src/server/mod_parts/apply_explore_limit.rs new file mode 100644 index 00000000..010bc870 --- /dev/null +++ b/crates/devup-mcp/src/server/mod_parts/apply_explore_limit.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/artifacts.rs b/crates/devup-mcp/src/server/mod_parts/artifacts.rs new file mode 100644 index 00000000..47a466f4 --- /dev/null +++ b/crates/devup-mcp/src/server/mod_parts/artifacts.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/mod_parts/impl.rs b/crates/devup-mcp/src/server/mod_parts/impl.rs new file mode 100644 index 00000000..5601c2c8 --- /dev/null +++ b/crates/devup-mcp/src/server/mod_parts/impl.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/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()); + } + } +} From 351b4be8958bd2f940be0624628d2d1a31805300 Mon Sep 17 00:00:00 2001 From: leader Date: Sun, 18 Oct 2026 18:50:35 +0900 Subject: [PATCH 06/19] refactor: separate audit source sections --- crates/devup-mcp/src/server/audit_plan.rs | 1769 +--------- .../src/server/audit_plan/static_params.rs | 793 +++++ .../src/server/audit_plan/use_declaration.rs | 975 ++++++ crates/devup-mcp/src/server/diagnostics.rs | 1281 +------ .../devup-mcp/src/server/diagnostics/tests.rs | 534 +++ .../src/server/diagnostics/use_declaration.rs | 746 +++++ crates/devup-mcp/src/server/runtime_audit.rs | 2970 +---------------- .../runtime_audit/capture_comparison.rs | 166 + .../src/server/runtime_audit/tests.rs | 865 +++++ .../server/runtime_audit/use_declaration.rs | 944 ++++++ .../src/server/runtime_audit/write_plan.rs | 994 ++++++ 11 files changed, 6028 insertions(+), 6009 deletions(-) create mode 100644 crates/devup-mcp/src/server/audit_plan/static_params.rs create mode 100644 crates/devup-mcp/src/server/audit_plan/use_declaration.rs create mode 100644 crates/devup-mcp/src/server/diagnostics/tests.rs create mode 100644 crates/devup-mcp/src/server/diagnostics/use_declaration.rs create mode 100644 crates/devup-mcp/src/server/runtime_audit/capture_comparison.rs create mode 100644 crates/devup-mcp/src/server/runtime_audit/tests.rs create mode 100644 crates/devup-mcp/src/server/runtime_audit/use_declaration.rs create mode 100644 crates/devup-mcp/src/server/runtime_audit/write_plan.rs diff --git a/crates/devup-mcp/src/server/audit_plan.rs b/crates/devup-mcp/src/server/audit_plan.rs index 60efad6e..b75b69f0 100644 --- a/crates/devup-mcp/src/server/audit_plan.rs +++ b/crates/devup-mcp/src/server/audit_plan.rs @@ -7,1770 +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!( - 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 -} - -/// `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/use_declaration.rs"); +include!("audit_plan/static_params.rs"); 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..de414609 --- /dev/null +++ b/crates/devup-mcp/src/server/audit_plan/static_params.rs @@ -0,0 +1,793 @@ + + +/// `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); + } + } + } +} diff --git a/crates/devup-mcp/src/server/audit_plan/use_declaration.rs b/crates/devup-mcp/src/server/audit_plan/use_declaration.rs new file mode 100644 index 00000000..ad525bd0 --- /dev/null +++ b/crates/devup-mcp/src/server/audit_plan/use_declaration.rs @@ -0,0 +1,975 @@ +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!( + 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 +} \ No newline at end of file diff --git a/crates/devup-mcp/src/server/diagnostics.rs b/crates/devup-mcp/src/server/diagnostics.rs index 17303230..1b955b31 100644 --- a/crates/devup-mcp/src/server/diagnostics.rs +++ b/crates/devup-mcp/src/server/diagnostics.rs @@ -35,1281 +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 = 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" - } - } - } - } - } - } - }) -} - -#[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/use_declaration.rs"); +include!("diagnostics/tests.rs"); 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/diagnostics/use_declaration.rs b/crates/devup-mcp/src/server/diagnostics/use_declaration.rs new file mode 100644 index 00000000..61f44fa2 --- /dev/null +++ b/crates/devup-mcp/src/server/diagnostics/use_declaration.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/runtime_audit.rs b/crates/devup-mcp/src/server/runtime_audit.rs index 4ae20ffa..295bc44e 100644 --- a/crates/devup-mcp/src/server/runtime_audit.rs +++ b/crates/devup-mcp/src/server/runtime_audit.rs @@ -11,2968 +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": 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}, - }) -} - -/// 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"), - }) -} - -#[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/use_declaration.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/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/use_declaration.rs b/crates/devup-mcp/src/server/runtime_audit/use_declaration.rs new file mode 100644 index 00000000..14a1f3a0 --- /dev/null +++ b/crates/devup-mcp/src/server/runtime_audit/use_declaration.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/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 From a1cf960449aac8caca1e552f572cd9072e9f35bb Mon Sep 17 00:00:00 2001 From: leader Date: Sun, 18 Oct 2026 18:52:24 +0900 Subject: [PATCH 07/19] refactor: separate skills and stack source sections --- crates/devup-mcp/src/server/skills.rs | 2510 +---------------- crates/devup-mcp/src/server/skills/impl.rs | 958 +++++++ crates/devup-mcp/src/server/skills/tests.rs | 790 ++++++ .../src/server/skills/use_declaration.rs | 761 +++++ crates/devup-mcp/src/server/stack_diff.rs | 2471 +--------------- .../server/stack_diff/openapi_client_layer.rs | 242 ++ .../devup-mcp/src/server/stack_diff/tests.rs | 979 +++++++ ...api_layer_flags_route_missing_from_spec.rs | 300 ++ .../src/server/stack_diff/use_declaration.rs | 953 +++++++ 9 files changed, 4990 insertions(+), 4974 deletions(-) create mode 100644 crates/devup-mcp/src/server/skills/impl.rs create mode 100644 crates/devup-mcp/src/server/skills/tests.rs create mode 100644 crates/devup-mcp/src/server/skills/use_declaration.rs create mode 100644 crates/devup-mcp/src/server/stack_diff/openapi_client_layer.rs create mode 100644 crates/devup-mcp/src/server/stack_diff/tests.rs create mode 100644 crates/devup-mcp/src/server/stack_diff/tests_route_openapi_layer_flags_route_missing_from_spec.rs create mode 100644 crates/devup-mcp/src/server/stack_diff/use_declaration.rs diff --git a/crates/devup-mcp/src/server/skills.rs b/crates/devup-mcp/src/server/skills.rs index aacf9fe2..ac2cdaa9 100644 --- a/crates/devup-mcp/src/server/skills.rs +++ b/crates/devup-mcp/src/server/skills.rs @@ -46,2509 +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!( - 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 = "", + 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 = " 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/ui_validate_response.rs b/crates/devup-mcp/tests/ui_validate_response.rs index cc4645a8..2edb1f93 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