From b8e5e79e8638106b65a423ef4dbab55298ef4ed4 Mon Sep 17 00:00:00 2001 From: leader Date: Wed, 14 Oct 2026 21:24:13 +0900 Subject: [PATCH] feat: improve export reliability and interaction guidance --- .../changepack_log_feedback_interactions.json | 7 + ...pack_log_feedback_release_integration.json | 9 + ...ngepack_log_feedback_theme_responsive.json | 8 + .../changepack_log_nested_asset_exports.json | 8 + .gitignore | 3 + Cargo.lock | 2 + README.md | 22 +- .../src/codegen/component.rs | 37 + .../src/codegen/property_derivations.rs | 1 + .../src/codegen/responsive.rs | 23 +- crates/devup-mcp-devup-ui/src/codegen/text.rs | 73 +- .../devup-mcp-devup-ui/src/codegen/variant.rs | 2 + .../src/theme/devup_json.rs | 90 +- crates/devup-mcp-devup-ui/src/theme/mod.rs | 6 +- crates/devup-mcp-devup-ui/src/theme/usage.rs | 306 + .../tests/feedback_theme.rs | 319 + .../tests/responsive_discovery.rs | 26 + crates/devup-mcp-figma/src/asset_timeout.rs | 41 + crates/devup-mcp-figma/src/assets.rs | 38 +- crates/devup-mcp-figma/src/bridge.rs | 42 +- crates/devup-mcp-figma/src/bridge/relay.rs | 18 +- crates/devup-mcp-figma/src/collector.rs | 53 +- crates/devup-mcp-figma/src/large_values.rs | 2 + crates/devup-mcp-figma/src/lib.rs | 11 +- .../src/scripts/asset_render_helpers.js | 130 + crates/devup-mcp-figma/src/scripts/assets.js | 43 +- .../src/scripts/component_identity_helpers.js | 24 + .../src/scripts/fast_snapshot.js | 21 +- .../src/scripts/large_value.js | 8 +- .../devup-mcp-figma/src/scripts/snapshot.js | 6 +- crates/devup-mcp-figma/src/upstream.rs | 51 +- crates/devup-mcp-figma/src/url.rs | 40 +- .../tests/asset_script_transport.rs | 2 +- crates/devup-mcp-figma/tests/assets.rs | 14 + .../tests/component_identity_script.rs | 67 + .../tests/explore_script_behavior.mjs | 4 + .../tests/feedback_scripts.test.mjs | 80 + crates/devup-mcp-figma/tests/figma_url.rs | 41 + .../tests/nested_asset_export.rs | 288 + .../tests/original_image_script.mjs | 1 + crates/devup-mcp/Cargo.toml | 2 + crates/devup-mcp/src/server/asset_jobs.rs | 32 +- crates/devup-mcp/src/server/feature_trace.rs | 44 + .../src/server/feedback_interactions_tests.rs | 716 + crates/devup-mcp/src/server/guide.rs | 8 +- crates/devup-mcp/src/server/interactions.rs | 718 + crates/devup-mcp/src/server/mod.rs | 35 +- crates/devup-mcp/src/server/navigation.rs | 602 + crates/devup-mcp/src/server/operation.rs | 1 + .../devup-mcp/src/server/project_context.rs | 20 +- .../src/server/project_context_export.rs | 175 + crates/devup-mcp/src/server/projection.rs | 398 +- .../server/projection/feedback_theme_tests.rs | 308 + .../server/projection/responsive_groups.rs | 126 + .../src/server/projection/theme_export.rs | 250 + crates/devup-mcp/src/server/review.rs | 46 +- .../server/skills/devfive-frontend/SKILL.md | 59 + .../src/server/skills/fetch_tests.rs | 65 + .../devup-mcp/src/server/skills/manifest.json | 4 +- crates/devup-mcp/src/server/tools.rs | 19 +- crates/devup-mcp/src/server/validation.rs | 2 +- crates/devup-mcp/tests/composite_export.rs | 19 +- crates/devup-mcp/tests/ground_truth_tools.rs | 53 + docs/R17-REPORT.md | 81 - docs/R7-plan.md | 11 - docs/R7-protocol.jsonl | 4 - docs/R7-report.md | 34 - docs/R7-verification.txt | 38 - docs/W10-design-drift.md | 128 - docs/W3-reuse-context-report.md | 229 - docs/W5-ui-structure-report.md | 69 - docs/W8-jsx-text-fidelity.md | 174 - docs/about-landing-mobile-evidence.json | 12319 ------------ docs/about-landing-mobile-golden-review.md | 53 - docs/about-landing-mobile.md | 259 - docs/about-mobile-composition.md | 155 - docs/about-mobile-evidence.json | 977 - docs/about-vertical-geometry.md | 195 - docs/bridge-asset-delivery.md | 111 - docs/bridge-original-image-bytes.md | 182 - docs/bridge-original-image-evidence.json | 562 - docs/feature-trace/check-results.json | 22 - docs/feature-trace/input-schema.json | 93 - docs/feature-trace/report.md | 82 - docs/figma-vector-export-regression.md | 96 - docs/grid-raster-composition.md | 173 - docs/grid-raster-evidence.json | 2820 --- docs/harness-determinism-evidence.json | 542 - docs/harness-determinism.md | 200 - docs/hero-export-contract-probe.py | 93 - docs/integer-line-advance-verification.md | 190 - docs/isolated-fill-export-contract.md | 172 - docs/isolated-fill-export-evidence.json | 2559 --- docs/keyframes-static-alignment.md | 310 - docs/keyframes-static-evidence.json | 4036 ---- docs/landing-rasterization-crop.png | Bin 67336 -> 0 bytes docs/landing-rasterization-evidence.json | 15575 ---------------- docs/landing-rasterization.md | 150 - docs/line-box-displacement.md | 194 - docs/line-box-fidelity-investigation.md | 197 - docs/macos-path-assertion.md | 64 - docs/macos-path-binary-proof.json | 21 - docs/mobile-excess-evidence.json | 13390 ------------- docs/mobile-excess.md | 259 - docs/mobile-width-divergence.md | 101 - docs/notice-layout-divergence.md | 287 - docs/notice-layout-evidence.json | 532 - docs/path-guard-platform.md | 60 - docs/popup-image-sources-evidence.json | 2949 --- docs/popup-image-sources.md | 196 - docs/r1-result-contract.md | 36 - docs/r10-report.md | 40 - docs/r11-report.md | 55 - docs/r12/README.md | 44 - docs/r12/check-types.cjs | 31 - docs/r12/clippy.txt | 3 - docs/r12/fmt.txt | 0 docs/r12/green-original-validator.json | 20 - docs/r12/green-polymorphic.txt | 53 - docs/r12/green-tests.txt | 942 - docs/r12/js-tests.txt | 20 - docs/r12/red-asset-split.txt | 60 - docs/r12/red-delivery.txt | 16 - docs/r12/red-original-validator.json | 47 - docs/r12/red-projection.txt | 16 - docs/r12/red-tests.txt | 1285 -- docs/r12/red-validator.txt | 23 - docs/r12/red-wire-identity.txt | 35 - docs/r12/replay-validator.cjs | 39 - docs/r12/target-proof.json | 37 - docs/r12/test-summary.json | 5 - docs/r12/type-evidence.json | 21 - docs/r12/workspace-tests.txt | 1739 -- docs/r13-clippy.txt | 242 - docs/r13-final-review-red.txt | 78 - docs/r13-final-workspace.txt | 1767 -- docs/r13-fmt.txt | 3 - docs/r13-js.txt | 20 - docs/r13-local-executables.txt | 22 - docs/r13-local-target.json | 22 - docs/r13-plan.md | 28 - docs/r13-red-contract.txt | 14 - docs/r13-red-server.txt | 33 - docs/r13-report.md | 74 - docs/r13-reprojection-red.txt | 39 - docs/r13-review-red.txt | 77 - docs/r13-strict-red.txt | 61 - docs/r14/REPORT.md | 115 - docs/r14/clippy-final.txt | 4 - docs/r14/clippy.txt | 344 - docs/r14/focused.txt | 254 - docs/r14/js-final.txt | 20 - docs/r14/review-red.txt | 43 - docs/r14/server-response.txt | 42 - docs/r14/target-identity.json | 39 - docs/r14/workspace-before-corrections.txt | 2096 --- docs/r14/workspace-final.txt | 1790 -- docs/r15/clippy.txt | 242 - docs/r15/fmt.txt | 0 docs/r15/green.txt | 28 - docs/r15/js.txt | 20 - docs/r15/local-executable-list.txt | 6 - docs/r15/local-executable-run.txt | 5 - docs/r15/local-target.json | 7 - docs/r15/plan.md | 9 - docs/r15/red-image.txt | 27 - docs/r15/red-mask.txt | 50 - docs/r15/report.md | 35 - docs/r15/review.md | 7 - docs/r15/verification.json | 26 - docs/r15/workspace-final.txt | 1803 -- docs/r15/workspace-first.txt | 1987 -- docs/r16-plan.md | 13 - docs/r16/REPORT.md | 47 - docs/r16/checks.txt | 32 - docs/r16/golden-audit.json | 64 - docs/r16/red-evidence.txt | 191 - docs/r16/response-contract.md | 38 - docs/r16/target-identity.json | 43 - docs/r16/target-tests.txt | 44 - docs/r16/test-summary.txt | 196 - docs/r16/verification.json | 37 - docs/r20/REPORT.md | 178 - docs/r20/asset-full-snapshot.json | 1877 -- docs/r20/clippy.txt | 242 - docs/r20/fmt.txt | 0 docs/r20/inputs.txt | 23 - docs/r20/inventory-build.txt | 48 - docs/r20/inventory.json | 14217 -------------- docs/r20/inventory.rs | 59 - docs/r20/js-tests.txt | 21 - docs/r20/local-test.txt | 5 - docs/r20/target-identity.json | 14 - docs/r20/verification.json | 30 - docs/r20/workspace-tests.txt | 1866 -- docs/r3-asset-jobs-and-evidence.md | 15 - docs/r3-validation/REPORT.md | 50 - docs/r3-validation/build-exit.json | 4 - docs/r3-validation/build-identity.json | 6 - docs/r3-validation/clippy-exit.json | 4 - docs/r3-validation/fmt-exit.json | 4 - docs/r3-validation/test-binary-proof.json | 22 - docs/r3-validation/test-exit.json | 4 - docs/r5-exact-and-batch-summary.md | 39 - docs/r6/REPORT.md | 70 - docs/r6/completion-sizing-evidence.json | 166 - docs/r6/completion.tsx | 57 - docs/r6/validation.json | 62 - docs/r8-live-measurement.json | 635 - docs/r8-plan.md | 10 - docs/r8-report.md | 74 - docs/r9/REPORT.md | 44 - docs/r9/clippy.txt | 3 - docs/r9/fmt.txt | 0 docs/r9/golden-changes.md | 4 - docs/r9/green-response-evidence.txt | 41 - docs/r9/green.txt | 40 - docs/r9/input-evidence.json | 10 - docs/r9/red-border.txt | 25 - docs/r9/red-boundaries.txt | 45 - docs/r9/red-capture.txt | 65 - docs/r9/red-fill-dimension.txt | 25 - docs/r9/red-legacy.txt | 24 - docs/r9/red-parent-mapping.txt | 23 - docs/r9/red-response-evidence.txt | 35 - docs/r9/red-revalidation.txt | 32 - docs/r9/red-sizing-guards.txt | 26 - docs/r9/red.txt | 71 - docs/r9/verification.json | 27 - docs/r9/workspace-final.txt | 1696 -- docs/responsive-merge-rules.md | 337 - docs/route-openapi-exact-report.md | 76 - docs/runtime-audit-contract.md | 140 - docs/self-description.md | 139 - .../plans/2026-08-30-figma-remote-mcp.md | 423 - ...26-08-31-figma-collection-live-contract.md | 496 - ...6-08-31-figma-linked-screen-exploration.md | 458 - .../2026-08-31-figma-search-and-export.md | 304 - .../2026-08-31-figma-single-call-envelope.md | 356 - .../plans/2026-08-31-json-fixture-parity.md | 502 - ...-09-01-devup-mcp-asset-output-hardening.md | 201 - ...09-01-devup-mcp-fidelity-visual-modules.md | 272 - ...1-devup-mcp-figma-correctness-and-reuse.md | 704 - ...2026-09-01-devup-mcp-output-correctness.md | 206 - .../2026-09-01-devup-mcp-resource-delivery.md | 185 - ...2026-09-01-devup-mcp-section-multi-root.md | 193 - ...-09-01-figma-explore-cache-optimization.md | 101 - .../2026-09-01-figma-mcp-live-hardening.md | 349 - .../plans/2026-09-09-r2-actionable-partial.md | 16 - .../2026-09-09-r3-assets-and-evidence.md | 14 - docs/superpowers/plans/2026-09-10-r5-exact.md | 15 - .../plans/2026-09-11-r14-absolute-evidence.md | 38 - .../2026-08-30-figma-remote-mcp-design.md | 335 - ...08-31-figma-host-fallback-parity-design.md | 503 - ...-figma-linked-screen-exploration-design.md | 221 - ...08-31-figma-single-call-envelope-design.md | 310 - ...-mcp-figma-correctness-and-reuse-design.md | 401 - ...devup-mcp-hardening-and-delivery-design.md | 498 - ...-01-devup-mcp-output-correctness-design.md | 76 - ...figma-explore-cache-optimization-design.md | 57 - ...6-09-01-figma-mcp-live-hardening-design.md | 182 - docs/visual-renderer-contract.md | 99 - docs/w7-page-scaffold-red.txt | 26 - docs/w7-page-scaffold.md | 84 - plugin/dist/code.js | 2 +- plugin/scripts/gen-scripts.mjs | 6 +- 266 files changed, 5549 insertions(+), 108178 deletions(-) create mode 100644 .changepacks/changepack_log_feedback_interactions.json create mode 100644 .changepacks/changepack_log_feedback_release_integration.json create mode 100644 .changepacks/changepack_log_feedback_theme_responsive.json create mode 100644 .changepacks/changepack_log_nested_asset_exports.json create mode 100644 crates/devup-mcp-devup-ui/src/theme/usage.rs create mode 100644 crates/devup-mcp-devup-ui/tests/feedback_theme.rs create mode 100644 crates/devup-mcp-figma/src/asset_timeout.rs create mode 100644 crates/devup-mcp-figma/src/scripts/asset_render_helpers.js create mode 100644 crates/devup-mcp-figma/src/scripts/component_identity_helpers.js create mode 100644 crates/devup-mcp-figma/tests/component_identity_script.rs create mode 100644 crates/devup-mcp-figma/tests/feedback_scripts.test.mjs create mode 100644 crates/devup-mcp-figma/tests/nested_asset_export.rs create mode 100644 crates/devup-mcp/src/server/feedback_interactions_tests.rs create mode 100644 crates/devup-mcp/src/server/interactions.rs create mode 100644 crates/devup-mcp/src/server/navigation.rs create mode 100644 crates/devup-mcp/src/server/project_context_export.rs create mode 100644 crates/devup-mcp/src/server/projection/feedback_theme_tests.rs create mode 100644 crates/devup-mcp/src/server/projection/responsive_groups.rs create mode 100644 crates/devup-mcp/src/server/projection/theme_export.rs delete mode 100644 docs/R17-REPORT.md delete mode 100644 docs/R7-plan.md delete mode 100644 docs/R7-protocol.jsonl delete mode 100644 docs/R7-report.md delete mode 100644 docs/R7-verification.txt delete mode 100644 docs/W10-design-drift.md delete mode 100644 docs/W3-reuse-context-report.md delete mode 100644 docs/W5-ui-structure-report.md delete mode 100644 docs/W8-jsx-text-fidelity.md delete mode 100644 docs/about-landing-mobile-evidence.json delete mode 100644 docs/about-landing-mobile-golden-review.md delete mode 100644 docs/about-landing-mobile.md delete mode 100644 docs/about-mobile-composition.md delete mode 100644 docs/about-mobile-evidence.json delete mode 100644 docs/about-vertical-geometry.md delete mode 100644 docs/bridge-asset-delivery.md delete mode 100644 docs/bridge-original-image-bytes.md delete mode 100644 docs/bridge-original-image-evidence.json delete mode 100644 docs/feature-trace/check-results.json delete mode 100644 docs/feature-trace/input-schema.json delete mode 100644 docs/feature-trace/report.md delete mode 100644 docs/figma-vector-export-regression.md delete mode 100644 docs/grid-raster-composition.md delete mode 100644 docs/grid-raster-evidence.json delete mode 100644 docs/harness-determinism-evidence.json delete mode 100644 docs/harness-determinism.md delete mode 100644 docs/hero-export-contract-probe.py delete mode 100644 docs/integer-line-advance-verification.md delete mode 100644 docs/isolated-fill-export-contract.md delete mode 100644 docs/isolated-fill-export-evidence.json delete mode 100644 docs/keyframes-static-alignment.md delete mode 100644 docs/keyframes-static-evidence.json delete mode 100644 docs/landing-rasterization-crop.png delete mode 100644 docs/landing-rasterization-evidence.json delete mode 100644 docs/landing-rasterization.md delete mode 100644 docs/line-box-displacement.md delete mode 100644 docs/line-box-fidelity-investigation.md delete mode 100644 docs/macos-path-assertion.md delete mode 100644 docs/macos-path-binary-proof.json delete mode 100644 docs/mobile-excess-evidence.json delete mode 100644 docs/mobile-excess.md delete mode 100644 docs/mobile-width-divergence.md delete mode 100644 docs/notice-layout-divergence.md delete mode 100644 docs/notice-layout-evidence.json delete mode 100644 docs/path-guard-platform.md delete mode 100644 docs/popup-image-sources-evidence.json delete mode 100644 docs/popup-image-sources.md delete mode 100644 docs/r1-result-contract.md delete mode 100644 docs/r10-report.md delete mode 100644 docs/r11-report.md delete mode 100644 docs/r12/README.md delete mode 100644 docs/r12/check-types.cjs delete mode 100644 docs/r12/clippy.txt delete mode 100644 docs/r12/fmt.txt delete mode 100644 docs/r12/green-original-validator.json delete mode 100644 docs/r12/green-polymorphic.txt delete mode 100644 docs/r12/green-tests.txt delete mode 100644 docs/r12/js-tests.txt delete mode 100644 docs/r12/red-asset-split.txt delete mode 100644 docs/r12/red-delivery.txt delete mode 100644 docs/r12/red-original-validator.json delete mode 100644 docs/r12/red-projection.txt delete mode 100644 docs/r12/red-tests.txt delete mode 100644 docs/r12/red-validator.txt delete mode 100644 docs/r12/red-wire-identity.txt delete mode 100644 docs/r12/replay-validator.cjs delete mode 100644 docs/r12/target-proof.json delete mode 100644 docs/r12/test-summary.json delete mode 100644 docs/r12/type-evidence.json delete mode 100644 docs/r12/workspace-tests.txt delete mode 100644 docs/r13-clippy.txt delete mode 100644 docs/r13-final-review-red.txt delete mode 100644 docs/r13-final-workspace.txt delete mode 100644 docs/r13-fmt.txt delete mode 100644 docs/r13-js.txt delete mode 100644 docs/r13-local-executables.txt delete mode 100644 docs/r13-local-target.json delete mode 100644 docs/r13-plan.md delete mode 100644 docs/r13-red-contract.txt delete mode 100644 docs/r13-red-server.txt delete mode 100644 docs/r13-report.md delete mode 100644 docs/r13-reprojection-red.txt delete mode 100644 docs/r13-review-red.txt delete mode 100644 docs/r13-strict-red.txt delete mode 100644 docs/r14/REPORT.md delete mode 100644 docs/r14/clippy-final.txt delete mode 100644 docs/r14/clippy.txt delete mode 100644 docs/r14/focused.txt delete mode 100644 docs/r14/js-final.txt delete mode 100644 docs/r14/review-red.txt delete mode 100644 docs/r14/server-response.txt delete mode 100644 docs/r14/target-identity.json delete mode 100644 docs/r14/workspace-before-corrections.txt delete mode 100644 docs/r14/workspace-final.txt delete mode 100644 docs/r15/clippy.txt delete mode 100644 docs/r15/fmt.txt delete mode 100644 docs/r15/green.txt delete mode 100644 docs/r15/js.txt delete mode 100644 docs/r15/local-executable-list.txt delete mode 100644 docs/r15/local-executable-run.txt delete mode 100644 docs/r15/local-target.json delete mode 100644 docs/r15/plan.md delete mode 100644 docs/r15/red-image.txt delete mode 100644 docs/r15/red-mask.txt delete mode 100644 docs/r15/report.md delete mode 100644 docs/r15/review.md delete mode 100644 docs/r15/verification.json delete mode 100644 docs/r15/workspace-final.txt delete mode 100644 docs/r15/workspace-first.txt delete mode 100644 docs/r16-plan.md delete mode 100644 docs/r16/REPORT.md delete mode 100644 docs/r16/checks.txt delete mode 100644 docs/r16/golden-audit.json delete mode 100644 docs/r16/red-evidence.txt delete mode 100644 docs/r16/response-contract.md delete mode 100644 docs/r16/target-identity.json delete mode 100644 docs/r16/target-tests.txt delete mode 100644 docs/r16/test-summary.txt delete mode 100644 docs/r16/verification.json delete mode 100644 docs/r20/REPORT.md delete mode 100644 docs/r20/asset-full-snapshot.json delete mode 100644 docs/r20/clippy.txt delete mode 100644 docs/r20/fmt.txt delete mode 100644 docs/r20/inputs.txt delete mode 100644 docs/r20/inventory-build.txt delete mode 100644 docs/r20/inventory.json delete mode 100644 docs/r20/inventory.rs delete mode 100644 docs/r20/js-tests.txt delete mode 100644 docs/r20/local-test.txt delete mode 100644 docs/r20/target-identity.json delete mode 100644 docs/r20/verification.json delete mode 100644 docs/r20/workspace-tests.txt delete mode 100644 docs/r3-asset-jobs-and-evidence.md delete mode 100644 docs/r3-validation/REPORT.md delete mode 100644 docs/r3-validation/build-exit.json delete mode 100644 docs/r3-validation/build-identity.json delete mode 100644 docs/r3-validation/clippy-exit.json delete mode 100644 docs/r3-validation/fmt-exit.json delete mode 100644 docs/r3-validation/test-binary-proof.json delete mode 100644 docs/r3-validation/test-exit.json delete mode 100644 docs/r5-exact-and-batch-summary.md delete mode 100644 docs/r6/REPORT.md delete mode 100644 docs/r6/completion-sizing-evidence.json delete mode 100644 docs/r6/completion.tsx delete mode 100644 docs/r6/validation.json delete mode 100644 docs/r8-live-measurement.json delete mode 100644 docs/r8-plan.md delete mode 100644 docs/r8-report.md delete mode 100644 docs/r9/REPORT.md delete mode 100644 docs/r9/clippy.txt delete mode 100644 docs/r9/fmt.txt delete mode 100644 docs/r9/golden-changes.md delete mode 100644 docs/r9/green-response-evidence.txt delete mode 100644 docs/r9/green.txt delete mode 100644 docs/r9/input-evidence.json delete mode 100644 docs/r9/red-border.txt delete mode 100644 docs/r9/red-boundaries.txt delete mode 100644 docs/r9/red-capture.txt delete mode 100644 docs/r9/red-fill-dimension.txt delete mode 100644 docs/r9/red-legacy.txt delete mode 100644 docs/r9/red-parent-mapping.txt delete mode 100644 docs/r9/red-response-evidence.txt delete mode 100644 docs/r9/red-revalidation.txt delete mode 100644 docs/r9/red-sizing-guards.txt delete mode 100644 docs/r9/red.txt delete mode 100644 docs/r9/verification.json delete mode 100644 docs/r9/workspace-final.txt delete mode 100644 docs/responsive-merge-rules.md delete mode 100644 docs/route-openapi-exact-report.md delete mode 100644 docs/runtime-audit-contract.md delete mode 100644 docs/self-description.md delete mode 100644 docs/superpowers/plans/2026-08-30-figma-remote-mcp.md delete mode 100644 docs/superpowers/plans/2026-08-31-figma-collection-live-contract.md delete mode 100644 docs/superpowers/plans/2026-08-31-figma-linked-screen-exploration.md delete mode 100644 docs/superpowers/plans/2026-08-31-figma-search-and-export.md delete mode 100644 docs/superpowers/plans/2026-08-31-figma-single-call-envelope.md delete mode 100644 docs/superpowers/plans/2026-08-31-json-fixture-parity.md delete mode 100644 docs/superpowers/plans/2026-09-01-devup-mcp-asset-output-hardening.md delete mode 100644 docs/superpowers/plans/2026-09-01-devup-mcp-fidelity-visual-modules.md delete mode 100644 docs/superpowers/plans/2026-09-01-devup-mcp-figma-correctness-and-reuse.md delete mode 100644 docs/superpowers/plans/2026-09-01-devup-mcp-output-correctness.md delete mode 100644 docs/superpowers/plans/2026-09-01-devup-mcp-resource-delivery.md delete mode 100644 docs/superpowers/plans/2026-09-01-devup-mcp-section-multi-root.md delete mode 100644 docs/superpowers/plans/2026-09-01-figma-explore-cache-optimization.md delete mode 100644 docs/superpowers/plans/2026-09-01-figma-mcp-live-hardening.md delete mode 100644 docs/superpowers/plans/2026-09-09-r2-actionable-partial.md delete mode 100644 docs/superpowers/plans/2026-09-09-r3-assets-and-evidence.md delete mode 100644 docs/superpowers/plans/2026-09-10-r5-exact.md delete mode 100644 docs/superpowers/plans/2026-09-11-r14-absolute-evidence.md delete mode 100644 docs/superpowers/specs/2026-08-30-figma-remote-mcp-design.md delete mode 100644 docs/superpowers/specs/2026-08-31-figma-host-fallback-parity-design.md delete mode 100644 docs/superpowers/specs/2026-08-31-figma-linked-screen-exploration-design.md delete mode 100644 docs/superpowers/specs/2026-08-31-figma-single-call-envelope-design.md delete mode 100644 docs/superpowers/specs/2026-09-01-devup-mcp-figma-correctness-and-reuse-design.md delete mode 100644 docs/superpowers/specs/2026-09-01-devup-mcp-hardening-and-delivery-design.md delete mode 100644 docs/superpowers/specs/2026-09-01-devup-mcp-output-correctness-design.md delete mode 100644 docs/superpowers/specs/2026-09-01-figma-explore-cache-optimization-design.md delete mode 100644 docs/superpowers/specs/2026-09-01-figma-mcp-live-hardening-design.md delete mode 100644 docs/visual-renderer-contract.md delete mode 100644 docs/w7-page-scaffold-red.txt delete mode 100644 docs/w7-page-scaffold.md diff --git a/.changepacks/changepack_log_feedback_interactions.json b/.changepacks/changepack_log_feedback_interactions.json new file mode 100644 index 00000000..3054b908 --- /dev/null +++ b/.changepacks/changepack_log_feedback_interactions.json @@ -0,0 +1,7 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Minor" + }, + "note": "Expose bounded interaction, prototype, state, shared-component and widget evidence in Figma exports, with required wiring review and completion evidence. Merge existing UI/route inventories and environment key names without values; validate semantic controls and static destinations, report source/rendered href coverage and compare explicit prototype navigation bindings in feature traces. Add frontend guidance for accessible links, unresolved destinations, visual verification and Windows tooling.", + "date": "2026-10-14T09:45:00+00:00" +} diff --git a/.changepacks/changepack_log_feedback_release_integration.json b/.changepacks/changepack_log_feedback_release_integration.json new file mode 100644 index 00000000..29526cbc --- /dev/null +++ b/.changepacks/changepack_log_feedback_release_integration.json @@ -0,0 +1,9 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Patch", + "crates/devup-mcp-figma/Cargo.toml": "Patch", + "crates/devup-mcp-devup-ui/Cargo.toml": "Patch" + }, + "note": "Integrate the settled theme, nested asset and interaction feedback work. Flag constant navigation underlines as unverified info, preserve MCP schema compatibility, bound text diagnostic excerpts, deliver complete bulky diagnostics/verdict/layout metadata through hash-verified resources with explicit preview omissions and required review, and reserve context headroom for final MCP wire sizing. Discover tall named breakpoint wrappers and provide valid direct-frame responsive guidance without claiming implementation. Verify final code/theme/file/resource hashes and enforce nested asset/async identity source regressions in existing CI; include a read-only Prime verification driver and exact coverage/limitations.", + "date": "2026-10-14T10:15:00Z" +} diff --git a/.changepacks/changepack_log_feedback_theme_responsive.json b/.changepacks/changepack_log_feedback_theme_responsive.json new file mode 100644 index 00000000..568c408e --- /dev/null +++ b/.changepacks/changepack_log_feedback_theme_responsive.json @@ -0,0 +1,8 @@ +{ + "changes": { + "crates/devup-mcp/Cargo.toml": "Patch", + "crates/devup-mcp-devup-ui/Cargo.toml": "Patch" + }, + "note": "Keep independent theme tokens when a text style is unreadable, report named style/node diagnostics, support used-only themes and existing-theme missing-token diffs, elide proven typography duplicates, and discover responsive frame groups with breakpoint completion guidance while preserving percentage and fill/hug mappings.", + "date": "2026-10-14T18:39:56+09:00" +} diff --git a/.changepacks/changepack_log_nested_asset_exports.json b/.changepacks/changepack_log_nested_asset_exports.json new file mode 100644 index 00000000..3f3a4d63 --- /dev/null +++ b/.changepacks/changepack_log_nested_asset_exports.json @@ -0,0 +1,8 @@ +{ + "changes": { + "crates/devup-mcp-figma/Cargo.toml": "Patch", + "crates/devup-mcp/Cargo.toml": "Patch" + }, + "note": "Nested instance IDs now parse and round-trip through encoded Figma and bridge URLs, enabling an asset-only export of an instance descendant. Asset exports retain the real lookup/render exception, node type, elapsed time and bounded native/fallback stage diagnostics; deterministic renderer failures are no longer advertised as retryable and failed manifests omit path/outputPath. DEVUP_ASSET_TIMEOUT_MS configures a bounded per-asset lookup/render budget (default 90000 ms, range 1000–600000), also carried through export fragment reads; polling reports elapsed time on running calls. A hung SVG renderer leaves time for a read-only fallback using the requested subtree's actual rendered fill geometry, centered stroke outlines, colors, opacity, winding rules and transforms. Unsupported masks, clipping, gradients, effects, text, non-center/dashed strokes and ambiguous bounds fail explicitly rather than changing the picture; native export and normal PNG/PDF assets stay unchanged. Fragment re-exports use the same fallback and preserve its original failure evidence. Rebuild and reopen the plugin bundle, and update the bridge host, to enable the new script and timeout behavior. Instance snapshots additionally collect async main-component identity and preserve lookup errors for shared-component review. Live Prime SVG, PNG at scale 2 and PDF exports were byte-identical before/after the server change; the historical repeated renderer failure did not recur in this session and its cause remains unproven.", + "date": "2026-10-14T18:50:00+09:00" +} diff --git a/.gitignore b/.gitignore index 547c6ad4..b8d20e7f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ # directory, which was cleared overnight and took the previous copies with it. /fixtures/local-tools/ +/docs/ +/feedback.txt +/devup-mcp-feedback/ diff --git a/Cargo.lock b/Cargo.lock index 8991650f..789a66bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -714,8 +714,10 @@ dependencies = [ "image", "oxc_allocator", "oxc_ast", + "oxc_ast_visit", "oxc_parser", "oxc_span", + "quick-xml", "rand 0.10.2", "reqwest", "rmcp", diff --git a/README.md b/README.md index c426a431..e611d0a0 100644 --- a/README.md +++ b/README.md @@ -615,8 +615,16 @@ codex mcp add figma --url https://mcp.figma.com/mcp 결과에는 `tsx`와 함께 `status`, `quality`, `cache`, `collection`, `source`가 포함됩니다. import 목록과 사용 token은 별도 키로 보내지 않습니다 — 각각 TSX의 첫 줄과 본문의 `$token`이 이미 같은 내용을 담고 있어, 응답에 두 번 싣는 만큼이 그대로 낭비였습니다. Auto Layout은 `Flex`, 일반 container는 `Box`, text는 `Text`로 변환하고 theme binding이 있으면 JSX prop에서 `$token`을 우선 사용합니다. 변수 token은 비어 있지 않은 Figma `codeSyntax.WEB`을 우선하고, 없으면 변수 경로의 마지막 이름을 정규화합니다. 따라서 TSX의 `$token`, `devup.json` key와 source map이 같은 이름을 사용합니다. `rootLayout` 기본값인 `standalone`은 선택한 root의 크기·위치 제약까지 포함하고 Figma instance의 실제 자식 상태를 펼쳐 정의되지 않은 component 참조를 만들지 않습니다. 이미 레이아웃을 소유한 React 부모 안에 삽입할 때는 `rootLayout: "embedded"`로 root의 외부 크기·위치 제약만 생략합니다. +대형 화면의 진단 details와 verdict/layout 메타데이터는 `metadataDelivery.uri`가 가리키는 `projection-details.json` resource로 전달할 수 있습니다. inline에는 이슈 개수·ID·severity와 검토 안내를 유지하고, 각 `fullDetailsResource.jsonPointer` 또는 `metadataPreviewFields`의 pointer로 원본을 찾습니다. preview의 생략 개수·상한은 명시하며, 필수 체크리스트에 따라 resource를 읽기 전에는 검증을 완료로 보고하지 마십시오. 코드·theme·에셋 파일과 resource byte/hash 계약은 동일합니다. + ### Figma → devup.json +`outputs: ["tsx", "devupJson"]`와 `devupJsonMode: "used-only"`를 함께 지정하면 생성 코드가 실제 참조하는 토큰과 typography 의존성만 반환합니다. `projectRoot`에 기존 theme가 있으면 `themeDiff`가 없는 정의와 이름 충돌을 보고하고, `devupJsonMode: "missing-only"`는 기존 값을 보존한 추가분만 반환합니다. 기본값은 `full`입니다. 읽을 수 없는 스타일은 `skippedStyles`와 이름·노드 ID가 있는 경고로 분리하며, strict 모드는 거절합니다. 변수/미지정 fontSize의 백분율 lineHeight는 문자열로 보존합니다. + +코드 출력은 정적입니다. `interactiveCandidates`, 프로토타입/상태 TSX 주석, `ambiguityQuestions`, 필수 `reviewChecklist`를 실제 라우트와 동작에 연결하고 `devup_ui_validate`로 검증해야 합니다. `projectRoot`는 기존 UI/라우트와 환경 키 **이름**만 병합합니다. `devup_project_context`의 `routes` scope와 validator의 `renderedHtml` href 집계는 정적 증거이며 클릭 성공을 증명하지 않습니다. 고정 navigation 밑줄은 활성 상태를 단정하지 않고 info 검토 후보로 보고합니다. `responsiveGroups`와 `breakpointCompletion`은 export한 폭과 남은 폭을 구분하며, 구현/시각 검증은 별도입니다. + +중첩 `I44:8533;10:973` 노드 ID도 URL/assetRequests로 지정할 수 있습니다. SVG native export 실패 시 지원되는 실제 subtree geometry만 read-only로 복구하며, 지원되지 않는 mask/paint/effect는 실패 원인을 보존합니다. 실패한 에셋에는 생성 경로를 표시하지 않습니다. `DEVUP_ASSET_TIMEOUT_MS`는 1000–600000 ms 범위이며 기본값은 90000 ms입니다. 진단에 단계별 시간이 포함되며 Figma 내부 renderer timeout 자체를 바꾸지는 않습니다. 새 스크립트를 적용하려면 갱신된 bridge host와 재빌드한 plugin을 함께 사용하십시오. + ```json { "url": "https://www.figma.com/design//?node-id=1-2", @@ -701,7 +709,7 @@ devup://artifact/{artifactId}/outputs/{outputId}/chunks/{index} 모든 공개 TSX generator는 반환 전에 Rust의 고정된 TypeScript+JSX parser를 통과합니다. parser 오류는 디자인 원문을 노출하지 않고 byte range와 오류 category만 반환합니다. 응답의 `fidelity`는 생성된 mapping 수가 아니라 수집한 source snapshot에서 독립적으로 계산한 node/text segment/variable/style/asset/layout 기대 집합을 분모로 사용하고, 내부 validator가 각 항목을 `emitted | flattened | ignored` 중 정확히 하나로 검증했는지와 축별 coverage·typed impact count를 담습니다. component set, non-default variant selector와 inline instance도 최종 변환 후 source identity별 provenance를 다시 만들며, 반복된 동일 text segment는 하나의 mapping을 재사용하지 않고 occurrence별로 소비하고 multiline·중첩 text와 asset identity를 검증합니다. 알 수 없는 codegen warning/error도 각각 최소 `approximated`/`failed`로 보수적으로 판정하며, `strict`는 syntax, source-derived trace coverage, lossy/failed impact를 함께 검사합니다. -브라우저 시각 회귀는 MCP 서버가 임의 명령을 실행하지 않고 소비자 repository가 실제 font/asset/DevupUI 환경으로 `actual.png`를 만든 뒤 순수 Rust `devup-mcp-visual`로 비교합니다. renderer pinning, 기본 0.5% threshold, diff PNG와 개인정보 취급 계약은 [`docs/visual-renderer-contract.md`](docs/visual-renderer-contract.md)에 있습니다. +브라우저 시각 회귀는 MCP 서버가 임의 명령을 실행하지 않고 소비자 repository가 실제 font/asset/DevupUI 환경으로 `actual.png`를 만든 뒤 순수 Rust `devup-mcp-visual`로 비교합니다. ### 렌더링 하네스 — 생성 코드를 Figma가 그린 PNG와 비교 @@ -733,7 +741,7 @@ node scripts/render.mjs # 빌드·캡처·비교, 화면별 임계 Section 링크에서 TSX를 요청하면 먼저 내부 screen frame 후보와 canonical URL을 `selection_required`로 반환합니다. `frameIds`로 검토한 frame만 고르거나 `allScreens: true`로 모든 화면을 시각 순서대로 batch export할 수 있으며 두 옵션은 동시에 사용할 수 없습니다. `sourceMap`은 `nodeId`·원본 `property`·`generatedProperty`·`resolution`으로 필드→생성 속성을 설명하는 sidecar입니다. TSX 문자/바이트 오프셋은 제공하지 않습니다. 생성 코드 발췌는 진단의 `generatedSource`를 확인합니다. devup.json은 JSON pointer를 사용합니다. `assetManifest`는 image hash/vector/export provenance를 항상 열거하고, `assetRequests`로 명시한 항목만 권장 1–3개, 최대 6개·scale 1~4 범위에서 read-only SVG/PNG export합니다. `assetRequests`를 쓰는 호출은 `outputs`에 `assetManifest`가 함께 있어야 하며, 빠뜨리면 거절됩니다. `outputPath`를 지정하면 binary를 해당 파일로 디코딩하고 응답의 base64를 제거하며, 생략하면 후속 소비를 위해 base64가 memory-only artifact와 해당 MCP 응답에 남을 수 있습니다. -자산 6개 초과 요청은 인증·수집·파일 쓰기 전에 거절하며 `recommendedBatchSize`, `maxAssetCount`, `recommendedAssetRequests`, `remainingAssetRequests`로 분할을 안내합니다. 느린 자산 호출은 1초 대기 후 `assetJob.jobId`, 단계별 호출 기록, 자산별 수집/파일 상태를 먼저 반환합니다. `{"jobId":"..."}`로 조회하고, `paused`이면 `{"jobId":"...","jobAction":"resume"}`로 미완료 호출만 재개합니다. 동일 인자를 다시 보내면 유실된 최초 응답의 작업을 찾습니다. 클라이언트 타임아웃은 작업을 취소하지 않습니다. 체크포인트는 **같은 서버 프로세스에서 최대 30분**, 완료 결과는 5분 보존하며 서버 재시작 후에는 복구되지 않습니다. 개별 upstream 호출은 90초에 일시정지합니다. 자산 `exported`는 검증된 바이트 수집이며, `fileState=written`은 실제 파일 바이트까지 확인한 결과입니다. 상세 계약은 [R3 자산 작업과 근거](docs/r3-asset-jobs-and-evidence.md)를 참조하세요. +자산 6개 초과 요청은 인증·수집·파일 쓰기 전에 거절하며 `recommendedBatchSize`, `maxAssetCount`, `recommendedAssetRequests`, `remainingAssetRequests`로 분할을 안내합니다. 느린 자산 호출은 1초 대기 후 `assetJob.jobId`, 단계별 호출 기록, 자산별 수집/파일 상태를 먼저 반환합니다. `{"jobId":"..."}`로 조회하고, `paused`이면 `{"jobId":"...","jobAction":"resume"}`로 미완료 호출만 재개합니다. 동일 인자를 다시 보내면 유실된 최초 응답의 작업을 찾습니다. 클라이언트 타임아웃은 작업을 취소하지 않습니다. 체크포인트는 **같은 서버 프로세스에서 최대 30분**, 완료 결과는 5분 보존하며 서버 재시작 후에는 복구되지 않습니다. 개별 upstream 호출은 90초에 일시정지합니다. 자산 `exported`는 검증된 바이트 수집이며, `fileState=written`은 실제 파일 바이트까지 확인한 결과입니다. **`assetManifest`는 목록일 뿐 파일을 쓰지 않습니다.** 파일을 얻으려면 `assetRequests`로 다시 부르되, 그 호출은 **`artifactId`가 아니라 원래 `url`로** 해야 합니다. 자산 캡처 없이 수집된 artifact는 자산 요청을 처리할 수 없기 때문입니다. 그 조합으로 부르면 무엇이 없는지와 어떻게 복구하는지를 담아 거절합니다: @@ -1011,7 +1019,7 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 **이 도구는 스스로 그리지 않습니다.** 브라우저를 띄우지도, 명령을 실행하지도 않습니다. `actual.png`는 소비자 repository가 자기 font·asset·DevupUI 환경에서 만들어 건네는 것이고, 여기서는 순수 Rust로 픽셀만 비교합니다. 경로는 allowlist 안이어야 합니다. -그래서 **통과 여부보다 판정 근거가 중요합니다.** renderer 환경 manifest가 없거나 불완전하거나 유효하지 않으면, 픽셀이 일치해 `visual.passed`가 `true`여도 최종 판정은 `inconclusive`입니다 — 무엇으로 그린 그림인지 모르는 채 "같다"고 말하는 것은 보증이 아니기 때문입니다. renderer pinning과 환경 manifest 계약은 [`docs/visual-renderer-contract.md`](docs/visual-renderer-contract.md)에 있습니다. +그래서 **통과 여부보다 판정 근거가 중요합니다.** renderer 환경 manifest가 없거나 불완전하거나 유효하지 않으면, 픽셀이 일치해 `visual.passed`가 `true`여도 최종 판정은 `inconclusive`입니다 — 무엇으로 그린 그림인지 모르는 채 "같다"고 말하는 것은 보증이 아니기 때문입니다. 서버가 스스로 증명할 수 있는 것은 서버가 채웁니다. reference를 `artifactId`로 주고 manifest에 `referenceResource`를 빠뜨리면, 이 서버가 Figma에서 받아 들고 있는 그 PNG의 해시로 채우고 `environment.filledByServer`에 적습니다. 경로로 준 reference는 소비자의 파일이라 채우지 않습니다. 나머지 manifest 항목은 여전히 소비자가 말해야 합니다. @@ -1037,7 +1045,7 @@ snapshot에 없는 목적지(legacy 경로, 다중 루트 요청)는 조용히 `ingest`는 그린 폭에서 찍혀 검증된 스크린샷마다 **바로 부를 수 있는 `devup_visual_compare` 호출**을 `visualComparisons[]`로 돌려줍니다. audit이 아는 것 — 스크린샷 경로(`actual`·`actualPng`), viewport, 실행기가 신고한 브라우저 버전(`renderer`), OS·locale·timezone, 그 run에 환경 오류가 있었는지(`environmentStatus`) — 은 채워져 있고, 소비자만 말할 수 있는 것(reference의 `artifactId`, `tsxResource`, `themePath`, `assetDirectory`, `devupUiVersion`, `fontManifest`)은 `consumerMustAdd`가 이름으로 댑니다. 그대로 불러도 `inconclusive`가 될 사정 — scale 1이 아닌 캡처, viewport보다 긴 페이지의 full-page 캡처, 환경 오류 — 은 `blockers`가 미리 말합니다. 예전에는 audit 스크린샷을 비교기에 넣으면 이 항목들이 비어 언제나 `inconclusive`였습니다. -360·768·1200·1440 폭 × 화면 2개 × 검사 8개 계획에서 `plan`은 4.5 ms(structured content 9.2 KB), 스크린샷 8장을 확인하는 `ingest`는 18.9 ms(12.5 KB)였습니다. 중단된 실행 이어가기(resume), 부수효과가 있는 단계, 데모 녹화·TTS·자막, 인계 패키지는 구현하지 않았습니다. 계약 전체는 [`docs/runtime-audit-contract.md`](docs/runtime-audit-contract.md)에 있습니다. +360·768·1200·1440 폭 × 화면 2개 × 검사 8개 계획에서 `plan`은 4.5 ms(structured content 9.2 KB), 스크린샷 8장을 확인하는 `ingest`는 18.9 ms(12.5 KB)였습니다. 중단된 실행 이어가기(resume), 부수효과가 있는 단계, 데모 녹화·TTS·자막, 인계 패키지는 구현하지 않았습니다. ## 읽기 전용·개인정보 보호 @@ -1083,9 +1091,7 @@ Figma Remote MCP에서는 `JSON_REST_V1` export가 허용되지 않으므로 hos - vector, mask, image, absolute layout과 일부 effect는 diagnostics를 포함한 제한적 fallback입니다. - Figma Remote MCP의 `use_figma` tool contract가 바뀌면 live smoke test와 adapter 갱신이 필요합니다. -상세 설계는 [`docs/superpowers/specs/2026-08-30-figma-remote-mcp-design.md`](docs/superpowers/specs/2026-08-30-figma-remote-mcp-design.md)를 참고하세요. -`docs/superpowers/` 아래의 plan·spec은 **작성 시점의 기록**이지 현재 API 문서가 아닙니다. 예를 들어 위에서 제거했다고 적은 `devup_figma_to_ui`/`devup_figma_to_json`을 그 문서들은 아직 현재 도구처럼 기술합니다. 현재 도구 목록과 동작의 기준은 이 README와 서버가 게시하는 스키마·tool description입니다. ### 생성 코드의 배치 계약 @@ -1101,7 +1107,7 @@ absolute 자식이 있는 프레임은 생성 코드가 containing block을 만 `quality.assets`는 자산 바이트 수집 품질이며 manifest 생성 성공을 뜻하지 않습니다. `assetSummary.description`과 `excludedCount`가 미수집·부분 수집·숨김 제외를 설명합니다. 바이트 수집을 요청하지 않았다면 `not-collected` 요약과 `quality.assets: not-requested`가 함께 올 수 있습니다. -배치 응답의 `recommendedBatchSize`는 최대 3, `maxFrameCount`는 6, `maxFrameOutputUnits`는 12입니다. 자세한 정의는 [R1 응답 계약](docs/r1-result-contract.md)을 참고하세요. +배치 응답의 `recommendedBatchSize`는 최대 3, `maxFrameCount`는 6, `maxFrameOutputUnits`는 12입니다. ### R8 provenance, scrolling and acquisition @@ -1143,7 +1149,7 @@ R16의 `sourceMap.resolutionSemantics`는 이 라벨 사전과 `axis="mapping-me R16은 실패한 치수 증명의 `blockedBy`를 추가합니다. 부모 폭 불명(`parent-width-unknown`), 부모 폭 불일치(`parent-width-unequal`), 읽기 오류(`read-error`), 비FIXED sizing(`non-fixed-sizing`), dimension 충돌(`conflicting-dimension-props`)을 구분합니다. 성공 시 null이며 widthPreservation에도 같은 값이 들어갑니다. 첫 차단 이유만 보고하므로 나머지 조건의 통과나 다른 실패 분기의 실행을 뜻하지 않습니다. 자산은 부모 percentage 증명 대신 실제 선택된 render-boundary 증명의 실패 이유를 보고합니다. -최종 응답과 각 frame의 `verdictScope`는 status/projection을 그대로 표시하고 `statusCauses`와 `projectionCauses`로 원인을 요약합니다. projection 원인은 nodeId/screenId/output/code/property와 미해결 component를 가리키므로, width/height가 verified여도 horizontal/vertical이 approximated인 화면의 전체 판정을 바로 설명합니다. `diagnosticIndex`는 같은 객체의 projectionIssues 인덱스입니다. includeDiagnostics=false 및 resource delivery에도 요약이 유지됩니다. 자세한 범위는 [R16 응답 계약](docs/r16/response-contract.md)을 참고합니다. +최종 응답과 각 frame의 `verdictScope`는 status/projection을 그대로 표시하고 `statusCauses`와 `projectionCauses`로 원인을 요약합니다. projection 원인은 nodeId/screenId/output/code/property와 미해결 component를 가리키므로, width/height가 verified여도 horizontal/vertical이 approximated인 화면의 전체 판정을 바로 설명합니다. `diagnosticIndex`는 같은 객체의 projectionIssues 인덱스입니다. includeDiagnostics=false 및 resource delivery에도 요약이 유지됩니다. ABSOLUTE 진단의 `components`는 height/width/horizontal/vertical별 state·fidelityImpact·원본 값·생성 값·검증 이유를 제공합니다. `resolutionConditions`에는 남은 근사 항목의 해소 조건만 들어갑니다. CENTER는 원본 중심 오프셋, left/top 50%, translate 및 실제 생성 부모 좌표 기준까지 확인해야 검증됩니다. MIN은 원본 오프셋과 생성 px를 비교합니다. MAX는 크기 검증을 전제로 parentSize-offset-size와 right/bottom 여백을 비교합니다. 제약 필드 전체가 없는 이전 캡처는 `constraintDeclared=false`로 표시하며 생성된 로컬 오프셋만 비교하고 반응형 제약을 추정하지 않습니다. 증명되지 않은 MAX/STRETCH/SCALE, 누락 geometry 또는 필드 오류는 근사로 남습니다. 명시적 FIXED 높이 보존은 percentage 너비의 동등성을 증명하지 않습니다. diff --git a/crates/devup-mcp-devup-ui/src/codegen/component.rs b/crates/devup-mcp-devup-ui/src/codegen/component.rs index b748b9e8..0a0a7cb2 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/component.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/component.rs @@ -28,6 +28,9 @@ pub struct CodegenOptions { pub include_diagnostics: bool, pub inline_instances: bool, pub text_style_tokens: std::collections::BTreeMap, + /// Materialized typography values from the same resources as devup.json. + /// Names alone cannot prove that a local font property is redundant. + pub typography_values: BTreeMap, pub variable_tokens: std::collections::BTreeMap, pub root_layout: RootLayout, /// Name every asset after the node it came from, rather than after its @@ -74,6 +77,36 @@ impl CodegenOptions { self.text_style_tokens = named_tokens(styles, "styles"); self.variable_tokens = named_tokens(variables, "variables"); self.length_tokens = length_tokens(variables); + let mut snapshot = variables + .and_then(|result| crate::theme::variable_snapshot_from_result(result).ok()) + .unwrap_or_else(|| { + serde_json::from_value(serde_json::json!({})).expect("empty variable snapshot") + }); + if let Some(styles) = + styles.and_then(|result| crate::theme::variable_snapshot_from_result(result).ok()) + { + for style in styles.styles { + if !snapshot + .styles + .iter() + .any(|existing| existing.id == style.id) + { + snapshot.styles.push(style); + } + } + } + self.typography_values = + crate::theme::generate_devup_json(&snapshot, crate::theme::ThemeScope::File) + .ok() + .and_then(|output| serde_json::from_str::(&output.json).ok()) + .and_then(|value| { + value + .pointer("/theme/typography") + .and_then(serde_json::Value::as_object) + .cloned() + }) + .map(|values| values.into_iter().collect()) + .unwrap_or_default(); self } } @@ -1073,6 +1106,7 @@ fn generate_node_marked( asset_names_per_node: options.asset_names_per_node, inline_instances: options.inline_instances, text_style_tokens: options.text_style_tokens.clone(), + typography_values: options.typography_values.clone(), variable_tokens: options.variable_tokens.clone(), length_tokens: options.length_tokens.clone(), root_layout: options.root_layout, @@ -1419,6 +1453,7 @@ struct Context { inline_instances: bool, instance_boolean_properties: Vec>, text_style_tokens: std::collections::BTreeMap, + typography_values: BTreeMap, variable_tokens: std::collections::BTreeMap, length_tokens: BTreeMap, root_layout: RootLayout, @@ -1697,6 +1732,7 @@ fn render_node( text::push_text_props( &view, &context.text_style_tokens, + &context.typography_values, &context.variable_tokens, &mut context.used_tokens, &mut props, @@ -1794,6 +1830,7 @@ fn render_node( let children = text::render_text_children( &view, &context.text_style_tokens, + &context.typography_values, &context.variable_tokens, &mut context.used_tokens, depth + 1, 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 aef13639..089e4a11 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/property_derivations.rs @@ -52,6 +52,7 @@ pub(crate) fn property_derivations( "text" => text::push_text_props( &node.typed_view(), &options.text_style_tokens, + &options.typography_values, &options.variable_tokens, &mut tokens, &mut props, diff --git a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs index f0cdd762..1008fe4b 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/responsive.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/responsive.rs @@ -20,9 +20,7 @@ //! therefore a report, not a switch. //! //! The rules here are ported from `devup-figma-plugin` at the commit this -//! repo's corpus pins — `src/codegen/responsive/` — and `docs/ -//! responsive-merge-rules.md` says which function each came from and where -//! this repo deliberately differs. +//! repo's corpus pins — `src/codegen/responsive/`. use std::collections::{BTreeMap, BTreeSet, VecDeque}; @@ -284,7 +282,24 @@ impl DivergenceReason { fn rank_of(name: &str) -> Option { let name = name.trim().to_ascii_lowercase(); - BREAKPOINT_NAMES.iter().position(|known| *known == name) + BREAKPOINT_NAMES + .iter() + .position(|known| *known == name) + .or_else(|| { + // Some Sections label their middle design by a width range instead + // of "tablet". Recognize only explicit numeric ranges; a number in + // an unrelated screen name is not a breakpoint declaration. + let (start, end) = name.split_once(['~', '–'])?; + let (start, end) = ( + start.trim().parse::().ok()?, + end.trim().parse::().ok()?, + ); + (start > 0 && end >= start).then_some(1) + }) +} + +pub fn is_breakpoint_name(name: &str) -> bool { + rank_of(name).is_some() } /// Whether roots with these names and widths could be a breakpoint family. diff --git a/crates/devup-mcp-devup-ui/src/codegen/text.rs b/crates/devup-mcp-devup-ui/src/codegen/text.rs index eb5c4137..d0e14e42 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/text.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/text.rs @@ -12,6 +12,7 @@ use super::{ pub(super) fn push_text_props( view: &TypedNode<'_>, text_style_tokens: &BTreeMap, + typography_values: &BTreeMap, variable_tokens: &BTreeMap, used_tokens: &mut BTreeSet, props: &mut Vec, @@ -22,6 +23,7 @@ pub(super) fn push_text_props( let segment = default_segment(view); let typography = segment .and_then(|value| value.get("textStyleId")) + .or_else(|| view.value("textStyleId")) .and_then(Value::as_str) .and_then(|id| text_style_tokens.get(id)); let value = |field: &str| { @@ -146,6 +148,9 @@ pub(super) fn push_text_props( ); string_prop(props, "paintOrder", "stroke fill"); } + if let Some(value) = typography.and_then(|token| typography_values.get(token)) { + elide_typography_duplicates(props, value); + } if let Some(list) = segment .and_then(|value| value.get("listOptions")) .and_then(Value::as_object) @@ -162,6 +167,40 @@ pub(super) fn push_text_props( } } +fn elide_typography_duplicates(props: &mut Vec, typography: &Value) { + // A responsive property can be omitted only when every effective slot + // defines that same value, including the first width. Never select an + // active viewport from a text node's own width. + let values = match typography { + Value::Object(value) => vec![value], + Value::Array(slots) if slots.first().is_some_and(Value::is_object) => { + let Some(values) = slots + .iter() + .filter(|slot| !slot.is_null()) + .map(Value::as_object) + .collect::>>() + else { + return; + }; + values + } + _ => return, + }; + props.retain(|(name, PropValue::String(value))| { + if !matches!(name.as_str(), "fontSize" | "fontWeight" | "lineHeight") { + return true; + } + !values.iter().all(|values| { + values.get(name).is_some_and(|expected| { + expected.as_str().is_some_and(|expected| expected == value) + || expected + .as_f64() + .is_some_and(|expected| value.parse::().ok() == Some(expected)) + }) + }) + }); +} + fn is_resolved_value(value: &Value) -> bool { !value.as_object().is_some_and(|object| { ["$unsupported", "$undefined", "$truncated", "$error"] @@ -279,6 +318,7 @@ fn replace_prop(props: &mut Vec, name: &str, value: String) { pub(super) fn render_text_children( view: &TypedNode<'_>, text_style_tokens: &BTreeMap, + typography_values: &BTreeMap, variable_tokens: &BTreeMap, used_tokens: &mut BTreeSet, depth: usize, @@ -337,6 +377,18 @@ pub(super) fn render_text_children( .iter() .any(|(default_name, default_value)| default_name == name && default_value == value) }); + // A different run style establishes its own metrics. Equal metrics + // can be omitted there; same-style runs keep local overrides relative + // to the parent, even when a run happens to equal the token again. + if segment_props.iter().any(|(name, _)| name == "typography") + && let Some(value) = segment + .get("textStyleId") + .and_then(Value::as_str) + .and_then(|id| text_style_tokens.get(id)) + .and_then(|token| typography_values.get(token)) + { + elide_typography_duplicates(&mut segment_props, value); + } let content = escape_jsx_text(value); if segment_props.is_empty() { if content.is_empty() { @@ -648,6 +700,20 @@ pub(super) fn whitespace_collapse_diagnostic( if !collapses { return None; } + let mut details = serde_json::json!({ + "originalValue": characters.chars().take(800).collect::(), + "classification": "css-whitespace-collapse", + "whiteSpace": if view.number("maxLines") == Some(1.0) { "nowrap" } else { "normal" }, + "nextAction": "Review intentional spaces or tabs in Figma and choose an explicit whitespace-preserving style if required." + }); + let count = characters.chars().count(); + if count > 800 { + details["originalValueTruncated"] = serde_json::json!(true); + details["originalValueCharCount"] = serde_json::json!(count); + details["fullValueSource"] = serde_json::json!( + "The original node characters in rawSnapshot; generated TSX retains the complete text." + ); + } Some(devup_mcp_figma::Diagnostic { code: "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE".into(), node_id: Some(view.id().to_owned()), @@ -655,12 +721,7 @@ pub(super) fn whitespace_collapse_diagnostic( message: "Text characters are retained in JSX, but CSS collapses source spaces or tabs." .into(), fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), - details: Some(serde_json::json!({ - "originalValue": characters, - "classification": "css-whitespace-collapse", - "whiteSpace": if view.number("maxLines") == Some(1.0) { "nowrap" } else { "normal" }, - "nextAction": "Review intentional spaces or tabs in Figma and choose an explicit whitespace-preserving style if required." - })), + details: Some(details), ..devup_mcp_figma::Diagnostic::default() }) } diff --git a/crates/devup-mcp-devup-ui/src/codegen/variant.rs b/crates/devup-mcp-devup-ui/src/codegen/variant.rs index 536e9277..b2e1bfec 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/variant.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/variant.rs @@ -546,6 +546,7 @@ fn project_tree_inner( text::push_text_props( &view, &options.text_style_tokens, + &options.typography_values, &options.variable_tokens, &mut used_tokens, &mut props, @@ -596,6 +597,7 @@ fn project_tree_inner( text::render_text_children( &view, &options.text_style_tokens, + &options.typography_values, &options.variable_tokens, &mut used_tokens, 0, diff --git a/crates/devup-mcp-devup-ui/src/theme/devup_json.rs b/crates/devup-mcp-devup-ui/src/theme/devup_json.rs index ce52b349..0fecc0c6 100644 --- a/crates/devup-mcp-devup-ui/src/theme/devup_json.rs +++ b/crates/devup-mcp-devup-ui/src/theme/devup_json.rs @@ -102,9 +102,21 @@ pub struct ThemeOutput { pub diagnostics: Vec, pub conflicts: Vec, pub unresolved_variables: Vec, + #[serde(default)] + pub skipped_styles: Vec, pub source_map: SourceMap, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ThemeSkippedStyle { + pub style_id: String, + pub name: String, + pub reason: String, + #[serde(default)] + pub node_ids: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ThemeUnresolvedVariable { @@ -167,6 +179,7 @@ pub fn generate_devup_json( let mut diagnostics = Vec::new(); let mut conflicts = Vec::new(); let mut unresolved_variables = Vec::new(); + let mut skipped_styles = Vec::new(); let mut source_entries = Vec::new(); let collections = snapshot .collections @@ -438,6 +451,26 @@ pub fn generate_devup_json( }) .collect::>(); for style in &styles { + if style.style_type == "TEXT" + && let Some(reason) = typography_problem(&style.value) + { + skipped_styles.push(ThemeSkippedStyle { + style_id: style.id.clone(), + name: style.name.clone(), + reason: reason.clone(), + node_ids: Vec::new(), + }); + diagnostics.push(Diagnostic { + code: "DEVUP_THEME_STYLE_SKIPPED".into(), + message: format!("Text style '{}' ({}) was skipped: {reason}", style.name, style.id), + resource_kind: Some("style".into()), resource_id: Some(style.id.clone()), + severity: Some(DiagnosticSeverity::Warning), recoverable: Some(true), + fidelity_impact: Some(devup_mcp_figma::FidelityImpact::Lossy), + details: Some(json!({"styleName":style.name,"styleId":style.id,"reason":reason,"output":"devupJson"})), + ..Diagnostic::default() + }); + continue; + } let (level, token) = style_token(&style.name); let level = level.min(5); match style.style_type.as_str() { @@ -673,14 +706,16 @@ pub fn generate_devup_json( })?; output.push('\n'); - let completeness = - if scope == ThemeScope::File && snapshot.local_complete && snapshot.used_remote_complete { - Completeness::FullLocalPlusUsedRemote - } else if variable_count > 0 || !styles.is_empty() { - Completeness::UsedTokens - } else { - Completeness::ResolvedValuesOnly - }; + let completeness = if !skipped_styles.is_empty() || !unresolved_variables.is_empty() { + Completeness::ResolvedValuesOnly + } else if scope == ThemeScope::File && snapshot.local_complete && snapshot.used_remote_complete + { + Completeness::FullLocalPlusUsedRemote + } else if variable_count > 0 || !styles.is_empty() { + Completeness::UsedTokens + } else { + Completeness::ResolvedValuesOnly + }; unresolved_variables.sort_by(|left, right| { left.variable_id .cmp(&right.variable_id) @@ -693,7 +728,7 @@ pub fn generate_devup_json( counts: ThemeCounts { collections: snapshot.collections.len(), variables: variable_count, - styles: styles.len(), + styles: styles.len() - skipped_styles.len(), modes: snapshot .collections .iter() @@ -704,6 +739,7 @@ pub fn generate_devup_json( diagnostics, conflicts, unresolved_variables, + skipped_styles, source_map: SourceMap { version: 1, entries: source_entries, @@ -712,6 +748,42 @@ pub fn generate_devup_json( }) } +// A missing size is representable (including percentage line height). An +// unreadable style or malformed metric is not; isolate it rather than invent +// a default typography entry or reject all the other resources. +fn typography_problem(style: &Value) -> Option { + let Some(object) = style.as_object() else { + return Some("style value is not an object".into()); + }; + fn unreadable(value: &Value) -> bool { + match value { + Value::Object(object) => { + ["$unsupported", "$undefined", "$truncated", "$error"] + .iter() + .any(|marker| object.contains_key(*marker)) + || object.values().any(unreadable) + } + Value::Array(values) => values.iter().any(unreadable), + _ => false, + } + } + if unreadable(style) || object.values().any(unreadable) { + return Some("style contains an unreadable field; recollect the style".into()); + } + for metric in ["lineHeight", "letterSpacing"] { + if let Some(value) = object.get(metric).filter(|v| !v.is_null()) { + let unit = value.get("unit").and_then(Value::as_str); + if unit != Some("AUTO") + && (value.get("value").and_then(Value::as_f64).is_none() + || !matches!(unit, Some("PERCENT" | "PIXELS"))) + { + return Some(format!("{metric} has no representable unit/value")); + } + } + } + None +} + /// A text style's value as devup-ui reads it — the plugin's /// `textStyleToTypography`. /// diff --git a/crates/devup-mcp-devup-ui/src/theme/mod.rs b/crates/devup-mcp-devup-ui/src/theme/mod.rs index 3346bb9e..43468659 100644 --- a/crates/devup-mcp-devup-ui/src/theme/mod.rs +++ b/crates/devup-mcp-devup-ui/src/theme/mod.rs @@ -1,13 +1,15 @@ mod devup_json; mod project_theme; mod tokens; +mod usage; +pub use usage::{TokenUsage, generate_used_devup_json, missing_token_diff, restrict_devup_json}; pub(crate) use tokens::{style_token, variable_token}; pub use devup_json::{ Completeness, ThemeConflict, ThemeConflictCandidate, ThemeCounts, ThemeOutput, ThemeScope, - ThemeUnresolvedVariable, ThemeVariableSource, VariableCollection, VariableDefinition, - VariableMode, VariableSnapshot, VariableStyle, generate_devup_json, + ThemeSkippedStyle, ThemeUnresolvedVariable, ThemeVariableSource, VariableCollection, + VariableDefinition, VariableMode, VariableSnapshot, VariableStyle, generate_devup_json, variable_snapshot_from_result, }; pub use project_theme::{ diff --git a/crates/devup-mcp-devup-ui/src/theme/usage.rs b/crates/devup-mcp-devup-ui/src/theme/usage.rs new file mode 100644 index 00000000..577a70c8 --- /dev/null +++ b/crates/devup-mcp-devup-ui/src/theme/usage.rs @@ -0,0 +1,306 @@ +//! A minimal theme is a projection of actual generated references, including +//! dependencies of typography tokens. Existing project names always win. +use std::collections::BTreeSet; + +use devup_mcp_figma::{DevupError, ErrorCode}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{JSXAttribute, JSXAttributeName, StringLiteral}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use serde::Serialize; +use serde_json::{Value, json}; + +use super::{Completeness, ProjectTheme, ThemeOutput, ThemeScope, VariableSnapshot}; + +#[derive(Debug, Default, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenUsage { + pub tokens: BTreeSet, + pub typography: BTreeSet, +} + +impl TokenUsage { + pub fn add_tsx(&mut self, source: &str) -> Result<(), DevupError> { + struct References<'u> { + usage: &'u mut TokenUsage, + property: Option, + } + impl<'a> Visit<'a> for References<'_> { + fn visit_jsx_attribute(&mut self, attribute: &JSXAttribute<'a>) { + let previous = self.property.take(); + self.property = match &attribute.name { + JSXAttributeName::Identifier(name) => Some(name.name.to_string()), + _ => None, + }; + walk::walk_jsx_attribute(self, attribute); + self.property = previous; + } + fn visit_string_literal(&mut self, literal: &StringLiteral<'a>) { + if self.property.as_deref() == Some("typography") { + self.usage.typography.insert(literal.value.to_string()); + } else if self.property.is_some() { + dollar_references(literal.value.as_str(), &mut self.usage.tokens); + } + } + } + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + if !parsed.diagnostics.is_empty() { + return Err(DevupError::new( + ErrorCode::DevupCodegenFailed, + "Cannot determine used theme tokens from invalid generated TSX.", + false, + )); + } + References { + usage: self, + property: None, + } + .visit_program(&parsed.program); + Ok(()) + } +} + +fn dollar_references(text: &str, tokens: &mut BTreeSet) { + for tail in text.split('$').skip(1) { + let token: String = tail + .chars() + .take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '-')) + .collect(); + if !token.is_empty() { + tokens.insert(token); + } + } +} + +fn dependencies(value: &Value, tokens: &mut BTreeSet) { + match value { + Value::String(text) => dollar_references(text, tokens), + Value::Array(items) => items.iter().for_each(|item| dependencies(item, tokens)), + Value::Object(items) => items.values().for_each(|item| dependencies(item, tokens)), + _ => {} + } +} + +pub fn generate_used_devup_json( + snapshot: &VariableSnapshot, + usage: &TokenUsage, +) -> Result { + let mut selected = snapshot.clone(); + selected.styles.retain(|style| { + usage + .typography + .contains(&super::tokens::style_token(&style.name).1) + || usage + .tokens + .contains(&super::tokens::style_token(&style.name).1) + }); + let variables = snapshot + .variables + .iter() + .chain(&snapshot.used_remote_variables) + .collect::>(); + let mut ids = variables + .iter() + .filter(|variable| { + usage.tokens.contains(&super::tokens::variable_token( + &variable.name, + variable.code_syntax.get("WEB").map(String::as_str), + )) + }) + .map(|variable| variable.id.clone()) + .collect::>(); + fn binding_ids(value: &Value, ids: &mut BTreeSet) { + if let Some(id) = value.get("id").and_then(Value::as_str) { + ids.insert(id.into()); + } + match value { + Value::Object(items) => items.values().for_each(|item| binding_ids(item, ids)), + Value::Array(items) => items.iter().for_each(|item| binding_ids(item, ids)), + _ => {} + } + } + for style in &selected.styles { + if let Some(bound) = style.value.get("boundVariables") { + binding_ids(bound, &mut ids); + } + } + loop { + let before = ids.len(); + for variable in &variables { + if ids.contains(&variable.id) { + for value in variable + .values_by_mode + .values() + .filter(|value| value["type"] == "VARIABLE_ALIAS") + { + binding_ids(value, &mut ids); + } + } + } + if before == ids.len() { + break; + } + } + selected + .variables + .retain(|variable| ids.contains(&variable.id)); + selected + .used_remote_variables + .retain(|variable| ids.contains(&variable.id)); + let mut output = super::generate_devup_json(&selected, ThemeScope::File)?; + restrict_devup_json(&mut output, usage)?; + Ok(output) +} + +fn selected_theme(source: &Value, usage: &TokenUsage, existing: Option<&ProjectTheme>) -> Value { + let mut selected = source.clone(); + let mut needed = usage.tokens.clone(); + if let Some(typography) = source + .pointer("/theme/typography") + .and_then(Value::as_object) + { + for (token, value) in typography { + if usage.typography.contains(token) + && existing.is_none_or(|theme| !theme.typography.contains_key(token)) + { + dependencies(value, &mut needed); + } + } + } + loop { + let before = needed.len(); + for category in ["colors", "length", "shadow"] { + if let Some(modes) = source["theme"][category].as_object() { + for values in modes.values().filter_map(Value::as_object) { + for (token, value) in values { + if needed.contains(token) + && existing.is_none_or(|theme| !theme.contains_token(token)) + { + dependencies(value, &mut needed); + } + } + } + } + } + if before == needed.len() { + break; + } + } + if let Some(tokens) = selected["theme"]["typography"].as_object_mut() { + tokens.retain(|name, _| usage.typography.contains(name)); + } + for category in ["colors", "length", "shadow"] { + if let Some(modes) = selected["theme"][category].as_object_mut() { + for values in modes.values_mut().filter_map(Value::as_object_mut) { + values.retain(|name, _| needed.contains(name)); + } + } + } + selected +} + +pub fn restrict_devup_json(output: &mut ThemeOutput, usage: &TokenUsage) -> Result<(), DevupError> { + let source: Value = serde_json::from_str(&output.json).map_err(|error| { + DevupError::new( + ErrorCode::DevupThemeConflict, + format!("Cannot read generated theme: {error}"), + false, + ) + })?; + let selected = selected_theme(&source, usage, None); + output.source_map.entries.retain(|entry| { + entry + .json_pointer + .as_deref() + .is_some_and(|pointer| selected.pointer(pointer).is_some()) + }); + output.counts.variables = output + .source_map + .entries + .iter() + .filter_map(|entry| entry.variable_id.as_ref()) + .collect::>() + .len(); + output.counts.styles = output + .source_map + .entries + .iter() + .filter_map(|entry| entry.style_id.as_ref()) + .collect::>() + .len(); + output.completeness = Completeness::UsedTokens; + if !output.skipped_styles.is_empty() || !output.unresolved_variables.is_empty() { + output.completeness = Completeness::ResolvedValuesOnly; + } + output.json = format!( + "{}\n", + serde_json::to_string_pretty(&selected).expect("JSON value serializes") + ); + Ok(()) +} + +/// An additive proposal only: values of every existing token are preserved, +/// including existing names whose Figma value differs. No project file is written. +pub fn missing_token_diff( + source: &str, + usage: &TokenUsage, + existing: &ProjectTheme, +) -> Result { + let source: Value = serde_json::from_str(source).map_err(|error| { + DevupError::new( + ErrorCode::DevupThemeConflict, + format!("Cannot read generated theme: {error}"), + false, + ) + })?; + let mut additions = selected_theme(&source, usage, Some(existing)); + let mut missing = Vec::new(); + if let Some(tokens) = additions["theme"]["typography"].as_object_mut() { + tokens.retain(|token, _| !existing.typography.contains_key(token)); + missing.extend( + tokens + .keys() + .map(|name| json!({"category":"typography","token":name})), + ); + } + for (category, defined) in [ + ("colors", &existing.colors), + ("length", &existing.length), + ("shadow", &existing.shadow), + ] { + let mut names = BTreeSet::new(); + if let Some(modes) = additions["theme"][category].as_object_mut() { + for values in modes.values_mut().filter_map(Value::as_object_mut) { + values + .retain(|token, _| !defined.values().any(|tokens| tokens.contains_key(token))); + names.extend(values.keys().cloned()); + } + } + missing.extend( + names + .into_iter() + .map(|name| json!({"category":category,"token":name})), + ); + } + let generated = + super::parse_project_theme(&serde_json::to_string(&source).expect("JSON serializes"))?; + let mut needed = usage.tokens.clone(); + dependencies(&additions, &mut needed); + let unresolved = needed.iter().filter(|token| !existing.contains_token(token) && !generated.contains_token(token)) + .map(|name| json!({"token":format!("${name}"),"reason":"no-collected-definition"})) + .chain(usage.typography.iter().filter(|token| !existing.typography.contains_key(*token) && !generated.typography.contains_key(*token)) + .map(|name| json!({"token":name,"category":"typography","reason":"no-collected-definition"}))).collect::>(); + let conflicts = usage.typography.iter().filter_map(|token| { + let before = existing.typography.get(token)?; + let proposed = generated.typography.get(token)?; + (before != proposed).then(|| json!({"category":"typography","token":token,"existingValue":before,"figmaValue":proposed, + "resolution":"existing-project-value-preserved","fidelity":"unverified"})) + }).collect::>(); + Ok( + json!({"state":if unresolved.is_empty() {"complete"} else {"partial"}, + "additions":additions,"missingTokens":missing,"unresolvedTokens":unresolved, + "existingNamesPreserved":true,"existingTypographyConflicts":conflicts,"visualFidelity":"unverified","writesProjectTheme":false}), + ) +} diff --git a/crates/devup-mcp-devup-ui/tests/feedback_theme.rs b/crates/devup-mcp-devup-ui/tests/feedback_theme.rs new file mode 100644 index 00000000..1f3160ad --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/feedback_theme.rs @@ -0,0 +1,319 @@ +use devup_mcp_devup_ui::{ + codegen::{CodegenOptions, generate_component}, + theme::{ + Completeness, ThemeScope, TokenUsage, VariableSnapshot, generate_devup_json, + missing_token_diff, parse_project_theme, restrict_devup_json, + }, +}; +use devup_mcp_figma::{Snapshot, UpstreamResult}; +use serde_json::{Value, json}; + +fn resources() -> VariableSnapshot { + serde_json::from_value(json!({ + "collections":[{"id":"c","name":"Theme","defaultModeId":"m","modes":[{"modeId":"m","name":"Default"}]}], + "variables":[ + {"id":"brand","name":"primary","resolvedType":"COLOR","variableCollectionId":"c","valuesByMode":{"m":{"r":1,"g":0,"b":0,"a":1}}}, + {"id":"unused","name":"unused","resolvedType":"COLOR","variableCollectionId":"c","valuesByMode":{"m":{"r":0,"g":1,"b":0,"a":1}}}, + {"id":"size","name":"bodySize","resolvedType":"FLOAT","variableCollectionId":"c","valuesByMode":{"m":16}} + ], + "styles":[ + {"id":"body","name":"Body","styleType":"TEXT","value":{"fontName":{"family":"Inter","style":"Regular"},"fontSize":16,"lineHeight":{"unit":"PERCENT","value":140},"boundVariables":{"fontSize":{"id":"size"}}}}, + {"id":"title","name":"Heading","styleType":"TEXT","value":{"fontName":{"family":"Inter","style":"Bold"},"fontSize":32,"lineHeight":{"unit":"PERCENT","value":150}}} + ],"localComplete":true,"usedRemoteComplete":true + })).unwrap() +} + +#[test] +fn unreadable_style_is_isolated_and_percentage_fallback_survives() { + let mut snapshot = resources(); + snapshot.styles.push(serde_json::from_value(json!({"id":"broken","name":"Unreadable heading","styleType":"TEXT","value":{"lineHeight":{"$error":"not read"}}})).unwrap()); + snapshot.styles.push(serde_json::from_value(json!({"id":"missing-size","name":"Caption","styleType":"TEXT","value":{"lineHeight":{"unit":"PERCENT","value":140}}})).unwrap()); + let output = generate_devup_json(&snapshot, ThemeScope::File).unwrap(); + let value: Value = serde_json::from_str(&output.json).unwrap(); + assert_eq!(value["theme"]["typography"]["body"]["lineHeight"], "140%"); + assert_eq!( + value["theme"]["typography"]["caption"]["lineHeight"], + "140%" + ); + assert_eq!( + value["theme"]["typography"]["heading"]["lineHeight"], + "48px" + ); + assert!( + value["theme"]["typography"] + .get("unreadableHeading") + .is_none() + ); + assert_eq!(output.skipped_styles[0].name, "Unreadable heading"); + assert!( + output + .diagnostics + .iter() + .any( + |diagnostic| diagnostic.resource_id.as_deref() == Some("broken") + && diagnostic.message.contains("Unreadable heading") + ) + ); + assert_eq!(output.completeness, Completeness::ResolvedValuesOnly); +} + +#[test] +fn used_only_reads_jsx_attributes_and_includes_typography_dependencies() { + let mut output = generate_devup_json(&resources(), ThemeScope::File).unwrap(); + let mut usage = TokenUsage::default(); + usage.add_tsx(r#"// + export const X = $unused"#).unwrap(); + restrict_devup_json(&mut output, &usage).unwrap(); + let theme: Value = serde_json::from_str(&output.json).unwrap(); + assert!(theme["theme"]["colors"]["default"].get("primary").is_some()); + assert!(theme["theme"]["colors"]["default"].get("unused").is_none()); + assert_eq!(theme["theme"]["length"]["default"]["bodySize"], "16px"); + assert!(theme["theme"]["typography"].get("heading").is_none()); + assert_eq!(output.counts.variables, 2); + assert_eq!(output.counts.styles, 1); + assert!(output.source_map.entries.iter().all(|entry| { + theme + .pointer(entry.json_pointer.as_deref().unwrap()) + .is_some() + })); +} + +#[test] +fn diff_preserves_existing_values_and_reports_typography_conflict() { + let output = generate_devup_json(&resources(), ThemeScope::File).unwrap(); + let existing = parse_project_theme(r##"{"theme":{"colors":{"default":{"primary":"#123456"}},"typography":{"body":{"fontSize":"18px","lineHeight":"150%"}}}}"##).unwrap(); + let mut usage = TokenUsage::default(); + usage + .add_tsx(r#""#) + .unwrap(); + let diff = missing_token_diff(&output.json, &usage, &existing).unwrap(); + assert!( + diff["additions"]["theme"]["colors"]["default"] + .as_object() + .unwrap() + .is_empty() + ); + assert!( + diff["additions"]["theme"]["typography"] + .as_object() + .unwrap() + .is_empty() + ); + assert!( + diff["additions"]["theme"]["length"]["default"] + .as_object() + .unwrap() + .is_empty(), + "an existing body token does not need Figma body's dependencies" + ); + assert_eq!(diff["existingTypographyConflicts"][0]["token"], "body"); + assert_eq!( + diff["existingTypographyConflicts"][0]["existingValue"]["fontSize"], + "18px" + ); + assert_eq!(existing.colors["default"]["primary"], "#123456"); + assert_eq!(diff["visualFidelity"], "unverified"); +} + +#[test] +fn diff_adds_missing_style_dependency_and_names_uncollected_references() { + let output = generate_devup_json(&resources(), ThemeScope::File).unwrap(); + let existing = parse_project_theme("{\"theme\":{}}").unwrap(); + let mut usage = TokenUsage::default(); + usage + .add_tsx(r#""#) + .unwrap(); + let diff = missing_token_diff(&output.json, &usage, &existing).unwrap(); + assert_eq!( + diff["additions"]["theme"]["length"]["default"]["bodySize"], + "16px" + ); + assert_eq!(diff["state"], "partial"); + assert_eq!(diff["unresolvedTokens"].as_array().unwrap().len(), 2); +} + +#[test] +fn used_export_resolves_alias_closure_and_keeps_style_dependencies_outside_used_id_hints() { + let mut snapshot = resources(); + snapshot.used_variable_ids = vec!["brand".into()]; + snapshot.variables[0] + .values_by_mode + .insert("m".into(), json!({"type":"VARIABLE_ALIAS","id":"unused"})); + let mut usage = TokenUsage::default(); + usage + .add_tsx(r#""#) + .unwrap(); + let output = devup_mcp_devup_ui::theme::generate_used_devup_json(&snapshot, &usage).unwrap(); + let theme: Value = serde_json::from_str(&output.json).unwrap(); + assert_eq!(theme["theme"]["colors"]["default"]["primary"], "#00ff00"); + assert!(theme["theme"]["colors"]["default"].get("unused").is_none()); + assert_eq!(theme["theme"]["length"]["default"]["bodySize"], "16px"); + assert!(output.unresolved_variables.is_empty()); +} + +#[test] +fn breakpoint_only_attribute_tokens_survive_used_export() { + let mut usage = TokenUsage::default(); + usage + .add_tsx(r#""#) + .unwrap(); + let output = devup_mcp_devup_ui::theme::generate_used_devup_json(&resources(), &usage).unwrap(); + let theme: Value = serde_json::from_str(&output.json).unwrap(); + assert!(theme["theme"]["colors"]["default"].get("primary").is_some()); + assert!(theme["theme"]["typography"].get("heading").is_some()); + assert!(theme["theme"]["typography"].get("body").is_none()); +} + +#[test] +fn dependency_cycles_terminate_and_missing_dependencies_are_reported() { + let source = r#"{"theme":{"typography":{"body":{"fontSize":"$a"}},"length":{"default":{"a":"$b","b":"$a"}}}}"#; + let mut usage = TokenUsage::default(); + usage.add_tsx(r#""#).unwrap(); + let diff = missing_token_diff(source, &usage, &Default::default()).unwrap(); + assert_eq!(diff["additions"]["theme"]["length"]["default"]["b"], "$a"); + let missing = missing_token_diff( + r#"{"theme":{"typography":{"body":{"fontSize":"$missing"}}}}"#, + &usage, + &Default::default(), + ) + .unwrap(); + assert_eq!(missing["state"], "partial"); + assert_eq!(missing["unresolvedTokens"][0]["token"], "$missing"); +} + +fn text(weight: u32) -> Snapshot { + serde_json::from_value(json!({"fileKey":"test","roots":["t"],"diagnostics":[],"nodes":{ + "t":{"id":"t","type":"TEXT","fields":{"name":"Title","textStyleId":"title","characters":"Example", + "fontSize":32,"fontWeight":weight,"lineHeight":{"unit":"PERCENT","value":150}, + "layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","textTruncation":"DISABLED"}}}})).unwrap() +} + +#[test] +fn collected_equal_typography_properties_are_elided_but_local_overrides_stay() { + let raw = serde_json::to_value(resources()).unwrap(); + let result = UpstreamResult { raw }; + let options = CodegenOptions::default().with_resource_results(Some(&result), Some(&result)); + let output = generate_component(&text(700), "t", &options).unwrap(); + assert!(output.tsx.contains("typography=\"heading\"")); + for property in ["fontSize=", "fontWeight=", "lineHeight="] { + assert!(!output.tsx.contains(property), "{}", output.tsx); + } + let overridden = generate_component(&text(400), "t", &options).unwrap(); + assert!( + overridden.tsx.contains("fontWeight=\"400\""), + "{}", + overridden.tsx + ); + let names_only = CodegenOptions { + text_style_tokens: options.text_style_tokens, + ..Default::default() + }; + assert!( + generate_component(&text(700), "t", &names_only) + .unwrap() + .tsx + .contains("fontSize=\"32px\"") + ); +} + +#[test] +fn responsive_typography_elides_only_properties_equal_at_every_slot() { + let options = CodegenOptions { + text_style_tokens: [("title".into(), "heading".into())].into(), + typography_values: [( + "heading".into(), + json!([{"fontSize":"16px","fontWeight":700},null,{"fontSize":"32px","fontWeight":700}]), + )] + .into(), + ..Default::default() + }; + let output = generate_component(&text(700), "t", &options).unwrap(); + assert!(!output.tsx.contains("fontWeight="), "{}", output.tsx); + assert!(output.tsx.contains("fontSize=\"32px\""), "{}", output.tsx); + assert!( + generate_component(&text(400), "t", &options) + .unwrap() + .tsx + .contains("fontWeight=\"400\"") + ); +} + +#[test] +fn a_different_rich_text_token_elides_equal_metrics_without_losing_overrides() { + let result = UpstreamResult { + raw: serde_json::to_value(resources()).unwrap(), + }; + let options = CodegenOptions::default().with_resource_results(Some(&result), Some(&result)); + let mut snapshot = text(700); + let fields = &mut snapshot.nodes.get_mut("t").unwrap().fields; + for name in ["fontSize", "fontWeight", "lineHeight", "textStyleId"] { + fields.remove(name); + } + fields.insert("styledTextSegments".into(), json!([ + {"characters":"Title","textStyleId":"title","fontSize":32,"fontWeight":700,"lineHeight":{"unit":"PERCENT","value":150}}, + {"characters":" longer body paragraph","textStyleId":"body","fontSize":16,"fontWeight":400,"lineHeight":{"unit":"PERCENT","value":140}} + ])); + fields.insert("characters".into(), json!("Title longer body paragraph")); + let output = generate_component(&snapshot, "t", &options).unwrap(); + let tag = output + .tsx + .split("') + .next() + .unwrap(); + for field in ["fontSize=", "fontWeight=", "lineHeight="] { + assert!(!tag.contains(field), "{tag}"); + } +} + +#[test] +fn six_award_cards_keep_main_axis_fill_and_cross_axis_hug() { + let cards = (0..6).map(|i| format!("card{i}")).collect::>(); + let mut nodes = serde_json::Map::new(); + nodes.insert("root".into(), json!({"id":"root","type":"FRAME","fields":{"name":"Awards","width":1260,"height":40, + "layoutMode":"HORIZONTAL","layoutSizingHorizontal":"FIXED","layoutSizingVertical":"HUG","counterAxisAlignItems":"CENTER","childrenIds":cards}})); + for (i, id) in cards.iter().enumerate() { + let child = format!("text{i}"); + nodes.insert(id.clone(), json!({"id":id,"type":"FRAME","fields":{"name":"Award","parentId":"root","width":210,"height":40,"maxWidth":null,"maxHeight":null, + "layoutMode":"VERTICAL","layoutSizingHorizontal":"FILL","layoutSizingVertical":"HUG","childrenIds":[child]}})); + nodes.insert(child.clone(), json!({"id":child,"type":"TEXT","fields":{"characters":"Award","parentId":id,"width":40,"height":20,"fontSize":16, + "layoutSizingHorizontal":"HUG","layoutSizingVertical":"HUG","textAutoResize":"WIDTH_AND_HEIGHT"}})); + } + for node in nodes.values_mut() { + if node["fields"].get("layoutMode").is_some() { + node["fields"]["inferredAutoLayout"] = node["fields"].clone(); + } + } + let snapshot: Snapshot = serde_json::from_value( + json!({"fileKey":"test","roots":["root"],"diagnostics":[],"nodes":nodes}), + ) + .unwrap(); + let output = generate_component(&snapshot, "root", &CodegenOptions::default()).unwrap(); + assert_eq!( + output.tsx.matches("flex=\"1\"").count(), + 6, + "{}", + output.tsx + ); + assert!(!output.tsx.contains("210px"), "{}", output.tsx); + for id in cards { + let entry = output + .source_map + .entries + .iter() + .find(|entry| entry.node_id.as_deref() == Some(&id) && entry.property.is_none()) + .unwrap(); + let range = entry.generated_range.as_ref().unwrap(); + let opening = output.tsx[range.start..range.end] + .split('>') + .next() + .unwrap(); + assert!(opening.contains("flex=\"1\""), "{opening}"); + assert!( + !opening.contains("h="), + "HUG height stays intrinsic: {opening}" + ); + } +} diff --git a/crates/devup-mcp-devup-ui/tests/responsive_discovery.rs b/crates/devup-mcp-devup-ui/tests/responsive_discovery.rs index a361b2c7..18d5e119 100644 --- a/crates/devup-mcp-devup-ui/tests/responsive_discovery.rs +++ b/crates/devup-mcp-devup-ui/tests/responsive_discovery.rs @@ -108,3 +108,29 @@ fn different_names_are_never_merged_on_width_alone() { fn a_lone_frame_is_not_a_family() { assert_eq!(merged_slots(&[("777:510", "AUTH-01", 1920.0)]), None); } + +#[test] +fn explicit_width_range_label_merges_with_mobile_and_desktop() { + let frames = [ + ("m", "Mobile", 360.0), + ("t", "992~1280", 1280.0), + ("d", "Desktop", 1440.0), + ]; + assert!( + devup_mcp_devup_ui::codegen::responsive::breakpoint_family_is_possible( + &frames + .iter() + .map(|(_, name, width)| (*name, *width)) + .collect::>() + ) + ); + assert_eq!(merged_slots(&frames), Some(vec![0, 3, 4])); +} + +#[test] +fn unrelated_numbered_screens_are_not_breakpoint_labels() { + assert_eq!( + merged_slots(&[("a", "Screen 992", 992.0), ("b", "Screen 1440", 1440.0)]), + None + ); +} diff --git a/crates/devup-mcp-figma/src/asset_timeout.rs b/crates/devup-mcp-figma/src/asset_timeout.rs new file mode 100644 index 00000000..83c6c778 --- /dev/null +++ b/crates/devup-mcp-figma/src/asset_timeout.rs @@ -0,0 +1,41 @@ +use std::time::Duration; + +/// Total lookup/render budget per asset, shared by the server, script and bridge. +/// Invalid settings retain the existing 90 second default. +pub fn asset_timeout() -> Duration { + Duration::from_millis(parse_asset_timeout( + std::env::var("DEVUP_ASSET_TIMEOUT_MS").ok().as_deref(), + )) +} + +fn parse_asset_timeout(value: Option<&str>) -> u64 { + value + .and_then(|s| s.parse::().ok()) + .filter(|v| (1_000..=600_000).contains(v)) + .unwrap_or(90_000) +} + +/// A transport must leave time for the script to return its diagnostic. +pub(crate) fn asset_transport_timeout() -> Duration { + asset_timeout() + Duration::from_secs(5) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn configuration_is_bounded_and_backwards_compatible() { + for value in [ + None, + Some("0"), + Some("999"), + Some("600001"), + Some("invalid"), + ] { + assert_eq!(parse_asset_timeout(value), 90_000); + } + assert_eq!(parse_asset_timeout(Some("180000")), 180_000); + assert_eq!(parse_asset_timeout(Some("1000")), 1_000); + assert_eq!(parse_asset_timeout(Some("600000")), 600_000); + } +} diff --git a/crates/devup-mcp-figma/src/assets.rs b/crates/devup-mcp-figma/src/assets.rs index 69d0d463..9112549d 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -10,6 +10,22 @@ use crate::{ pub const MAX_ASSET_BYTES: usize = 8 * 1024 * 1024; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetExportDiagnostics { + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_error: Option, + pub stages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AssetExportStage { + pub stage: String, + pub elapsed_ms: u64, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AssetFormat { @@ -100,6 +116,8 @@ pub struct AssetManifestEntry { /// the message Figma or the transport gave. Bounded to 500 characters. #[serde(default, skip_serializing_if = "Option::is_none")] pub error_detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub export_diagnostics: Option, /// Where the generated code refers to this asset, `/icons/x.svg` or /// `/images/x.png`, filled in by the server from the code generator's /// naming so the bytes can be written where the code will look. @@ -457,6 +475,7 @@ fn manifest_entry(node: &RawNode, asset: AssetNode) -> AssetManifestEntry { output_path: None, error_code, error_detail: None, + export_diagnostics: None, path: None, } } @@ -610,6 +629,7 @@ pub fn asset_export_from_result( output_path: None, error_code: descriptor.error_code, error_detail: bounded(descriptor.error_detail), + export_diagnostics: descriptor.export_diagnostics, path: None, }))); } @@ -673,6 +693,7 @@ pub fn asset_export_from_result( output_path: None, error_code: None, error_detail: None, + export_diagnostics: descriptor.export_diagnostics, path: None, }))) } @@ -738,6 +759,7 @@ pub fn exported_asset_from_bytes( output_path: None, error_code: None, error_detail: None, + export_diagnostics: None, path: None, }) } @@ -761,6 +783,8 @@ struct AssetExportDescriptor { #[serde(default)] error_detail: Option, #[serde(default)] + export_diagnostics: Option, + #[serde(default)] cursor: Option, } @@ -771,9 +795,17 @@ fn bounded(text: Option) -> Option { } fn find_descriptor(value: &Value) -> Option { - if let Ok(descriptor) = serde_json::from_value::(value.clone()) + if let Ok(mut descriptor) = serde_json::from_value::(value.clone()) && descriptor.kind == "devupAssetExport" { + if let Some(diagnostics) = &mut descriptor.export_diagnostics { + diagnostics.native_error = bounded(diagnostics.native_error.take()); + diagnostics.method = diagnostics.method.chars().take(80).collect(); + diagnostics.stages.truncate(16); + for stage in &mut diagnostics.stages { + stage.stage = stage.stage.chars().take(80).collect(); + } + } return Some(descriptor); } match value { @@ -786,6 +818,10 @@ fn find_descriptor(value: &Value) -> Option { } } +pub(crate) fn asset_export_diagnostics(result: &UpstreamResult) -> Option { + find_descriptor(&result.raw)?.export_diagnostics +} + /// How an upstream carried the exported asset. enum AssetPayload { /// An image content block or a blob resource, which are base64. diff --git a/crates/devup-mcp-figma/src/bridge.rs b/crates/devup-mcp-figma/src/bridge.rs index 92df7155..c541b2c0 100644 --- a/crates/devup-mcp-figma/src/bridge.rs +++ b/crates/devup-mcp-figma/src/bridge.rs @@ -829,6 +829,9 @@ impl BridgeState { params: Value, ) -> Result<(Value, BridgeServed), Failure> { let request_id = self.next_request_id(); + let budget = job_timeout(script, ¶ms); + let is_asset = job_is_asset_read(script, ¶ms); + let requested_node = params["nodeId"].as_str().unwrap_or("unknown").to_owned(); let (tx, rx) = oneshot::channel(); let served = { @@ -882,7 +885,7 @@ impl BridgeState { pending: &self.pending, request_id, }; - match timeout(JOB_TIMEOUT, rx).await { + match timeout(budget, rx).await { Ok(Ok(result)) => match (result.data, result.error) { // 스크립트가 던진 DEVUP_* 코드는 그대로 올린다. 원격 경로와 같은 // 문자열이어야 위쪽 분기가 동일하게 동작한다. @@ -896,9 +899,15 @@ impl BridgeState { Ok(Err(_)) => Err(Failure::Interrupted(unavailable( "the Devup Bridge plugin disconnected mid-read", ))), - Err(_) => Err(Failure::Final(unavailable( - "the Devup Bridge plugin did not answer in time", - ))), + Err(_) => Err(Failure::Final(unavailable(format!( + "{}Devup Bridge {script} did not answer after {} ms (node: {requested_node})", + if is_asset { + "DEVUP_ASSET_TIMEOUT: " + } else { + "" + }, + budget.as_millis() + )))), } } @@ -912,6 +921,31 @@ impl BridgeState { } } +pub(super) fn job_timeout(script: &str, params: &Value) -> Duration { + if job_is_asset_read(script, params) { + let options = if script == "assets" { + ¶ms["asset"] + } else { + ¶ms["largeValue"] + }; + let millis = options["timeoutMs"] + .as_u64() + .filter(|v| (1000..=600000).contains(v)) + .unwrap_or(90000); + Duration::from_millis(millis) + Duration::from_secs(5) + } else { + JOB_TIMEOUT + } +} + +pub(super) fn job_is_asset_read(script: &str, params: &Value) -> bool { + script == "assets" + || (script == "largeValue" + && params["largeValue"]["field"] + .as_str() + .is_some_and(|field| field.starts_with("$export:"))) +} + /// 한 번에 담을 수 있는 양. 로컬 소켓이라 공식 MCP 의 자름을 따를 이유가 없다. /// /// 기본값(봉투 19KB · 필드 4KB)은 공식 MCP 가 텍스트 결과를 20,480 바이트에서 diff --git a/crates/devup-mcp-figma/src/bridge/relay.rs b/crates/devup-mcp-figma/src/bridge/relay.rs index 84f52379..6b842c9d 100644 --- a/crates/devup-mcp-figma/src/bridge/relay.rs +++ b/crates/devup-mcp-figma/src/bridge/relay.rs @@ -106,7 +106,7 @@ use tokio_tungstenite::{ use super::{ AttachedFile, BridgeIssue, BridgePeer, BridgeServed, BridgeState, Connected, Failure, - JOB_TIMEOUT, LinkState, PluginContext, Waiting, + LinkState, PluginContext, Waiting, holder::{self, PortOwner}, secret::{self, RelaySecret, Side}, shut_down, unavailable, @@ -432,6 +432,8 @@ impl RelayConnection { params: Value, ) -> Result<(Value, BridgeServed), Failure> { let id = self.next.fetch_add(1, Ordering::Relaxed); + let budget = super::job_timeout(script, ¶ms) + RELAY_SLACK; + let is_asset = super::job_is_asset_read(script, ¶ms); let (tx, rx) = oneshot::channel(); self.pending .lock() @@ -447,7 +449,7 @@ impl RelayConnection { if self.outbox.send(job.to_string()).is_err() { return Err(self.gone()); } - match timeout(JOB_TIMEOUT + RELAY_SLACK, rx).await { + match timeout(budget, rx).await { Ok(Ok(Answer::Data(data, served))) => Ok((data, served)), // 호스트가 붙인 문구를 그대로 올린다. 스크립트가 던진 DEVUP_* 코드도 // 그 안에 있고, 위쪽 분기는 그 문자열로 판정한다. 호스트가 이미 다시 @@ -458,9 +460,15 @@ impl RelayConnection { DevupError::new(ErrorCode::DevupFigmaDirectUnavailable, message, false) })), Ok(Err(_)) => Err(self.gone()), - Err(_) => Err(Failure::Final(unavailable( - "the Devup Bridge plugin did not answer in time", - ))), + Err(_) => Err(Failure::Final(unavailable(format!( + "{}Devup Bridge relay {script} did not answer after {} ms", + if is_asset { + "DEVUP_ASSET_TIMEOUT: " + } else { + "" + }, + budget.as_millis() + )))), } } diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index 5f86dba5..b623998a 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -254,7 +254,8 @@ pub struct CollectorSession { large_values: BTreeMap<(String, String), LargeValueAssembler>, /// An SVG or PNG export announced as too large for one answer, by the /// key its fragments arrive under, until the last of them has. - chunked_assets: BTreeMap<(String, String), AssetRequest>, + chunked_assets: + BTreeMap<(String, String), (AssetRequest, Option)>, /// The pages of a paginated theme so far, merged, until the last one. fast_theme_pages: Option, fast_theme_page_count: usize, @@ -331,7 +332,7 @@ impl CollectorSession { self.request.asset_selections.iter().map(|selection| { match self.asset_results.iter().find(|asset| asset.asset_id == selection.asset_id) { Some(asset) => serde_json::json!({"assetId":asset.asset_id,"status":asset.status, - "byteLength":asset.byte_length,"sha256":asset.sha256,"errorCode":asset.error_code}), + "byteLength":asset.byte_length,"sha256":asset.sha256,"errorCode":asset.error_code,"errorDetail":asset.error_detail,"exportDiagnostics":asset.export_diagnostics}), None => serde_json::json!({"assetId":selection.asset_id,"status":"pending"}), } }).collect() @@ -753,7 +754,13 @@ impl CollectorSession { }; self.record_asset_failure( *request, - "DEVUP_ASSET_EXPORT_FAILED", + if error.message.contains("DEVUP_ASSET_TIMEOUT:") + || error.details["reason"] == "timeout" + { + "DEVUP_ASSET_TIMEOUT" + } else { + "DEVUP_ASSET_EXPORT_FAILED" + }, Some(error.message.clone()), ); return Ok(true); @@ -767,7 +774,11 @@ impl CollectorSession { let ReadToolCall::LargeValue { options, .. } = pending.planned.call else { return Err(invalid_call("large value call format is invalid.")); }; - self.record_large_value_unsupported(&options, "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM")?; + self.record_large_value_unsupported( + &options, + "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM", + Some(&error.message), + )?; return Ok(true); } if pending.kind == CallKind::Snapshot @@ -1535,7 +1546,11 @@ impl CollectorSession { "large value unsupported response does not match the request.", )); } - return self.record_large_value_unsupported(options, &unsupported.error_code); + return self.record_large_value_unsupported( + options, + &unsupported.error_code, + unsupported.error_detail.as_deref(), + ); } let LargeValueResult::Fragment(fragment) = result else { unreachable!() @@ -1558,12 +1573,13 @@ impl CollectorSession { .large_values .remove(&key) .ok_or_else(|| invalid_call("large value assembler is missing."))?; - if let Some(request) = self.chunked_assets.remove(&key) { + if let Some((request, diagnostics)) = self.chunked_assets.remove(&key) { // The fragments were an SVG or PNG export, not a field: the // bytes are the asset, and go where a one-answer export would. let bytes = assembler.finish_bytes()?; - self.asset_results - .push(exported_asset_from_bytes(&request, &bytes)?); + let mut asset = exported_asset_from_bytes(&request, &bytes)?; + asset.export_diagnostics = diagnostics; + self.asset_results.push(asset); return Ok(()); } let descriptor = assembler.descriptor().clone(); @@ -1592,18 +1608,24 @@ impl CollectorSession { &mut self, options: &LargeValueReadOptions, error_code: &str, + error_detail: Option<&str>, ) -> Result<(), DevupError> { let key = (options.node_id.clone(), options.field.clone()); let assembler = self .large_values .remove(&key) .ok_or_else(|| invalid_call("large value assembler is missing."))?; - if let Some(request) = self.chunked_assets.remove(&key) { + if let Some((request, _)) = self.chunked_assets.remove(&key) { self.record_asset_failure( request, - "DEVUP_ASSET_EXPORT_FAILED", + if error_detail.is_some_and(|detail| detail.contains("DEVUP_ASSET_TIMEOUT:")) { + "DEVUP_ASSET_TIMEOUT" + } else { + "DEVUP_ASSET_EXPORT_FAILED" + }, Some(format!( - "reading the export back in fragments failed: {error_code}" + "reading the export back in fragments failed: {}", + error_detail.unwrap_or(error_code) )), ); return Ok(()); @@ -1682,7 +1704,13 @@ impl CollectorSession { descriptor, )?, ); - self.chunked_assets.insert(key, request.as_ref().clone()); + self.chunked_assets.insert( + key, + ( + request.as_ref().clone(), + crate::assets::asset_export_diagnostics(&result), + ), + ); self.enqueue( ReadToolCall::large_value(&self.request.target.file_key, options.clone()), Some(options.node_id), @@ -1775,6 +1803,7 @@ impl CollectorSession { output_path: None, error_code: Some(error_code.to_owned()), error_detail, + export_diagnostics: None, path: None, }); } diff --git a/crates/devup-mcp-figma/src/large_values.rs b/crates/devup-mcp-figma/src/large_values.rs index 41415b87..8fc3b42a 100644 --- a/crates/devup-mcp-figma/src/large_values.rs +++ b/crates/devup-mcp-figma/src/large_values.rs @@ -85,6 +85,8 @@ pub struct LargeValueUnsupported { pub byte_length: usize, pub sha256: String, pub error_code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_detail: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/devup-mcp-figma/src/lib.rs b/crates/devup-mcp-figma/src/lib.rs index 868ada4d..8a31be81 100644 --- a/crates/devup-mcp-figma/src/lib.rs +++ b/crates/devup-mcp-figma/src/lib.rs @@ -1,4 +1,6 @@ +mod asset_timeout; mod assets; +pub use asset_timeout::asset_timeout; mod bridge; mod collector; mod credentials; @@ -82,9 +84,10 @@ mod metadata; mod original_image; mod reference_compose; pub use assets::{ - AssetExportOutcome, AssetFormat, AssetManifest, AssetManifestEntry, AssetRequest, - AssetSelection, AssetStatus, MAX_ASSET_BYTES, PNG_EXPORT_FIELD, SVG_EXPORT_FIELD, - asset_exclusion_reason, asset_export_from_result, discover_asset_manifest, - exported_asset_from_bytes, resolve_asset_selections, source_kind_of, validate_asset_requests, + AssetExportDiagnostics, AssetExportOutcome, AssetExportStage, AssetFormat, AssetManifest, + AssetManifestEntry, AssetRequest, AssetSelection, AssetStatus, MAX_ASSET_BYTES, + PNG_EXPORT_FIELD, SVG_EXPORT_FIELD, asset_exclusion_reason, asset_export_from_result, + discover_asset_manifest, exported_asset_from_bytes, resolve_asset_selections, source_kind_of, + validate_asset_requests, }; pub use original_image::{OriginalImage, original_image_from_result}; diff --git a/crates/devup-mcp-figma/src/scripts/asset_render_helpers.js b/crates/devup-mcp-figma/src/scripts/asset_render_helpers.js new file mode 100644 index 00000000..e9e15522 --- /dev/null +++ b/crates/devup-mcp-figma/src/scripts/asset_render_helpers.js @@ -0,0 +1,130 @@ +// Read-only recovery. Never clone, detach, crop a parent or manufacture a +// substitute: the returned geometry must belong to this exact subtree. +async function devupLookupAssetNode(id) { + try { + const node = await figma.getNodeByIdAsync(id); + if (node) return node; + } catch (error) { + if (!id.startsWith("I") || !id.includes(";")) throw error; + try { return await walk(); } + catch (fallback) { throw new Error(`${error.message || error}; nested lookup fallback: ${fallback.message || fallback}`); } + } + if (!id.startsWith("I") || !id.includes(";")) return null; + return walk(); + + async function walk() { + const root = await figma.getNodeByIdAsync(id.slice(1).split(";")[0]); + const pending = root ? [root] : []; + const seen = new Set(); + while (pending.length) { + const node = pending.pop(); + if (node.id === id) return node; + if (seen.has(node.id)) continue; + seen.add(node.id); + if (seen.size > 10000) throw new Error("nested lookup exceeds 10000 nodes"); + if (Array.isArray(node.children)) pending.push(...node.children); + } + throw new Error(`nested node ${id} not found in its instance`); + } +} + +function devupGeometrySvg(root) { + const escape = value => String(value).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); + const finite = value => typeof value === "number" && Number.isFinite(value); + const fail = (node, reason) => { throw new Error(`geometry fallback unsupported: ${node.type || "unknown"} ${node.id || ""}: ${reason}`); }; + let geometryCount = 0, visited = 0; + function paintPaths(node, paths, paints) { + if ((paints !== undefined && !Array.isArray(paints)) || (paints === undefined && Array.isArray(paths) && paths.length)) fail(node, "unresolved paint requires native export"); + const visible = Array.isArray(paints) ? paints.filter(p => p.visible !== false) : []; + if (!visible.length) return ""; + if (!Array.isArray(paths) || !paths.length) fail(node, "paint has no rendered geometry"); + return visible.map(paint => { + if (paint.type !== "SOLID" || (paint.blendMode && paint.blendMode !== "NORMAL")) fail(node, "paint type or blending requires native export"); + const color = paint.color; + if (!color || ![color.r, color.g, color.b].every(c => finite(c) && c >= 0 && c <= 1)) fail(node, "invalid solid color"); + const opacity = paint.opacity === undefined ? 1 : paint.opacity; + if (!finite(opacity) || opacity < 0 || opacity > 1) fail(node, "invalid paint opacity"); + // Paths of one winding rule are painted together, preserving holes and + // overlap within the rendered geometry. Preserve floating-point colors. + return ["NONZERO", "EVENODD"].map(rule => { + const matching = paths.filter(path => path.windingRule === rule); + if (!matching.length) return ""; + for (const path of matching) { + if (typeof path.data !== "string" || !path.data.trim()) fail(node, "empty path"); + geometryCount++; + } + return ``; + }).join(""); + }).join(""); + } + function render(node, isRoot) { + if (++visited > 10000) fail(node, "subtree exceeds 10000 nodes"); + if (node.isMask) fail(node, "mask requires native export"); + if (node.visible === false || node.opacity === 0) return ""; + if (node.clipsContent) fail(node, "clipping requires native export"); + if (Array.isArray(node.effects) && node.effects.some(e => e.visible !== false)) fail(node, "effect requires native export"); + if (node.blendMode && !["NORMAL", "PASS_THROUGH"].includes(node.blendMode)) fail(node, "layer blending requires native export"); + const opacity = node.opacity === undefined ? 1 : node.opacity; + if (!finite(opacity) || opacity < 0 || opacity > 1) fail(node, "invalid layer opacity"); + let transform = ""; + if (!isRoot) { + const t = node.relativeTransform; + if (!Array.isArray(t) || t.length !== 2 || !t.every(row => Array.isArray(row) && row.length === 3 && row.every(finite))) fail(node, "missing relative transform"); + transform = ` transform="matrix(${t[0][0]} ${t[1][0]} ${t[0][1]} ${t[1][1]} ${t[0][2]} ${t[1][2]})"`; + } + let body = ""; + if (["GROUP", "FRAME", "INSTANCE", "COMPONENT"].includes(node.type)) { + if ((Array.isArray(node.fills) && node.fills.some(p => p.visible !== false)) || (Array.isArray(node.strokes) && node.strokes.some(p => p.visible !== false))) fail(node, "container paint requires native export"); + if (!Array.isArray(node.children)) fail(node, "missing children"); + const children = node.itemReverseZIndex === true ? [...node.children].reverse() : node.children; + body = children.map(child => render(child, false)).join(""); + } else { + if (!["VECTOR", "BOOLEAN_OPERATION", "RECTANGLE", "ELLIPSE", "POLYGON", "STAR", "LINE"].includes(node.type)) fail(node, "node type requires native export"); + // Plugin API strokeGeometry is centered even for INSIDE/OUTSIDE strokes. + // Using it there would silently change the shape. + if (Array.isArray(node.strokes) && node.strokes.some(p => p.visible !== false) && node.strokeAlign !== "CENTER") fail(node, "non-center stroke alignment requires native export"); + if (Array.isArray(node.dashPattern) && node.dashPattern.length) fail(node, "dashed stroke requires native export"); + for (const paths of [node.fillGeometry, node.strokeGeometry]) { + if (paths !== undefined && (!Array.isArray(paths) || paths.some(p => !["NONZERO", "EVENODD"].includes(p.windingRule)))) fail(node, "unknown winding rule"); + } + // Baked geometry includes boolean operations and centered stroke outlines. + // Never approximate those using a bounding box. + body = paintPaths(node, node.fillGeometry, node.fills) + paintPaths(node, node.strokeGeometry, node.strokes); + } + return `${body}`; + } + const bounds = root.absoluteRenderBounds, t = root.absoluteTransform; + // Native exports include stroke extents. Convert rendered bounds back to + // local space only when this is exact; rotated/sheared bounds lose that info. + if (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(finite) || bounds.width <= 0 || bounds.height <= 0) fail(root, "missing render bounds"); + if (!Array.isArray(t) || t.length !== 2 || !t.every(row => Array.isArray(row) && row.length === 3 && row.every(finite)) || t[0][1] !== 0 || t[1][0] !== 0 || t[0][0] <= 0 || t[1][1] <= 0) fail(root, "root rotation, reflection or shear requires native export"); + const x = (bounds.x - t[0][2]) / t[0][0], y = (bounds.y - t[1][2]) / t[1][1]; + const width = bounds.width / t[0][0], height = bounds.height / t[1][1]; + const body = render(root, true); + if (!geometryCount) fail(root, "no painted geometry"); + return `${body}`; +} + +async function devupRenderAsset(node, settings, nativeBudgetMs = 85000, observe = () => {}) { + const began = Date.now(); + let timer; + try { + const result = await Promise.race([ + node.exportAsync(settings), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`DEVUP_ASSET_TIMEOUT: native export exceeded ${nativeBudgetMs} ms`)), nativeBudgetMs); }), + ]); + observe("native-export", Date.now() - began); + return result; + } + catch (nativeError) { + observe("native-export", Date.now() - began, String(nativeError.message || nativeError).slice(0, 500)); + if (settings.format !== "SVG_STRING") throw nativeError; + const fallbackBegan = Date.now(); + try { + const svg = devupGeometrySvg(node); + observe("geometry-fallback", Date.now() - fallbackBegan); + return svg; + } + catch (fallbackError) { throw new Error(`native export: ${nativeError.message || nativeError}; ${fallbackError.message || fallbackError}`); } + } finally { clearTimeout(timer); } +} diff --git a/crates/devup-mcp-figma/src/scripts/assets.js b/crates/devup-mcp-figma/src/scripts/assets.js index d8fde1d0..db16e668 100644 --- a/crates/devup-mcp-figma/src/scripts/assets.js +++ b/crates/devup-mcp-figma/src/scripts/assets.js @@ -4,14 +4,31 @@ figma.skipInvisibleInstanceChildren = false; const options = "__DEVUP_ASSET__"; "__DEVUP_LARGE_VALUE_HELPERS__"; +"__DEVUP_ASSET_RENDER_HELPERS__"; const started = Date.now(); let stage = "lookup"; +let nodeType = "unknown"; +const timeoutMs = Math.min(600000, Math.max(1000, Number(options.timeoutMs) || 90000)); +const exportDiagnostics = { method: "native", stages: [] }; +function observe(stage, elapsedMs, nativeError) { + exportDiagnostics.stages.push({ stage, elapsedMs }); + if (nativeError) exportDiagnostics.nativeError = nativeError; + if (stage === "geometry-fallback") exportDiagnostics.method = "geometry"; +} +async function timed(work) { + let timer; + try { + return await Promise.race([work(), new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`DEVUP_ASSET_TIMEOUT: ${stage} exceeded ${timeoutMs} ms asset budget`)), Math.max(0, timeoutMs - (Date.now() - started))); + })]); + } finally { clearTimeout(timer); } +} function describe(error) { const message = error && typeof error.message === "string" ? error.message : String(error); const editor = typeof figma.editorType === "string" ? ` (editor: ${figma.editorType})` : ""; - return `${stage} failed after ${Date.now() - started} ms${editor}: ${message}`.slice(0, 500); + return `${stage} failed after ${Date.now() - started} ms${editor} (node: ${options.nodeId}, type: ${nodeType}): ${message}`.slice(0, 500); } function failed(errorCode, errorDetail) { @@ -29,6 +46,7 @@ function failed(errorCode, errorDetail) { byteLength: null, sha256: null, errorCode, + exportDiagnostics, ...(errorDetail ? { errorDetail } : {}), }; } @@ -42,9 +60,11 @@ try { if (original && options.transport !== "bridge") { return failed("DEVUP_ORIGINAL_IMAGE_REQUIRES_BRIDGE"); } - const node = await figma.getNodeByIdAsync(options.nodeId); + const node = await timed(() => devupLookupAssetNode(options.nodeId)); + observe("lookup", Date.now() - started); + nodeType = node && node.type || "unknown"; if (!node || (!original && typeof node.exportAsync !== "function")) { - return failed("DEVUP_ASSET_UNSUPPORTED_BY_UPSTREAM"); + return failed("DEVUP_ASSET_UNSUPPORTED_BY_UPSTREAM", describe(new Error(node ? "node has no exportAsync" : "node not found"))); } if (original || (typeof options.field === "string" && options.field.startsWith("fills/"))) { const index = Number(options.field.slice(original ? "$original-image/fills/".length : "fills/".length)); @@ -62,7 +82,7 @@ try { stage = "reading the original image"; const image = figma.getImageByHash(options.imageHash); if (!image) return failed("DEVUP_ORIGINAL_IMAGE_NOT_FOUND"); - const bytes = await image.getBytesAsync(); + const bytes = await timed(() => image.getBytesAsync()); if (bytes.length === 0 || bytes.length > 8 * 1024 * 1024) { return failed("DEVUP_ASSET_RESPONSE_TOO_LARGE", `${bytes.length} bytes; one answer carries 1 to 8388608`); } @@ -73,7 +93,7 @@ try { : starts([82,73,70,70]) && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80 ? "image/webp" : null; if (!mimeType) return failed("DEVUP_ORIGINAL_IMAGE_CODEC_UNSUPPORTED"); - const size = await image.getSizeAsync(); + const size = await timed(() => image.getSizeAsync()); return { ...failed(null), status: "exported", kind: "devupOriginalImage", representation: "original-image-v1", format: null, scale: null, @@ -99,8 +119,14 @@ try { settings.constraint = { type: "SCALE", value: scale }; } stage = "export"; - const exported = await node.exportAsync(settings); + const remaining = Math.max(1, timeoutMs - (Date.now() - started)); + // Reserve up to five seconds for read-only geometry after a hung renderer. + // A late native answer has no file writer and cannot replace the fallback. + const nativeBudget = inlineSvg ? Math.max(1, remaining - Math.min(5000, Math.floor(remaining / 5))) : remaining; + const exported = await timed(() => devupRenderAsset(node, settings, nativeBudget, observe)); + if (Date.now() - started >= timeoutMs) throw new Error(`DEVUP_ASSET_TIMEOUT: export exceeded ${timeoutMs} ms asset budget`); stage = "encode"; + const encodeBegan = Date.now(); const svgText = inlineSvg && typeof exported === "string" ? exported : null; const bytes = svgText === null @@ -112,6 +138,7 @@ try { return failed("DEVUP_ASSET_RESPONSE_TOO_LARGE", `${format} at scale ${scale} is ${bytes.length} bytes; one answer carries 1 to 8388608`); } const sha256 = devupSha256(bytes); + observe("encode", Date.now() - encodeBegan); // figma.io is an extension of the remote MCP, not the native Plugin API. // The bridge returns JSON over its local socket and has no attachment // writer. Carry its bounded bytes inline, without exporting again for each @@ -145,6 +172,7 @@ try { sha256, cursor: { nextOffset: 0, maxChunkBytes: DEVUP_LARGE_VALUE_CHUNK_BYTES }, errorCode: null, + exportDiagnostics, }; } if (hasFileWriter) { @@ -170,7 +198,8 @@ try { ...(!hasFileWriter && svgText === null ? { data: figma.base64Encode(bytes) } : {}), text: svgText, errorCode: null, + exportDiagnostics, }; } catch (error) { - return failed(original ? "DEVUP_ORIGINAL_IMAGE_READ_FAILED" : "DEVUP_ASSET_EXPORT_FAILED", describe(error)); + return failed(String(error.message || error).includes("DEVUP_ASSET_TIMEOUT:") ? "DEVUP_ASSET_TIMEOUT" : original ? "DEVUP_ORIGINAL_IMAGE_READ_FAILED" : "DEVUP_ASSET_EXPORT_FAILED", describe(error)); } diff --git a/crates/devup-mcp-figma/src/scripts/component_identity_helpers.js b/crates/devup-mcp-figma/src/scripts/component_identity_helpers.js new file mode 100644 index 00000000..85f3dfd2 --- /dev/null +++ b/crates/devup-mcp-figma/src/scripts/component_identity_helpers.js @@ -0,0 +1,24 @@ +// Main-component identity is asynchronous under dynamic-page access. It is +// not a synchronous getter from the property manifest. Cache per collection +// so repacking the same page does not repeat its reads. +const devupComponentIdentity = new Map(); +async function devupReadComponentIdentity(node, fields, fieldErrors) { + if (node.type !== "INSTANCE" || typeof node.getMainComponentAsync !== "function") return; + if (!devupComponentIdentity.has(node.id)) { + devupComponentIdentity.set(node.id, (async () => { + try { + const main = await node.getMainComponentAsync(); + return { fields: { + mainComponentId: main ? main.id : null, + mainComponentName: main ? main.name : null, + mainComponentSetId: main && main.parent && main.parent.type === "COMPONENT_SET" ? main.parent.id : null, + } }; + } catch (error) { + return { error: String(error && error.message ? error.message : error) }; + } + })()); + } + const identity = await devupComponentIdentity.get(node.id); + if (identity.error) fieldErrors.mainComponentId = identity.error; + else Object.assign(fields, identity.fields); +} diff --git a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js index 773bb895..80ef91bd 100644 --- a/crates/devup-mcp-figma/src/scripts/fast_snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/fast_snapshot.js @@ -29,8 +29,15 @@ if (roots.length === 1 && roots[0].type === "SECTION") { // the copy shows in the responsive array. Sorting the family by name here put // tablet's values where the reference has desktop's. const BREAKPOINT_NAMES = ["mobile", "tablet", "desktop"]; -const breakpointRank = (node) => - BREAKPOINT_NAMES.indexOf(String(node.name || "").trim().toLowerCase()); +const breakpointRank = (node) => { + const name = String(node.name || "").trim().toLowerCase(); + const known = BREAKPOINT_NAMES.indexOf(name); + if (known >= 0) return known; + const range = name.match(/^(\d+)\s*[~–]\s*(\d+)$/); + if (!range) return -1; + const start = Number(range[1]), end = Number(range[2]); + return Number.isSafeInteger(start) && Number.isSafeInteger(end) && start > 0 && end >= start && end <= 0xffffffff ? 1 : -1; +}; if (roots.length === 1 && breakpointRank(roots[0]) >= 0) { const parent = roots[0].parent; if (parent && parent.type === "SECTION" && "children" in parent) { @@ -258,7 +265,8 @@ function serialize(value, resource = false, seen = new WeakSet(), depth = 0) { return result; } -function snapshotNode(node) { +"__DEVUP_COMPONENT_IDENTITY_HELPERS__"; +async function snapshotNode(node) { const fields = {}; const fieldErrors = {}; if (node.parent) fields.parentId = node.parent.id; @@ -338,6 +346,7 @@ function snapshotNode(node) { fieldErrors.styledTextSegments = String(error && error.message ? error.message : error); } } + await devupReadComponentIdentity(node, fields, fieldErrors); // `extra` and `fieldErrors` are `#[serde(default)]` on the Rust `RawNode`, // so an empty one is the same as an absent one on the wire. const snapshotted = { id: node.id, type: node.type, fields }; @@ -529,11 +538,11 @@ async function collectResources(nodes) { // Packs as many nodes as fit under `budget`, starting at `offset`. Same // dynamic, byte-budget-driven pagination the legacy cursor snapshot uses. -function packPage(budget) { +async function packPage(budget) { const pageNodes = []; let payloadBytes = 2; for (let index = offset; index < allNodes.length; index += 1) { - const snapshotted = snapshotNode(allNodes[index]); + const snapshotted = await snapshotNode(allNodes[index]); const nodeBytes = jsonByteLength(snapshotted) + (pageNodes.length ? 1 : 0); if (pageNodes.length && payloadBytes + nodeBytes > budget) break; pageNodes.push(snapshotted); @@ -611,7 +620,7 @@ function buildEnvelope(pageNodes, resources) { let nodeBudget = maxPayloadBytes - 1024; let built = null; for (let attempt = 0; attempt < 5; attempt += 1) { - const { pageNodes, packedBytes } = packPage(nodeBudget); + const { pageNodes, packedBytes } = await packPage(nodeBudget); if (pageNodes.length === 0) throw new Error("DEVUP_SNAPSHOT_RANGE_INVALID"); const candidate = buildEnvelope(pageNodes, await collectResources(pageNodes)); if (candidate.bytes <= MAX_TEXT_ENVELOPE_BYTES) { diff --git a/crates/devup-mcp-figma/src/scripts/large_value.js b/crates/devup-mcp-figma/src/scripts/large_value.js index 9afef5aa..321e9cf6 100644 --- a/crates/devup-mcp-figma/src/scripts/large_value.js +++ b/crates/devup-mcp-figma/src/scripts/large_value.js @@ -3,7 +3,8 @@ // here so the direct path and the Devup Bridge read the same document. figma.skipInvisibleInstanceChildren = false; const options = "__DEVUP_LARGE_VALUE__"; -const node = await figma.getNodeByIdAsync(options.nodeId); +"__DEVUP_ASSET_RENDER_HELPERS__"; +const node = await devupLookupAssetNode(options.nodeId); if (!node) throw new Error("DEVUP_NODE_NOT_FOUND"); const textSegmentManifest = "__DEVUP_TEXT_SEGMENT_MANIFEST__"; @@ -158,7 +159,7 @@ let exportedBytes = null; try { if (options.field === "$export:svg") { if (typeof node.exportAsync !== "function") throw new Error("unsupported"); - const svg = await node.exportAsync({ format: "SVG_STRING" }); + const svg = await devupRenderAsset(node, { format: "SVG_STRING" }, Math.max(1, (Number(options.timeoutMs) || 90000) - 5000)); if (typeof svg !== "string") throw new Error("unsupported"); exportedBytes = utf8Encode(svg); } else if (typeof options.field === "string" && options.field.startsWith("$export:png")) { @@ -180,7 +181,7 @@ try { } else { throw new Error("unsupported"); } -} catch (_) { +} catch (error) { return { kind: "devupLargeValueUnsupported", fileKey: figma.fileKey || "", @@ -190,6 +191,7 @@ try { byteLength: options.byteLength, sha256: options.sha256, errorCode: "DEVUP_FIELD_UNSUPPORTED_BY_UPSTREAM", + errorDetail: `${options.field} failed: ${error.message || error}`.slice(0, 500), }; } diff --git a/crates/devup-mcp-figma/src/scripts/snapshot.js b/crates/devup-mcp-figma/src/scripts/snapshot.js index 07349690..8c9e9828 100644 --- a/crates/devup-mcp-figma/src/scripts/snapshot.js +++ b/crates/devup-mcp-figma/src/scripts/snapshot.js @@ -182,7 +182,8 @@ function variantDefinitions(node, name) { ); } -function snapshotNode(node) { +"__DEVUP_COMPONENT_IDENTITY_HELPERS__"; +async function snapshotNode(node) { const fields = {}; const extra = {}; const fieldErrors = {}; @@ -232,6 +233,7 @@ function snapshotNode(node) { fieldErrors.styledTextSegments = String(error && error.message ? error.message : error); } } + await devupReadComponentIdentity(node, fields, fieldErrors); return { id: node.id, type: node.type, fields, extra, fieldErrors }; } @@ -247,7 +249,7 @@ const nodes = []; const nodeBudget = maxPayloadBytes - 1024; let payloadBytes = 2; for (let index = offset; index < allNodes.length; index += 1) { - const node = shrinkNodeToBudget(snapshotNode(allNodes[index]), nodeBudget); + const node = shrinkNodeToBudget(await snapshotNode(allNodes[index]), nodeBudget); const nodeBytes = jsonByteLength(node) + (nodes.length ? 1 : 0); if (nodes.length && payloadBytes + nodeBytes > nodeBudget) break; nodes.push(node); diff --git a/crates/devup-mcp-figma/src/upstream.rs b/crates/devup-mcp-figma/src/upstream.rs index fb12d3fa..6a8ff27b 100644 --- a/crates/devup-mcp-figma/src/upstream.rs +++ b/crates/devup-mcp-figma/src/upstream.rs @@ -211,8 +211,8 @@ impl BuiltinScript { .expect("snapshot options serialize"); let root_ids = serde_json::to_string(inputs.root_ids.unwrap_or(&default_root_ids)) .expect("root ids serialize"); - let large_value = - serde_json::to_string(inputs.large_value.unwrap_or(&LargeValueReadOptions { + let mut large_value = + serde_json::to_value(inputs.large_value.unwrap_or(&LargeValueReadOptions { node_id: String::new(), field: String::new(), offset: 0, @@ -222,6 +222,13 @@ impl BuiltinScript { version: None, })) .expect("large value options serialize"); + if large_value["field"] + .as_str() + .is_some_and(|field| field.starts_with("$export:")) + { + large_value["timeoutMs"] = json!(crate::asset_timeout().as_millis() as u64); + } + let large_value = large_value.to_string(); let asset = inputs .asset .map(|(request, version)| { @@ -232,6 +239,7 @@ impl BuiltinScript { "imageHash": request.image_hash, "format": request.format, "scale": request.scale, + "timeoutMs": crate::asset_timeout().as_millis() as u64, "version": version }) }) @@ -240,6 +248,14 @@ impl BuiltinScript { let theme = serde_json::to_string(&json!({ "offset": inputs.theme_offset.unwrap_or(0) })) .expect("theme page options serialize"); source + .replace( + "\"__DEVUP_COMPONENT_IDENTITY_HELPERS__\"", + include_str!("scripts/component_identity_helpers.js"), + ) + .replace( + "\"__DEVUP_ASSET_RENDER_HELPERS__\"", + include_str!("scripts/asset_render_helpers.js"), + ) .replace( "\"__DEVUP_LARGE_VALUE_HELPERS__\"", include_str!("scripts/large_value_helpers.js"), @@ -926,7 +942,15 @@ impl FigmaUpstream for RemoteFigmaClient { } let result = self .sessions - .call_tool(&session, &call, self.call_tool_timeout) + .call_tool( + &session, + &call, + if call.is_asset_read() { + crate::asset_timeout::asset_transport_timeout() + } else { + self.call_tool_timeout + }, + ) .await?; let raw = serde_json::to_value(result) .map_err(|error| map_upstream_error(UpstreamFailureContext::Decode, error))?; @@ -989,6 +1013,11 @@ impl BuiltinScript { } impl ReadToolCall { + /// An asset rendition or a fragment of one, rather than a document field. + pub fn is_asset_read(&self) -> bool { + matches!(self, Self::AssetExport { .. }) + || matches!(self, Self::LargeValue { options, .. } if options.field.starts_with("$export:")) + } /// 이 읽기가 향하는 파일. pub fn file_key(&self) -> &str { match self { @@ -1096,10 +1125,17 @@ impl ReadToolCall { params: json!({ "theme": { "offset": offset } }), }, - Self::LargeValue { options, .. } => BridgeJob { - script: BuiltinScript::LargeValue.plugin_name(), - params: json!({ "nodeId": options.node_id, "largeValue": options }), - }, + Self::LargeValue { options, .. } => { + let mut params = json!({ "nodeId": options.node_id, "largeValue": options }); + if self.is_asset_read() { + params["largeValue"]["timeoutMs"] = + json!(crate::asset_timeout().as_millis() as u64); + } + BridgeJob { + script: BuiltinScript::LargeValue.plugin_name(), + params, + } + } Self::AssetExport { version, request, .. @@ -1115,6 +1151,7 @@ impl ReadToolCall { "imageHash": request.image_hash, "format": request.format, "scale": request.scale, + "timeoutMs": crate::asset_timeout().as_millis() as u64, "version": version, }, }), diff --git a/crates/devup-mcp-figma/src/url.rs b/crates/devup-mcp-figma/src/url.rs index 1e1be440..f8ad6c98 100644 --- a/crates/devup-mcp-figma/src/url.rs +++ b/crates/devup-mcp-figma/src/url.rs @@ -120,7 +120,12 @@ impl FigmaTarget { format!("https://www.figma.com/design/{}/devup", self.file_key) }; match node_id { - Some(node_id) => format!("{base}?node-id={}", node_id.replace(':', "-")), + Some(node_id) => { + let mut url = Url::parse(&base).expect("target link is a valid URL"); + url.query_pairs_mut() + .append_pair("node-id", &node_id.replace(':', "-")); + url.to_string() + } None => base, } } @@ -147,26 +152,27 @@ fn validate_key(key: &str) -> Result<(), DevupError> { } fn normalize_node_id(node_id: &str) -> Result { - let normalized = if node_id.contains(':') { - node_id.to_owned() - } else if let Some((left, right)) = node_id.split_once('-') { - format!("{left}:{right}") + // query_pairs has decoded percent escapes exactly once. Instance descendants + // have an I prefix and a semicolon-separated chain of ordinary node IDs. + let (prefix, body) = node_id + .strip_prefix('I') + .map_or(("", node_id), |body| ("I", body)); + let segments = body.split(';').collect::>(); + let valid = if prefix.is_empty() { + segments.len() == 1 } else { - return Err(DevupError::unsupported_file( - "Figma node-id format is invalid.", - )); - }; - - let mut parts = normalized.split(':'); - let valid = matches!((parts.next(), parts.next(), parts.next()), (Some(left), Some(right), None) - if !left.is_empty() - && !right.is_empty() - && left.bytes().all(|byte| byte.is_ascii_digit()) - && right.bytes().all(|byte| byte.is_ascii_digit())); + segments.len() >= 2 + } && segments.iter().all(|segment| { + let parts = segment.split([':', '-']).collect::>(); + parts.len() == 2 + && parts + .iter() + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) + }); if !valid { return Err(DevupError::unsupported_file( "Figma node-id format is invalid.", )); } - Ok(normalized) + Ok(format!("{prefix}{}", body.replace('-', ":"))) } diff --git a/crates/devup-mcp-figma/tests/asset_script_transport.rs b/crates/devup-mcp-figma/tests/asset_script_transport.rs index c3350612..02e3f5f1 100644 --- a/crates/devup-mcp-figma/tests/asset_script_transport.rs +++ b/crates/devup-mcp-figma/tests/asset_script_transport.rs @@ -153,7 +153,7 @@ fn a_failed_export_says_what_figma_said() { .expect("the failure carries Figma's words"); assert!(detail.starts_with("export failed after "), "{detail}"); assert!( - detail.contains("(editor: dev): renderer failed"), + detail.contains("(editor: dev)") && detail.contains("renderer failed"), "{detail}" ); } diff --git a/crates/devup-mcp-figma/tests/assets.rs b/crates/devup-mcp-figma/tests/assets.rs index d0fbd628..db338c64 100644 --- a/crates/devup-mcp-figma/tests/assets.rs +++ b/crates/devup-mcp-figma/tests/assets.rs @@ -533,6 +533,7 @@ fn an_svg_over_the_inline_cap_arrives_in_fragments() { "assetId":"1:1:node","nodeId":"1:1","field":"node", "imageHash":null,"format":"svg","scale":1, "status":"chunked","byteLength":svg.len(),"sha256":sha256, + "exportDiagnostics":{"method":"geometry","nativeError":"renderer failed","stages":[{"stage":"native-export","elapsedMs":10},{"stage":"geometry-fallback","elapsedMs":1}]}, "cursor":{"nextOffset":0,"maxChunkBytes":24},"errorCode":null }).to_string()}]}), }, @@ -584,6 +585,19 @@ fn an_svg_over_the_inline_cap_arrives_in_fragments() { assert_eq!(exported.asset_id, "1:1:node"); assert_eq!(exported.field, "node"); assert_eq!(exported.byte_length, Some(svg.len())); + assert_eq!( + exported.export_diagnostics.as_ref().unwrap().method, + "geometry" + ); + assert_eq!( + exported + .export_diagnostics + .as_ref() + .unwrap() + .native_error + .as_deref(), + Some("renderer failed") + ); assert_eq!(exported.mime_type.as_deref(), Some("image/svg+xml")); assert_eq!( STANDARD diff --git a/crates/devup-mcp-figma/tests/component_identity_script.rs b/crates/devup-mcp-figma/tests/component_identity_script.rs new file mode 100644 index 00000000..37fda953 --- /dev/null +++ b/crates/devup-mcp-figma/tests/component_identity_script.rs @@ -0,0 +1,67 @@ +use devup_mcp_figma::{BuiltinScript, ReadToolCall}; +use serde_json::{Value, json}; +use std::{ + io::Write, + process::{Command, Stdio}, +}; + +#[test] +fn async_component_identity_and_getter_failures_survive_both_snapshot_paths() { + for script in [ + BuiltinScript::NodeSnapshot, + BuiltinScript::FastSnapshotEnvelope, + ] { + let code = ReadToolCall::snapshot("fixture", "1:1", script).arguments()["code"].clone(); + let js = r#" +const {code}=JSON.parse(require('node:fs').readFileSync(0,'utf8')); +let calls=0; +const root={id:'1:1',type:'FRAME',name:'screen',children:[]}; +const main={id:'10:1',type:'COMPONENT',name:'Header / desktop',parent:{id:'10:0',type:'COMPONENT_SET'}}; +root.children=[ + {id:'1:2',type:'INSTANCE',children:[],parent:root,getMainComponentAsync:async()=>{calls++;return main}}, + {id:'1:3',type:'INSTANCE',children:[],parent:root,getMainComponentAsync:async()=>{calls++;throw new Error('actual main-component lookup failure')}}, + {id:'1:4',type:'INSTANCE',children:[],parent:root,getMainComponentAsync:async()=>{calls++;return null}}, +]; +const figma={fileKey:'fixture',getNodeByIdAsync:async()=>root,variables:{}}; +const AsyncFunction=Object.getPrototypeOf(async function(){}).constructor; +new AsyncFunction('figma',code)(figma).then(data=>process.stdout.write(JSON.stringify({calls,data}))).catch(e=>{console.error(e);process.exitCode=1}); +"#; + let mut child = Command::new("node") + .args(["-e", js]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(json!({"code":code}).to_string().as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["calls"], 3); + let data = &result["data"]; + let nodes = data.get("snapshot").unwrap_or(data)["nodes"] + .as_array() + .unwrap(); + let instance = nodes.iter().find(|n| n["id"] == "1:2").unwrap(); + assert_eq!(instance["fields"]["mainComponentId"], "10:1"); + assert_eq!(instance["fields"]["mainComponentName"], "Header / desktop"); + assert_eq!(instance["fields"]["mainComponentSetId"], "10:0"); + let failed = nodes.iter().find(|n| n["id"] == "1:3").unwrap(); + assert_eq!( + failed["fieldErrors"]["mainComponentId"], + "actual main-component lookup failure" + ); + assert!(failed["fields"].get("mainComponentId").is_none()); + let detached = nodes.iter().find(|n| n["id"] == "1:4").unwrap(); + assert!(detached["fields"].get("mainComponentId").unwrap().is_null()); + } +} diff --git a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs index 5316e0ef..16140b20 100644 --- a/crates/devup-mcp-figma/tests/explore_script_behavior.mjs +++ b/crates/devup-mcp-figma/tests/explore_script_behavior.mjs @@ -377,3 +377,7 @@ test("R17 Section preview exhaustion is distinct from absent text", async () => pageWith(small); assert.equal((await executeSection(small)).nodes[1].fields.textPreviewState, "no-text"); }); +// The existing CI entry point also enforces asset and async identity source +// regressions; no workflow change is needed to include these suites. +import './original_image_script.mjs'; +import './feedback_scripts.test.mjs'; diff --git a/crates/devup-mcp-figma/tests/feedback_scripts.test.mjs b/crates/devup-mcp-figma/tests/feedback_scripts.test.mjs new file mode 100644 index 00000000..ae0c5d9b --- /dev/null +++ b/crates/devup-mcp-figma/tests/feedback_scripts.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const src = new URL('../src/', import.meta.url); +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; +function script(name, parameters) { + let source = readFileSync(new URL(`scripts/${name}.js`, src), 'utf8'); + for (const [key, file] of Object.entries({ + __DEVUP_ASSET_RENDER_HELPERS__: 'scripts/asset_render_helpers.js', + __DEVUP_COMPONENT_IDENTITY_HELPERS__: 'scripts/component_identity_helpers.js', + __DEVUP_LARGE_VALUE_HELPERS__: 'scripts/large_value_helpers.js', + __DEVUP_PLUGIN_API_MANIFEST__: 'plugin_api_manifest.json', + __DEVUP_TEXT_SEGMENT_MANIFEST__: 'text_segment_manifest.json', + })) source = source.replaceAll(`"${key}"`, () => readFileSync(new URL(file, src), 'utf8')); + for (const [key, value] of Object.entries(parameters)) source = source.replaceAll(`"${key}"`, () => JSON.stringify(value)); + return new AsyncFunction('figma', source); +} + +const id = 'I44:8533;10:973'; +const options = { assetId: `${id}:node`, nodeId: id, field: 'node', format: 'svg', scale: 1, timeoutMs: 1000, transport: 'bridge' }; +function vector() { + return { id, type: 'VECTOR', width: 32, height: 24, opacity: 1, + absoluteTransform: [[1, 0, 0], [0, 1, 0]], relativeTransform: [[1, 0, 0], [0, 1, 0]], + absoluteRenderBounds: { x: 0, y: 0, width: 32, height: 24 }, + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 } }], strokes: [], effects: [], + fillGeometry: [{ windingRule: 'NONZERO', data: 'M0 0H32V24H0Z' }], + exportAsync: async () => { throw Error('actual nested renderer exception'); } }; +} +async function asset(node, lookup = async () => node) { + return script('assets', { __DEVUP_ASSET__: options })({ fileKey: 'fixture', getNodeByIdAsync: lookup, base64Encode: b => Buffer.from(b).toString('base64') }); +} +test('nested asset SVG recovery preserves actual subtree geometry and exception evidence', async () => { + const node = vector(); + const result = await asset(node, async requested => requested === id ? null : { id: '44:8533', children: [node] }); + assert.equal(result.status, 'exported'); + assert.equal(result.exportDiagnostics.method, 'geometry'); + assert.match(result.exportDiagnostics.nativeError, /actual nested renderer exception/); + assert.match(result.text, /M0 0H32V24H0Z/); +}); +test('unsupported fallback paint remains a failure with the real native cause', async () => { + const node = vector(); + node.fills = [{ type: 'GRADIENT_LINEAR' }]; + const result = await asset(node); + assert.equal(result.status, 'failed'); + assert.match(result.errorDetail, /actual nested renderer exception/); + assert.equal(result.data, undefined); +}); +test('native SVG remains authoritative', async () => { + const node = vector(); + const original = ''; + node.exportAsync = async () => original; + const result = await asset(node); + assert.equal(result.status, 'exported'); + assert.equal(result.text, original); + assert.equal(result.exportDiagnostics.method, 'native'); +}); +for (const name of ['snapshot', 'fast_snapshot']) { + test(`${name} acquires async component identities and preserves actual lookup failures`, async () => { + const root = { id: '1:1', type: 'FRAME', name: 'screen', children: [] }; + const main = { id: '10:1', name: 'Header', parent: { id: '10:0', type: 'COMPONENT_SET' } }; + root.children = [ + { id: '1:2', type: 'INSTANCE', parent: root, children: [], getMainComponentAsync: async () => main }, + { id: '1:3', type: 'INSTANCE', parent: root, children: [], getMainComponentAsync: async () => { throw Error('actual identity lookup exception'); } }, + ]; + const result = await script(name, { __DEVUP_NODE_ID__: '1:1', __DEVUP_ROOT_IDS__: ['1:1'], __DEVUP_SNAPSHOT__: {} })({ fileKey: 'fixture', getNodeByIdAsync: async () => root, variables: {} }); + const nodes = (result.snapshot ?? result).nodes; + assert.equal(nodes.find(n => n.id === '1:2').fields.mainComponentId, '10:1'); + assert.equal(nodes.find(n => n.id === '1:2').fields.mainComponentSetId, '10:0'); + assert.equal(nodes.find(n => n.id === '1:3').fieldErrors.mainComponentId, 'actual identity lookup exception'); + }); +} + +test('direct Desktop capture includes a numeric range sibling and excludes unrelated frames', async () => { + const section = {id:'0:1',type:'SECTION',children:[]}; + const make = (id,name,width) => ({id,name,width,height:900,type:'FRAME',parent:section,children:[]}); + section.children = [make('1:1','Desktop',1440),make('1:2','992~1280',992),make('1:3','Mobile',360),make('1:4','Frame 14',992),make('1:5','1280~992',992)]; + const result = await script('fast_snapshot', {__DEVUP_ROOT_IDS__:['1:1'],__DEVUP_SNAPSHOT__:{}})({fileKey:'fixture',getNodeByIdAsync:async id=>section.children.find(n=>n.id===id),variables:{}}); + assert.deepEqual(new Set(result.snapshot.rootIds),new Set(['1:1','1:2','1:3'])); +}); diff --git a/crates/devup-mcp-figma/tests/figma_url.rs b/crates/devup-mcp-figma/tests/figma_url.rs index 223e39ec..59b6d0ed 100644 --- a/crates/devup-mcp-figma/tests/figma_url.rs +++ b/crates/devup-mcp-figma/tests/figma_url.rs @@ -131,3 +131,44 @@ fn errors_do_not_echo_query_secrets() { assert!(!rendered.contains("oauth-code")); assert!(!rendered.contains("access_token")); } + +#[test] +fn nested_instance_ids_decode_once_and_roundtrip_on_all_routes() { + for route in [ + "https://www.figma.com/design/FileKey123/x", + "https://www.figma.com/branch/FileKey123/BranchKey456/x", + "figma-bridge://current", + ] { + for id in [ + "I44%3A8533%3B10%3A973", + "I44-8533;10-973", + "I44:8533;10:1214;10:996", + ] { + let target = FigmaTarget::parse(&format!("{route}?node-id={id}")).unwrap(); + let link = target.link(target.node_id.as_deref()); + let parsed = FigmaTarget::parse(&link).unwrap(); + assert_eq!(parsed.node_id, target.node_id, "{link}"); + assert!(link.contains("%3B")); + } + } +} + +#[test] +fn malformed_nested_ids_cannot_alias_real_nodes() { + for id in [ + "I44:8533", + "44:8533;10:973", + "I44:8533;", + "I44:8533;;10:973", + "I44:8533;I10:973", + "I44:8533;10:973:node", + "I44%253A8533%253B10%253A973", + "I44:8533;10-973-1", + "I44:8533;10:abc", + ] { + assert!( + FigmaTarget::parse(&format!("figma-bridge://current?node-id={id}")).is_err(), + "{id}" + ); + } +} diff --git a/crates/devup-mcp-figma/tests/nested_asset_export.rs b/crates/devup-mcp-figma/tests/nested_asset_export.rs new file mode 100644 index 00000000..7e0ff101 --- /dev/null +++ b/crates/devup-mcp-figma/tests/nested_asset_export.rs @@ -0,0 +1,288 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use devup_mcp_figma::{ + AssetExportOutcome, AssetFormat, AssetRequest, AssetStatus, ReadToolCall, UpstreamResult, + asset_export_from_result, +}; +use serde_json::{Value, json}; +use std::{ + io::Write, + process::{Command, Stdio}, +}; + +fn vector() -> Value { + json!({"id":"I44:8533;10:973", "type":"VECTOR", "opacity":0.8, + "absoluteRenderBounds":{"x":10,"y":20,"width":32,"height":24}, + "absoluteTransform":[[1,0,10],[0,1,20]], "relativeTransform":[[0,-1,15],[1,0,2]], + "fills":[{"type":"SOLID","color":{"r":0.25,"g":0.5,"b":0.75},"opacity":0.6}], + "fillGeometry":[{"windingRule":"EVENODD","data":"M0 0H32V24H0Z M4 4H8V8H4Z"}], + "strokes":[], "effects":[]}) +} + +fn execute(node: Value, format: AssetFormat, mode: &str, writer: bool) -> (AssetRequest, Value) { + let request = AssetRequest { + asset_id: "I44:8533;10:973:node".into(), + node_id: "I44:8533;10:973".into(), + field: "node".into(), + image_hash: None, + format, + scale: 2, + }; + let call = ReadToolCall::asset_export("fixture", None, request.clone()); + let result = run(call.arguments()["code"].clone(), node, mode, writer); + (request, result) +} + +fn run(code: Value, node: Value, mode: &str, writer: bool) -> Value { + let input = json!({"code":code,"node":node,"mode":mode,"writer":writer}); + let js = r#" +const input = JSON.parse(require('node:fs').readFileSync(0,'utf8')); +const node = input.node; +let exports = 0, writes = 0, lookups = []; +node.exportAsync = async settings => { + exports++; + if(input.mode === 'export-timeout') return new Promise(()=>{}); + if(input.mode === 'late-native') return new Promise(resolve=>setTimeout(()=>resolve(''),1200)); + if(input.mode !== 'native') throw new Error('renderer exploded on nested instance'); + return ''; +}; +const figma = { fileKey:'fixture', editorType:'dev', + base64Encode:b=>Buffer.from(b).toString('base64'), + getNodeByIdAsync:async id=>{ + lookups.push(id); + if(input.mode === 'lookup-timeout') return new Promise(()=>{}); + if(input.mode === 'lookup-fallback' && id.startsWith('I')) throw new Error('nested lookup failed'); + if(id === '44:8533') return {id, children:[{id:'sibling',children:[]},node]}; + return node; + }}; +if(input.writer) figma.io={write:()=>writes++}; +const AsyncFunction=Object.getPrototypeOf(async function(){}).constructor; +const code=input.code.replace(/"timeoutMs":\d+/, '"timeoutMs":1000'); +new AsyncFunction('figma',code)(figma).then(data=>process.stdout.write(JSON.stringify({data,exports,writes,lookups}))).catch(e=>{console.error(e);process.exitCode=1}); +"#; + let mut child = Command::new("node") + .args(["-e", js]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(input.to_string().as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +fn decoded(request: &AssetRequest, result: &Value) -> devup_mcp_figma::AssetManifestEntry { + let result = UpstreamResult { + raw: json!({"content":[{"type":"text","text":result["data"].to_string()}]}), + }; + let AssetExportOutcome::Entry(entry) = + asset_export_from_result(&result, "fixture", None, request).unwrap() + else { + panic!("expected entry") + }; + *entry +} + +#[test] +fn nested_vector_fallback_preserves_real_paths_color_opacity_and_holes() { + let (request, result) = execute(vector(), AssetFormat::Svg, "failed", false); + let asset = decoded(&request, &result); + assert_eq!(asset.status, AssetStatus::Exported); + let svg = String::from_utf8(STANDARD.decode(asset.data_base64.unwrap()).unwrap()).unwrap(); + let mut reader = quick_xml::Reader::from_str(&svg); + while !matches!(reader.read_event().unwrap(), quick_xml::events::Event::Eof) {} + for fragment in [ + "" + ); + for format in [AssetFormat::Png, AssetFormat::Pdf] { + let (request, result) = execute(vector(), format, "failed", false); + assert_eq!(decoded(&request, &result).status, AssetStatus::Failed); + assert!(result["data"].get("text").is_none()); + } +} + +#[test] +fn lookup_and_export_timeouts_are_distinct_and_bounded() { + for (mode, stage) in [("lookup-timeout", "lookup"), ("export-timeout", "export")] { + let mut node = vector(); + node["isMask"] = json!(true); + let (request, result) = execute(node, AssetFormat::Svg, mode, false); + let asset = decoded(&request, &result); + assert_eq!(asset.status, AssetStatus::Failed); + assert_eq!(asset.error_code.as_deref(), Some("DEVUP_ASSET_TIMEOUT")); + let detail = asset.error_detail.unwrap(); + assert!( + detail.starts_with(stage) && detail.contains("DEVUP_ASSET_TIMEOUT:"), + "{detail}" + ); + assert_eq!(result["writes"], 0); + } +} + +#[test] +fn hung_renderer_can_fallback_and_retains_its_real_timeout_evidence() { + let (request, result) = execute(vector(), AssetFormat::Svg, "export-timeout", false); + let asset = decoded(&request, &result); + assert_eq!(asset.status, AssetStatus::Exported); + let diagnostics = asset.export_diagnostics.unwrap(); + assert_eq!(diagnostics.method, "geometry"); + assert!( + diagnostics + .native_error + .unwrap() + .contains("DEVUP_ASSET_TIMEOUT") + ); + assert!( + diagnostics + .stages + .iter() + .any(|s| s.stage == "native-export" && s.elapsed_ms >= 790) + ); + assert!( + diagnostics + .stages + .iter() + .any(|s| s.stage == "geometry-fallback") + ); + assert_eq!(result["exports"], 1); +} + +#[test] +fn a_late_native_answer_cannot_overwrite_the_validated_fallback() { + let (request, result) = execute(vector(), AssetFormat::Svg, "late-native", true); + let asset = decoded(&request, &result); + assert_eq!(asset.status, AssetStatus::Exported); + let svg = String::from_utf8(STANDARD.decode(asset.data_base64.unwrap()).unwrap()).unwrap(); + assert!(svg.contains("M0 0H32V24H0Z") && !svg.contains("M999")); + assert_eq!( + result["writes"], 1, + "only the winning geometry payload is written" + ); +} + +#[test] +fn fragment_reexport_uses_the_same_fallback_and_keeps_real_failure_details() { + use devup_mcp_figma::LargeValueReadOptions; + let (request, result) = execute(vector(), AssetFormat::Svg, "failed", false); + let asset = decoded(&request, &result); + let code = ReadToolCall::large_value( + "fixture", + LargeValueReadOptions { + node_id: request.node_id.clone(), + field: "$export:svg".into(), + offset: 0, + max_chunk_bytes: 65536, + byte_length: asset.byte_length.unwrap(), + sha256: asset.sha256.unwrap(), + version: None, + }, + ) + .arguments()["code"] + .clone(); + let fragment = run(code.clone(), vector(), "failed", true); + assert_eq!(fragment["data"]["kind"], "devupLargeValueFragment"); + assert_eq!( + STANDARD + .decode(fragment["data"]["dataBase64"].as_str().unwrap()) + .unwrap(), + STANDARD.decode(asset.data_base64.unwrap()).unwrap() + ); + let mut node = vector(); + node["isMask"] = json!(true); + let failed = run(code, node, "failed", true); + assert_eq!(failed["data"]["kind"], "devupLargeValueUnsupported"); + let detail = failed["data"]["errorDetail"].as_str().unwrap(); + assert!(detail.contains("renderer exploded") && detail.contains("mask")); + assert_eq!(failed["writes"], 0); +} diff --git a/crates/devup-mcp-figma/tests/original_image_script.mjs b/crates/devup-mcp-figma/tests/original_image_script.mjs index a2bea117..9717906b 100644 --- a/crates/devup-mcp-figma/tests/original_image_script.mjs +++ b/crates/devup-mcp-figma/tests/original_image_script.mjs @@ -4,6 +4,7 @@ import { test } from 'node:test'; import { createHash } from 'node:crypto'; const source = readFileSync(new URL('../src/scripts/assets.js', import.meta.url), 'utf8') + .replace('"__DEVUP_ASSET_RENDER_HELPERS__"', readFileSync(new URL('../src/scripts/asset_render_helpers.js', import.meta.url), 'utf8')) .replace('"__DEVUP_LARGE_VALUE_HELPERS__"', readFileSync(new URL('../src/scripts/large_value_helpers.js', import.meta.url), 'utf8')); const bytes = Uint8Array.from([255, 216, 255, 224, 1, 2, 3]); async function read(overrides = {}, api = {}) { diff --git a/crates/devup-mcp/Cargo.toml b/crates/devup-mcp/Cargo.toml index 3fc87f88..e2c7714f 100644 --- a/crates/devup-mcp/Cargo.toml +++ b/crates/devup-mcp/Cargo.toml @@ -17,6 +17,7 @@ devup-mcp-figma = { path = "../devup-mcp-figma" } devup-mcp-visual = { path = "../devup-mcp-visual" } oxc_allocator.workspace = true oxc_ast.workspace = true +oxc_ast_visit.workspace = true oxc_parser.workspace = true oxc_span.workspace = true dunce.workspace = true @@ -26,6 +27,7 @@ rand.workspace = true # dev-dependency for the axum-backed test servers; a normal dependency serves # both, so the duplicate entry below is gone. reqwest.workspace = true +quick-xml.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/devup-mcp/src/server/asset_jobs.rs b/crates/devup-mcp/src/server/asset_jobs.rs index 216bd4d5..45931520 100644 --- a/crates/devup-mcp/src/server/asset_jobs.rs +++ b/crates/devup-mcp/src/server/asset_jobs.rs @@ -173,7 +173,7 @@ impl AssetJob { for progress in &mut state.assets { if let Some(asset) = assets.iter().find(|a| progress["assetId"] == a.asset_id) { *progress = json!({"assetId":asset.asset_id,"status":asset.status,"byteLength":asset.byte_length, - "sha256":asset.sha256,"errorCode":asset.error_code}); + "sha256":asset.sha256,"errorCode":asset.error_code,"errorDetail":asset.error_detail,"exportDiagnostics":asset.export_diagnostics}); } } state.stage = "projection-and-write"; @@ -210,6 +210,11 @@ impl AssetJob { ), _ => ("acquisition", json!({"kind":"resource-or-metadata"})), }; + let call_timeout = if call.is_asset_read() { + devup_mcp_figma::asset_timeout() + Duration::from_secs(10) + } else { + CALL_TIMEOUT + }; loop { let began = Instant::now(); { @@ -218,10 +223,10 @@ impl AssetJob { if state.calls.len() == 128 { state.calls.remove(0); } - state.calls.push(json!({"stage":stage,"detail":detail,"state":"running","startedMs":self.started.elapsed().as_millis() as u64})); + state.calls.push(json!({"stage":stage,"detail":detail,"state":"running","startedMs":self.started.elapsed().as_millis() as u64,"timeoutMs":call_timeout.as_millis() as u64})); } let result = match server - .call_waiting_out_a_spent_allowance(call.clone(), Some(CALL_TIMEOUT)) + .call_waiting_out_a_spent_allowance(call.clone(), Some(call_timeout)) .await { Ok(result) => match super::operation::upstream_error(&result.raw) { @@ -270,6 +275,7 @@ impl AssetJob { "sha256", "errorCode", "errorDetail", + "exportDiagnostics", "outputPath", "path", ] { @@ -318,6 +324,15 @@ impl AssetJob { fn response(&self) -> Result { let state = self.state.lock().unwrap(); + let mut calls = state.calls.clone(); + for call in &mut calls { + if call["state"] == "running" { + call["elapsedMs"] = json!( + (self.started.elapsed().as_millis() as u64) + .saturating_sub(call["startedMs"].as_u64().unwrap_or(0)) + ); + } + } let assets: Vec<_> = state .assets .iter() @@ -360,9 +375,10 @@ impl AssetJob { json!({"tool":"devup_figma_export","arguments":{"jobId":self.id,"jobAction":if state.state=="paused" {"resume"} else {"status"}}}) }; let job = json!({"jobId":self.id,"budget":self.budget,"state":state.state,"stage":state.stage,"assets":assets, - "completedCalls":state.completed_calls,"calls":state.calls,"elapsedMs":self.started.elapsed().as_millis() as u64, + "completedCalls":state.completed_calls,"calls":calls,"elapsedMs":self.started.elapsed().as_millis() as u64, "lastError":state.error,"checkpointScope":"server-process","retentionSeconds":JOB_LIFETIME.as_secs(), "resultRetentionSeconds":RESULT_RETENTION.as_secs(),"callTimeoutSeconds":CALL_TIMEOUT.as_secs(), + "assetTimeoutMs":devup_mcp_figma::asset_timeout().as_millis() as u64, "nextActionReason":if terminal && next_action.is_null() { Some("This job has finished; status polling repeats the retained result. No corrective action is available.") } else { None }, "nextAction":next_action}); // Preserve the existing error contract for code-only upstream refusals, @@ -474,6 +490,14 @@ mod tests { job.response().unwrap()["exportJob"]["nextAction"]["arguments"]["jobAction"], "status" ); + job.state + .lock() + .unwrap() + .calls + .push(json!({"stage":"asset-export","state":"running","startedMs":0})); + let progress = job.response().unwrap(); + assert!(progress["exportJob"]["calls"][0]["elapsedMs"].is_u64()); + assert!(progress["exportJob"]["assetTimeoutMs"].is_u64()); let next = if correction { json!({"tool":"devup_figma_export","arguments":{"url":"https://www.figma.com/design/test?node-id=1-1","frameIds":["2:1"]}}) } else { diff --git a/crates/devup-mcp/src/server/feature_trace.rs b/crates/devup-mcp/src/server/feature_trace.rs index 9f5d29e0..df441730 100644 --- a/crates/devup-mcp/src/server/feature_trace.rs +++ b/crates/devup-mcp/src/server/feature_trace.rs @@ -330,6 +330,11 @@ async fn design(input: &FeatureTraceInput, artifacts: &ArtifactStore) -> Design .node_id .as_deref()); if let Some(node) = node { + let interaction_candidates = serde_json::to_value(super::interactions::candidates( + &artifact.payload.snapshot, + &[node.to_owned()], + )) + .unwrap(); match generate_component( &artifact.payload.snapshot, node, @@ -338,10 +343,12 @@ async fn design(input: &FeatureTraceInput, artifacts: &ArtifactStore) -> Design Ok(output) => { let mut design = design_source(&output.tsx); design.evidence = json!({"source":"cached-artifact","artifactId":artifact.artifact_id,"nodeId":node,"sourceMap":output.source_map,"designFingerprints":design_fingerprints(&artifact.payload.snapshot),"staleness":"unknown: cached snapshot has not been compared with live Figma"}); + design.evidence["interactiveCandidates"] = interaction_candidates; return design; } Err(e) => { return Design { + evidence: json!({"source":"cached-artifact","interactiveCandidates":interaction_candidates}), diagnostics: vec![json!({"reason":e.to_string()})], ..Design::default() }; @@ -367,6 +374,26 @@ async fn design(input: &FeatureTraceInput, artifacts: &ArtifactStore) -> Design { let mut design = design_source(source); design.evidence = json!({"source":"caller-supplied-componentTsx","nodeId":input.figma_node_id,"provenance":"UNVERIFIED: caller associates this export with the node; no cached artifact validates that association"}); + // oxc identifies actual comments so string literals cannot spoof hints. + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + let hints = if parsed.diagnostics.is_empty() && !parsed.panicked { + parsed + .program + .comments + .iter() + .filter_map(|c| { + c.span + .source_text(source) + .strip_prefix("// @devup-interaction ") + }) + .filter_map(|s| serde_json::from_str::(s).ok()) + .filter(|v| v["nodeId"].is_string() && v["prototypeActions"].is_array()) + .collect::>() + } else { + vec![] + }; + design.evidence["interactiveCandidates"] = json!(hints); return design; } Design { @@ -700,6 +727,7 @@ pub(super) async fn run( if (supplied(&input.operation_id) || supplied(&input.api_path)) && selected_ops.is_empty() { chain.push(hop("component-api",json!({"operationId":input.operation_id,"apiPath":input.api_path,"method":input.method}),Value::Null,json!({}),Some("Explicit operation anchor was not found in project OpenAPI endpoints."))); } + let mut navigation_sources = vec![]; for file in &selected_files { let source = match read(&root.join(file)) { Ok(s) => s, @@ -714,6 +742,7 @@ pub(super) async fn run( continue; } }; + navigation_sources.push((file.clone(), source.clone())); let (calls, configs) = stack_diff::parse::client_references(&source); if calls.is_empty() && configs.is_empty() { chain.push(hop("component-api",json!(file),Value::Null,json!({}),Some("No supported literal API reference in this file; helpers, wrappers and dynamic calls are not resolved."))); @@ -999,9 +1028,24 @@ pub(super) async fn run( &anchor_status, ui["truncation"]["truncated"] == true, ); + let mut navigation_graph = super::navigation::graph( + &design.evidence["interactiveCandidates"], + &navigation_sources, + &input.navigation_targets, + ); + navigation_graph["sourceRoute"] = json!(input.route_path); + if let Some(route) = &input.route_path + && let Some(edges) = navigation_graph["codeEdges"].as_array_mut() + { + for edge in edges { + edge["sourceFile"] = edge["from"].clone(); + edge["from"] = json!(route); + } + } Ok(bound( json!({"status":"OK","summary":summary,"projectRoot":root,"anchors":{"routePath":input.route_path,"figmaNodeId":input.figma_node_id,"artifactId":input.artifact_id,"operationId":input.operation_id,"apiPath":input.api_path,"method":input.method,"componentPath":input.component_path,"tableName":input.table_name},"requirement":input.requirement,"acceptanceMatrix":acceptance, "chain":chain,"artifacts":traced_artifacts.into_values().collect::>(),"sourceOwnership":ownership,"reuseCandidates":reuse,"designContract":comparisons,"requiredStates":states, + "navigationGraph":navigation_graph, "designEvidence":design.evidence,"designDiagnostics":design.diagnostics,"diagnostics":diagnostics, "inventoryEvidence":{"uiTruncation":ui["truncation"],"uiLimits":ui["limits"],"uiDiagnostics":ui["diagnostics"],"uiExcludedPaths":ui["excludedPaths"],"unparsedFiles":ui["unparsedFiles"],"api":{"found":api["found"],"excludedPaths":api["excludedPaths"],"authorityNote":api["authorityNote"],"issues":array(&api["specs"]).iter().filter(|v|v.get("parseError").is_some()||v.get("readError").is_some()).collect::>()}, "db":{"found":db["found"],"excludedPaths":db["excludedPaths"],"authorityNote":db["authorityNote"],"issues":array(&db["tables"]).iter().filter(|v|v.get("parseError").is_some()||v.get("readError").is_some()).collect::>()} diff --git a/crates/devup-mcp/src/server/feedback_interactions_tests.rs b/crates/devup-mcp/src/server/feedback_interactions_tests.rs new file mode 100644 index 00000000..0540eb15 --- /dev/null +++ b/crates/devup-mcp/src/server/feedback_interactions_tests.rs @@ -0,0 +1,716 @@ +//! Feedback 002/005: executable evidence for interaction and context boundaries. +use super::*; +use devup_mcp_figma::Snapshot; + +struct Project(std::path::PathBuf); +impl Project { + fn new() -> Self { + let p = Self(std::env::temp_dir().join(format!( + "devup-feedback-{}-{}", + std::process::id(), + rand::random::() + ))); + p.write("package.json", "{}"); + p + } + fn write(&self, path: &str, text: &str) { + let path = self.0.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + fn root(&self) -> &str { + self.0.to_str().unwrap() + } +} +impl Drop for Project { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn design() -> Snapshot { + let nodes = json!([ + {"id":"screen","type":"FRAME","fields":{"name":"Main","childrenIds":["header1","checkbox","radio","card","widget","chip","timer"]}}, + {"id":"other","type":"FRAME","fields":{"name":"Inquiry","childrenIds":["header2"]}}, + {"id":"header1","type":"INSTANCE","fields":{"name":"GNB","mainComponentId":"header-master","componentProperties":{"State":{"value":"active"}},"childrenIds":["nav","privacy"]}}, + {"id":"header2","type":"INSTANCE","fields":{"name":"GNB","mainComponentId":"header-master","componentProperties":{"State":{"value":"disabled"}},"childrenIds":["nav2"]}}, + {"id":"nav","type":"TEXT","fields":{"name":"Menu item","characters":"포트폴리오","reactions":[{"trigger":{"type":"ON_CLICK"},"actions":[{"type":"NODE","navigation":"NAVIGATE","destinationId":"portfolio-screen"}]}]}}, + {"id":"nav2","type":"TEXT","fields":{"name":"Label","characters":"고객센터"}}, + {"id":"privacy","type":"TEXT","fields":{"name":"Label","characters":"개인정보 처리방침","reactions":[{"trigger":{"type":"ON_CLICK"},"action":{"type":"URL","url":"https://example.com/privacy"}}]}}, + {"id":"checkbox","type":"INSTANCE","fields":{"name":"Checkbox round","variantProperties":{"Selected":"True","Disabled":"False"},"cornerRadius":20,"strokes":[{"type":"SOLID"}]}}, + {"id":"radio","type":"INSTANCE","fields":{"name":"Radio","variantProperties":{"Selected":"False"}}}, + {"id":"card","type":"INSTANCE","fields":{"name":"PortfolioCard","reactions":[{"trigger":{"type":"ON_CLICK"},"actions":[{"type":"NODE","destinationId":"detail"}]}]}}, + {"id":"widget","type":"FRAME","fields":{"name":"reCAPTCHA widget"}}, + {"id":"chip","type":"INSTANCE","fields":{"name":"Chip chevron outline","cornerRadius":12,"paddingLeft":8,"paddingRight":8,"strokeWeight":1}}, + {"id":"timer","type":"FRAME","fields":{"name":"Animation","reactions":[{"trigger":{"type":"AFTER_TIMEOUT"},"actions":[{"type":"NODE","destinationId":"other"}]}]}} + ]); + let nodes = nodes + .as_array() + .unwrap() + .iter() + .map(|v| serde_json::from_value(v.clone()).unwrap()) + .map(|n: devup_mcp_figma::RawNode| (n.id.clone(), n)) + .collect(); + Snapshot { + file_key: "fixture".into(), + version: None, + roots: vec!["screen".into(), "other".into()], + nodes, + diagnostics: vec![], + } +} + +#[test] +fn feedback_interactions_preserve_targets_roles_states_and_unresolved_data() { + let schema = Value::Object((*interactions::output_schema()).clone()); + assert_eq!( + schema["properties"]["interactiveCandidates"]["items"]["$ref"], + "#/$defs/InteractiveCandidate" + ); + assert_eq!( + schema["$defs"]["Role"]["enum"], + json!(["link", "button", "tab", "nav", "checkbox", "radio"]) + ); + assert_eq!( + schema["$defs"]["InteractiveCandidate"]["properties"]["targetResolved"]["type"], + "boolean" + ); + let snapshot = design(); + let evidence = interactions::evidence(&snapshot, &snapshot.roots); + let candidates = evidence["interactiveCandidates"].as_array().unwrap(); + let find = |id: &str| candidates.iter().find(|c| c["nodeId"] == id).unwrap(); + assert_eq!(find("checkbox")["role"], "checkbox"); + assert_eq!(find("radio")["role"], "radio"); + assert_eq!( + find("checkbox")["states"]["variantProperties"]["Selected"], + "True" + ); + assert_eq!( + find("nav")["prototypeActions"][0]["destinationId"], + "portfolio-screen" + ); + assert_eq!( + find("privacy")["prototypeActions"][0]["url"], + "https://example.com/privacy" + ); + assert_eq!(find("card")["hasPrototypeTarget"], true); + assert_eq!(find("card")["targetResolved"], false); + assert!( + find("card")["unresolvedReasons"] + .as_array() + .unwrap() + .contains(&json!("record-id-data-not-verified")) + ); + assert!(!candidates.iter().any(|c| c["nodeId"] == "timer")); + assert!(candidates.iter().all(|c| c["targetResolved"] == false)); + let mut snapshot = design(); + let node = snapshot.nodes.get_mut("radio").unwrap(); + node.fields.insert("name".into(), json!("Tab")); + let node = snapshot.nodes.get_mut("card").unwrap(); + node.fields.insert("reactions".into(),json!([{"trigger":{"type":"ON_CLICK"},"actions":[{"type":"NODE","navigation":"OVERLAY","destinationId":"dialog"}]}])); + let candidates = + serde_json::to_value(interactions::candidates(&snapshot, &snapshot.roots)).unwrap(); + assert!( + candidates + .as_array() + .unwrap() + .iter() + .any(|c| c["nodeId"] == "radio" && c["role"] == "tab") + ); + assert!( + candidates + .as_array() + .unwrap() + .iter() + .any(|c| c["nodeId"] == "card" + && c["prototypeActions"][0]["navigation"] == "OVERLAY" + && c["targetResolved"] == false) + ); +} + +#[test] +fn feedback_shared_widgets_details_and_grouped_questions_require_review() { + let snapshot = design(); + let mut result = json!({"status":"complete","quality":{"projection":"exact"},"tsx":"","deliverable":{"isFinal":true}}); + interactions::attach(&mut result, &snapshot); + review::attach(&mut result); + assert_eq!( + result["sharedComponentCandidates"][0]["variantDifferences"], + true + ); + assert_eq!(result["widgetCandidates"][0]["nodeId"], "widget"); + assert!( + result["stateAndIconDetails"] + .as_array() + .unwrap() + .iter() + .any(|d| d["iconStyle"] == "outline-name-hint" && d["fields"]["paddingLeft"] == 8) + ); + assert_eq!(result["ambiguityQuestions"].as_array().unwrap().len(), 3); + assert!( + result["reviewChecklist"] + .as_array() + .unwrap() + .iter() + .any(|i| i["code"] == "DEVUP_CODEGEN_INTERACTION_UNMAPPED" + && i["severity"] == "required") + ); + assert_eq!(result["deliverable"]["isFinal"], false); + assert_eq!( + result["completionEvidence"]["visualCompare"]["automaticRun"], + false + ); + assert_eq!(result["interactionCoverage"]["wiredCount"], 0); + // No master identity: same name is insufficient evidence. + let mut old = snapshot; + for node in old.nodes.values_mut() { + node.fields.remove("mainComponentId"); + } + assert!( + interactions::evidence(&old, &old.roots)["sharedComponentCandidates"] + .as_array() + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn feedback_context_routes_reuse_env_names_and_no_secret_values() { + let p = Project::new(); + p.write( + "src/app/(public)/portfolio/[id]/page.tsx", + "export default function Page(){return
}", + ); + p.write( + "src/app/privacy/page.tsx", + "export default function Page(){return
}", + ); + p.write( + "src/components/Header.tsx", + "export function Header(){return
}", + ); + p.write(".env.local","NEXT_PUBLIC_RECAPTCHA_SITE_KEY=secret-site-value\nRECAPTCHA_SECRET_KEY=secret-server-value\nexport OTHER_KEY='super-private'\n# BAD=ignore\n"); + p.write(".worktrees/stale/.git", "gitdir: another"); + p.write(".worktrees/stale/.env", "EXCLUDED_KEY=private"); + let routes = project_context::run("routes", Some(p.root()), None) + .await + .unwrap(); + assert!(routes.get("components").is_none()); + assert!( + routes["routes"] + .as_array() + .unwrap() + .iter() + .any(|r| r["route"] == "/portfolio/[id]") + ); + let context = project_context::export_context(p.root()); + assert!( + context["ui"]["components"] + .as_array() + .unwrap() + .iter() + .any(|c| c["path"] == "src/components/Header.tsx") + ); + assert_eq!( + context["envKeyNames"], + json!([ + "NEXT_PUBLIC_RECAPTCHA_SITE_KEY", + "OTHER_KEY", + "RECAPTCHA_SECRET_KEY" + ]) + ); + let serialized = context.to_string(); + for value in [ + "secret-site-value", + "secret-server-value", + "super-private", + "EXCLUDED_KEY", + ] { + assert!(!serialized.contains(value)); + } + let mut result = json!({"tsx":"","interactionCoverage":{},"interactiveCandidates":[{"nodeId":"a","text":"포트폴리오"},{"nodeId":"b","text":"소개"}]}); + result = with_project_checks( + result, + Some(p.root()), + &skills::Lookup::project_only(p.0.clone(), skills::Runtime::Codex), + ); + assert_eq!( + result["routeSuggestions"][0]["suggestions"][0]["route"], + "/portfolio/[id]" + ); + assert_eq!( + result["routeSuggestions"][0]["suggestions"][0]["requiresRecordId"], + true + ); + assert_eq!(result["routeSuggestions"][1]["suggestions"], json!([])); + assert_eq!(result["routeSuggestions"][0]["targetResolved"], false); + assert!(!result.to_string().contains("secret-site-value")); +} + +#[test] +fn feedback_validation_catches_missing_routes_unmapped_actions_and_active_state() { + let inventory = json!({"found":true,"routes":[{"route":"/portfolio/[id]","appliesTo":"."},{"route":"/privacy","appliesTo":"."}]}); + let source = r#"import NavLink from 'next/link'; export function Header(){return <>견적문의전체 보기{}}>동의개인정보 처리방침Card}"#; + let report = navigation::report( + source, + &inventory, + Some( + "ABC", + ), + ); + let findings = report["findings"].as_array().unwrap(); + assert_eq!( + findings + .iter() + .filter(|f| f["rule"] == "missing-static-route") + .count(), + 1 + ); + assert!(findings.iter().any(|f| f["rule"] == "missing-static-route" + && f["severity"] == "error" + && f["href"] == "/inquiry")); + assert_eq!( + findings + .iter() + .filter(|f| f["rule"] == "interaction-unmapped") + .count(), + 3 + ); + assert!( + findings + .iter() + .any(|f| f["rule"] == "interaction-accessibility") + ); + assert!( + findings + .iter() + .any(|f| f["rule"] == "hardcoded-active-state" && f["severity"] == "info") + ); + assert_eq!(report["sourceHrefs"]["/privacy"], 1); + assert_eq!(report["renderedHrefs"]["counts"]["/privacy"], 2); + assert_eq!( + report["renderedHrefs"]["counts"]["/portfolio/real-id?x=1&y=2"], + 1 + ); + assert_eq!(report["coverage"]["runtimeVerified"], false); +} + +#[test] +fn feedback_fixed_navigation_underline_is_an_unverified_info_candidate() { + let source = r#"import Link from 'next/link'; const Header=()=> "#; + let report = navigation::report(source, &json!({}), None); + let findings = report["findings"].as_array().unwrap(); + assert_eq!( + findings + .iter() + .filter(|f| f["rule"] == "fixed-navigation-marker" && f["severity"] == "info") + .count(), + 2 + ); + assert_eq!( + findings + .iter() + .filter(|f| f["rule"] == "hardcoded-active-state") + .count(), + 1 + ); + assert_eq!(report["coverage"]["runtimeVerified"], false); + assert!( + report["elements"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["fixedVisualNavigationMarker"] == true) + .all(|e| e["staticActive"] == false) + ); + let decorative = navigation::report( + "const Card=()=> Decorative heading", + &json!({}), + None, + ); + assert!(decorative["findings"].as_array().unwrap().is_empty()); +} + +#[test] +fn feedback_prime_link_center_text_marker_respects_border_order_and_dynamic_pathname() { + let source = r#"import Link from 'next/link'; const Header=()=> "#; + let report = navigation::report(source, &json!({}), None); + let markers = report["findings"] + .as_array() + .unwrap() + .iter() + .filter(|f| f["rule"] == "fixed-navigation-marker") + .collect::>(); + assert_eq!(markers.len(), 1); + assert_eq!(markers[0]["element"], "Center"); + assert_eq!(markers[0]["severity"], "info"); + assert_eq!(report["coverage"]["candidateCount"], 2); + assert_eq!(report["coverage"]["runtimeVerified"], false); + assert!( + !report["findings"] + .as_array() + .unwrap() + .iter() + .any(|f| f["rule"] == "hardcoded-active-state") + ); +} + +#[test] +fn feedback_opaque_prime_captcha_image_is_only_a_review_candidate() { + let mut data = design(); + let root = data.nodes.get_mut("widget").unwrap(); + root.node_type = "RECTANGLE".into(); + root.fields.clear(); + root.fields.insert("name".into(), json!("image 1")); + root.fields.insert("width".into(), json!(304)); + root.fields.insert("height".into(), json!(78)); + root.fields.insert( + "fills".into(), + json!([{"type":"IMAGE","imageHash":"captured-image-hash"}]), + ); + let evidence = interactions::evidence(&data, &["widget".into()]); + assert!( + evidence["widgetCandidates"] + .as_array() + .unwrap() + .iter() + .any(|w| w["nodeId"] == "widget" + && w["verified"] == false + && w["reason"] == "captcha-sized-opaque-image-provider-and-content-unverified") + ); + data.roots = vec!["widget".into()]; + let mut result = json!({"tsx":"const View=()=> ;","deliverable":{"isFinal":true}}); + interactions::attach(&mut result, &data); + assert_eq!(result["deliverable"]["isFinal"], false); + assert_eq!(result["interactionCoverage"]["candidateCount"], 0); + for paint in [ + json!({"type":"SOLID"}), + json!({"type":"IMAGE","imageHash":"source","visible":false}), + json!({"type":"IMAGE","imageHash":"source","opacity":0.0}), + ] { + data.nodes + .get_mut("widget") + .unwrap() + .fields + .insert("fills".into(), json!([paint])); + assert!( + interactions::evidence(&data, &["widget".into()])["widgetCandidates"] + .as_array() + .unwrap() + .is_empty() + ); + } +} + +#[test] +fn feedback_validation_limits_do_not_claim_absence_or_success() { + let source = r#"import Link from 'next/link'; const Screen=()=> <>DynamicExternalHashAssetDatabase table"#; + for inventory in [ + json!({}), + json!({"found":true,"truncation":{"truncated":true}}), + json!({"found":true,"unparsedFiles":1}), + json!({"found":true,"customRoutingUnverified":true}), + json!({"found":true,"routes":[{"route":"/","appliesTo":"apps/a"},{"route":"/other","appliesTo":"apps/b"}]}), + ] { + let report = navigation::report(source, &inventory, None); + assert_eq!(report["routeCheck"]["ran"], false); + assert!( + report["findings"].as_array().unwrap().is_empty(), + "{report}" + ); + assert_eq!(report["coverage"]["dynamicTargetCount"], 1); + } + assert_eq!(navigation::source_evidence("}", + ); + let input=serde_json::from_value(json!({"tsx":"import Link from 'next/link'; const C=()=> 견적문의","projectRoot":p.root()})).unwrap(); + let mut result = json!({"ok":true,"okReason":"clean","violations":[]}); + navigation::attach_validation(&mut result, &input); + assert_eq!(result["ok"], false); + assert_eq!(result["okReason"], "error-violations"); + assert_eq!(result["violationCounts"]["error"], 1); + let input=serde_json::from_value(json!({"files":[{"path":"app/privacy/page.tsx","content":"export default function Page(){return 전체 보기}"}],"projectRoot":"Z:/must-never-be-read","strict":true})).unwrap(); + let mut result = json!({"ok":true,"okReason":"clean","violations":[]}); + navigation::attach_validation(&mut result, &input); + assert_eq!(result["ok"], false); + assert_eq!(result["okReason"], "strict-warnings"); + assert_eq!( + result["navigation"]["sources"][0]["report"]["routeCheck"]["ran"], + false + ); + assert!(result["navigation"]["routeInventory"]["projectRoot"].is_null()); +} + +#[test] +fn feedback_navigation_graph_compares_explicit_targets_without_inference() { + let candidates = + serde_json::to_value(interactions::candidates(&design(), &["screen".into()])).unwrap(); + let source = r#"import Link from 'next/link'; const C=()=> <>PortfolioPrivacy"#; + let graph = navigation::graph( + &candidates, + &[("Header.tsx".into(), source.into())], + &std::collections::BTreeMap::from([("portfolio-screen".into(), "/portfolio".into())]), + ); + let comparison = graph["comparison"].as_array().unwrap(); + assert!( + comparison + .iter() + .any(|c| c["nodeId"] == "nav" && c["status"] == "MISMATCH") + ); + assert!( + comparison + .iter() + .any(|c| c["nodeId"] == "privacy" && c["status"] == "MATCHED_STATIC_HREF") + ); + assert!(comparison.iter().any(|c| c["nodeId"] == "card" + && c["status"] == "UNVERIFIED" + && c["expectedTarget"].is_null())); + let graph = navigation::graph(&candidates, &[], &Default::default()); + assert!( + graph["comparison"] + .as_array() + .unwrap() + .iter() + .any(|c| c["status"] == "MISSING_STATIC_HREF") + ); +} + +#[test] +fn feedback_widget_compositions_and_large_metadata_are_bounded() { + let mut snapshot = design(); + for (id, name) in [("map", "Google map widget"), ("payment", "Stripe payment")] { + let node: devup_mcp_figma::RawNode = + serde_json::from_value(json!({"id":id,"type":"FRAME","fields":{"name":name}})).unwrap(); + snapshot.nodes.insert(id.into(), node); + snapshot.roots.push(id.into()); + } + for value in [ + json!({"id":"panel","type":"FRAME","fields":{"name":"Frame 123","width":304,"height":78,"childrenIds":["square","label","mark"]}}), + json!({"id":"square","type":"RECTANGLE","fields":{"width":28,"height":28}}), + json!({"id":"label","type":"TEXT","fields":{"characters":"I'm not a robot"}}), + json!({"id":"mark","type":"VECTOR","fields":{}}), + ] { + let node: devup_mcp_figma::RawNode = serde_json::from_value(value).unwrap(); + snapshot.nodes.insert(node.id.clone(), node); + } + snapshot.roots.push("panel".into()); + let evidence = interactions::evidence(&snapshot, &snapshot.roots); + for id in ["map", "payment"] { + assert!( + evidence["widgetCandidates"] + .as_array() + .unwrap() + .iter() + .any(|w| w["nodeId"] == id && w["verified"] == false) + ); + } + assert!( + evidence["widgetCandidates"] + .as_array() + .unwrap() + .iter() + .any(|w| w["nodeId"] == "panel" + && w["reason"] == "captcha-sized-panel-square-control-text-and-mark-composition" + && w["verified"] == false) + ); + for i in 0..250 { + let id = format!("button-{i}"); + let node:devup_mcp_figma::RawNode=serde_json::from_value(json!({"id":id,"type":"INSTANCE","fields":{"name":"CTA Button","componentProperties":{"State":{"value":"selected"},"Text":{"value":"x".repeat(10_000)}}}})).unwrap(); + snapshot.nodes.insert(id.clone(), node); + snapshot.roots.push(id); + } + let evidence = interactions::evidence(&snapshot, &snapshot.roots); + assert!(evidence.to_string().len() <= 65_536); + assert_eq!(evidence["interactionEvidenceLimits"]["truncated"], true); + assert!( + evidence["interactionCoverage"]["candidateCount"] + .as_u64() + .unwrap() + > evidence["interactionCoverage"]["returnedCandidateCount"] + .as_u64() + .unwrap() + ); + let mut result = json!({"tsx":"","deliverable":{"isFinal":true}}); + interactions::attach(&mut result, &snapshot); + review::attach(&mut result); + assert_eq!(result["deliverable"]["isFinal"], false); + assert!( + result["reviewChecklist"] + .as_array() + .unwrap() + .iter() + .any(|i| i["code"] == "DEVUP_CODEGEN_INTERACTION_TRUNCATED" + && i["severity"] == "required") + ); +} + +#[test] +fn feedback_native_semantics_hrefs_and_html_do_not_overstate_wiring() { + let source = r#"import Link from 'next/link'; const C=()=> <>DynamicPlaceholderCardMail
견적문의 화면에 대한 설명이며 클릭 대상이 아닙니다."#; + let inventory = json!({"found":true,"routes":[{"route":"/portfolio/[id]","appliesTo":"."}]}); + let html = r#"Privacy"#; + let report = navigation::report(source, &inventory, Some(html)); + assert_eq!(report["coverage"]["nativeToggleOnlyCount"], 1); + assert_eq!(report["coverage"]["dynamicTargetCount"], 1); + assert_eq!(report["coverage"]["wiredCount"], 5); + assert!( + !report["findings"] + .as_array() + .unwrap() + .iter() + .any(|f| f["rule"] == "missing-static-route") + ); + assert_eq!(report["renderedHrefs"]["counts"], json!({"/privacy":1})); + assert_eq!(report["renderedHrefs"]["parsed"], true); +} + +#[tokio::test] +async fn feedback_route_handlers_public_files_and_custom_routing_avoid_false_errors() { + let p = Project::new(); + p.write( + "app/page.tsx", + "export default function Page(){return
}", + ); + p.write("app/api/contact/route.ts", "export function GET(){}"); + p.write("public/download", "synthetic"); + let source = "const C=()=> <>APIDownload"; + let inventory = project_context::routes_for_validation(Some(p.root())); + assert_eq!( + navigation::report(source, &inventory, None)["findings"], + json!([]) + ); + p.write( + "next.config.ts", + "export default { rewrites: async()=> [] }", + ); + let inventory = project_context::routes_for_validation(Some(p.root())); + assert_eq!( + navigation::report("CTA", &inventory, None)["routeCheck"]["ran"], + false + ); +} + +#[tokio::test] +async fn feedback_feature_trace_reads_real_export_hints_and_source_slice() { + let p = Project::new(); + p.write("app/page.tsx","import Link from 'next/link'; export default function Page(){return Card}"); + let hint = json!({"nodeId":"card","prototypeActions":[{"destinationId":"portfolio-screen"}],"states":{"selected":true}}); + let input=serde_json::from_value(json!({"projectRoot":p.root(),"routePath":"/","figmaNodeId":"screen","componentTsx":format!("const C=()=> ;\n// @devup-interaction {hint}"),"navigationTargets":{"portfolio-screen":"/portfolio"}})).unwrap(); + let report = feature_trace::run(input, &artifacts::ArtifactStore::default()) + .await + .unwrap(); + assert_eq!( + report["navigationGraph"]["comparison"][0]["status"], + "MATCHED_STATIC_HREF" + ); + assert_eq!( + report["navigationGraph"]["comparison"][0]["expectedTarget"], + "/portfolio" + ); + assert_eq!(report["navigationGraph"]["codeEdges"][0]["from"], "/"); +} + +#[test] +fn feedback_boolean_state_and_form_context_keep_bare_buttons_unresolved() { + let report = navigation::report( + "const C=()=> <>
Privacy", + &json!({}), + None, + ); + assert_eq!(report["coverage"]["candidateCount"], 4); + assert_eq!(report["coverage"]["wiredCount"], 2); + let findings = report["findings"].as_array().unwrap(); + assert_eq!( + findings + .iter() + .filter(|f| f["rule"] == "interaction-unmapped") + .count(), + 1 + ); + assert!( + findings + .iter() + .any(|f| f["rule"] == "hardcoded-active-state") + ); +} + +#[test] +fn feedback_context_budget_caps_or_defers_explicitly() { + let p = Project::new(); + p.write( + "app/page.tsx", + "export default function Page(){return
}", + ); + for i in 0..80 { + p.write( + &format!("components/Component{i}.tsx"), + &format!( + "export function Component{i}(props: {{label: '{}'}}){{return
}}", + "x".repeat(800) + ), + ); + } + for length in [490_000, 520_000] { + let mut result = json!({"existingPayload":"x".repeat(length),"interactiveCandidates":[]}); + project_context::attach_export_context(&mut result, p.root()); + assert!( + serde_json::to_vec(&tool_result(result.clone())) + .unwrap() + .len() + <= delivery::MAX_INLINE_TOTAL_BYTES + ); + assert!( + result["projectContext"]["exportContextLimits"]["truncated"] == true + || result["projectContext"]["state"] == "deferred" + ); + } +} + +#[test] +fn feedback_read_failures_and_direct_selection_preserve_review_and_source_maps() { + let mut snapshot = design(); + snapshot + .nodes + .get_mut("nav") + .unwrap() + .field_errors + .insert("reactions".into(), "field read failed".into()); + let original_map = + json!({"tsx":[{"elementIndex":0,"nodeId":"nav","generatedProperty":"fontSize"}]}); + let issues=(0..30).map(|i|json!({"code":format!("OLD_{i}"),"fidelityImpact":"lossy","message":"Existing issue"})).collect::>(); + let mut result = json!({"source":{"nodeId":"screen"},"tsx":"","sourceMap":original_map,"projectionIssues":issues,"deliverable":{"isFinal":true}}); + interactions::attach(&mut result, &snapshot); + review::attach(&mut result); + assert_eq!(result["sourceMap"], original_map); + assert!( + !result["interactiveCandidates"] + .as_array() + .unwrap() + .iter() + .any(|c| c["nodeId"] == "nav2") + ); + assert!( + result["reviewChecklist"] + .as_array() + .unwrap() + .iter() + .any(|c| c["code"] == "DEVUP_CODEGEN_INTERACTION_UNVERIFIED" + && c["severity"] == "required") + ); + assert_eq!(result["deliverable"]["isFinal"], false); + assert!(result["summary"]["omittedReviewItems"].as_u64().unwrap() > 0); +} diff --git a/crates/devup-mcp/src/server/guide.rs b/crates/devup-mcp/src/server/guide.rs index 4959d28b..1b449c1e 100644 --- a/crates/devup-mcp/src/server/guide.rs +++ b/crates/devup-mcp/src/server/guide.rs @@ -35,12 +35,12 @@ pub const INSTRUCTIONS: &str = concat!( "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; ", - "a complete response marks it with deliverable.isFinal.\n", + "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", - "3. Request only the outputs you will read, and read the rest of the rules before your second call: ", - "resources/read \"devup://guide/usage\" carries output sizing, verification boundaries, SECTION batching, ", - "asset placement and delivery. It is a resource so that a caller who never touches Figma never pays for it." + "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." ); /// The rules that moved out of `instructions`, verbatim in meaning. This is a diff --git a/crates/devup-mcp/src/server/interactions.rs b/crates/devup-mcp/src/server/interactions.rs new file mode 100644 index 00000000..0333ebea --- /dev/null +++ b/crates/devup-mcp/src/server/interactions.rs @@ -0,0 +1,718 @@ +//! Design interaction evidence. Candidates are review hints, never invented links. +use std::collections::{BTreeMap, BTreeSet}; + +use devup_mcp_figma::{RawNode, Snapshot}; +use serde::Serialize; +use serde_json::{Value, json}; + +const MAX_CANDIDATES: usize = 128; +const MAX_DETAILS: usize = 64; +fn bounded(value: &Value, depth: usize, truncated: &mut bool) -> Value { + if depth == 0 && (value.is_object() || value.is_array()) { + *truncated = true; + return json!("omitted: nested detail cap"); + } + match value { + Value::String(s) if s.chars().count() > 160 => { + *truncated = true; + json!(s.chars().take(160).collect::()) + } + Value::Array(a) => { + if a.len() > 16 { + *truncated = true + } + Value::Array( + a.iter() + .take(16) + .map(|v| bounded(v, depth - 1, truncated)) + .collect(), + ) + } + Value::Object(o) => { + if o.len() > 16 { + *truncated = true + } + Value::Object( + o.iter() + .take(16) + .map(|(k, v)| (k.clone(), bounded(v, depth - 1, truncated))) + .collect(), + ) + } + _ => value.clone(), + } +} + +#[derive(Debug, Clone, Copy, Serialize, schemars::JsonSchema)] +#[serde(rename_all = "lowercase")] +pub(super) enum Role { + Link, + Button, + Tab, + Nav, + Checkbox, + Radio, +} + +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(super) struct InteractiveCandidate { + pub node_id: String, + pub name: String, + pub text: String, + pub role: Role, + pub reason: Vec, + pub has_prototype_target: bool, + /// A design target alone never resolves a code route or a record ID. + pub target_resolved: bool, + pub unresolved_reasons: Vec, + pub prototype_actions: Vec, + pub states: Value, +} + +pub(super) fn output_schema() -> std::sync::Arc> { + let mut candidate = serde_json::to_value(schemars::schema_for!(InteractiveCandidate)) + .expect("candidate schema"); + let mut definitions = candidate + .as_object_mut() + .unwrap() + .remove("$defs") + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + candidate.as_object_mut().unwrap().remove("$schema"); + // Value's schemars schema is a bare true; MCP clients require objects at + // every schema position. These values are structured action/state objects. + candidate["properties"]["prototypeActions"]["items"] = + json!({"type":"object","additionalProperties":{}}); + candidate["properties"]["states"] = json!({"type":"object","additionalProperties":{}}); + definitions.insert("InteractiveCandidate".into(), candidate); + std::sync::Arc::new(json!({"type":"object","additionalProperties":{},"properties":{"interactiveCandidates":{"type":"array","items":{"$ref":"#/$defs/InteractiveCandidate"}}},"$defs":definitions}).as_object().unwrap().clone()) +} + +fn str_field<'a>(node: &'a RawNode, field: &str) -> &'a str { + node.typed_view().string(field).unwrap_or("") +} + +fn has(label: &str, words: &[&str]) -> bool { + let label = label.to_lowercase(); + words.iter().any(|word| label.contains(word)) +} + +pub(super) fn label_role(label: &str) -> Option { + if has(label, &["checkbox", "check box", "체크박스", "동의"]) { + Some(Role::Checkbox) + } else if has(label, &["radio", "라디오"]) { + Some(Role::Radio) + } else if has(label, &["탭"]) + || label + .to_lowercase() + .split(|c: char| !c.is_ascii_alphanumeric()) + .any(|s| ["tab", "tabs", "tabitem"].contains(&s)) + { + Some(Role::Tab) + } else if has( + label, + &["gnb", "navigation", "navbar", "breadcrumb", "메뉴", "menu"], + ) { + Some(Role::Nav) + } else if has( + label, + &[ + "link", + "로고", + "logo", + "개인정보", + "이용약관", + "privacy", + "terms", + "portfolio card", + "portfoliocard", + "카드", + "breadcrumb", + ], + ) { + Some(Role::Link) + } else if has( + label, + &[ + "button", + "btn", + "cta", + "견적", + "문의하기", + "전체 보기", + "전체보기", + "사례 보기", + "view all", + "arrow", + "chevron", + "화살표", + "carousel", + ], + ) { + Some(Role::Button) + } else if [ + "소개", + "서비스", + "포트폴리오", + "고객센터", + "seo · geo", + "seo/geo", + ] + .contains(&label.trim().to_lowercase().as_str()) + { + Some(Role::Link) + } else { + None + } +} + +fn descendants(snapshot: &Snapshot, root: &str) -> Vec { + let mut pending = vec![root.to_owned()]; + let mut visited = BTreeSet::new(); + let mut result = vec![]; + while let Some(id) = pending.pop() { + if !visited.insert(id.clone()) { + continue; + } + let Some(node) = snapshot.nodes.get(&id) else { + continue; + }; + if node.typed_view().bool("visible") == Some(false) { + continue; + } + pending.extend(node.typed_view().child_ids().map(str::to_owned)); + result.push(id); + } + result +} + +fn node_text(snapshot: &Snapshot, id: &str) -> String { + descendants(snapshot, id) + .iter() + .filter_map(|id| snapshot.nodes[id].typed_view().string("characters")) + .take(40) + .collect::>() + .join(" ") + .chars() + .take(500) + .collect() +} + +/// Both Plugin API reactions.actions and older reaction.action are captured. +fn actions(node: &RawNode) -> Vec { + let mut result = vec![]; + for reaction in node + .typed_view() + .value("reactions") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if reaction["trigger"]["type"] == "AFTER_TIMEOUT" { + continue; + } + for action in reaction + .get("action") + .into_iter() + .chain(reaction["actions"].as_array().into_iter().flatten()) + { + let kind = action["type"].as_str().unwrap_or("UNKNOWN"); + let navigation = action["navigation"].as_str(); + result.push(json!({"trigger":reaction["trigger"]["type"],"type":kind, + "navigation":navigation,"destinationId":action["destinationId"],"url":action["url"], + "codeTargetResolved":false})); + } + } + result +} + +pub(super) fn candidates(snapshot: &Snapshot, roots: &[String]) -> Vec { + let ids: BTreeSet<_> = roots + .iter() + .flat_map(|id| descendants(snapshot, id)) + .collect(); + let mut result = vec![]; + for id in &ids { + let node = &snapshot.nodes[id]; + let name = str_field(node, "name"); + let text = node_text(snapshot, id); + let prototype_actions = actions(node); + let explicit = label_role(name).or_else(|| view_component_role(node)); + let text_role = label_role(&text); + let view = node.typed_view(); + let boxed = view.number("cornerRadius").is_some_and(|r| r > 0.0) + && view + .value("fills") + .and_then(Value::as_array) + .is_some_and(|f| !f.is_empty()) + && !text.is_empty() + && text.chars().count() <= 35; + // Text leaves inside a named control are not separate controls. + let named_parent = ids.iter().any(|parent| { + snapshot.nodes[parent] + .typed_view() + .child_ids() + .any(|child| child == id) + && label_role(str_field(&snapshot.nodes[parent], "name")) + .is_some_and(|role| !matches!(role, Role::Nav)) + }); + let role = explicit + .or_else(|| { + (!named_parent && node.node_type == "TEXT") + .then_some(text_role) + .flatten() + }) + .or_else(|| boxed.then_some(Role::Button)) + .or_else(|| (!prototype_actions.is_empty()).then_some(Role::Button)); + let Some(mut role) = role else { continue }; + if !prototype_actions.is_empty() + && prototype_actions + .iter() + .any(|a| a["url"].is_string() || a["navigation"] == "NAVIGATE") + { + role = Role::Link; + } + let has_target = prototype_actions + .iter() + .any(|a| a["url"].is_string() || a["destinationId"].is_string()); + let mut reason = vec![]; + if explicit.is_some() { + reason.push("semantic-layer-or-instance-name".into()); + } + if !named_parent && text_role.is_some() { + reason.push("action-label".into()); + } + if boxed { + reason.push("short-text-background-rounded-box".into()); + } + if !prototype_actions.is_empty() { + reason.push("prototype-reaction".into()); + } + let mut unresolved = vec!["static-export-does-not-wire-events".into()]; + if has_target { + unresolved + .push("prototype-target-needs-explicit-code-route-or-behavior-mapping".into()); + } else { + unresolved.push("destination-or-action-not-declared".into()); + } + if has(name, &["card", "카드", "portfolio", "포트폴리오"]) { + unresolved.push("record-id-data-not-verified".into()); + } + let properties = view + .value("componentProperties") + .cloned() + .unwrap_or(json!({})); + let variants = view + .value("variantProperties") + .cloned() + .unwrap_or(json!({})); + let mut truncated = false; + let properties = bounded(&properties, 3, &mut truncated); + let variants = bounded(&variants, 3, &mut truncated); + result.push(InteractiveCandidate { + node_id: id.clone(), + name: name.into(), + text, + role, + reason, + has_prototype_target: has_target, + target_resolved: false, + unresolved_reasons: unresolved, + prototype_actions, + states: json!({"componentProperties":properties,"variantProperties":variants, + "runtimeStateVerified":false,"captureTruncated":truncated}), + }); + } + result +} + +fn view_component_role(node: &RawNode) -> Option { + node.typed_view() + .string("mainComponentName") + .and_then(label_role) + .or_else(|| { + node.typed_view() + .value("mainComponent") + .and_then(|v| v["name"].as_str()) + .and_then(label_role) + }) +} + +fn widget_composition(snapshot: &Snapshot, node: &RawNode) -> Option<&'static str> { + let view = node.typed_view(); + let text = node_text(snapshot, &node.id).to_lowercase(); + // Prime's checkbox CAPTCHA is a flattened, unnamed 304x78 image. Its + // dimensions suggest review, not a verified provider or image content. + if matches!((view.number("width"),view.number("height")),(Some(w),Some(h)) if (w-304.0).abs()<=1.0 && (h-78.0).abs()<=1.0) + && view + .value("fills") + .and_then(Value::as_array) + .is_some_and(|fills| { + fills.iter().any(|p| { + p["type"] == "IMAGE" + && p["visible"] != false + && p["opacity"].as_f64().is_none_or(|o| o > 0.0) + && p["imageHash"].as_str().is_some_and(|hash| !hash.is_empty()) + }) + }) + { + return Some("captcha-sized-opaque-image-provider-and-content-unverified"); + } + if ["card number", "카드 번호", "mm/yy", "cvc", "cvv"] + .iter() + .filter(|s| text.contains(**s)) + .count() + >= 2 + { + return Some("payment-field-composition"); + } + let children = view + .child_ids() + .filter_map(|id| snapshot.nodes.get(id)) + .collect::>(); + let small_square=children.iter().any(|child| { + let v=child.typed_view(); + matches!((v.number("width"),v.number("height")),(Some(w),Some(h)) if (15.0..=35.0).contains(&w) && (w-h).abs()<=2.0) + }); + if matches!((view.number("width"),view.number("height")),(Some(w),Some(h)) if (250.0..=400.0).contains(&w) && (60.0..=100.0).contains(&h)) + && small_square + && children.iter().any(|c| c.node_type == "TEXT") + && children.len() >= 3 + { + return Some("captcha-sized-panel-square-control-text-and-mark-composition"); + } + if children.len() >= 3 + && children + .iter() + .any(|c| has(str_field(c, "name"), &["pin", "marker", "마커"])) + && children.iter().any(|c| c.node_type == "VECTOR") + && view.number("width").is_some_and(|w| w >= 200.0) + { + return Some("map-sized-panel-marker-and-vector-composition"); + } + None +} + +fn append_hints(result: &mut Value, data: &Value) { + let mut hints = String::new(); + for candidate in result_candidates(data) { + let hint = json!({"nodeId":candidate["nodeId"],"role":candidate["role"],"prototypeActions":candidate["prototypeActions"],"states":candidate["states"],"targetResolved":false}); + hints.push_str("\n// @devup-interaction "); + hints.push_str( + &hint + .to_string() + .replace('\u{2028}', "\\u2028") + .replace('\u{2029}', "\\u2029"), + ); + } + for field in ["tsx", "componentTsx", "responsiveTsx"] { + if let Some(code) = result.get(field).and_then(Value::as_str).map(str::to_owned) { + result[field] = json!(format!("{code}{hints}")); + } + } +} + +pub(super) fn evidence(snapshot: &Snapshot, roots: &[String]) -> Value { + let mut candidates = candidates(snapshot, roots); + let total_candidates = candidates.len(); + candidates.truncate(MAX_CANDIDATES); + let ids: BTreeSet<_> = roots + .iter() + .flat_map(|r| descendants(snapshot, r)) + .collect(); + let mut masters: BTreeMap> = BTreeMap::new(); + let mut widgets = vec![]; + let mut details = vec![]; + let mut read_errors = vec![]; + for id in ids { + let node = &snapshot.nodes[&id]; + let view = node.typed_view(); + let name = str_field(node, "name"); + for (field, error) in &node.field_errors { + if [ + "reactions", + "componentProperties", + "variantProperties", + "mainComponentId", + "mainComponentSetId", + ] + .contains(&field.as_str()) + { + read_errors.push(json!({"nodeId":id,"field":field,"reason":error})); + } + } + if let Some(master) = view + .string("mainComponentSetId") + .or_else(|| view.string("mainComponentId")) + .or_else(|| view.string("componentId")) + .or_else(|| view.value("mainComponent").and_then(|v| v["id"].as_str())) + { + let labels = descendants(snapshot, &id) + .iter() + .filter_map(|id| snapshot.nodes[id].typed_view().string("characters")) + .map(str::to_owned) + .collect::>(); + let mut truncated = false; + masters.entry(master.into()).or_default().push(json!({"nodeId":id,"name":name,"labelCount":labels.len(),"labels":labels.iter().take(24).collect::>(), + "text":node_text(snapshot,&id),"variantProperties":view.value("variantProperties").map(|v|bounded(v,3,&mut truncated)),"componentProperties":view.value("componentProperties").map(|v|bounded(v,3,&mut truncated)),"detailTruncated":truncated || labels.len()>24})); + } + let named_widget = has( + name, + &[ + "recaptcha", + "captcha", + "map widget", + "google map", + "payment", + "stripe", + "카카오맵", + "지도", + "결제", + ], + ); + let composition = widget_composition(snapshot, node); + if named_widget || composition.is_some() { + widgets.push(json!({"nodeId":id,"name":name,"reason":if named_widget{"widget-layer-name"}else{composition.unwrap()},"verified":false, + "requiredAction":"Confirm provider, integration, consent and env key names; never invent keys or copy a widget screenshot as functionality."})); + } + if has( + name, + &[ + "icon", + "chevron", + "check", + "chip", + "checkbox", + "아이콘", + "칩", + "체크", + ], + ) { + let mut fields = serde_json::Map::new(); + let mut truncated = false; + for field in [ + "cornerRadius", + "rectangleCornerRadii", + "strokes", + "strokeWeight", + "fills", + "paddingLeft", + "paddingRight", + "paddingTop", + "paddingBottom", + "variantProperties", + "componentProperties", + ] { + if let Some(value) = view.value(field) { + fields.insert(field.into(), bounded(value, 4, &mut truncated)); + } + } + details.push(json!({"nodeId":id,"name":name,"fields":fields,"fieldErrors":node.field_errors,"detailTruncated":truncated, + "iconStyle":if has(name,&["outline","outlined","line"]){"outline-name-hint"}else if has(name,&["filled","solid"]){"filled-name-hint"}else{"unresolved"}, + "shapeAndStateVerified":false})); + } + } + let mut shared = masters.into_iter().filter(|(_,members)| members.len()>1).map(|(master,mut members)| { + let signatures: BTreeSet<_> = members.iter().map(|m| json!([m["text"],m["variantProperties"],m["componentProperties"]]).to_string()).collect(); + let total=members.len(); members.truncate(24); + json!({"componentId":master,"instances":members,"instanceCount":total,"instancesOmitted":total-members.len(),"variantDifferences":signatures.len()>1, + "requiredAction":"Reuse one shared component and wire links once; confirm label/menu/state differences before applying a version across screens."}) + }).collect::>(); + let truncated = total_candidates > candidates.len() + || widgets.len() > MAX_DETAILS + || details.len() > MAX_DETAILS + || shared.len() > MAX_DETAILS; + let limits = json!({"truncated":truncated,"omittedCandidates":total_candidates-candidates.len(),"omittedWidgets":widgets.len().saturating_sub(MAX_DETAILS),"omittedStateDetails":details.len().saturating_sub(MAX_DETAILS),"omittedSharedGroups":shared.len().saturating_sub(MAX_DETAILS),"maxCandidates":MAX_CANDIDATES,"maxDetails":MAX_DETAILS,"maxInstancesPerGroup":24,"stateFieldCaps":{"maxEntries":16,"maxStringChars":160,"maxDepth":3}}); + widgets.truncate(MAX_DETAILS); + details.truncate(MAX_DETAILS); + shared.truncate(MAX_DETAILS); + let mut evidence = json!({"interactiveCandidates":candidates,"sharedComponentCandidates":shared,"widgetCandidates":widgets,"stateAndIconDetails":details, + "interactionEvidenceLimits":limits, + "interactionReadErrors":read_errors,"stateProjection":"Variants/prototype actions appear in @devup-interaction TSX comments and structured metadata. They do not implement runtime state/events or add native semantics automatically.", + "sharedComponentLimitations":"Only explicit master/set identity is grouped. Old snapshots without identity cannot establish shared components; no name match is treated as proof.", + "interactionCoverage":{"candidateCount":total_candidates,"returnedCandidateCount":candidates.len(),"wiredCount":0,"runtimeVerified":false,"scope":"Design candidates only; code wiring must be measured after implementation."}}); + // Metadata stays bounded even when a capture contains large component text properties. + evidence["interactionEvidenceLimits"]["maxBytes"] = json!(65_536); + while evidence.to_string().len() > 65_536 { + let field = [ + "interactiveCandidates", + "sharedComponentCandidates", + "widgetCandidates", + "stateAndIconDetails", + "interactionReadErrors", + ] + .into_iter() + .filter(|f| evidence[*f].as_array().is_some_and(|a| !a.is_empty())) + .max_by_key(|f| evidence[*f].to_string().len()); + let Some(field) = field else { break }; + evidence[field].as_array_mut().unwrap().pop(); + evidence["interactionEvidenceLimits"]["truncated"] = json!(true); + let count = evidence["interactionEvidenceLimits"]["byteCapOmissions"][field] + .as_u64() + .unwrap_or(0); + evidence["interactionEvidenceLimits"]["byteCapOmissions"][field] = json!(count + 1); + } + evidence["interactionCoverage"]["returnedCandidateCount"] = + json!(result_candidates(&evidence).len()); + evidence +} + +pub(super) fn attach(result: &mut Value, snapshot: &Snapshot) { + let has_code = result.get("deliverable").is_some() + || result.get("tsx").is_some() + || result.get("frames").is_some(); + if !has_code { + return; + } + let roots = if let Some(frames) = result["frames"].as_array() { + frames + .iter() + .filter_map(|f| f["nodeId"].as_str().map(str::to_owned)) + .collect() + } else if result.get("responsiveTsx").is_none() + && let Some(node) = result["source"]["nodeId"].as_str() + { + vec![node.to_owned()] + } else { + snapshot.roots.clone() + }; + let data = evidence(snapshot, &roots); + let count = data["interactiveCandidates"].as_array().map_or(0, Vec::len); + append_hints(result, &data); + result + .as_object_mut() + .unwrap() + .extend(data.as_object().unwrap().clone()); + if data["interactionEvidenceLimits"]["truncated"] == true { + result.as_object_mut().unwrap().entry("projectionIssues").or_insert(json!([])).as_array_mut().unwrap().push(json!({"code":"DEVUP_CODEGEN_INTERACTION_TRUNCATED","severity":"warning","fidelityImpact":"none","message":"Interaction evidence exceeded a named cap; candidate coverage is incomplete.","details":{"classification":"interaction-unverified","nextAction":"Read interactionEvidenceLimits and narrow the export selection or inspect the raw snapshot before claiming all candidates resolved."}})); + if result["deliverable"].is_object() { + result["deliverable"]["isFinal"] = json!(false); + result["deliverable"]["note"] = json!( + "Interaction evidence is truncated; review the named caps before accepting completeness." + ); + } + } + if data["interactionReadErrors"] + .as_array() + .is_some_and(|a| !a.is_empty()) + { + result.as_object_mut().unwrap().entry("projectionIssues").or_insert(json!([])).as_array_mut().unwrap().push(json!({"code":"DEVUP_CODEGEN_INTERACTION_UNVERIFIED","severity":"warning","fidelityImpact":"none","message":"Interaction/state fields could not be read; candidate coverage is incomplete.","details":{"classification":"interaction-unverified","nextAction":"Review interactionReadErrors and recollect failed fields before claiming interaction coverage."}})); + if result["deliverable"].is_object() { + result["deliverable"]["isFinal"] = json!(false); + result["deliverable"]["note"] = + json!("Interaction evidence is incomplete; review failed source fields."); + } + } + if count > 0 { + let issues = result + .as_object_mut() + .unwrap() + .entry("projectionIssues") + .or_insert(json!([])); + let owners = devup_mcp_figma::screen_owners(snapshot, roots.iter().map(String::as_str)); + let mut groups = BTreeMap::, Vec>::new(); + for candidate in result_candidates(&data) { + let id = candidate["nodeId"].as_str().unwrap(); + groups + .entry(owners.get(id).cloned().flatten()) + .or_default() + .push(candidate["nodeId"].clone()); + } + for (screen_id, node_ids) in groups { + 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."}})); + } + if result["deliverable"].is_object() { + result["deliverable"]["isFinal"] = json!(false); + result["deliverable"]["note"] = json!( + "Static visual output is available; interaction candidates require wiring and verification before functional completion." + ); + } + } + let mut questions = vec![]; + let unknown_targets = result_candidates(&data) + .iter() + .filter(|c| { + c["prototypeActions"] + .as_array() + .is_none_or(|a| a.is_empty() || a.iter().any(|a| !a["url"].is_string())) + }) + .map(|c| c["nodeId"].clone()) + .collect::>(); + if !unknown_targets.is_empty() { + questions.push(json!({"topic":"destinations-and-actions","nodeIds":unknown_targets,"question":"Confirm unresolved destinations, record IDs and local actions together; which candidates intentionally have no behavior?"})); + } + let logos = result_candidates(&data) + .iter() + .filter(|c| { + c["name"] + .as_str() + .is_some_and(|s| has(s, &["logo", "로고"])) + }) + .collect::>(); + if logos + .iter() + .filter_map(|c| c["name"].as_str()) + .collect::>() + .len() + > 1 + { + questions.push(json!({"topic":"logo-versions","nodeIds":logos.iter().map(|c|c["nodeId"].clone()).collect::>(),"question":"Different logo layer names were captured; confirm the intended version per placement before choosing or reusing assets."})); + } + if data["sharedComponentCandidates"] + .as_array() + .is_some_and(|v| v.iter().any(|v| v["variantDifferences"] == true)) + { + questions.push(json!({"topic":"shared-component-variants","question":"Which menu/label/state version applies to each screen, and should any version become site-wide?"})); + } + if data["widgetCandidates"] + .as_array() + .is_some_and(|v| !v.is_empty()) + { + questions.push(json!({"topic":"third-party-widgets","question":"Confirm widget provider and existing integration/env key names without sharing values."})); + if result["deliverable"].is_object() { + result["deliverable"]["isFinal"] = json!(false); + result["deliverable"]["note"] = json!( + "Widget candidates require provider confirmation and functional integration; a static image does not establish widget behavior." + ); + } + } + 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."], + "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 { + let root = frame["nodeId"].as_str().unwrap_or("").to_owned(); + let data = evidence(snapshot, &[root]); + append_hints(frame, &data); + frame["interactionCandidateIds"] = json!( + result_candidates(&data) + .iter() + .map(|c| c["nodeId"].clone()) + .collect::>() + ); + frame["interactionEvidencePath"] = json!("/interactiveCandidates"); + frame["interactionCoverage"] = data["interactionCoverage"].clone(); + if data["interactiveCandidates"] + .as_array() + .is_some_and(|c| !c.is_empty()) + && frame["deliverable"].is_object() + { + frame["deliverable"]["isFinal"] = json!(false); + frame["deliverable"]["note"] = json!( + "Static visual output; interactions need wiring and runtime verification." + ); + } + } + } +} + +fn result_candidates(value: &Value) -> &[Value] { + value["interactiveCandidates"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]) +} diff --git a/crates/devup-mcp/src/server/mod.rs b/crates/devup-mcp/src/server/mod.rs index 42e54011..eca9344e 100644 --- a/crates/devup-mcp/src/server/mod.rs +++ b/crates/devup-mcp/src/server/mod.rs @@ -5,8 +5,12 @@ pub mod delivery; mod design_drift; mod diagnostics; mod feature_trace; +#[cfg(test)] +mod feedback_interactions_tests; mod field_select; mod guide; +mod interactions; +mod navigation; pub mod operation; pub mod output; mod pacing; @@ -1477,7 +1481,7 @@ impl DevupServer { 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 = permissive_object_output_schema() + output_schema = interactions::output_schema() )] async fn devup_figma_export( &self, @@ -1548,6 +1552,12 @@ impl DevupServer { 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() && input.page_scaffold.is_none() && !input @@ -1754,6 +1764,10 @@ impl DevupServer { 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, @@ -1797,6 +1811,10 @@ impl DevupServer { 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, @@ -1894,6 +1912,10 @@ impl DevupServer { 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, @@ -1942,7 +1964,7 @@ 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 | all) — read-only, no session cache, never guesses. UI is opt-in and excluded from all: large monorepo inventories must not pollute token context. Identify deployments by server.commit/buildId, not version alone; server.displayVersion is a readable version+buildId.", + 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. Identify deployments by server.commit/buildId, not version alone; server.displayVersion is a readable version+buildId.", output_schema = permissive_object_output_schema() )] async fn devup_project_context( @@ -1970,7 +1992,7 @@ 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. `themeNotes` summarizes each encountered empty token category once. \ - `checkedTokens` counts $token references this TSX makes and `availableTokenCount` counts tokens devup.json defines; they count different things and are not a ratio, which `tokens` restates by name.", + Navigation rules also report unmapped action candidates/accessibility warnings, missing static App Router href errors when projectRoot inventory is complete, and literal active-state info. Dynamic hrefs/custom routing and incomplete bundles remain unverified. Optional renderedHtml reports captured anchor counts separately from source coverage; neither proves runtime behavior. `checkedTokens` counts references and `availableTokenCount` counts definitions; they are not a ratio.", output_schema = permissive_object_output_schema() )] async fn devup_ui_validate( @@ -2078,6 +2100,7 @@ impl DevupServer { } } 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()) @@ -2475,6 +2498,11 @@ fn with_project_checks( 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 @@ -3105,6 +3133,7 @@ mod section_retry_tests { 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, diff --git a/crates/devup-mcp/src/server/navigation.rs b/crates/devup-mcp/src/server/navigation.rs new file mode 100644 index 00000000..08d6f589 --- /dev/null +++ b/crates/devup-mcp/src/server/navigation.rs @@ -0,0 +1,602 @@ +//! Static navigation checks share the UI inventory; they never guess destinations. +use std::collections::{BTreeMap, BTreeSet}; + +use oxc_allocator::Allocator; +use oxc_ast::ast::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::{GetSpan, SourceType}; +use serde_json::{Value, json}; + +fn array(v: &Value) -> &[Value] { + v.as_array().map(Vec::as_slice).unwrap_or(&[]) +} + +fn literal(a: &JSXAttribute<'_>) -> Option { + match a.value.as_ref()? { + JSXAttributeValue::StringLiteral(s) => Some(s.value.to_string()), + JSXAttributeValue::ExpressionContainer(c) => match &c.expression { + JSXExpression::StringLiteral(s) => Some(s.value.to_string()), + _ => None, + }, + _ => None, + } +} + +#[derive(Default)] +struct Collector<'s> { + source: &'s str, + linked_ancestors: usize, + link_ancestors: usize, + form_ancestors: usize, + nav_ancestors: usize, + link_names: BTreeSet, + elements: Vec, + diagnostics: Vec, +} + +// Only literal visual markers on a navigation candidate warrant this hint. +// A marker can be decorative; its presence never establishes active state. +fn fixed_visual_marker(attrs: &BTreeMap<&str, &JSXAttribute<'_>>) -> bool { + let mut values = BTreeMap::::new(); + for (key, attribute) in attrs { + if let Some(value) = literal(attribute) { + values.insert((*key).into(), value); + } else if let Some(JSXAttributeValue::ExpressionContainer(container)) = &attribute.value { + match &container.expression { + JSXExpression::NumericLiteral(n) => { + values.insert((*key).into(), n.value.to_string()); + } + JSXExpression::ObjectExpression(object) if *key == "style" => { + // Spread/computed properties can override a literal marker. + if object + .properties + .iter() + .any(|p| !matches!(p, ObjectPropertyKind::ObjectProperty(p) if !p.computed)) + { + continue; + } + for property in &object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(key) = property.key.static_name() + { + match &property.value { + Expression::StringLiteral(s) => { + values.insert(key.into_owned(), s.value.to_string()); + } + Expression::NumericLiteral(n) => { + values.insert(key.into_owned(), n.value.to_string()); + } + _ => {} + } + } + } + } + _ => {} + } + } + } + values.get("borderBottom").is_some_and(|v| { + v.split_whitespace() + .any(|width| ["1px", "2px", "3px", "4px"].contains(&width)) + && v.split_whitespace().any(|s| s == "solid") + && !v.contains("transparent") + }) || (values + .get("borderBottomWidth") + .is_some_and(|v| ["1", "2", "3", "4", "1px", "2px", "3px", "4px"].contains(&v.as_str())) + && values + .get("borderBottomColor") + .is_some_and(|v| !v.is_empty() && v != "transparent")) +} + +fn boolean(a: &JSXAttribute<'_>) -> Option { + match &a.value { + None => Some(true), + Some(JSXAttributeValue::ExpressionContainer(c)) => match &c.expression { + JSXExpression::BooleanLiteral(b) => Some(b.value), + _ => None, + }, + Some(JSXAttributeValue::StringLiteral(s)) => match s.value.as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }, + _ => None, + } +} + +impl<'a> Visit<'a> for Collector<'_> { + fn visit_import_declaration(&mut self, declaration: &ImportDeclaration<'a>) { + if declaration.source.value == "next/link" { + for specifier in declaration.specifiers.iter().flatten() { + if let ImportDeclarationSpecifier::ImportDefaultSpecifier(s) = specifier { + self.link_names.insert(s.local.name.to_string()); + } + } + } + walk::walk_import_declaration(self, declaration); + } + + fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { + let name = element.opening_element.name.span().source_text(self.source); + let attrs: BTreeMap<_, _> = element + .opening_element + .attributes + .iter() + .filter_map(|a| { + if let JSXAttributeItem::Attribute(a) = a { + Some((a.name.span().source_text(self.source), a.as_ref())) + } else { + None + } + }) + .collect(); + let href = attrs.get("href").and_then(|a| literal(a)); + let is_link = name == "a" || self.link_names.contains(name); + let native = matches!(name, "button" | "input" | "select" | "textarea") + && !attrs + .get("type") + .and_then(|a| literal(a)) + .is_some_and(|s| s == "hidden"); + let handler = attrs + .keys() + .any(|k| matches!(*k, "onClick" | "onChange" | "onSubmit")); + let static_target = href + .as_deref() + .is_some_and(|h| !h.trim().is_empty() && h != "#" && !h.starts_with("javascript:")); + let disabled = attrs.get("disabled").and_then(|a| boolean(a)) == Some(true); + let native_toggle = name == "input" + && attrs + .get("type") + .and_then(|a| literal(a)) + .is_some_and(|s| ["checkbox", "radio"].contains(&s.as_str())); + let form_control = native + && self.form_ancestors > 0 + && (name != "button" + || attrs + .get("type") + .and_then(|a| literal(a)) + .is_none_or(|t| t == "submit")); + let wired = (is_link && static_target) || (native && handler) || form_control; + let form_action = + name == "form" && (attrs.contains_key("action") || attrs.contains_key("onSubmit")); + let own_text = element + .children + .iter() + .filter_map(|c| { + if let JSXChild::Text(t) = c { + Some(t.value.as_str()) + } else { + None + } + }) + .collect::>() + .join(" "); + let accessible_name = attrs + .get("aria-label") + .and_then(|a| literal(a)) + .unwrap_or(own_text); + let role = attrs.get("role").and_then(|a| literal(a)); + let nav = name == "nav" + || role.as_deref() == Some("navigation") + || matches!( + super::interactions::label_role(name), + Some(super::interactions::Role::Nav) + ); + let candidate = is_link + || native + || handler + || role + .as_deref() + .is_some_and(|r| ["link", "button", "tab", "checkbox", "radio"].contains(&r)) + || super::interactions::label_role(name).is_some() + || attrs + .get("data-name") + .and_then(|a| literal(a)) + .is_some_and(|s| super::interactions::label_role(&s).is_some()) + || (accessible_name.chars().count() <= 35 + && !accessible_name.contains(['.', '!', '?']) + && super::interactions::label_role(accessible_name.trim()).is_some()); + let line = self.source[..element.span.start as usize] + .bytes() + .filter(|b| *b == b'\n') + .count() + + 1; + let active = attrs + .get("aria-current") + .is_some_and(|a| literal(a).is_some_and(|s| s != "false")) + || ["data-active", "aria-selected"] + .iter() + .any(|k| attrs.get(k).is_some_and(|a| boolean(a) == Some(true))) + || attrs.get("data-state").is_some_and(|a| { + literal(a).is_some_and(|s| ["active", "selected"].contains(&s.as_str())) + }); + let visual_marker = !active + && ((candidate + && (self.nav_ancestors > 0 || nav || is_link || role.as_deref() == Some("tab"))) + || self.link_ancestors > 0) + && fixed_visual_marker(&attrs); + if candidate || active || visual_marker { + self.elements.push(json!({"element":name,"name":accessible_name.trim(),"line":line,"offset":element.span.start, + "candidate":candidate,"wired":wired || self.linked_ancestors>0,"href":href,"dynamicHref":attrs.contains_key("href") && href.is_none(), + "nodeId":attrs.get("data-figma-node-id").and_then(|a|literal(a)), + "semanticControl":is_link || native,"nativeToggleOnly":native_toggle && !wired,"disabled":disabled, + "staticActive":active,"nonSemanticClick":handler && matches!(name,"div"|"span"|"Box"|"Flex"|"Text"|"Center"|"Grid"), + "fixedVisualNavigationMarker":visual_marker, + "ancestorControl":self.linked_ancestors>0,"isLink":is_link})); + } + // A dynamic Link is still a semantic ancestor, but its destination remains unresolved. + let parent_control = is_link || native; + self.linked_ancestors += usize::from(parent_control); + self.link_ancestors += usize::from(is_link); + self.form_ancestors += usize::from(form_action); + self.nav_ancestors += usize::from(nav); + walk::walk_jsx_element(self, element); + self.linked_ancestors -= usize::from(parent_control); + self.link_ancestors -= usize::from(is_link); + self.form_ancestors -= usize::from(form_action); + self.nav_ancestors -= usize::from(nav); + } +} + +pub(super) fn source_evidence(source: &str) -> Value { + if source.len() > 2_097_152 { + return json!({"elements":[],"diagnostics":[{"reason":"Source exceeds 2 MiB; navigation parsing skipped."}],"parsed":false}); + } + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + if parsed.panicked || !parsed.diagnostics.is_empty() { + return json!({"elements":[],"diagnostics":[{"reason":"TSX parse failed; navigation coverage is unverified."}],"parsed":false}); + } + let mut collector = Collector { + source, + ..Default::default() + }; + // Imports may follow declarations; collect aliases before traversing JSX. + for statement in &parsed.program.body { + if let Statement::ImportDeclaration(d) = statement { + collector.visit_import_declaration(d); + } + } + collector.visit_program(&parsed.program); + json!({"elements":collector.elements,"diagnostics":collector.diagnostics,"parsed":true}) +} + +/// Concrete hrefs can match dynamic routes, but a literal placeholder is not an ID. +pub(super) fn matches_route(pattern: &str, href: &str) -> bool { + fn segments_match(p: &[&str], h: &[&str]) -> bool { + match (p.split_first(), h.split_first()) { + (None, None) => true, + (Some((s, rest)), _) if s.starts_with("[[...") => rest.is_empty(), + (Some((s, rest)), Some(_)) if s.starts_with("[...") => rest.is_empty(), + (Some((s, rest)), Some((t, tail))) + if (*s == *t + || (s.starts_with('[') && s.ends_with(']') && !t.contains(['[', ']']))) => + { + segments_match(rest, tail) + } + _ => false, + } + } + let path = href.split(['?', '#']).next().unwrap_or(""); + if path.contains(['[', ']']) { + return false; + } + let p = pattern + .trim_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .collect::>(); + let h = path + .trim_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .collect::>(); + segments_match(&p, &h) +} + +fn route_inventory_usable(inventory: &Value) -> bool { + inventory["found"] == true + && !array(&inventory["routes"]).is_empty() + && inventory["truncation"]["truncated"] != true + && inventory["customRoutingUnverified"] != true + && inventory["unparsedFiles"].as_u64().unwrap_or(0) == 0 + && array(&inventory["routes"]) + .iter() + .map(|r| r["appliesTo"].as_str().unwrap_or(".")) + .collect::>() + .len() + <= 1 +} + +pub(super) fn report(source: &str, inventory: &Value, rendered_html: Option<&str>) -> Value { + let evidence = source_evidence(source); + let mut findings = vec![]; + let mut hrefs = BTreeMap::::new(); + let usable = route_inventory_usable(inventory); + for element in array(&evidence["elements"]) { + let mut finding = |code: &str, severity: &str, message: &str| { + findings.push(json!({"code":code,"rule":code,"severity":severity,"message":message,"line":element["line"],"element":element["element"],"name":element["name"],"byteRange":[element["offset"],element["offset"]]})); + }; + if element["candidate"] == true + && element["disabled"] != true + && element["nativeToggleOnly"] != true + && element["wired"] != true + && element["dynamicHref"] != true + { + finding( + "interaction-unmapped", + "warning", + "Action candidate has no semantic link or native control; confirm its intended action and wire it accessibly.", + ); + } + if element["nonSemanticClick"] == true { + finding( + "interaction-accessibility", + "warning", + "Use a native button/input or Link/a; a click handler on visual markup lacks native keyboard behavior.", + ); + } + if element["staticActive"] == true { + finding( + "hardcoded-active-state", + "info", + "Active state is literal; verify route/state-based selection. For GNB use usePathname in a small client component.", + ); + } + if element["fixedVisualNavigationMarker"] == true { + 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.", + ); + } + if let Some(href) = element["href"] + .as_str() + .filter(|_| element["isLink"] == true) + { + *hrefs.entry(href.into()).or_default() += 1; + // Relative, external, hash and dynamic links cannot be checked as absolute app paths. + if usable + && href.starts_with('/') + && !href.starts_with("//") + && !href + .split(['?', '#']) + .next() + .unwrap_or("") + .rsplit('/') + .next() + .unwrap_or("") + .contains('.') + && !array(&inventory["routes"]) + .iter() + .any(|r| r["route"].as_str().is_some_and(|p| matches_route(p, href))) + && !array(&inventory["nonPageDestinations"]) + .iter() + .any(|p| p.as_str().is_some_and(|p| matches_route(p, href))) + { + finding( + "missing-static-route", + "error", + "Static internal href has no matching App Router page in the selected application inventory; confirm rewrites/basePath/public resources before creating a route.", + ); + findings.last_mut().unwrap()["href"] = json!(href); + } + } + } + let candidates = array(&evidence["elements"]) + .iter() + .filter(|e| e["candidate"] == true && e["ancestorControl"] != true) + .collect::>(); + let wired = candidates.iter().filter(|e| e["wired"] == true).count(); + let native_toggles = candidates + .iter() + .filter(|e| e["nativeToggleOnly"] == true) + .count(); + let semantic = candidates + .iter() + .filter(|e| e["semanticControl"] == true) + .count(); + let dynamic = candidates + .iter() + .filter(|e| e["dynamicHref"] == true) + .count(); + let rendered = rendered_html.map(rendered_hrefs); + json!({"findings":findings,"sourceHrefs":hrefs,"renderedHrefs":rendered, + "coverage":{"candidateCount":candidates.len(),"wiredCount":wired,"semanticControlCount":semantic,"nativeToggleOnlyCount":native_toggles,"dynamicTargetCount":dynamic,"runtimeVerified":false,"sourceParsed":evidence["parsed"]}, + "elements":evidence["elements"],"diagnostics":evidence["diagnostics"],"routeCheck":{"ran":usable,"reason":if usable{"Selected application App Router inventory; rewrites, middleware, basePath and public resources require explicit review."}else{"No complete single-application route inventory; missing-route errors skipped."}}, + "limitations":["Counts are static source candidates, not a census of rendered controls; components, loops, wrappers and runtime state may differ.","Literal active attributes are evidence; constant navigation underlines are review candidates only, and other visual markers may be unclassified.","Rendered href counts are separate evidence and never establish click success or one-to-one source coverage."]}) +} + +pub(super) fn attach_validation(result: &mut Value, input: &super::tools::UiValidateInput) { + if let Some(rules) = result["verificationScope"]["static"]["rules"].as_array_mut() { + rules.extend( + [ + "interaction-unmapped", + "interaction-accessibility", + "missing-static-route", + "hardcoded-active-state", + "fixed-navigation-marker", + ] + .map(|r| json!(r)), + ); + } + let inventory = if let Some(files) = &input.files { + let routes=files.iter().filter_map(|f| { + let path=f.path.replace('\\',"/"); + let (authority,rest)=path.split_once("/app/").or_else(||path.strip_prefix("app/").map(|p|("",p)))?; + if !["page.tsx","page.ts","page.jsx","page.js"].contains(&rest.rsplit('/').next()?) {return None} + let segments=rest.split('/').collect::>(); + let directories=&segments[..segments.len()-1]; + if directories.iter().any(|s|s.starts_with('_')) {return None} + let route=directories.iter().filter(|s|!s.starts_with('(') && !s.starts_with('@')).copied().collect::>().join("/"); + Some(json!({"route":format!("/{route}"),"page":path,"appliesTo":authority.strip_suffix("/src").unwrap_or(authority)})) + }).collect::>(); + json!({"found":!routes.is_empty(),"routes":routes,"scope":"supplied-files-only; omitted pages are not proven absent","truncation":{"truncated":true}}) + } else { + super::project_context::routes_for_validation(input.project_root.as_deref()) + }; + let sources = if let Some(files) = &input.files { + files + .iter() + .filter(|f| f.path.ends_with(".tsx") || f.path.ends_with(".jsx")) + .map(|f| (f.path.clone(), f.content.as_str())) + .collect::>() + } else { + vec![( + input.source_name.clone().unwrap_or_else(|| "tsx".into()), + input.tsx.as_str(), + )] + }; + let mut reports = vec![]; + for (path, source) in sources { + let mut navigation = report(source, &inventory, None); + for finding in navigation["findings"].as_array_mut().unwrap() { + finding["sourceName"] = json!(path); + } + result["violations"] + .as_array_mut() + .unwrap() + .extend(array(&navigation["findings"]).iter().cloned()); + reports.push(json!({"sourceName":path,"report":navigation})); + } + result["navigation"] = json!({"sources":reports,"renderedHrefs":input.rendered_html.as_deref().map(rendered_hrefs),"routeInventory":inventory}); + let errors = array(&result["violations"]) + .iter() + .filter(|f| f["severity"] == "error") + .count(); + let warnings = array(&result["violations"]) + .iter() + .filter(|f| f["severity"] == "warning") + .count(); + let infos = array(&result["violations"]) + .iter() + .filter(|f| f["severity"] == "info") + .count(); + result["violationCounts"] = json!({"error":errors,"warning":warnings,"info":infos}); + result["ok"] = json!(errors == 0 && (!input.strict || warnings == 0)); + if errors > 0 { + result["okReason"] = json!("error-violations"); + } else if warnings > 0 { + result["okReason"] = json!(if input.strict { + "strict-warnings" + } else if infos > 0 { + "warnings-and-info" + } else { + "warnings-only" + }); + } else if infos > 0 { + result["okReason"] = json!("info-only"); + } +} + +fn rendered_hrefs(html: &str) -> Value { + use quick_xml::{Reader, events::Event}; + if html.len() > 2_097_152 { + return json!({"parsed":false,"reason":"Rendered HTML exceeds 2 MiB."}); + } + let mut reader = Reader::from_str(html); + reader.config_mut().check_end_names = false; + let mut counts = BTreeMap::::new(); + let mut errors = vec![]; + loop { + match reader.read_event() { + Ok(Event::Start(e) | Event::Empty(e)) + if e.name().as_ref().eq_ignore_ascii_case("a") => + { + for attribute in e.html_attributes().with_checks(false) { + match attribute { + Ok(a) if a.key.as_ref().eq_ignore_ascii_case("href") => { + match quick_xml::escape::unescape(&a.value) { + Ok(v) => *counts.entry(v.to_string()).or_default() += 1, + Err(e) => errors.push(e.to_string()), + } + } + Ok(_) => {} + Err(e) => errors.push(e.to_string()), + } + } + } + Ok(Event::Start(e)) + if ["script", "style"] + .iter() + .any(|s| e.name().as_ref().eq_ignore_ascii_case(s)) => + { + if let Err(e) = reader.read_to_end(e.name()) { + errors.push(e.to_string()); + break; + } + } + Ok(Event::Eof) => break, + Err(e) => { + errors.push(e.to_string()); + break; + } + _ => {} + } + } + json!({"counts":counts,"parsed":errors.is_empty(),"diagnostics":errors,"scope":"Caller-supplied rendered HTML anchor hrefs; XML-compatible HTML parser, incomplete/malformed HTML may be unverified."}) +} + +pub(super) fn suggestions(candidates: &Value, inventory: &Value) -> Value { + let mut result = vec![]; + for candidate in array(candidates) { + let label = format!( + "{} {}", + candidate["name"].as_str().unwrap_or(""), + candidate["text"].as_str().unwrap_or("") + ) + .to_lowercase(); + let concepts = [ + ("portfolio", vec!["portfolio", "포트폴리오", "구축 사례"]), + ("privacy", vec!["privacy", "개인정보"]), + ("terms", vec!["terms", "이용약관"]), + ("faq", vec!["faq", "고객센터"]), + ("reservation", vec!["reservation", "견적", "문의하기"]), + ("about", vec!["about", "소개"]), + ("services", vec!["services", "서비스"]), + ]; + let options=array(&inventory["routes"]).iter().filter(|r| { + let route=r["route"].as_str().unwrap_or("").to_lowercase(); + concepts.iter().any(|(segment,labels)|route.split('/').any(|s|s==*segment) && labels.iter().any(|s|label.contains(s))) + }).map(|r|json!({"route":r["route"],"page":r["page"],"appliesTo":r["appliesTo"],"requiresRecordId":r["route"].as_str().is_some_and(|s|s.contains('['))})).collect::>(); + result.push(json!({"nodeId":candidate["nodeId"],"suggestions":options,"targetResolved":false, + "reason":if options.is_empty(){"No matching route evidence; ask for destination/action."}else{"Label/path heuristic only; confirm authority and record IDs before wiring. Suggestions never resolve a target automatically."}})); + } + json!(result) +} + +/// Compare only explicitly bound URLs. Figma destination node IDs remain unverified. +pub(super) fn graph( + design: &Value, + sources: &[(String, String)], + targets: &BTreeMap, +) -> Value { + let mut edges = vec![]; + for (path, source) in sources { + for element in array(&source_evidence(source)["elements"]) { + if element["isLink"] == true { + edges.push(json!({"from":path,"to":element["href"],"dynamic":element["dynamicHref"],"line":element["line"],"nodeId":element["nodeId"]})); + } + } + } + let mut comparisons = vec![]; + for candidate in array(design) { + for action in array(&candidate["prototypeActions"]) { + let url = action["url"].as_str().or_else(|| { + action["destinationId"] + .as_str() + .and_then(|id| targets.get(id).map(String::as_str)) + }); + let matched = url.is_some_and(|url| edges.iter().any(|e| e["to"] == url)); + let mismatch = url.is_some_and(|url| { + edges.iter().any(|e| { + e["nodeId"] == candidate["nodeId"] && e["to"].is_string() && e["to"] != url + }) + }); + comparisons.push(json!({"nodeId":candidate["nodeId"],"prototype":action,"expectedTarget":url,"status":if mismatch{"MISMATCH"}else if matched{"MATCHED_STATIC_HREF"}else if url.is_some(){"MISSING_STATIC_HREF"}else{"UNVERIFIED"}, + "reason":if mismatch{"Explicit data-figma-node-id link has a different literal destination."}else if matched{"Literal URL occurs in selected source slice; origin control and click behavior not proven."}else if url.is_some(){"Literal prototype URL absent from selected source slice; wrapper/dynamic links may be unresolved."}else{"Figma node/action has no explicit code route binding; no destination invented."}})); + } + } + json!({"codeEdges":edges,"comparison":comparisons,"scope":"Selected source slice; URL equality is destination presence, not node-to-control mapping or runtime navigation proof.","designAvailable":!array(design).is_empty()}) +} diff --git a/crates/devup-mcp/src/server/operation.rs b/crates/devup-mcp/src/server/operation.rs index a01757d3..61fc6d39 100644 --- a/crates/devup-mcp/src/server/operation.rs +++ b/crates/devup-mcp/src/server/operation.rs @@ -32,6 +32,7 @@ pub enum PendingOperation { root_layout: RootLayout, asset_names_per_node: bool, scope: String, + theme_options: super::projection::theme_export::ThemeExportOptions, strict: bool, output_paths: BTreeMap, page_scaffold: Option, diff --git a/crates/devup-mcp/src/server/project_context.rs b/crates/devup-mcp/src/server/project_context.rs index cf73adc0..da46c6ec 100644 --- a/crates/devup-mcp/src/server/project_context.rs +++ b/crates/devup-mcp/src/server/project_context.rs @@ -22,6 +22,16 @@ mod ui_tests; #[path = "project_context_ui.rs"] mod ui; +#[path = "project_context_export.rs"] +mod export; +pub(super) use export::attach as attach_export_context; +pub(super) use export::export_context; + +pub(super) fn routes_for_validation(project_root: Option<&str>) -> Value { + project_root.map(export_context).map(|v|v["ui"].clone()) + .unwrap_or_else(||json!({"found":false,"reason":"No explicit projectRoot supplied; route validation skipped."})) +} + use super::project_root::{ PROJECT_ROOT_NOT_FOUND_MESSAGE, display_path, find_dirs_named, find_files_named, find_project_root, guardrail_object, json_files_in, not_found_response, @@ -213,10 +223,10 @@ pub async fn run( project_root: Option<&str>, filter: Option<&str>, ) -> Result { - if !["theme", "api", "db", "ui", "all"].contains(&scope) { + if !["theme", "api", "db", "ui", "routes", "all"].contains(&scope) { return Err(DevupError::new( ErrorCode::DevupInvalidInput, - "scope must be theme, api, db, ui, or all (ui is opt-in).", + "scope must be theme, api, db, ui, routes, or all (ui/routes are opt-in).", false, )); } @@ -246,6 +256,12 @@ pub async fn run( "api" => Ok(api_scope(&root, filter)), "db" => Ok(db_scope(&root, filter)), "ui" => Ok(ui::run(&root, filter)), + "routes" => { + let mut inventory = ui::run(&root, filter); + inventory.as_object_mut().unwrap().remove("components"); + inventory["scope"] = json!("routes"); + Ok(inventory) + } "all" => { let mut all = Map::new(); all.insert("found".to_owned(), Value::Bool(true)); diff --git a/crates/devup-mcp/src/server/project_context_export.rs b/crates/devup-mcp/src/server/project_context_export.rs new file mode 100644 index 00000000..a7bb8694 --- /dev/null +++ b/crates/devup-mcp/src/server/project_context_export.rs @@ -0,0 +1,175 @@ +//! Bounded export context: existing UI evidence and environment key NAMES only. +use super::super::project_root::{find_matching_files, find_project_root}; +use serde_json::{Value, json}; +use std::{collections::BTreeSet, io::Read, path::Path}; + +pub(crate) fn export_context(project_root: &str) -> Value { + let Some(root) = find_project_root(Path::new(project_root)) else { + return json!({"ui":{"found":false},"envKeyNames":[],"reason":"Project root could not be resolved."}); + }; + let mut ui = super::ui::run(&root, None); + // Read key names and routing configuration evidence; never return source values. + let (files, excluded) = find_matching_files( + &root, + |p| { + p.file_name().and_then(|s| s.to_str()).is_some_and(|s| { + s.starts_with(".env") + || [ + "next.config.js", + "next.config.mjs", + "next.config.ts", + "middleware.ts", + "middleware.js", + "proxy.ts", + "proxy.js", + ] + .contains(&s) + }) + }, + usize::MAX, + ); + let mut keys = BTreeSet::new(); + let mut diagnostics = vec![]; + let mut routing_config = false; + for file in files.iter().take(32) { + let mut source = String::new(); + let read = + std::fs::File::open(file).and_then(|f| f.take(65_537).read_to_string(&mut source)); + if read.is_err() || source.len() > 65_536 { + diagnostics.push(json!({"path":super::relative_display(&root,file),"reason":"Context file unreadable or exceeds 64 KiB; contents omitted."})); + continue; + } + if !file + .file_name() + .is_some_and(|s| s.to_string_lossy().starts_with(".env")) + { + routing_config |= file + .file_name() + .is_some_and(|s| s.to_string_lossy().starts_with("middleware")) + || file + .file_name() + .is_some_and(|s| s.to_string_lossy().starts_with("proxy")) + || ["rewrites", "redirects", "basePath", "i18n"] + .iter() + .any(|s| source.contains(s)); + continue; + } + for line in source.lines() { + let line = line.trim().strip_prefix("export ").unwrap_or(line.trim()); + if let Some((key, _)) = line.split_once('=') { + let key = key.trim(); + if !key.is_empty() + && key.bytes().enumerate().all(|(i, b)| { + b == b'_' || b.is_ascii_alphabetic() || (i > 0 && b.is_ascii_digit()) + }) + { + keys.insert(key.to_owned()); + } + } + } + } + ui["customRoutingUnverified"] = + json!(routing_config || files.len() > 32 || !diagnostics.is_empty()); + let (supplementary, _) = find_matching_files( + &root, + |p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| ["route.ts", "route.js"].contains(&n)) + || p.components().any(|c| c.as_os_str() == "public") + }, + usize::MAX, + ); + let extra = supplementary + .iter() + .take(256) + .filter_map(|p| { + let relative = super::relative_display(&root, p); + if let Some((_, asset)) = relative.split_once("public/") { + return Some(json!(format!("/{asset}"))); + } + let (_, rest) = relative.split_once("app/")?; + let segments = rest.split('/').collect::>(); + let dirs = &segments[..segments.len() - 1]; + if dirs.iter().any(|s| s.starts_with('_')) { + return None; + } + Some(json!(format!( + "/{}", + dirs.iter() + .filter(|s| !s.starts_with('(') && !s.starts_with('@')) + .copied() + .collect::>() + .join("/") + ))) + }) + .collect::>(); + ui["nonPageDestinations"] = json!(extra); + ui["customRoutingUnverified"] = + json!(ui["customRoutingUnverified"] == true || supplementary.len() > 256); + 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."}) +} + +/// Added context is bounded independently and respects remaining inline space. +pub(crate) fn attach(result: &mut Value, root: &str) { + let context = export_context(root); + let suggestions = + super::super::navigation::suggestions(&result["interactiveCandidates"], &context["ui"]); + let mut added = json!({"projectContext":context,"routeSuggestions":suggestions}); + let wire_bytes = serde_json::to_vec(&super::super::tool_result(result.clone())) + .map_or(usize::MAX, |v| v.len()); + let budget = 65_536usize.min( + super::super::delivery::MAX_INLINE_TOTAL_BYTES + // Review, skills, theme-validation and job metadata are attached + // around this boundary and are also duplicated in MCP content. + .saturating_sub(wire_bytes.saturating_add(32_768)) + / 3, + ); + if budget < 2048 { + added = json!({"projectContext":{"state":"deferred","reason":"Remaining inline response budget cannot hold UI context; call devup_project_context scope ui/routes with projectRoot.","projectRoot":root},"routeSuggestions":[]}); + } else { + added["projectContext"]["exportContextLimits"] = + json!({"truncated":false,"maxBytes":budget,"omitted":{}}); + while added.to_string().len() > budget { + let pointers = [ + "/projectContext/ui/components", + "/projectContext/ui/routes", + "/projectContext/ui/diagnostics", + "/projectContext/ui/excludedPaths", + "/routeSuggestions", + ]; + let path = pointers + .into_iter() + .filter(|p| { + added + .pointer(p) + .and_then(Value::as_array) + .is_some_and(|a| !a.is_empty()) + }) + .max_by_key(|p| added.pointer(p).unwrap().to_string().len()); + let Some(path) = path else { break }; + added + .pointer_mut(path) + .unwrap() + .as_array_mut() + .unwrap() + .pop(); + added["projectContext"]["exportContextLimits"]["truncated"] = json!(true); + added["projectContext"]["ui"]["truncation"]["truncated"] = json!(true); + let omitted = added["projectContext"]["exportContextLimits"]["omitted"][path] + .as_u64() + .unwrap_or(0); + added["projectContext"]["exportContextLimits"]["omitted"][path] = json!(omitted + 1); + } + if added.to_string().len() > budget { + added = json!({"projectContext":{"state":"deferred","reason":"UI context exceeds export metadata cap; request ui/routes scope separately.","projectRoot":root},"routeSuggestions":[]}); + } + } + result + .as_object_mut() + .unwrap() + .extend(added.as_object().unwrap().clone()); +} diff --git a/crates/devup-mcp/src/server/projection.rs b/crates/devup-mcp/src/server/projection.rs index 8c09def7..c7426eb5 100644 --- a/crates/devup-mcp/src/server/projection.rs +++ b/crates/devup-mcp/src/server/projection.rs @@ -1,5 +1,9 @@ #[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 _; @@ -285,6 +289,156 @@ pub(super) struct DeliveryAttachment { reservation: OutputReservation, } +/// Keep complete diagnostic evidence readable without duplicating it into a +/// multi-megabyte tool envelope. Only resource delivery changes these details; +/// diagnostic identities/counts/severities and verdicts remain inline. +fn resource_diagnostic_details( + result: &mut Value, + outputs: &mut Vec, + artifact_id: &str, +) -> Result<(), DevupError> { + const FIELDS: [&str; 3] = ["diagnostics", "projectionIssues", "projectionEvidence"]; + const BULKY: [&str; 5] = [ + "verdictScope", + "projectionReview", + "placementContracts", + "outputResults", + "widthBehavior", + ]; + let mut full = json!({"response":{},"frames":[]}); + for field in FIELDS.into_iter().chain(BULKY) { + if let Some(value) = result.get(field) { + full["response"][field] = value.clone(); + } + } + if let Some(frames) = result["frames"].as_array() { + for frame in frames { + let mut record = json!({"nodeId":frame["nodeId"]}); + for field in FIELDS.into_iter().chain(BULKY) { + if let Some(value) = frame.get(field) { + record[field] = value.clone(); + } + } + full["frames"].as_array_mut().unwrap().push(record); + } + } + let bytes = encode_projected_json(&full)?; + if bytes.len() <= super::delivery::MAX_INLINE_OUTPUT_BYTES / 2 { + return Ok(()); + } + let output = ProjectedOutput::text("projection-details.json", "application/json", bytes); + let uri = output.manifest_uri(artifact_id); + fn preview( + value: &Value, + depth: usize, + path: &str, + omissions: &mut Map, + ) -> Value { + let (original, returned) = match value { + Value::Array(v) => (v.len(), if depth > 0 { v.len().min(8) } else { 0 }), + Value::Object(v) => (v.len(), if depth > 0 { v.len().min(32) } else { 0 }), + Value::String(v) => (v.chars().count(), v.chars().count().min(320)), + _ => (0, 0), + }; + if original > returned { + omissions.insert(path.into(), json!({"originalCount":original,"returnedCount":returned,"omittedCount":original-returned})); + } + match value { + Value::String(s) => json!(s.chars().take(320).collect::()), + Value::Array(items) if depth > 0 => json!( + items + .iter() + .take(8) + .enumerate() + .map(|(i, v)| preview(v, depth - 1, &format!("{path}/{i}"), omissions)) + .collect::>() + ), + Value::Object(items) if depth > 0 => Value::Object( + items + .iter() + .take(32) + .map(|(k, v)| { + ( + k.clone(), + preview( + v, + depth - 1, + &format!("{path}/{}", k.replace('~', "~0").replace('/', "~1")), + omissions, + ), + ) + }) + .collect(), + ), + Value::Array(items) => json!({"state":"resource-details","entryCount":items.len()}), + Value::Object(items) => json!({"state":"resource-details","entryCount":items.len()}), + _ => value.clone(), + } + } + fn summarize(value: &mut Value, prefix: &str) { + for field in FIELDS { + if let Some(items) = value[field].as_array_mut() { + for (index, item) in items.iter_mut().enumerate() { + let Some(details) = item.get_mut("details").filter(|d| d.is_object()) else { + continue; + }; + let mut summary = Map::new(); + // Preserve small source values (notably FILL/HUG) and + // contracts; only bulky detail values need a resource. + for (key, value) in details.as_object().unwrap() { + if value.to_string().len() <= 512 + || (key == "originalValue" && value.to_string().len() <= 1024) + { + summary.insert(key.clone(), value.clone()); + } + } + for key in [ + "classification", + "nextAction", + "output", + "nodeIds", + "candidateCount", + "state", + "propertyMappingVerified", + ] { + if let Some(value) = details.get(key) { + summary.insert(key.into(), value.clone()); + } + } + if Value::Object(summary.clone()) == *details { + continue; + } + // Share the long URI once at the response root rather + // than repeating it for every diagnostic in both MCP forms. + summary.insert("fullDetailsResource".into(), json!({"uriPath":"/metadataDelivery/uri","jsonPointer":format!("{prefix}/{field}/{index}/details")})); + *details = Value::Object(summary); + } + } + } + for field in BULKY { + if let Some(original) = value + .get(field) + .filter(|v| v.to_string().len() > 16_384) + .cloned() + { + let mut omissions = Map::new(); + value[field] = preview(&original, 3, "", &mut omissions); + value["metadataPreviewFields"][field] = json!({"truncated":true,"uriPath":"/metadataDelivery/uri","jsonPointer":format!("{prefix}/{field}"),"originalBytes":original.to_string().len(),"omissions":omissions,"maxArrayEntries":8,"maxObjectEntries":32,"maxStringChars":320,"maxDepth":3}); + } + } + } + summarize(result, "/response"); + if let Some(frames) = result["frames"].as_array_mut() { + for (index, frame) in frames.iter_mut().enumerate() { + summarize(frame, &format!("/frames/{index}")); + } + } + result["metadataDelivery"] = json!({"mode":"resource-details","uri":uri,"name":"projection-details.json","fields":FIELDS.into_iter().chain(BULKY).collect::>(),"requiresFullDetailRead":true, + "note":"Diagnostic identities, counts, severity and verdicts remain inline; complete original details are in this resource. Read it before accepting projection evidence or final completeness."}); + outputs.push(output); + Ok(()) +} + pub(super) async fn apply_delivery( result: &mut Value, mode: DeliveryMode, @@ -345,6 +499,12 @@ pub(super) async fn apply_delivery( let coupled = reprojection_arguments.is_some_and(|a| { a.get("pageScaffold").is_some() || 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 { @@ -374,9 +534,19 @@ pub(super) async fn apply_delivery( if args.get("pageScaffold").is_some() { args["pageScaffold"]["write"] = json!(false); } + if matches!( + args["devupJsonMode"].as_str(), + Some("used-only" | "missing-only") + ) && args.get("pageScaffold").is_none() + && !args["assetRequests"] + .as_array() + .is_some_and(|requests| !requests.is_empty()) + { + args.as_object_mut().unwrap().remove("outputPaths"); + } let example = json!({"tool":"devup_figma_export","arguments":args}); result["nextAction"] = json!({"tool":"devup_figma_export","arguments":args,"example":example, - "how":if coupled { "Read the linked resources. Preserve coupled asset requests and paths in this resource reprojection; splitting them can change generated URLs." } + "how":if coupled { "Read the linked resources. Preserve coupled code/theme outputs and asset requests/paths in this resource reprojection; splitting them can change token selection or generated URLs." } else if field == "responsiveTsx" { "Read the linked resource or reproject this responsive output with the same breakpoint selection. A responsive merge requires multiple source screens." } else { "Read the linked resources, or compare the same artifact using this single-screen, single-output reprojection. No new Figma acquisition is required. Inline errors retain R12 resource recovery arguments." }, "sizeEstimate":{"outputBytes":bytes,"minimumWireBytes":escaped_bytes * 2, @@ -385,12 +555,31 @@ pub(super) async fn apply_delivery( "binary":binary, "basis":"Existing output UTF-8/binary bytes; minimumWireBytes doubles its JSON encoding (base64 for PNG). This lower bound excludes metadata/diagnostics. The new projection's full MCP response has not been measured and can still exceed the limit."}}); } + resource_diagnostic_details(result, &mut outputs, &artifact.artifact_id)?; let projection_key = projection_key(&outputs); materialize_asset_resource_references(result, &mut outputs, &artifact.artifact_id)?; let reservation = artifact_store .reserve_outputs(&artifact.artifact_id, &projection_key, outputs) .await?; let manifests = reservation.manifests().to_vec(); + if let Some(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, @@ -450,6 +639,13 @@ pub(super) async fn apply_delivery( .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 })) } @@ -1364,6 +1560,7 @@ async fn project_operation( root_layout, asset_names_per_node, scope, + theme_options, strict, output_paths, page_scaffold, @@ -1418,6 +1615,7 @@ async fn project_operation( "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", @@ -1456,8 +1654,8 @@ async fn project_operation( .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. Re-collect this asset from its URL.", - "retryable":true}); + "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) { @@ -1577,6 +1775,20 @@ async fn project_operation( } 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 @@ -1635,6 +1847,7 @@ async fn project_operation( "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, @@ -1712,6 +1925,7 @@ async fn project_operation( if scaffold_requested { page_scaffold::selection_guidance(&mut result); } + responsive_groups::completion(&mut result, &frame_ids, all_screens); return Ok(Value::Object(result)); } @@ -2282,9 +2496,43 @@ async fn project_operation( 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 output = generate_devup_json(&variables, parse_scope(&scope)?)?; + 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(); + 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()); @@ -2474,6 +2722,11 @@ async fn project_operation( // 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() { // An image fill is named from the node it is painted // on and which fill it is, which holds for a layout @@ -2638,6 +2891,7 @@ async fn project_operation( } } } + responsive_groups::completion(&mut result, &frame_ids, all_screens); result.insert( "placementContracts".into(), json!( @@ -2877,9 +3131,10 @@ async fn project_operation( result.insert("resolutionSemantics".into(), semantics); } let mut value = Value::Object(std::mem::take(&mut result)); - attach_diagnostic_screens(&mut value, &owners); 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 { @@ -3345,6 +3600,7 @@ fn attach_diagnostic_screens(value: &mut Value, owners: &BTreeMap() + )); + 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, @@ -6188,6 +6536,44 @@ mod w1_regressions { 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); diff --git a/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs b/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs new file mode 100644 index 00000000..5a13d1f2 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/feedback_theme_tests.rs @@ -0,0 +1,308 @@ +fn feedback_theme_payload() -> CollectedPayload { + let mut data = payload(); + let node = data.snapshot.nodes.get_mut("1:1").unwrap(); + node.node_type = "TEXT".into(); + node.fields.insert("characters".into(), json!("Example")); + node.fields.insert("textStyleId".into(), json!("good")); + node.fields.insert("fontSize".into(), json!(16)); + node.fields.insert("lineHeight".into(), json!({"unit":"PERCENT","value":140})); + node.fields.insert("layoutSizingHorizontal".into(), json!("HUG")); + node.fields.insert("layoutSizingVertical".into(), json!("HUG")); + node.fields.insert("textTruncation".into(), json!("DISABLED")); + let raw = json!({"collections":[],"variables":[],"styles":[ + {"id":"good","name":"Body","styleType":"TEXT","value":{"fontSize":16,"lineHeight":{"unit":"PERCENT","value":140}}}, + {"id":"bad","name":"Unreadable Heading","styleType":"TEXT","value":{"lineHeight":{"$error":"cannot read"}}}, + {"id":"unused","name":"Unused","styleType":"TEXT","value":{"fontSize":32}} + ]}); + data.variables = Some(devup_mcp_figma::UpstreamResult { raw: raw.clone() }); + data.styles = Some(devup_mcp_figma::UpstreamResult { raw }); + data +} + +#[tokio::test] +async fn feedback_theme_partial_export_keeps_code_and_valid_styles_with_named_issue() { + let mut data = feedback_theme_payload(); + data.snapshot.nodes.get_mut("1:1").unwrap().fields.insert("textStyleId".into(), json!("bad")); + let mut op = operation(&["tsx", "devupJson"]); + if let PendingOperation::Export { include_diagnostics, .. } = &mut op { *include_diagnostics = false; } + let result = project(data, op).await.unwrap(); + assert!(result["tsx"].is_string()); + let theme: Value = serde_json::from_str(result["devupJson"].as_str().unwrap()).unwrap(); + assert!(theme["theme"]["typography"].get("body").is_some()); + assert_eq!(result["status"], "partial"); + assert_eq!(result["quality"]["theme"], "unresolved"); + assert_eq!(result["skippedStyles"][0]["name"], "Unreadable Heading"); + assert_eq!(result["skippedStyles"][0]["nodeIds"], json!(["1:1"])); + let issue = result["projectionIssues"].as_array().unwrap().iter().find(|issue| issue["code"] == "DEVUP_THEME_STYLE_SKIPPED").unwrap(); + assert_eq!(issue["resourceId"], "bad"); + assert_eq!(issue["nodeId"], "1:1"); + assert!(issue["message"].as_str().unwrap().contains("Unreadable Heading")); + assert!(result["warnings"].as_array().unwrap().iter().any(|warning| warning["code"] == "DEVUP_THEME_STYLE_SKIPPED")); +} + +#[tokio::test] +async fn feedback_theme_strict_refuses_skipped_style_with_names() { + let mut op = operation(&["devupJson"]); + if let PendingOperation::Export { strict, .. } = &mut op { *strict = true; } + let error = project(feedback_theme_payload(), op).await.unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupThemeConflict); + assert!(error.message.contains("Unreadable Heading")); +} + +#[tokio::test] +async fn feedback_theme_used_only_is_derived_from_emitted_typography() { + let mut op = operation(&["tsx", "devupJson", "sourceMap"]); + if let PendingOperation::Export { theme_options, .. } = &mut op { theme_options.mode = "used-only".into(); } + let result = project(feedback_theme_payload(), op).await.unwrap(); + let theme: Value = serde_json::from_str(result["devupJson"].as_str().unwrap()).unwrap(); + assert_eq!(theme["theme"]["typography"].as_object().unwrap().keys().collect::>(), vec!["body"]); + assert!(result["tsx"].as_str().unwrap().contains("typography=\"body\"")); + assert_eq!(result["devupJsonMode"], "used-only"); + assert_eq!(result["themeCounts"]["styles"], 1); +} + +#[tokio::test] +async fn feedback_integrated_large_code_hints_theme_and_resources_match_saved_bytes() { + let mut data = feedback_theme_payload(); + let node = data.snapshot.nodes.get_mut("1:1").unwrap(); + node.fields.insert("name".into(), json!("CTA Button")); + node.fields.insert("characters".into(), json!("견적문의 ".repeat(25_000))); + node.fields.insert("reactions".into(), json!([{"trigger":{"type":"ON_CLICK"},"actions":[{"type":"URL","url":"https://example.com/confirmed"}]}])); + let directory = std::env::temp_dir().join(format!("feedback-integrated-{}", rand::random::())); + std::fs::create_dir_all(&directory).unwrap(); + let mut op = operation(&["tsx", "devupJson", "sourceMap"]); + if let PendingOperation::Export { theme_options, output_paths, delivery, .. } = &mut op { + theme_options.mode = "used-only".into(); + *delivery = DeliveryMode::Auto; + for (key, file) in [("tsx", "screen.tsx"), ("devupJson", "devup.json")] { + output_paths.insert(key.into(), directory.join(file).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 manifests = store.output_manifests().await; + assert!(!manifests.is_empty(), "Large code must use resource delivery"); + for (name, file) in [("tsx", "screen.tsx"), ("devupJson", "devup.json")] { + let manifest = manifests.iter().find(|m| m.name == name).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(directory.join(file)).unwrap()); + assert_eq!(bytes.len(), manifest.raw_bytes); + assert_eq!(sha256_hex(&bytes), manifest.sha256); + } + let code = std::fs::read_to_string(directory.join("screen.tsx")).unwrap(); + assert!(code.contains("// @devup-interaction ")); + assert!(code.contains("https://example.com/confirmed")); + let theme: Value = serde_json::from_slice(&std::fs::read(directory.join("devup.json")).unwrap()).unwrap(); + assert_eq!(theme["theme"]["typography"].as_object().unwrap().keys().collect::>(), vec!["body"]); + assert!(code.contains("typography=\"body\"")); + let diagnostic = result["diagnostics"].as_array().unwrap().iter().find(|d| d["code"] == "DEVUP_CODEGEN_TEXT_WHITESPACE_COLLAPSE").unwrap(); + assert_eq!(diagnostic["details"]["originalValueTruncated"], true); + assert_eq!(diagnostic["details"]["originalValueCharCount"], 125_000); + let wire = serde_json::to_vec(&super::super::tool_result(result.clone())).unwrap(); + assert!(wire.len() <= super::super::delivery::MAX_INLINE_TOTAL_BYTES, "wire {}", wire.len()); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[tokio::test] +async fn feedback_large_diagnostic_details_are_complete_bounded_and_reuse_resource_uris() { + let data = feedback_theme_payload(); + 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 issues = (0..200).map(|index| json!({"code":"DEVUP_CODEGEN_PROPERTY_UNMAPPED","nodeId":format!("1:{index}"),"severity":"warning","fidelityImpact":"lossy","message":"Review source value", + "details":{"classification":"unmapped","nextAction":"Review complete original evidence","originalValue":"x".repeat(3000),"appliedValue":{"generatedSource":"y".repeat(1000)}}})).collect::>(); + let original = json!({"tsx":"const Screen=()=> ;","status":"partial","projectionIssues":issues,"projectionEvidence":[],"quality":{"projection":"lossy"},"verdictScope":{"projection":"lossy","statusCauses":vec!["known-source-cause".repeat(120);200]}}); + let mut uris = vec![]; + for _ in 0..2 { + let mut result = original.clone(); + 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); + super::super::review::attach(&mut result); + assert!(serde_json::to_vec(&super::super::tool_result(result.clone())).unwrap().len() <= super::super::delivery::MAX_INLINE_TOTAL_BYTES); + assert_eq!(result["projectionIssues"].as_array().unwrap().len(), issues.len()); + assert_eq!(result["projectionIssues"][0]["severity"], "warning"); + assert!(result["reviewChecklist"].as_array().unwrap().iter().any(|i| i["code"] == "PROJECTION_DETAILS_RESOURCE" && i["severity"] == "required")); + let uri = result["metadataDelivery"]["uri"].as_str().unwrap(); + let manifest = store.output_manifests().await.into_iter().find(|m| m.manifest_uri == uri).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.len(), manifest.raw_bytes); + assert_eq!(sha256_hex(&bytes), manifest.sha256); + let full: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(full["response"]["projectionIssues"], json!(issues)); + assert_eq!(full["response"]["verdictScope"], original["verdictScope"]); + assert_eq!(result["verdictScope"]["projection"], "lossy"); + assert_eq!(result["metadataPreviewFields"]["verdictScope"]["truncated"], true); + assert_eq!(result["metadataPreviewFields"]["verdictScope"]["omissions"]["/statusCauses"]["omittedCount"], 192); + let pointer = result["projectionIssues"][0]["details"]["fullDetailsResource"]["jsonPointer"].as_str().unwrap(); + assert_eq!(full.pointer(pointer).unwrap(), &original["projectionIssues"][0]["details"]); + let uri_path = result["projectionIssues"][0]["details"]["fullDetailsResource"]["uriPath"].as_str().unwrap(); + assert_eq!(result.pointer(uri_path).unwrap(), uri); + uris.push(uri.to_owned()); + } + assert_eq!(uris[0], uris[1]); +} + +#[tokio::test] +async fn feedback_theme_missing_only_preserves_project_and_reports_conflict() { + let directory = std::env::temp_dir().join(format!("devup-theme-feedback-{}", rand::random::())); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("package.json"), "{}").unwrap(); + let original = r#"{"theme":{"typography":{"body":{"fontSize":"18px"}}}}"#; + std::fs::write(directory.join("devup.json"), original).unwrap(); + let mut op = operation(&["tsx", "devupJson"]); + if let PendingOperation::Export { theme_options, .. } = &mut op { + theme_options.mode = "missing-only".into(); + theme_options.project_root = Some(directory.to_string_lossy().into()); + } + let result = project(feedback_theme_payload(), op).await.unwrap(); + let theme: Value = serde_json::from_str(result["devupJson"].as_str().unwrap()).unwrap(); + assert!(theme["theme"]["typography"].as_object().unwrap().is_empty()); + assert_eq!(result["themeDiff"]["existingTypographyConflicts"][0]["token"], "body"); + assert_eq!(result["themeDiff"]["visualFidelity"], "unverified"); + assert_eq!(result["quality"]["theme"], "conflicted"); + assert_eq!(std::fs::read_to_string(directory.join("devup.json")).unwrap(), original); + std::fs::remove_dir_all(directory).unwrap(); +} + +fn feedback_responsive_section() -> CollectedPayload { + let mut data = section(3); + for (index, (name, width)) in [("Desktop", 1440), ("992~1280", 1280), ("Mobile", 360)].iter().enumerate() { + let id = format!("1:{}", index + 1); + let candidate = &mut data.metadata["sectionIndex"]["candidates"][index]; + candidate["name"] = json!(name); + candidate["bounds"]["width"] = json!(width); + let node = data.snapshot.nodes.get_mut(&id).unwrap(); + node.fields.insert("name".into(), json!(name)); + node.fields.insert("width".into(), json!(width)); + node.fields.insert("parentId".into(), json!("0:1")); + node.fields.insert("layoutMode".into(), json!("VERTICAL")); + } + data +} + +#[tokio::test] +async fn feedback_responsive_selection_discovers_groups_without_claiming_implementation() { + let result = project(feedback_responsive_section(), operation(&["tsx"])).await.unwrap(); + assert_eq!(result["status"], "selection_required"); + assert_eq!(result["responsiveGroups"][0]["frameIds"], json!(["1:3", "1:2", "1:1"])); + assert_eq!(result["responsiveGroups"][0]["nextAction"]["arguments"]["outputs"], json!(["responsiveTsx"])); + assert_eq!(result["responsiveGroups"][0]["structureVerified"], false); + assert_eq!(result["breakpointCompletion"]["remainingBreakpoints"].as_array().unwrap().len(), 3); + assert_eq!(result["breakpointCompletion"]["implementedBreakpoints"], json!([])); +} + +#[tokio::test] +async fn feedback_responsive_desktop_export_reports_two_remaining_widths() { + let mut op = operation(&["tsx"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { *frame_ids = vec!["1:1".into()]; } + let result = project(feedback_responsive_section(), op).await.unwrap(); + assert_eq!(result["breakpointCompletion"]["exportedBreakpoints"].as_array().unwrap().len(), 1); + assert_eq!(result["breakpointCompletion"]["remainingBreakpoints"].as_array().unwrap().len(), 2); +} + +#[tokio::test] +async fn feedback_responsive_merge_reports_all_exported_widths_and_still_requires_verification() { + let mut op = operation(&["responsiveTsx"]); + if let PendingOperation::Export { frame_ids, .. } = &mut op { *frame_ids = vec!["1:1".into(), "1:2".into(), "1:3".into()]; } + let result = project(feedback_responsive_section(), op).await.unwrap(); + assert!(result["responsiveTsx"].is_string()); + assert_eq!(result["breakpointCompletion"]["exportedBreakpoints"].as_array().unwrap().len(), 3); + assert_eq!(result["breakpointCompletion"]["remainingBreakpoints"], json!([])); + assert_eq!(result["breakpointCompletion"]["implementationState"], "unverified"); +} + +#[tokio::test] +async fn feedback_responsive_tall_wrappers_and_direct_roots_have_callable_guidance() { + let mut data = feedback_responsive_section(); + for candidate in data.metadata["sectionIndex"]["candidates"].as_array_mut().unwrap() { + candidate["bounds"]["height"] = json!(10_000); + candidate["directChildCount"] = json!(2); + } + let selection = project(data.clone(), operation(&["tsx"])).await.unwrap(); + assert_eq!(selection["responsiveGroups"][0]["frameIds"], json!(["1:3","1:2","1:1"])); + assert!(selection["selection"]["explicitCandidates"].as_array().unwrap().len() >= 3); + data.metadata = json!({}); + data.target.node_id = Some("1:1".into()); + let result = project(data, operation(&["tsx", "responsiveTsx"])).await.unwrap(); + assert_eq!(result["responsiveGroups"][0]["breakpoints"].as_array().unwrap().len(), 3); + assert!(result["responsiveGroups"][0]["nextAction"]["arguments"].get("frameIds").is_none()); + assert_eq!(result["breakpointCompletion"]["exportedBreakpoints"].as_array().unwrap().len(), 3); + assert_eq!(result["breakpointCompletion"]["implementedBreakpoints"], json!([])); +} + +#[tokio::test] +async fn feedback_responsive_different_screen_names_are_not_grouped() { + let result = project(section(3), operation(&["tsx"])).await.unwrap(); + assert_eq!(result["responsiveGroups"], json!([]), "duplicate width variants are ambiguous"); +} + +#[test] +fn feedback_theme_mode_validation_is_additive_and_rejects_unprovable_requests() { + use theme_export::validate; + assert!(validate("full", &["devupJson".into()], None).is_ok()); + assert!(validate("used-only", &["devupJson".into()], None).is_err()); + assert!(validate("missing-only", &["devupJson".into(), "tsx".into()], None).is_err()); + assert!(validate("unknown", &["devupJson".into()], None).is_err()); +} + +#[tokio::test] +async fn feedback_theme_resource_retry_preserves_mode_and_required_code_outputs() { + let mut op = operation(&["tsx", "devupJson"]); + if let PendingOperation::Export { theme_options, delivery, .. } = &mut op { + theme_options.mode = "used-only".into(); + *delivery = DeliveryMode::Resource; + } + let result = project(feedback_theme_payload(), op).await.unwrap(); + let args = &result["nextAction"]["arguments"]; + assert_eq!(args["devupJsonMode"], "used-only"); + assert_eq!(args["outputs"], json!(["tsx", "devupJson"])); + let outputs: Vec = serde_json::from_value(args["outputs"].clone()).unwrap(); + assert!(theme_export::validate(args["devupJsonMode"].as_str().unwrap(), &outputs, None).is_ok()); +} + +#[tokio::test] +async fn feedback_theme_missing_only_refuses_missing_project_theme() { + let directory = std::env::temp_dir().join(format!("devup-no-theme-{}", rand::random::())); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("package.json"), "{}").unwrap(); + let mut op = operation(&["tsx", "devupJson"]); + if let PendingOperation::Export { theme_options, .. } = &mut op { + theme_options.mode = "missing-only".into(); + theme_options.project_root = Some(directory.to_string_lossy().into()); + } + let error = project(feedback_theme_payload(), op).await.unwrap_err(); + assert_eq!(error.code, ErrorCode::DevupInvalidInput); + assert!(error.message.contains("existing devup.json")); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn feedback_theme_input_defaults_to_compatible_full_export() { + let input: super::super::tools::FigmaExportInput = serde_json::from_value(json!({"url":"https://www.figma.com/design/test/Test?node-id=1-1"})).unwrap(); + assert_eq!(input.devup_json_mode, "full"); + assert_eq!(input.outputs, vec!["tsx"]); +} + +#[tokio::test] +async fn feedback_theme_uncollected_used_reference_is_partial_and_never_invented() { + let mut data = feedback_theme_payload(); + data.styles.as_mut().unwrap().raw["styles"][0]["value"]["boundVariables"] = json!({"fontSize":{"id":"missing-size"}}); + data.variables.as_mut().unwrap().raw["styles"][0]["value"]["boundVariables"] = json!({"fontSize":{"id":"missing-size"}}); + // A string token that the collected variables cannot represent: preserve + // the reference and report its missing definition, rather than synthesize. + data.variables.as_mut().unwrap().raw["variables"] = json!([{ "id":"missing-size","name":"UncollectedSize","resolvedType":"FLOAT","variableCollectionId":"missing","valuesByMode":{}}]); + let mut op = operation(&["tsx", "devupJson"]); + if let PendingOperation::Export { theme_options, .. } = &mut op { theme_options.mode = "used-only".into(); } + let result = project(data, op).await.unwrap(); + assert_eq!(result["quality"]["theme"], "unresolved"); + assert_eq!(result["unresolvedUsedTokens"][0]["token"], "$uncollectedSize"); + assert_eq!(result["status"], "partial"); +} diff --git a/crates/devup-mcp/src/server/projection/responsive_groups.rs b/crates/devup-mcp/src/server/projection/responsive_groups.rs new file mode 100644 index 00000000..208d4536 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/responsive_groups.rs @@ -0,0 +1,126 @@ +use super::*; +use devup_mcp_devup_ui::codegen::responsive::{ + breakpoint_family_is_possible, is_breakpoint_name, slot_of_width, +}; +use devup_mcp_figma::ExploreCandidate; + +pub fn discover(candidates: &[ExploreCandidate], artifact_id: &str) -> Vec { + let mut buckets = BTreeMap::<(Option, String), Vec<&ExploreCandidate>>::new(); + for candidate in candidates.iter().filter(|candidate| { + candidate.node.is_screen_candidate() + || (candidate.node.visible + && candidate.node.node_type == "FRAME" + && is_breakpoint_name(&candidate.node.name)) + }) { + let name = candidate.node.name.trim().to_ascii_lowercase(); + let family = if is_breakpoint_name(&name) { + "@breakpoint-labels".into() + } else { + name + }; + buckets + .entry((candidate.node.parent_id.clone(), family)) + .or_default() + .push(candidate); + } + buckets.into_iter().filter_map(|((parent, name), mut frames)| { + let names_widths = frames.iter().map(|frame| (frame.node.name.as_str(), frame.node.bounds.width)).collect::>(); + let slots = frames.iter().filter_map(|frame| width_slot(frame.node.bounds.width)).collect::>(); + // Repeated width bands are ambiguous (e.g. light/dark copies). Never + // silently choose a winner or merge different screens on width alone. + if !breakpoint_family_is_possible(&names_widths) || slots.len() != frames.len() { return None; } + frames.sort_by(|left, right| left.node.bounds.width.total_cmp(&right.node.bounds.width)); + let ids = frames.iter().map(|frame| frame.node.node_id.clone()).collect::>(); + Some(json!({"name":if name == "@breakpoint-labels" {"breakpoint variants"} else {&name}, + "parentId":parent,"state":"candidate","relationshipEvidence":"sibling names and distinct width bands", + "structureVerified":false,"frameIds":ids, + "breakpoints":frames.iter().map(|frame| json!({"nodeId":frame.node.node_id,"name":frame.node.name, + "width":frame.node.bounds.width,"slot":width_slot(frame.node.bounds.width)})).collect::>(), + "nextAction":{"tool":"devup_figma_export","arguments":{"artifactId":artifact_id,"frameIds":ids,"outputs":["responsiveTsx"],"delivery":"resource"}, + "why":"Export these related widths together. The merge will report structural differences and unrepresented properties."}, + "completionRequirement":"Report implemented breakpoints and remaining breakpoints; generated code alone does not verify the application at those widths."})) + }).collect() +} + +pub fn from_roots(payload: &CollectedPayload, artifact_id: &str) -> Vec { + let candidates = payload + .snapshot + .roots + .iter() + .filter_map(|id| { + let node = + devup_mcp_figma::ExploreNode::try_from(payload.snapshot.nodes.get(id)?).ok()?; + node.parent_id.as_ref()?; + Some(devup_mcp_figma::ExploreCandidate { + canonical_url: payload.target.link(Some(id)), + node, + score: 0, + selection_reasons: vec!["captured-root".into()], + }) + }) + .collect::>(); + let mut groups = discover(&candidates, artifact_id); + for group in &mut groups { + group["nextAction"]["arguments"] + .as_object_mut() + .unwrap() + .remove("frameIds"); + } + groups +} + +fn width_slot(width: f64) -> Option { + if !width.is_finite() || width <= 0.0 || width > f64::from(u32::MAX) { + return None; + } + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "validated positive frame pixel width" + )] + Some(slot_of_width(width as u32)) +} + +pub fn completion(result: &mut Map, selected: &[String], all_screens: bool) { + let Some(groups) = result.get("responsiveGroups").and_then(Value::as_array) else { + return; + }; + if groups.is_empty() { + return; + } + let merged = result.get("responsiveTsx").is_some(); + let exported_ids = result + .get("frames") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|frame| { + ["tsx", "componentTsx"] + .iter() + .any(|key| frame.get(key).is_some()) + }) + .filter_map(|frame| frame.get("nodeId").and_then(Value::as_str)) + .collect::>(); + let mut exported = Vec::new(); + let mut remaining = Vec::new(); + for group in groups { + for frame in group["breakpoints"].as_array().into_iter().flatten() { + let id = frame["nodeId"].as_str().unwrap_or_default(); + let included = exported_ids.contains(id) + || (merged + && (all_screens + || selected.is_empty() + || selected.iter().any(|node| node == id))) + || (result["source"]["nodeId"] == id && result.get("tsx").is_some()); + if included { + exported.push(frame.clone()); + } else { + remaining.push(frame.clone()); + } + } + } + result.insert("breakpointCompletion".into(), json!({"exportedBreakpoints":exported,"remainingBreakpoints":remaining, + "implementedBreakpoints":[],"implementationState":"unverified", + "reportRequired":["implementedBreakpoints","remainingBreakpoints"], + "guidance":"Implement and verify every exported width in the project, export remaining variants with responsiveTsx, and report both lists before calling the screen complete."})); +} diff --git a/crates/devup-mcp/src/server/projection/theme_export.rs b/crates/devup-mcp/src/server/projection/theme_export.rs new file mode 100644 index 00000000..43973b99 --- /dev/null +++ b/crates/devup-mcp/src/server/projection/theme_export.rs @@ -0,0 +1,250 @@ +use super::*; +use devup_mcp_devup_ui::theme::{ThemeOutput, TokenUsage}; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ThemeExportOptions { + pub mode: String, + pub project_root: Option, +} + +pub fn validate( + mode: &str, + outputs: &[String], + project_root: Option<&str>, +) -> Result<(), DevupError> { + if !matches!(mode, "full" | "used-only" | "missing-only") { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "devupJsonMode must be full, used-only or missing-only.", + false, + )); + } + if mode != "full" + && (!outputs.iter().any(|o| o == "devupJson") + || !outputs + .iter() + .any(|o| matches!(o.as_str(), "tsx" | "componentTsx" | "responsiveTsx"))) + { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "used-only/missing-only requires devupJson and a generated code output (tsx, componentTsx or responsiveTsx).", + false, + )); + } + if mode == "missing-only" && project_root.is_none() { + return Err(DevupError::new( + ErrorCode::DevupInvalidInput, + "missing-only requires projectRoot with an existing devup.json.", + false, + )); + } + Ok(()) +} + +fn usage(result: &Map) -> Result<(TokenUsage, usize), DevupError> { + fn add( + result: &Map, + usage: &mut TokenUsage, + count: &mut usize, + ) -> Result<(), DevupError> { + for key in ["tsx", "componentTsx", "responsiveTsx"] { + if let Some(source) = result.get(key).and_then(Value::as_str) { + usage.add_tsx(source)?; + *count += 1; + } + } + if let Some(frames) = result.get("frames").and_then(Value::as_array) { + for frame in frames.iter().filter_map(Value::as_object) { + add(frame, usage, count)?; + } + } + Ok(()) + } + let mut usage = TokenUsage::default(); + let mut count = 0; + add(result, &mut usage, &mut count)?; + Ok((usage, count)) +} + +pub fn generate( + snapshot: &devup_mcp_devup_ui::theme::VariableSnapshot, + scope: devup_mcp_devup_ui::theme::ThemeScope, + options: &ThemeExportOptions, + result: &Map, +) -> Result { + if matches!(options.mode.as_str(), "used-only" | "missing-only") { + let (usage, count) = usage(result)?; + if count == 0 { + return Err(DevupError::new( + ErrorCode::DevupCodegenFailed, + "No generated code is available to establish used theme references.", + false, + )); + } + devup_mcp_devup_ui::theme::generate_used_devup_json(snapshot, &usage) + } else { + generate_devup_json(snapshot, scope) + } +} + +pub fn prepare( + output: &mut ThemeOutput, + options: &ThemeExportOptions, + result: &mut Map, + payload: &CollectedPayload, + strict: bool, +) -> Result<(), DevupError> { + for skipped in &mut output.skipped_styles { + skipped.node_ids = payload + .snapshot + .nodes + .values() + .filter(|node| { + let view = node.typed_view(); + view.string("textStyleId") == Some(skipped.style_id.as_str()) + || view + .value("styledTextSegments") + .and_then(Value::as_array) + .is_some_and(|segments| { + segments + .iter() + .any(|segment| segment["textStyleId"] == skipped.style_id) + }) + }) + .map(|node| node.id.clone()) + .collect(); + for diagnostic in output + .diagnostics + .iter_mut() + .filter(|d| d.resource_id.as_ref() == Some(&skipped.style_id)) + { + diagnostic.node_id = skipped.node_ids.first().cloned(); + diagnostic.message = format!( + "Text style '{}' ({}) used by nodes [{}] was skipped: {}", + skipped.name, + skipped.style_id, + skipped.node_ids.join(", "), + skipped.reason + ); + if let Some(details) = &mut diagnostic.details { + details["nodeIds"] = json!(skipped.node_ids); + } + } + } + if strict && let Some(skipped) = output.skipped_styles.first() { + return Err(DevupError::with_details( + ErrorCode::DevupThemeConflict, + format!( + "Text style '{}' ({}) used by nodes [{}] is not representable: {}", + skipped.name, + skipped.style_id, + skipped.node_ids.join(", "), + skipped.reason + ), + false, + json!({"skippedStyles":output.skipped_styles}), + )); + } + let (usage, code_count) = usage(result)?; + if options.mode != "full" && !options.mode.is_empty() { + if code_count == 0 { + return Err(DevupError::new( + ErrorCode::DevupCodegenFailed, + "No code output was generated; used-only/missing-only cannot infer successful token usage.", + false, + )); + } + devup_mcp_devup_ui::theme::restrict_devup_json(output, &usage)?; + } + result.insert( + "devupJsonMode".into(), + json!(if options.mode.is_empty() { + "full" + } else { + &options.mode + }), + ); + result.insert("skippedStyles".into(), json!(output.skipped_styles)); + if let Some(root) = options.project_root.as_deref() { + let lookup = super::super::project_context::theme_for_validation(Some(root))?; + if let Some(existing) = lookup.theme { + if code_count > 0 { + let diff = + devup_mcp_devup_ui::theme::missing_token_diff(&output.json, &usage, &existing)?; + if options.mode == "missing-only" { + output.json = format!( + "{}\n", + serde_json::to_string_pretty(&diff["additions"]).expect("JSON serializes") + ); + let additions = &diff["additions"]; + output.source_map.entries.retain(|entry| { + entry + .json_pointer + .as_deref() + .is_some_and(|pointer| additions.pointer(pointer).is_some()) + }); + output.counts.variables = output + .source_map + .entries + .iter() + .filter_map(|entry| entry.variable_id.as_ref()) + .collect::>() + .len(); + output.counts.styles = output + .source_map + .entries + .iter() + .filter_map(|entry| entry.style_id.as_ref()) + .collect::>() + .len(); + } + result.insert("themeDiff".into(), diff); + } else { + result.insert("themeDiff".into(), json!({"state":"not-checked","reason":"No generated code output was requested."})); + } + } else if options.mode == "missing-only" { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + "No unambiguous existing devup.json was available for missing-only.", + false, + json!({"guardrail":lookup.guardrail}), + )); + } else { + result.insert( + "themeDiff".into(), + json!({"state":"unavailable","guardrail":lookup.guardrail}), + ); + } + } + if code_count > 0 { + let unresolved = if let Some(tokens) = result + .get("themeDiff") + .and_then(|diff| diff.get("unresolvedTokens")) + { + tokens.clone() + } else { + devup_mcp_devup_ui::theme::missing_token_diff( + &output.json, + &usage, + &Default::default(), + )?["unresolvedTokens"] + .clone() + }; + if unresolved + .as_array() + .is_some_and(|tokens| !tokens.is_empty()) + { + output.completeness = devup_mcp_devup_ui::theme::Completeness::ResolvedValuesOnly; + if strict { + return Err(DevupError::with_details( + ErrorCode::DevupThemeConflict, + "Generated code references tokens without a collected or existing definition.", + false, + json!({"unresolvedUsedTokens":unresolved}), + )); + } + } + result.insert("unresolvedUsedTokens".into(), unresolved); + } + Ok(()) +} diff --git a/crates/devup-mcp/src/server/review.rs b/crates/devup-mcp/src/server/review.rs index 413e673f..0034445f 100644 --- a/crates/devup-mcp/src/server/review.rs +++ b/crates/devup-mcp/src/server/review.rs @@ -27,7 +27,15 @@ fn items_from_issues(scope: &str, issues: &[Value], out: &mut Vec) { let lossy = members .iter() .any(|m| matches!(text(&m["fidelityImpact"]), "lossy" | "failed")); - let nodes: Vec<&Value> = members.iter().map(|m| &m["nodeId"]).collect(); + let nodes: Vec<&Value> = members + .iter() + .flat_map(|m| { + m["details"]["nodeIds"] + .as_array() + .map(|a| a.iter().collect::>()) + .unwrap_or_else(|| vec![&m["nodeId"]]) + }) + .collect(); let first = members[0]; let how = first["details"]["nextAction"] .as_str() @@ -37,7 +45,7 @@ fn items_from_issues(scope: &str, issues: &[Value], out: &mut Vec) { "Compare the generated property with the raw Figma value for these nodes (export rawSnapshot with debug: true) before shipping.".into() }); out.push(json!({ - "severity": if lossy { "required" } else { "recommended" }, + "severity": if lossy || code.starts_with("DEVUP_CODEGEN_INTERACTION_") { "required" } else { "recommended" }, "scope": scope, "code": code, "count": members.len(), @@ -63,6 +71,26 @@ fn has_line_height(value: &Value) -> bool { } fn collect(response: &Value, scope: &str, out: &mut Vec) { + for (field, code, what, how) in [ + ( + "widgetCandidates", + "WIDGET_INTEGRATION_REQUIRED", + "External widget candidates require provider integration.", + "Confirm provider, existing integration and env key names without values; implement and test the widget behavior.", + ), + ( + "sharedComponentCandidates", + "SHARED_COMPONENT_REVIEW", + "Repeated component instances may differ across screens.", + "Review instance labels, menu counts and variants together; confirm the authoritative versions, reuse shared components and wire navigation once.", + ), + ] { + if response[field].as_array().is_some_and(|a| !a.is_empty()) { + out.push( + json!({"severity":"required","scope":scope,"code":code,"what":what,"how":how}), + ); + } + } if let Some(issues) = response["projectionIssues"].as_array() { items_from_issues(scope, issues, out); } @@ -84,6 +112,11 @@ fn collect(response: &Value, scope: &str, out: &mut Vec) { /// Add `summary` and, only when something needs review, `reviewChecklist`. pub(super) fn attach(result: &mut Value) { let mut items = vec![]; + if result["metadataDelivery"]["requiresFullDetailRead"] == true { + items.push(json!({"severity":"required","scope":"response","code":"PROJECTION_DETAILS_RESOURCE", + "what":"Complete projection diagnostic details are delivered as a resource.", + "how":"Read metadataDelivery.uri and follow each fullDetailsResource.jsonPointer before accepting evidence; inline issues retain identities and severity but summarize details."})); + } collect(result, "response", &mut items); if let Some(frames) = result["frames"].as_array() { for frame in frames { @@ -108,8 +141,15 @@ pub(super) fn attach(result: &mut Value) { "required":required,"recommended":recommended,"next":next}), ); if !items.is_empty() { - items.sort_by_key(|i| i["severity"] != "required"); + items.sort_by_key(|i| { + ( + i["severity"] != "required", + !text(&i["code"]).starts_with("DEVUP_CODEGEN_INTERACTION_"), + ) + }); + let omitted = items.len().saturating_sub(MAX_ITEMS); items.truncate(MAX_ITEMS); + object.get_mut("summary").unwrap()["omittedReviewItems"] = json!(omitted); object.insert("reviewChecklist".into(), json!(items)); } } diff --git a/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md b/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md index 2bda3e93..b0056f2e 100644 --- a/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devfive-frontend/SKILL.md @@ -17,6 +17,65 @@ description: | Common coding conventions for DevFive frontend projects (puzzle-fit, girok-space, etc.). +## Links, controls and completion evidence + +`devup_figma_export` produces static visual markup. Its `interactiveCandidates` +are a review list, and `deliverable.isFinal` never proves working navigation or +forms. Resolve GNB, logo, footer, CTA, cards, “view all”, tabs and breadcrumbs +before reporting functional completion. Preserve checkbox/radio semantics and +selected/disabled/active variants; an icon or widget screenshot is not behavior. +`@devup-interaction` TSX comments preserve prototype/state hints; they do not wire +events or implement runtime state. Validation separates semantic controls, native +checkbox/radio toggling and application wiring; report those counts by their scope. + +1. Supply `projectRoot` with the export to review `projectContext.ui` reuse + evidence, routes, `routeSuggestions`, and `envKeyNames` (names only, never + request or print values). Alternatively read `devup_project_context` scope + `ui` or `routes`. Check each candidate against the actual application's routes. + Suggestions are heuristics, not approved destinations. A dynamic detail page + also needs real per-card IDs/data; report missing data as an unresolved reason. +2. Reuse existing Header/Footer/widgets after comparing props and design variants. + Review `sharedComponentCandidates` for menu counts, labels and states that + differ between screens. Ask which version applies before making it site-wide. + Review `ambiguityQuestions` together in the first response, including unresolved + destinations, shared variants, logos and third-party widget providers. +3. Use `next/link` for internal navigation, `` for external URLs (when opening a + new tab, set `rel="noopener noreferrer"`), and `