diff --git a/.changepacks/changepack_log_boolean_logo_pagination.json b/.changepacks/changepack_log_boolean_logo_pagination.json new file mode 100644 index 00000000..4e5de6fb --- /dev/null +++ b/.changepacks/changepack_log_boolean_logo_pagination.json @@ -0,0 +1,9 @@ +{ + "changes": { + "crates/devup-mcp-figma/Cargo.toml": "Patch", + "crates/devup-mcp-devup-ui/Cargo.toml": "Minor", + "crates/devup-mcp/Cargo.toml": "Patch" + }, + "note": "Keep the vector operands of a Section screen that does not fit one page. A multi-root snapshot answers with a cursor when it has more nodes to give, and the collector read the first page and stopped, so on the two Loading screens of section 4279:7810 the twelve operands of two Boolean logos never arrived and the logos came out as grey boxes. The continuation is now followed to the end with the same Section and the same root set, and the cursor is checked rather than trusted: the offset has to be the one that was asked for, the next offset has to be the requested offset plus the nodes that came back, it may not pass the total, the complete flag has to agree with it, and a cursor that does not advance is refused. A continuation can no longer fall back to a legacy restart either, because the pages already accepted would be mixed with a second capture, and a first page is never cached as a finished design while operands remain unread. BOOLEAN_OPERATION is recognised as a vector asset, in asset discovery and in the single-colour test that decides between a masked Box and an Image, so the logo is exported as SVG and the generated TSX refers to those bytes. Fidelity gains uncoveredNodeIds: a declared child absent from both the snapshot and any asset projection is a hole nothing represents, so it counts against node coverage and as a lossy impact, where an operand deliberately flattened into an SVG does not. Verified against file 85CgSws3o5XsLv7aAwWJyS with cache bypassed - status partial to complete, 18 nodes to 30, 16 of 28 formats collected to 28 of 28, twelve missing formats to none, and both logos exported as SVG whose manifest, file hash, source map and TSX reference agree.", + "date": "2026-09-08T18:40:00+09:00" +} diff --git a/Cargo.lock b/Cargo.lock index 6abc7e8b..418bf70b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -684,7 +684,7 @@ dependencies = [ [[package]] name = "devup-mcp" -version = "0.1.0" +version = "0.2.1" dependencies = [ "anyhow", "async-trait", @@ -707,7 +707,7 @@ dependencies = [ [[package]] name = "devup-mcp-devup-ui" -version = "0.1.0" +version = "0.2.1" dependencies = [ "devup-mcp-figma", "insta", @@ -724,7 +724,7 @@ dependencies = [ [[package]] name = "devup-mcp-figma" -version = "0.1.0" +version = "0.2.1" dependencies = [ "anyhow", "async-trait", @@ -749,7 +749,7 @@ dependencies = [ [[package]] name = "devup-mcp-visual" -version = "0.1.0" +version = "0.2.1" dependencies = [ "anyhow", "image", diff --git a/crates/devup-mcp-devup-ui/src/codegen/style.rs b/crates/devup-mcp-devup-ui/src/codegen/style.rs index 57ae5d0c..2174cb48 100644 --- a/crates/devup-mcp-devup-ui/src/codegen/style.rs +++ b/crates/devup-mcp-devup-ui/src/codegen/style.rs @@ -40,7 +40,10 @@ fn asset_kind_nested(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Optio return None; } - if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") { + if matches!( + view.node_type(), + "VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION" + ) { return Some(svg_asset_kind(snapshot, node, nested)); } @@ -323,7 +326,11 @@ fn same_color( { return SameColor::Null; } - if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") { + // Boolean operands define geometry; the result's own paint colors it. + if matches!( + view.node_type(), + "VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION" + ) { return own(); } if view.node_type() == "ELLIPSE" diff --git a/crates/devup-mcp-devup-ui/src/provenance.rs b/crates/devup-mcp-devup-ui/src/provenance.rs index bae5e99c..91130f93 100644 --- a/crates/devup-mcp-devup-ui/src/provenance.rs +++ b/crates/devup-mcp-devup-ui/src/provenance.rs @@ -125,6 +125,10 @@ pub struct FidelityReport { pub assets: FidelityCoverage, pub layout: FidelityCoverage, pub impacts: FidelityImpactCounts, + /// Declared children absent from both the snapshot and an asset projection. + /// Unlike an intentionally flattened SVG operand, these are visual losses. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub uncovered_node_ids: Vec, /// The `nodeId#property` layout pairs the generated TSX does not account /// for, bounded by [`MAX_REPORTED_UNCOVERED`]. Reporting only a ratio left /// a shortfall untriageable: nothing said whether the layout was wrong or @@ -395,6 +399,18 @@ pub fn validate_fidelity( .map(|node| node.id.clone()) .collect::>(); let parents = source_parents(snapshot); + // Coverage is otherwise measured only over nodes that arrived. Count + // declared holes too, unless an actual asset projection represents them. + // Snapshot completeness still reports every missing operand in either case. + let unrepresented_children = semantic_nodes + .iter() + .filter(|node_id| { + !asset_nodes.contains(**node_id) && !has_asset_ancestor(node_id, &parents, &asset_nodes) + }) + .filter_map(|node_id| snapshot.nodes.get(*node_id)) + .flat_map(|node| node.typed_view().child_ids().collect::>()) + .filter(|child_id| !snapshot.nodes.contains_key(*child_id)) + .collect::>(); let layout = semantic_nodes .iter() .filter(|node_id| !has_asset_ancestor(node_id, &parents, &asset_nodes)) @@ -443,9 +459,18 @@ pub fn validate_fidelity( FidelityImpact::Failed => impacts.failed += 1, } } + impacts.lossy += unrepresented_children.len(); Ok(FidelityReport { syntax_valid: true, - nodes: FidelityCoverage::new(expected.len(), observed.len()), + nodes: FidelityCoverage::new( + expected.len() + unrepresented_children.len(), + observed.len(), + ), + uncovered_node_ids: unrepresented_children + .into_iter() + .take(MAX_REPORTED_UNCOVERED) + .map(str::to_owned) + .collect(), text: FidelityCoverage::new(text_segments.len(), covered_text), variables: FidelityCoverage::new(variables.len(), covered_variables), typography: FidelityCoverage::new(typography.len(), covered_typography), diff --git a/crates/devup-mcp-devup-ui/tests/boolean_logos.rs b/crates/devup-mcp-devup-ui/tests/boolean_logos.rs new file mode 100644 index 00000000..6597d9e0 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/boolean_logos.rs @@ -0,0 +1,67 @@ +use devup_mcp_devup_ui::codegen::{CodegenOptions, generate_component}; +use devup_mcp_figma::{Snapshot, discover_asset_manifest}; + +#[test] +fn loading_boolean_logos_reference_exportable_svg_without_hiding_missing_operands() { + // Captured 2026-09-08 from the two Loading screens in section 4279:7810. + // Keep only the logo wrappers and unions; their twelve operands were absent. + let snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/loading-boolean-logos.json")).unwrap(); + assert_eq!(snapshot.audit().missing_children.len(), 12); + let manifest = discover_asset_manifest(&snapshot); + assert_eq!(manifest.assets.len(), 2, "both logos must be exportable"); + for (root, expected_asset) in [ + ("3831:10710", "3831:10710:node"), + ("3831:10725", "3831:10725:node"), + ] { + let asset = manifest + .assets + .iter() + .find(|a| a.asset_id == expected_asset) + .unwrap(); + assert_eq!(asset.source_kind, "vector-node"); + assert_eq!(asset.field, "node"); + let output = generate_component(&snapshot, root, &CodegenOptions::default()).unwrap(); + assert!(output.tsx.contains(".svg"), "{}", output.tsx); + assert!( + output + .source_map + .entries + .iter() + .any(|e| e.asset_id.as_deref() == Some(expected_asset) + && e.generated_range.is_some()) + ); + assert_eq!(output.fidelity_report.assets.total, 1); + assert_eq!(output.fidelity_report.assets.covered, 1); + } + assert_eq!(snapshot.audit().missing_children.len(), 12); +} + +#[test] +fn an_unrepresented_missing_child_is_lossy_even_when_every_collected_node_has_tsx() { + let mut snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/loading-boolean-logos.json")).unwrap(); + let union = snapshot.nodes.get_mut("3831:10711").unwrap(); + union.node_type = "GROUP".into(); + let output = generate_component(&snapshot, "3831:10710", &CodegenOptions::default()).unwrap(); + assert!(output.fidelity_report.impacts.lossy > 0); + assert_eq!(output.fidelity_report.nodes.total, 8); + assert_eq!(output.fidelity_report.nodes.covered, 2); + assert!(!output.fidelity_report.strict_compatible()); +} + +#[test] +fn missing_descendants_of_a_hidden_logo_do_not_claim_visual_loss() { + let mut snapshot: Snapshot = + serde_json::from_str(include_str!("fixtures/loading-boolean-logos.json")).unwrap(); + snapshot + .nodes + .get_mut("3831:10710") + .unwrap() + .fields + .insert("visible".into(), serde_json::json!(false)); + snapshot.nodes.get_mut("3831:10711").unwrap().node_type = "GROUP".into(); + let output = generate_component(&snapshot, "3831:10710", &CodegenOptions::default()).unwrap(); + assert_eq!(output.fidelity_report.impacts.lossy, 0); + assert!(output.fidelity_report.nodes.complete()); +} diff --git a/crates/devup-mcp-devup-ui/tests/fixtures/loading-boolean-logos.json b/crates/devup-mcp-devup-ui/tests/fixtures/loading-boolean-logos.json new file mode 100644 index 00000000..aeafd149 --- /dev/null +++ b/crates/devup-mcp-devup-ui/tests/fixtures/loading-boolean-logos.json @@ -0,0 +1,121 @@ +{ + "fileKey": "85CgSws3o5XsLv7aAwWJyS", + "version": null, + "roots": [ + "3831:10710", + "3831:10725" + ], + "nodes": { + "3831:10710": { + "id": "3831:10710", + "type": "FRAME", + "fields": { + "childrenIds": [ + "3831:10711" + ], + "height": 34, + "isAsset": true, + "name": "BI - 아이콘", + "targetAspectRatio": { + "x": 59.07421875, + "y": 31.029436111450195 + }, + "visible": true, + "width": 64 + }, + "extra": {}, + "fieldErrors": {} + }, + "3831:10711": { + "id": "3831:10711", + "type": "BOOLEAN_OPERATION", + "fields": { + "parentId": "3831:10710", + "childrenIds": [ + "3831:10712", + "3831:10713", + "3831:10714", + "3831:10715", + "3831:10716", + "3831:10717" + ], + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.17999267578125, + "g": 0.17999267578125, + "r": 0.4589996337890625 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 33.99979019165039, + "name": "Union", + "visible": true, + "width": 64 + }, + "extra": {}, + "fieldErrors": {} + }, + "3831:10725": { + "id": "3831:10725", + "type": "FRAME", + "fields": { + "childrenIds": [ + "3831:10726" + ], + "height": 34, + "isAsset": true, + "name": "BI - 아이콘", + "targetAspectRatio": { + "x": 59.07421875, + "y": 31.029436111450195 + }, + "visible": true, + "width": 64 + }, + "extra": {}, + "fieldErrors": {} + }, + "3831:10726": { + "id": "3831:10726", + "type": "BOOLEAN_OPERATION", + "fields": { + "parentId": "3831:10725", + "childrenIds": [ + "3831:10727", + "3831:10728", + "3831:10729", + "3831:10730", + "3831:10731", + "3831:10732" + ], + "fills": [ + { + "blendMode": "NORMAL", + "boundVariables": {}, + "color": { + "b": 0.17999267578125, + "g": 0.17999267578125, + "r": 0.4589996337890625 + }, + "opacity": 1, + "type": "SOLID", + "visible": true + } + ], + "height": 33.99979019165039, + "name": "Union", + "visible": true, + "width": 64 + }, + "extra": {}, + "fieldErrors": {} + } + }, + "diagnostics": [] +} diff --git a/crates/devup-mcp-figma/src/assets.rs b/crates/devup-mcp-figma/src/assets.rs index 14002e62..9fc2355c 100644 --- a/crates/devup-mcp-figma/src/assets.rs +++ b/crates/devup-mcp-figma/src/assets.rs @@ -185,7 +185,12 @@ fn compute_asset_node(snapshot: &Snapshot, node: &RawNode, nested: bool) -> Opti return None; } - if matches!(view.node_type(), "VECTOR" | "STAR" | "POLYGON") { + // A boolean's rendered shape is authoritative even when its operands + // were not included in the snapshot. Export the node, never its bounds. + if matches!( + view.node_type(), + "VECTOR" | "STAR" | "POLYGON" | "BOOLEAN_OPERATION" + ) { return Some(AssetNode::Svg); } diff --git a/crates/devup-mcp-figma/src/collector.rs b/crates/devup-mcp-figma/src/collector.rs index 34035bf0..3613a7eb 100644 --- a/crates/devup-mcp-figma/src/collector.rs +++ b/crates/devup-mcp-figma/src/collector.rs @@ -690,6 +690,15 @@ impl CollectorSession { if !fast_call_fallback_allowed(error) { return Ok(false); } + // A continuation already has accepted pages and possibly field reads + // in flight. Restarting at zero would mix them with a second capture. + if pending.kind == CallKind::FastMultiRoot + && matches!(&pending.planned.call, ReadToolCall::Snapshot { + snapshot: Some(options), .. + } if options.offset > 0) + { + return Ok(false); + } let pending = self .pending .remove(call_id) @@ -1166,20 +1175,40 @@ impl CollectorSession { let ReadToolCall::Snapshot { script: BuiltinScript::MultiRootSnapshotEnvelope, root_ids: Some(root_ids), + snapshot: options, .. } = &planned.call else { return Err(invalid_call("multi-root snapshot call format is invalid.")); }; + let requested_offset = options.as_ref().map_or(0, |options| options.offset); let payload = match decode_fast_multi_snapshot(&result, &self.request.target, root_ids) { Ok(payload) => payload, - Err(error) if fast_call_fallback_allowed(&error) => { + Err(error) if requested_offset == 0 && fast_call_fallback_allowed(&error) => { self.fallback_multi_root_batch(planned, fallback_category(&error))?; return Ok(()); } Err(error) => return Err(error), }; - if let (Some(existing), Some(incoming)) = (&self.source_version, &payload.snapshot.version) + let mut chunk = payload.snapshot; + let cursor = take_snapshot_cursor(&mut chunk)?; + if let Some(cursor) = &cursor { + if cursor.offset != requested_offset + || requested_offset.checked_add(chunk.nodes.len()) != Some(cursor.next_offset) + || cursor.next_offset > cursor.total_nodes + || cursor.complete != (cursor.next_offset == cursor.total_nodes) + || (!cursor.complete && cursor.next_offset <= requested_offset) + { + return Err(invalid_call( + "Figma multi-root snapshot cursor does not match the requested node range.", + )); + } + } else if requested_offset != 0 { + return Err(invalid_call( + "Figma multi-root continuation is missing its snapshot cursor.", + )); + } + if let (Some(existing), Some(incoming)) = (&self.source_version, &chunk.version) && existing != incoming { return Err(DevupError::new( @@ -1188,13 +1217,20 @@ impl CollectorSession { true, )); } - if payload.snapshot.version.is_some() { - self.source_version = payload.snapshot.version.clone(); + if chunk.version.is_some() { + self.source_version = chunk.version.clone(); } - self.fast_multi_has_large_values |= !descriptors_in_chunk(&payload.snapshot)?.is_empty(); + self.fast_multi_has_large_values |= !descriptors_in_chunk(&chunk)?.is_empty(); merge_fast_resources(&mut self.fast_multi_resources, payload.resources)?; self.stats.transport = if self.section_fallback_roots.is_empty() { - payload.stats.transport + if requested_offset > 0 + || self.stats.transport == "text-paginated" + || cursor.as_ref().is_some_and(|cursor| !cursor.complete) + { + "text-paginated" + } else { + payload.stats.transport + } } else { "hybrid-multi-root-cursor" } @@ -1208,7 +1244,23 @@ impl CollectorSession { .stats .envelope_chunks .saturating_add(payload.stats.chunk_count); - self.record_snapshot_chunk(order, payload.snapshot)?; + self.record_snapshot_chunk(order, chunk)?; + if let Some(cursor) = cursor.filter(|cursor| !cursor.complete) { + // Keep this Section and this exact root set: another sibling may + // have a different cursor in flight. Never cache a first page as + // a complete design while its vector operands remain unread. + let mut continuation = planned.call.clone(); + if let ReadToolCall::Snapshot { snapshot, .. } = &mut continuation { + let mut options = options.clone().unwrap_or_default(); + options.offset = cursor.next_offset; + *snapshot = Some(options); + } + self.enqueue( + continuation, + planned.expected_node_id.clone(), + CallKind::FastMultiRoot, + ); + } Ok(()) } diff --git a/crates/devup-mcp-figma/tests/collector.rs b/crates/devup-mcp-figma/tests/collector.rs index 20f142b0..5e7f93b9 100644 --- a/crates/devup-mcp-figma/tests/collector.rs +++ b/crates/devup-mcp-figma/tests/collector.rs @@ -2113,6 +2113,194 @@ fn multi_root_ids(call: &ReadToolCall) -> Vec<&str> { root_ids.iter().map(String::as_str).collect() } +fn boolean_logo_page(root: &str, offset: usize) -> UpstreamResult { + let variable = format!("{root}-variable-{offset}"); + let base = fast_multi_envelope_result(&[root], &[&variable]); + let mut envelope: Value = + serde_json::from_str(base.raw["content"][0]["text"].as_str().unwrap()).unwrap(); + let union = format!("{root}-union"); + let operands = (0..6) + .map(|i| format!("{root}-vector-{i}")) + .collect::>(); + let mut first = envelope["snapshot"]["nodes"][0].clone(); + let mut nodes = if offset == 0 { + first["fields"]["childrenIds"] = json!([union]); + vec![ + first, + json!({"id": union, "type": "BOOLEAN_OPERATION", "fields": {"parentId": root, "childrenIds": operands}}), + ] + } else { + first["id"] = json!(operands[0]); + first["type"] = json!("VECTOR"); + first["fields"]["parentId"] = json!(union); + let mut nodes = vec![first]; + nodes.extend( + operands + .iter() + .skip(1) + .map(|id| json!({"id": id, "type": "VECTOR", "fields": {"parentId": union}})), + ); + nodes + }; + nodes.push(json!({"id": "__DEVUP_SNAPSHOT_CURSOR__", "type": "DEVUP_INTERNAL", "fields": { + "offset": offset, "nextOffset": if offset == 0 {2} else {8}, "totalNodes": 8, "complete": offset != 0 + }})); + envelope["integrity"]["nodeCount"] = json!(nodes.len()); + envelope["snapshot"]["nodes"] = json!(nodes); + UpstreamResult { + raw: json!({"content": [{"type": "text", "text": envelope.to_string()}]}), + } +} + +#[test] +fn section_boolean_operands_are_collected_from_each_roots_continuation() { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: vec!["root-0".into(), "root-1".into()], + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[8, 8])); + let mut collector = CollectorSession::new(request); + let mut first = Vec::new(); + for _ in 0..2 { + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!("first page expected") + }; + first.push(call); + } + // Complete siblings out of order, as the server does with concurrent reads. + for call in first.iter().rev() { + let root = multi_root_ids(&call.call)[0]; + collector + .accept(&call.id, boolean_logo_page(root, 0)) + .unwrap(); + } + for _ in 0..2 { + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!("six boolean operands still need a continuation page") + }; + let root = multi_root_ids(&call.call)[0]; + assert_eq!(call.expected_node_id.as_deref(), Some("10:1")); + assert!( + call.call.arguments()["code"] + .as_str() + .unwrap() + .contains("\"offset\":2") + ); + collector + .accept(&call.id, boolean_logo_page(root, 2)) + .unwrap(); + } + let CollectorStep::Complete(parts) = collector.advance().unwrap() else { + panic!("all pages should complete") + }; + let snapshot = merge_chunks(parts.snapshot_chunks).unwrap(); + assert_eq!(snapshot.nodes.len(), 16); + assert!(snapshot.audit().missing_children.is_empty()); + assert!(!snapshot.nodes.contains_key("__DEVUP_SNAPSHOT_CURSOR__")); + assert_eq!( + parts.variables.unwrap().raw["variables"] + .as_array() + .unwrap() + .len(), + 4 + ); +} + +#[test] +fn section_pages_reject_mismatched_or_nonadvancing_cursors() { + for (field, value) in [ + ("offset", json!(1)), + ("nextOffset", json!(0)), + ("nextOffset", json!(3)), + ("totalNodes", json!(1)), + // nextOffset == totalNodes requires complete: true. + ("totalNodes", json!(2)), + ] { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: vec!["root-0".into()], + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[8])); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(call) = collector.advance().unwrap() else { + panic!("first page expected") + }; + let mut result = boolean_logo_page("root-0", 0); + let mut envelope: Value = + serde_json::from_str(result.raw["content"][0]["text"].as_str().unwrap()).unwrap(); + envelope["snapshot"]["nodes"][2]["fields"][field] = value; + result.raw["content"][0]["text"] = json!(envelope.to_string()); + assert!( + collector.accept(&call.id, result).is_err(), + "invalid {field} must not be cached or followed" + ); + } +} + +#[test] +fn failed_section_continuations_do_not_restart_over_accepted_large_values() { + for malformed_response in [false, true] { + let mut request = CollectionRequest::new(target("10:1"), CollectionScope::Node); + request.resource_scope = ResourceScope::Used; + request.section = Some(SectionReadOptions { + frame_ids: vec!["root-0".into()], + all_screens: false, + }); + request.cached_section_index = Some(section_index_with_node_counts(&[8])); + let mut collector = CollectorSession::new(request); + let CollectorStep::Call(first) = collector.advance().unwrap() else { + panic!("first page expected") + }; + let mut page = boolean_logo_page("root-0", 0); + let mut envelope: Value = + serde_json::from_str(page.raw["content"][0]["text"].as_str().unwrap()).unwrap(); + envelope["snapshot"]["nodes"][0]["fields"]["characters"] = json!({"$largeValue": { + "nodeId": "root-0", "field": "characters", "byteLength": 19, + "sha256": "5ab6efd34df9db2f30a0581487fd5d023fde8f658c3dfe9378dbed52332e11f8", + "cursor": {"nextOffset": 0, "maxChunkBytes": 8} + }}); + page.raw["content"][0]["text"] = json!(envelope.to_string()); + collector.accept(&first.id, page).unwrap(); + let CollectorStep::Call(large_value) = collector.advance().unwrap() else { + panic!("large value read expected") + }; + assert!(matches!(large_value.call, ReadToolCall::LargeValue { .. })); + let CollectorStep::Call(continuation) = collector.advance().unwrap() else { + panic!("continuation expected") + }; + if malformed_response { + assert!( + collector + .accept( + &continuation.id, + UpstreamResult { + raw: json!({"content": []}) + } + ) + .is_err() + ); + } else { + let error = DevupError::new( + ErrorCode::DevupFigmaDirectUnavailable, + "continuation failed", + true, + ); + assert!( + !collector.reject(&continuation.id, &error).unwrap(), + "propagate the failure instead of restarting at offset zero" + ); + } + assert!( + matches!(collector.advance().unwrap(), CollectorStep::AwaitingResults), + "no legacy restart may mix with the pending field read" + ); + } +} + fn legacy_root_id(call: &ReadToolCall) -> &str { let ReadToolCall::Snapshot { node_id, diff --git a/docs/figma-vector-export-regression.md b/docs/figma-vector-export-regression.md new file mode 100644 index 00000000..03905db7 --- /dev/null +++ b/docs/figma-vector-export-regression.md @@ -0,0 +1,96 @@ +# Loading logo export regression + +Reproduced on 2026-09-08 from `origin/main` at +`1e8b0b6de9a6c10d59b2b27c282575922cc675d8` in an independent worktree. +No AGENTS.md was present in the repository or its applicable ancestor directories. + +Target: `https://www.figma.com/design/85CgSws3o5XsLv7aAwWJyS/?node-id=4279-7810`. +`devup_figma_explore` selected `3831:10708` and `3831:10723`. +Exports requested `tsx`, `devupJson`, `sourceMap`, `rawSnapshot`, and +`assetManifest`, with `refresh: true`. + +## Confirmed causes + +- Each screen's first upstream envelope contained nine nodes and a cursor + `{offset: 0, nextOffset: 9, totalNodes: 15, complete: false}`. + `accept_fast_multi_root` stored that page without following the cursor. + The six remaining nodes per screen were the Boolean logo's operands. + Neither vector filtering nor serialization nor related-explore reuse caused + this loss: the snapshot script walks every child, and the unread continuation + pages restore all twelve nodes. The same failure occurred in a fresh process. +- Asset discovery and TSX generation treated `BOOLEAN_OPERATION` as an ordinary + container requiring all its children. With absent operands, both rejected the + logo as an asset and rendered its solid fill over its rectangular bounds. +- Fidelity only counted collected nodes and discovered assets. The rectangle + accounted for a collected node, while the missing operands and undiscovered + logo asset were outside its denominator. + +## Fix and quality semantics + +The Section collector follows each root's cursor independently, validates its +range, removes internal markers, and merges resources from every page. Boolean +results are SVG assets, using their own paint rather than operand paint. +Figma's `exportAsync` supplies the geometry; no paths are inferred or drawn by hand. + +First-page legacy fallback remains supported. A failed continuation aborts the +collection with its original error instead of restarting at offset zero over +already accepted pages and potentially pending large-value reads. The caller can +retry a fresh acquisition; no mixed or incomplete result is cached as complete. + +`acquisition` measures snapshot/resource completeness. `projection` measures how +the acquired design is represented in TSX; `exact` is not a pixel comparison or +proof that referenced asset bytes have been downloaded. Missing visible children +without an asset representation now lower node coverage, add lossy impacts, and +appear in bounded `fidelity.uncoveredNodeIds`. An SVG can represent missing +operands, but the raw snapshot audit still reports those operands as missing. +Asset byte delivery is reported separately by each manifest entry's status. + +## Live verification + +Installed MCP 0.2.1 and a separately built baseline executable both reproduced +the bug. A separately built modified executable ran over stdio in this worktree; +the installed MCP processes and configuration were not replaced. Its process +had `DEVUP_FIGMA_CALL_CACHE` unset and URL requests used `refresh: true`. +The final release executable was copied to this worktree's ignored +`fixtures/local-tools/fixed.exe`, reporting `devup-mcp 0.2.1 (1e8b0b6de9a6-dirty)`. +Its SHA-256 is `10886d9efdb439ab52ae722b2a32671c911b6c26acc0d59021d3822d1aa1e135`; +the version string alone does not distinguish an installed build from this source build. + +| Measurement | Before | After | +| --- | --- | --- | +| Status / acquisition | partial / partial | complete / complete | +| Preserved / reachable nodes | 18 / 18 | 30 / 30 | +| Declared / exported children | 28 / 16 | 28 / 28 | +| Missing children | 12 | 0 | +| Manifest entries | 2 image fills | 2 image fills + 2 SVG logos | +| Logo TSX | solid Box | Box with original SVG maskImage | +| Per-frame asset coverage | 1 / 1, logo absent | 2 / 2, logo included | + +Explicit SVG asset requests for `3831:10710:node` and `3831:10725:node` +both returned `exported`: 606 bytes, viewBox `0 0 64 34`, SHA-256 +`ace6e87aecd15a5237d36630135e297783ce17eec4e2f31b0de93a043ac7824f`. +The actual files, manifest hashes, TSX references, and sourceMap byte ranges +were checked together. The theme remained byte-equivalent as parsed JSON. +Reusing the artifact with the same `frameIds` made zero Figma calls and returned +the same complete snapshot and TSX. Section selection must also be specified on +artifact reuse; omitting it requests screen selection rather than screen export. +Large responses, binaries, and exported assets remain ignored local artifacts. + +Regression coverage includes two interleaved screen continuations, resource +merging, invalid cursors, failed continuations with pending large values, +the small captured incomplete-logo fixture, uncovered +visible children, and hidden descendants. Cargo.lock only synchronizes the four +workspace package versions with the existing 0.2.1 manifests. + +Validation commands passed: + +- `cargo fmt --all -- --check` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `cargo test --workspace --all-features` — 476 passed, 2 opt-in live tests ignored +- `cargo insta test --workspace --all-features --check` — no snapshots to review +- `node --test crates/devup-mcp-figma/tests/explore_script_behavior.mjs` — 4 passed +- `cargo build --workspace --release` + +The initial final-check attempt ran out of disk space. The complete check sequence +above was rerun successfully after space became available. Windows' linker emitted +informational library/export-file messages; clippy with warnings denied passed.