From db0f05edfcd4eff1ce0c80124430197392340078 Mon Sep 17 00:00:00 2001 From: leader Date: Fri, 30 Oct 2026 19:52:46 +0900 Subject: [PATCH 1/2] chore: normalize CRLF files from #112 back to LF --- .../changepack_log_MeHDVNKPOt__WmNG_NvmL.json | 7 + crates/devup-mcp-visual/src/lib.rs | 460 ++-- crates/devup-mcp-visual/tests/compare.rs | 292 +-- .../mod_parts/component_width_acquisition.rs | 184 +- crates/devup-mcp/src/server/screen_plan.rs | 86 +- .../src/server/screen_plan/actions.rs | 1778 +++++++-------- .../devup-mcp/src/server/screen_plan/draft.rs | 1428 ++++++------ .../server/screen_plan/plan_route_group.rs | 1380 ++++++------ .../src/server/screen_plan/status_action.rs | 240 +- .../devup-mcp/src/server/screen_plan_tests.rs | 24 +- .../screen_plan_tests/confirm_recovery.rs | 102 +- .../screen_plan_tests/fixtures_and_cases.rs | 1976 ++++++++--------- .../skills/devup-implementation-plan/SKILL.md | 1064 ++++----- .../src/server/skills/devup/SKILL.md | 432 ++-- crates/devup-mcp/src/server/visual_compare.rs | 1956 ++++++++-------- 15 files changed, 5708 insertions(+), 5701 deletions(-) create mode 100644 .changepacks/changepack_log_MeHDVNKPOt__WmNG_NvmL.json diff --git a/.changepacks/changepack_log_MeHDVNKPOt__WmNG_NvmL.json b/.changepacks/changepack_log_MeHDVNKPOt__WmNG_NvmL.json new file mode 100644 index 00000000..d686a722 --- /dev/null +++ b/.changepacks/changepack_log_MeHDVNKPOt__WmNG_NvmL.json @@ -0,0 +1,7 @@ +{ + "changes": { + "Cargo.toml": "Patch" + }, + "note": "Normalize line endings of files committed with CRLF back to LF", + "date": "2026-10-30T10:52:46.367309500Z" +} \ No newline at end of file diff --git a/crates/devup-mcp-visual/src/lib.rs b/crates/devup-mcp-visual/src/lib.rs index e7b5e057..ff5d8119 100644 --- a/crates/devup-mcp-visual/src/lib.rs +++ b/crates/devup-mcp-visual/src/lib.rs @@ -1,230 +1,230 @@ -use std::path::{Path, PathBuf}; - -use image::{ImageBuffer, ImageError, Rgba}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, PartialEq)] -pub struct CompareOptions { - pub channel_tolerance: u8, - pub max_changed_ratio: f64, - pub diff_path: Option, -} - -impl Default for CompareOptions { - fn default() -> Self { - Self { - channel_tolerance: 0, - max_changed_ratio: 0.005, - diff_path: None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum VisualStatus { - Exact, - WithinThreshold, - Mismatch, - InvalidDimensions, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct VisualReport { - pub status: VisualStatus, - pub reference_dimensions: [u32; 2], - pub actual_dimensions: [u32; 2], - pub changed_pixels: u64, - pub total_pixels: u64, - /// `None` when no pixels were compared (`InvalidDimensions`). - pub changed_ratio: Option, - pub max_changed_ratio: f64, - pub channel_tolerance: u8, - pub max_channel_delta: u8, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub diff_path: Option, -} - -impl VisualReport { - pub fn passed(&self) -> bool { - matches!( - self.status, - VisualStatus::Exact | VisualStatus::WithinThreshold - ) - } -} - -#[derive(Debug)] -pub enum VisualError { - Image(ImageError), - InvalidThreshold, -} - -impl std::fmt::Display for VisualError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Image(error) => write!(formatter, "Could not read or write the PNG: {error}"), - Self::InvalidThreshold => { - formatter.write_str("max_changed_ratio must be between 0 and 1 inclusive.") - } - } - } -} - -impl std::error::Error for VisualError {} - -impl From for VisualError { - fn from(value: ImageError) -> Self { - Self::Image(value) - } -} - -pub fn compare_png( - reference_path: impl AsRef, - actual_path: impl AsRef, - options: &CompareOptions, -) -> Result { - if !(0.0..=1.0).contains(&options.max_changed_ratio) { - return Err(VisualError::InvalidThreshold); - } - let reference = std::fs::read(reference_path).map_err(ImageError::IoError)?; - let actual = std::fs::read(actual_path).map_err(ImageError::IoError)?; - let (mut report, diff) = - compare_png_bytes(&reference, &actual, options, options.diff_path.is_some())?; - if let (Some(path), Some(bytes)) = (&options.diff_path, diff) { - std::fs::write(path, bytes).map_err(ImageError::IoError)?; - report.diff_path = Some(path.to_string_lossy().into_owned()); - } - Ok(report) -} - -/// Compare PNGs without disk writes. The optional diff uses opaque red changed -/// pixels and transparent unchanged pixels, exactly as the CLI does. -/// Inputs are limited to 16 MiB compressed, 8192 pixels per axis, and 64 MiB RGBA. -pub fn compare_png_bytes( - reference: &[u8], - actual: &[u8], - options: &CompareOptions, - include_diff: bool, -) -> Result<(VisualReport, Option>), VisualError> { - if !(0.0..=1.0).contains(&options.max_changed_ratio) { - return Err(VisualError::InvalidThreshold); - } - let reference = decode_png(reference)?; - let actual = decode_png(actual)?; - let reference_dimensions = [reference.width(), reference.height()]; - let actual_dimensions = [actual.width(), actual.height()]; - if reference_dimensions != actual_dimensions { - return Ok(( - VisualReport { - status: VisualStatus::InvalidDimensions, - reference_dimensions, - actual_dimensions, - changed_pixels: 0, - total_pixels: 0, - changed_ratio: None, - max_changed_ratio: options.max_changed_ratio, - channel_tolerance: options.channel_tolerance, - max_channel_delta: 0, - diff_path: None, - }, - None, - )); - } - - let mut changed_pixels = 0_u64; - let mut max_channel_delta = 0_u8; - let mut diff = include_diff.then(|| { - ImageBuffer::from_pixel( - reference.width(), - reference.height(), - Rgba::([0, 0, 0, 0]), - ) - }); - for (index, (reference_pixel, actual_pixel)) in - reference.pixels().zip(actual.pixels()).enumerate() - { - let delta = reference_pixel - .0 - .iter() - .zip(actual_pixel.0) - .map(|(reference, actual)| reference.abs_diff(actual)) - .max() - .unwrap_or(0); - max_channel_delta = max_channel_delta.max(delta); - if delta <= options.channel_tolerance { - continue; - } - changed_pixels += 1; - if let Some(diff) = &mut diff { - let x = index as u32 % reference.width(); - let y = index as u32 / reference.width(); - diff.put_pixel(x, y, Rgba::([255, 0, 0, 255])); - } - } - let total_pixels = u64::from(reference.width()) * u64::from(reference.height()); - let changed_ratio = if total_pixels == 0 { - 0.0 - } else { - changed_pixels as f64 / total_pixels as f64 - }; - let status = if changed_pixels == 0 { - VisualStatus::Exact - } else if changed_ratio <= options.max_changed_ratio { - VisualStatus::WithinThreshold - } else { - VisualStatus::Mismatch - }; - let diff_png = diff - .map(|diff| { - let mut bytes = std::io::Cursor::new(Vec::new()); - diff.write_to(&mut bytes, image::ImageFormat::Png)?; - Ok::<_, ImageError>(bytes.into_inner()) - }) - .transpose()?; - Ok(( - VisualReport { - status, - reference_dimensions, - actual_dimensions, - changed_pixels, - total_pixels, - changed_ratio: Some(changed_ratio), - max_changed_ratio: options.max_changed_ratio, - channel_tolerance: options.channel_tolerance, - max_channel_delta, - diff_path: None, - }, - diff_png, - )) -} - -/// Width and height of a PNG, after decoding it under the same limits as a -/// comparison - so a file that merely starts like a PNG is not accepted. -pub fn png_dimensions(bytes: &[u8]) -> Result<[u32; 2], VisualError> { - let image = decode_png(bytes)?; - Ok([image.width(), image.height()]) -} - -fn decode_png(bytes: &[u8]) -> Result { - use image::{ImageDecoder, Limits, codecs::png::PngDecoder}; - let invalid = || { - ImageError::Limits(image::error::LimitError::from_kind( - image::error::LimitErrorKind::DimensionError, - )) - }; - if bytes.len() > 16 * 1024 * 1024 { - return Err(invalid().into()); - } - let mut limits = Limits::default(); - limits.max_image_width = Some(8192); - limits.max_image_height = Some(8192); - limits.max_alloc = Some(64 * 1024 * 1024); - let decoder = PngDecoder::with_limits(std::io::Cursor::new(bytes), limits)?; - let (width, height) = decoder.dimensions(); - if width == 0 || height == 0 || u64::from(width) * u64::from(height) * 4 > 64 * 1024 * 1024 { - return Err(invalid().into()); - } - Ok(image::DynamicImage::from_decoder(decoder)?.into_rgba8()) -} +use std::path::{Path, PathBuf}; + +use image::{ImageBuffer, ImageError, Rgba}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq)] +pub struct CompareOptions { + pub channel_tolerance: u8, + pub max_changed_ratio: f64, + pub diff_path: Option, +} + +impl Default for CompareOptions { + fn default() -> Self { + Self { + channel_tolerance: 0, + max_changed_ratio: 0.005, + diff_path: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum VisualStatus { + Exact, + WithinThreshold, + Mismatch, + InvalidDimensions, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VisualReport { + pub status: VisualStatus, + pub reference_dimensions: [u32; 2], + pub actual_dimensions: [u32; 2], + pub changed_pixels: u64, + pub total_pixels: u64, + /// `None` when no pixels were compared (`InvalidDimensions`). + pub changed_ratio: Option, + pub max_changed_ratio: f64, + pub channel_tolerance: u8, + pub max_channel_delta: u8, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub diff_path: Option, +} + +impl VisualReport { + pub fn passed(&self) -> bool { + matches!( + self.status, + VisualStatus::Exact | VisualStatus::WithinThreshold + ) + } +} + +#[derive(Debug)] +pub enum VisualError { + Image(ImageError), + InvalidThreshold, +} + +impl std::fmt::Display for VisualError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Image(error) => write!(formatter, "Could not read or write the PNG: {error}"), + Self::InvalidThreshold => { + formatter.write_str("max_changed_ratio must be between 0 and 1 inclusive.") + } + } + } +} + +impl std::error::Error for VisualError {} + +impl From for VisualError { + fn from(value: ImageError) -> Self { + Self::Image(value) + } +} + +pub fn compare_png( + reference_path: impl AsRef, + actual_path: impl AsRef, + options: &CompareOptions, +) -> Result { + if !(0.0..=1.0).contains(&options.max_changed_ratio) { + return Err(VisualError::InvalidThreshold); + } + let reference = std::fs::read(reference_path).map_err(ImageError::IoError)?; + let actual = std::fs::read(actual_path).map_err(ImageError::IoError)?; + let (mut report, diff) = + compare_png_bytes(&reference, &actual, options, options.diff_path.is_some())?; + if let (Some(path), Some(bytes)) = (&options.diff_path, diff) { + std::fs::write(path, bytes).map_err(ImageError::IoError)?; + report.diff_path = Some(path.to_string_lossy().into_owned()); + } + Ok(report) +} + +/// Compare PNGs without disk writes. The optional diff uses opaque red changed +/// pixels and transparent unchanged pixels, exactly as the CLI does. +/// Inputs are limited to 16 MiB compressed, 8192 pixels per axis, and 64 MiB RGBA. +pub fn compare_png_bytes( + reference: &[u8], + actual: &[u8], + options: &CompareOptions, + include_diff: bool, +) -> Result<(VisualReport, Option>), VisualError> { + if !(0.0..=1.0).contains(&options.max_changed_ratio) { + return Err(VisualError::InvalidThreshold); + } + let reference = decode_png(reference)?; + let actual = decode_png(actual)?; + let reference_dimensions = [reference.width(), reference.height()]; + let actual_dimensions = [actual.width(), actual.height()]; + if reference_dimensions != actual_dimensions { + return Ok(( + VisualReport { + status: VisualStatus::InvalidDimensions, + reference_dimensions, + actual_dimensions, + changed_pixels: 0, + total_pixels: 0, + changed_ratio: None, + max_changed_ratio: options.max_changed_ratio, + channel_tolerance: options.channel_tolerance, + max_channel_delta: 0, + diff_path: None, + }, + None, + )); + } + + let mut changed_pixels = 0_u64; + let mut max_channel_delta = 0_u8; + let mut diff = include_diff.then(|| { + ImageBuffer::from_pixel( + reference.width(), + reference.height(), + Rgba::([0, 0, 0, 0]), + ) + }); + for (index, (reference_pixel, actual_pixel)) in + reference.pixels().zip(actual.pixels()).enumerate() + { + let delta = reference_pixel + .0 + .iter() + .zip(actual_pixel.0) + .map(|(reference, actual)| reference.abs_diff(actual)) + .max() + .unwrap_or(0); + max_channel_delta = max_channel_delta.max(delta); + if delta <= options.channel_tolerance { + continue; + } + changed_pixels += 1; + if let Some(diff) = &mut diff { + let x = index as u32 % reference.width(); + let y = index as u32 / reference.width(); + diff.put_pixel(x, y, Rgba::([255, 0, 0, 255])); + } + } + let total_pixels = u64::from(reference.width()) * u64::from(reference.height()); + let changed_ratio = if total_pixels == 0 { + 0.0 + } else { + changed_pixels as f64 / total_pixels as f64 + }; + let status = if changed_pixels == 0 { + VisualStatus::Exact + } else if changed_ratio <= options.max_changed_ratio { + VisualStatus::WithinThreshold + } else { + VisualStatus::Mismatch + }; + let diff_png = diff + .map(|diff| { + let mut bytes = std::io::Cursor::new(Vec::new()); + diff.write_to(&mut bytes, image::ImageFormat::Png)?; + Ok::<_, ImageError>(bytes.into_inner()) + }) + .transpose()?; + Ok(( + VisualReport { + status, + reference_dimensions, + actual_dimensions, + changed_pixels, + total_pixels, + changed_ratio: Some(changed_ratio), + max_changed_ratio: options.max_changed_ratio, + channel_tolerance: options.channel_tolerance, + max_channel_delta, + diff_path: None, + }, + diff_png, + )) +} + +/// Width and height of a PNG, after decoding it under the same limits as a +/// comparison - so a file that merely starts like a PNG is not accepted. +pub fn png_dimensions(bytes: &[u8]) -> Result<[u32; 2], VisualError> { + let image = decode_png(bytes)?; + Ok([image.width(), image.height()]) +} + +fn decode_png(bytes: &[u8]) -> Result { + use image::{ImageDecoder, Limits, codecs::png::PngDecoder}; + let invalid = || { + ImageError::Limits(image::error::LimitError::from_kind( + image::error::LimitErrorKind::DimensionError, + )) + }; + if bytes.len() > 16 * 1024 * 1024 { + return Err(invalid().into()); + } + let mut limits = Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(64 * 1024 * 1024); + let decoder = PngDecoder::with_limits(std::io::Cursor::new(bytes), limits)?; + let (width, height) = decoder.dimensions(); + if width == 0 || height == 0 || u64::from(width) * u64::from(height) * 4 > 64 * 1024 * 1024 { + return Err(invalid().into()); + } + Ok(image::DynamicImage::from_decoder(decoder)?.into_rgba8()) +} diff --git a/crates/devup-mcp-visual/tests/compare.rs b/crates/devup-mcp-visual/tests/compare.rs index b7feef8b..2684458d 100644 --- a/crates/devup-mcp-visual/tests/compare.rs +++ b/crates/devup-mcp-visual/tests/compare.rs @@ -1,146 +1,146 @@ -use std::{path::PathBuf, time::SystemTime}; - -use devup_mcp_visual::{CompareOptions, VisualStatus, compare_png}; -use image::{ImageBuffer, Rgba}; - -/// One directory per test. The two tests here run on two threads of one -/// process, and a clock read on each is the same value often enough that -/// they shared a directory and each other's PNGs - a 4x2 reference next to -/// a 3x2 actual, reported as `InvalidDimensions` by whichever test lost. -fn temp_dir(test: &str) -> anyhow::Result { - let path = std::env::temp_dir().join(format!( - "devup-mcp-visual-{test}-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH)? - .as_nanos() - )); - std::fs::create_dir_all(&path)?; - Ok(path) -} - -fn write_png(path: &std::path::Path, pixels: &[[u8; 4]], width: u32) -> anyhow::Result<()> { - let height = pixels.len() as u32 / width; - let image = ImageBuffer::, _>::from_raw( - width, - height, - pixels.iter().flatten().copied().collect::>(), - ) - .expect("fixture dimensions"); - image.save_with_format(path, image::ImageFormat::Png)?; - Ok(()) -} - -#[test] -fn exact_and_changed_pngs_report_deterministic_metrics_and_diff() -> anyhow::Result<()> { - let root = temp_dir("exact-and-changed")?; - let reference = root.join("reference.png"); - let actual = root.join("actual.png"); - let diff = root.join("diff.png"); - let white = [255, 255, 255, 255]; - write_png(&reference, &[white; 8], 4)?; - write_png(&actual, &[white; 8], 4)?; - let exact = compare_png(&reference, &actual, &CompareOptions::default())?; - assert_eq!(exact.status, VisualStatus::Exact); - assert_eq!(exact.changed_pixels, 0); - - let mut changed = [white; 8]; - changed[1] = [0, 0, 0, 255]; - changed[2] = [0, 0, 0, 255]; - write_png(&actual, &changed, 4)?; - let report = compare_png( - &reference, - &actual, - &CompareOptions { - diff_path: Some(diff.clone()), - ..CompareOptions::default() - }, - )?; - assert_eq!(report.status, VisualStatus::Mismatch); - assert_eq!(report.changed_pixels, 2); - assert_eq!(report.total_pixels, 8); - assert_eq!(report.changed_ratio, Some(0.25)); - assert!(diff.exists()); - std::fs::remove_dir_all(root)?; - Ok(()) -} - -#[test] -fn tolerance_and_dimension_mismatch_are_explicit() -> anyhow::Result<()> { - let root = temp_dir("tolerance-and-dimensions")?; - let reference = root.join("reference.png"); - let actual = root.join("actual.png"); - write_png(&reference, &[[100, 100, 100, 255]; 4], 2)?; - write_png(&actual, &[[102, 100, 100, 255]; 4], 2)?; - let tolerated = compare_png( - &reference, - &actual, - &CompareOptions { - channel_tolerance: 2, - ..CompareOptions::default() - }, - )?; - assert_eq!(tolerated.status, VisualStatus::Exact); - - write_png(&actual, &[[100, 100, 100, 255]; 6], 3)?; - let mismatch = compare_png(&reference, &actual, &CompareOptions::default())?; - assert_eq!(mismatch.status, VisualStatus::InvalidDimensions); - assert!(!mismatch.passed()); - assert_eq!(mismatch.total_pixels, 0); - assert_eq!(mismatch.changed_ratio, None); - let json = serde_json::to_value(&mismatch)?; - assert!(json["changedRatio"].is_null(), "{json}"); - std::fs::remove_dir_all(root)?; - Ok(()) -} - -#[test] -fn memory_comparison_returns_red_diff_without_writing_a_path() -> anyhow::Result<()> { - use devup_mcp_visual::compare_png_bytes; - let mut reference = std::io::Cursor::new(Vec::new()); - let mut actual = std::io::Cursor::new(Vec::new()); - let white = ImageBuffer::from_pixel(2, 1, Rgba([255_u8; 4])); - let mut changed = white.clone(); - changed.put_pixel(1, 0, Rgba([0, 0, 0, 255])); - white.write_to(&mut reference, image::ImageFormat::Png)?; - changed.write_to(&mut actual, image::ImageFormat::Png)?; - let (report, diff) = compare_png_bytes( - reference.get_ref(), - actual.get_ref(), - &CompareOptions { - max_changed_ratio: 0.5, - diff_path: Some(PathBuf::from("nonexistent-directory/must-not-write.png")), - ..CompareOptions::default() - }, - true, - )?; - assert_eq!(report.changed_ratio, Some(0.5)); - assert!(report.passed()); - assert!(report.diff_path.is_none()); - let decoded = - image::load_from_memory_with_format(&diff.unwrap(), image::ImageFormat::Png)?.into_rgba8(); - assert_eq!(decoded.get_pixel(0, 0).0, [0, 0, 0, 0]); - assert_eq!(decoded.get_pixel(1, 0).0, [255, 0, 0, 255]); - Ok(()) -} - -#[test] -fn memory_comparison_refuses_invalid_png_and_nonfinite_threshold() { - use devup_mcp_visual::{VisualError, compare_png_bytes}; - assert!(matches!( - compare_png_bytes(b"not PNG", b"not PNG", &CompareOptions::default(), false), - Err(VisualError::Image(_)) - )); - assert!(matches!( - compare_png_bytes( - &[], - &[], - &CompareOptions { - max_changed_ratio: f64::NAN, - ..CompareOptions::default() - }, - false - ), - Err(VisualError::InvalidThreshold) - )); -} +use std::{path::PathBuf, time::SystemTime}; + +use devup_mcp_visual::{CompareOptions, VisualStatus, compare_png}; +use image::{ImageBuffer, Rgba}; + +/// One directory per test. The two tests here run on two threads of one +/// process, and a clock read on each is the same value often enough that +/// they shared a directory and each other's PNGs - a 4x2 reference next to +/// a 3x2 actual, reported as `InvalidDimensions` by whichever test lost. +fn temp_dir(test: &str) -> anyhow::Result { + let path = std::env::temp_dir().join(format!( + "devup-mcp-visual-{test}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH)? + .as_nanos() + )); + std::fs::create_dir_all(&path)?; + Ok(path) +} + +fn write_png(path: &std::path::Path, pixels: &[[u8; 4]], width: u32) -> anyhow::Result<()> { + let height = pixels.len() as u32 / width; + let image = ImageBuffer::, _>::from_raw( + width, + height, + pixels.iter().flatten().copied().collect::>(), + ) + .expect("fixture dimensions"); + image.save_with_format(path, image::ImageFormat::Png)?; + Ok(()) +} + +#[test] +fn exact_and_changed_pngs_report_deterministic_metrics_and_diff() -> anyhow::Result<()> { + let root = temp_dir("exact-and-changed")?; + let reference = root.join("reference.png"); + let actual = root.join("actual.png"); + let diff = root.join("diff.png"); + let white = [255, 255, 255, 255]; + write_png(&reference, &[white; 8], 4)?; + write_png(&actual, &[white; 8], 4)?; + let exact = compare_png(&reference, &actual, &CompareOptions::default())?; + assert_eq!(exact.status, VisualStatus::Exact); + assert_eq!(exact.changed_pixels, 0); + + let mut changed = [white; 8]; + changed[1] = [0, 0, 0, 255]; + changed[2] = [0, 0, 0, 255]; + write_png(&actual, &changed, 4)?; + let report = compare_png( + &reference, + &actual, + &CompareOptions { + diff_path: Some(diff.clone()), + ..CompareOptions::default() + }, + )?; + assert_eq!(report.status, VisualStatus::Mismatch); + assert_eq!(report.changed_pixels, 2); + assert_eq!(report.total_pixels, 8); + assert_eq!(report.changed_ratio, Some(0.25)); + assert!(diff.exists()); + std::fs::remove_dir_all(root)?; + Ok(()) +} + +#[test] +fn tolerance_and_dimension_mismatch_are_explicit() -> anyhow::Result<()> { + let root = temp_dir("tolerance-and-dimensions")?; + let reference = root.join("reference.png"); + let actual = root.join("actual.png"); + write_png(&reference, &[[100, 100, 100, 255]; 4], 2)?; + write_png(&actual, &[[102, 100, 100, 255]; 4], 2)?; + let tolerated = compare_png( + &reference, + &actual, + &CompareOptions { + channel_tolerance: 2, + ..CompareOptions::default() + }, + )?; + assert_eq!(tolerated.status, VisualStatus::Exact); + + write_png(&actual, &[[100, 100, 100, 255]; 6], 3)?; + let mismatch = compare_png(&reference, &actual, &CompareOptions::default())?; + assert_eq!(mismatch.status, VisualStatus::InvalidDimensions); + assert!(!mismatch.passed()); + assert_eq!(mismatch.total_pixels, 0); + assert_eq!(mismatch.changed_ratio, None); + let json = serde_json::to_value(&mismatch)?; + assert!(json["changedRatio"].is_null(), "{json}"); + std::fs::remove_dir_all(root)?; + Ok(()) +} + +#[test] +fn memory_comparison_returns_red_diff_without_writing_a_path() -> anyhow::Result<()> { + use devup_mcp_visual::compare_png_bytes; + let mut reference = std::io::Cursor::new(Vec::new()); + let mut actual = std::io::Cursor::new(Vec::new()); + let white = ImageBuffer::from_pixel(2, 1, Rgba([255_u8; 4])); + let mut changed = white.clone(); + changed.put_pixel(1, 0, Rgba([0, 0, 0, 255])); + white.write_to(&mut reference, image::ImageFormat::Png)?; + changed.write_to(&mut actual, image::ImageFormat::Png)?; + let (report, diff) = compare_png_bytes( + reference.get_ref(), + actual.get_ref(), + &CompareOptions { + max_changed_ratio: 0.5, + diff_path: Some(PathBuf::from("nonexistent-directory/must-not-write.png")), + ..CompareOptions::default() + }, + true, + )?; + assert_eq!(report.changed_ratio, Some(0.5)); + assert!(report.passed()); + assert!(report.diff_path.is_none()); + let decoded = + image::load_from_memory_with_format(&diff.unwrap(), image::ImageFormat::Png)?.into_rgba8(); + assert_eq!(decoded.get_pixel(0, 0).0, [0, 0, 0, 0]); + assert_eq!(decoded.get_pixel(1, 0).0, [255, 0, 0, 255]); + Ok(()) +} + +#[test] +fn memory_comparison_refuses_invalid_png_and_nonfinite_threshold() { + use devup_mcp_visual::{VisualError, compare_png_bytes}; + assert!(matches!( + compare_png_bytes(b"not PNG", b"not PNG", &CompareOptions::default(), false), + Err(VisualError::Image(_)) + )); + assert!(matches!( + compare_png_bytes( + &[], + &[], + &CompareOptions { + max_changed_ratio: f64::NAN, + ..CompareOptions::default() + }, + false + ), + Err(VisualError::InvalidThreshold) + )); +} diff --git a/crates/devup-mcp/src/server/mod_parts/component_width_acquisition.rs b/crates/devup-mcp/src/server/mod_parts/component_width_acquisition.rs index bcdfb7d0..5f46355b 100644 --- a/crates/devup-mcp/src/server/mod_parts/component_width_acquisition.rs +++ b/crates/devup-mcp/src/server/mod_parts/component_width_acquisition.rs @@ -1,92 +1,92 @@ -impl DevupServer { - async fn plan_component_widths( - &self, - input: &screen_plan::ScreenPlanInput, - screens: &[screen_plan::PlanScreenInput], - source: &mut Value, - ) -> Result<(), DevupError> { - if !input.screens.is_empty() - && self.services.upstream.bridge_path_snapshot().await.is_none_or(|bridge| bridge.attached_files.len() != 1) { - source["componentWidthEvidence"] = json!({"state":"unavailable","reason":"Caller screen summaries have no component identity; no unique Bridge file is attached."}); - return Ok(()); - } - let target = self.bind_to_bridge(parse_link(input.url.as_deref())?, Unnamed::Selection, - &json!({"tool":"devup_screen_plan","arguments":{"action":"draft"}})).await?; - let mut snapshot = None; - let mut payloads = Vec::new(); - for screen in screens.iter().filter(|screen| screen.visible != Some(false)) { - let mut target = target.clone(); - target.node_id = Some(screen.node_id.clone()); - let request = CollectionRequest::new(target, CollectionScope::Node); - let key = ArtifactRequestKey::from_collection(&request); - let payload = CollectedPayload::try_from(self.run_direct(request, &PendingOperation::Collect, None).await?)?; - let artifact = self.artifacts.insert(key, payload).await?; - match &mut snapshot { - None => snapshot = Some(artifact.payload.snapshot.clone()), - Some(combined) => combined.nodes.extend(artifact.payload.snapshot.nodes.clone()), - } - payloads.push(artifact.artifact_id); - } - let Some(snapshot) = snapshot else { return Ok(()) }; - let roots = screens.iter().map(|screen| screen.node_id.clone()).collect::>(); - let mut mismatches = screen_plan::component_width_mismatches(&snapshot, &roots); - for mismatch in &mut mismatches { - let mut references = Vec::new(); - let mut render_bytes = Vec::new(); - for field in ["nodeId", "correctNodeId"] { - let Some(id) = mismatch[field].as_str() else { continue }; - let mut target = target.clone(); - target.node_id = Some(id.into()); - let mut request = CollectionRequest::new(target, CollectionScope::Node); - request.asset_selections.push(devup_mcp_figma::AssetSelection { - asset_id: format!("{id}:node"), format: devup_mcp_figma::AssetFormat::Png, - scale: 1.0, render_mode: devup_mcp_figma::AssetRenderMode::Node, - usage_aspect_ratio: None, max_width: None, srcset: false, - visible_scope_node_id: None, quality: None, lossless: None, - }); - let key = ArtifactRequestKey::from_collection(&request); - match self.run_direct(request, &PendingOperation::Collect, None).await.and_then(CollectedPayload::try_from) { - Ok(payload) => { - let artifact = self.artifacts.insert(key, payload).await?; - for asset in &artifact.payload.assets { - if asset.node_id == id - && asset.status == devup_mcp_figma::AssetStatus::Exported - && asset.format == Some(devup_mcp_figma::AssetFormat::Png) - && asset.scale == Some(1.0) - && asset.render_info.as_ref().is_some_and(|info|info["representation"] == "native-node") { - references.push(json!({"nodeId":id,"artifactId":artifact.artifact_id,"assetId":asset.asset_id,"renderMode":"node","format":"png","scale":1})); - if let Some(encoded) = &asset.data_base64 { - use base64::Engine as _; - if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) { - render_bytes.push(bytes); - } - } - } - } - payloads.push(artifact.artifact_id); - } - Err(error) => mismatch["renderError"] = json!(error.to_string()), - } - } - mismatch["render"] = json!(if render_bytes.len() == 2 { "captured" } else { "unavailable" }); - mismatch["renderReferences"] = json!(references); - mismatch["comparison"] = json!({"method":"drawn-width-majority","pixelComparison":"unverified","decision":"use expected master as one responsive component"}); - if let [suspect, correct] = render_bytes.as_slice() { - match devup_mcp_visual::compare_png_bytes(correct, suspect, &devup_mcp_visual::CompareOptions::default(), false) { - Ok((report, _)) => { - let comparable = report.status != devup_mcp_visual::VisualStatus::InvalidDimensions; - mismatch["comparison"]["pixelComparison"] = serde_json::to_value(report).map_err(|error|DevupError::new(ErrorCode::DevupInvalidInput,error.to_string(),false))?; - mismatch["comparison"]["pixelVerdict"] = json!(if comparable { "compared" } else { "inconclusive" }); - if !comparable { - mismatch["comparison"]["pixelReason"] = json!("the two renders have different dimensions, so no pixels were compared"); - } - } - Err(error) => mismatch["comparison"]["renderError"] = json!(error.to_string()), - } - } - } - source["componentWidthArtifacts"] = json!(payloads); - source["componentWidthMismatches"] = json!(mismatches); - Ok(()) - } -} +impl DevupServer { + async fn plan_component_widths( + &self, + input: &screen_plan::ScreenPlanInput, + screens: &[screen_plan::PlanScreenInput], + source: &mut Value, + ) -> Result<(), DevupError> { + if !input.screens.is_empty() + && self.services.upstream.bridge_path_snapshot().await.is_none_or(|bridge| bridge.attached_files.len() != 1) { + source["componentWidthEvidence"] = json!({"state":"unavailable","reason":"Caller screen summaries have no component identity; no unique Bridge file is attached."}); + return Ok(()); + } + let target = self.bind_to_bridge(parse_link(input.url.as_deref())?, Unnamed::Selection, + &json!({"tool":"devup_screen_plan","arguments":{"action":"draft"}})).await?; + let mut snapshot = None; + let mut payloads = Vec::new(); + for screen in screens.iter().filter(|screen| screen.visible != Some(false)) { + let mut target = target.clone(); + target.node_id = Some(screen.node_id.clone()); + let request = CollectionRequest::new(target, CollectionScope::Node); + let key = ArtifactRequestKey::from_collection(&request); + let payload = CollectedPayload::try_from(self.run_direct(request, &PendingOperation::Collect, None).await?)?; + let artifact = self.artifacts.insert(key, payload).await?; + match &mut snapshot { + None => snapshot = Some(artifact.payload.snapshot.clone()), + Some(combined) => combined.nodes.extend(artifact.payload.snapshot.nodes.clone()), + } + payloads.push(artifact.artifact_id); + } + let Some(snapshot) = snapshot else { return Ok(()) }; + let roots = screens.iter().map(|screen| screen.node_id.clone()).collect::>(); + let mut mismatches = screen_plan::component_width_mismatches(&snapshot, &roots); + for mismatch in &mut mismatches { + let mut references = Vec::new(); + let mut render_bytes = Vec::new(); + for field in ["nodeId", "correctNodeId"] { + let Some(id) = mismatch[field].as_str() else { continue }; + let mut target = target.clone(); + target.node_id = Some(id.into()); + let mut request = CollectionRequest::new(target, CollectionScope::Node); + request.asset_selections.push(devup_mcp_figma::AssetSelection { + asset_id: format!("{id}:node"), format: devup_mcp_figma::AssetFormat::Png, + scale: 1.0, render_mode: devup_mcp_figma::AssetRenderMode::Node, + usage_aspect_ratio: None, max_width: None, srcset: false, + visible_scope_node_id: None, quality: None, lossless: None, + }); + let key = ArtifactRequestKey::from_collection(&request); + match self.run_direct(request, &PendingOperation::Collect, None).await.and_then(CollectedPayload::try_from) { + Ok(payload) => { + let artifact = self.artifacts.insert(key, payload).await?; + for asset in &artifact.payload.assets { + if asset.node_id == id + && asset.status == devup_mcp_figma::AssetStatus::Exported + && asset.format == Some(devup_mcp_figma::AssetFormat::Png) + && asset.scale == Some(1.0) + && asset.render_info.as_ref().is_some_and(|info|info["representation"] == "native-node") { + references.push(json!({"nodeId":id,"artifactId":artifact.artifact_id,"assetId":asset.asset_id,"renderMode":"node","format":"png","scale":1})); + if let Some(encoded) = &asset.data_base64 { + use base64::Engine as _; + if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) { + render_bytes.push(bytes); + } + } + } + } + payloads.push(artifact.artifact_id); + } + Err(error) => mismatch["renderError"] = json!(error.to_string()), + } + } + mismatch["render"] = json!(if render_bytes.len() == 2 { "captured" } else { "unavailable" }); + mismatch["renderReferences"] = json!(references); + mismatch["comparison"] = json!({"method":"drawn-width-majority","pixelComparison":"unverified","decision":"use expected master as one responsive component"}); + if let [suspect, correct] = render_bytes.as_slice() { + match devup_mcp_visual::compare_png_bytes(correct, suspect, &devup_mcp_visual::CompareOptions::default(), false) { + Ok((report, _)) => { + let comparable = report.status != devup_mcp_visual::VisualStatus::InvalidDimensions; + mismatch["comparison"]["pixelComparison"] = serde_json::to_value(report).map_err(|error|DevupError::new(ErrorCode::DevupInvalidInput,error.to_string(),false))?; + mismatch["comparison"]["pixelVerdict"] = json!(if comparable { "compared" } else { "inconclusive" }); + if !comparable { + mismatch["comparison"]["pixelReason"] = json!("the two renders have different dimensions, so no pixels were compared"); + } + } + Err(error) => mismatch["comparison"]["renderError"] = json!(error.to_string()), + } + } + } + source["componentWidthArtifacts"] = json!(payloads); + source["componentWidthMismatches"] = json!(mismatches); + Ok(()) + } +} diff --git a/crates/devup-mcp/src/server/screen_plan.rs b/crates/devup-mcp/src/server/screen_plan.rs index 9677f90e..cc810813 100644 --- a/crates/devup-mcp/src/server/screen_plan.rs +++ b/crates/devup-mcp/src/server/screen_plan.rs @@ -1,44 +1,44 @@ -//! `devup_screen_plan`: which Figma screen becomes which URL, decided and -//! confirmed before any page is written. -//! -//! An agent handed "implement this design" used to export and write pages -//! straight away. Afterwards nobody could say which Figma screen had become -//! which URL, so nobody could say what had been built or whether it worked: -//! on one site the pages rendered "This page couldn't load" at some widths and -//! were cut off on the right at others, and there was no URL-by-width table on -//! which anyone could have noticed. -//! -//! So the order is fixed. `draft` maps every screen to a route from evidence -//! only - an existing page built from the frame, a nav label already bound to -//! an href, an explicit `navigationTargets` entry or the project's config - -//! and turns everything it cannot decide into questions shaped for the -//! runtime's question tool, split into rounds that are all asked up front, back -//! to back. A guess from a layer name is never silent: it is marked -//! `name-match` and put to the user in the same rounds. `confirm` takes every -//! answer that applies at once and refuses to proceed with any missing. After -//! it nothing is asked: implementation records what it decided alone with -//! `assume`. `status` reads the confirmed plan against the routes on disk and a -//! runtime audit report, one row per URL and width, where a width nobody ran is -//! `not-run` and never `pass`. -//! -//! Nothing here reaches Figma or runs a browser. The screen list comes from -//! `devup_figma_explore` (or the caller, from an earlier explore), the routes -//! from `devup_project_context`, the links from the navigation parser and the -//! widths from `devup_runtime_audit`. - -// Source sections share this module to preserve privacy and test names. -include!("screen_plan/state.rs"); -include!("screen_plan/routes.rs"); -include!("screen_plan/app_select.rs"); -include!("screen_plan/draft.rs"); +//! `devup_screen_plan`: which Figma screen becomes which URL, decided and +//! confirmed before any page is written. +//! +//! An agent handed "implement this design" used to export and write pages +//! straight away. Afterwards nobody could say which Figma screen had become +//! which URL, so nobody could say what had been built or whether it worked: +//! on one site the pages rendered "This page couldn't load" at some widths and +//! were cut off on the right at others, and there was no URL-by-width table on +//! which anyone could have noticed. +//! +//! So the order is fixed. `draft` maps every screen to a route from evidence +//! only - an existing page built from the frame, a nav label already bound to +//! an href, an explicit `navigationTargets` entry or the project's config - +//! and turns everything it cannot decide into questions shaped for the +//! runtime's question tool, split into rounds that are all asked up front, back +//! to back. A guess from a layer name is never silent: it is marked +//! `name-match` and put to the user in the same rounds. `confirm` takes every +//! answer that applies at once and refuses to proceed with any missing. After +//! it nothing is asked: implementation records what it decided alone with +//! `assume`. `status` reads the confirmed plan against the routes on disk and a +//! runtime audit report, one row per URL and width, where a width nobody ran is +//! `not-run` and never `pass`. +//! +//! Nothing here reaches Figma or runs a browser. The screen list comes from +//! `devup_figma_explore` (or the caller, from an earlier explore), the routes +//! from `devup_project_context`, the links from the navigation parser and the +//! widths from `devup_runtime_audit`. + +// Source sections share this module to preserve privacy and test names. +include!("screen_plan/state.rs"); +include!("screen_plan/routes.rs"); +include!("screen_plan/app_select.rs"); +include!("screen_plan/draft.rs"); include!("screen_plan/actions.rs"); -include!("screen_plan/status_action.rs"); -include!("screen_plan/audit_base_url.rs"); -include!("screen_plan/remap.rs"); -include!("screen_plan/status.rs"); -include!("screen_plan/draft_plan_with.rs"); -include!("screen_plan/plan_route_group.rs"); -include!("screen_plan/state_variants.rs"); -include!("screen_plan/lookup.rs"); -include!("screen_plan/component_width.rs"); -include!("screen_plan/responsive_contract.rs"); +include!("screen_plan/status_action.rs"); +include!("screen_plan/audit_base_url.rs"); +include!("screen_plan/remap.rs"); +include!("screen_plan/status.rs"); +include!("screen_plan/draft_plan_with.rs"); +include!("screen_plan/plan_route_group.rs"); +include!("screen_plan/state_variants.rs"); +include!("screen_plan/lookup.rs"); +include!("screen_plan/component_width.rs"); +include!("screen_plan/responsive_contract.rs"); diff --git a/crates/devup-mcp/src/server/screen_plan/actions.rs b/crates/devup-mcp/src/server/screen_plan/actions.rs index a3852aa0..7f471f58 100644 --- a/crates/devup-mcp/src/server/screen_plan/actions.rs +++ b/crates/devup-mcp/src/server/screen_plan/actions.rs @@ -1,889 +1,889 @@ -/// `confirm`: every question that applies answered, in one call, or nothing -/// changes. -pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { - let (plan_root, mut plan) = audit_plan_location(input)?; - let root = plan_root.to_str().ok_or_else(|| invalid("The plan root is not UTF-8."))?; - let plan_id = input - .plan_id - .as_deref() - .ok_or_else(|| invalid("action confirm needs planId."))?; - if plan["planId"] != plan_id { - return Err(DevupError::with_details( - ErrorCode::DevupInvalidInput, - "planId does not match the plan on disk; it was redrafted. Ask the questions of the current draft.", - false, - json!({"planId": plan_id, "currentPlanId": plan["planId"]}), - )); - } - if plan["status"] == "confirmed" && input.answers.is_empty() { - return Ok(json!({ - "status": "confirmed", - "alreadyConfirmed": true, - "planId": plan["planId"], - "confirmedAt": plan["confirmedAt"], - "routes": plan["routes"], - "markdownTable": route_markdown(array(&plan["routes"])), - "nextAction": {"tool": "devup_screen_plan", "arguments": {"action": "status", "projectRoot": root, "planId": plan_id}, - "how": "Nothing changed. The plan was confirmed before; continue implementation from its status."}, - })); - } - let mut questions = array(&plan["questions"]).to_vec(); - let ordered = questions.clone(); - questions.sort_by_key(|question| depth(question, &ordered)); - let known = questions - .iter() - .map(|question| text(&question["id"]).to_owned()) - .collect::>(); - let unknown = input - .answers - .keys() - .filter(|id| !known.contains(*id)) - .cloned() - .collect::>(); - let mut actions = BTreeMap::new(); - let answers = proposal_answers(&questions, &input.answers); - let mut custom = Vec::new(); - let mut missing = Vec::new(); - for question in &questions { - let id = text(&question["id"]).to_owned(); - if !required(question, &actions) { - continue; - } - let Some(answer) = answers.get(&id) else { - if input.accept_recommended - && let Some(recommended) = array(&question["resolve"]).first() - { - actions.insert(id, recommended["action"].clone()); - } else { - missing.push(id); - } - continue; - }; - let (action, note) = resolve_answer(question, answer)?; - if let Some(note) = note { - custom.push(json!({"question": id, "answer": note})); - } - actions.insert(id, action); - } - if !missing.is_empty() || !unknown.is_empty() { - let pending = questions - .iter() - .filter(|question| missing.iter().any(|id| question["id"] == id.as_str())) - .cloned() - .collect::>(); - return Err(DevupError::with_details( - ErrorCode::DevupInvalidInput, - format!( - "confirm needs an answer to every question that applies. Missing: {}.", - if missing.is_empty() { - "none".to_owned() - } else { - missing.join(", ") - } - ), - false, - json!({"missing": missing, "unknown": unknown, "askInstruction": ASK_INSTRUCTION, - "questionRounds": question_rounds(&pending, per_round(&plan))}), - )); - } - let accepted = actions - .get("heuristics") - .is_none_or(|action| action["heuristics"] == "accept"); - let nothing_here = actions - .get("scope") - .is_some_and(|action| action["scope"] == "none"); - // Every "the Figma structure is wrong" answer: its frames are not built, - // and the fix is reported. The work goes on without them. - let design_fixes = questions - .iter() - .filter(|question| { - actions - .get(text(&question["id"])) - .is_some_and(|action| action["designFix"] == true) - }) - .map(|question| { - json!({"question": question["id"], "frames": question["fix"]["frames"], - "fix": question["fix"]["text"]}) - }) - .collect::>(); - let fixed = |ids: &[&str]| { - ids.iter().any(|id| { - actions - .get(*id) - .is_some_and(|action| action["designFix"] == true) - }) - }; - let app_dir = actions - .get("site:app") - .and_then(|action| action["app"]["appDir"].as_str()) - .unwrap_or(text(&plan["appDir"])) - .to_owned(); - plan["appDir"] = json!(app_dir); - let folds = state_folds(&questions, &actions); - let folded = folded_ids(&folds); - let mut routes = Vec::new(); - let mut screen_routes = BTreeMap::new(); - let screens = array(&plan["screens"]).to_vec(); - for screen in &screens { - let id = text(&screen["id"]).to_owned(); - let frames = array(&screen["frames"]).to_vec(); - if let Some(page) = actions.get("base-page").and_then(|action| action["pageId"].as_str()) - && !frames.iter().any(|frame| frame["pageId"] == page) { - continue; - } - let mut deciding = array(&screen["structureQuestions"]) - .iter() - .map(text) - .collect::>(); - deciding.extend(screen["groupingQuestion"].as_str()); - if nothing_here || fixed(&deciding) || folded.contains(&id) { - continue; - } - let chosen_app = actions - .get(&format!("app:{id}")) - .or_else(|| actions.get("site:app")) - .map(|action| action["app"].clone()); - let screen_app = chosen_app - .as_ref() - .and_then(|app| app["dir"].as_str()) - .or(screen["app"].as_str()) - .map(str::to_owned); - let screen_app_dir = chosen_app - .as_ref() - .and_then(|app| app["appDir"].as_str()) - .or(screen["appDir"].as_str()) - .unwrap_or(&app_dir) - .to_owned(); - let group = screen["groupingQuestion"] - .as_str() - .and_then(|question| actions.get(question)); - let separate = group.is_some_and(|action| action["group"] == "separate"); - let data = actions - .get(&format!("dynamic:{id}")) - .map(|action| action["data"].clone()); - // Each placement is (frames, implementation, route, host, collision). - let mut placements = Vec::new(); - if separate { - let explicit = group.and_then(|action| action["routes"].as_array()); - if explicit.is_some_and(|routes| routes.len() != frames.len()) { - return Err(invalid(format!( - "{}: list exactly one route per frame ({} frames).", - text(&screen["groupingQuestion"]), - frames.len() - ))); - } - for (index, frame) in frames.iter().enumerate() { - let node = text(&frame["nodeId"]); - let action = explicit - .map(|routes| json!({"route": routes[index]})) - .or_else(|| actions.get(&format!("split-route:{node}")).cloned()) - .unwrap_or(Value::Null); - placements.push((vec![frame.clone()], action, None)); - } - } else { - let placed = group - .filter(|action| action["route"].is_string()) - .or_else(|| actions.get(&format!("route:{id}"))) - .or_else(|| actions.get(&format!("modal:{id}"))) - .or_else(|| actions.get(&format!("review:{id}"))) - .cloned(); - let action = match placed { - Some(action) => action, - None if screen["routeEvidence"] == NAME_MATCH && accepted => { - json!({"route": screen["proposedRoute"], "collision": "merge", "acceptedHeuristic": true}) - } - None => json!({"route": screen["proposedRoute"]}), - }; - let collision = actions - .get(&format!("collision:{id}")) - .map(|action| text(&action["collision"]).to_owned()); - placements.push((frames.clone(), action, collision)); - } - for (group_frames, action, collision) in placements { - let collision = collision.or_else(|| action["collision"].as_str().map(str::to_owned)); - let (implementation, route, host) = - if action["skip"] == true || collision.as_deref() == Some("skip") { - ("skip", None, None) - } else if action["notPage"] == true { - ("not-a-page", None, None) - } else if let Some(host) = action["state"].as_str() { - ("state", None, Some(host.to_owned())) - } else { - ("page", action["route"].as_str().map(str::to_owned), None) - }; - let first = text(&group_frames[0]["nodeId"]).to_owned(); - if implementation == "page" && route.is_none() { - return Err(invalid(format!( - "{first} has no route after the answers; answer its route question with a route." - ))); - } - if let Some(url) = &route { - screen_routes - .entry(id.clone()) - .or_insert_with(|| url.clone()); - } - let existing = route.as_deref().and_then(|url| { - (screen["proposedRoute"] == url && !screen["existingFile"].is_null()) - .then(|| screen["existingFile"].clone()) - }); - routes.push(json!({ - "url": route, - "screen": if separate { first.clone() } else { id.clone() }, - "name": group_frames[0]["name"], - "nodeIds": group_frames.iter().map(|frame| frame["nodeId"].clone()).collect::>(), - "pages": distinct_pages(&group_frames), - "drawnWidths": group_frames.iter().filter_map(|frame| frame["width"].as_f64()).map(|width| width.round() as i64).collect::>(), - "implementation": implementation, - "hostScreen": host, - "app": screen_app, - "file": existing.clone().unwrap_or_else(|| route.as_deref().map_or(Value::Null, |url| json!(planned_page(url, &screen_app_dir)))), - "fileExists": existing.is_some(), - "collisionPolicy": collision, - "data": data, - "routeEvidence": if action["acceptedHeuristic"] == true { json!("name-match (accepted)") } else { screen["routeEvidence"].clone() }, - })); - } - } - record_states(&mut routes, &folds); - let mut excluded = Vec::new(); - for row in array(&plan["excluded"]) { - let node = text(&row["nodeId"]).to_owned(); - let built = !nothing_here - && actions - .get(&format!("exclusion:{node}")) - .is_some_and(|action| action["build"] == true); - let action = actions.get(&format!("exclusion-route:{node}")); - match action.and_then(|action| action["route"].as_str()) { - Some(route) if built => routes.push(json!({ - "url": route, "screen": node, "name": row["name"], "nodeIds": [node], - "drawnWidths": [row["width"].as_f64().map(|width| width.round() as i64)], - "implementation": "page", "hostScreen": null, - "file": planned_page(route, &app_dir), "fileExists": false, - "collisionPolicy": null, "data": null, - "routeEvidence": "confirmed answer (was excluded)", - })), - _ => { - let mut row = row.clone(); - row["decision"] = json!("excluded"); - excluded.push(row); - } - } - } - if nothing_here { - for screen in &screens { - for frame in array(&screen["frames"]) { - excluded.push(json!({"nodeId": frame["nodeId"], "name": frame["name"], "width": frame["width"], - "section": frame["sectionName"], "reason": "the linked area is not for implementation (answered)", - "decision": "excluded"})); - } - } - } - // One URL per app: the public site and the console may both have `/`. - let mut seen = BTreeMap::<(String, String), String>::new(); - let mut collisions = Vec::new(); - for row in &routes { - if let Some(url) = row["url"].as_str() - && let Some(other) = seen.insert( - (text(&row["app"]).to_owned(), url.to_owned()), - text(&row["screen"]).to_owned(), - ) - && other != text(&row["screen"]) - { - collisions.push((url.to_owned(), other, row["screen"].clone(), row["name"].clone())); - } - } - if !collisions.is_empty() { - return Err(duplicate_routes(&plan, &collisions)); - } - for row in routes - .iter_mut() - .filter(|row| row["implementation"] == "state") - { - row["hostUrl"] = json!(screen_routes.get(text(&row["hostScreen"]))); - } - let node_routes = routes - .iter() - .flat_map(|route| { - array(&route["nodeIds"]) - .iter() - .map(move |node| (text(node).to_owned(), route["url"].clone())) - }) - .collect::>(); - if let Some(rows) = plan["screens"].as_array_mut() { - for screen in rows { - let id = text(&screen["id"]).to_owned(); - let mut resolved = Vec::new(); - for (index, link) in array(&screen["links"]).iter().enumerate() { - let mut link = link.clone(); - if let Some(action) = actions.get(&format!("link:{id}:{index}")) { - link["resolved"] = - json!({"url": action["link"], "evidence": "confirmed answer"}); - } else if let Some(target) = link["resolved"]["screen"].as_str() { - link["resolved"]["url"] = - if let Some(node) = link["resolved"]["nodeId"].as_str() { - node_routes.get(node).cloned().unwrap_or(Value::Null) - } else { - let urls = routes - .iter() - .filter(|route| { - screens - .iter() - .find(|screen| screen["id"] == target) - .is_some_and(|screen| { - array(&screen["frames"]).iter().any(|frame| { - array(&route["nodeIds"]).contains(&frame["nodeId"]) - }) - }) - }) - .filter_map(|route| route["url"].as_str()) - .collect::>(); - if urls.len() == 1 { - json!(urls.first()) - } else { - Value::Null - } - }; - } - resolved.push(link); - } - screen["links"] = json!(resolved); - } - } - plan["mobileNavigation"] = actions.get("mobile-menu").cloned().unwrap_or(Value::Null); - plan["answers"] = json!(input.answers); - plan["customAnswers"] = json!(custom); - plan["heuristicsAccepted"] = json!(accepted); - plan["layout"] = json!( - actions - .iter() - .filter(|(id, _)| id.starts_with("layout:")) - .map(|(id, action)| (id.clone(), action["layout"].clone())) - .collect::>() - ); - for asked in questions - .iter() - .filter(|asked| text(&asked["id"]).starts_with("site:")) - { - let field = text(&asked["field"]).to_owned(); - if let Some(action) = actions.get(text(&asked["id"])) { - plan["site"][&field]["state"] = json!("answered"); - plan["site"][&field]["answer"] = action[&field].clone(); - } - } - plan["extraAnswers"] = json!( - actions - .iter() - .filter(|(id, _)| id.starts_with("extra:")) - .map(|(id, action)| (id.clone(), action["extra"].clone())) - .collect::>() - ); - plan["routes"] = json!(routes); - plan["excluded"] = json!(excluded); - plan["designFixes"] = json!(design_fixes); - plan["confirmedPage"] = confirmed_page(&plan); - if let Some(page) = actions.get("base-page").and_then(|action| action["pageId"].as_str()) { - let name = screens.iter().flat_map(|screen| array(&screen["frames"])) - .find(|frame| frame["pageId"] == page).map(|frame| frame["pageName"].clone()); - plan["confirmedPage"] = json!({"id":page,"name":name,"basis":"base-page answer"}); - } - plan["status"] = json!("confirmed"); - plan["confirmedAt"] = json!(now()); - refresh_inventory_checks(&mut plan); - if !plan["assumptions"].is_array() { - plan["assumptions"] = json!([]); - } - let written = write_plan(policy, root, &plan)?; - let mut work_queue = theme_sync_queue(&plan, root); - work_queue.extend(routes - .iter() - .filter(|route| route["url"].is_string()) - .map(|route| { - json!({ - "do": "build", - "url": route["url"], - "implementation": route["implementation"], - "file": route["file"], - "nodeIds": route["nodeIds"], - "drawnWidths": route["drawnWidths"], - "states": state_exports(route), - "nextExport": if multi_width(route) { responsive_export_action(&plan, route, root) } else { Value::Null }, - }) - }) - .collect::>()); - let urls = work_queue - .iter() - .filter_map(|item| item["url"].as_str().map(str::to_owned)) - .collect::>(); - Ok(json!({ - "status": "confirmed", - "planId": plan["planId"], - "workQueue": work_queue, - "confirmedPage": plan["confirmedPage"], - "routes": routes, - "markdownTable": route_markdown(&routes), - "frameInventory": plan["frameInventory"], - "linkLabelCheck": plan["linkLabelCheck"], - "excluded": excluded, - "designFixes": design_fixes, - "site": plan["site"], - "defaults": plan["defaults"], - "extraAnswers": plan["extraAnswers"], - "mobileNavigation": plan["mobileNavigation"], - "customAnswers": custom, - "rule": IMPLEMENT_INSTRUCTION, - "writtenTo": written, - "nextAction": { - "how": IMPLEMENT_INSTRUCTION, - "buildAll": urls, - "until": "every URL in workQueue is built; do not stop after the first route. Check progress with action status.", - }, - })) -} - -/// `assume`: what implementation decided without asking, appended to the -/// confirmed plan so status and the final report carry it. -pub(super) fn assume(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { - let (plan_root, mut plan) = audit_plan_location(input)?; - let root = plan_root.to_str().ok_or_else(|| invalid("The plan root is not UTF-8."))?; - if plan["status"] != "confirmed" { - return Err(invalid( - "Assumptions belong to implementation, after confirm. Before confirm, put the decision to the user in the question rounds instead.", - )); - } - if input.assumptions.is_empty() && input.dependency_update.is_none() && input.navigation_targets.is_empty() { - return Err(invalid( - "action assume needs assumptions, dependencyUpdate or navigationTargets.", - )); - } - let recorded_at = now(); - // An assumption about a pending decision takes it, unless it is - // destructive: that one stays unbuilt and goes into the final report. - let mut decisions = array(&plan["pendingDecisions"]).to_vec(); - for decision in &mut decisions { - let Some(taken) = input - .assumptions - .iter() - .find(|assumption| decision["id"] == assumption.about.as_str()) - else { - continue; - }; - if decision["destructive"] == true { - decision["status"] = json!("left-unbuilt"); - } else { - decision["status"] = json!("taken"); - decision["taken"] = json!(taken.decision); - } - decision["takenAt"] = json!(recorded_at); - } - if !decisions.is_empty() { - plan["pendingDecisions"] = json!(decisions); - } - if let Some(update) = &input.dependency_update { - plan["dependencies"] = json!({"updatedAt": recorded_at, "bumped": update.bumped, - "pins": update.pins, "command": UPDATE_STEP}); - } - let mut list = array(&plan["assumptions"]).to_vec(); - for pin in input - .dependency_update - .iter() - .flat_map(|update| &update.pins) - { - list.push(json!({"about": format!("dependency {}", pin.package), - "decision": format!("pinned to {}", pin.version), "reason": pin.reason, "recordedAt": recorded_at})); - } - for assumption in &input.assumptions { - if assumption.about.trim().is_empty() || assumption.decision.trim().is_empty() { - return Err(invalid("Every assumption needs about and decision.")); - } - list.push( - json!({"about": assumption.about, "decision": assumption.decision, - "reason": assumption.reason, "recordedAt": recorded_at}), - ); - } - let mut remapped = Vec::new(); - for (node, url) in &input.navigation_targets { - let route = normalize_route(url).filter(|route| valid_route(route)).ok_or_else(|| invalid(format!("navigationTargets[{node}] = {url:?} is not an app route.")))?; - remap_frame(&mut plan, node, &route, &mut remapped)?; - list.push(json!({"about": node, "decision": format!("remapped to {route}"), "reason": "navigationTargets in assume", "recordedAt": recorded_at})); - } - if !remapped.is_empty() { - refresh_inventory_checks(&mut plan); - let mut all = array(&plan["remaps"]).to_vec(); - all.extend(remapped.iter().cloned()); - plan["remaps"] = json!(all); - } - plan["assumptions"] = json!(list); - // An assumption never builds what the plan excluded or sent back for a - // design fix; the export gate refuses those frames whatever is recorded. - let blocked = array(&plan["excluded"]) - .iter() - .map(|row| text(&row["nodeId"]).to_owned()) - .chain(array(&plan["designFixes"]).iter().flat_map(|fix| { - array(&fix["frames"]) - .iter() - .map(|frame| text(&frame["nodeId"]).to_owned()) - .collect::>() - })) - .filter(|id| { - !id.is_empty() - && input - .assumptions - .iter() - .any(|assumption| assumption.about.contains(id.as_str())) - }) - .collect::>(); - let written = write_plan(policy, root, &plan)?; - Ok(json!({ - "status": "confirmed", - "planId": plan["planId"], - "pendingDecisions": plan["pendingDecisions"], - "stillNotBuilt": blocked.iter().map(|id| json!({"frame": id, - "why": "Excluded or sent back for a design fix; an assumption cannot build it, and the export gate refuses it."})).collect::>(), - "assumptions": plan["assumptions"], - "remapped": remapped, - "dependencies": plan["dependencies"], - "writtenTo": written, - "how": "Keep implementing. Every assumption appears in status and must be in your final report.", - })) -} - -/// Records a decision the work needs without stopping it. -pub(super) fn defer(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { - let (plan_root, mut plan) = audit_plan_location(input)?; - let root = plan_root.to_str().ok_or_else(|| invalid("The plan root is not UTF-8."))?; - if plan["status"] != "confirmed" { - return Err(invalid( - "A decision before confirm is a question: put it in extraQuestions on draft.", - )); - } - let decision = input - .decision - .as_ref() - .ok_or_else(|| invalid("action defer needs decision."))?; - for (field, value) in [ - ("id", &decision.id), - ("about", &decision.about), - ("question", &decision.question), - ("recommended", &decision.recommended), - ] { - if value.trim().is_empty() { - return Err(invalid(format!("decision.{field} is empty."))); - } - } - let mut decisions = array(&plan["pendingDecisions"]).to_vec(); - decisions.retain(|existing| existing["id"] != decision.id.as_str()); - decisions.push( - json!({"id": decision.id, "about": decision.about, "question": decision.question, - "options": decision.options, "recommended": decision.recommended, - "destructive": decision.destructive, "status": "open", "raisedAt": now()}), - ); - plan["pendingDecisions"] = json!(decisions); - let written = write_plan(policy, root, &plan)?; - Ok(json!({ - "status": "confirmed", - "pendingDecisions": plan["pendingDecisions"], - "writtenTo": written, - "how": if decision.destructive { - "Recorded. Do not build what it touches; carry on with everything else. It stays unbuilt and is listed in the final report." - } else { - include_str!("../text/screen_plan-defer-3281174c.md") - }, - })) -} - -/// Writes one runtime-audit plan per confirmed URL and records where. -pub(super) async fn audit_plan( - input: &ScreenPlanInput, - policy: &OutputPolicy, - artifacts: &ArtifactStore, -) -> Result { - let (plan_root, mut plan) = audit_plan_location(input)?; - let root = plan_root.to_str().ok_or_else(|| invalid("The plan root is not UTF-8."))?; - if plan["status"] != "confirmed" { - return Err(invalid( - "auditPlan needs the confirmed plan: answer the questions and confirm first.", - )); - } - let config = read_config(root)?.unwrap_or_default(); - let thresholds = config.visual_thresholds.clone().unwrap_or_default(); - let base_url = audit_base_url(input, &plan); - let mut result = super::audit_plan::write( - &plan, - &super::audit_plan::AuditRequest { - root, - base_url: &base_url, - base_urls: &input.base_urls, - only: &input.urls, - artifact_ids: &input.artifacts, - thresholds: &thresholds, - }, - policy, - artifacts, - ) - .await?; - let mut entries = array(&plan["audit"]["plans"]).to_vec(); - let replaced = array(&result["plans"]).iter().map(|entry| entry["url"].clone()).collect::>(); - entries.retain(|entry| !replaced.contains(&entry["url"])); - for written in array(&result["plans"]) { - entries.push(json!({"url": written["url"], "screen": written["screen"], - "app": written["app"], "baseUrl": written["baseUrl"], - "state": written["state"], - "missingReferences": written["missingReferences"], "planPath": written["planPath"], "reportPath": format!("{}/report.json", text(&written["reportDirectory"]).replace('\\', "/"))})); - } - // A dynamic route sampled from its Figma frame is an assumption, and is - // reported as one. - let mut assumptions = array(&plan["assumptions"]).to_vec(); - for written in array(&result["plans"]) { - if let Some(assumption) = written["dynamic"]["assumption"].as_str() - && !assumptions - .iter() - .any(|existing| existing["decision"] == assumption) - { - assumptions.push(json!({"about": written["dynamic"]["pattern"], "decision": assumption, - "reason": "no generateStaticParams and no link to the route on an audited page yet", "recordedAt": now()})); - } - } - plan["assumptions"] = json!(assumptions); - let max_fix_attempts = config.max_fix_attempts.unwrap_or(3); - if let Some(mismatch) = base_url_mismatch(&entries) { - result["baseUrlMismatch"] = mismatch; - } - plan["audit"]["baseUrl"] = json!(base_url); - plan["audit"]["plans"] = json!(entries); - plan["audit"]["maxFixAttempts"] = json!(max_fix_attempts); - plan["audit"]["generatedAt"] = json!(now()); - result["framePages"] = json!(frame_page_table(&plan)); - plan["audit"]["framePages"] = result["framePages"].clone(); - result["writtenTo"] = write_plan(policy, root, &plan)?; - result["maxFixAttempts"] = json!(max_fix_attempts); - result["doneWhen"] = json!(DONE_RULE); - Ok(result) -} - -pub(super) const DONE_RULE: &str = include_str!("../text/screen_plan-DONE_RULE-95793a55.md"); - -/// The question ids that place `screen` (a screen id, or a frame node id for -/// a frame split into its own page). -fn placing_questions(plan: &Value, screen: &str) -> Vec { - array(&plan["questions"]) - .iter() - .map(|question| text(&question["id"])) - .filter(|id| { - ["route:", "review:", "modal:", "group:", "split-route:", "collision:"] - .iter() - .any(|prefix| id.strip_prefix(prefix) == Some(screen)) - }) - .map(str::to_owned) - .collect() -} - -fn suggested_route(name: &str, screen: &str, taken: &str) -> String { - let words = name - .to_lowercase() - .split(|c: char| !c.is_ascii_alphanumeric()) - .filter(|word| !word.is_empty()) - .map(str::to_owned) - .collect::>(); - let base = if words.is_empty() { - format!("/page-{}", screen.replace([':', ';'], "-")) - } else { - format!("/{}", words.join("-")) - }; - if base == taken { - format!("{base}-{}", screen.replace([':', ';'], "-")) - } else { - base - } -} - -/// Screens on one URL: the error names every colliding pair, the questions -/// that place each, and a distinct URL for the later screen, so the caller can -/// retry confirm with all changed answers at once instead of one per call. -fn duplicate_routes(plan: &Value, collisions: &[(String, String, Value, Value)]) -> DevupError { - let mut conflicts = Vec::new(); - let mut retry = serde_json::Map::new(); - let mut taken = collisions.iter().map(|c| c.0.clone()).collect::>(); - for (url, first, second, name) in collisions { - let second = text(second); - let first_name = array(&plan["screens"]) - .iter() - .find(|screen| screen["id"] == first.as_str()) - .map(|screen| screen["name"].clone()) - .unwrap_or(Value::Null); - let mut suggestion = suggested_route(text(name), second, url); - while !taken.insert(suggestion.clone()) { - suggestion = format!("{suggestion}-{}", second.replace([':', ';'], "-")); - } - let questions = placing_questions(plan, second); - if let Some(id) = questions.iter().find(|id| !id.starts_with("collision:")) { - retry.insert(id.clone(), json!(suggestion)); - } - conflicts.push(json!({ - "url": url, - "screens": [ - {"screen": first, "name": first_name, "questions": placing_questions(plan, first)}, - {"screen": second, "name": name, "questions": questions, "suggestedUrl": suggestion}, - ], - })); - } - let (url, first, second, _) = &collisions[0]; - let retry = Value::Object(retry); - DevupError::with_details( - ErrorCode::DevupInvalidInput, - format!( - "Two screens resolve to {url} ({first} and {}); {} URL conflict(s) in total. Nothing was written. Retry confirm with the same answers plus {retry}, or show the user the conflicting screens and ask which one owns each URL.", - text(second), - collisions.len() - ), - false, - json!({ - "url": url, - "conflict": conflicts[0]["screens"], - "conflicts": conflicts, - "retryAnswers": retry, - }), - ) -} -fn screen_table(plan: &Value) -> Vec { - array(&plan["screens"]) - .iter() - .map(|screen| { - json!({ - "id": screen["id"], - "name": screen["name"], - "kind": screen["kind"], - "drawnWidths": screen["drawnWidths"], - "proposedRoute": screen["proposedRoute"], - "routeEvidence": screen["routeEvidence"], - "existingFile": screen["existingFile"], - "sharedParts": screen["sharedParts"], - "openDecisions": screen["openDecisions"], - }) - }) - .collect() -} - -fn cell(value: &Value) -> String { - let rendered = match value { - Value::Null => "-".to_owned(), - Value::String(text) => text.clone(), - Value::Array(items) if items.is_empty() => "-".to_owned(), - Value::Array(items) => items.iter().map(cell).collect::>().join(", "), - other => other.to_string(), - }; - rendered.replace('|', "\\|").replace('\n', " ") -} - -fn route_markdown(routes: &[Value]) -> String { - let mut out = String::from( - "| URL | Figma screen | Figma page | nodeIds | widths | file | implementation | app |\n|---|---|---|---|---|---|---|---|\n", - ); - for row in routes { - let url = if row["implementation"] == "state" { - format!("(state on {})", cell(&row["hostUrl"])) - } else { - cell(&row["url"]) - }; - out.push_str(&format!( - "| {url} | {} | {} | {} | {} | {} | {} | {} |\n", - cell(&row["name"]), - cell(&row["pages"]), - cell(&row["nodeIds"]), - cell(&row["drawnWidths"]), - cell(&row["file"]), - cell(&row["implementation"]), - cell(&row["app"]), - )); - } - out -} - -/// The Markdown twin of the plan: the same content, for a person. -fn markdown(plan: &Value) -> String { - let mut out = format!( - "# Screen plan {}\n\nStatus: **{}**. Source: {}.\n", - cell(&plan["planId"]), - cell(&plan["status"]), - cell(&plan["source"]["url"]), - ); - if let Some(notes) = plan["notes"].as_str() { - out.push_str(&format!("\nNotes: {notes}\n")); - } - if let Some(note) = plan["inventoryNote"].as_str() { - out.push_str(&format!("\n> {note}\n")); - } - out.push_str("\n## Screens\n\n| Figma screen | nodeId | widths | proposed route | evidence | existing file | shared parts | open decisions |\n|---|---|---|---|---|---|---|---|\n"); - for screen in array(&plan["screens"]) { - out.push_str(&format!( - "| {} | {} | {} | {} | {} | {} | {} | {} |\n", - cell(&screen["name"]), - cell(&screen["id"]), - cell(&screen["drawnWidths"]), - cell(&screen["proposedRoute"]), - cell(&screen["routeEvidence"]), - cell(&screen["existingFile"]), - cell(&screen["sharedParts"]), - cell(&screen["openDecisions"]), - )); - } - if plan["frameInventory"].is_object() { - out.push_str( - "\n## Frame inventory\n\n| Frame | nodeId | disposition | route |\n|---|---|---|---|\n", - ); - for frame in array(&plan["frameInventory"]["frames"]) { - out.push_str(&format!( - "| {} | {} | {} | {} |\n", - cell(&frame["name"]), - cell(&frame["nodeId"]), - cell(&frame["disposition"]), - cell(&frame["route"]) - )); - } - out.push_str(&format!("\n{}\n", text(&plan["frameInventory"]["rule"]))); - out.push_str( - "\n## Link labels\n\n| On frame | Label | state | resolved |\n|---|---|---|---|\n", - ); - for label in array(&plan["linkLabelCheck"]["labels"]) { - out.push_str(&format!( - "| {} | {} | {} | {} |\n", - cell(&label["on"]), - cell(&label["label"]), - cell(&label["state"]), - cell(&label["resolved"]) - )); - } - } - let questions = array(&plan["questions"]); - if !questions.is_empty() { - out.push_str(&format!("\n## Open questions\n\n{ASK_INSTRUCTION}\n\n")); - for question in questions { - let id = text(&question["id"]); - out.push_str(&format!( - "- **{}** (`{id}`): {}\n", - cell(&question["header"]), - text(&question["question"]) - )); - for option in array(&question["options"]) { - out.push_str(&format!( - " - {}: {}\n", - text(&option["label"]), - text(&option["description"]) - )); - } - if let Some(answer) = plan["answers"].get(id) { - out.push_str(&format!(" - Answer: {}\n", cell(answer))); - } - } - } - let routes = array(&plan["routes"]); - if !routes.is_empty() { - out.push_str("\n## Confirmed routes\n\n"); - out.push_str(&route_markdown(routes)); - } - let lists = trailing_lists(plan); - if !lists.is_empty() { - out.push_str("\n## Not built, and why\n"); - out.push_str(&lists); - } - out -} +/// `confirm`: every question that applies answered, in one call, or nothing +/// changes. +pub(super) fn confirm(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let (plan_root, mut plan) = audit_plan_location(input)?; + let root = plan_root.to_str().ok_or_else(|| invalid("The plan root is not UTF-8."))?; + let plan_id = input + .plan_id + .as_deref() + .ok_or_else(|| invalid("action confirm needs planId."))?; + if plan["planId"] != plan_id { + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + "planId does not match the plan on disk; it was redrafted. Ask the questions of the current draft.", + false, + json!({"planId": plan_id, "currentPlanId": plan["planId"]}), + )); + } + if plan["status"] == "confirmed" && input.answers.is_empty() { + return Ok(json!({ + "status": "confirmed", + "alreadyConfirmed": true, + "planId": plan["planId"], + "confirmedAt": plan["confirmedAt"], + "routes": plan["routes"], + "markdownTable": route_markdown(array(&plan["routes"])), + "nextAction": {"tool": "devup_screen_plan", "arguments": {"action": "status", "projectRoot": root, "planId": plan_id}, + "how": "Nothing changed. The plan was confirmed before; continue implementation from its status."}, + })); + } + let mut questions = array(&plan["questions"]).to_vec(); + let ordered = questions.clone(); + questions.sort_by_key(|question| depth(question, &ordered)); + let known = questions + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect::>(); + let unknown = input + .answers + .keys() + .filter(|id| !known.contains(*id)) + .cloned() + .collect::>(); + let mut actions = BTreeMap::new(); + let answers = proposal_answers(&questions, &input.answers); + let mut custom = Vec::new(); + let mut missing = Vec::new(); + for question in &questions { + let id = text(&question["id"]).to_owned(); + if !required(question, &actions) { + continue; + } + let Some(answer) = answers.get(&id) else { + if input.accept_recommended + && let Some(recommended) = array(&question["resolve"]).first() + { + actions.insert(id, recommended["action"].clone()); + } else { + missing.push(id); + } + continue; + }; + let (action, note) = resolve_answer(question, answer)?; + if let Some(note) = note { + custom.push(json!({"question": id, "answer": note})); + } + actions.insert(id, action); + } + if !missing.is_empty() || !unknown.is_empty() { + let pending = questions + .iter() + .filter(|question| missing.iter().any(|id| question["id"] == id.as_str())) + .cloned() + .collect::>(); + return Err(DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "confirm needs an answer to every question that applies. Missing: {}.", + if missing.is_empty() { + "none".to_owned() + } else { + missing.join(", ") + } + ), + false, + json!({"missing": missing, "unknown": unknown, "askInstruction": ASK_INSTRUCTION, + "questionRounds": question_rounds(&pending, per_round(&plan))}), + )); + } + let accepted = actions + .get("heuristics") + .is_none_or(|action| action["heuristics"] == "accept"); + let nothing_here = actions + .get("scope") + .is_some_and(|action| action["scope"] == "none"); + // Every "the Figma structure is wrong" answer: its frames are not built, + // and the fix is reported. The work goes on without them. + let design_fixes = questions + .iter() + .filter(|question| { + actions + .get(text(&question["id"])) + .is_some_and(|action| action["designFix"] == true) + }) + .map(|question| { + json!({"question": question["id"], "frames": question["fix"]["frames"], + "fix": question["fix"]["text"]}) + }) + .collect::>(); + let fixed = |ids: &[&str]| { + ids.iter().any(|id| { + actions + .get(*id) + .is_some_and(|action| action["designFix"] == true) + }) + }; + let app_dir = actions + .get("site:app") + .and_then(|action| action["app"]["appDir"].as_str()) + .unwrap_or(text(&plan["appDir"])) + .to_owned(); + plan["appDir"] = json!(app_dir); + let folds = state_folds(&questions, &actions); + let folded = folded_ids(&folds); + let mut routes = Vec::new(); + let mut screen_routes = BTreeMap::new(); + let screens = array(&plan["screens"]).to_vec(); + for screen in &screens { + let id = text(&screen["id"]).to_owned(); + let frames = array(&screen["frames"]).to_vec(); + if let Some(page) = actions.get("base-page").and_then(|action| action["pageId"].as_str()) + && !frames.iter().any(|frame| frame["pageId"] == page) { + continue; + } + let mut deciding = array(&screen["structureQuestions"]) + .iter() + .map(text) + .collect::>(); + deciding.extend(screen["groupingQuestion"].as_str()); + if nothing_here || fixed(&deciding) || folded.contains(&id) { + continue; + } + let chosen_app = actions + .get(&format!("app:{id}")) + .or_else(|| actions.get("site:app")) + .map(|action| action["app"].clone()); + let screen_app = chosen_app + .as_ref() + .and_then(|app| app["dir"].as_str()) + .or(screen["app"].as_str()) + .map(str::to_owned); + let screen_app_dir = chosen_app + .as_ref() + .and_then(|app| app["appDir"].as_str()) + .or(screen["appDir"].as_str()) + .unwrap_or(&app_dir) + .to_owned(); + let group = screen["groupingQuestion"] + .as_str() + .and_then(|question| actions.get(question)); + let separate = group.is_some_and(|action| action["group"] == "separate"); + let data = actions + .get(&format!("dynamic:{id}")) + .map(|action| action["data"].clone()); + // Each placement is (frames, implementation, route, host, collision). + let mut placements = Vec::new(); + if separate { + let explicit = group.and_then(|action| action["routes"].as_array()); + if explicit.is_some_and(|routes| routes.len() != frames.len()) { + return Err(invalid(format!( + "{}: list exactly one route per frame ({} frames).", + text(&screen["groupingQuestion"]), + frames.len() + ))); + } + for (index, frame) in frames.iter().enumerate() { + let node = text(&frame["nodeId"]); + let action = explicit + .map(|routes| json!({"route": routes[index]})) + .or_else(|| actions.get(&format!("split-route:{node}")).cloned()) + .unwrap_or(Value::Null); + placements.push((vec![frame.clone()], action, None)); + } + } else { + let placed = group + .filter(|action| action["route"].is_string()) + .or_else(|| actions.get(&format!("route:{id}"))) + .or_else(|| actions.get(&format!("modal:{id}"))) + .or_else(|| actions.get(&format!("review:{id}"))) + .cloned(); + let action = match placed { + Some(action) => action, + None if screen["routeEvidence"] == NAME_MATCH && accepted => { + json!({"route": screen["proposedRoute"], "collision": "merge", "acceptedHeuristic": true}) + } + None => json!({"route": screen["proposedRoute"]}), + }; + let collision = actions + .get(&format!("collision:{id}")) + .map(|action| text(&action["collision"]).to_owned()); + placements.push((frames.clone(), action, collision)); + } + for (group_frames, action, collision) in placements { + let collision = collision.or_else(|| action["collision"].as_str().map(str::to_owned)); + let (implementation, route, host) = + if action["skip"] == true || collision.as_deref() == Some("skip") { + ("skip", None, None) + } else if action["notPage"] == true { + ("not-a-page", None, None) + } else if let Some(host) = action["state"].as_str() { + ("state", None, Some(host.to_owned())) + } else { + ("page", action["route"].as_str().map(str::to_owned), None) + }; + let first = text(&group_frames[0]["nodeId"]).to_owned(); + if implementation == "page" && route.is_none() { + return Err(invalid(format!( + "{first} has no route after the answers; answer its route question with a route." + ))); + } + if let Some(url) = &route { + screen_routes + .entry(id.clone()) + .or_insert_with(|| url.clone()); + } + let existing = route.as_deref().and_then(|url| { + (screen["proposedRoute"] == url && !screen["existingFile"].is_null()) + .then(|| screen["existingFile"].clone()) + }); + routes.push(json!({ + "url": route, + "screen": if separate { first.clone() } else { id.clone() }, + "name": group_frames[0]["name"], + "nodeIds": group_frames.iter().map(|frame| frame["nodeId"].clone()).collect::>(), + "pages": distinct_pages(&group_frames), + "drawnWidths": group_frames.iter().filter_map(|frame| frame["width"].as_f64()).map(|width| width.round() as i64).collect::>(), + "implementation": implementation, + "hostScreen": host, + "app": screen_app, + "file": existing.clone().unwrap_or_else(|| route.as_deref().map_or(Value::Null, |url| json!(planned_page(url, &screen_app_dir)))), + "fileExists": existing.is_some(), + "collisionPolicy": collision, + "data": data, + "routeEvidence": if action["acceptedHeuristic"] == true { json!("name-match (accepted)") } else { screen["routeEvidence"].clone() }, + })); + } + } + record_states(&mut routes, &folds); + let mut excluded = Vec::new(); + for row in array(&plan["excluded"]) { + let node = text(&row["nodeId"]).to_owned(); + let built = !nothing_here + && actions + .get(&format!("exclusion:{node}")) + .is_some_and(|action| action["build"] == true); + let action = actions.get(&format!("exclusion-route:{node}")); + match action.and_then(|action| action["route"].as_str()) { + Some(route) if built => routes.push(json!({ + "url": route, "screen": node, "name": row["name"], "nodeIds": [node], + "drawnWidths": [row["width"].as_f64().map(|width| width.round() as i64)], + "implementation": "page", "hostScreen": null, + "file": planned_page(route, &app_dir), "fileExists": false, + "collisionPolicy": null, "data": null, + "routeEvidence": "confirmed answer (was excluded)", + })), + _ => { + let mut row = row.clone(); + row["decision"] = json!("excluded"); + excluded.push(row); + } + } + } + if nothing_here { + for screen in &screens { + for frame in array(&screen["frames"]) { + excluded.push(json!({"nodeId": frame["nodeId"], "name": frame["name"], "width": frame["width"], + "section": frame["sectionName"], "reason": "the linked area is not for implementation (answered)", + "decision": "excluded"})); + } + } + } + // One URL per app: the public site and the console may both have `/`. + let mut seen = BTreeMap::<(String, String), String>::new(); + let mut collisions = Vec::new(); + for row in &routes { + if let Some(url) = row["url"].as_str() + && let Some(other) = seen.insert( + (text(&row["app"]).to_owned(), url.to_owned()), + text(&row["screen"]).to_owned(), + ) + && other != text(&row["screen"]) + { + collisions.push((url.to_owned(), other, row["screen"].clone(), row["name"].clone())); + } + } + if !collisions.is_empty() { + return Err(duplicate_routes(&plan, &collisions)); + } + for row in routes + .iter_mut() + .filter(|row| row["implementation"] == "state") + { + row["hostUrl"] = json!(screen_routes.get(text(&row["hostScreen"]))); + } + let node_routes = routes + .iter() + .flat_map(|route| { + array(&route["nodeIds"]) + .iter() + .map(move |node| (text(node).to_owned(), route["url"].clone())) + }) + .collect::>(); + if let Some(rows) = plan["screens"].as_array_mut() { + for screen in rows { + let id = text(&screen["id"]).to_owned(); + let mut resolved = Vec::new(); + for (index, link) in array(&screen["links"]).iter().enumerate() { + let mut link = link.clone(); + if let Some(action) = actions.get(&format!("link:{id}:{index}")) { + link["resolved"] = + json!({"url": action["link"], "evidence": "confirmed answer"}); + } else if let Some(target) = link["resolved"]["screen"].as_str() { + link["resolved"]["url"] = + if let Some(node) = link["resolved"]["nodeId"].as_str() { + node_routes.get(node).cloned().unwrap_or(Value::Null) + } else { + let urls = routes + .iter() + .filter(|route| { + screens + .iter() + .find(|screen| screen["id"] == target) + .is_some_and(|screen| { + array(&screen["frames"]).iter().any(|frame| { + array(&route["nodeIds"]).contains(&frame["nodeId"]) + }) + }) + }) + .filter_map(|route| route["url"].as_str()) + .collect::>(); + if urls.len() == 1 { + json!(urls.first()) + } else { + Value::Null + } + }; + } + resolved.push(link); + } + screen["links"] = json!(resolved); + } + } + plan["mobileNavigation"] = actions.get("mobile-menu").cloned().unwrap_or(Value::Null); + plan["answers"] = json!(input.answers); + plan["customAnswers"] = json!(custom); + plan["heuristicsAccepted"] = json!(accepted); + plan["layout"] = json!( + actions + .iter() + .filter(|(id, _)| id.starts_with("layout:")) + .map(|(id, action)| (id.clone(), action["layout"].clone())) + .collect::>() + ); + for asked in questions + .iter() + .filter(|asked| text(&asked["id"]).starts_with("site:")) + { + let field = text(&asked["field"]).to_owned(); + if let Some(action) = actions.get(text(&asked["id"])) { + plan["site"][&field]["state"] = json!("answered"); + plan["site"][&field]["answer"] = action[&field].clone(); + } + } + plan["extraAnswers"] = json!( + actions + .iter() + .filter(|(id, _)| id.starts_with("extra:")) + .map(|(id, action)| (id.clone(), action["extra"].clone())) + .collect::>() + ); + plan["routes"] = json!(routes); + plan["excluded"] = json!(excluded); + plan["designFixes"] = json!(design_fixes); + plan["confirmedPage"] = confirmed_page(&plan); + if let Some(page) = actions.get("base-page").and_then(|action| action["pageId"].as_str()) { + let name = screens.iter().flat_map(|screen| array(&screen["frames"])) + .find(|frame| frame["pageId"] == page).map(|frame| frame["pageName"].clone()); + plan["confirmedPage"] = json!({"id":page,"name":name,"basis":"base-page answer"}); + } + plan["status"] = json!("confirmed"); + plan["confirmedAt"] = json!(now()); + refresh_inventory_checks(&mut plan); + if !plan["assumptions"].is_array() { + plan["assumptions"] = json!([]); + } + let written = write_plan(policy, root, &plan)?; + let mut work_queue = theme_sync_queue(&plan, root); + work_queue.extend(routes + .iter() + .filter(|route| route["url"].is_string()) + .map(|route| { + json!({ + "do": "build", + "url": route["url"], + "implementation": route["implementation"], + "file": route["file"], + "nodeIds": route["nodeIds"], + "drawnWidths": route["drawnWidths"], + "states": state_exports(route), + "nextExport": if multi_width(route) { responsive_export_action(&plan, route, root) } else { Value::Null }, + }) + }) + .collect::>()); + let urls = work_queue + .iter() + .filter_map(|item| item["url"].as_str().map(str::to_owned)) + .collect::>(); + Ok(json!({ + "status": "confirmed", + "planId": plan["planId"], + "workQueue": work_queue, + "confirmedPage": plan["confirmedPage"], + "routes": routes, + "markdownTable": route_markdown(&routes), + "frameInventory": plan["frameInventory"], + "linkLabelCheck": plan["linkLabelCheck"], + "excluded": excluded, + "designFixes": design_fixes, + "site": plan["site"], + "defaults": plan["defaults"], + "extraAnswers": plan["extraAnswers"], + "mobileNavigation": plan["mobileNavigation"], + "customAnswers": custom, + "rule": IMPLEMENT_INSTRUCTION, + "writtenTo": written, + "nextAction": { + "how": IMPLEMENT_INSTRUCTION, + "buildAll": urls, + "until": "every URL in workQueue is built; do not stop after the first route. Check progress with action status.", + }, + })) +} + +/// `assume`: what implementation decided without asking, appended to the +/// confirmed plan so status and the final report carry it. +pub(super) fn assume(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let (plan_root, mut plan) = audit_plan_location(input)?; + let root = plan_root.to_str().ok_or_else(|| invalid("The plan root is not UTF-8."))?; + if plan["status"] != "confirmed" { + return Err(invalid( + "Assumptions belong to implementation, after confirm. Before confirm, put the decision to the user in the question rounds instead.", + )); + } + if input.assumptions.is_empty() && input.dependency_update.is_none() && input.navigation_targets.is_empty() { + return Err(invalid( + "action assume needs assumptions, dependencyUpdate or navigationTargets.", + )); + } + let recorded_at = now(); + // An assumption about a pending decision takes it, unless it is + // destructive: that one stays unbuilt and goes into the final report. + let mut decisions = array(&plan["pendingDecisions"]).to_vec(); + for decision in &mut decisions { + let Some(taken) = input + .assumptions + .iter() + .find(|assumption| decision["id"] == assumption.about.as_str()) + else { + continue; + }; + if decision["destructive"] == true { + decision["status"] = json!("left-unbuilt"); + } else { + decision["status"] = json!("taken"); + decision["taken"] = json!(taken.decision); + } + decision["takenAt"] = json!(recorded_at); + } + if !decisions.is_empty() { + plan["pendingDecisions"] = json!(decisions); + } + if let Some(update) = &input.dependency_update { + plan["dependencies"] = json!({"updatedAt": recorded_at, "bumped": update.bumped, + "pins": update.pins, "command": UPDATE_STEP}); + } + let mut list = array(&plan["assumptions"]).to_vec(); + for pin in input + .dependency_update + .iter() + .flat_map(|update| &update.pins) + { + list.push(json!({"about": format!("dependency {}", pin.package), + "decision": format!("pinned to {}", pin.version), "reason": pin.reason, "recordedAt": recorded_at})); + } + for assumption in &input.assumptions { + if assumption.about.trim().is_empty() || assumption.decision.trim().is_empty() { + return Err(invalid("Every assumption needs about and decision.")); + } + list.push( + json!({"about": assumption.about, "decision": assumption.decision, + "reason": assumption.reason, "recordedAt": recorded_at}), + ); + } + let mut remapped = Vec::new(); + for (node, url) in &input.navigation_targets { + let route = normalize_route(url).filter(|route| valid_route(route)).ok_or_else(|| invalid(format!("navigationTargets[{node}] = {url:?} is not an app route.")))?; + remap_frame(&mut plan, node, &route, &mut remapped)?; + list.push(json!({"about": node, "decision": format!("remapped to {route}"), "reason": "navigationTargets in assume", "recordedAt": recorded_at})); + } + if !remapped.is_empty() { + refresh_inventory_checks(&mut plan); + let mut all = array(&plan["remaps"]).to_vec(); + all.extend(remapped.iter().cloned()); + plan["remaps"] = json!(all); + } + plan["assumptions"] = json!(list); + // An assumption never builds what the plan excluded or sent back for a + // design fix; the export gate refuses those frames whatever is recorded. + let blocked = array(&plan["excluded"]) + .iter() + .map(|row| text(&row["nodeId"]).to_owned()) + .chain(array(&plan["designFixes"]).iter().flat_map(|fix| { + array(&fix["frames"]) + .iter() + .map(|frame| text(&frame["nodeId"]).to_owned()) + .collect::>() + })) + .filter(|id| { + !id.is_empty() + && input + .assumptions + .iter() + .any(|assumption| assumption.about.contains(id.as_str())) + }) + .collect::>(); + let written = write_plan(policy, root, &plan)?; + Ok(json!({ + "status": "confirmed", + "planId": plan["planId"], + "pendingDecisions": plan["pendingDecisions"], + "stillNotBuilt": blocked.iter().map(|id| json!({"frame": id, + "why": "Excluded or sent back for a design fix; an assumption cannot build it, and the export gate refuses it."})).collect::>(), + "assumptions": plan["assumptions"], + "remapped": remapped, + "dependencies": plan["dependencies"], + "writtenTo": written, + "how": "Keep implementing. Every assumption appears in status and must be in your final report.", + })) +} + +/// Records a decision the work needs without stopping it. +pub(super) fn defer(input: &ScreenPlanInput, policy: &OutputPolicy) -> Result { + let (plan_root, mut plan) = audit_plan_location(input)?; + let root = plan_root.to_str().ok_or_else(|| invalid("The plan root is not UTF-8."))?; + if plan["status"] != "confirmed" { + return Err(invalid( + "A decision before confirm is a question: put it in extraQuestions on draft.", + )); + } + let decision = input + .decision + .as_ref() + .ok_or_else(|| invalid("action defer needs decision."))?; + for (field, value) in [ + ("id", &decision.id), + ("about", &decision.about), + ("question", &decision.question), + ("recommended", &decision.recommended), + ] { + if value.trim().is_empty() { + return Err(invalid(format!("decision.{field} is empty."))); + } + } + let mut decisions = array(&plan["pendingDecisions"]).to_vec(); + decisions.retain(|existing| existing["id"] != decision.id.as_str()); + decisions.push( + json!({"id": decision.id, "about": decision.about, "question": decision.question, + "options": decision.options, "recommended": decision.recommended, + "destructive": decision.destructive, "status": "open", "raisedAt": now()}), + ); + plan["pendingDecisions"] = json!(decisions); + let written = write_plan(policy, root, &plan)?; + Ok(json!({ + "status": "confirmed", + "pendingDecisions": plan["pendingDecisions"], + "writtenTo": written, + "how": if decision.destructive { + "Recorded. Do not build what it touches; carry on with everything else. It stays unbuilt and is listed in the final report." + } else { + include_str!("../text/screen_plan-defer-3281174c.md") + }, + })) +} + +/// Writes one runtime-audit plan per confirmed URL and records where. +pub(super) async fn audit_plan( + input: &ScreenPlanInput, + policy: &OutputPolicy, + artifacts: &ArtifactStore, +) -> Result { + let (plan_root, mut plan) = audit_plan_location(input)?; + let root = plan_root.to_str().ok_or_else(|| invalid("The plan root is not UTF-8."))?; + if plan["status"] != "confirmed" { + return Err(invalid( + "auditPlan needs the confirmed plan: answer the questions and confirm first.", + )); + } + let config = read_config(root)?.unwrap_or_default(); + let thresholds = config.visual_thresholds.clone().unwrap_or_default(); + let base_url = audit_base_url(input, &plan); + let mut result = super::audit_plan::write( + &plan, + &super::audit_plan::AuditRequest { + root, + base_url: &base_url, + base_urls: &input.base_urls, + only: &input.urls, + artifact_ids: &input.artifacts, + thresholds: &thresholds, + }, + policy, + artifacts, + ) + .await?; + let mut entries = array(&plan["audit"]["plans"]).to_vec(); + let replaced = array(&result["plans"]).iter().map(|entry| entry["url"].clone()).collect::>(); + entries.retain(|entry| !replaced.contains(&entry["url"])); + for written in array(&result["plans"]) { + entries.push(json!({"url": written["url"], "screen": written["screen"], + "app": written["app"], "baseUrl": written["baseUrl"], + "state": written["state"], + "missingReferences": written["missingReferences"], "planPath": written["planPath"], "reportPath": format!("{}/report.json", text(&written["reportDirectory"]).replace('\\', "/"))})); + } + // A dynamic route sampled from its Figma frame is an assumption, and is + // reported as one. + let mut assumptions = array(&plan["assumptions"]).to_vec(); + for written in array(&result["plans"]) { + if let Some(assumption) = written["dynamic"]["assumption"].as_str() + && !assumptions + .iter() + .any(|existing| existing["decision"] == assumption) + { + assumptions.push(json!({"about": written["dynamic"]["pattern"], "decision": assumption, + "reason": "no generateStaticParams and no link to the route on an audited page yet", "recordedAt": now()})); + } + } + plan["assumptions"] = json!(assumptions); + let max_fix_attempts = config.max_fix_attempts.unwrap_or(3); + if let Some(mismatch) = base_url_mismatch(&entries) { + result["baseUrlMismatch"] = mismatch; + } + plan["audit"]["baseUrl"] = json!(base_url); + plan["audit"]["plans"] = json!(entries); + plan["audit"]["maxFixAttempts"] = json!(max_fix_attempts); + plan["audit"]["generatedAt"] = json!(now()); + result["framePages"] = json!(frame_page_table(&plan)); + plan["audit"]["framePages"] = result["framePages"].clone(); + result["writtenTo"] = write_plan(policy, root, &plan)?; + result["maxFixAttempts"] = json!(max_fix_attempts); + result["doneWhen"] = json!(DONE_RULE); + Ok(result) +} + +pub(super) const DONE_RULE: &str = include_str!("../text/screen_plan-DONE_RULE-95793a55.md"); + +/// The question ids that place `screen` (a screen id, or a frame node id for +/// a frame split into its own page). +fn placing_questions(plan: &Value, screen: &str) -> Vec { + array(&plan["questions"]) + .iter() + .map(|question| text(&question["id"])) + .filter(|id| { + ["route:", "review:", "modal:", "group:", "split-route:", "collision:"] + .iter() + .any(|prefix| id.strip_prefix(prefix) == Some(screen)) + }) + .map(str::to_owned) + .collect() +} + +fn suggested_route(name: &str, screen: &str, taken: &str) -> String { + let words = name + .to_lowercase() + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_owned) + .collect::>(); + let base = if words.is_empty() { + format!("/page-{}", screen.replace([':', ';'], "-")) + } else { + format!("/{}", words.join("-")) + }; + if base == taken { + format!("{base}-{}", screen.replace([':', ';'], "-")) + } else { + base + } +} + +/// Screens on one URL: the error names every colliding pair, the questions +/// that place each, and a distinct URL for the later screen, so the caller can +/// retry confirm with all changed answers at once instead of one per call. +fn duplicate_routes(plan: &Value, collisions: &[(String, String, Value, Value)]) -> DevupError { + let mut conflicts = Vec::new(); + let mut retry = serde_json::Map::new(); + let mut taken = collisions.iter().map(|c| c.0.clone()).collect::>(); + for (url, first, second, name) in collisions { + let second = text(second); + let first_name = array(&plan["screens"]) + .iter() + .find(|screen| screen["id"] == first.as_str()) + .map(|screen| screen["name"].clone()) + .unwrap_or(Value::Null); + let mut suggestion = suggested_route(text(name), second, url); + while !taken.insert(suggestion.clone()) { + suggestion = format!("{suggestion}-{}", second.replace([':', ';'], "-")); + } + let questions = placing_questions(plan, second); + if let Some(id) = questions.iter().find(|id| !id.starts_with("collision:")) { + retry.insert(id.clone(), json!(suggestion)); + } + conflicts.push(json!({ + "url": url, + "screens": [ + {"screen": first, "name": first_name, "questions": placing_questions(plan, first)}, + {"screen": second, "name": name, "questions": questions, "suggestedUrl": suggestion}, + ], + })); + } + let (url, first, second, _) = &collisions[0]; + let retry = Value::Object(retry); + DevupError::with_details( + ErrorCode::DevupInvalidInput, + format!( + "Two screens resolve to {url} ({first} and {}); {} URL conflict(s) in total. Nothing was written. Retry confirm with the same answers plus {retry}, or show the user the conflicting screens and ask which one owns each URL.", + text(second), + collisions.len() + ), + false, + json!({ + "url": url, + "conflict": conflicts[0]["screens"], + "conflicts": conflicts, + "retryAnswers": retry, + }), + ) +} +fn screen_table(plan: &Value) -> Vec { + array(&plan["screens"]) + .iter() + .map(|screen| { + json!({ + "id": screen["id"], + "name": screen["name"], + "kind": screen["kind"], + "drawnWidths": screen["drawnWidths"], + "proposedRoute": screen["proposedRoute"], + "routeEvidence": screen["routeEvidence"], + "existingFile": screen["existingFile"], + "sharedParts": screen["sharedParts"], + "openDecisions": screen["openDecisions"], + }) + }) + .collect() +} + +fn cell(value: &Value) -> String { + let rendered = match value { + Value::Null => "-".to_owned(), + Value::String(text) => text.clone(), + Value::Array(items) if items.is_empty() => "-".to_owned(), + Value::Array(items) => items.iter().map(cell).collect::>().join(", "), + other => other.to_string(), + }; + rendered.replace('|', "\\|").replace('\n', " ") +} + +fn route_markdown(routes: &[Value]) -> String { + let mut out = String::from( + "| URL | Figma screen | Figma page | nodeIds | widths | file | implementation | app |\n|---|---|---|---|---|---|---|---|\n", + ); + for row in routes { + let url = if row["implementation"] == "state" { + format!("(state on {})", cell(&row["hostUrl"])) + } else { + cell(&row["url"]) + }; + out.push_str(&format!( + "| {url} | {} | {} | {} | {} | {} | {} | {} |\n", + cell(&row["name"]), + cell(&row["pages"]), + cell(&row["nodeIds"]), + cell(&row["drawnWidths"]), + cell(&row["file"]), + cell(&row["implementation"]), + cell(&row["app"]), + )); + } + out +} + +/// The Markdown twin of the plan: the same content, for a person. +fn markdown(plan: &Value) -> String { + let mut out = format!( + "# Screen plan {}\n\nStatus: **{}**. Source: {}.\n", + cell(&plan["planId"]), + cell(&plan["status"]), + cell(&plan["source"]["url"]), + ); + if let Some(notes) = plan["notes"].as_str() { + out.push_str(&format!("\nNotes: {notes}\n")); + } + if let Some(note) = plan["inventoryNote"].as_str() { + out.push_str(&format!("\n> {note}\n")); + } + out.push_str("\n## Screens\n\n| Figma screen | nodeId | widths | proposed route | evidence | existing file | shared parts | open decisions |\n|---|---|---|---|---|---|---|---|\n"); + for screen in array(&plan["screens"]) { + out.push_str(&format!( + "| {} | {} | {} | {} | {} | {} | {} | {} |\n", + cell(&screen["name"]), + cell(&screen["id"]), + cell(&screen["drawnWidths"]), + cell(&screen["proposedRoute"]), + cell(&screen["routeEvidence"]), + cell(&screen["existingFile"]), + cell(&screen["sharedParts"]), + cell(&screen["openDecisions"]), + )); + } + if plan["frameInventory"].is_object() { + out.push_str( + "\n## Frame inventory\n\n| Frame | nodeId | disposition | route |\n|---|---|---|---|\n", + ); + for frame in array(&plan["frameInventory"]["frames"]) { + out.push_str(&format!( + "| {} | {} | {} | {} |\n", + cell(&frame["name"]), + cell(&frame["nodeId"]), + cell(&frame["disposition"]), + cell(&frame["route"]) + )); + } + out.push_str(&format!("\n{}\n", text(&plan["frameInventory"]["rule"]))); + out.push_str( + "\n## Link labels\n\n| On frame | Label | state | resolved |\n|---|---|---|---|\n", + ); + for label in array(&plan["linkLabelCheck"]["labels"]) { + out.push_str(&format!( + "| {} | {} | {} | {} |\n", + cell(&label["on"]), + cell(&label["label"]), + cell(&label["state"]), + cell(&label["resolved"]) + )); + } + } + let questions = array(&plan["questions"]); + if !questions.is_empty() { + out.push_str(&format!("\n## Open questions\n\n{ASK_INSTRUCTION}\n\n")); + for question in questions { + let id = text(&question["id"]); + out.push_str(&format!( + "- **{}** (`{id}`): {}\n", + cell(&question["header"]), + text(&question["question"]) + )); + for option in array(&question["options"]) { + out.push_str(&format!( + " - {}: {}\n", + text(&option["label"]), + text(&option["description"]) + )); + } + if let Some(answer) = plan["answers"].get(id) { + out.push_str(&format!(" - Answer: {}\n", cell(answer))); + } + } + } + let routes = array(&plan["routes"]); + if !routes.is_empty() { + out.push_str("\n## Confirmed routes\n\n"); + out.push_str(&route_markdown(routes)); + } + let lists = trailing_lists(plan); + if !lists.is_empty() { + out.push_str("\n## Not built, and why\n"); + out.push_str(&lists); + } + out +} diff --git a/crates/devup-mcp/src/server/screen_plan/draft.rs b/crates/devup-mcp/src/server/screen_plan/draft.rs index 7f8c702b..2fc76c95 100644 --- a/crates/devup-mcp/src/server/screen_plan/draft.rs +++ b/crates/devup-mcp/src/server/screen_plan/draft.rs @@ -1,714 +1,714 @@ -/// The first exclude keyword in any of `names`, unless an include keyword -/// forces them in. -fn excluded_by<'a>(names: &[(&'a str, &'a str)], config: &PlanConfig) -> Option { - let include = config.include_keywords.as_deref().unwrap_or_default(); - if names - .iter() - .any(|(_, name)| include.iter().any(|keyword| keyword_in(name, keyword))) - { - return None; - } - let defaults = EXCLUDE_WORDS - .iter() - .map(|word| (*word).to_owned()) - .collect::>(); - let keywords = config.exclude_keywords.as_ref().unwrap_or(&defaults); - names.iter().find_map(|(place, name)| { - keywords - .iter() - .find(|keyword| keyword_in(name, keyword)) - .map(|keyword| format!("{place} \"{name}\" contains \"{keyword}\"")) - }) -} - -/// Why a frame is not for implementation, or `None`. The names of the -/// linked node and its ancestors are left out: the user pointed there, which -/// is asked once rather than excluded (see the `scope` question). -fn exclusion_reason( - screen: &PlanScreenInput, - linked: &[String], - config: &PlanConfig, -) -> Option { - if screen.visible == Some(false) { - return Some("hidden in Figma".into()); - } - if screen.opacity.is_some_and(|opacity| opacity <= 0.0) { - return Some("zero opacity in Figma".into()); - } - let mut names = vec![("frame", screen.name.as_str())]; - if let Some(page) = screen.page_name.as_deref() { - names.push(("page", page)); - } - if let Some(section) = screen.section_name.as_deref() { - names.push(("section", section)); - } - for ancestor in screen - .breadcrumb - .split_last() - .map(|(_, ancestors)| ancestors) - .unwrap_or_default() - { - names.push(("parent", ancestor.as_str())); - } - names.retain(|(place, name)| *place == "frame" || !linked.iter().any(|link| link == name)); - let mut seen = BTreeSet::new(); - names.retain(|(_, name)| seen.insert(*name)); - excluded_by(&names, config) -} - -/// One devup_figma_explore candidate node as a screen to plan. Its parent -/// counts as its section only when the breadcrumb shows one between the page -/// and the frame; a frame drawn straight on the page is in no section. -pub(super) fn screen_from_explore(node: &Value) -> Option { - let breadcrumb = array(&node["breadcrumb"]) - .iter() - .filter_map(Value::as_str) - .map(str::to_owned) - .collect::>(); - let parent = node["parentId"].as_str().map(str::to_owned); - let in_section = breadcrumb.len() >= 3; - Some(PlanScreenInput { - node_id: node["nodeId"].as_str()?.to_owned(), - name: text(&node["name"]).to_owned(), - width: node["bounds"]["width"].as_f64()?, - height: node["bounds"]["height"].as_f64(), - section: parent.clone().filter(|_| in_section), - section_name: in_section.then(|| breadcrumb[breadcrumb.len() - 2].clone()), - ancestors: parent.into_iter().collect(), - visible: node["visible"].as_bool(), - opacity: node["opacity"].as_f64(), - page_id: node["pageId"].as_str().map(str::to_owned), - page_name: node["pageName"].as_str().map(str::to_owned).or_else(|| breadcrumb.first().cloned()), - breadcrumb, - navigation_content_width: node["navigationContentWidth"].as_f64(), - ..PlanScreenInput::default() - }) -} - -/// Whether a frame is under the node the user linked. A frame that carries -/// no ancestry at all came from the caller's own list and is taken as given; -/// one whose ancestry is known and does not reach the link is never a -/// candidate. -pub(super) fn in_scope( - screen: &PlanScreenInput, - anchor_id: Option<&str>, - anchor_name: Option<&str>, -) -> bool { - let Some(anchor) = anchor_id else { - return true; - }; - if screen.node_id == anchor - || screen.page_id.as_deref() == Some(anchor) - || screen.section.as_deref() == Some(anchor) - || screen.ancestors.iter().any(|id| id == anchor) - { - return true; - } - let ancestor_names = screen - .breadcrumb - .split_last() - .map(|(_, ancestors)| ancestors) - .unwrap_or_default(); - if anchor_name.is_some_and(|name| ancestor_names.iter().any(|ancestor| ancestor == name)) { - return true; - } - screen.section.is_none() && screen.ancestors.is_empty() && screen.breadcrumb.is_empty() -} - -fn with_when(mut question: Value, conditions: &[Value]) -> Value { - if !conditions.is_empty() { - let mut all = array(&question["when"]).to_vec(); - all.extend(conditions.iter().cloned()); - question["when"] = json!(all); - } - question -} - -const SECTIONS_SEPARATE: &str = "Separate pages: the sections are different pages"; -const SECTIONS_SINGLE: &str = "One responsive page: the sections only split by device"; -const KEEP_EXCLUDED: &str = "Keep it excluded"; -const BUILD_IT: &str = "Build it"; -const ACCEPT_EXCLUSIONS: &str = "Accept the exclusions"; -const PLAN_ANYWAY: &str = "Plan them: this is what I linked"; -const NOTHING_HERE: &str = "Nothing here is for implementation"; - -/// One route the evidence names, what kind of evidence it is, and where. -#[derive(Debug, Clone)] -struct RouteHit { - route: String, - kind: &'static str, - detail: String, -} - -/// `name-match` is the only kind that is a guess: a layer name compared with -/// a route segment. Every other kind is something the project or the caller -/// already says. -const NAME_MATCH: &str = "name-match"; - -/// Every route the evidence supports for these frames. -fn route_hits( - frames: &[Frame], - words: &[String], - targets: &BTreeMap, - evidence: &ProjectEvidence, - notes: &mut Vec, -) -> Vec { - let mut hits = Vec::new(); - for frame in frames { - if let Some(target) = targets.get(&frame.node_id) { - match normalize_route(target) { - Some(route) => hits.push(RouteHit { - route, - kind: "navigationTargets", - detail: format!("navigationTargets[{}]", frame.node_id), - }), - None => notes.push(format!( - "navigationTargets[{}] = {target:?} is not an app route and was not used.", - frame.node_id - )), - } - } - if let Some((route, file)) = evidence.figma_nodes.get(&frame.node_id) { - hits.push(RouteHit { - route: route.clone(), - kind: "figma-node", - detail: format!("data-figma-node-id=\"{}\" in {file}", frame.node_id), - }); - } - for (alias, route) in &evidence.config.route_aliases { - if alias == &frame.name || compact(alias) == compact(&words.join("")) { - hits.push(RouteHit { - route: route.clone(), - kind: "config", - detail: format!("{CONFIG_FILE} routeAliases[{alias:?}]"), - }); - } - } - } - let name = compact(&words.join("")); - for (label, href, location) in &evidence.nav_links { - if compact(label).is_empty() || compact(label) != name { - continue; - } - if let Some((route, _)) = evidence - .routes - .iter() - .find(|(route, _)| super::navigation::matches_route(route, href)) - { - hits.push(RouteHit { - route: route.clone(), - kind: "nav-label", - detail: format!("nav-label: {label:?} -> {href} ({location})"), - }); - } - } - if hits.is_empty() { - let screen_slug = slug(words); - let is_root = words.iter().any(|word| ROOT_WORDS.contains(&word.as_str())); - for (route, page) in &evidence.routes { - // The last segment only: `/news/[id]` is a detail page, and a - // screen named News is the list, not that page. - let last = route - .trim_matches('/') - .rsplit('/') - .next() - .unwrap_or_default(); - if (!screen_slug.is_empty() && last == screen_slug) || (route == "/" && is_root) { - hits.push(RouteHit { - route: route.clone(), - kind: NAME_MATCH, - detail: format!("layer name matches the existing route {route} ({page})"), - }); - } - } - } - hits -} - -/// The route the hits agree on, if they agree. -fn agreed(hits: &[RouteHit]) -> Option<&RouteHit> { - let distinct = hits - .iter() - .map(|hit| hit.route.as_str()) - .collect::>(); - (distinct.len() == 1).then(|| &hits[0]) -} - -fn existing_page<'a>(route: &str, evidence: &'a ProjectEvidence) -> Option<&'a str> { - evidence - .routes - .iter() - .find(|(candidate, _)| candidate == route) - .map(|(_, page)| page.as_str()) -} - -fn planned_page(route: &str, app_dir: &str) -> String { - let route = route.trim_matches('/'); - if route.is_empty() { - format!("{app_dir}/page.tsx") - } else { - format!("{app_dir}/{route}/page.tsx") - } -} - -/// A condition on another question's answer: this question is asked only -/// when that one resolved to `action`. The caller sees the label. -fn when(question: &str, label: &str, action: Value) -> Value { - json!({"question": question, "answer": label, "action": action}) -} - -const SINGLE: &str = "One responsive page"; -const SEPARATE: &str = "Separate pages"; -const ACCEPT_ALL: &str = "Accept all"; -const REVIEW: &str = "Review individually"; - -/// Builds the draft plan. Pure: every input is passed in, so the rules are -/// tested without Figma or a project on disk. -#[cfg(test)] -pub(super) fn draft_plan( - screens: &[PlanScreenInput], - targets: &BTreeMap, - evidence: &ProjectEvidence, - source: Value, - notes: Option<&str>, -) -> Result { - draft_plan_with(screens, &[], &[], targets, evidence, source, notes) -} - -/// How deep a question sits behind the answers it depends on. A question is -/// asked in a round after every question it depends on. -fn depth(question: &Value, questions: &[Value]) -> usize { - array(&question["when"]) - .iter() - .filter_map(|condition| { - questions - .iter() - .find(|other| other["id"] == condition["question"]) - }) - .map(|parent| depth(parent, questions) + 1) - .max() - .unwrap_or(0) -} - -/// The questions split into rounds a question tool takes in one call each: -/// at most four questions of at most four options, every question exactly -/// once, and a conditional question always in a later round than the one it -/// depends on. -pub(super) fn question_rounds(questions: &[Value], per_round: usize) -> Vec { - let mut by_depth = BTreeMap::>::new(); - for question in questions { - by_depth - .entry(depth(question, questions)) - .or_default() - .push(question); - } - let mut rounds = Vec::new(); - for level in by_depth.values() { - for chunk in level.chunks(per_round.max(1)) { - let shaped = ask_shape_with( - questions, - &chunk - .iter() - .map(|question| (*question).clone()) - .collect::>(), - ); - rounds.push(json!({"round": rounds.len() + 1, "questions": shaped})); - } - } - rounds -} - -/// Every open decision as one table row with the answer the plan recommends, -/// so the user confirms the whole plan at once instead of being interviewed. -pub(crate) fn proposal(questions: &[Value]) -> Vec { - let mut actions = BTreeMap::new(); - let mut ordered = questions.to_vec(); - ordered.sort_by_key(|question| depth(question, questions)); - for question in &ordered { - if required(question, &actions) { - actions.insert(text(&question["id"]).to_owned(), question["resolve"][0]["action"].clone()); - } - } - let active = questions.iter().filter(|question| required(question, &actions)).cloned().collect::>(); - let rows = ask_shape_with(questions, &active) - .iter() - .map(|question| { - let labels = array(&question["options"]) - .iter() - .map(|option| strip_recommended(text(&option["label"])).to_owned()) - .collect::>(); - json!({ - "id": question["id"], - "topic": question["header"], - "decision": question["question"], - "recommended": labels.first(), - "alternatives": labels.get(1..).unwrap_or_default(), - "when": question["when"], - }) - }) - .collect::>(); - let mut grouped: Vec = Vec::new(); - for mut row in rows { - if text(&row["id"]).starts_with("collision:") { - if let Some(existing) = grouped.iter_mut().find(|existing| { - text(&existing["id"]).starts_with("collision:") - && existing["recommended"] == row["recommended"] - && existing["alternatives"] == row["alternatives"] - }) { - existing["questionIds"].as_array_mut().unwrap().push(row["id"].clone()); - existing["screens"].as_array_mut().unwrap().push(row["topic"].clone()); - existing["topic"] = json!(format!("Existing pages: {}", array(&existing["questionIds"]).len())); - existing["decision"] = json!("How should these existing pages be updated? The answer applies to every listed question id."); - continue; - } - row["questionIds"] = json!([row["id"]]); - row["screens"] = json!([row["topic"]]); - } - grouped.push(row); - } - grouped -} - -pub(crate) fn proposal_markdown(rows: &[Value]) -> String { - let cell = |value: &Value| match value { - Value::Null => "-".to_owned(), - Value::String(s) => s.clone(), - Value::Array(items) if items.is_empty() => "-".to_owned(), - Value::Array(items) => items - .iter() - .map(|item| item.as_str().map_or_else(|| item.to_string(), str::to_owned)) - .collect::>() - .join(" / "), - other => other.to_string(), - } - .replace('|', "\\|") - .replace('\n', " "); - let mut out = String::from( - "| # | topic | decision | recommended | alternatives |\n|---|---|---|---|---|\n", - ); - for (index, row) in rows.iter().enumerate() { - out.push_str(&format!( - "| {} | {} | {} | {} | {} |\n", - index + 1, - cell(&row["topic"]), - cell(&row["decision"]), - cell(&row["recommended"]), - cell(&row["alternatives"]), - )); - } - out -} - -fn proposal_answers(questions: &[Value], supplied: &BTreeMap) -> BTreeMap { - let mut answers = supplied.clone(); - for row in proposal(questions) { - if let Some(answer) = supplied.get(text(&row["id"])) { - for id in array(&row["questionIds"]) { - answers.entry(text(id).to_owned()).or_insert_with(|| answer.clone()); - } - } - } - answers -} - -fn plan_path(project_root: &str, relative: &str) -> String { - Path::new(project_root) - .join(relative) - .to_string_lossy() - .into_owned() -} - -/// Writes the plan JSON and its Markdown twin in one transaction, so the two -/// never describe different plans. -fn write_plan( - policy: &OutputPolicy, - project_root: &str, - plan: &Value, -) -> Result { - let json_target = policy.resolve(&plan_path(project_root, PLAN_FILE))?; - let markdown_target = policy.resolve(&plan_path(project_root, PLAN_MARKDOWN))?; - let written = json!([ - json_target.display_path().display().to_string(), - markdown_target.display_path().display().to_string(), - ]); - let bytes = serde_json::to_vec_pretty(plan) - .map_err(|error| invalid(format!("Cannot serialize the screen plan: {error}")))?; - let mut transaction = OutputTransaction::new(); - transaction.stage("screenPlan", json_target, &bytes)?; - transaction.stage( - "screenPlanMarkdown", - markdown_target, - markdown(plan).as_bytes(), - )?; - transaction.commit()?; - Ok(written) -} - -/// Reads `/.devup/screen-plan.json`, or `None` when there is -/// none. A file that exists but is not a plan is an error, never "no plan". -pub(super) fn read_plan(project_root: &str) -> Result, DevupError> { - let path = Path::new(project_root).join(PLAN_FILE); - let Ok(metadata) = std::fs::metadata(&path) else { - return Ok(None); - }; - if metadata.len() > MAX_PLAN_BYTES as u64 { - return Err(invalid(format!( - "{} exceeds the plan size limit.", - path.display() - ))); - } - let bytes = std::fs::read(&path) - .map_err(|error| invalid(format!("Cannot read {}: {error}", path.display())))?; - let plan: Value = serde_json::from_slice(&bytes) - .map_err(|error| invalid(format!("{} is not valid JSON: {error}", path.display())))?; - if plan["kind"] != PLAN_KIND || plan["schemaVersion"] != SCHEMA_VERSION { - return Err(invalid(format!( - "{} is not a devup screen plan; create one with devup_screen_plan action draft.", - path.display() - ))); - } - Ok(Some(plan)) -} - -fn require_root(input: &ScreenPlanInput) -> Result<&str, DevupError> { - input - .project_root - .as_deref() - .filter(|root| !root.trim().is_empty()) - .ok_or_else(|| invalid(format!("action {} needs projectRoot.", input.action))) -} - -/// `draft`: plan the given screens against the project and write both files. -#[cfg(test)] -pub(super) async fn draft( - input: &ScreenPlanInput, - screens: &[PlanScreenInput], - source: Value, - policy: &OutputPolicy, -) -> Result { - draft_with(input, screens, &input.assets, source, policy).await -} - -/// [`draft`] with the Figma asset candidates an explore found beside the -/// screens. -pub(super) async fn draft_with( - input: &ScreenPlanInput, - screens: &[PlanScreenInput], - assets: &[PlanAssetInput], - source: Value, - policy: &OutputPolicy, -) -> Result { - let root = require_root(input)?; - let evidence = project_evidence(root).await?; - let mut plan = draft_plan_with( - screens, - assets, - &input.extra_questions, - &input.navigation_targets, - &evidence, - source, - input.notes.as_deref(), - )?; - let per_round = input - .max_questions_per_round - .unwrap_or(MAX_QUESTIONS_PER_ROUND); - if !(1..=50).contains(&per_round) { - return Err(invalid("maxQuestionsPerRound must be between 1 and 50.")); - } - plan["maxQuestionsPerRound"] = json!(per_round); - plan["createdAt"] = json!(now()); - let previous_plan = read_plan(root).ok().flatten(); - // Earlier ids stay resolvable, so a call that still carries one is told - // which draft superseded it instead of only that it does not match. - if let Some(previous) = &previous_plan { - let mut ids = array(&previous["previousPlanIds"]).to_vec(); - if previous["planId"].is_string() && previous["planId"] != plan["planId"] { - ids.push(previous["planId"].clone()); - } - ids.retain(|id| *id != plan["planId"]); - ids.dedup(); - let keep = ids.len().saturating_sub(20); - plan["previousPlanIds"] = json!(ids[keep..]); - } - let replaced = previous_plan - .filter(|previous| previous["status"] == "confirmed") - .map(|previous| previous["planId"].clone()); - let written = write_plan(policy, root, &plan)?; - let questions = ask_shape(array(&plan["questions"])); - let rounds = question_rounds(array(&plan["questions"]), per_round); - let unverified = array(&plan["screens"]) - .iter() - .filter(|screen| screen["routeEvidence"] == "UNVERIFIED") - .count(); - let proposal_rows = proposal(array(&plan["questions"])); - Ok(json!({ - "status": "draft", - "componentWidthMismatches": plan["componentWidthMismatches"], - "assumptions": plan["assumptions"], - "planId": plan["planId"], - "proposal": proposal_rows, - "proposalMarkdown": proposal_markdown(&proposal_rows), - "conditionalQuestions": questions.iter().filter(|question| !proposal_rows.iter().any(|row| row["id"] == question["id"] || array(&row["questionIds"]).contains(&question["id"]))).collect::>(), - "sourcedDefaults": array(&plan["screens"]).iter().filter(|screen| text(&screen["routeEvidence"]).contains("navigationTargets") || text(&screen["routeEvidence"]).contains("routeAliases")).map(|screen| json!({"screen":screen["id"],"route":screen["proposedRoute"],"evidence":screen["routeEvidence"]})).collect::>(), - "screens": screen_table(&plan), - "questionRounds": rounds, - "questions": questions, - "questionCount": questions.len(), - "askInstruction": ASK_INSTRUCTION, - "maxQuestionsPerRound": per_round, - "site": plan["site"], - "defaults": plan["defaults"], - "heuristics": plan["heuristics"], - "excluded": plan["excluded"], - "outOfScope": plan["outOfScope"], - "frameInventory": plan["frameInventory"], - "linkLabelCheck": plan["linkLabelCheck"], - "config": plan["config"], - "unverifiedRoutes": unverified, - "inventoryNote": plan["inventoryNote"], - "writtenTo": written, - "replacedConfirmedPlan": replaced, - "nextAction": if questions.is_empty() { - json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": {}}, - "how": "Nothing is open. Show the user the screens table, then confirm with empty answers."}) - } else { - json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "acceptRecommended": true, "answers": " chosen label or custom text>"}, - "how": ASK_INSTRUCTION}) - }, - })) -} - -pub(super) fn strip_recommended(label: &str) -> &str { - label - .trim() - .strip_suffix(RECOMMENDED) - .unwrap_or(label.trim()) -} - -/// The action an answer selects, or the custom text when it matches no -/// option. A custom answer to a question that places a screen must itself be -/// a route; anything else is recorded and the recommended action applies. -fn resolve_answer( - question: &Value, - answer: &AnswerInput, -) -> Result<(Value, Option), DevupError> { - let id = text(&question["id"]); - let values = answer.values(); - if question["multiple"] == true { - let labels = values - .iter() - .map(|value| strip_recommended(value)) - .collect::>(); - return Ok((json!({"extra": labels}), None)); - } - let [value] = values.as_slice() else { - return Err(invalid(format!("{id} takes exactly one answer."))); - }; - let label = strip_recommended(value); - if let Some(found) = array(&question["resolve"]) - .iter() - .find(|option| text(&option["label"]) == label) - { - return Ok((found["action"].clone(), None)); - } - let route_of = |value: &str| normalize_route(value).filter(|route| valid_route(route)); - match text(&question["kind"]) { - "route" | "modal" => route_of(label) - .map(|route| (json!({"route": route}), Some(label.to_owned()))) - .ok_or_else(|| { - invalid(format!( - "{id}: a custom answer must be an app route such as /about, not {label:?}." - )) - }), - // One route is one page at that URL; one route per frame, in frame - // order, is separate pages at those URLs. - "group" => { - let routes = label - .split([',', ';', '\n']) - .map(str::trim) - .filter(|part| !part.is_empty()) - .map(|part| route_of(part.rsplit('=').next().unwrap_or(part).trim())) - .collect::>>() - .ok_or_else(|| { - invalid(format!( - "{id}: a custom answer must be one route, or one route per frame such as \"/a, /b\"; not {label:?}." - )) - })?; - match routes.as_slice() { - [route] => Ok(( - json!({"group": "single", "route": route}), - Some(label.to_owned()), - )), - _ => Ok(( - json!({"group": "separate", "routes": routes}), - Some(label.to_owned()), - )), - } - } - "link" => Ok(( - json!({"link": normalize_route(label)}), - Some(label.to_owned()), - )), - "extra" => Ok((json!({"extra": label}), Some(label.to_owned()))), - // A custom site answer is the file, name or URL itself. - "site" => { - let field = text(&question["field"]); - match field { - "favicon" | "ogImage" => Ok(( - json!({field: {"source": "provide", "path": label}}), - Some(label.to_owned()), - )), - "siteName" | "appName" => Ok(( - json!({field: {"source": "answer", "value": label}}), - Some(label.to_owned()), - )), - "app" => Ok(( - json!({field: {"dir": label, "appDir": format!("{}/src/app", label.trim_end_matches('/'))}}), - Some(label.to_owned()), - )), - "siteUrl" if label.starts_with("https://") || label.starts_with("http://") => Ok(( - json!({field: {"source": "answer", "value": label}}), - Some(label.to_owned()), - )), - "siteUrl" => Err(invalid(format!( - "{id}: a custom answer must be an http(s) URL, not {label:?}." - ))), - _ => Ok(( - array(&question["resolve"]) - .first() - .map(|option| option["action"].clone()) - .unwrap_or(Value::Null), - Some(label.to_owned()), - )), - } - } - _ => Ok(( - array(&question["resolve"]) - .first() - .map(|option| option["action"].clone()) - .unwrap_or(Value::Null), - Some(label.to_owned()), - )), - } -} - -fn per_round(plan: &Value) -> usize { - plan["maxQuestionsPerRound"] - .as_u64() - .and_then(|n| usize::try_from(n).ok()) - .unwrap_or(MAX_QUESTIONS_PER_ROUND) -} - -/// Whether a question has to be answered, given the answers before it. -fn required(question: &Value, actions: &BTreeMap) -> bool { - array(&question["when"]) - .iter() - .all(|condition| { - let actual = actions.get(text(&condition["question"])); - actual == Some(&condition["action"]) - || (condition["action"]["structure"] == "placed" - && actual.is_some_and(|action| { - matches!(text(&action["structure"]), "keep" | "states") - })) - }) -} +/// The first exclude keyword in any of `names`, unless an include keyword +/// forces them in. +fn excluded_by<'a>(names: &[(&'a str, &'a str)], config: &PlanConfig) -> Option { + let include = config.include_keywords.as_deref().unwrap_or_default(); + if names + .iter() + .any(|(_, name)| include.iter().any(|keyword| keyword_in(name, keyword))) + { + return None; + } + let defaults = EXCLUDE_WORDS + .iter() + .map(|word| (*word).to_owned()) + .collect::>(); + let keywords = config.exclude_keywords.as_ref().unwrap_or(&defaults); + names.iter().find_map(|(place, name)| { + keywords + .iter() + .find(|keyword| keyword_in(name, keyword)) + .map(|keyword| format!("{place} \"{name}\" contains \"{keyword}\"")) + }) +} + +/// Why a frame is not for implementation, or `None`. The names of the +/// linked node and its ancestors are left out: the user pointed there, which +/// is asked once rather than excluded (see the `scope` question). +fn exclusion_reason( + screen: &PlanScreenInput, + linked: &[String], + config: &PlanConfig, +) -> Option { + if screen.visible == Some(false) { + return Some("hidden in Figma".into()); + } + if screen.opacity.is_some_and(|opacity| opacity <= 0.0) { + return Some("zero opacity in Figma".into()); + } + let mut names = vec![("frame", screen.name.as_str())]; + if let Some(page) = screen.page_name.as_deref() { + names.push(("page", page)); + } + if let Some(section) = screen.section_name.as_deref() { + names.push(("section", section)); + } + for ancestor in screen + .breadcrumb + .split_last() + .map(|(_, ancestors)| ancestors) + .unwrap_or_default() + { + names.push(("parent", ancestor.as_str())); + } + names.retain(|(place, name)| *place == "frame" || !linked.iter().any(|link| link == name)); + let mut seen = BTreeSet::new(); + names.retain(|(_, name)| seen.insert(*name)); + excluded_by(&names, config) +} + +/// One devup_figma_explore candidate node as a screen to plan. Its parent +/// counts as its section only when the breadcrumb shows one between the page +/// and the frame; a frame drawn straight on the page is in no section. +pub(super) fn screen_from_explore(node: &Value) -> Option { + let breadcrumb = array(&node["breadcrumb"]) + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>(); + let parent = node["parentId"].as_str().map(str::to_owned); + let in_section = breadcrumb.len() >= 3; + Some(PlanScreenInput { + node_id: node["nodeId"].as_str()?.to_owned(), + name: text(&node["name"]).to_owned(), + width: node["bounds"]["width"].as_f64()?, + height: node["bounds"]["height"].as_f64(), + section: parent.clone().filter(|_| in_section), + section_name: in_section.then(|| breadcrumb[breadcrumb.len() - 2].clone()), + ancestors: parent.into_iter().collect(), + visible: node["visible"].as_bool(), + opacity: node["opacity"].as_f64(), + page_id: node["pageId"].as_str().map(str::to_owned), + page_name: node["pageName"].as_str().map(str::to_owned).or_else(|| breadcrumb.first().cloned()), + breadcrumb, + navigation_content_width: node["navigationContentWidth"].as_f64(), + ..PlanScreenInput::default() + }) +} + +/// Whether a frame is under the node the user linked. A frame that carries +/// no ancestry at all came from the caller's own list and is taken as given; +/// one whose ancestry is known and does not reach the link is never a +/// candidate. +pub(super) fn in_scope( + screen: &PlanScreenInput, + anchor_id: Option<&str>, + anchor_name: Option<&str>, +) -> bool { + let Some(anchor) = anchor_id else { + return true; + }; + if screen.node_id == anchor + || screen.page_id.as_deref() == Some(anchor) + || screen.section.as_deref() == Some(anchor) + || screen.ancestors.iter().any(|id| id == anchor) + { + return true; + } + let ancestor_names = screen + .breadcrumb + .split_last() + .map(|(_, ancestors)| ancestors) + .unwrap_or_default(); + if anchor_name.is_some_and(|name| ancestor_names.iter().any(|ancestor| ancestor == name)) { + return true; + } + screen.section.is_none() && screen.ancestors.is_empty() && screen.breadcrumb.is_empty() +} + +fn with_when(mut question: Value, conditions: &[Value]) -> Value { + if !conditions.is_empty() { + let mut all = array(&question["when"]).to_vec(); + all.extend(conditions.iter().cloned()); + question["when"] = json!(all); + } + question +} + +const SECTIONS_SEPARATE: &str = "Separate pages: the sections are different pages"; +const SECTIONS_SINGLE: &str = "One responsive page: the sections only split by device"; +const KEEP_EXCLUDED: &str = "Keep it excluded"; +const BUILD_IT: &str = "Build it"; +const ACCEPT_EXCLUSIONS: &str = "Accept the exclusions"; +const PLAN_ANYWAY: &str = "Plan them: this is what I linked"; +const NOTHING_HERE: &str = "Nothing here is for implementation"; + +/// One route the evidence names, what kind of evidence it is, and where. +#[derive(Debug, Clone)] +struct RouteHit { + route: String, + kind: &'static str, + detail: String, +} + +/// `name-match` is the only kind that is a guess: a layer name compared with +/// a route segment. Every other kind is something the project or the caller +/// already says. +const NAME_MATCH: &str = "name-match"; + +/// Every route the evidence supports for these frames. +fn route_hits( + frames: &[Frame], + words: &[String], + targets: &BTreeMap, + evidence: &ProjectEvidence, + notes: &mut Vec, +) -> Vec { + let mut hits = Vec::new(); + for frame in frames { + if let Some(target) = targets.get(&frame.node_id) { + match normalize_route(target) { + Some(route) => hits.push(RouteHit { + route, + kind: "navigationTargets", + detail: format!("navigationTargets[{}]", frame.node_id), + }), + None => notes.push(format!( + "navigationTargets[{}] = {target:?} is not an app route and was not used.", + frame.node_id + )), + } + } + if let Some((route, file)) = evidence.figma_nodes.get(&frame.node_id) { + hits.push(RouteHit { + route: route.clone(), + kind: "figma-node", + detail: format!("data-figma-node-id=\"{}\" in {file}", frame.node_id), + }); + } + for (alias, route) in &evidence.config.route_aliases { + if alias == &frame.name || compact(alias) == compact(&words.join("")) { + hits.push(RouteHit { + route: route.clone(), + kind: "config", + detail: format!("{CONFIG_FILE} routeAliases[{alias:?}]"), + }); + } + } + } + let name = compact(&words.join("")); + for (label, href, location) in &evidence.nav_links { + if compact(label).is_empty() || compact(label) != name { + continue; + } + if let Some((route, _)) = evidence + .routes + .iter() + .find(|(route, _)| super::navigation::matches_route(route, href)) + { + hits.push(RouteHit { + route: route.clone(), + kind: "nav-label", + detail: format!("nav-label: {label:?} -> {href} ({location})"), + }); + } + } + if hits.is_empty() { + let screen_slug = slug(words); + let is_root = words.iter().any(|word| ROOT_WORDS.contains(&word.as_str())); + for (route, page) in &evidence.routes { + // The last segment only: `/news/[id]` is a detail page, and a + // screen named News is the list, not that page. + let last = route + .trim_matches('/') + .rsplit('/') + .next() + .unwrap_or_default(); + if (!screen_slug.is_empty() && last == screen_slug) || (route == "/" && is_root) { + hits.push(RouteHit { + route: route.clone(), + kind: NAME_MATCH, + detail: format!("layer name matches the existing route {route} ({page})"), + }); + } + } + } + hits +} + +/// The route the hits agree on, if they agree. +fn agreed(hits: &[RouteHit]) -> Option<&RouteHit> { + let distinct = hits + .iter() + .map(|hit| hit.route.as_str()) + .collect::>(); + (distinct.len() == 1).then(|| &hits[0]) +} + +fn existing_page<'a>(route: &str, evidence: &'a ProjectEvidence) -> Option<&'a str> { + evidence + .routes + .iter() + .find(|(candidate, _)| candidate == route) + .map(|(_, page)| page.as_str()) +} + +fn planned_page(route: &str, app_dir: &str) -> String { + let route = route.trim_matches('/'); + if route.is_empty() { + format!("{app_dir}/page.tsx") + } else { + format!("{app_dir}/{route}/page.tsx") + } +} + +/// A condition on another question's answer: this question is asked only +/// when that one resolved to `action`. The caller sees the label. +fn when(question: &str, label: &str, action: Value) -> Value { + json!({"question": question, "answer": label, "action": action}) +} + +const SINGLE: &str = "One responsive page"; +const SEPARATE: &str = "Separate pages"; +const ACCEPT_ALL: &str = "Accept all"; +const REVIEW: &str = "Review individually"; + +/// Builds the draft plan. Pure: every input is passed in, so the rules are +/// tested without Figma or a project on disk. +#[cfg(test)] +pub(super) fn draft_plan( + screens: &[PlanScreenInput], + targets: &BTreeMap, + evidence: &ProjectEvidence, + source: Value, + notes: Option<&str>, +) -> Result { + draft_plan_with(screens, &[], &[], targets, evidence, source, notes) +} + +/// How deep a question sits behind the answers it depends on. A question is +/// asked in a round after every question it depends on. +fn depth(question: &Value, questions: &[Value]) -> usize { + array(&question["when"]) + .iter() + .filter_map(|condition| { + questions + .iter() + .find(|other| other["id"] == condition["question"]) + }) + .map(|parent| depth(parent, questions) + 1) + .max() + .unwrap_or(0) +} + +/// The questions split into rounds a question tool takes in one call each: +/// at most four questions of at most four options, every question exactly +/// once, and a conditional question always in a later round than the one it +/// depends on. +pub(super) fn question_rounds(questions: &[Value], per_round: usize) -> Vec { + let mut by_depth = BTreeMap::>::new(); + for question in questions { + by_depth + .entry(depth(question, questions)) + .or_default() + .push(question); + } + let mut rounds = Vec::new(); + for level in by_depth.values() { + for chunk in level.chunks(per_round.max(1)) { + let shaped = ask_shape_with( + questions, + &chunk + .iter() + .map(|question| (*question).clone()) + .collect::>(), + ); + rounds.push(json!({"round": rounds.len() + 1, "questions": shaped})); + } + } + rounds +} + +/// Every open decision as one table row with the answer the plan recommends, +/// so the user confirms the whole plan at once instead of being interviewed. +pub(crate) fn proposal(questions: &[Value]) -> Vec { + let mut actions = BTreeMap::new(); + let mut ordered = questions.to_vec(); + ordered.sort_by_key(|question| depth(question, questions)); + for question in &ordered { + if required(question, &actions) { + actions.insert(text(&question["id"]).to_owned(), question["resolve"][0]["action"].clone()); + } + } + let active = questions.iter().filter(|question| required(question, &actions)).cloned().collect::>(); + let rows = ask_shape_with(questions, &active) + .iter() + .map(|question| { + let labels = array(&question["options"]) + .iter() + .map(|option| strip_recommended(text(&option["label"])).to_owned()) + .collect::>(); + json!({ + "id": question["id"], + "topic": question["header"], + "decision": question["question"], + "recommended": labels.first(), + "alternatives": labels.get(1..).unwrap_or_default(), + "when": question["when"], + }) + }) + .collect::>(); + let mut grouped: Vec = Vec::new(); + for mut row in rows { + if text(&row["id"]).starts_with("collision:") { + if let Some(existing) = grouped.iter_mut().find(|existing| { + text(&existing["id"]).starts_with("collision:") + && existing["recommended"] == row["recommended"] + && existing["alternatives"] == row["alternatives"] + }) { + existing["questionIds"].as_array_mut().unwrap().push(row["id"].clone()); + existing["screens"].as_array_mut().unwrap().push(row["topic"].clone()); + existing["topic"] = json!(format!("Existing pages: {}", array(&existing["questionIds"]).len())); + existing["decision"] = json!("How should these existing pages be updated? The answer applies to every listed question id."); + continue; + } + row["questionIds"] = json!([row["id"]]); + row["screens"] = json!([row["topic"]]); + } + grouped.push(row); + } + grouped +} + +pub(crate) fn proposal_markdown(rows: &[Value]) -> String { + let cell = |value: &Value| match value { + Value::Null => "-".to_owned(), + Value::String(s) => s.clone(), + Value::Array(items) if items.is_empty() => "-".to_owned(), + Value::Array(items) => items + .iter() + .map(|item| item.as_str().map_or_else(|| item.to_string(), str::to_owned)) + .collect::>() + .join(" / "), + other => other.to_string(), + } + .replace('|', "\\|") + .replace('\n', " "); + let mut out = String::from( + "| # | topic | decision | recommended | alternatives |\n|---|---|---|---|---|\n", + ); + for (index, row) in rows.iter().enumerate() { + out.push_str(&format!( + "| {} | {} | {} | {} | {} |\n", + index + 1, + cell(&row["topic"]), + cell(&row["decision"]), + cell(&row["recommended"]), + cell(&row["alternatives"]), + )); + } + out +} + +fn proposal_answers(questions: &[Value], supplied: &BTreeMap) -> BTreeMap { + let mut answers = supplied.clone(); + for row in proposal(questions) { + if let Some(answer) = supplied.get(text(&row["id"])) { + for id in array(&row["questionIds"]) { + answers.entry(text(id).to_owned()).or_insert_with(|| answer.clone()); + } + } + } + answers +} + +fn plan_path(project_root: &str, relative: &str) -> String { + Path::new(project_root) + .join(relative) + .to_string_lossy() + .into_owned() +} + +/// Writes the plan JSON and its Markdown twin in one transaction, so the two +/// never describe different plans. +fn write_plan( + policy: &OutputPolicy, + project_root: &str, + plan: &Value, +) -> Result { + let json_target = policy.resolve(&plan_path(project_root, PLAN_FILE))?; + let markdown_target = policy.resolve(&plan_path(project_root, PLAN_MARKDOWN))?; + let written = json!([ + json_target.display_path().display().to_string(), + markdown_target.display_path().display().to_string(), + ]); + let bytes = serde_json::to_vec_pretty(plan) + .map_err(|error| invalid(format!("Cannot serialize the screen plan: {error}")))?; + let mut transaction = OutputTransaction::new(); + transaction.stage("screenPlan", json_target, &bytes)?; + transaction.stage( + "screenPlanMarkdown", + markdown_target, + markdown(plan).as_bytes(), + )?; + transaction.commit()?; + Ok(written) +} + +/// Reads `/.devup/screen-plan.json`, or `None` when there is +/// none. A file that exists but is not a plan is an error, never "no plan". +pub(super) fn read_plan(project_root: &str) -> Result, DevupError> { + let path = Path::new(project_root).join(PLAN_FILE); + let Ok(metadata) = std::fs::metadata(&path) else { + return Ok(None); + }; + if metadata.len() > MAX_PLAN_BYTES as u64 { + return Err(invalid(format!( + "{} exceeds the plan size limit.", + path.display() + ))); + } + let bytes = std::fs::read(&path) + .map_err(|error| invalid(format!("Cannot read {}: {error}", path.display())))?; + let plan: Value = serde_json::from_slice(&bytes) + .map_err(|error| invalid(format!("{} is not valid JSON: {error}", path.display())))?; + if plan["kind"] != PLAN_KIND || plan["schemaVersion"] != SCHEMA_VERSION { + return Err(invalid(format!( + "{} is not a devup screen plan; create one with devup_screen_plan action draft.", + path.display() + ))); + } + Ok(Some(plan)) +} + +fn require_root(input: &ScreenPlanInput) -> Result<&str, DevupError> { + input + .project_root + .as_deref() + .filter(|root| !root.trim().is_empty()) + .ok_or_else(|| invalid(format!("action {} needs projectRoot.", input.action))) +} + +/// `draft`: plan the given screens against the project and write both files. +#[cfg(test)] +pub(super) async fn draft( + input: &ScreenPlanInput, + screens: &[PlanScreenInput], + source: Value, + policy: &OutputPolicy, +) -> Result { + draft_with(input, screens, &input.assets, source, policy).await +} + +/// [`draft`] with the Figma asset candidates an explore found beside the +/// screens. +pub(super) async fn draft_with( + input: &ScreenPlanInput, + screens: &[PlanScreenInput], + assets: &[PlanAssetInput], + source: Value, + policy: &OutputPolicy, +) -> Result { + let root = require_root(input)?; + let evidence = project_evidence(root).await?; + let mut plan = draft_plan_with( + screens, + assets, + &input.extra_questions, + &input.navigation_targets, + &evidence, + source, + input.notes.as_deref(), + )?; + let per_round = input + .max_questions_per_round + .unwrap_or(MAX_QUESTIONS_PER_ROUND); + if !(1..=50).contains(&per_round) { + return Err(invalid("maxQuestionsPerRound must be between 1 and 50.")); + } + plan["maxQuestionsPerRound"] = json!(per_round); + plan["createdAt"] = json!(now()); + let previous_plan = read_plan(root).ok().flatten(); + // Earlier ids stay resolvable, so a call that still carries one is told + // which draft superseded it instead of only that it does not match. + if let Some(previous) = &previous_plan { + let mut ids = array(&previous["previousPlanIds"]).to_vec(); + if previous["planId"].is_string() && previous["planId"] != plan["planId"] { + ids.push(previous["planId"].clone()); + } + ids.retain(|id| *id != plan["planId"]); + ids.dedup(); + let keep = ids.len().saturating_sub(20); + plan["previousPlanIds"] = json!(ids[keep..]); + } + let replaced = previous_plan + .filter(|previous| previous["status"] == "confirmed") + .map(|previous| previous["planId"].clone()); + let written = write_plan(policy, root, &plan)?; + let questions = ask_shape(array(&plan["questions"])); + let rounds = question_rounds(array(&plan["questions"]), per_round); + let unverified = array(&plan["screens"]) + .iter() + .filter(|screen| screen["routeEvidence"] == "UNVERIFIED") + .count(); + let proposal_rows = proposal(array(&plan["questions"])); + Ok(json!({ + "status": "draft", + "componentWidthMismatches": plan["componentWidthMismatches"], + "assumptions": plan["assumptions"], + "planId": plan["planId"], + "proposal": proposal_rows, + "proposalMarkdown": proposal_markdown(&proposal_rows), + "conditionalQuestions": questions.iter().filter(|question| !proposal_rows.iter().any(|row| row["id"] == question["id"] || array(&row["questionIds"]).contains(&question["id"]))).collect::>(), + "sourcedDefaults": array(&plan["screens"]).iter().filter(|screen| text(&screen["routeEvidence"]).contains("navigationTargets") || text(&screen["routeEvidence"]).contains("routeAliases")).map(|screen| json!({"screen":screen["id"],"route":screen["proposedRoute"],"evidence":screen["routeEvidence"]})).collect::>(), + "screens": screen_table(&plan), + "questionRounds": rounds, + "questions": questions, + "questionCount": questions.len(), + "askInstruction": ASK_INSTRUCTION, + "maxQuestionsPerRound": per_round, + "site": plan["site"], + "defaults": plan["defaults"], + "heuristics": plan["heuristics"], + "excluded": plan["excluded"], + "outOfScope": plan["outOfScope"], + "frameInventory": plan["frameInventory"], + "linkLabelCheck": plan["linkLabelCheck"], + "config": plan["config"], + "unverifiedRoutes": unverified, + "inventoryNote": plan["inventoryNote"], + "writtenTo": written, + "replacedConfirmedPlan": replaced, + "nextAction": if questions.is_empty() { + json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "answers": {}}, + "how": "Nothing is open. Show the user the screens table, then confirm with empty answers."}) + } else { + json!({"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"], "acceptRecommended": true, "answers": " chosen label or custom text>"}, + "how": ASK_INSTRUCTION}) + }, + })) +} + +pub(super) fn strip_recommended(label: &str) -> &str { + label + .trim() + .strip_suffix(RECOMMENDED) + .unwrap_or(label.trim()) +} + +/// The action an answer selects, or the custom text when it matches no +/// option. A custom answer to a question that places a screen must itself be +/// a route; anything else is recorded and the recommended action applies. +fn resolve_answer( + question: &Value, + answer: &AnswerInput, +) -> Result<(Value, Option), DevupError> { + let id = text(&question["id"]); + let values = answer.values(); + if question["multiple"] == true { + let labels = values + .iter() + .map(|value| strip_recommended(value)) + .collect::>(); + return Ok((json!({"extra": labels}), None)); + } + let [value] = values.as_slice() else { + return Err(invalid(format!("{id} takes exactly one answer."))); + }; + let label = strip_recommended(value); + if let Some(found) = array(&question["resolve"]) + .iter() + .find(|option| text(&option["label"]) == label) + { + return Ok((found["action"].clone(), None)); + } + let route_of = |value: &str| normalize_route(value).filter(|route| valid_route(route)); + match text(&question["kind"]) { + "route" | "modal" => route_of(label) + .map(|route| (json!({"route": route}), Some(label.to_owned()))) + .ok_or_else(|| { + invalid(format!( + "{id}: a custom answer must be an app route such as /about, not {label:?}." + )) + }), + // One route is one page at that URL; one route per frame, in frame + // order, is separate pages at those URLs. + "group" => { + let routes = label + .split([',', ';', '\n']) + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| route_of(part.rsplit('=').next().unwrap_or(part).trim())) + .collect::>>() + .ok_or_else(|| { + invalid(format!( + "{id}: a custom answer must be one route, or one route per frame such as \"/a, /b\"; not {label:?}." + )) + })?; + match routes.as_slice() { + [route] => Ok(( + json!({"group": "single", "route": route}), + Some(label.to_owned()), + )), + _ => Ok(( + json!({"group": "separate", "routes": routes}), + Some(label.to_owned()), + )), + } + } + "link" => Ok(( + json!({"link": normalize_route(label)}), + Some(label.to_owned()), + )), + "extra" => Ok((json!({"extra": label}), Some(label.to_owned()))), + // A custom site answer is the file, name or URL itself. + "site" => { + let field = text(&question["field"]); + match field { + "favicon" | "ogImage" => Ok(( + json!({field: {"source": "provide", "path": label}}), + Some(label.to_owned()), + )), + "siteName" | "appName" => Ok(( + json!({field: {"source": "answer", "value": label}}), + Some(label.to_owned()), + )), + "app" => Ok(( + json!({field: {"dir": label, "appDir": format!("{}/src/app", label.trim_end_matches('/'))}}), + Some(label.to_owned()), + )), + "siteUrl" if label.starts_with("https://") || label.starts_with("http://") => Ok(( + json!({field: {"source": "answer", "value": label}}), + Some(label.to_owned()), + )), + "siteUrl" => Err(invalid(format!( + "{id}: a custom answer must be an http(s) URL, not {label:?}." + ))), + _ => Ok(( + array(&question["resolve"]) + .first() + .map(|option| option["action"].clone()) + .unwrap_or(Value::Null), + Some(label.to_owned()), + )), + } + } + _ => Ok(( + array(&question["resolve"]) + .first() + .map(|option| option["action"].clone()) + .unwrap_or(Value::Null), + Some(label.to_owned()), + )), + } +} + +fn per_round(plan: &Value) -> usize { + plan["maxQuestionsPerRound"] + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(MAX_QUESTIONS_PER_ROUND) +} + +/// Whether a question has to be answered, given the answers before it. +fn required(question: &Value, actions: &BTreeMap) -> bool { + array(&question["when"]) + .iter() + .all(|condition| { + let actual = actions.get(text(&condition["question"])); + actual == Some(&condition["action"]) + || (condition["action"]["structure"] == "placed" + && actual.is_some_and(|action| { + matches!(text(&action["structure"]), "keep" | "states") + })) + }) +} diff --git a/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs b/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs index 0dd07990..2142257e 100644 --- a/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs +++ b/crates/devup-mcp/src/server/screen_plan/plan_route_group.rs @@ -1,690 +1,690 @@ -type RouteDecision = (Vec, Option<(String, &'static str)>); -type RouteChoiceFn<'a> = dyn Fn(&ProjectEvidence, &str, &str, Value) -> Choice + 'a; -type UnclaimedRoutesFn<'a> = - dyn Fn(&ProjectEvidence, &[String], &BTreeSet) -> Vec<(usize, String)> + 'a; - -struct RouteGroupPlanning<'a, 'e> { - evidence_for: &'a (dyn Fn(&[Frame]) -> &'e ProjectEvidence + 'a), - is_modal: &'a (dyn Fn(&str) -> bool + 'a), - route_choice: &'a RouteChoiceFn<'a>, - unclaimed_for: &'a UnclaimedRoutesFn<'a>, - decided: &'a [RouteDecision], - claimed: &'a BTreeSet, - drafts: &'a [DraftScreen], - targets: &'a BTreeMap, - screen_apps: &'a BTreeMap, - kind_evidence: &'a str, - page_screens: &'a [usize], - frame_owner: &'a BTreeMap, - screen_notes: &'a [Vec], - questions: &'a mut Vec, - rows: &'a mut Vec, - heuristics: &'a mut Vec, - part_screens: &'a mut BTreeMap>, -} - -fn plan_route_group(context: RouteGroupPlanning<'_, '_>, index: usize, screen: &DraftScreen) { - let RouteGroupPlanning { - evidence_for, - is_modal, - route_choice, - unclaimed_for, - decided, - claimed, - drafts, - targets, - screen_apps, - kind_evidence, - page_screens, - frame_owner, - screen_notes, - questions, - rows, - heuristics, - part_screens, - } = context; - - let evidence = evidence_for(&screen.frames); - let (hits, proposed) = &decided[index]; - let modal = is_modal(screen.name()); - let id = screen.id().to_owned(); - let name = screen.name().to_owned(); - let widths = screen.widths(); - let mut decisions = Vec::new(); - let candidates = hits - .iter() - .map(|hit| hit.route.clone()) - .collect::>(); - let screen_slug = slug(&screen.words); - let new_route = (!screen_slug.is_empty()).then(|| format!("/{screen_slug}")); - // Questions about this screen as one page apply only if the group is - // one page; when that is still being asked, they carry the condition. - let group_id = screen.grouping_question().unwrap_or_default(); - let (single_label, separate_label) = match screen.grouping { - Grouping::Sections { .. } => (SECTIONS_SINGLE, SECTIONS_SEPARATE), - _ => (SINGLE, SEPARATE), - }; - let mut single_when = screen.extra_when.clone(); - if !matches!(screen.grouping, Grouping::Fixed(_)) { - single_when.push(when(&group_id, single_label, json!({"group": "single"}))); - } - let push = |questions: &mut Vec, mut question: Value, extra: &[Value]| { - let conditions = single_when.iter().chain(extra).cloned().collect::>(); - if !conditions.is_empty() { - question["when"] = json!(conditions); - } - questions.push(question); - }; - let grouping_question = match &screen.grouping { - Grouping::Fixed(_) => None, - Grouping::Ask { - recommend_single, - reason, - } => { - let single = choice( - SINGLE, - "One URL; the widths become devup-ui responsive arrays (responsiveTsx).", - json!({"group": "single"}), - ); - let separate = choice( - SEPARATE, - "Each frame is its own page and gets its own route, asked next.", - json!({"group": "separate"}), - ); - Some(question( - group_id.clone(), - header("Pages", &name), - format!( - "These frames share the name \"{name}\" and are {reason}: {}. One responsive page, or separate pages? A custom answer may list one route per frame, e.g. \"/a, /b\".", - describe_frames(&screen.frames) - ), - if *recommend_single { - vec![single, separate] - } else { - vec![separate, single] - }, - "group", - )) - } - Grouping::Sections { - all_device, - sections, - why, - } => { - let separate = choice( - SECTIONS_SEPARATE, - "Each frame is its own page; its URL is asked next.", - json!({"group": "separate"}), - ); - let single = choice( - SECTIONS_SINGLE, - if *all_device { - format!( - "Inferred from the section names ({}), which name only devices.", - sections.join(", ") - ) - } else { - "The sections would only split one page by device.".to_owned() - }, - json!({"group": "single"}), - ); - let fix = choice( - FIX_FIGMA, - "These frames are not built. They are listed under Design fixes requested; everything else goes on.", - json!({"designFix": true}), - ); - let mut asked = question( - group_id.clone(), - header("Sections", &name), - format!( - include_str!("../text/screen_plan-draft_plan_with-140b58b5.md"), - sections.join(", "), - describe_frames(&screen.frames), - why = why - ), - if *all_device { - vec![single, separate, fix] - } else { - vec![separate, single, fix] - }, - "group", - ); - asked["fix"] = json!({ - "frames": screen.frames.iter().map(Frame::json).collect::>(), - "text": format!( - "Frames {} share the name \"{name}\" but sit in sections {}. Move them into one section if they are one page, or rename them if they are different pages.", - describe_frames(&screen.frames), - sections.join(", ") - ), - }); - Some(asked) - } - }; - if let Some(asked) = grouping_question { - decisions.push("one-page-or-separate"); - questions.push(with_when(asked, &screen.extra_when)); - for frame in &screen.frames { - let mut frame_notes = Vec::new(); - let frame_hits = route_hits( - std::slice::from_ref(frame), - &screen.words, - targets, - evidence, - &mut frame_notes, - ); - let mut choices = Vec::new(); - for hit in frame_hits.iter().filter(|hit| hit.kind != NAME_MATCH) { - choices.push(route_choice( - evidence, - &hit.route, - &format!("From {}.", hit.detail), - json!({}), - )); - } - for hit in frame_hits.iter().filter(|hit| hit.kind == NAME_MATCH) { - choices.push(route_choice( - evidence, - &hit.route, - "Inferred from the layer name; only one frame can take it.", - json!({}), - )); - } - let section_slug = frame - .section_name - .as_deref() - .map(|section| slug(&base_words(section))) - .unwrap_or_default(); - if !section_slug.is_empty() && !screen_slug.is_empty() { - choices.push(route_choice( - evidence, - &format!("/{section_slug}/{screen_slug}"), - "Named after its Figma section and layer.", - json!({}), - )); - } - for (_, route) in unclaimed_for(evidence, &screen.words, &candidates) - .iter() - .filter(|(score, _)| *score > 0) - { - choices.push(route_choice( - evidence, - route, - "Existing route with a similar name.", - json!({}), - )); - } - choices.truncate(MAX_OPTIONS - 1); - choices.push(choice( - SKIP, - "Leave this frame out of this build.", - json!({"skip": true}), - )); - if choices.len() < 2 { - choices.push(choice( - NOT_A_PAGE, - "It is a section or state of another screen; it gets no URL.", - json!({"notPage": true}), - )); - } - let asked = question( - format!("split-route:{}", frame.node_id), - header("Route", &format!("{} {}", frame.name, frame.width.round())), - format!( - "As a separate page, which URL should \"{}\" ({}, {}px) be served at?", - frame.name, - frame.node_id, - frame.width.round() - ), - choices, - "route", - ); - let separate_when = [ - screen.extra_when.clone(), - vec![when( - &group_id, - separate_label, - json!({"group": "separate"}), - )], - ] - .concat(); - questions.push(with_when(asked, &separate_when)); - } - } - let route_evidence = match proposed { - Some((_, kind)) if *kind == NAME_MATCH => json!(NAME_MATCH), - Some(_) => json!( - hits.iter() - .map(|hit| hit.detail.clone()) - .collect::>() - .join("; ") - ), - None => json!("UNVERIFIED"), - }; - if modal { - decisions.push("modal-or-route"); - let mut hosts = page_screens - .iter() - .filter(|other| **other != index) - .map(|other| &drafts[*other]) - .map(|host| { - let shared = host - .words - .iter() - .filter(|word| screen.words.contains(word)) - .count(); - (shared, host) - }) - .collect::>(); - hosts.sort_by_key(|host| std::cmp::Reverse(host.0)); - let state_choices = hosts - .iter() - .take(2) - .map(|(_, host)| { - choice( - format!("State on {}", truncate(host.name(), 40)), - format!( - "Open/close state inside the page built from {} ({}); no URL of its own.", - host.name(), - host.id() - ), - json!({"state": host.id()}), - ) - }) - .collect::>(); - let own_route = proposed - .as_ref() - .map(|(route, _)| route_choice(evidence, route, "A page with its own URL.", json!({}))) - .or_else(|| { - new_route.as_ref().map(|route| { - route_choice( - evidence, - route, - "A page with its own URL, named after the layer.", - json!({}), - ) - }) - }); - let mut choices = Vec::new(); - if proposed - .as_ref() - .is_some_and(|(_, kind)| *kind != NAME_MATCH) - { - choices.extend(own_route); - choices.extend(state_choices); - } else { - choices.extend(state_choices); - choices.extend(own_route); - } - choices.push(choice( - SKIP, - "Leave it out of this build.", - json!({"skip": true}), - )); - let why = if kind_evidence == NAME_MATCH { - "Its layer name suggests a modal or overlay (name-match)" - } else { - "Its layer name matches the project's modalKeywords" - }; - push( - questions, - question( - format!("modal:{id}"), - header("Modal", &name), - format!( - "\"{name}\" ({id}): {why}. Is it a state of another screen, or a page with its own URL?" - ), - choices, - "modal", - ), - &[], - ); - if kind_evidence == NAME_MATCH { - heuristics.push(json!({"screen": id, "name": name, "decision": "kind: modal", "evidence": NAME_MATCH, "askedIn": format!("modal:{id}")})); - } - } else if let Some((route, NAME_MATCH)) = proposed { - decisions.push("route-from-name-match"); - heuristics.push(json!({"screen": id, "name": name, "decision": format!("route: {route}"), "evidence": NAME_MATCH, "askedIn": format!("review:{id}")})); - let mut choices = vec![ - route_choice( - evidence, - route, - "Keep the inferred route.", - json!({"collision": "merge"}), - ), - choice( - format!("Overwrite {route}"), - "Keep the inferred route, but replace the existing page file outright.", - json!({"route": route, "collision": "overwrite"}), - ), - ]; - if let Some(alternative) = new_route - .as_ref() - .filter(|candidate| *candidate != route && !claimed.contains(*candidate)) - { - choices.push(route_choice( - evidence, - alternative, - "Named after the layer.", - json!({}), - )); - } - choices.push(choice( - SKIP, - "Leave it out of this build.", - json!({"skip": true}), - )); - push( - questions, - question( - format!("review:{id}"), - header("Check route", &name), - format!( - "\"{name}\" ({id}) was matched to {route} by its layer name only. Keep it?" - ), - choices, - "route", - ), - &[when("heuristics", REVIEW, json!({"heuristics": "review"}))], - ); - } else if proposed.is_none() { - decisions.push(if candidates.len() > 1 { - "conflicting-route-evidence" - } else { - "no-route-evidence" - }); - let mut choices = Vec::new(); - for route in &candidates { - choices.push(route_choice( - evidence, - route, - "Named by conflicting evidence.", - json!({}), - )); - } - let unclaimed = unclaimed_for(evidence, &screen.words, &candidates); - for (_, route) in unclaimed.iter().filter(|(score, _)| *score > 0) { - choices.push(route_choice( - evidence, - route, - "Existing route with a similar name.", - json!({}), - )); - } - if let Some(route) = new_route.as_ref().filter(|route| !claimed.contains(*route)) { - choices.push(route_choice( - evidence, - route, - "Named after the layer.", - json!({}), - )); - } - for (_, route) in unclaimed.iter().filter(|(score, _)| *score == 0).take(1) { - choices.push(route_choice( - evidence, - route, - "Existing route not claimed by another screen.", - json!({}), - )); - } - // A state lives on a page no more specific than itself, and the - // (word count, index) order is strict, so two screens can never be - // offered as each other's host. - let state_host = page_screens - .iter() - .filter(|other| **other != index) - .filter(|other| (drafts[**other].words.len(), **other) < (screen.words.len(), index)) - .map(|other| &drafts[*other]) - .map(|host| { - let shared = host - .words - .iter() - .filter(|word| screen.words.contains(word)) - .count(); - (shared, host) - }) - .filter(|(shared, _)| *shared > 0) - .max_by_key(|(shared, _)| *shared) - .map(|(_, host)| host); - choices.truncate(MAX_OPTIONS - 1 - usize::from(state_host.is_some())); - if let Some(host) = state_host { - choices.push(choice( - format!("State on {}", truncate(host.name(), 40)), - format!( - "A different state of the page built from {} ({}); no URL of its own.", - host.name(), - host.id() - ), - json!({"state": host.id()}), - )); - } - choices.push(choice( - SKIP, - "Leave it out of this build.", - json!({"skip": true}), - )); - if choices.len() < 2 { - choices.push(choice( - NOT_A_PAGE, - "It is a section or state of another screen; it gets no URL.", - json!({"notPage": true}), - )); - } - let reason = if candidates.len() > 1 { - format!( - "The evidence names {} different routes.", - candidates.iter().cloned().collect::>().join(", ") - ) - } else if let Some(route) = candidates.iter().next() { - format!( - "The evidence names {route}, but another screen in this plan names it too, so neither keeps it unasked." - ) - } else { - "No existing page, nav link, navigationTargets entry or routeAliases entry names it." - .to_owned() - }; - push( - questions, - question( - format!("route:{id}"), - header("Route", &name), - format!( - "Which URL should the Figma screen \"{name}\" ({id}, drawn at {}) be served at? {reason}", - width_list(&widths) - ), - choices, - "route", - ), - &[], - ); - } - let route = proposed.as_ref().map(|(route, _)| route.as_str()); - let existing = route.and_then(|route| existing_page(route, evidence)); - let name_matched = proposed - .as_ref() - .is_some_and(|(_, kind)| *kind == NAME_MATCH); - if !modal - && !name_matched - && let (Some(route), Some(page)) = (route, existing) - { - decisions.push("collides-with-existing-page"); - push( - questions, - question( - format!("collision:{id}"), - header("Existing page", route), - format!( - "{route} already has a page ({page}). What should building \"{name}\" ({id}) do to it?" - ), - vec![ - choice( - "Merge into the existing page", - "Keep the page file and its data wiring; replace its markup with the screen.", - json!({"collision": "merge"}), - ), - choice( - "Overwrite the existing page", - "Replace the page file outright; existing wiring is lost.", - json!({"collision": "overwrite"}), - ), - choice( - SKIP, - "Leave the existing page as it is.", - json!({"collision": "skip"}), - ), - ], - "collision", - ), - &[], - ); - } - if let Some(route) = route.filter(|route| route.contains('[')) { - decisions.push("dynamic-segment"); - let mut choices = vec![ - choice("Sample data from the design", "Render the Figma content as fixture data for one id; wire real data later.", json!({"data":"sample"})), - choice("Wire to the API now", "Fetch the record by the segment value; needs the endpoint from devup_project_context scope api.", json!({"data":"api"})), - ]; - if project_api_screen(evidence, &name, route) { - choices.swap(0, 1); - } - push( - questions, - question( - format!("dynamic:{id}"), - header("Dynamic route", route), - format!( - "{route} has a dynamic segment. Where does \"{name}\" get the record it shows?" - ), - choices, - "dynamic", - ), - &[], - ); - } - if widths.len() > 1 { - decisions.push("several-widths"); - } - let mut links = Vec::new(); - for (link_index, link) in screen.links.iter().enumerate() { - let resolved = link - .url - .as_deref() - .map(|url| json!({"url": url, "evidence": "figma-url"})) - .or_else(|| { - let inferred = if link.destination_node_id.is_none() { - let owners = drafts - .iter() - .enumerate() - .filter(|(_, draft)| { - screen_apps.get(draft.id()).map(|app| &app.dir) - == screen_apps.get(screen.id()).map(|app| &app.dir) - && draft.frames.iter().any(|frame| { - label_names_frame(&link.label, &frame.name) - }) - }) - .map(|(index, _)| index) - .collect::>(); - (owners.len() == 1).then(|| owners[0]) - } else { - None - }; - let destination = link - .destination_node_id - .as_deref() - .or_else(|| inferred.map(|owner| drafts[owner].id()))?; - frame_owner.get(destination).map(|owner| { - json!({"screen": drafts[*owner].id(), - "nodeId": link.destination_node_id, - "evidence": if inferred.is_some() { "label matches a frame in this plan" } else { "destination frame in this plan" }}) - }).or_else(|| { - targets.get(destination).map(|url| { - json!({"url": url, "evidence": format!("navigationTargets[{destination}]")}) - }) - }) - }); - if resolved.is_none() { - decisions.push("unresolved-link"); - let mut choices = vec![choice( - "Leave unlinked", - "Render it without an href; wire it once the destination exists.", - json!({"link": null}), - )]; - for (route, _) in evidence.routes.iter().take(MAX_OPTIONS - 1) { - choices.push(choice( - route.as_str(), - format!("Link to the existing route {route}."), - json!({"link": route}), - )); - } - if choices.len() < 2 { - choices.push(choice( - "Remove the link", - "Render the label as plain text.", - json!({"link": "remove"}), - )); - } - questions.push(with_when(question( - format!("link:{id}:{link_index}"), - header("Link", &link.label), - format!( - "\"{}\" on \"{name}\" ({id}) links somewhere this plan cannot see{}. Where should it go?", - link.label, - link.destination_node_id - .as_deref() - .map(|node| format!(" (Figma destination {node})")) - .unwrap_or_default() - ), - choices, - "link", - ), &screen.extra_when)); - } - links.push(json!({"label": link.label, "destinationNodeId": link.destination_node_id, "resolved": resolved})); - } - let mut shared = evidence.layout_parts.clone(); - for part in &screen.parts { - part_screens - .entry(part.clone()) - .or_default() - .push(id.clone()); - if !shared.contains(part) { - shared.push(part.clone()); - } - } - rows.push(json!({ - "id": id, - "name": name, - "kind": if modal { "modal" } else { "page" }, - "kindEvidence": if modal { json!(kind_evidence) } else { Value::Null }, - "frames": screen.frames.iter().map(Frame::json).collect::>(), - "drawnWidths": widths, - "app": screen_apps.get(screen.id()).map(|app| app.dir.clone()), - "appDir": screen_apps.get(screen.id()).map(|app| app.path(app.app_router.as_deref().unwrap_or("src/app"))), - "groupingQuestion": screen.grouping_question(), - "structureQuestions": screen.structure, - "grouping": match &screen.grouping { - Grouping::Fixed(evidence) => json!({"mode": "decided", "evidence": evidence}), - Grouping::Sections { all_device, sections, why } => json!({"mode": "ask-sections", "question": group_id, - "sections": sections, "allDeviceNames": all_device, "why": why}), - Grouping::Ask { recommend_single, reason } => json!({"mode": "ask", "question": group_id, - "recommended": if *recommend_single { "single" } else { "separate" }, "reason": reason}), - }, - "proposedRoute": route, - "routeEvidence": route_evidence, - "routeCandidates": candidates, - "existingFile": existing, - "sharedParts": shared, - "links": links, - "openDecisions": decisions, - "notes": screen_notes[index], -})); -} - -fn project_api_screen(evidence: &ProjectEvidence, name: &str, route: &str) -> bool { - let Some(source) = read_small(&evidence.root.join(".devup/project-plan.json")) else { return false; }; - let Ok(plan) = serde_json::from_str::(&source) else { return false; }; - array(&plan["features"]).iter().any(|feature| { - let scope = text(&feature["scope"]).to_lowercase(); - scope.split(|character: char| !character.is_alphanumeric()).any(|word| word == "api") - && (array(&feature["screens"]).iter().any(|screen| text(screen) == name || text(screen) == route) - || route.split('/').any(|segment| segment == text(&feature["id"]))) - }) -} +type RouteDecision = (Vec, Option<(String, &'static str)>); +type RouteChoiceFn<'a> = dyn Fn(&ProjectEvidence, &str, &str, Value) -> Choice + 'a; +type UnclaimedRoutesFn<'a> = + dyn Fn(&ProjectEvidence, &[String], &BTreeSet) -> Vec<(usize, String)> + 'a; + +struct RouteGroupPlanning<'a, 'e> { + evidence_for: &'a (dyn Fn(&[Frame]) -> &'e ProjectEvidence + 'a), + is_modal: &'a (dyn Fn(&str) -> bool + 'a), + route_choice: &'a RouteChoiceFn<'a>, + unclaimed_for: &'a UnclaimedRoutesFn<'a>, + decided: &'a [RouteDecision], + claimed: &'a BTreeSet, + drafts: &'a [DraftScreen], + targets: &'a BTreeMap, + screen_apps: &'a BTreeMap, + kind_evidence: &'a str, + page_screens: &'a [usize], + frame_owner: &'a BTreeMap, + screen_notes: &'a [Vec], + questions: &'a mut Vec, + rows: &'a mut Vec, + heuristics: &'a mut Vec, + part_screens: &'a mut BTreeMap>, +} + +fn plan_route_group(context: RouteGroupPlanning<'_, '_>, index: usize, screen: &DraftScreen) { + let RouteGroupPlanning { + evidence_for, + is_modal, + route_choice, + unclaimed_for, + decided, + claimed, + drafts, + targets, + screen_apps, + kind_evidence, + page_screens, + frame_owner, + screen_notes, + questions, + rows, + heuristics, + part_screens, + } = context; + + let evidence = evidence_for(&screen.frames); + let (hits, proposed) = &decided[index]; + let modal = is_modal(screen.name()); + let id = screen.id().to_owned(); + let name = screen.name().to_owned(); + let widths = screen.widths(); + let mut decisions = Vec::new(); + let candidates = hits + .iter() + .map(|hit| hit.route.clone()) + .collect::>(); + let screen_slug = slug(&screen.words); + let new_route = (!screen_slug.is_empty()).then(|| format!("/{screen_slug}")); + // Questions about this screen as one page apply only if the group is + // one page; when that is still being asked, they carry the condition. + let group_id = screen.grouping_question().unwrap_or_default(); + let (single_label, separate_label) = match screen.grouping { + Grouping::Sections { .. } => (SECTIONS_SINGLE, SECTIONS_SEPARATE), + _ => (SINGLE, SEPARATE), + }; + let mut single_when = screen.extra_when.clone(); + if !matches!(screen.grouping, Grouping::Fixed(_)) { + single_when.push(when(&group_id, single_label, json!({"group": "single"}))); + } + let push = |questions: &mut Vec, mut question: Value, extra: &[Value]| { + let conditions = single_when.iter().chain(extra).cloned().collect::>(); + if !conditions.is_empty() { + question["when"] = json!(conditions); + } + questions.push(question); + }; + let grouping_question = match &screen.grouping { + Grouping::Fixed(_) => None, + Grouping::Ask { + recommend_single, + reason, + } => { + let single = choice( + SINGLE, + "One URL; the widths become devup-ui responsive arrays (responsiveTsx).", + json!({"group": "single"}), + ); + let separate = choice( + SEPARATE, + "Each frame is its own page and gets its own route, asked next.", + json!({"group": "separate"}), + ); + Some(question( + group_id.clone(), + header("Pages", &name), + format!( + "These frames share the name \"{name}\" and are {reason}: {}. One responsive page, or separate pages? A custom answer may list one route per frame, e.g. \"/a, /b\".", + describe_frames(&screen.frames) + ), + if *recommend_single { + vec![single, separate] + } else { + vec![separate, single] + }, + "group", + )) + } + Grouping::Sections { + all_device, + sections, + why, + } => { + let separate = choice( + SECTIONS_SEPARATE, + "Each frame is its own page; its URL is asked next.", + json!({"group": "separate"}), + ); + let single = choice( + SECTIONS_SINGLE, + if *all_device { + format!( + "Inferred from the section names ({}), which name only devices.", + sections.join(", ") + ) + } else { + "The sections would only split one page by device.".to_owned() + }, + json!({"group": "single"}), + ); + let fix = choice( + FIX_FIGMA, + "These frames are not built. They are listed under Design fixes requested; everything else goes on.", + json!({"designFix": true}), + ); + let mut asked = question( + group_id.clone(), + header("Sections", &name), + format!( + include_str!("../text/screen_plan-draft_plan_with-140b58b5.md"), + sections.join(", "), + describe_frames(&screen.frames), + why = why + ), + if *all_device { + vec![single, separate, fix] + } else { + vec![separate, single, fix] + }, + "group", + ); + asked["fix"] = json!({ + "frames": screen.frames.iter().map(Frame::json).collect::>(), + "text": format!( + "Frames {} share the name \"{name}\" but sit in sections {}. Move them into one section if they are one page, or rename them if they are different pages.", + describe_frames(&screen.frames), + sections.join(", ") + ), + }); + Some(asked) + } + }; + if let Some(asked) = grouping_question { + decisions.push("one-page-or-separate"); + questions.push(with_when(asked, &screen.extra_when)); + for frame in &screen.frames { + let mut frame_notes = Vec::new(); + let frame_hits = route_hits( + std::slice::from_ref(frame), + &screen.words, + targets, + evidence, + &mut frame_notes, + ); + let mut choices = Vec::new(); + for hit in frame_hits.iter().filter(|hit| hit.kind != NAME_MATCH) { + choices.push(route_choice( + evidence, + &hit.route, + &format!("From {}.", hit.detail), + json!({}), + )); + } + for hit in frame_hits.iter().filter(|hit| hit.kind == NAME_MATCH) { + choices.push(route_choice( + evidence, + &hit.route, + "Inferred from the layer name; only one frame can take it.", + json!({}), + )); + } + let section_slug = frame + .section_name + .as_deref() + .map(|section| slug(&base_words(section))) + .unwrap_or_default(); + if !section_slug.is_empty() && !screen_slug.is_empty() { + choices.push(route_choice( + evidence, + &format!("/{section_slug}/{screen_slug}"), + "Named after its Figma section and layer.", + json!({}), + )); + } + for (_, route) in unclaimed_for(evidence, &screen.words, &candidates) + .iter() + .filter(|(score, _)| *score > 0) + { + choices.push(route_choice( + evidence, + route, + "Existing route with a similar name.", + json!({}), + )); + } + choices.truncate(MAX_OPTIONS - 1); + choices.push(choice( + SKIP, + "Leave this frame out of this build.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let asked = question( + format!("split-route:{}", frame.node_id), + header("Route", &format!("{} {}", frame.name, frame.width.round())), + format!( + "As a separate page, which URL should \"{}\" ({}, {}px) be served at?", + frame.name, + frame.node_id, + frame.width.round() + ), + choices, + "route", + ); + let separate_when = [ + screen.extra_when.clone(), + vec![when( + &group_id, + separate_label, + json!({"group": "separate"}), + )], + ] + .concat(); + questions.push(with_when(asked, &separate_when)); + } + } + let route_evidence = match proposed { + Some((_, kind)) if *kind == NAME_MATCH => json!(NAME_MATCH), + Some(_) => json!( + hits.iter() + .map(|hit| hit.detail.clone()) + .collect::>() + .join("; ") + ), + None => json!("UNVERIFIED"), + }; + if modal { + decisions.push("modal-or-route"); + let mut hosts = page_screens + .iter() + .filter(|other| **other != index) + .map(|other| &drafts[*other]) + .map(|host| { + let shared = host + .words + .iter() + .filter(|word| screen.words.contains(word)) + .count(); + (shared, host) + }) + .collect::>(); + hosts.sort_by_key(|host| std::cmp::Reverse(host.0)); + let state_choices = hosts + .iter() + .take(2) + .map(|(_, host)| { + choice( + format!("State on {}", truncate(host.name(), 40)), + format!( + "Open/close state inside the page built from {} ({}); no URL of its own.", + host.name(), + host.id() + ), + json!({"state": host.id()}), + ) + }) + .collect::>(); + let own_route = proposed + .as_ref() + .map(|(route, _)| route_choice(evidence, route, "A page with its own URL.", json!({}))) + .or_else(|| { + new_route.as_ref().map(|route| { + route_choice( + evidence, + route, + "A page with its own URL, named after the layer.", + json!({}), + ) + }) + }); + let mut choices = Vec::new(); + if proposed + .as_ref() + .is_some_and(|(_, kind)| *kind != NAME_MATCH) + { + choices.extend(own_route); + choices.extend(state_choices); + } else { + choices.extend(state_choices); + choices.extend(own_route); + } + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + let why = if kind_evidence == NAME_MATCH { + "Its layer name suggests a modal or overlay (name-match)" + } else { + "Its layer name matches the project's modalKeywords" + }; + push( + questions, + question( + format!("modal:{id}"), + header("Modal", &name), + format!( + "\"{name}\" ({id}): {why}. Is it a state of another screen, or a page with its own URL?" + ), + choices, + "modal", + ), + &[], + ); + if kind_evidence == NAME_MATCH { + heuristics.push(json!({"screen": id, "name": name, "decision": "kind: modal", "evidence": NAME_MATCH, "askedIn": format!("modal:{id}")})); + } + } else if let Some((route, NAME_MATCH)) = proposed { + decisions.push("route-from-name-match"); + heuristics.push(json!({"screen": id, "name": name, "decision": format!("route: {route}"), "evidence": NAME_MATCH, "askedIn": format!("review:{id}")})); + let mut choices = vec![ + route_choice( + evidence, + route, + "Keep the inferred route.", + json!({"collision": "merge"}), + ), + choice( + format!("Overwrite {route}"), + "Keep the inferred route, but replace the existing page file outright.", + json!({"route": route, "collision": "overwrite"}), + ), + ]; + if let Some(alternative) = new_route + .as_ref() + .filter(|candidate| *candidate != route && !claimed.contains(*candidate)) + { + choices.push(route_choice( + evidence, + alternative, + "Named after the layer.", + json!({}), + )); + } + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + push( + questions, + question( + format!("review:{id}"), + header("Check route", &name), + format!( + "\"{name}\" ({id}) was matched to {route} by its layer name only. Keep it?" + ), + choices, + "route", + ), + &[when("heuristics", REVIEW, json!({"heuristics": "review"}))], + ); + } else if proposed.is_none() { + decisions.push(if candidates.len() > 1 { + "conflicting-route-evidence" + } else { + "no-route-evidence" + }); + let mut choices = Vec::new(); + for route in &candidates { + choices.push(route_choice( + evidence, + route, + "Named by conflicting evidence.", + json!({}), + )); + } + let unclaimed = unclaimed_for(evidence, &screen.words, &candidates); + for (_, route) in unclaimed.iter().filter(|(score, _)| *score > 0) { + choices.push(route_choice( + evidence, + route, + "Existing route with a similar name.", + json!({}), + )); + } + if let Some(route) = new_route.as_ref().filter(|route| !claimed.contains(*route)) { + choices.push(route_choice( + evidence, + route, + "Named after the layer.", + json!({}), + )); + } + for (_, route) in unclaimed.iter().filter(|(score, _)| *score == 0).take(1) { + choices.push(route_choice( + evidence, + route, + "Existing route not claimed by another screen.", + json!({}), + )); + } + // A state lives on a page no more specific than itself, and the + // (word count, index) order is strict, so two screens can never be + // offered as each other's host. + let state_host = page_screens + .iter() + .filter(|other| **other != index) + .filter(|other| (drafts[**other].words.len(), **other) < (screen.words.len(), index)) + .map(|other| &drafts[*other]) + .map(|host| { + let shared = host + .words + .iter() + .filter(|word| screen.words.contains(word)) + .count(); + (shared, host) + }) + .filter(|(shared, _)| *shared > 0) + .max_by_key(|(shared, _)| *shared) + .map(|(_, host)| host); + choices.truncate(MAX_OPTIONS - 1 - usize::from(state_host.is_some())); + if let Some(host) = state_host { + choices.push(choice( + format!("State on {}", truncate(host.name(), 40)), + format!( + "A different state of the page built from {} ({}); no URL of its own.", + host.name(), + host.id() + ), + json!({"state": host.id()}), + )); + } + choices.push(choice( + SKIP, + "Leave it out of this build.", + json!({"skip": true}), + )); + if choices.len() < 2 { + choices.push(choice( + NOT_A_PAGE, + "It is a section or state of another screen; it gets no URL.", + json!({"notPage": true}), + )); + } + let reason = if candidates.len() > 1 { + format!( + "The evidence names {} different routes.", + candidates.iter().cloned().collect::>().join(", ") + ) + } else if let Some(route) = candidates.iter().next() { + format!( + "The evidence names {route}, but another screen in this plan names it too, so neither keeps it unasked." + ) + } else { + "No existing page, nav link, navigationTargets entry or routeAliases entry names it." + .to_owned() + }; + push( + questions, + question( + format!("route:{id}"), + header("Route", &name), + format!( + "Which URL should the Figma screen \"{name}\" ({id}, drawn at {}) be served at? {reason}", + width_list(&widths) + ), + choices, + "route", + ), + &[], + ); + } + let route = proposed.as_ref().map(|(route, _)| route.as_str()); + let existing = route.and_then(|route| existing_page(route, evidence)); + let name_matched = proposed + .as_ref() + .is_some_and(|(_, kind)| *kind == NAME_MATCH); + if !modal + && !name_matched + && let (Some(route), Some(page)) = (route, existing) + { + decisions.push("collides-with-existing-page"); + push( + questions, + question( + format!("collision:{id}"), + header("Existing page", route), + format!( + "{route} already has a page ({page}). What should building \"{name}\" ({id}) do to it?" + ), + vec![ + choice( + "Merge into the existing page", + "Keep the page file and its data wiring; replace its markup with the screen.", + json!({"collision": "merge"}), + ), + choice( + "Overwrite the existing page", + "Replace the page file outright; existing wiring is lost.", + json!({"collision": "overwrite"}), + ), + choice( + SKIP, + "Leave the existing page as it is.", + json!({"collision": "skip"}), + ), + ], + "collision", + ), + &[], + ); + } + if let Some(route) = route.filter(|route| route.contains('[')) { + decisions.push("dynamic-segment"); + let mut choices = vec![ + choice("Sample data from the design", "Render the Figma content as fixture data for one id; wire real data later.", json!({"data":"sample"})), + choice("Wire to the API now", "Fetch the record by the segment value; needs the endpoint from devup_project_context scope api.", json!({"data":"api"})), + ]; + if project_api_screen(evidence, &name, route) { + choices.swap(0, 1); + } + push( + questions, + question( + format!("dynamic:{id}"), + header("Dynamic route", route), + format!( + "{route} has a dynamic segment. Where does \"{name}\" get the record it shows?" + ), + choices, + "dynamic", + ), + &[], + ); + } + if widths.len() > 1 { + decisions.push("several-widths"); + } + let mut links = Vec::new(); + for (link_index, link) in screen.links.iter().enumerate() { + let resolved = link + .url + .as_deref() + .map(|url| json!({"url": url, "evidence": "figma-url"})) + .or_else(|| { + let inferred = if link.destination_node_id.is_none() { + let owners = drafts + .iter() + .enumerate() + .filter(|(_, draft)| { + screen_apps.get(draft.id()).map(|app| &app.dir) + == screen_apps.get(screen.id()).map(|app| &app.dir) + && draft.frames.iter().any(|frame| { + label_names_frame(&link.label, &frame.name) + }) + }) + .map(|(index, _)| index) + .collect::>(); + (owners.len() == 1).then(|| owners[0]) + } else { + None + }; + let destination = link + .destination_node_id + .as_deref() + .or_else(|| inferred.map(|owner| drafts[owner].id()))?; + frame_owner.get(destination).map(|owner| { + json!({"screen": drafts[*owner].id(), + "nodeId": link.destination_node_id, + "evidence": if inferred.is_some() { "label matches a frame in this plan" } else { "destination frame in this plan" }}) + }).or_else(|| { + targets.get(destination).map(|url| { + json!({"url": url, "evidence": format!("navigationTargets[{destination}]")}) + }) + }) + }); + if resolved.is_none() { + decisions.push("unresolved-link"); + let mut choices = vec![choice( + "Leave unlinked", + "Render it without an href; wire it once the destination exists.", + json!({"link": null}), + )]; + for (route, _) in evidence.routes.iter().take(MAX_OPTIONS - 1) { + choices.push(choice( + route.as_str(), + format!("Link to the existing route {route}."), + json!({"link": route}), + )); + } + if choices.len() < 2 { + choices.push(choice( + "Remove the link", + "Render the label as plain text.", + json!({"link": "remove"}), + )); + } + questions.push(with_when(question( + format!("link:{id}:{link_index}"), + header("Link", &link.label), + format!( + "\"{}\" on \"{name}\" ({id}) links somewhere this plan cannot see{}. Where should it go?", + link.label, + link.destination_node_id + .as_deref() + .map(|node| format!(" (Figma destination {node})")) + .unwrap_or_default() + ), + choices, + "link", + ), &screen.extra_when)); + } + links.push(json!({"label": link.label, "destinationNodeId": link.destination_node_id, "resolved": resolved})); + } + let mut shared = evidence.layout_parts.clone(); + for part in &screen.parts { + part_screens + .entry(part.clone()) + .or_default() + .push(id.clone()); + if !shared.contains(part) { + shared.push(part.clone()); + } + } + rows.push(json!({ + "id": id, + "name": name, + "kind": if modal { "modal" } else { "page" }, + "kindEvidence": if modal { json!(kind_evidence) } else { Value::Null }, + "frames": screen.frames.iter().map(Frame::json).collect::>(), + "drawnWidths": widths, + "app": screen_apps.get(screen.id()).map(|app| app.dir.clone()), + "appDir": screen_apps.get(screen.id()).map(|app| app.path(app.app_router.as_deref().unwrap_or("src/app"))), + "groupingQuestion": screen.grouping_question(), + "structureQuestions": screen.structure, + "grouping": match &screen.grouping { + Grouping::Fixed(evidence) => json!({"mode": "decided", "evidence": evidence}), + Grouping::Sections { all_device, sections, why } => json!({"mode": "ask-sections", "question": group_id, + "sections": sections, "allDeviceNames": all_device, "why": why}), + Grouping::Ask { recommend_single, reason } => json!({"mode": "ask", "question": group_id, + "recommended": if *recommend_single { "single" } else { "separate" }, "reason": reason}), + }, + "proposedRoute": route, + "routeEvidence": route_evidence, + "routeCandidates": candidates, + "existingFile": existing, + "sharedParts": shared, + "links": links, + "openDecisions": decisions, + "notes": screen_notes[index], +})); +} + +fn project_api_screen(evidence: &ProjectEvidence, name: &str, route: &str) -> bool { + let Some(source) = read_small(&evidence.root.join(".devup/project-plan.json")) else { return false; }; + let Ok(plan) = serde_json::from_str::(&source) else { return false; }; + array(&plan["features"]).iter().any(|feature| { + let scope = text(&feature["scope"]).to_lowercase(); + scope.split(|character: char| !character.is_alphanumeric()).any(|word| word == "api") + && (array(&feature["screens"]).iter().any(|screen| text(screen) == name || text(screen) == route) + || route.split('/').any(|segment| segment == text(&feature["id"]))) + }) +} diff --git a/crates/devup-mcp/src/server/screen_plan/status_action.rs b/crates/devup-mcp/src/server/screen_plan/status_action.rs index 5ea7b5ce..316d004d 100644 --- a/crates/devup-mcp/src/server/screen_plan/status_action.rs +++ b/crates/devup-mcp/src/server/screen_plan/status_action.rs @@ -1,120 +1,120 @@ -fn worst(a: &str, b: &str) -> &'static str { - let rank = |status: &str| match status { - "fail" => 3, - "inconclusive" => 2, - "not-run" => 1, - _ => 0, - }; - match rank(a).max(rank(b)) { - 3 => "fail", - 2 => "inconclusive", - 1 => "not-run", - _ => "pass", - } -} - -/// `status`: the confirmed plan read against the routes on disk and, when -/// given, a runtime audit. A width without an audit item is `not-run`. -pub(super) async fn status( - input: &ScreenPlanInput, - policy: &OutputPolicy, - artifacts: &ArtifactStore, -) -> Result { - let (plan_root, plan) = audit_plan_location(input)?; - let root = plan_root.to_str().ok_or_else(|| invalid("The plan root is not UTF-8."))?; - if plan["status"] != "confirmed" { - return Ok(json!({ - "status": "not-confirmed", - "componentWidthMismatches": plan["componentWidthMismatches"], - "assumptions": plan["assumptions"], - "planId": plan["planId"], - "questionRounds": question_rounds(array(&plan["questions"]), per_round(&plan)), - "questions": ask_shape(array(&plan["questions"])), - "askInstruction": ASK_INSTRUCTION, - "nextAction": {"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"]}, "how": ASK_INSTRUCTION}, - })); - } - let mut plan = plan; - let audit = match ( - &input.audit_plan_path, - &input.audit_report_path, - &input.tooling_unavailable, - ) { - (Some(plan_path), Some(report_path), None) => Some(( - super::runtime_audit::ingest_with_artifacts(policy, plan_path, report_path, artifacts) - .await?, - sha256(&policy.resolve(report_path)?.read_bounded(8 * 1024 * 1024)?), - )), - (Some(plan_path), None, Some(reason)) => { - let bytes = policy.resolve(plan_path)?.read_bounded(MAX_PLAN_BYTES)?; - let audit_plan: Value = serde_json::from_slice(&bytes) - .map_err(|_| invalid("auditPlanPath is not a runtime audit plan."))?; - for screen in array(&audit_plan["plan"]["screens"]) { - if let Some(route) = screen["routePath"].as_str() { - plan["audit"]["inconclusive"][route] = json!(reason); - } - } - write_plan(policy, root, &plan)?; - None - } - (None, None, None) => None, - _ => { - return Err(invalid( - "status takes auditPlanPath with auditReportPath, or auditPlanPath with toolingUnavailable, or neither.", - )); - } - }; - let evidence = project_evidence(root).await?; - let mut audit = audit; - if let Some((audit, _)) = &mut audit { - super::audit_plan::classify_geometry(audit, root, &evidence.sources, policy); - } - let round = match &audit { - Some((audit, report_sha)) if audit["verdict"] != "rejected" => { - let routes = array(&audit["screenTable"]) - .iter() - .filter_map(|row| row["routePath"].as_str()) - .map(str::to_owned) - .collect::>(); - if let Some(inconclusive) = plan["audit"]["inconclusive"].as_object_mut() { - inconclusive.retain(|route, _| !routes.contains(route)); - } - // The links each page holds, for auditPlan's next pass over the - // dynamic routes. - for (route, hrefs) in audit["links"].as_object().into_iter().flatten() { - plan["audit"]["links"][route] = hrefs.clone(); - } - let mut link_table = array(&plan["audit"]["linkTable"]).iter() - .filter(|row| !row["from"].as_str().and_then(|from| url::Url::parse(from).ok()).and_then(|url| normalize_route(url.path())).is_some_and(|route| routes.contains(&route))) - .cloned().collect::>(); - link_table.extend(array(&audit["linkTable"]).iter().cloned()); - plan["audit"]["linkTable"] = json!(link_table); - let max = plan["audit"]["maxFixAttempts"] - .as_u64() - .and_then(|max| u32::try_from(max).ok()) - .or(evidence.config.max_fix_attempts) - .unwrap_or(3); - let round = super::audit_plan::record_round( - &mut plan, - audit, - report_sha, - &input.visual_results, - &evidence.figma_nodes, - max, - ); - write_plan(policy, root, &plan)?; - Some(round) - } - _ => None, - }; - let mut report = status_report(&plan, &evidence, audit.as_ref().map(|(audit, _)| audit)); - if let Some((audit, _)) = &audit { - report["visualComparisons"] = audit["visualComparisons"].clone(); - report["missingNodes"] = audit["missingNodes"].clone(); - } - if let Some(round) = round { - report["pendingComparisons"] = round["pendingComparisons"].clone(); - report["inconclusiveComparisons"] = round["inconclusiveComparisons"].clone(); - } - Ok(report) -} +fn worst(a: &str, b: &str) -> &'static str { + let rank = |status: &str| match status { + "fail" => 3, + "inconclusive" => 2, + "not-run" => 1, + _ => 0, + }; + match rank(a).max(rank(b)) { + 3 => "fail", + 2 => "inconclusive", + 1 => "not-run", + _ => "pass", + } +} + +/// `status`: the confirmed plan read against the routes on disk and, when +/// given, a runtime audit. A width without an audit item is `not-run`. +pub(super) async fn status( + input: &ScreenPlanInput, + policy: &OutputPolicy, + artifacts: &ArtifactStore, +) -> Result { + let (plan_root, plan) = audit_plan_location(input)?; + let root = plan_root.to_str().ok_or_else(|| invalid("The plan root is not UTF-8."))?; + if plan["status"] != "confirmed" { + return Ok(json!({ + "status": "not-confirmed", + "componentWidthMismatches": plan["componentWidthMismatches"], + "assumptions": plan["assumptions"], + "planId": plan["planId"], + "questionRounds": question_rounds(array(&plan["questions"]), per_round(&plan)), + "questions": ask_shape(array(&plan["questions"])), + "askInstruction": ASK_INSTRUCTION, + "nextAction": {"tool": "devup_screen_plan", "arguments": {"action": "confirm", "projectRoot": root, "planId": plan["planId"]}, "how": ASK_INSTRUCTION}, + })); + } + let mut plan = plan; + let audit = match ( + &input.audit_plan_path, + &input.audit_report_path, + &input.tooling_unavailable, + ) { + (Some(plan_path), Some(report_path), None) => Some(( + super::runtime_audit::ingest_with_artifacts(policy, plan_path, report_path, artifacts) + .await?, + sha256(&policy.resolve(report_path)?.read_bounded(8 * 1024 * 1024)?), + )), + (Some(plan_path), None, Some(reason)) => { + let bytes = policy.resolve(plan_path)?.read_bounded(MAX_PLAN_BYTES)?; + let audit_plan: Value = serde_json::from_slice(&bytes) + .map_err(|_| invalid("auditPlanPath is not a runtime audit plan."))?; + for screen in array(&audit_plan["plan"]["screens"]) { + if let Some(route) = screen["routePath"].as_str() { + plan["audit"]["inconclusive"][route] = json!(reason); + } + } + write_plan(policy, root, &plan)?; + None + } + (None, None, None) => None, + _ => { + return Err(invalid( + "status takes auditPlanPath with auditReportPath, or auditPlanPath with toolingUnavailable, or neither.", + )); + } + }; + let evidence = project_evidence(root).await?; + let mut audit = audit; + if let Some((audit, _)) = &mut audit { + super::audit_plan::classify_geometry(audit, root, &evidence.sources, policy); + } + let round = match &audit { + Some((audit, report_sha)) if audit["verdict"] != "rejected" => { + let routes = array(&audit["screenTable"]) + .iter() + .filter_map(|row| row["routePath"].as_str()) + .map(str::to_owned) + .collect::>(); + if let Some(inconclusive) = plan["audit"]["inconclusive"].as_object_mut() { + inconclusive.retain(|route, _| !routes.contains(route)); + } + // The links each page holds, for auditPlan's next pass over the + // dynamic routes. + for (route, hrefs) in audit["links"].as_object().into_iter().flatten() { + plan["audit"]["links"][route] = hrefs.clone(); + } + let mut link_table = array(&plan["audit"]["linkTable"]).iter() + .filter(|row| !row["from"].as_str().and_then(|from| url::Url::parse(from).ok()).and_then(|url| normalize_route(url.path())).is_some_and(|route| routes.contains(&route))) + .cloned().collect::>(); + link_table.extend(array(&audit["linkTable"]).iter().cloned()); + plan["audit"]["linkTable"] = json!(link_table); + let max = plan["audit"]["maxFixAttempts"] + .as_u64() + .and_then(|max| u32::try_from(max).ok()) + .or(evidence.config.max_fix_attempts) + .unwrap_or(3); + let round = super::audit_plan::record_round( + &mut plan, + audit, + report_sha, + &input.visual_results, + &evidence.figma_nodes, + max, + ); + write_plan(policy, root, &plan)?; + Some(round) + } + _ => None, + }; + let mut report = status_report(&plan, &evidence, audit.as_ref().map(|(audit, _)| audit)); + if let Some((audit, _)) = &audit { + report["visualComparisons"] = audit["visualComparisons"].clone(); + report["missingNodes"] = audit["missingNodes"].clone(); + } + if let Some(round) = round { + report["pendingComparisons"] = round["pendingComparisons"].clone(); + report["inconclusiveComparisons"] = round["inconclusiveComparisons"].clone(); + } + Ok(report) +} diff --git a/crates/devup-mcp/src/server/screen_plan_tests.rs b/crates/devup-mcp/src/server/screen_plan_tests.rs index 37bb5003..56693d60 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests.rs @@ -1,13 +1,13 @@ - -// Source sections share this module to preserve privacy and test names. + +// Source sections share this module to preserve privacy and test names. include!("screen_plan_tests/fixtures_and_cases.rs"); -include!("screen_plan_tests/confirm_recovery.rs"); -include!("screen_plan_tests/a_custom_group_answer_can_name_one_route_per_frame.rs"); -include!("screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs"); -include!("screen_plan_tests/frame_inventory_and_link_labels.rs"); - -include!("screen_plan_tests/prevention.rs"); -include!("screen_plan_tests/monorepo_root_plan.rs"); -include!("screen_plan_tests/feedback_remaining.rs"); -include!("screen_plan_tests/component_width.rs"); -include!("screen_plan_tests/responsive_contract.rs"); +include!("screen_plan_tests/confirm_recovery.rs"); +include!("screen_plan_tests/a_custom_group_answer_can_name_one_route_per_frame.rs"); +include!("screen_plan_tests/a_service_template_project_is_vinext_with_view_transitions_enabled.rs"); +include!("screen_plan_tests/frame_inventory_and_link_labels.rs"); + +include!("screen_plan_tests/prevention.rs"); +include!("screen_plan_tests/monorepo_root_plan.rs"); +include!("screen_plan_tests/feedback_remaining.rs"); +include!("screen_plan_tests/component_width.rs"); +include!("screen_plan_tests/responsive_contract.rs"); diff --git a/crates/devup-mcp/src/server/screen_plan_tests/confirm_recovery.rs b/crates/devup-mcp/src/server/screen_plan_tests/confirm_recovery.rs index 9db8bdc2..0556f036 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests/confirm_recovery.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests/confirm_recovery.rs @@ -1,51 +1,51 @@ -#[tokio::test] -async fn a_superseded_plan_id_names_the_current_plan_and_the_next_call() { - let fixture = Fixture::new(); - let old = drafted(&fixture, &[screen("1:2", "Pricing", 1440.0)]).await; - let current = drafted(&fixture, &[screen("1:2", "Pricing", 1440.0), screen("1:3", "Careers", 1440.0)]).await; - assert_ne!(old.plan_id, current.plan_id); - let saved = read_plan(fixture.root()).unwrap().unwrap(); - assert_eq!(saved["previousPlanIds"], json!([old.plan_id])); - let mut stale = old; - stale.accept_recommended = true; - let error = confirm(&stale, &fixture.policy()).unwrap_err(); - assert!(error.message.contains("superseded"), "{}", error.message); - assert_eq!(error.details["supersededBy"], json!(current.plan_id)); - assert_eq!(error.details["existingPlans"][0]["status"], "draft"); - assert_eq!(error.details["nextAction"]["arguments"]["action"], "confirm"); - assert_eq!(error.details["nextAction"]["arguments"]["planId"], json!(current.plan_id)); -} - -#[tokio::test] -async fn confirming_a_confirmed_plan_again_changes_nothing() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &main_widths()).await; - confirm_input.accept_recommended = true; - confirm(&confirm_input, &fixture.policy()).unwrap(); - let before = std::fs::read_to_string(fixture.0.join(PLAN_FILE)).unwrap(); - let again = confirm(&confirm_input, &fixture.policy()).unwrap(); - assert_eq!(again["alreadyConfirmed"], true); - assert_eq!(again["routes"][0]["url"], "/"); - assert_eq!(again["nextAction"]["arguments"]["action"], "status"); - assert_eq!(std::fs::read_to_string(fixture.0.join(PLAN_FILE)).unwrap(), before); -} - -#[test] -fn a_state_host_is_never_more_specific_than_its_state() { - let plan = draft_plan( - &[screen("3:1", "Pricing", 1440.0), screen("3:2", "Pricing detail", 1440.0)], - &BTreeMap::new(), - &evidence(), - json!({}), - None, - ) - .unwrap(); - let labels = |id: &str| { - array(&find(&plan, id)["options"]) - .iter() - .map(|option| text(&option["label"]).to_owned()) - .collect::>() - }; - assert!(labels("route:3:2").iter().any(|label| label.starts_with("State on Pricing")), "{:?}", labels("route:3:2")); - assert!(!labels("route:3:1").iter().any(|label| label.starts_with("State on")), "{:?}", labels("route:3:1")); -} +#[tokio::test] +async fn a_superseded_plan_id_names_the_current_plan_and_the_next_call() { + let fixture = Fixture::new(); + let old = drafted(&fixture, &[screen("1:2", "Pricing", 1440.0)]).await; + let current = drafted(&fixture, &[screen("1:2", "Pricing", 1440.0), screen("1:3", "Careers", 1440.0)]).await; + assert_ne!(old.plan_id, current.plan_id); + let saved = read_plan(fixture.root()).unwrap().unwrap(); + assert_eq!(saved["previousPlanIds"], json!([old.plan_id])); + let mut stale = old; + stale.accept_recommended = true; + let error = confirm(&stale, &fixture.policy()).unwrap_err(); + assert!(error.message.contains("superseded"), "{}", error.message); + assert_eq!(error.details["supersededBy"], json!(current.plan_id)); + assert_eq!(error.details["existingPlans"][0]["status"], "draft"); + assert_eq!(error.details["nextAction"]["arguments"]["action"], "confirm"); + assert_eq!(error.details["nextAction"]["arguments"]["planId"], json!(current.plan_id)); +} + +#[tokio::test] +async fn confirming_a_confirmed_plan_again_changes_nothing() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.accept_recommended = true; + confirm(&confirm_input, &fixture.policy()).unwrap(); + let before = std::fs::read_to_string(fixture.0.join(PLAN_FILE)).unwrap(); + let again = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!(again["alreadyConfirmed"], true); + assert_eq!(again["routes"][0]["url"], "/"); + assert_eq!(again["nextAction"]["arguments"]["action"], "status"); + assert_eq!(std::fs::read_to_string(fixture.0.join(PLAN_FILE)).unwrap(), before); +} + +#[test] +fn a_state_host_is_never_more_specific_than_its_state() { + let plan = draft_plan( + &[screen("3:1", "Pricing", 1440.0), screen("3:2", "Pricing detail", 1440.0)], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let labels = |id: &str| { + array(&find(&plan, id)["options"]) + .iter() + .map(|option| text(&option["label"]).to_owned()) + .collect::>() + }; + assert!(labels("route:3:2").iter().any(|label| label.starts_with("State on Pricing")), "{:?}", labels("route:3:2")); + assert!(!labels("route:3:1").iter().any(|label| label.starts_with("State on")), "{:?}", labels("route:3:1")); +} diff --git a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs index dedffbc5..fe290a84 100644 --- a/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs +++ b/crates/devup-mcp/src/server/screen_plan_tests/fixtures_and_cases.rs @@ -1,988 +1,988 @@ -use super::*; -use serde_json::json; -use std::sync::atomic::{AtomicUsize, Ordering}; - -struct Fixture(std::path::PathBuf); - -impl Fixture { - fn new() -> Self { - static NEXT: AtomicUsize = AtomicUsize::new(0); - let root = std::env::temp_dir().join(format!( - "screen-plan-{}-{}", - std::process::id(), - NEXT.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir_all(&root).unwrap(); - let fixture = Self(dunce::canonicalize(&root).unwrap()); - fixture.write("package.json", "{}"); - fixture.write(CONFIG_FILE, SITE_CONFIG); - fixture.write( - "src/app/layout.tsx", - "import { Header } from '../components/Header';\nexport default function RootLayout({ children }) { return
{children}; }", - ); - fixture.write( - "src/components/Header.tsx", - "import Link from 'next/link';\nexport function Header() { return ; }", - ); - fixture.write( - "src/app/page.tsx", - "export default function Page() { return
; }", - ); - fixture.write( - "src/app/company/page.tsx", - "export default function Page() { return
; }", - ); - fixture - } - - fn write(&self, path: &str, text: &str) { - let target = self.0.join(path); - std::fs::create_dir_all(target.parent().unwrap()).unwrap(); - std::fs::write(target, text).unwrap(); - } - - fn root(&self) -> &str { - self.0.to_str().unwrap() - } - - fn policy(&self) -> OutputPolicy { - OutputPolicy::from_roots(vec![self.0.clone()]).unwrap() - } -} - -impl Drop for Fixture { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn screen(node_id: &str, name: &str, width: f64) -> PlanScreenInput { - PlanScreenInput { - node_id: node_id.into(), - name: name.into(), - width, - height: Some(900.0), - ..PlanScreenInput::default() - } -} - -fn evidence() -> ProjectEvidence { - ProjectEvidence { - routes: vec![ - ("/".into(), "src/app/page.tsx".into()), - ("/company".into(), "src/app/company/page.tsx".into()), - ("/news/[id]".into(), "src/app/news/[id]/page.tsx".into()), - ], - nav_links: vec![( - "회사소개".into(), - "/company".into(), - "src/components/Header.tsx:2".into(), - )], - layout_parts: vec!["Header".into()], - app_dir: "src/app".into(), - // The site questions are pre-answered here so each test below is - // about its own questions; `site_*` tests start from bare evidence. - config: answered_site(), - ..ProjectEvidence::default() - } -} - -fn answered_site() -> PlanConfig { - PlanConfig { - favicon: Some("app/icon.svg".into()), - og_image: Some("public/og.png".into()), - site_name: Some("Prime".into()), - site_url: Some("https://example.com".into()), - motion: Some("none".into()), - ..PlanConfig::default() - } -} - -/// The site config the fixtures write, matching [`answered_site`]. -const SITE_CONFIG: &str = "{\"favicon\": \"app/icon.svg\", \"ogImage\": \"public/og.png\", \"siteName\": \"Prime\", \"siteUrl\": \"https://example.com\", \"motion\": \"none\"}"; - -fn sectioned(node_id: &str, name: &str, width: f64, section: &str) -> PlanScreenInput { - PlanScreenInput { - section: Some(format!("section-{section}")), - section_name: Some(section.into()), - ..screen(node_id, name, width) - } -} - -fn find<'a>(plan: &'a Value, id: &str) -> &'a Value { - array(&plan["questions"]) - .iter() - .find(|question| question["id"] == id) - .unwrap_or_else(|| panic!("{id} is asked: {}", plan["questions"])) -} - -fn first_label(question: &Value) -> &str { - text(&question["options"][0]["label"]) -} - -fn question_ids(plan: &Value) -> Vec { - array(&plan["questions"]) - .iter() - .map(|question| text(&question["id"]).to_owned()) - .collect() -} - -fn input(fixture: &Fixture, action: &str) -> ScreenPlanInput { - ScreenPlanInput { - action: action.into(), - project_root: Some(fixture.root().into()), - ..ScreenPlanInput::default() - } -} - -/// The route the user never saw being chosen is the failure this tool exists -/// for, so a screen nothing names gets no route and a question instead. -#[test] -fn an_unverified_route_is_null_and_becomes_a_question() { - let plan = draft_plan( - &[screen("1:2", "Pricing", 1440.0)], - &BTreeMap::new(), - &evidence(), - json!({}), - None, - ) - .unwrap(); - let row = &plan["screens"][0]; - assert_eq!(row["proposedRoute"], Value::Null); - assert_eq!(row["routeEvidence"], "UNVERIFIED"); - let question = array(&plan["questions"]) - .iter() - .find(|question| question["id"] == "route:1:2") - .expect("an unverified route is asked"); - let labels = array(&question["options"]) - .iter() - .map(|option| text(&option["label"])) - .collect::>(); - assert_eq!(labels[0], "/pricing (Recommended)"); - assert!(labels.contains(&SKIP)); -} - -/// Each kind of evidence proposes the route on its own, and a screen it -/// proposes is not asked about its route. -#[test] -fn an_evidence_backed_route_is_proposed_without_a_route_question() { - let targets = BTreeMap::from([("2:1".to_owned(), "/contact?from=nav".to_owned())]); - let plan = draft_plan( - &[ - screen("2:1", "Contact", 1440.0), - screen("2:2", "회사소개", 1440.0), - screen("2:3", "Home", 1440.0), - ], - &targets, - &evidence(), - json!({}), - None, - ) - .unwrap(); - let rows = array(&plan["screens"]); - assert_eq!(rows[0]["proposedRoute"], "/contact"); - assert_eq!(rows[0]["routeEvidence"], "navigationTargets[2:1]"); - assert_eq!(rows[1]["proposedRoute"], "/company"); - assert!(text(&rows[1]["routeEvidence"]).starts_with("nav-label")); - assert_eq!(rows[2]["proposedRoute"], "/"); - // Home -> / is a guess from the layer name, so it is shown and put to - // the user in one compact question rather than decided silently. - assert_eq!(rows[2]["routeEvidence"], NAME_MATCH); - assert_eq!(plan["heuristics"][0]["screen"], "2:3"); - let ids = question_ids(&plan); - assert!(ids.contains(&"heuristics".to_owned()), "{ids:?}"); - assert!(ids.contains(&"review:2:3".to_owned()), "{ids:?}"); - assert_eq!( - first_label(find(&plan, "heuristics")), - "Accept all (Recommended)" - ); - assert!( - !ids.iter() - .any(|id| id == "route:2:1" || id.ends_with(":2:1")), - "{ids:?}" - ); - assert!(!ids.iter().any(|id| id.starts_with("route:")), "{ids:?}"); - // An existing page is a different question: what building over it does. - assert!(ids.iter().any(|id| id == "collision:2:2")); - assert_eq!(rows[1]["existingFile"], "src/app/company/page.tsx"); - assert_eq!(rows[0]["sharedParts"], json!(["Header"])); -} - -/// Two screens the evidence puts on one URL leave neither decided. -#[test] -fn two_screens_on_one_evidence_route_are_both_asked() { - let targets = BTreeMap::from([ - ("3:1".to_owned(), "/about".to_owned()), - ("3:2".to_owned(), "/about".to_owned()), - ]); - let plan = draft_plan( - &[ - screen("3:1", "About A", 1440.0), - screen("3:2", "About B", 1440.0), - ], - &targets, - &evidence(), - json!({}), - None, - ) - .unwrap(); - let ids = question_ids(&plan); - assert!(ids.contains(&"route:3:1".to_owned()) && ids.contains(&"route:3:2".to_owned())); -} - -#[test] -fn widths_in_one_section_recommend_one_page_and_modals_are_asked() { - let plan = draft_plan( - &[ - screen("4:1", "Main - 1440", 1440.0), - screen("4:2", "Main - 390", 390.0), - screen("4:3", "Login Modal", 480.0), - ], - &BTreeMap::new(), - &evidence(), - json!({}), - None, - ) - .unwrap(); - let rows = array(&plan["screens"]); - assert_eq!(rows.len(), 2); - assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); - assert_eq!(rows[0]["proposedRoute"], "/"); - assert_eq!(rows[0]["grouping"]["recommended"], "single"); - let group = find(&plan, "group:4:1"); - assert_eq!(first_label(group), "One responsive page (Recommended)"); - // Separate pages need their own routes, asked in the same up-front batch - // and only if separate is chosen. No URL is made up for the 390 frame. - let split = find(&plan, "split-route:4:2"); - assert_eq!(split["when"][0]["question"], "group:4:1"); - assert_eq!(split["when"][0]["answer"], "Separate pages"); - assert!(!plan.to_string().contains("/390")); - let modal = find(&plan, "modal:4:3"); - assert!(first_label(modal).starts_with("State on Main")); - assert_eq!(rows[1]["kindEvidence"], NAME_MATCH); - assert!(text(&modal["question"]).contains("name-match")); -} - -/// Every question must be accepted as-is by a structured question tool. -#[test] -fn every_question_has_the_question_tool_shape() { - let mut linked = screen("5:1", "Gallery", 1440.0); - linked.links = vec![PlanLinkInput { - label: "More".into(), - destination_node_id: Some("9:9".into()), - url: None, - }]; - linked.parts = vec!["Footer".into()]; - let mut other = screen("5:2", "News", 1440.0); - other.parts = vec!["Footer".into()]; - let targets = BTreeMap::from([ - ("5:3".to_owned(), "/news/[id]".to_owned()), - ("5:7".to_owned(), "/".to_owned()), - ]); - let plan = draft_plan( - &[ - linked, - other, - screen("5:3", "News detail", 1440.0), - screen("5:4", "공지 팝업", 400.0), - screen("5:5", "Company - 1440", 1440.0), - screen("5:6", "Company - 390", 390.0), - screen("5:7", "Landing", 1440.0), - ], - &targets, - &evidence(), - json!({}), - None, - ) - .unwrap(); - let questions = ask_shape(array(&plan["questions"])); - let ids = questions - .iter() - .map(|question| text(&question["id"])) - .collect::>(); - assert_eq!(ids.len(), questions.len(), "question ids are unique"); - for kind in [ - "route:", - "link:", - "layout:", - "dynamic:", - "modal:", - "group:", - "split-route:", - "heuristics", - "review:", - "collision:", - ] { - assert!( - ids.iter().any(|id| id.starts_with(kind)), - "{kind} missing from {ids:?}" - ); - } - for question in &questions { - let header = text(&question["header"]); - assert!( - !header.is_empty() && header.chars().count() <= HEADER_LIMIT, - "{header}" - ); - assert!(!text(&question["question"]).is_empty()); - assert_eq!(question["multiple"], false); - assert!( - question.get("resolve").is_none(), - "resolution stays in the plan file" - ); - let options = array(&question["options"]); - assert!((2..=MAX_OPTIONS).contains(&options.len()), "{question}"); - assert!( - text(&options[0]["label"]).ends_with(RECOMMENDED), - "{question}" - ); - for option in &options[1..] { - assert!( - !text(&option["label"]).contains("(Recommended)"), - "{question}" - ); - } - assert!( - options - .iter() - .all(|option| !text(&option["description"]).is_empty()) - ); - for condition in array(&question["when"]) { - assert!(condition.get("action").is_none(), "{question}"); - assert!(ids.contains(text(&condition["question"])), "{question}"); - } - } - assert_rounds_cover(&plan); -} - -/// Every question in exactly one round, rounds within the tool's limits, and -/// a conditional question always after the question it depends on. -fn assert_rounds_cover(plan: &Value) { - let questions = array(&plan["questions"]); - let rounds = question_rounds(questions, MAX_QUESTIONS_PER_ROUND); - let mut seen = Vec::new(); - let mut round_of = BTreeMap::new(); - for (index, round) in rounds.iter().enumerate() { - assert_eq!(round["round"], index + 1); - let asked = array(&round["questions"]); - assert!( - (1..=MAX_QUESTIONS_PER_ROUND).contains(&asked.len()), - "{round}" - ); - for question in asked { - assert!(array(&question["options"]).len() <= MAX_OPTIONS); - let id = text(&question["id"]).to_owned(); - round_of.insert(id.clone(), index); - seen.push(id); - } - } - let mut expected = questions - .iter() - .map(|question| text(&question["id"]).to_owned()) - .collect::>(); - let mut got = seen.clone(); - expected.sort(); - got.sort(); - assert_eq!(got, expected, "every question exactly once"); - for question in questions { - for condition in array(&question["when"]) { - assert!( - round_of[text(&condition["question"])] < round_of[text(&question["id"])], - "{} must come after {}", - question["id"], - condition["question"] - ); - } - } -} - -#[test] -fn rounds_split_many_questions_into_fours() { - let screens = (0..11) - .map(|index| screen(&format!("8:{index}"), &format!("Page{index}"), 1440.0)) - .collect::>(); - let plan = draft_plan(&screens, &BTreeMap::new(), &evidence(), json!({}), None).unwrap(); - let rounds = question_rounds(array(&plan["questions"]), MAX_QUESTIONS_PER_ROUND); - assert_eq!(rounds.len(), 3); - assert_rounds_cover(&plan); - // A runtime with a larger cap gets fewer, larger rounds of the same - // questions; one with no cap asks the flat list. - let wide = question_rounds(array(&plan["questions"]), 10); - assert_eq!(wide.len(), 2); - assert_eq!(array(&wide[0]["questions"]).len(), 10); - assert_eq!(question_rounds(array(&plan["questions"]), 50).len(), 1); -} - -/// Frames of one name in different sections are never resolved here: the -/// question states the sections, frames, widths and ids, and offers a Figma -/// fix. Separate pages are recommended unless every section names a device. -#[test] -fn sections_are_asked_explicitly_with_a_fix_option() { - let plan = draft_plan( - &[ - sectioned("9:1", "About", 1440.0, "Company"), - sectioned("9:2", "About", 390.0, "Recruit"), - sectioned("9:3", "Pricing", 1440.0, "PC"), - sectioned("9:4", "Pricing", 390.0, "Mobile"), - ], - &BTreeMap::new(), - &evidence(), - json!({}), - None, - ) - .unwrap(); - let company = find(&plan, "sections:9:1"); - assert_eq!( - first_label(company), - "Separate pages: the sections are different pages (Recommended)" - ); - let device = find(&plan, "sections:9:3"); - assert_eq!( - first_label(device), - "One responsive page: the sections only split by device (Recommended)" - ); - let prompt = text(&device["question"]); - for fact in ["PC", "Mobile", "9:3", "9:4", "1440px", "390px", "Pricing"] { - assert!(prompt.contains(fact), "{fact} missing from {prompt}"); - } - assert!( - text(&device["options"][0]["description"]) - .contains("Inferred from the section names (PC, Mobile)") - ); - let labels = array(&device["options"]) - .iter() - .map(|option| text(&option["label"])) - .collect::>(); - assert!(labels.contains(&FIX_FIGMA), "{labels:?}"); - assert!( - !question_ids(&plan) - .iter() - .any(|id| id.starts_with("group:")) - ); - let split = find(&plan, "split-route:9:2"); - assert!( - array(&split["options"]) - .iter() - .any(|option| text(&option["label"]).starts_with("/recruit/about")), - "{split}" - ); - assert_rounds_cover(&plan); -} - -#[test] -fn project_config_counts_as_evidence_and_suppresses_its_questions() { - let mut evidence = evidence(); - evidence.config = PlanConfig { - modal_keywords: Some(vec!["sheet".into()]), - route_aliases: BTreeMap::from([("요금제".to_owned(), "/pricing".to_owned())]), - width_grouping: Some("responsive".into()), - ..answered_site() - }; - let plan = draft_plan( - &[ - screen("10:1", "요금제 - 1440", 1440.0), - screen("10:2", "요금제 - 390", 390.0), - screen("10:3", "Share sheet", 400.0), - screen("10:4", "Login popup", 400.0), - ], - &BTreeMap::new(), - &evidence, - json!({}), - None, - ) - .unwrap(); - let rows = array(&plan["screens"]); - assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); - assert_eq!(rows[0]["grouping"]["evidence"], "config:widthGrouping"); - assert_eq!(rows[0]["proposedRoute"], "/pricing"); - assert!(text(&rows[0]["routeEvidence"]).contains("routeAliases")); - assert_eq!(rows[1]["kind"], "modal"); - assert_eq!(rows[1]["kindEvidence"], "config"); - // The project's keywords replace the built-in ones. - assert_eq!(rows[2]["kind"], "page"); - let ids = question_ids(&plan); - assert!( - !ids.iter().any(|id| id.starts_with("group:") - || id.starts_with("route:10:1") - || id == "heuristics"), - "{ids:?}" - ); -} - -#[test] -fn a_frame_id_already_in_a_page_is_evidence() { - let mut evidence = evidence(); - evidence.figma_nodes.insert( - "11:1".into(), - ("/company".into(), "src/app/company/page.tsx".into()), - ); - let plan = draft_plan( - &[screen("11:1", "Intro", 1440.0)], - &BTreeMap::new(), - &evidence, - json!({}), - None, - ) - .unwrap(); - assert_eq!(plan["screens"][0]["proposedRoute"], "/company"); - assert!(text(&plan["screens"][0]["routeEvidence"]).contains("data-figma-node-id")); - assert_eq!(question_ids(&plan), ["collision:11:1"]); - assert_eq!( - figma_node_ids(""), - ["1:2", "3:4"] - ); -} - -#[tokio::test] -async fn confirm_rejects_missing_answers_by_id_and_accepts_a_complete_set() { - let fixture = Fixture::new(); - let mut draft_input = input(&fixture, "draft"); - draft_input.navigation_targets = BTreeMap::from([("6:3".to_owned(), "/contact".to_owned())]); - let drafted = draft( - &draft_input, - &[ - screen("6:1", "Pricing", 1440.0), - screen("6:2", "Careers", 1440.0), - screen("6:3", "Contact", 1440.0), - ], - json!({"from": "caller"}), - &fixture.policy(), - ) - .await - .unwrap(); - assert_eq!(drafted["askInstruction"], ASK_INSTRUCTION); - assert!(fixture.0.join(".devup/screen-plan.json").is_file()); - assert!(fixture.0.join(".devup/screen-plan.md").is_file()); - let plan_id = text(&drafted["planId"]).to_owned(); - - let mut confirm_input = input(&fixture, "confirm"); - confirm_input.plan_id = Some(plan_id.clone()); - confirm_input.answers = BTreeMap::from([( - "route:6:1".to_owned(), - AnswerInput::One("/pricing (Recommended)".into()), - )]); - let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); - assert_eq!(error.details["missing"], json!(["route:6:2"])); - assert!(error.message.contains("route:6:2")); - assert_eq!( - read_plan(fixture.root()).unwrap().unwrap()["status"], - "draft" - ); - - confirm_input - .answers - .insert("route:6:2".into(), AnswerInput::One("/jobs".into())); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - let urls = array(&confirmed["routes"]) - .iter() - .map(|row| row["url"].clone()) - .collect::>(); - assert_eq!(urls, [json!("/pricing"), json!("/jobs"), json!("/contact")]); - assert_eq!(confirmed["customAnswers"][0]["answer"], "/jobs"); - let saved = read_plan(fixture.root()).unwrap().unwrap(); - assert_eq!(saved["status"], "confirmed"); - let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); - assert!(markdown.contains("| /jobs | Careers |"), "{markdown}"); -} - -#[tokio::test] -async fn a_custom_route_answer_must_be_a_route() { - let fixture = Fixture::new(); - let drafted = draft( - &input(&fixture, "draft"), - &[screen("7:1", "Pricing", 1440.0)], - json!({}), - &fixture.policy(), - ) - .await - .unwrap(); - let mut confirm_input = input(&fixture, "confirm"); - confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); - confirm_input.answers = BTreeMap::from([( - "route:7:1".to_owned(), - AnswerInput::One("the pricing page".into()), - )]); - let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); - assert!(error.message.contains("custom answer must be an app route")); -} - -fn confirmed_plan() -> Value { - json!({ - "kind": PLAN_KIND, "schemaVersion": SCHEMA_VERSION, "planId": "sp-test", "status": "confirmed", - "source": {"nodeId": "10:0"}, - "routes": [ - {"url": "/", "screen": "10:1", "name": "Main", "nodeIds": ["10:1", "10:2"], "drawnWidths": [1440, 390], "implementation": "page", "file": "src/app/page.tsx"}, - {"url": "/about", "screen": "10:3", "name": "About", "nodeIds": ["10:3"], "drawnWidths": [1440], "implementation": "page", "file": "src/app/about/page.tsx"}, - {"url": null, "screen": "10:4", "name": "Login modal", "nodeIds": ["10:4"], "drawnWidths": [480], "implementation": "state", "hostUrl": "/"}, - ], - }) -} - -/// A width the audit has no item for is not-run, and a URL with a failing -/// width is never counted as verified. -#[test] -fn status_marks_widths_without_an_audit_item_not_run() { - let audit = json!({ - "verdict": "fail", - "widthTable": [{"width": 1440}, {"width": 390}], - "screenTable": [ - {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "screenshot": "1440x900@1-main.png"}, - {"screen": "about", "routePath": "/about", "width": 1440, "status": "pass", "reasons": []}, - {"screen": "about", "routePath": "/about/", "width": 390, "status": "fail", "reasons": ["horizontal overflow: 42px past the viewport"]}, - ], - }); - let report = status_report(&confirmed_plan(), &evidence(), Some(&audit)); - let rows = array(&report["rows"]); - assert_eq!(rows.len(), 2); - assert_eq!( - rows[0]["implemented"], "written-unverified", - "a page file without a passing rendered-completeness check is only written" - ); - assert_eq!(rows[1]["implemented"], "no"); - assert_eq!(rows[0]["widths"][0]["result"], "pass"); - assert_eq!(rows[0]["widths"][1]["result"], "not-run"); - assert_eq!(rows[1]["widths"][3]["result"], "fail"); - assert!( - text(&report["summary"]).starts_with( - "2 URLs: 0 implemented, 0 verified at all widths, 1 written but not rendered complete at every drawn width, /about overflows at 390" - ), - "{}", - report["summary"] - ); - assert!(text(&report["markdownTable"]).contains("written, not rendered complete")); - - let complete = json!({ - "verdict": "pass", - "widthTable": [{"width": 1440}, {"width": 390}], - "screenTable": [ - {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, - {"screen": "main", "routePath": "/", "width": 390, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, - ], - }); - let rendered = status_report(&confirmed_plan(), &evidence(), Some(&complete)); - assert_eq!(rendered["rows"][0]["implemented"], "yes"); - let half = json!({ - "widthTable": [{"width": 1440}, {"width": 390}], - "screenTable": [ - {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, - {"screen": "main", "routePath": "/", "width": 390, "status": "fail", "reasons": ["rendered-completeness: the page is 300px tall"], "columns": {"completeness": "fail"}}, - ], - }); - let blank = status_report(&confirmed_plan(), &evidence(), Some(&half)); - assert_eq!(blank["rows"][0]["implemented"], "written-unverified"); - assert_eq!(report["states"][0]["hostUrl"], "/"); - assert!(text(&report["markdownTable"]).contains("1440x900@1-main.png")); - - let without = status_report(&confirmed_plan(), &evidence(), None); - assert!( - array(&without["rows"]) - .iter() - .flat_map(|row| array(&row["widths"])) - .all(|width| width["result"] == "not-run") - ); - assert!(text(&without["summary"]).contains("every width is not-run")); -} - -fn write_plan_file(fixture: &Fixture, plan: &Value) { - fixture.write(PLAN_FILE, &serde_json::to_string(plan).unwrap()); -} - -fn export<'a>(fixture: &'a Fixture, frames: &[&str]) -> ExportRequest<'a> { - ExportRequest { - project_root: fixture.root(), - url: None, - frames: frames.iter().map(|frame| (*frame).to_owned()).collect(), - all_screens: false, - scaffold_write: false, - scaffold_route: None, - output_paths: Vec::new(), - } -} - -#[test] -fn the_export_gate_blocks_page_writes_without_a_confirmed_plan() { - let fixture = Fixture::new(); - let mut scaffold = export(&fixture, &["10:3"]); - scaffold.scaffold_write = true; - scaffold.scaffold_route = Some("/about"); - let Gate::Blocked(response) = export_gate(&scaffold).unwrap() else { - panic!("a pageScaffold write without a plan is blocked"); - }; - assert_eq!(response["status"], "plan_required"); - assert_eq!(response["nextAction"]["tool"], "devup_screen_plan"); - assert_eq!(response["nextAction"]["arguments"]["action"], "draft"); - - let mut app_path = export(&fixture, &["10:3"]); - app_path.output_paths = vec!["src/app/about/page.tsx"]; - assert!(matches!(export_gate(&app_path).unwrap(), Gate::Blocked(_))); - - let mut several = export(&fixture, &["10:1", "10:3"]); - several.output_paths = vec!["src/components/Screens.tsx"]; - assert!(matches!(export_gate(&several).unwrap(), Gate::Blocked(_))); - - let mut draft_only = confirmed_plan(); - draft_only["status"] = json!("draft"); - write_plan_file(&fixture, &draft_only); - assert!(matches!(export_gate(&scaffold).unwrap(), Gate::Blocked(_))); -} - -#[test] -fn the_export_gate_never_blocks_a_component_or_a_preview() { - let fixture = Fixture::new(); - let mut component = export(&fixture, &["10:3"]); - component.output_paths = vec!["src/components/Card.tsx"]; - assert!(matches!(export_gate(&component).unwrap(), Gate::Open)); - let preview = export(&fixture, &["10:1", "10:3"]); - assert!(matches!(export_gate(&preview).unwrap(), Gate::Open)); - let mut scaffold_preview = export(&fixture, &["10:3"]); - scaffold_preview.scaffold_route = Some("/about"); - assert!(matches!( - export_gate(&scaffold_preview).unwrap(), - Gate::Open - )); -} - -#[test] -fn a_confirmed_plan_supplies_the_scaffold_route_and_refuses_another() { - let fixture = Fixture::new(); - write_plan_file(&fixture, &confirmed_plan()); - let mut scaffold = export(&fixture, &["10:2"]); - scaffold.scaffold_write = true; - scaffold.scaffold_route = Some(""); - let Gate::Route(route) = export_gate(&scaffold).unwrap() else { - panic!("the plan covers 10:2"); - }; - assert_eq!(route, "/"); - scaffold.scaffold_route = Some("/home"); - let error = export_gate(&scaffold) - .err() - .expect("a different route is a mismatch"); - assert_eq!(error.details["routeMismatch"]["planned"], "/"); - - let mut uncovered = export(&fixture, &["99:1"]); - uncovered.scaffold_write = true; - uncovered.scaffold_route = Some("/x"); - let Gate::Blocked(response) = export_gate(&uncovered).unwrap() else { - panic!("a frame the plan does not place is blocked"); - }; - assert_eq!( - response["planRequired"]["details"]["uncoveredFrames"], - json!(["99:1"]) - ); - - let mut several = export(&fixture, &["10:1", "10:3"]); - several.output_paths = vec!["src/components/Screens.tsx"]; - assert!(matches!(export_gate(&several).unwrap(), Gate::Open)); -} - -#[tokio::test] -async fn project_evidence_reads_routes_layout_parts_and_nav_links() { - let fixture = Fixture::new(); - let evidence = project_evidence(fixture.root()).await.unwrap(); - let routes = evidence - .routes - .iter() - .map(|(route, _)| route.as_str()) - .collect::>(); - assert!( - routes.contains(&"/") && routes.contains(&"/company"), - "{routes:?}" - ); - assert_eq!(evidence.app_dir, "src/app"); - assert!(evidence.layout_parts.contains(&"Header".to_owned())); - assert!( - evidence - .nav_links - .iter() - .any(|(label, href, _)| label == "회사소개" && href == "/company"), - "{:?}", - evidence.nav_links - ); -} - -fn answers(pairs: &[(&str, &str)]) -> BTreeMap { - pairs - .iter() - .map(|(id, answer)| ((*id).to_owned(), AnswerInput::One((*answer).to_owned()))) - .collect() -} - -async fn drafted(fixture: &Fixture, screens: &[PlanScreenInput]) -> ScreenPlanInput { - let plan = draft( - &input(fixture, "draft"), - screens, - json!({}), - &fixture.policy(), - ) - .await - .unwrap(); - let mut confirm_input = input(fixture, "confirm"); - confirm_input.plan_id = plan["planId"].as_str().map(str::to_owned); - confirm_input -} - -fn main_widths() -> [PlanScreenInput; 2] { - [ - screen("12:1", "Main - 1440", 1440.0), - screen("12:2", "Main - 390", 390.0), - ] -} - -/// Separate pages bring their own route questions; the answers decide the -/// URLs, and nothing is derived from a width. -#[tokio::test] -async fn separate_pages_need_a_route_per_frame_and_get_exactly_those() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &main_widths()).await; - confirm_input.answers = answers(&[ - ("group:12:1", "Separate pages"), - ("split-route:12:1", "/"), - ("heuristics", "Accept all (Recommended)"), - ]); - let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); - assert_eq!(error.details["missing"], json!(["split-route:12:2"])); - assert!(error.details["questionRounds"][0]["questions"][0]["id"] == "split-route:12:2"); - - confirm_input - .answers - .insert("split-route:12:2".into(), AnswerInput::One("/m".into())); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - let urls = array(&confirmed["routes"]) - .iter() - .map(|row| (row["url"].clone(), row["nodeIds"].clone())) - .collect::>(); - assert_eq!( - urls, - [ - (json!("/"), json!(["12:1"])), - (json!("/m"), json!(["12:2"])) - ] - ); -} - -#[tokio::test] -async fn one_responsive_page_skips_the_split_routes_and_accepts_the_inferred_route() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &main_widths()).await; - confirm_input.answers = answers(&[ - ("group:12:1", "One responsive page (Recommended)"), - ("heuristics", "Accept all (Recommended)"), - ]); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - let route = &confirmed["routes"][0]; - assert_eq!(route["url"], "/"); - assert_eq!(route["nodeIds"], json!(["12:1", "12:2"])); - assert_eq!(route["collisionPolicy"], "merge"); - assert_eq!(route["routeEvidence"], "name-match (accepted)"); -} - -#[tokio::test] -async fn reviewing_heuristics_asks_each_one_in_the_same_batch() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &main_widths()).await; - confirm_input.answers = answers(&[ - ("group:12:1", "One responsive page (Recommended)"), - ("heuristics", "Review individually"), - ]); - let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); - assert_eq!(error.details["missing"], json!(["review:12:1"])); - confirm_input - .answers - .insert("review:12:1".into(), AnswerInput::One("/home".into())); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - assert_eq!(confirmed["routes"][0]["url"], "/home"); -} -#[test] -fn a_partial_report_cannot_drop_required_widths_or_claim_done() { - let report = status_report(&confirmed_plan(), &evidence(), Some(&json!({ - "widthTable":[{"width":1440}], - "screenTable":[{"routePath":"/", "width":1440, "status":"pass", "columns":{"completeness":"pass"}}] - }))); - let widths = array(&report["rows"][0]["widths"]); - assert_eq!(widths.iter().map(|row| row["width"].as_u64().unwrap()).collect::>(), [1440,1100,858,390]); - assert!(widths[1..].iter().all(|row| row["result"] == "not-run")); - assert!(text(&report["finalReport"]).contains("not-run")); - assert_eq!(report["workQueue"][0]["done"], false); -} - -#[test] -fn site_structure_and_url_questions_are_all_in_the_initial_rounds() { - let mut project = evidence(); - project.config = PlanConfig::default(); - let plan = draft_plan(&main_widths(), &BTreeMap::new(), &project, json!({}), None).unwrap(); - let ids = question_ids(&plan); - assert!(ids.iter().any(|id| id.starts_with("site:"))); - assert!(ids.iter().any(|id| id.starts_with("group:"))); - assert!(ids.iter().any(|id| id.starts_with("split-route:"))); - assert_rounds_cover(&plan); -} - -#[test] -fn visual_comparisons_and_missing_asset_references_block_a_passing_cell() { - let mut plan = confirmed_plan(); - plan["audit"] = json!({"visualPending":[{"routePath":"/", "viewport":{"width":1440}}]}); - let audit = json!({"widthTable":[{"width":1440}],"screenTable":[{"routePath":"/", "width":1440,"status":"pass","columns":{"completeness":"pass"}}]}); - assert_eq!(status_report(&plan, &evidence(), Some(&audit))["rows"][0]["widths"][0]["result"], "not-run"); - plan["audit"] = json!({"plans":[{"url":"/", "missingReferences":[{"width":1440,"kind":"nativeRender"}]}]}); - assert_eq!(status_report(&plan, &evidence(), Some(&audit))["rows"][0]["widths"][0]["result"], "not-run"); -} - - -#[tokio::test] -async fn accepting_the_proposal_confirms_with_no_answers() { - let fixture = Fixture::new(); - let mut confirm_input = drafted(&fixture, &main_widths()).await; - confirm_input.accept_recommended = true; - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - assert_eq!(confirmed["routes"][0]["url"], "/"); - assert_eq!(confirmed["routes"][0]["nodeIds"], json!(["12:1", "12:2"])); -} - -#[tokio::test] -async fn two_screens_on_one_url_return_a_retry_that_confirms() { - let fixture = Fixture::new(); - let mut confirm_input = drafted( - &fixture, - &[ - screen("1:2", "Pricing", 1440.0), - screen("1:3", "Plans", 1440.0), - ], - ) - .await; - confirm_input.accept_recommended = true; - confirm_input.answers = answers(&[("route:1:2", "/pricing"), ("route:1:3", "/pricing")]); - let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); - assert_eq!(error.details["url"], "/pricing"); - assert_eq!(array(&error.details["conflict"]).len(), 2); - let retry = error.details["retryAnswers"].as_object().unwrap(); - assert_eq!(retry.len(), 1); - let (id, url) = retry.iter().next().unwrap(); - assert!(id.starts_with("route:")); - assert_ne!(url, "/pricing"); - confirm_input - .answers - .insert(id.clone(), AnswerInput::One(text(url).to_owned())); - let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); - assert_eq!(array(&confirmed["routes"]).len(), 2); -} - -#[tokio::test] -async fn draft_returns_only_recommended_path_proposal_rows() { - let fixture = Fixture::new(); - let drafted = draft( - &input(&fixture, "draft"), - &main_widths(), - json!({}), - &fixture.policy(), - ) - .await - .unwrap(); - let rows = array(&drafted["proposal"]); - assert!(rows.len() < array(&drafted["questions"]).len()); - assert!(!array(&drafted["conditionalQuestions"]).is_empty()); - assert!(rows.iter().all(|row| row["recommended"].is_string())); - assert!( - text(&drafted["proposalMarkdown"]).starts_with("| # | topic | decision | recommended") - ); - assert_eq!(drafted["nextAction"]["arguments"]["acceptRecommended"], true); -} +use super::*; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; + +struct Fixture(std::path::PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let root = std::env::temp_dir().join(format!( + "screen-plan-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).unwrap(); + let fixture = Self(dunce::canonicalize(&root).unwrap()); + fixture.write("package.json", "{}"); + fixture.write(CONFIG_FILE, SITE_CONFIG); + fixture.write( + "src/app/layout.tsx", + "import { Header } from '../components/Header';\nexport default function RootLayout({ children }) { return
{children}; }", + ); + fixture.write( + "src/components/Header.tsx", + "import Link from 'next/link';\nexport function Header() { return ; }", + ); + fixture.write( + "src/app/page.tsx", + "export default function Page() { return
; }", + ); + fixture.write( + "src/app/company/page.tsx", + "export default function Page() { return
; }", + ); + fixture + } + + fn write(&self, path: &str, text: &str) { + let target = self.0.join(path); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(target, text).unwrap(); + } + + fn root(&self) -> &str { + self.0.to_str().unwrap() + } + + fn policy(&self) -> OutputPolicy { + OutputPolicy::from_roots(vec![self.0.clone()]).unwrap() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn screen(node_id: &str, name: &str, width: f64) -> PlanScreenInput { + PlanScreenInput { + node_id: node_id.into(), + name: name.into(), + width, + height: Some(900.0), + ..PlanScreenInput::default() + } +} + +fn evidence() -> ProjectEvidence { + ProjectEvidence { + routes: vec![ + ("/".into(), "src/app/page.tsx".into()), + ("/company".into(), "src/app/company/page.tsx".into()), + ("/news/[id]".into(), "src/app/news/[id]/page.tsx".into()), + ], + nav_links: vec![( + "회사소개".into(), + "/company".into(), + "src/components/Header.tsx:2".into(), + )], + layout_parts: vec!["Header".into()], + app_dir: "src/app".into(), + // The site questions are pre-answered here so each test below is + // about its own questions; `site_*` tests start from bare evidence. + config: answered_site(), + ..ProjectEvidence::default() + } +} + +fn answered_site() -> PlanConfig { + PlanConfig { + favicon: Some("app/icon.svg".into()), + og_image: Some("public/og.png".into()), + site_name: Some("Prime".into()), + site_url: Some("https://example.com".into()), + motion: Some("none".into()), + ..PlanConfig::default() + } +} + +/// The site config the fixtures write, matching [`answered_site`]. +const SITE_CONFIG: &str = "{\"favicon\": \"app/icon.svg\", \"ogImage\": \"public/og.png\", \"siteName\": \"Prime\", \"siteUrl\": \"https://example.com\", \"motion\": \"none\"}"; + +fn sectioned(node_id: &str, name: &str, width: f64, section: &str) -> PlanScreenInput { + PlanScreenInput { + section: Some(format!("section-{section}")), + section_name: Some(section.into()), + ..screen(node_id, name, width) + } +} + +fn find<'a>(plan: &'a Value, id: &str) -> &'a Value { + array(&plan["questions"]) + .iter() + .find(|question| question["id"] == id) + .unwrap_or_else(|| panic!("{id} is asked: {}", plan["questions"])) +} + +fn first_label(question: &Value) -> &str { + text(&question["options"][0]["label"]) +} + +fn question_ids(plan: &Value) -> Vec { + array(&plan["questions"]) + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect() +} + +fn input(fixture: &Fixture, action: &str) -> ScreenPlanInput { + ScreenPlanInput { + action: action.into(), + project_root: Some(fixture.root().into()), + ..ScreenPlanInput::default() + } +} + +/// The route the user never saw being chosen is the failure this tool exists +/// for, so a screen nothing names gets no route and a question instead. +#[test] +fn an_unverified_route_is_null_and_becomes_a_question() { + let plan = draft_plan( + &[screen("1:2", "Pricing", 1440.0)], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let row = &plan["screens"][0]; + assert_eq!(row["proposedRoute"], Value::Null); + assert_eq!(row["routeEvidence"], "UNVERIFIED"); + let question = array(&plan["questions"]) + .iter() + .find(|question| question["id"] == "route:1:2") + .expect("an unverified route is asked"); + let labels = array(&question["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert_eq!(labels[0], "/pricing (Recommended)"); + assert!(labels.contains(&SKIP)); +} + +/// Each kind of evidence proposes the route on its own, and a screen it +/// proposes is not asked about its route. +#[test] +fn an_evidence_backed_route_is_proposed_without_a_route_question() { + let targets = BTreeMap::from([("2:1".to_owned(), "/contact?from=nav".to_owned())]); + let plan = draft_plan( + &[ + screen("2:1", "Contact", 1440.0), + screen("2:2", "회사소개", 1440.0), + screen("2:3", "Home", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows[0]["proposedRoute"], "/contact"); + assert_eq!(rows[0]["routeEvidence"], "navigationTargets[2:1]"); + assert_eq!(rows[1]["proposedRoute"], "/company"); + assert!(text(&rows[1]["routeEvidence"]).starts_with("nav-label")); + assert_eq!(rows[2]["proposedRoute"], "/"); + // Home -> / is a guess from the layer name, so it is shown and put to + // the user in one compact question rather than decided silently. + assert_eq!(rows[2]["routeEvidence"], NAME_MATCH); + assert_eq!(plan["heuristics"][0]["screen"], "2:3"); + let ids = question_ids(&plan); + assert!(ids.contains(&"heuristics".to_owned()), "{ids:?}"); + assert!(ids.contains(&"review:2:3".to_owned()), "{ids:?}"); + assert_eq!( + first_label(find(&plan, "heuristics")), + "Accept all (Recommended)" + ); + assert!( + !ids.iter() + .any(|id| id == "route:2:1" || id.ends_with(":2:1")), + "{ids:?}" + ); + assert!(!ids.iter().any(|id| id.starts_with("route:")), "{ids:?}"); + // An existing page is a different question: what building over it does. + assert!(ids.iter().any(|id| id == "collision:2:2")); + assert_eq!(rows[1]["existingFile"], "src/app/company/page.tsx"); + assert_eq!(rows[0]["sharedParts"], json!(["Header"])); +} + +/// Two screens the evidence puts on one URL leave neither decided. +#[test] +fn two_screens_on_one_evidence_route_are_both_asked() { + let targets = BTreeMap::from([ + ("3:1".to_owned(), "/about".to_owned()), + ("3:2".to_owned(), "/about".to_owned()), + ]); + let plan = draft_plan( + &[ + screen("3:1", "About A", 1440.0), + screen("3:2", "About B", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let ids = question_ids(&plan); + assert!(ids.contains(&"route:3:1".to_owned()) && ids.contains(&"route:3:2".to_owned())); +} + +#[test] +fn widths_in_one_section_recommend_one_page_and_modals_are_asked() { + let plan = draft_plan( + &[ + screen("4:1", "Main - 1440", 1440.0), + screen("4:2", "Main - 390", 390.0), + screen("4:3", "Login Modal", 480.0), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); + assert_eq!(rows[0]["proposedRoute"], "/"); + assert_eq!(rows[0]["grouping"]["recommended"], "single"); + let group = find(&plan, "group:4:1"); + assert_eq!(first_label(group), "One responsive page (Recommended)"); + // Separate pages need their own routes, asked in the same up-front batch + // and only if separate is chosen. No URL is made up for the 390 frame. + let split = find(&plan, "split-route:4:2"); + assert_eq!(split["when"][0]["question"], "group:4:1"); + assert_eq!(split["when"][0]["answer"], "Separate pages"); + assert!(!plan.to_string().contains("/390")); + let modal = find(&plan, "modal:4:3"); + assert!(first_label(modal).starts_with("State on Main")); + assert_eq!(rows[1]["kindEvidence"], NAME_MATCH); + assert!(text(&modal["question"]).contains("name-match")); +} + +/// Every question must be accepted as-is by a structured question tool. +#[test] +fn every_question_has_the_question_tool_shape() { + let mut linked = screen("5:1", "Gallery", 1440.0); + linked.links = vec![PlanLinkInput { + label: "More".into(), + destination_node_id: Some("9:9".into()), + url: None, + }]; + linked.parts = vec!["Footer".into()]; + let mut other = screen("5:2", "News", 1440.0); + other.parts = vec!["Footer".into()]; + let targets = BTreeMap::from([ + ("5:3".to_owned(), "/news/[id]".to_owned()), + ("5:7".to_owned(), "/".to_owned()), + ]); + let plan = draft_plan( + &[ + linked, + other, + screen("5:3", "News detail", 1440.0), + screen("5:4", "공지 팝업", 400.0), + screen("5:5", "Company - 1440", 1440.0), + screen("5:6", "Company - 390", 390.0), + screen("5:7", "Landing", 1440.0), + ], + &targets, + &evidence(), + json!({}), + None, + ) + .unwrap(); + let questions = ask_shape(array(&plan["questions"])); + let ids = questions + .iter() + .map(|question| text(&question["id"])) + .collect::>(); + assert_eq!(ids.len(), questions.len(), "question ids are unique"); + for kind in [ + "route:", + "link:", + "layout:", + "dynamic:", + "modal:", + "group:", + "split-route:", + "heuristics", + "review:", + "collision:", + ] { + assert!( + ids.iter().any(|id| id.starts_with(kind)), + "{kind} missing from {ids:?}" + ); + } + for question in &questions { + let header = text(&question["header"]); + assert!( + !header.is_empty() && header.chars().count() <= HEADER_LIMIT, + "{header}" + ); + assert!(!text(&question["question"]).is_empty()); + assert_eq!(question["multiple"], false); + assert!( + question.get("resolve").is_none(), + "resolution stays in the plan file" + ); + let options = array(&question["options"]); + assert!((2..=MAX_OPTIONS).contains(&options.len()), "{question}"); + assert!( + text(&options[0]["label"]).ends_with(RECOMMENDED), + "{question}" + ); + for option in &options[1..] { + assert!( + !text(&option["label"]).contains("(Recommended)"), + "{question}" + ); + } + assert!( + options + .iter() + .all(|option| !text(&option["description"]).is_empty()) + ); + for condition in array(&question["when"]) { + assert!(condition.get("action").is_none(), "{question}"); + assert!(ids.contains(text(&condition["question"])), "{question}"); + } + } + assert_rounds_cover(&plan); +} + +/// Every question in exactly one round, rounds within the tool's limits, and +/// a conditional question always after the question it depends on. +fn assert_rounds_cover(plan: &Value) { + let questions = array(&plan["questions"]); + let rounds = question_rounds(questions, MAX_QUESTIONS_PER_ROUND); + let mut seen = Vec::new(); + let mut round_of = BTreeMap::new(); + for (index, round) in rounds.iter().enumerate() { + assert_eq!(round["round"], index + 1); + let asked = array(&round["questions"]); + assert!( + (1..=MAX_QUESTIONS_PER_ROUND).contains(&asked.len()), + "{round}" + ); + for question in asked { + assert!(array(&question["options"]).len() <= MAX_OPTIONS); + let id = text(&question["id"]).to_owned(); + round_of.insert(id.clone(), index); + seen.push(id); + } + } + let mut expected = questions + .iter() + .map(|question| text(&question["id"]).to_owned()) + .collect::>(); + let mut got = seen.clone(); + expected.sort(); + got.sort(); + assert_eq!(got, expected, "every question exactly once"); + for question in questions { + for condition in array(&question["when"]) { + assert!( + round_of[text(&condition["question"])] < round_of[text(&question["id"])], + "{} must come after {}", + question["id"], + condition["question"] + ); + } + } +} + +#[test] +fn rounds_split_many_questions_into_fours() { + let screens = (0..11) + .map(|index| screen(&format!("8:{index}"), &format!("Page{index}"), 1440.0)) + .collect::>(); + let plan = draft_plan(&screens, &BTreeMap::new(), &evidence(), json!({}), None).unwrap(); + let rounds = question_rounds(array(&plan["questions"]), MAX_QUESTIONS_PER_ROUND); + assert_eq!(rounds.len(), 3); + assert_rounds_cover(&plan); + // A runtime with a larger cap gets fewer, larger rounds of the same + // questions; one with no cap asks the flat list. + let wide = question_rounds(array(&plan["questions"]), 10); + assert_eq!(wide.len(), 2); + assert_eq!(array(&wide[0]["questions"]).len(), 10); + assert_eq!(question_rounds(array(&plan["questions"]), 50).len(), 1); +} + +/// Frames of one name in different sections are never resolved here: the +/// question states the sections, frames, widths and ids, and offers a Figma +/// fix. Separate pages are recommended unless every section names a device. +#[test] +fn sections_are_asked_explicitly_with_a_fix_option() { + let plan = draft_plan( + &[ + sectioned("9:1", "About", 1440.0, "Company"), + sectioned("9:2", "About", 390.0, "Recruit"), + sectioned("9:3", "Pricing", 1440.0, "PC"), + sectioned("9:4", "Pricing", 390.0, "Mobile"), + ], + &BTreeMap::new(), + &evidence(), + json!({}), + None, + ) + .unwrap(); + let company = find(&plan, "sections:9:1"); + assert_eq!( + first_label(company), + "Separate pages: the sections are different pages (Recommended)" + ); + let device = find(&plan, "sections:9:3"); + assert_eq!( + first_label(device), + "One responsive page: the sections only split by device (Recommended)" + ); + let prompt = text(&device["question"]); + for fact in ["PC", "Mobile", "9:3", "9:4", "1440px", "390px", "Pricing"] { + assert!(prompt.contains(fact), "{fact} missing from {prompt}"); + } + assert!( + text(&device["options"][0]["description"]) + .contains("Inferred from the section names (PC, Mobile)") + ); + let labels = array(&device["options"]) + .iter() + .map(|option| text(&option["label"])) + .collect::>(); + assert!(labels.contains(&FIX_FIGMA), "{labels:?}"); + assert!( + !question_ids(&plan) + .iter() + .any(|id| id.starts_with("group:")) + ); + let split = find(&plan, "split-route:9:2"); + assert!( + array(&split["options"]) + .iter() + .any(|option| text(&option["label"]).starts_with("/recruit/about")), + "{split}" + ); + assert_rounds_cover(&plan); +} + +#[test] +fn project_config_counts_as_evidence_and_suppresses_its_questions() { + let mut evidence = evidence(); + evidence.config = PlanConfig { + modal_keywords: Some(vec!["sheet".into()]), + route_aliases: BTreeMap::from([("요금제".to_owned(), "/pricing".to_owned())]), + width_grouping: Some("responsive".into()), + ..answered_site() + }; + let plan = draft_plan( + &[ + screen("10:1", "요금제 - 1440", 1440.0), + screen("10:2", "요금제 - 390", 390.0), + screen("10:3", "Share sheet", 400.0), + screen("10:4", "Login popup", 400.0), + ], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + let rows = array(&plan["screens"]); + assert_eq!(rows[0]["drawnWidths"], json!([1440, 390])); + assert_eq!(rows[0]["grouping"]["evidence"], "config:widthGrouping"); + assert_eq!(rows[0]["proposedRoute"], "/pricing"); + assert!(text(&rows[0]["routeEvidence"]).contains("routeAliases")); + assert_eq!(rows[1]["kind"], "modal"); + assert_eq!(rows[1]["kindEvidence"], "config"); + // The project's keywords replace the built-in ones. + assert_eq!(rows[2]["kind"], "page"); + let ids = question_ids(&plan); + assert!( + !ids.iter().any(|id| id.starts_with("group:") + || id.starts_with("route:10:1") + || id == "heuristics"), + "{ids:?}" + ); +} + +#[test] +fn a_frame_id_already_in_a_page_is_evidence() { + let mut evidence = evidence(); + evidence.figma_nodes.insert( + "11:1".into(), + ("/company".into(), "src/app/company/page.tsx".into()), + ); + let plan = draft_plan( + &[screen("11:1", "Intro", 1440.0)], + &BTreeMap::new(), + &evidence, + json!({}), + None, + ) + .unwrap(); + assert_eq!(plan["screens"][0]["proposedRoute"], "/company"); + assert!(text(&plan["screens"][0]["routeEvidence"]).contains("data-figma-node-id")); + assert_eq!(question_ids(&plan), ["collision:11:1"]); + assert_eq!( + figma_node_ids(""), + ["1:2", "3:4"] + ); +} + +#[tokio::test] +async fn confirm_rejects_missing_answers_by_id_and_accepts_a_complete_set() { + let fixture = Fixture::new(); + let mut draft_input = input(&fixture, "draft"); + draft_input.navigation_targets = BTreeMap::from([("6:3".to_owned(), "/contact".to_owned())]); + let drafted = draft( + &draft_input, + &[ + screen("6:1", "Pricing", 1440.0), + screen("6:2", "Careers", 1440.0), + screen("6:3", "Contact", 1440.0), + ], + json!({"from": "caller"}), + &fixture.policy(), + ) + .await + .unwrap(); + assert_eq!(drafted["askInstruction"], ASK_INSTRUCTION); + assert!(fixture.0.join(".devup/screen-plan.json").is_file()); + assert!(fixture.0.join(".devup/screen-plan.md").is_file()); + let plan_id = text(&drafted["planId"]).to_owned(); + + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = Some(plan_id.clone()); + confirm_input.answers = BTreeMap::from([( + "route:6:1".to_owned(), + AnswerInput::One("/pricing (Recommended)".into()), + )]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["route:6:2"])); + assert!(error.message.contains("route:6:2")); + assert_eq!( + read_plan(fixture.root()).unwrap().unwrap()["status"], + "draft" + ); + + confirm_input + .answers + .insert("route:6:2".into(), AnswerInput::One("/jobs".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| row["url"].clone()) + .collect::>(); + assert_eq!(urls, [json!("/pricing"), json!("/jobs"), json!("/contact")]); + assert_eq!(confirmed["customAnswers"][0]["answer"], "/jobs"); + let saved = read_plan(fixture.root()).unwrap().unwrap(); + assert_eq!(saved["status"], "confirmed"); + let markdown = std::fs::read_to_string(fixture.0.join(".devup/screen-plan.md")).unwrap(); + assert!(markdown.contains("| /jobs | Careers |"), "{markdown}"); +} + +#[tokio::test] +async fn a_custom_route_answer_must_be_a_route() { + let fixture = Fixture::new(); + let drafted = draft( + &input(&fixture, "draft"), + &[screen("7:1", "Pricing", 1440.0)], + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(&fixture, "confirm"); + confirm_input.plan_id = drafted["planId"].as_str().map(str::to_owned); + confirm_input.answers = BTreeMap::from([( + "route:7:1".to_owned(), + AnswerInput::One("the pricing page".into()), + )]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert!(error.message.contains("custom answer must be an app route")); +} + +fn confirmed_plan() -> Value { + json!({ + "kind": PLAN_KIND, "schemaVersion": SCHEMA_VERSION, "planId": "sp-test", "status": "confirmed", + "source": {"nodeId": "10:0"}, + "routes": [ + {"url": "/", "screen": "10:1", "name": "Main", "nodeIds": ["10:1", "10:2"], "drawnWidths": [1440, 390], "implementation": "page", "file": "src/app/page.tsx"}, + {"url": "/about", "screen": "10:3", "name": "About", "nodeIds": ["10:3"], "drawnWidths": [1440], "implementation": "page", "file": "src/app/about/page.tsx"}, + {"url": null, "screen": "10:4", "name": "Login modal", "nodeIds": ["10:4"], "drawnWidths": [480], "implementation": "state", "hostUrl": "/"}, + ], + }) +} + +/// A width the audit has no item for is not-run, and a URL with a failing +/// width is never counted as verified. +#[test] +fn status_marks_widths_without_an_audit_item_not_run() { + let audit = json!({ + "verdict": "fail", + "widthTable": [{"width": 1440}, {"width": 390}], + "screenTable": [ + {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "screenshot": "1440x900@1-main.png"}, + {"screen": "about", "routePath": "/about", "width": 1440, "status": "pass", "reasons": []}, + {"screen": "about", "routePath": "/about/", "width": 390, "status": "fail", "reasons": ["horizontal overflow: 42px past the viewport"]}, + ], + }); + let report = status_report(&confirmed_plan(), &evidence(), Some(&audit)); + let rows = array(&report["rows"]); + assert_eq!(rows.len(), 2); + assert_eq!( + rows[0]["implemented"], "written-unverified", + "a page file without a passing rendered-completeness check is only written" + ); + assert_eq!(rows[1]["implemented"], "no"); + assert_eq!(rows[0]["widths"][0]["result"], "pass"); + assert_eq!(rows[0]["widths"][1]["result"], "not-run"); + assert_eq!(rows[1]["widths"][3]["result"], "fail"); + assert!( + text(&report["summary"]).starts_with( + "2 URLs: 0 implemented, 0 verified at all widths, 1 written but not rendered complete at every drawn width, /about overflows at 390" + ), + "{}", + report["summary"] + ); + assert!(text(&report["markdownTable"]).contains("written, not rendered complete")); + + let complete = json!({ + "verdict": "pass", + "widthTable": [{"width": 1440}, {"width": 390}], + "screenTable": [ + {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, + {"screen": "main", "routePath": "/", "width": 390, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, + ], + }); + let rendered = status_report(&confirmed_plan(), &evidence(), Some(&complete)); + assert_eq!(rendered["rows"][0]["implemented"], "yes"); + let half = json!({ + "widthTable": [{"width": 1440}, {"width": 390}], + "screenTable": [ + {"screen": "main", "routePath": "/", "width": 1440, "status": "pass", "reasons": [], "columns": {"completeness": "pass"}}, + {"screen": "main", "routePath": "/", "width": 390, "status": "fail", "reasons": ["rendered-completeness: the page is 300px tall"], "columns": {"completeness": "fail"}}, + ], + }); + let blank = status_report(&confirmed_plan(), &evidence(), Some(&half)); + assert_eq!(blank["rows"][0]["implemented"], "written-unverified"); + assert_eq!(report["states"][0]["hostUrl"], "/"); + assert!(text(&report["markdownTable"]).contains("1440x900@1-main.png")); + + let without = status_report(&confirmed_plan(), &evidence(), None); + assert!( + array(&without["rows"]) + .iter() + .flat_map(|row| array(&row["widths"])) + .all(|width| width["result"] == "not-run") + ); + assert!(text(&without["summary"]).contains("every width is not-run")); +} + +fn write_plan_file(fixture: &Fixture, plan: &Value) { + fixture.write(PLAN_FILE, &serde_json::to_string(plan).unwrap()); +} + +fn export<'a>(fixture: &'a Fixture, frames: &[&str]) -> ExportRequest<'a> { + ExportRequest { + project_root: fixture.root(), + url: None, + frames: frames.iter().map(|frame| (*frame).to_owned()).collect(), + all_screens: false, + scaffold_write: false, + scaffold_route: None, + output_paths: Vec::new(), + } +} + +#[test] +fn the_export_gate_blocks_page_writes_without_a_confirmed_plan() { + let fixture = Fixture::new(); + let mut scaffold = export(&fixture, &["10:3"]); + scaffold.scaffold_write = true; + scaffold.scaffold_route = Some("/about"); + let Gate::Blocked(response) = export_gate(&scaffold).unwrap() else { + panic!("a pageScaffold write without a plan is blocked"); + }; + assert_eq!(response["status"], "plan_required"); + assert_eq!(response["nextAction"]["tool"], "devup_screen_plan"); + assert_eq!(response["nextAction"]["arguments"]["action"], "draft"); + + let mut app_path = export(&fixture, &["10:3"]); + app_path.output_paths = vec!["src/app/about/page.tsx"]; + assert!(matches!(export_gate(&app_path).unwrap(), Gate::Blocked(_))); + + let mut several = export(&fixture, &["10:1", "10:3"]); + several.output_paths = vec!["src/components/Screens.tsx"]; + assert!(matches!(export_gate(&several).unwrap(), Gate::Blocked(_))); + + let mut draft_only = confirmed_plan(); + draft_only["status"] = json!("draft"); + write_plan_file(&fixture, &draft_only); + assert!(matches!(export_gate(&scaffold).unwrap(), Gate::Blocked(_))); +} + +#[test] +fn the_export_gate_never_blocks_a_component_or_a_preview() { + let fixture = Fixture::new(); + let mut component = export(&fixture, &["10:3"]); + component.output_paths = vec!["src/components/Card.tsx"]; + assert!(matches!(export_gate(&component).unwrap(), Gate::Open)); + let preview = export(&fixture, &["10:1", "10:3"]); + assert!(matches!(export_gate(&preview).unwrap(), Gate::Open)); + let mut scaffold_preview = export(&fixture, &["10:3"]); + scaffold_preview.scaffold_route = Some("/about"); + assert!(matches!( + export_gate(&scaffold_preview).unwrap(), + Gate::Open + )); +} + +#[test] +fn a_confirmed_plan_supplies_the_scaffold_route_and_refuses_another() { + let fixture = Fixture::new(); + write_plan_file(&fixture, &confirmed_plan()); + let mut scaffold = export(&fixture, &["10:2"]); + scaffold.scaffold_write = true; + scaffold.scaffold_route = Some(""); + let Gate::Route(route) = export_gate(&scaffold).unwrap() else { + panic!("the plan covers 10:2"); + }; + assert_eq!(route, "/"); + scaffold.scaffold_route = Some("/home"); + let error = export_gate(&scaffold) + .err() + .expect("a different route is a mismatch"); + assert_eq!(error.details["routeMismatch"]["planned"], "/"); + + let mut uncovered = export(&fixture, &["99:1"]); + uncovered.scaffold_write = true; + uncovered.scaffold_route = Some("/x"); + let Gate::Blocked(response) = export_gate(&uncovered).unwrap() else { + panic!("a frame the plan does not place is blocked"); + }; + assert_eq!( + response["planRequired"]["details"]["uncoveredFrames"], + json!(["99:1"]) + ); + + let mut several = export(&fixture, &["10:1", "10:3"]); + several.output_paths = vec!["src/components/Screens.tsx"]; + assert!(matches!(export_gate(&several).unwrap(), Gate::Open)); +} + +#[tokio::test] +async fn project_evidence_reads_routes_layout_parts_and_nav_links() { + let fixture = Fixture::new(); + let evidence = project_evidence(fixture.root()).await.unwrap(); + let routes = evidence + .routes + .iter() + .map(|(route, _)| route.as_str()) + .collect::>(); + assert!( + routes.contains(&"/") && routes.contains(&"/company"), + "{routes:?}" + ); + assert_eq!(evidence.app_dir, "src/app"); + assert!(evidence.layout_parts.contains(&"Header".to_owned())); + assert!( + evidence + .nav_links + .iter() + .any(|(label, href, _)| label == "회사소개" && href == "/company"), + "{:?}", + evidence.nav_links + ); +} + +fn answers(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(id, answer)| ((*id).to_owned(), AnswerInput::One((*answer).to_owned()))) + .collect() +} + +async fn drafted(fixture: &Fixture, screens: &[PlanScreenInput]) -> ScreenPlanInput { + let plan = draft( + &input(fixture, "draft"), + screens, + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let mut confirm_input = input(fixture, "confirm"); + confirm_input.plan_id = plan["planId"].as_str().map(str::to_owned); + confirm_input +} + +fn main_widths() -> [PlanScreenInput; 2] { + [ + screen("12:1", "Main - 1440", 1440.0), + screen("12:2", "Main - 390", 390.0), + ] +} + +/// Separate pages bring their own route questions; the answers decide the +/// URLs, and nothing is derived from a width. +#[tokio::test] +async fn separate_pages_need_a_route_per_frame_and_get_exactly_those() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "Separate pages"), + ("split-route:12:1", "/"), + ("heuristics", "Accept all (Recommended)"), + ]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["split-route:12:2"])); + assert!(error.details["questionRounds"][0]["questions"][0]["id"] == "split-route:12:2"); + + confirm_input + .answers + .insert("split-route:12:2".into(), AnswerInput::One("/m".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let urls = array(&confirmed["routes"]) + .iter() + .map(|row| (row["url"].clone(), row["nodeIds"].clone())) + .collect::>(); + assert_eq!( + urls, + [ + (json!("/"), json!(["12:1"])), + (json!("/m"), json!(["12:2"])) + ] + ); +} + +#[tokio::test] +async fn one_responsive_page_skips_the_split_routes_and_accepts_the_inferred_route() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "One responsive page (Recommended)"), + ("heuristics", "Accept all (Recommended)"), + ]); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + let route = &confirmed["routes"][0]; + assert_eq!(route["url"], "/"); + assert_eq!(route["nodeIds"], json!(["12:1", "12:2"])); + assert_eq!(route["collisionPolicy"], "merge"); + assert_eq!(route["routeEvidence"], "name-match (accepted)"); +} + +#[tokio::test] +async fn reviewing_heuristics_asks_each_one_in_the_same_batch() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.answers = answers(&[ + ("group:12:1", "One responsive page (Recommended)"), + ("heuristics", "Review individually"), + ]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["missing"], json!(["review:12:1"])); + confirm_input + .answers + .insert("review:12:1".into(), AnswerInput::One("/home".into())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!(confirmed["routes"][0]["url"], "/home"); +} +#[test] +fn a_partial_report_cannot_drop_required_widths_or_claim_done() { + let report = status_report(&confirmed_plan(), &evidence(), Some(&json!({ + "widthTable":[{"width":1440}], + "screenTable":[{"routePath":"/", "width":1440, "status":"pass", "columns":{"completeness":"pass"}}] + }))); + let widths = array(&report["rows"][0]["widths"]); + assert_eq!(widths.iter().map(|row| row["width"].as_u64().unwrap()).collect::>(), [1440,1100,858,390]); + assert!(widths[1..].iter().all(|row| row["result"] == "not-run")); + assert!(text(&report["finalReport"]).contains("not-run")); + assert_eq!(report["workQueue"][0]["done"], false); +} + +#[test] +fn site_structure_and_url_questions_are_all_in_the_initial_rounds() { + let mut project = evidence(); + project.config = PlanConfig::default(); + let plan = draft_plan(&main_widths(), &BTreeMap::new(), &project, json!({}), None).unwrap(); + let ids = question_ids(&plan); + assert!(ids.iter().any(|id| id.starts_with("site:"))); + assert!(ids.iter().any(|id| id.starts_with("group:"))); + assert!(ids.iter().any(|id| id.starts_with("split-route:"))); + assert_rounds_cover(&plan); +} + +#[test] +fn visual_comparisons_and_missing_asset_references_block_a_passing_cell() { + let mut plan = confirmed_plan(); + plan["audit"] = json!({"visualPending":[{"routePath":"/", "viewport":{"width":1440}}]}); + let audit = json!({"widthTable":[{"width":1440}],"screenTable":[{"routePath":"/", "width":1440,"status":"pass","columns":{"completeness":"pass"}}]}); + assert_eq!(status_report(&plan, &evidence(), Some(&audit))["rows"][0]["widths"][0]["result"], "not-run"); + plan["audit"] = json!({"plans":[{"url":"/", "missingReferences":[{"width":1440,"kind":"nativeRender"}]}]}); + assert_eq!(status_report(&plan, &evidence(), Some(&audit))["rows"][0]["widths"][0]["result"], "not-run"); +} + + +#[tokio::test] +async fn accepting_the_proposal_confirms_with_no_answers() { + let fixture = Fixture::new(); + let mut confirm_input = drafted(&fixture, &main_widths()).await; + confirm_input.accept_recommended = true; + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!(confirmed["routes"][0]["url"], "/"); + assert_eq!(confirmed["routes"][0]["nodeIds"], json!(["12:1", "12:2"])); +} + +#[tokio::test] +async fn two_screens_on_one_url_return_a_retry_that_confirms() { + let fixture = Fixture::new(); + let mut confirm_input = drafted( + &fixture, + &[ + screen("1:2", "Pricing", 1440.0), + screen("1:3", "Plans", 1440.0), + ], + ) + .await; + confirm_input.accept_recommended = true; + confirm_input.answers = answers(&[("route:1:2", "/pricing"), ("route:1:3", "/pricing")]); + let error = confirm(&confirm_input, &fixture.policy()).unwrap_err(); + assert_eq!(error.details["url"], "/pricing"); + assert_eq!(array(&error.details["conflict"]).len(), 2); + let retry = error.details["retryAnswers"].as_object().unwrap(); + assert_eq!(retry.len(), 1); + let (id, url) = retry.iter().next().unwrap(); + assert!(id.starts_with("route:")); + assert_ne!(url, "/pricing"); + confirm_input + .answers + .insert(id.clone(), AnswerInput::One(text(url).to_owned())); + let confirmed = confirm(&confirm_input, &fixture.policy()).unwrap(); + assert_eq!(array(&confirmed["routes"]).len(), 2); +} + +#[tokio::test] +async fn draft_returns_only_recommended_path_proposal_rows() { + let fixture = Fixture::new(); + let drafted = draft( + &input(&fixture, "draft"), + &main_widths(), + json!({}), + &fixture.policy(), + ) + .await + .unwrap(); + let rows = array(&drafted["proposal"]); + assert!(rows.len() < array(&drafted["questions"]).len()); + assert!(!array(&drafted["conditionalQuestions"]).is_empty()); + assert!(rows.iter().all(|row| row["recommended"].is_string())); + assert!( + text(&drafted["proposalMarkdown"]).starts_with("| # | topic | decision | recommended") + ); + assert_eq!(drafted["nextAction"]["arguments"]["acceptRecommended"], true); +} diff --git a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md index 92eddf45..f15f9426 100644 --- a/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup-implementation-plan/SKILL.md @@ -1,350 +1,350 @@ ---- -name: devup-implementation-plan -description: | - Begin DevFive projects, features, bug fixes and refactors with project intake, - confirmed ERD models and supervisor work units. For Figma, plan which screen - becomes which URL, confirm it with the user in one - batch of questions, then implement URL by URL and finish with a URL x width - status table. Uses devup-mcp's devup_screen_plan, devup_figma_export and - devup_runtime_audit. - - TRIGGER WHEN: - - Starting a DevFive project, feature, bug fix or refactor through /devup - - Asked to implement, build or convert a Figma design, Section or several - screens into pages - - Given a Figma Section link, or several frames, for a Next App Router project - - Asked what was built from a design, where it lives, or whether it works - - Start it even when nobody typed /devup: a request to build pages or a site - from Figma is the trigger. /devup only makes it explicit. - - Trigger keywords: implement this design, Figma to pages, build the site, - screens, routes, URL, screen plan, devup_screen_plan, /devup ---- - +--- +name: devup-implementation-plan +description: | + Begin DevFive projects, features, bug fixes and refactors with project intake, + confirmed ERD models and supervisor work units. For Figma, plan which screen + becomes which URL, confirm it with the user in one + batch of questions, then implement URL by URL and finish with a URL x width + status table. Uses devup-mcp's devup_screen_plan, devup_figma_export and + devup_runtime_audit. + + TRIGGER WHEN: + - Starting a DevFive project, feature, bug fix or refactor through /devup + - Asked to implement, build or convert a Figma design, Section or several + screens into pages + - Given a Figma Section link, or several frames, for a Next App Router project + - Asked what was built from a design, where it lives, or whether it works + + Start it even when nobody typed /devup: a request to build pages or a site + from Figma is the trigger. /devup only makes it explicit. + + Trigger keywords: implement this design, Figma to pages, build the site, + screens, routes, URL, screen plan, devup_screen_plan, /devup +--- + ## Project intake comes first When replacing an inlined instance with a shared component, carry over every `instanceOverrides` entry through a new variant or prop, or explicitly report it as unresolved. Never discard `// @devup-override` annotations or the required `DEVUP_CODEGEN_INSTANCE_OVERRIDE` review item silently. For a confirmed multi-width URL, `responsiveTsx` is the deliverable. Export all sibling frameIds in width order using screen-plan status's next export action. Never start from single-width `tsx`/`componentTsx` for its page or sections. Only wire links/events/data; never change generated layout values, collapse arrays, or add hard-coded sizes/layout wrappers. Review `generatedExport.generatedDrift` in status; its confidence-labelled findings are heuristic and missing mappings remain unverified. - -`/devup` is the entry point for new projects, existing features, bug fixes, -Figma page implementation and refactors. Figma screen planning is a branch -of the confirmed project plan; complete this section before the screen workflow below. - -Read sources before asking: `devup_project_context` for every supported scope, -Atlassian MCP `getJiraIssue` / `searchJiraIssuesUsingJql` for issues, epic children -and dependencies, referenced Confluence/spec documents, and Figma exploration -(screen names, field labels, forms, tables and detail screens). Pass each read as -`evidence: [{source, facts}]` to `devup_project_plan`. Facts use `goal`, `parts`, -`figma` (links), `screens` ([{name, fields}]), and `.screens`, `.states`, -`.permissions`, `.forms`, `.notifications`. Backend decisions use `erd`, `tenancy`, -`auth`, `lifecycle`, `indexes`, `storage`; delivery uses `jiraPolicy`, `branchPolicy`, -`prPolicy`. A sourced fact is a default with its provenance, not another question. -Sources are read by the runtime's connectors, never by treating a URL as its contents. - -1. `devup_project_plan intake` with projectRoot: always ask the four initial - questions with the runtime question tool (recommended option first): work type - (refactor may be a custom answer), optional Figma/Jira/spec sources, one-sentence - goal, and parts. Existing `apis/` recommends front + admin + API; - `apps/admin` recommends front + admin. Retain planId on every later call. -2. Read newly named sources. `draft` with all initial answers, source-labelled - evidence and `features: [{id,scope,screens,acceptance}]` or - `issues: [{key,scope,dependencies,acceptance}]` (epic children are issues too). - Each unresolved feature gets concrete screen/role, state, permission, - validation, notification/integration rounds. Put all remaining unknowns into - extraQuestions. No implementation starts while a question remains unanswered. -3. API in scope: inspect existing `vespertide.json` and `apis/*/models/*.json`. - Draft complete `entities` as vespertide model objects and `relations` with - from/to, cardinality oneToMany|manyToMany, column/joinTable and onDelete. - Signup/post Figma evidence seeds user/post entities and a user FK, but the - agent must expand every actual domain from the source evidence. Review the - ERD round, ownership/tenancy, auth with integer roles (0,10,50,100), status - enums, soft delete, audit timestamps, nullable fields, unique/composite keys, - filter/sort indexes, file/image storage and cascade/restrict/set_null actions. - Read https://github.com/dev-five-git/vespertide/blob/main/SKILL.md and schemas/. - Use snake_case, `$schema` on every model, inline PK/FK/unique/index constraints, - and table constraints only for CHECK. A manyToMany relation generates its join - table. modelsDir is relative to projectRoot; set it explicitly for multiple APIs. - Re-draft structured entities for requested corrections before confirming. -4. When Figma is present, run the existing screen-plan draft/rounds/confirm - workflow (routes, groups, exclusions, site facts) before project confirm. -5. Ask all delivery questions now: Jira none/comment/transition, branch, PR. - `devup_project_plan confirm` requires every applicable answer and writes source - models plus confirmed state in one transaction. Then in modelsWorkingDirectory - run the returned commands, in order: `vespertide diff`, `vespertide sql`, - `vespertide revision -m "confirmed project plan"`, `vespertide export --orm seaorm`. - The tool returns commands; the runtime executes and checks them. Never hand-write - migrations/ or src/models/. Build vespera CRUD and feature routes, openapi.json, - and the devup-api client; `devup_stack_diff` must show zero drift before done. -6. Latest-first updates, vinext, exact layout, all interaction/site defaults, - runtime/visual audits and the existing never-wait rules below still apply. - After confirm, use project-plan defer with id/reason/recommended/destructive; - continue independent work. At the end assume with no decisions resolves all - pending recommendations; destructive decisions remain unbuilt. Status includes - ERD Mermaid, decisions and the unit table; retain unresolved evidence in reports. - -## Supervisor execution - -`devup_project_plan split` splits work, not files. Jira input produces one unit -per issue/sub-task with its explicit dependencies (reject cycles/unknown IDs). -Without Jira, it produces shared layout/theme first, then each feature's -models -> API -> client -> front chain; front-only features depend on shared. -Review cross-feature dependencies before dispatch; the supervisor sequences -shared database work to avoid concurrent schema edits. Each unit has acceptance -criteria and build/lint/typecheck/tests/validate/runtime/visual/stack-diff checks. -`.devup/units/.md` contains scope, decisions, ERD and screen context, and checklist. - -Default `unitQuestions: "supervisor"` means workers never ask the user: questions -were settled across all units during intake, and supervisor ownership avoids -repeated questions and keeps decisions consistent. `unitQuestions: "worker"` -is an explicit alternative, still subject to the confirmed never-wait policy. -Load `orca-cli` and `orchestration` skills to create one worktree per unit, -dispatch the DAG in parallel where independent, collect worker_done, and rerun -each unit's checks yourself before merging. New decisions use orchestration -ask/reply to the supervisor: answer from recorded decisions, or defer with a -recommended option while the worker continues other work. Send failed checks -back to the same worker session. Update unit states with status unitUpdates -(pending -> running -> verifying -> merged, or unresolved); merged needs recorded -supervisor verification. Without Orca, execute the same units sequentially in one -session. Only under the recorded Jira/PR policy, comment results and PR link and -transition issues. Final report includes the unit table, DAG, ERD and unresolved -items; never claim completion from split or command generation alone. - - - -# Screen plan first, confirm once, then implement - -A design handed over as "implement this" is several screens, and each one has -to land on a URL. If you pick those URLs yourself and start writing pages, the -user cannot tell afterwards what was built, where it lives, or whether it -works. On one real site the pages were written, some widths showed "This page -couldn't load", narrow widths were cut off on the right, and nobody had a -URL-by-width table on which to notice. - -So the order is fixed, and none of it is optional. - -## Hard rules - -- **No implementation before confirm.** No page, layout or component file is - written until `devup_screen_plan` `confirm` has succeeded. -- **One table, one confirmation.** Do not interview the user decision by - decision. Draft returns `proposal` / `proposalMarkdown`: every open decision - as a row with the recommended answer and the alternatives. Show that table - and ask ONE question with your question tool: accept it as it is, or name - the rows to change. Then confirm with `acceptRecommended: true` and only the - changed rows in `answers`. Use `questions` / `questionRounds` only to review - the rows the user asked to change. -- **Ask in the user's language.** Translate the table, the question and the - option labels into the language the user first wrote to you in, for display - only. Answers sent to confirm stay keyed by question id and use the original - option labels (or custom text such as a route). -- **Ask what the whole job needs, now.** Every question you would need at - any point - screens and routes, site files and facts, and your own - (`extraQuestions`) - goes into the up-front rounds. Then finish the site - without stopping. + +`/devup` is the entry point for new projects, existing features, bug fixes, +Figma page implementation and refactors. Figma screen planning is a branch +of the confirmed project plan; complete this section before the screen workflow below. + +Read sources before asking: `devup_project_context` for every supported scope, +Atlassian MCP `getJiraIssue` / `searchJiraIssuesUsingJql` for issues, epic children +and dependencies, referenced Confluence/spec documents, and Figma exploration +(screen names, field labels, forms, tables and detail screens). Pass each read as +`evidence: [{source, facts}]` to `devup_project_plan`. Facts use `goal`, `parts`, +`figma` (links), `screens` ([{name, fields}]), and `.screens`, `.states`, +`.permissions`, `.forms`, `.notifications`. Backend decisions use `erd`, `tenancy`, +`auth`, `lifecycle`, `indexes`, `storage`; delivery uses `jiraPolicy`, `branchPolicy`, +`prPolicy`. A sourced fact is a default with its provenance, not another question. +Sources are read by the runtime's connectors, never by treating a URL as its contents. + +1. `devup_project_plan intake` with projectRoot: always ask the four initial + questions with the runtime question tool (recommended option first): work type + (refactor may be a custom answer), optional Figma/Jira/spec sources, one-sentence + goal, and parts. Existing `apis/` recommends front + admin + API; + `apps/admin` recommends front + admin. Retain planId on every later call. +2. Read newly named sources. `draft` with all initial answers, source-labelled + evidence and `features: [{id,scope,screens,acceptance}]` or + `issues: [{key,scope,dependencies,acceptance}]` (epic children are issues too). + Each unresolved feature gets concrete screen/role, state, permission, + validation, notification/integration rounds. Put all remaining unknowns into + extraQuestions. No implementation starts while a question remains unanswered. +3. API in scope: inspect existing `vespertide.json` and `apis/*/models/*.json`. + Draft complete `entities` as vespertide model objects and `relations` with + from/to, cardinality oneToMany|manyToMany, column/joinTable and onDelete. + Signup/post Figma evidence seeds user/post entities and a user FK, but the + agent must expand every actual domain from the source evidence. Review the + ERD round, ownership/tenancy, auth with integer roles (0,10,50,100), status + enums, soft delete, audit timestamps, nullable fields, unique/composite keys, + filter/sort indexes, file/image storage and cascade/restrict/set_null actions. + Read https://github.com/dev-five-git/vespertide/blob/main/SKILL.md and schemas/. + Use snake_case, `$schema` on every model, inline PK/FK/unique/index constraints, + and table constraints only for CHECK. A manyToMany relation generates its join + table. modelsDir is relative to projectRoot; set it explicitly for multiple APIs. + Re-draft structured entities for requested corrections before confirming. +4. When Figma is present, run the existing screen-plan draft/rounds/confirm + workflow (routes, groups, exclusions, site facts) before project confirm. +5. Ask all delivery questions now: Jira none/comment/transition, branch, PR. + `devup_project_plan confirm` requires every applicable answer and writes source + models plus confirmed state in one transaction. Then in modelsWorkingDirectory + run the returned commands, in order: `vespertide diff`, `vespertide sql`, + `vespertide revision -m "confirmed project plan"`, `vespertide export --orm seaorm`. + The tool returns commands; the runtime executes and checks them. Never hand-write + migrations/ or src/models/. Build vespera CRUD and feature routes, openapi.json, + and the devup-api client; `devup_stack_diff` must show zero drift before done. +6. Latest-first updates, vinext, exact layout, all interaction/site defaults, + runtime/visual audits and the existing never-wait rules below still apply. + After confirm, use project-plan defer with id/reason/recommended/destructive; + continue independent work. At the end assume with no decisions resolves all + pending recommendations; destructive decisions remain unbuilt. Status includes + ERD Mermaid, decisions and the unit table; retain unresolved evidence in reports. + +## Supervisor execution + +`devup_project_plan split` splits work, not files. Jira input produces one unit +per issue/sub-task with its explicit dependencies (reject cycles/unknown IDs). +Without Jira, it produces shared layout/theme first, then each feature's +models -> API -> client -> front chain; front-only features depend on shared. +Review cross-feature dependencies before dispatch; the supervisor sequences +shared database work to avoid concurrent schema edits. Each unit has acceptance +criteria and build/lint/typecheck/tests/validate/runtime/visual/stack-diff checks. +`.devup/units/.md` contains scope, decisions, ERD and screen context, and checklist. + +Default `unitQuestions: "supervisor"` means workers never ask the user: questions +were settled across all units during intake, and supervisor ownership avoids +repeated questions and keeps decisions consistent. `unitQuestions: "worker"` +is an explicit alternative, still subject to the confirmed never-wait policy. +Load `orca-cli` and `orchestration` skills to create one worktree per unit, +dispatch the DAG in parallel where independent, collect worker_done, and rerun +each unit's checks yourself before merging. New decisions use orchestration +ask/reply to the supervisor: answer from recorded decisions, or defer with a +recommended option while the worker continues other work. Send failed checks +back to the same worker session. Update unit states with status unitUpdates +(pending -> running -> verifying -> merged, or unresolved); merged needs recorded +supervisor verification. Without Orca, execute the same units sequentially in one +session. Only under the recorded Jira/PR policy, comment results and PR link and +transition issues. Final report includes the unit table, DAG, ERD and unresolved +items; never claim completion from split or command generation alone. + + + +# Screen plan first, confirm once, then implement + +A design handed over as "implement this" is several screens, and each one has +to land on a URL. If you pick those URLs yourself and start writing pages, the +user cannot tell afterwards what was built, where it lives, or whether it +works. On one real site the pages were written, some widths showed "This page +couldn't load", narrow widths were cut off on the right, and nobody had a +URL-by-width table on which to notice. + +So the order is fixed, and none of it is optional. + +## Hard rules + +- **No implementation before confirm.** No page, layout or component file is + written until `devup_screen_plan` `confirm` has succeeded. +- **One table, one confirmation.** Do not interview the user decision by + decision. Draft returns `proposal` / `proposalMarkdown`: every open decision + as a row with the recommended answer and the alternatives. Show that table + and ask ONE question with your question tool: accept it as it is, or name + the rows to change. Then confirm with `acceptRecommended: true` and only the + changed rows in `answers`. Use `questions` / `questionRounds` only to review + the rows the user asked to change. +- **Ask in the user's language.** Translate the table, the question and the + option labels into the language the user first wrote to you in, for display + only. Answers sent to confirm stay keyed by question id and use the original + option labels (or custom text such as a route). +- **Ask what the whole job needs, now.** Every question you would need at + any point - screens and routes, site files and facts, and your own + (`extraQuestions`) - goes into the up-front rounds. Then finish the site + without stopping. - **Separate implementation choices from user decisions.** After `confirm`, record ordinary implementation choices with `defer`/`assume` and continue. A new decision requiring user authority still uses the runtime question tool; continue independent work while its answer is pending. Never silently accept a recommendation on the user's behalf. -- **Ask with the question tool, never in prose.** Pass each round's - `questions` to your runtime's structured question tool (`question` in - OpenCode, `AskUserQuestion` in Claude Code, whatever yours is called). They - are already shaped for it - `id`, `header` (30 characters or fewer), - `question`, `options[{label, description}]` with the recommended option first - and marked " (Recommended)", and `multiple`. Custom answers are allowed. -- **Skip what does not apply.** A question with `when` is asked only if the - named question got that answer, e.g. a per-frame route only after "Separate - pages". Rounds are ordered so the answer it depends on is always already in - hand. -- **Stay inside the scope the user gave.** Only frames under the linked - Section, frame or page (or the Bridge selection) are candidates. Never widen - to the whole file or other pages on your own, and never build from trash, - archive, old, draft, test, copy or hidden areas: draft lists them under - `excluded` and asks once whether to accept that. -- **Never resolve a cross-section ambiguity yourself.** Frames that look like - one screen in different sections - device-named ones such as PC / Mobile - included - are a designer's structure. Ask the `sections:` question as - returned; it names the sections, frames, widths and node ids. -- **Frames marked for a design fix are reported, not built.** When the user - answers "The Figma structure is wrong: I will fix it", those frames are left - out, listed under "Design fixes requested" with what to move or rename, and - the rest of the work goes on without waiting. -- **The stack is DevFive's default.** Next App Router run by vinext, bun - workspaces, devup-ui, oxlint, the latest versions. A project with no app - starts from `dev-five-git/service-template` (a default, not a question); one - on the `next` CLI is asked once, up front, whether to migrate to vinext - - never migrated silently. -- **Pick the app by meaning, not by name order.** In a repository with - several apps, `apps/admin` is the admin console and a Figma design for a - public site is almost never for it. The plan decides from the app's name - (`front`, `web`, `site`, `www`, `landing`, `home` for the public site; - `admin`, `dashboard`, `console`, `backoffice`, `cms`, `manager`, `관리자` for - the console), its routes (sign-in, dashboard) and the frames' own names - (Home, About, 회사소개 vs Admin, 관리자, 회원관리, 대시보드). When the - evidence points one way it takes that app without asking and records why in - `defaults`. The bias is the public app unless there is clear admin - evidence: a file with both maps admin screens to the console and every other - screen to the public app (the route table's `app` column), and frames that - say nothing either way go to the public app too. Only when no app looks like - the public site is the app asked. -- **Never invent a route.** A route comes from an existing page built from the - frame, a nav link already bound to an href, an explicit `navigationTargets` - entry, the project's `routeAliases`, or the user's answer. A route inferred - from a layer name is `routeEvidence: "name-match"` and is put to the user. - `proposedRoute: null` with `routeEvidence: "UNVERIFIED"` stays null until the - user answers. - -## Site completeness - -A site built only from what Figma draws feels dead: no hover, no focus, no -pressed or current state, no transitions, no metadata, no icon. Hover plus a -transition is still not enough. The plan splits what a finished site takes in -two. - -**Filled in by you, without a question** - draft lists these as `defaults`, -and the final report repeats them: - -- the interaction recipe for each element's role (table below), on every - interactive element `interactiveCandidates` and the navigation checks find; -- one shared `transition`, 150-250ms, ease or ease-out, on the properties that - change, defined once (a token or shared constant), not per element; -- `_motionReduce` so `prefers-reduced-motion` gets no transform or animation; -- view transitions between pages, by the mechanism the plan chose from the - project's versions (below); -- root `metadata` (title template, description, `metadataBase` when the URL is - known) and per-page `title`, `description` and `openGraph` from the page's - Figma heading and lead text - the devfive-frontend SEO rule; `lang` on - ``; -- `alt` from the layer name or text, width and height on every image, and a - `not-found` page when there is none. - -### Interaction recipe per role - -| Role | Hover | Active / pressed | Focus | Also | -| --- | --- | --- | --- | --- | -| Primary button | one token step darker | `scale(0.98)` or one more step darker | 2px ring with offset (`_focusVisible`) | disabled: reduced opacity, `cursor: not-allowed`; loading (disabled + spinner) while the form it submits is pending | -| Secondary / ghost / outline button | subtle tinted background | slightly stronger tint | same ring | same disabled | -| Text link / "더 보기 >" | colour change or underline | - | ring | a trailing chevron moves 2px on hover | -| Card that links | 2-4px lift + shadow token; inner image zooms 1.02-1.04 (overflow hidden) | lift removed | ring on the card | - | -| Nav item / tab | colour | - | ring | current route: `aria-current="page"` and the Figma active style (e.g. the underline the header draws) | -| Icon button | background circle or tint | press | ring | `aria-label` required | -| Input / select / textarea | border | - | border + ring | invalid state with its message; disabled | -| Accordion / disclosure / dropdown | - | - | ring | `aria-expanded`; the chevron rotates 180deg | -| Mobile menu / drawer | - | - | - | an open/close transition, when the design has one | - -**Restraint - hard rules.** Rich is not busy: - -- Figma variant states first; otherwise existing theme tokens only. No new - colours - record an assumption when nothing fits. -- At most two properties change per state. -- 150-250ms, ease or ease-out, one shared definition. -- No bounce, no infinite or attention-seeking animation; transforms of 4px or - less and scale between 0.97 and 1.05. -- Everything that moves is off under `_motionReduce`. -- Non-interactive elements get no hover effect. - -`status` counts every role against its recipe ("buttons 6/6 -hover+active+focus, cards 4/6 hover+focus"); that table is the record of which -recipe each element got. - -### View transitions - -Always on. DevFive runs the latest versions - the dependency step comes -first - and the latest Next App Router and React support React's -`` on vinext and on the `next` CLI alike, so no version is -checked. View Transitions are a web standard: browsers without them simply -navigate. - -- `import { ViewTransition } from 'react'`, wrapping each page's content in - its `page.tsx` (not the layout, which persists across navigations). -- A short cross-fade (about 250ms), no big slides, `animation-duration: 0s` - under `prefers-reduced-motion`, and `::view-transition { pointer-events: - none }`. -- Name shared elements only where the same element is on both pages - a page - title, a hero image, a card image and its detail page: a static name as the - devup-ui `viewTransitionName` prop, a per-record name on `` (devup-ui extracts style props at build time, so a runtime name - cannot be a style prop). -- **Verify it, do not doubt it.** auditPlan gives each URL one - `view-transition` check following its link to the next URL, placed after - every other check (the executor also returns to the URL afterwards). It - passes only when a view transition ran (`document.startViewTransition` - called, or a cross-document `pagereveal` carrying one). A failure is a bug to - fix and re-run, not an assumption. `experimental.viewTransition` goes into - the next config only if that check fails without it (vinext's own example - sets it). - -Skipped only when `.devup/screen-plan.config.json` opts out -(`viewTransitions: false`), or `updateDependencies: false` leaves the project -on a React older than 19.3. If the project already animates pages with its own -library (framer-motion, next-view-transitions, ...), the plan asks instead. -The "motion beyond hover" question is about scroll reveal only. - -**Asked, in the same up-front rounds** - only what needs a file or a fact: -the favicon and the share image (offered from existing files and Figma logo -or og nodes in scope; never generate or draw one), the site name and -production URL when the project does not say, analytics when the project -shows signs of it, scroll reveal ("None" unless the design shows motion), and -**your own questions**: pass `extraQuestions` to draft (same shape: `id`, -`header` of 30 characters or fewer, `question`, two to four `options` with the -recommended one first, `multiple`) for anything you would otherwise guess at -any point in the job - where a form submits, contact details, external links, -legal pages, a CMS or data source, the site's language. Collect every question +- **Ask with the question tool, never in prose.** Pass each round's + `questions` to your runtime's structured question tool (`question` in + OpenCode, `AskUserQuestion` in Claude Code, whatever yours is called). They + are already shaped for it - `id`, `header` (30 characters or fewer), + `question`, `options[{label, description}]` with the recommended option first + and marked " (Recommended)", and `multiple`. Custom answers are allowed. +- **Skip what does not apply.** A question with `when` is asked only if the + named question got that answer, e.g. a per-frame route only after "Separate + pages". Rounds are ordered so the answer it depends on is always already in + hand. +- **Stay inside the scope the user gave.** Only frames under the linked + Section, frame or page (or the Bridge selection) are candidates. Never widen + to the whole file or other pages on your own, and never build from trash, + archive, old, draft, test, copy or hidden areas: draft lists them under + `excluded` and asks once whether to accept that. +- **Never resolve a cross-section ambiguity yourself.** Frames that look like + one screen in different sections - device-named ones such as PC / Mobile + included - are a designer's structure. Ask the `sections:` question as + returned; it names the sections, frames, widths and node ids. +- **Frames marked for a design fix are reported, not built.** When the user + answers "The Figma structure is wrong: I will fix it", those frames are left + out, listed under "Design fixes requested" with what to move or rename, and + the rest of the work goes on without waiting. +- **The stack is DevFive's default.** Next App Router run by vinext, bun + workspaces, devup-ui, oxlint, the latest versions. A project with no app + starts from `dev-five-git/service-template` (a default, not a question); one + on the `next` CLI is asked once, up front, whether to migrate to vinext - + never migrated silently. +- **Pick the app by meaning, not by name order.** In a repository with + several apps, `apps/admin` is the admin console and a Figma design for a + public site is almost never for it. The plan decides from the app's name + (`front`, `web`, `site`, `www`, `landing`, `home` for the public site; + `admin`, `dashboard`, `console`, `backoffice`, `cms`, `manager`, `관리자` for + the console), its routes (sign-in, dashboard) and the frames' own names + (Home, About, 회사소개 vs Admin, 관리자, 회원관리, 대시보드). When the + evidence points one way it takes that app without asking and records why in + `defaults`. The bias is the public app unless there is clear admin + evidence: a file with both maps admin screens to the console and every other + screen to the public app (the route table's `app` column), and frames that + say nothing either way go to the public app too. Only when no app looks like + the public site is the app asked. +- **Never invent a route.** A route comes from an existing page built from the + frame, a nav link already bound to an href, an explicit `navigationTargets` + entry, the project's `routeAliases`, or the user's answer. A route inferred + from a layer name is `routeEvidence: "name-match"` and is put to the user. + `proposedRoute: null` with `routeEvidence: "UNVERIFIED"` stays null until the + user answers. + +## Site completeness + +A site built only from what Figma draws feels dead: no hover, no focus, no +pressed or current state, no transitions, no metadata, no icon. Hover plus a +transition is still not enough. The plan splits what a finished site takes in +two. + +**Filled in by you, without a question** - draft lists these as `defaults`, +and the final report repeats them: + +- the interaction recipe for each element's role (table below), on every + interactive element `interactiveCandidates` and the navigation checks find; +- one shared `transition`, 150-250ms, ease or ease-out, on the properties that + change, defined once (a token or shared constant), not per element; +- `_motionReduce` so `prefers-reduced-motion` gets no transform or animation; +- view transitions between pages, by the mechanism the plan chose from the + project's versions (below); +- root `metadata` (title template, description, `metadataBase` when the URL is + known) and per-page `title`, `description` and `openGraph` from the page's + Figma heading and lead text - the devfive-frontend SEO rule; `lang` on + ``; +- `alt` from the layer name or text, width and height on every image, and a + `not-found` page when there is none. + +### Interaction recipe per role + +| Role | Hover | Active / pressed | Focus | Also | +| --- | --- | --- | --- | --- | +| Primary button | one token step darker | `scale(0.98)` or one more step darker | 2px ring with offset (`_focusVisible`) | disabled: reduced opacity, `cursor: not-allowed`; loading (disabled + spinner) while the form it submits is pending | +| Secondary / ghost / outline button | subtle tinted background | slightly stronger tint | same ring | same disabled | +| Text link / "더 보기 >" | colour change or underline | - | ring | a trailing chevron moves 2px on hover | +| Card that links | 2-4px lift + shadow token; inner image zooms 1.02-1.04 (overflow hidden) | lift removed | ring on the card | - | +| Nav item / tab | colour | - | ring | current route: `aria-current="page"` and the Figma active style (e.g. the underline the header draws) | +| Icon button | background circle or tint | press | ring | `aria-label` required | +| Input / select / textarea | border | - | border + ring | invalid state with its message; disabled | +| Accordion / disclosure / dropdown | - | - | ring | `aria-expanded`; the chevron rotates 180deg | +| Mobile menu / drawer | - | - | - | an open/close transition, when the design has one | + +**Restraint - hard rules.** Rich is not busy: + +- Figma variant states first; otherwise existing theme tokens only. No new + colours - record an assumption when nothing fits. +- At most two properties change per state. +- 150-250ms, ease or ease-out, one shared definition. +- No bounce, no infinite or attention-seeking animation; transforms of 4px or + less and scale between 0.97 and 1.05. +- Everything that moves is off under `_motionReduce`. +- Non-interactive elements get no hover effect. + +`status` counts every role against its recipe ("buttons 6/6 +hover+active+focus, cards 4/6 hover+focus"); that table is the record of which +recipe each element got. + +### View transitions + +Always on. DevFive runs the latest versions - the dependency step comes +first - and the latest Next App Router and React support React's +`` on vinext and on the `next` CLI alike, so no version is +checked. View Transitions are a web standard: browsers without them simply +navigate. + +- `import { ViewTransition } from 'react'`, wrapping each page's content in + its `page.tsx` (not the layout, which persists across navigations). +- A short cross-fade (about 250ms), no big slides, `animation-duration: 0s` + under `prefers-reduced-motion`, and `::view-transition { pointer-events: + none }`. +- Name shared elements only where the same element is on both pages - a page + title, a hero image, a card image and its detail page: a static name as the + devup-ui `viewTransitionName` prop, a per-record name on `` (devup-ui extracts style props at build time, so a runtime name + cannot be a style prop). +- **Verify it, do not doubt it.** auditPlan gives each URL one + `view-transition` check following its link to the next URL, placed after + every other check (the executor also returns to the URL afterwards). It + passes only when a view transition ran (`document.startViewTransition` + called, or a cross-document `pagereveal` carrying one). A failure is a bug to + fix and re-run, not an assumption. `experimental.viewTransition` goes into + the next config only if that check fails without it (vinext's own example + sets it). + +Skipped only when `.devup/screen-plan.config.json` opts out +(`viewTransitions: false`), or `updateDependencies: false` leaves the project +on a React older than 19.3. If the project already animates pages with its own +library (framer-motion, next-view-transitions, ...), the plan asks instead. +The "motion beyond hover" question is about scroll reveal only. + +**Asked, in the same up-front rounds** - only what needs a file or a fact: +the favicon and the share image (offered from existing files and Figma logo +or og nodes in scope; never generate or draw one), the site name and +production URL when the project does not say, analytics when the project +shows signs of it, scroll reveal ("None" unless the design shows motion), and +**your own questions**: pass `extraQuestions` to draft (same shape: `id`, +`header` of 30 characters or fewer, `question`, two to four `options` with the +recommended one first, `multiple`) for anything you would otherwise guess at +any point in the job - where a form submits, contact details, external links, +legal pages, a CMS or data source, the site's language. Collect every question the whole job will need now. New user-owned decisions still use the question tool after confirm; ordinary implementation choices use `assume`. - -**Verified** - `devup_ui_validate` with `projectRoot` warns on interactive -elements with no focus style, on too much motion (over 400ms, scale above -1.05, infinite animation) and on icon buttons without `aria-label`, and -reports buttons without a pressed state, motion without `_motionReduce` and -nav links without `aria-current`; pages without metadata and an app without an -icon are warnings too. `status` reports site completeness item by item, -including a "view transitions: enabled (mechanism) / skipped (reason)" line. - + +**Verified** - `devup_ui_validate` with `projectRoot` warns on interactive +elements with no focus style, on too much motion (over 400ms, scale above +1.05, infinite animation) and on icon buttons without `aria-label`, and +reports buttons without a pressed state, motion without `_motionReduce` and +nav links without `aria-current`; pages without metadata and an app without an +icon are warnings too. `status` reports site completeness item by item, +including a "view transitions: enabled (mechanism) / skipped (reason)" line. + ## Steps Before listing screens, call `devup_figma_auth` status. For disconnected, out-of-view or mismatched-host states, load `figma-bridge-setup` and perform its computer-use setup yourself. Verify status and a small read before export. -1. **List the screens.** If you only have a Section link, `devup_figma_explore` - lists them; if you already hold the frame ids, you have the list. Then - `devup_project_context` with `scope: "routes"` shows the existing pages. -2. **Draft the plan.** Call `devup_screen_plan` with `action: "draft"`, - `projectRoot`, and the Section or frame `url` (or nothing, while one Devup - Bridge plugin is attached and the Section is selected). Pass `screens` from - step 1 (with each frame's `section`) to skip a second Figma read, and - `navigationTargets` for any Figma node -> route binding you were given - explicitly. It writes `.devup/screen-plan.json` and `.devup/screen-plan.md`. -3. **Show the plan, then ask everything.** Show the user the `screens` table - (Figma screen, widths, proposed route, evidence, existing file, shared - parts, open decisions), the `heuristics` list and the `excluded` list, then - show `proposalMarkdown` (every open decision, site questions included, with - its recommended answer) and ask ONE question: accept the table, or name the - rows to change. Never ask the rows one section or topic at a time. Keep the - original option labels in `answers`; they are what confirm reads. -4. **Confirm.** Call `devup_screen_plan` with `action: "confirm"`, the - `planId`, and `answers` keyed by question id (the chosen label, with or - without " (Recommended)", or the user's custom text) for every question - that applies. A refusal names the missing ids and returns them as rounds: - ask those right away, still before any implementation. -5. **Latest first, before implementing, without asking.** - a. At the repository root, run `bunx @dependency-check-updates/cli -d -u - --rm`. It updates every manifest in the repository to the latest - versions - every bun workspace and every Cargo.toml - in one recoverable - batch (`--recover rollback` restores the originals), and `--rm` removes - each lockfile and installed directory (`bun.lock`, `node_modules/`, - `Cargo.lock`, `target/`) so the next install resolves everything fresh. - b. `bun install`, then build, lint and typecheck. If the update breaks - something, fix the code to the new APIs. Pin a version back only when - no fix exists, and record the pin with its reason. - c. Before using a library API, check its current docs (context7 or the - library's site), not memory: what you remember is assumed stale. - Record the outcome with `devup_screen_plan` `assume` (`dependencyUpdate: - { bumped, pins }`); status shows it as the dependencies line. Skip only - when `.devup/screen-plan.config.json` says `updateDependencies: false`. +1. **List the screens.** If you only have a Section link, `devup_figma_explore` + lists them; if you already hold the frame ids, you have the list. Then + `devup_project_context` with `scope: "routes"` shows the existing pages. +2. **Draft the plan.** Call `devup_screen_plan` with `action: "draft"`, + `projectRoot`, and the Section or frame `url` (or nothing, while one Devup + Bridge plugin is attached and the Section is selected). Pass `screens` from + step 1 (with each frame's `section`) to skip a second Figma read, and + `navigationTargets` for any Figma node -> route binding you were given + explicitly. It writes `.devup/screen-plan.json` and `.devup/screen-plan.md`. +3. **Show the plan, then ask everything.** Show the user the `screens` table + (Figma screen, widths, proposed route, evidence, existing file, shared + parts, open decisions), the `heuristics` list and the `excluded` list, then + show `proposalMarkdown` (every open decision, site questions included, with + its recommended answer) and ask ONE question: accept the table, or name the + rows to change. Never ask the rows one section or topic at a time. Keep the + original option labels in `answers`; they are what confirm reads. +4. **Confirm.** Call `devup_screen_plan` with `action: "confirm"`, the + `planId`, and `answers` keyed by question id (the chosen label, with or + without " (Recommended)", or the user's custom text) for every question + that applies. A refusal names the missing ids and returns them as rounds: + ask those right away, still before any implementation. +5. **Latest first, before implementing, without asking.** + a. At the repository root, run `bunx @dependency-check-updates/cli -d -u + --rm`. It updates every manifest in the repository to the latest + versions - every bun workspace and every Cargo.toml - in one recoverable + batch (`--recover rollback` restores the originals), and `--rm` removes + each lockfile and installed directory (`bun.lock`, `node_modules/`, + `Cargo.lock`, `target/`) so the next install resolves everything fresh. + b. `bun install`, then build, lint and typecheck. If the update breaks + something, fix the code to the new APIs. Pin a version back only when + no fix exists, and record the pin with its reason. + c. Before using a library API, check its current docs (context7 or the + library's site), not memory: what you remember is assumed stale. + Record the outcome with `devup_screen_plan` `assume` (`dependencyUpdate: + { bumped, pins }`); status shows it as the dependencies line. Skip only + when `.devup/screen-plan.config.json` says `updateDependencies: false`. 6. **Extract components before pages.** The first implementation unit after confirm is component extraction, after the dependency prerequisite above. Export every used `COMPONENT_SET`/`COMPONENT` with `outputs: ["componentTsx"]` @@ -363,109 +363,109 @@ its computer-use setup yourself. Verify status and a small read before export. evidence; missing mapping coverage remains unverified, never a pass. **Implement URL by URL.** For each confirmed route, call - `devup_figma_export` with `projectRoot` and that screen's `frameIds`. With - `pageScaffold`, omit `route`: the export takes it from the plan and refuses - a different one. Add the plan's `defaults` (states, transition, reduced - motion, metadata, lang, alt, image sizes, not-found) as you go, and run + `devup_figma_export` with `projectRoot` and that screen's `frameIds`. With + `pageScaffold`, omit `route`: the export takes it from the plan and refuses + a different one. Add the plan's `defaults` (states, transition, reduced + motion, metadata, lang, alt, image sizes, not-found) as you go, and run `devup_ui_validate` with `projectRoot` on what you wrote. Record ordinary choices with `assume`; ask user-owned decisions with the question tool. Screens confirmed as a state of another page are built - inside that page. An export of a frame the confirmed plan does not place - answers `plan_required` and writes nothing: leave it unbuilt and record an - assumption - do not ask. An excluded or design-fix frame answers `refused`: - it is never built, and no assumption changes that. -7. **Plan the audit.** `devup_screen_plan` `action: "auditPlan"` with - `projectRoot`, `baseUrl` (default `http://localhost:3000`) and `artifacts`: - every `devup_figma_export` artifactId you hold for the plan's frames. It - writes one runtime-audit plan per URL under `.devup/runtime-audit//` - and the runner once under `.devup/runtime-audit/runner/`. Each plan checks, - at every width plus each drawn width (its viewport as tall as the frame): - page health (no page error, console error, error-boundary text, overflow - or clipped text), and at the drawn widths the page against its - referencePng, every section and asset against its own Figma render, and - the size and position of every section, card and asset - **exactly**. - Layout is converted statically, so at a drawn width it is either right or - wrong: only half a pixel of rounding is allowed (Chromium measures in - 1/64 px, unsnapped at deviceScaleFactor 1, and Figma's fractional values - are written rounded), and no tolerance can be set. The frames' fonts are - loaded first, so text boxes are exact too; a missing font is an - environment error. Between the drawn widths there is no design, so only - health, overflow, containment, clipped text and the nav are checked. A - page merged from one frame per width is found per width through - `data-figma-node-ids` (`"1440:12:34 390:56:78"`), and an element a width - does not draw is checked hidden there. A dynamic route (`[slug]`, `[id]`, - catch-all) is audited at concrete URLs: its `generateStaticParams` - (literal params, or the local data file it maps), else the links the - audited pages hold into it (run once, then auditPlan again), else a sample - from its Figma detail frame, recorded as an assumption - up to ten. Hover and focus - sweep every link and button; one view-transition check follows a link to - the next URL - placed last by code, so check order cannot break a run. - `missingReferences` lists every referencePng, native render or frame - export it lacks, with the export call that makes it: make them and run - auditPlan again. Nothing is skipped silently. -8. **Run it, URL by URL.** In the runner, `npm ci`, `npx playwright install - chromium`, then `node run.mjs --plan --out ` - with the app running. If the runner cannot be installed, call status with - `auditPlanPath` and `toolingUnavailable: ""`: that URL's audit is - inconclusive and the rest of the work goes on. - This real-browser step is required before claiming any page is done: build - and serve the actual generated app, not a hand-written stand-in. Static - checks and TSX snapshots cannot detect missing background images, wrong - colors or assets that never load. Run `image-loaded` and `page-health`, read - the browser report, and retain screenshots with their viewport and reference - identities. A report's `completed` means execution finished; a capture check's - `pass` means only that pixels were captured. Neither is a visual verdict. -9. **Read the status, fix, re-run.** `devup_screen_plan` `action: "status"` - with `auditPlanPath` and `auditReportPath`. Run every `visualComparisons` - call (`devup_visual_compare`, each with its `threshold` and `diffPath`) - and pass the results back as `visualResults` (`id`, `verdict`, - `changedRatio`, `diffPath`). Every failure comes back as a finding with - its class, Figma node, code file and fix hint: - - | class | what failed | first step | - | --- | --- | --- | - | `runtime-error` | the page threw, logged an error or showed an error page | read the first error; fix the component or the data state | - | `overflow` | content wider than the viewport, or text cut off by its box | `maxW="100%"`, wrap or `minW={0}`; let the box grow | - | `code-drift` | a box differs and its code no longer writes what the export wrote | restore the exported values; never hand-tune layout | - | `converter-bug` | a box differs and its code still equals the export | a devup-mcp defect: its repro is in `.devup/converter-bugs/.json`; file it. A local override needs a comment naming that file | - | `layout-position` / `layout-size` | a box differs and there is no export record to compare | re-export with `projectRoot`, then the class is one of the two above | - | `asset-mismatch` | an asset differs from its Figma render | a mask on a multi-colour SVG (use ``), the wrong file, crop or objectFit | - | `section-visual` | a section differs from its render | the diff PNG's highlighted area: font, background, spacing | - | `page-visual` | the page differs from its frame | fix sections first; then page background and section spacing | - | `interaction` | hover or focus changes nothing | the role's recipe from the defaults, and `_focusVisible` | - | `view-transition` | no transition ran on the navigation | `` around routed content, framework `Link` | - - Layout is exact; a mismatch is drift or a converter bug. Every export into - the project records what it wrote per node in `.devup/exports/`, and - status compares the element's code with it: a missing or changed exported - prop, or a layout prop the export did not write, is drift; extra behaviour - (handlers, hover styles, links) is not. A converter bug is reported in - `converterBugs` and the final report and never retried - a deterministic - result does not change. - - Fix every finding and re-run the URL, in the `workQueue` order. Pixels are - not layout: anti-aliasing does not fail, comparisons allow 3% changed pixels for a page or - section and 5% for an asset, and `.devup/screen-plan.config.json` - `visualThresholds` tunes them (`page`, `section`, `asset`, or `nodes` - keyed by node id for one asset) - raise one only for edge noise you have - looked at in its diff, never to pass a wrong shape. After - `maxFixAttempts` (default 3) fix-and-rerun rounds a finding is recorded as - `unresolved` with its screenshot and diff, and leaves the queue. + inside that page. An export of a frame the confirmed plan does not place + answers `plan_required` and writes nothing: leave it unbuilt and record an + assumption - do not ask. An excluded or design-fix frame answers `refused`: + it is never built, and no assumption changes that. +7. **Plan the audit.** `devup_screen_plan` `action: "auditPlan"` with + `projectRoot`, `baseUrl` (default `http://localhost:3000`) and `artifacts`: + every `devup_figma_export` artifactId you hold for the plan's frames. It + writes one runtime-audit plan per URL under `.devup/runtime-audit//` + and the runner once under `.devup/runtime-audit/runner/`. Each plan checks, + at every width plus each drawn width (its viewport as tall as the frame): + page health (no page error, console error, error-boundary text, overflow + or clipped text), and at the drawn widths the page against its + referencePng, every section and asset against its own Figma render, and + the size and position of every section, card and asset - **exactly**. + Layout is converted statically, so at a drawn width it is either right or + wrong: only half a pixel of rounding is allowed (Chromium measures in + 1/64 px, unsnapped at deviceScaleFactor 1, and Figma's fractional values + are written rounded), and no tolerance can be set. The frames' fonts are + loaded first, so text boxes are exact too; a missing font is an + environment error. Between the drawn widths there is no design, so only + health, overflow, containment, clipped text and the nav are checked. A + page merged from one frame per width is found per width through + `data-figma-node-ids` (`"1440:12:34 390:56:78"`), and an element a width + does not draw is checked hidden there. A dynamic route (`[slug]`, `[id]`, + catch-all) is audited at concrete URLs: its `generateStaticParams` + (literal params, or the local data file it maps), else the links the + audited pages hold into it (run once, then auditPlan again), else a sample + from its Figma detail frame, recorded as an assumption - up to ten. Hover and focus + sweep every link and button; one view-transition check follows a link to + the next URL - placed last by code, so check order cannot break a run. + `missingReferences` lists every referencePng, native render or frame + export it lacks, with the export call that makes it: make them and run + auditPlan again. Nothing is skipped silently. +8. **Run it, URL by URL.** In the runner, `npm ci`, `npx playwright install + chromium`, then `node run.mjs --plan --out ` + with the app running. If the runner cannot be installed, call status with + `auditPlanPath` and `toolingUnavailable: ""`: that URL's audit is + inconclusive and the rest of the work goes on. + This real-browser step is required before claiming any page is done: build + and serve the actual generated app, not a hand-written stand-in. Static + checks and TSX snapshots cannot detect missing background images, wrong + colors or assets that never load. Run `image-loaded` and `page-health`, read + the browser report, and retain screenshots with their viewport and reference + identities. A report's `completed` means execution finished; a capture check's + `pass` means only that pixels were captured. Neither is a visual verdict. +9. **Read the status, fix, re-run.** `devup_screen_plan` `action: "status"` + with `auditPlanPath` and `auditReportPath`. Run every `visualComparisons` + call (`devup_visual_compare`, each with its `threshold` and `diffPath`) + and pass the results back as `visualResults` (`id`, `verdict`, + `changedRatio`, `diffPath`). Every failure comes back as a finding with + its class, Figma node, code file and fix hint: + + | class | what failed | first step | + | --- | --- | --- | + | `runtime-error` | the page threw, logged an error or showed an error page | read the first error; fix the component or the data state | + | `overflow` | content wider than the viewport, or text cut off by its box | `maxW="100%"`, wrap or `minW={0}`; let the box grow | + | `code-drift` | a box differs and its code no longer writes what the export wrote | restore the exported values; never hand-tune layout | + | `converter-bug` | a box differs and its code still equals the export | a devup-mcp defect: its repro is in `.devup/converter-bugs/.json`; file it. A local override needs a comment naming that file | + | `layout-position` / `layout-size` | a box differs and there is no export record to compare | re-export with `projectRoot`, then the class is one of the two above | + | `asset-mismatch` | an asset differs from its Figma render | a mask on a multi-colour SVG (use ``), the wrong file, crop or objectFit | + | `section-visual` | a section differs from its render | the diff PNG's highlighted area: font, background, spacing | + | `page-visual` | the page differs from its frame | fix sections first; then page background and section spacing | + | `interaction` | hover or focus changes nothing | the role's recipe from the defaults, and `_focusVisible` | + | `view-transition` | no transition ran on the navigation | `` around routed content, framework `Link` | + + Layout is exact; a mismatch is drift or a converter bug. Every export into + the project records what it wrote per node in `.devup/exports/`, and + status compares the element's code with it: a missing or changed exported + prop, or a layout prop the export did not write, is drift; extra behaviour + (handlers, hover styles, links) is not. A converter bug is reported in + `converterBugs` and the final report and never retried - a deterministic + result does not change. + + Fix every finding and re-run the URL, in the `workQueue` order. Pixels are + not layout: anti-aliasing does not fail, comparisons allow 3% changed pixels for a page or + section and 5% for an asset, and `.devup/screen-plan.config.json` + `visualThresholds` tunes them (`page`, `section`, `asset`, or `nodes` + keyed by node id for one asset) - raise one only for edge noise you have + looked at in its diff, never to pass a wrong shape. After + `maxFixAttempts` (default 3) fix-and-rerun rounds a finding is recorded as + `unresolved` with its screenshot and diff, and leaves the queue. 10. **Continue independent work.** An ordinary implementation choice after - confirm goes to `action: "defer"` (`decision: {id, about, question, - options, recommended, destructive}`). Keep working: items it blocks move to - the back of their URL's queue. When everything else is done, take each one - with `assume` (`about`: the decision id, `decision`: the recommended - option). A destructive one (deleting, overwriting, reaching outside the - project) stays unbuilt and is listed in the report. -11. **Done means zero fails.** A URL is done only after its actual Chromium - screenshots have been compared with independently exported Figma originals, - every check and comparison passes at its drawn widths, and health and - overflow pass at every width. Inspect the page/section/asset diffs for - background images, colors and loaded assets; feed every result back to - status. Missing references, unavailable browser tooling, not-run comparisons, - converter bugs and unresolved findings remain incomplete with evidence. - Preserve screenshots, reports, Figma reference identities and diffs. + confirm goes to `action: "defer"` (`decision: {id, about, question, + options, recommended, destructive}`). Keep working: items it blocks move to + the back of their URL's queue. When everything else is done, take each one + with `assume` (`about`: the decision id, `decision`: the recommended + option). A destructive one (deleting, overwriting, reaching outside the + project) stays unbuilt and is listed in the report. +11. **Done means zero fails.** A URL is done only after its actual Chromium + screenshots have been compared with independently exported Figma originals, + every check and comparison passes at its drawn widths, and health and + overflow pass at every width. Inspect the page/section/asset diffs for + background images, colors and loaded assets; feed every result back to + status. Missing references, unavailable browser tooling, not-run comparisons, + converter bugs and unresolved findings remain incomplete with evidence. + Preserve screenshots, reports, Figma reference identities and diffs. Before reporting done, also: - run `bun run lint:fix` first, then `bunx oxlint --deny-warnings` at the root. @@ -476,122 +476,122 @@ its computer-use setup yourself. Verify status and a small read before export. Do not run `bunx prettier --write` or biome: their defaults produce a different style (semicolons and double quotes), undoing the project's lint formatting. - every confirmed URL is implemented; - - the build passes: `bun run build` (`vinext build` for every app); - - lint and typecheck pass: `bun run lint` (its prelint runs cargo clippy, fmt - and check, then oxlint), `bunx oxlint --deny-warnings`, and `bun tsc - --noEmit` inside each app you changed (TS 7); `bun test` passes. `bun run - test` also runs `cargo tarpaulin` afterwards, which does not work on - Windows - there, run `bun test` and `cargo test`; - - `devup_ui_validate` reports no errors; + - the build passes: `bun run build` (`vinext build` for every app); + - lint and typecheck pass: `bun run lint` (its prelint runs cargo clippy, fmt + and check, then oxlint), `bunx oxlint --deny-warnings`, and `bun tsc + --noEmit` inside each app you changed (TS 7); `bun test` passes. `bun run + test` also runs `cargo tarpaulin` afterwards, which does not work on + Windows - there, run `bun test` and `cargo test`; + - `devup_ui_validate` reports no errors; - the production build carries no audit ids: DevupUI strips them by default. Do not change app build config for auditing or register a strip plugin. `DEVUP_AUDIT=1` keeps opted-in attributes for audit builds; inspect the ordinary production output to verify they are absent; - - the site completeness block is filled. -12. **Finish with the final report.** End your reply with status's - `finalReport`: the URL x width table (health, geometry, drift / converter - bugs, page, section, asset, interaction, screenshot, diffs), the converter - bugs with their repro files, the unresolved findings with their - evidence, the pending decisions and how each was taken, the defaults, the - design fixes requested, the excluded frames, the assumptions and the - dependencies line - and its one-line `summary`. Report every `fail`, - `not-run`, unresolved finding and assumption as what it is. - -Hard rules, after confirm: - -- Visually verify every screen, section and asset at every drawn width. -- Never stop to wait; reorder the work. -- Done = a real browser render and zero failed or missing comparisons; - unresolved findings are reported as incomplete with evidence. -- Layout is exact; a mismatch is drift or a converter bug, never tuned. - -## What the questions mean - -| id | asked when | resolves to | -| --- | --- | --- | -| `scope` | the link itself points into a trash, archive or draft area | plan it anyway, or build nothing | -| `exclusions` | frames were excluded by name or because they are hidden | accept, or review each (`exclusion:` / `exclusion-route:`) | -| `group:` | one name drawn at several widths in one section | one responsive page, separate pages, or one route per frame (custom `/a, /b`) | -| `sections:` | one name in different sections | separate pages, one responsive page, or the Figma structure is wrong | -| `structure::` | a duplicate, a screen split across sections, or a frame outside any section | keep as is, or the Figma structure is wrong | -| `split-route:` | `when` its group is separate pages | that frame's URL, skip, or not a page | -| `route:` | no evidence names a route, or evidence conflicts | the URL, skip, or not a page | -| `heuristics` | a route was inferred from a layer name | accept all, or review individually | -| `review:` | `when` heuristics is review | keep (merge or overwrite), another URL, or skip | -| `modal:` | the layer name looks like a modal or overlay | a state on a host page, or its own URL | -| `collision:` | an evidence-backed route already has a page | merge, overwrite or skip | -| `dynamic:` | the route has a `[segment]` | sample data, or wire the API now | -| `link::` | a link's destination is not in the plan | a route, or unlinked | -| `layout:` | a part appears on several screens and no layout renders it | shared layout, or per page | - -A `group` question recommends one responsive page: the widths are in one -section with matching names. A `sections` question recommends separate pages, -unless every section name is a device name (`PC`, `Mobile`, `Tablet`, -`모바일`) - and then it says the recommendation was inferred from those names. - -## Project config - -Designers name things differently, so a project can say how its own do it in -an optional `.devup/screen-plan.config.json`. Each entry present counts as -evidence and replaces the question it would otherwise take: - -```json -{ - "modalKeywords": ["popup", "sheet"], - "routeAliases": { "메인": "/", "회사소개": "/company" }, - "widthGrouping": "responsive", - "excludeKeywords": ["trash", "archive", "legacy"], - "includeKeywords": ["test drive"], - "favicon": "app/icon.svg", - "ogImage": "public/og.png", - "siteName": "Prime", - "siteUrl": "https://prime.example", - "motion": "none", - "framework": "vinext", - "updateDependencies": true, - "maxFixAttempts": 3, - "visualThresholds": { "asset": 0.05 } -} -``` - -- `modalKeywords` replaces the built-in modal words. -- `routeAliases` maps a layer name to a route. -- `widthGrouping` is `responsive` (merge same-named widths in one section - into one page), `separate` (never merge), or `ask` (the default: ask per - group). Frames in different sections are always asked. -- `excludeKeywords` replaces the built-in words that mark an area as not for - implementation (trash/휴지통, archive/아카이브, old/구버전/이전, - deprecated/사용안함/미사용, backup/백업, draft/WIP/작업중, test/테스트, - sandbox, playground, copy/사본). -- `includeKeywords` forces names containing them back in. -- `favicon`, `ogImage` (a path, or `figma:`), `siteName`, `siteUrl` - and `motion` (`none` | `reveal`) pre-answer the site questions. -- `framework` (`vinext` | `next`) answers the migrate-to-vinext question, - `updateDependencies: false` skips the dependency step for a project that - forbids it, and `viewTransitions: false` opts out of view transitions. + - the site completeness block is filled. +12. **Finish with the final report.** End your reply with status's + `finalReport`: the URL x width table (health, geometry, drift / converter + bugs, page, section, asset, interaction, screenshot, diffs), the converter + bugs with their repro files, the unresolved findings with their + evidence, the pending decisions and how each was taken, the defaults, the + design fixes requested, the excluded frames, the assumptions and the + dependencies line - and its one-line `summary`. Report every `fail`, + `not-run`, unresolved finding and assumption as what it is. + +Hard rules, after confirm: + +- Visually verify every screen, section and asset at every drawn width. +- Never stop to wait; reorder the work. +- Done = a real browser render and zero failed or missing comparisons; + unresolved findings are reported as incomplete with evidence. +- Layout is exact; a mismatch is drift or a converter bug, never tuned. + +## What the questions mean + +| id | asked when | resolves to | +| --- | --- | --- | +| `scope` | the link itself points into a trash, archive or draft area | plan it anyway, or build nothing | +| `exclusions` | frames were excluded by name or because they are hidden | accept, or review each (`exclusion:` / `exclusion-route:`) | +| `group:` | one name drawn at several widths in one section | one responsive page, separate pages, or one route per frame (custom `/a, /b`) | +| `sections:` | one name in different sections | separate pages, one responsive page, or the Figma structure is wrong | +| `structure::` | a duplicate, a screen split across sections, or a frame outside any section | keep as is, or the Figma structure is wrong | +| `split-route:` | `when` its group is separate pages | that frame's URL, skip, or not a page | +| `route:` | no evidence names a route, or evidence conflicts | the URL, skip, or not a page | +| `heuristics` | a route was inferred from a layer name | accept all, or review individually | +| `review:` | `when` heuristics is review | keep (merge or overwrite), another URL, or skip | +| `modal:` | the layer name looks like a modal or overlay | a state on a host page, or its own URL | +| `collision:` | an evidence-backed route already has a page | merge, overwrite or skip | +| `dynamic:` | the route has a `[segment]` | sample data, or wire the API now | +| `link::` | a link's destination is not in the plan | a route, or unlinked | +| `layout:` | a part appears on several screens and no layout renders it | shared layout, or per page | + +A `group` question recommends one responsive page: the widths are in one +section with matching names. A `sections` question recommends separate pages, +unless every section name is a device name (`PC`, `Mobile`, `Tablet`, +`모바일`) - and then it says the recommendation was inferred from those names. + +## Project config + +Designers name things differently, so a project can say how its own do it in +an optional `.devup/screen-plan.config.json`. Each entry present counts as +evidence and replaces the question it would otherwise take: + +```json +{ + "modalKeywords": ["popup", "sheet"], + "routeAliases": { "메인": "/", "회사소개": "/company" }, + "widthGrouping": "responsive", + "excludeKeywords": ["trash", "archive", "legacy"], + "includeKeywords": ["test drive"], + "favicon": "app/icon.svg", + "ogImage": "public/og.png", + "siteName": "Prime", + "siteUrl": "https://prime.example", + "motion": "none", + "framework": "vinext", + "updateDependencies": true, + "maxFixAttempts": 3, + "visualThresholds": { "asset": 0.05 } +} +``` + +- `modalKeywords` replaces the built-in modal words. +- `routeAliases` maps a layer name to a route. +- `widthGrouping` is `responsive` (merge same-named widths in one section + into one page), `separate` (never merge), or `ask` (the default: ask per + group). Frames in different sections are always asked. +- `excludeKeywords` replaces the built-in words that mark an area as not for + implementation (trash/휴지통, archive/아카이브, old/구버전/이전, + deprecated/사용안함/미사용, backup/백업, draft/WIP/작업중, test/테스트, + sandbox, playground, copy/사본). +- `includeKeywords` forces names containing them back in. +- `favicon`, `ogImage` (a path, or `figma:`), `siteName`, `siteUrl` + and `motion` (`none` | `reveal`) pre-answer the site questions. +- `framework` (`vinext` | `next`) answers the migrate-to-vinext question, + `updateDependencies: false` skips the dependency step for a project that + forbids it, and `viewTransitions: false` opts out of view transitions. - Figma node attributes are off by default. Where the exporter supports it, `figmaNodeIds: true` explicitly opts in; do not send unsupported parameters to an older server. DevupUI strips these attributes by default and `DEVUP_AUDIT=1` keeps them. Do not change app build config for auditing. Missing audit anchors are unverified, not a reason to claim checks passed. -- `maxFixAttempts` (default 3) is how many fix-and-rerun rounds a finding gets - before it is unresolved. -- Geometry has no tolerance to configure; it is exact. -- `visualThresholds` sets the changed-pixel ratio the comparisons allow: +- `maxFixAttempts` (default 3) is how many fix-and-rerun rounds a finding gets + before it is unresolved. +- Geometry has no tolerance to configure; it is exact. +- `visualThresholds` sets the changed-pixel ratio the comparisons allow: `{ "page": 0.03, "section": 0.03, "asset": 0.05, "nodes": { "12:34": 0.08 } }`. -Connected URLs: run `links-resolve` at every width. Each same-origin anchor contributes source URL, accessible label, href, resolved pathname and width to `linkTable` (also Markdown in `finalReport`). GET each distinct target once, including concrete dynamic hrefs, and reject HTTP failures, error pages and `unplanned-target` URLs absent from the confirmed plan and existing routes. Any failing link row blocks done. - -## Staged split, contract-first data and decisions - -These rules apply to every plan this workflow produces. - -- Split into stages. Stage 0 (supervisor): shared layout and repeated components such as Header, Footer, PageHero and SubNav (a component repeated across frames with the same structure and different text is shared), `devup.json` tokens, shared assets, backend foundation. Stage 1 (parallel workers): one worker per top-level menu or Figma group, each with its model, API, client and pages together. Stage 2 (supervisor): API wiring, `devup_stack_diff` zero drift, runtime audit, visual compare. Parallelism is the number of independent groups, not the number of routes. -- Shared change requests: a worker never edits a shared component; it sends a request (what, why, diff) and the supervisor applies it once. -- Confirm the ERD and the per-screen endpoint contract (list with page/sort/category/query, detail, create with field rules) in the same plan, before writing any page that shows data. -- Done means real data, real handlers and no leftovers: no hard-coded fixture arrays standing in for an endpoint, no `TODO` or "replace when" comments, no inputs without a handler, no form submit without a network call, every referenced public asset present. A "wire to the API now" answer needs an `operationId` that exists in `openapi.json`; otherwise the screen is reported unfinished. +Connected URLs: run `links-resolve` at every width. Each same-origin anchor contributes source URL, accessible label, href, resolved pathname and width to `linkTable` (also Markdown in `finalReport`). GET each distinct target once, including concrete dynamic hrefs, and reject HTTP failures, error pages and `unplanned-target` URLs absent from the confirmed plan and existing routes. Any failing link row blocks done. + +## Staged split, contract-first data and decisions + +These rules apply to every plan this workflow produces. + +- Split into stages. Stage 0 (supervisor): shared layout and repeated components such as Header, Footer, PageHero and SubNav (a component repeated across frames with the same structure and different text is shared), `devup.json` tokens, shared assets, backend foundation. Stage 1 (parallel workers): one worker per top-level menu or Figma group, each with its model, API, client and pages together. Stage 2 (supervisor): API wiring, `devup_stack_diff` zero drift, runtime audit, visual compare. Parallelism is the number of independent groups, not the number of routes. +- Shared change requests: a worker never edits a shared component; it sends a request (what, why, diff) and the supervisor applies it once. +- Confirm the ERD and the per-screen endpoint contract (list with page/sort/category/query, detail, create with field rules) in the same plan, before writing any page that shows data. +- Done means real data, real handlers and no leftovers: no hard-coded fixture arrays standing in for an endpoint, no `TODO` or "replace when" comments, no inputs without a handler, no form submit without a network call, every referenced public asset present. A "wire to the API now" answer needs an `operationId` that exists in `openapi.json`; otherwise the screen is reported unfinished. - Ask every user-owned decision with the runtime question tool, before or after confirm: recommended option first, labelled "(Recommended)", two to four options and a header up to 30 characters. Show evidence in prose, never a numbered chat question ending with "tell me the numbers to change". Keep table approval separate from individual decisions in the same question call. Workers escalate to the supervisor. Record the chosen answer, decision id, affected model/endpoint/file and rationale with existing `defer`/`assume` actions, never an invented `ask` action. Model changes require source model updates and re-confirmation before dependent work. If no question tool exists, report the limitation and leave the user-owned decision unresolved; continue independent work. -- After editing code, run `bun run lint:fix` first and fix by hand only what it cannot fix; do not re-sort JSX props or reformat by hand, and do not run a bare `prettier`. +- After editing code, run `bun run lint:fix` first and fix by hand only what it cannot fix; do not re-sort JSX props or reformat by hand, and do not run a bare `prettier`. ## Wrong-Width Component Masters Treat `DEVUP_DESIGN_COMPONENT_WIDTH_MISMATCH` as an automatically resolved design placement error. The screen plan records the strict same-width majority, the expected master and a correct sibling instance, plus native-node PNG comparisons when available (otherwise `render=unavailable`). Follow that assumption without another question. Header/Footer/Nav remains one responsive component across widths, not a second variant. Audit planning substitutes the correct sibling's height and children at screen width and the suspect's original y position. The original whole-page PNG still depicts the design error and must not be accepted as a corrected-page visual reference. diff --git a/crates/devup-mcp/src/server/skills/devup/SKILL.md b/crates/devup-mcp/src/server/skills/devup/SKILL.md index 9964aee6..e2fbbd5c 100644 --- a/crates/devup-mcp/src/server/skills/devup/SKILL.md +++ b/crates/devup-mcp/src/server/skills/devup/SKILL.md @@ -1,161 +1,161 @@ ---- -name: devup -description: | - Start any DevFive project, feature, bug fix, Figma page implementation or - refactor with evidence-based project intake, all questions up front, ERD - models, a confirmed plan and supervisor work units. The Figma branch retains - screen planning, latest-first updates, vinext, exact layout, interaction states, - metadata, runtime/visual audits and the final URL x width table. - - Invoke as /devup [sources] [goal]. Also use the Figma branch when asked to - build pages or a site from a design, even without an explicit /devup. ---- - +--- +name: devup +description: | + Start any DevFive project, feature, bug fix, Figma page implementation or + refactor with evidence-based project intake, all questions up front, ERD + models, a confirmed plan and supervisor work units. The Figma branch retains + screen planning, latest-first updates, vinext, exact layout, interaction states, + metadata, runtime/visual audits and the final URL x width table. + + Invoke as /devup [sources] [goal]. Also use the Figma branch when asked to + build pages or a site from a design, even without an explicit /devup. +--- + ## Project intake comes first When replacing an inlined instance with a shared component, carry over every `instanceOverrides` entry through a new variant or prop, or explicitly report it as unresolved. Never discard `// @devup-override` annotations or the required `DEVUP_CODEGEN_INSTANCE_OVERRIDE` review item silently. For a confirmed multi-width URL, `responsiveTsx` is the deliverable. Export all sibling frameIds in width order using screen-plan status's next export action. Never start from single-width `tsx`/`componentTsx` for its page or sections. Only wire links/events/data; never change generated layout values, collapse arrays, or add hard-coded sizes/layout wrappers. Review `generatedExport.generatedDrift` in status; its confidence-labelled findings are heuristic and missing mappings remain unverified. - -`/devup` is the entry point for new projects, existing features, bug fixes, -Figma page implementation and refactors. Figma screen planning is a branch -of the confirmed project plan; complete this section before the screen workflow below. - -Read sources before asking: `devup_project_context` for every supported scope, -Atlassian MCP `getJiraIssue` / `searchJiraIssuesUsingJql` for issues, epic children -and dependencies, referenced Confluence/spec documents, and Figma exploration -(screen names, field labels, forms, tables and detail screens). Pass each read as -`evidence: [{source, facts}]` to `devup_project_plan`. Facts use `goal`, `parts`, -`figma` (links), `screens` ([{name, fields}]), and `.screens`, `.states`, -`.permissions`, `.forms`, `.notifications`. Backend decisions use `erd`, `tenancy`, -`auth`, `lifecycle`, `indexes`, `storage`; delivery uses `jiraPolicy`, `branchPolicy`, -`prPolicy`. A sourced fact is a default with its provenance, not another question. -Sources are read by the runtime's connectors, never by treating a URL as its contents. - -1. `devup_project_plan intake` with projectRoot: always ask the four initial - questions with the runtime question tool (recommended option first): work type - (refactor may be a custom answer), optional Figma/Jira/spec sources, one-sentence - goal, and parts. Existing `apis/` recommends front + admin + API; - `apps/admin` recommends front + admin. Retain planId on every later call. -2. Read newly named sources. `draft` with all initial answers, source-labelled - evidence and `features: [{id,scope,screens,acceptance}]` or - `issues: [{key,scope,dependencies,acceptance}]` (epic children are issues too). - Each unresolved feature gets concrete screen/role, state, permission, - validation, notification/integration rounds. Put all remaining unknowns into - extraQuestions. No implementation starts while a question remains unanswered. -3. API in scope: inspect existing `vespertide.json` and `apis/*/models/*.json`. - Draft complete `entities` as vespertide model objects and `relations` with - from/to, cardinality oneToMany|manyToMany, column/joinTable and onDelete. - Signup/post Figma evidence seeds user/post entities and a user FK, but the - agent must expand every actual domain from the source evidence. Review the - ERD round, ownership/tenancy, auth with integer roles (0,10,50,100), status - enums, soft delete, audit timestamps, nullable fields, unique/composite keys, - filter/sort indexes, file/image storage and cascade/restrict/set_null actions. - Read https://github.com/dev-five-git/vespertide/blob/main/SKILL.md and schemas/. - Use snake_case, `$schema` on every model, inline PK/FK/unique/index constraints, - and table constraints only for CHECK. A manyToMany relation generates its join - table. modelsDir is relative to projectRoot; set it explicitly for multiple APIs. - Re-draft structured entities for requested corrections before confirming. -4. When Figma is present, run the existing screen-plan draft/rounds/confirm - workflow (routes, groups, exclusions, site facts) before project confirm. -5. Ask all delivery questions now: Jira none/comment/transition, branch, PR. - `devup_project_plan confirm` requires every applicable answer and writes source - models plus confirmed state in one transaction. Then in modelsWorkingDirectory - run the returned commands, in order: `vespertide diff`, `vespertide sql`, - `vespertide revision -m "confirmed project plan"`, `vespertide export --orm seaorm`. - The tool returns commands; the runtime executes and checks them. Never hand-write - migrations/ or src/models/. Build vespera CRUD and feature routes, openapi.json, - and the devup-api client; `devup_stack_diff` must show zero drift before done. -6. Latest-first updates, vinext, exact layout, all interaction/site defaults, - runtime/visual audits and the existing never-wait rules below still apply. - After confirm, use project-plan defer with id/reason/recommended/destructive; - continue independent work. At the end assume with no decisions resolves all - pending recommendations; destructive decisions remain unbuilt. Status includes - ERD Mermaid, decisions and the unit table; retain unresolved evidence in reports. - -## Supervisor execution - -`devup_project_plan split` splits work, not files. Jira input produces one unit -per issue/sub-task with its explicit dependencies (reject cycles/unknown IDs). -Without Jira, it produces shared layout/theme first, then each feature's -models -> API -> client -> front chain; front-only features depend on shared. -Review cross-feature dependencies before dispatch; the supervisor sequences -shared database work to avoid concurrent schema edits. Each unit has acceptance -criteria and build/lint/typecheck/tests/validate/runtime/visual/stack-diff checks. -`.devup/units/.md` contains scope, decisions, ERD and screen context, and checklist. - -Default `unitQuestions: "supervisor"` means workers never ask the user: questions -were settled across all units during intake, and supervisor ownership avoids -repeated questions and keeps decisions consistent. `unitQuestions: "worker"` -is an explicit alternative, still subject to the confirmed never-wait policy. -Load `orca-cli` and `orchestration` skills to create one worktree per unit, -dispatch the DAG in parallel where independent, collect worker_done, and rerun -each unit's checks yourself before merging. New decisions use orchestration -ask/reply to the supervisor: answer from recorded decisions, or defer with a -recommended option while the worker continues other work. Send failed checks -back to the same worker session. Update unit states with status unitUpdates -(pending -> running -> verifying -> merged, or unresolved); merged needs recorded -supervisor verification. Without Orca, execute the same units sequentially in one -session. Only under the recorded Jira/PR policy, comment results and PR link and -transition issues. Final report includes the unit table, DAG, ERD and unresolved -items; never claim completion from split or command generation alone. - - - -# /devup [sources] [goal] - -Arguments: $ARGUMENTS - optional Figma links, Jira keys, spec URLs/files and a -one-sentence goal. With one Devup Bridge plugin, its selection supplies the -Figma anchor when a Figma implementation has no link. - -This is the devup-implementation-plan workflow, started explicitly. Follow it -in this order and do not skip a step. The devup-implementation-plan skill has -the full rules; these are the steps. - + +`/devup` is the entry point for new projects, existing features, bug fixes, +Figma page implementation and refactors. Figma screen planning is a branch +of the confirmed project plan; complete this section before the screen workflow below. + +Read sources before asking: `devup_project_context` for every supported scope, +Atlassian MCP `getJiraIssue` / `searchJiraIssuesUsingJql` for issues, epic children +and dependencies, referenced Confluence/spec documents, and Figma exploration +(screen names, field labels, forms, tables and detail screens). Pass each read as +`evidence: [{source, facts}]` to `devup_project_plan`. Facts use `goal`, `parts`, +`figma` (links), `screens` ([{name, fields}]), and `.screens`, `.states`, +`.permissions`, `.forms`, `.notifications`. Backend decisions use `erd`, `tenancy`, +`auth`, `lifecycle`, `indexes`, `storage`; delivery uses `jiraPolicy`, `branchPolicy`, +`prPolicy`. A sourced fact is a default with its provenance, not another question. +Sources are read by the runtime's connectors, never by treating a URL as its contents. + +1. `devup_project_plan intake` with projectRoot: always ask the four initial + questions with the runtime question tool (recommended option first): work type + (refactor may be a custom answer), optional Figma/Jira/spec sources, one-sentence + goal, and parts. Existing `apis/` recommends front + admin + API; + `apps/admin` recommends front + admin. Retain planId on every later call. +2. Read newly named sources. `draft` with all initial answers, source-labelled + evidence and `features: [{id,scope,screens,acceptance}]` or + `issues: [{key,scope,dependencies,acceptance}]` (epic children are issues too). + Each unresolved feature gets concrete screen/role, state, permission, + validation, notification/integration rounds. Put all remaining unknowns into + extraQuestions. No implementation starts while a question remains unanswered. +3. API in scope: inspect existing `vespertide.json` and `apis/*/models/*.json`. + Draft complete `entities` as vespertide model objects and `relations` with + from/to, cardinality oneToMany|manyToMany, column/joinTable and onDelete. + Signup/post Figma evidence seeds user/post entities and a user FK, but the + agent must expand every actual domain from the source evidence. Review the + ERD round, ownership/tenancy, auth with integer roles (0,10,50,100), status + enums, soft delete, audit timestamps, nullable fields, unique/composite keys, + filter/sort indexes, file/image storage and cascade/restrict/set_null actions. + Read https://github.com/dev-five-git/vespertide/blob/main/SKILL.md and schemas/. + Use snake_case, `$schema` on every model, inline PK/FK/unique/index constraints, + and table constraints only for CHECK. A manyToMany relation generates its join + table. modelsDir is relative to projectRoot; set it explicitly for multiple APIs. + Re-draft structured entities for requested corrections before confirming. +4. When Figma is present, run the existing screen-plan draft/rounds/confirm + workflow (routes, groups, exclusions, site facts) before project confirm. +5. Ask all delivery questions now: Jira none/comment/transition, branch, PR. + `devup_project_plan confirm` requires every applicable answer and writes source + models plus confirmed state in one transaction. Then in modelsWorkingDirectory + run the returned commands, in order: `vespertide diff`, `vespertide sql`, + `vespertide revision -m "confirmed project plan"`, `vespertide export --orm seaorm`. + The tool returns commands; the runtime executes and checks them. Never hand-write + migrations/ or src/models/. Build vespera CRUD and feature routes, openapi.json, + and the devup-api client; `devup_stack_diff` must show zero drift before done. +6. Latest-first updates, vinext, exact layout, all interaction/site defaults, + runtime/visual audits and the existing never-wait rules below still apply. + After confirm, use project-plan defer with id/reason/recommended/destructive; + continue independent work. At the end assume with no decisions resolves all + pending recommendations; destructive decisions remain unbuilt. Status includes + ERD Mermaid, decisions and the unit table; retain unresolved evidence in reports. + +## Supervisor execution + +`devup_project_plan split` splits work, not files. Jira input produces one unit +per issue/sub-task with its explicit dependencies (reject cycles/unknown IDs). +Without Jira, it produces shared layout/theme first, then each feature's +models -> API -> client -> front chain; front-only features depend on shared. +Review cross-feature dependencies before dispatch; the supervisor sequences +shared database work to avoid concurrent schema edits. Each unit has acceptance +criteria and build/lint/typecheck/tests/validate/runtime/visual/stack-diff checks. +`.devup/units/.md` contains scope, decisions, ERD and screen context, and checklist. + +Default `unitQuestions: "supervisor"` means workers never ask the user: questions +were settled across all units during intake, and supervisor ownership avoids +repeated questions and keeps decisions consistent. `unitQuestions: "worker"` +is an explicit alternative, still subject to the confirmed never-wait policy. +Load `orca-cli` and `orchestration` skills to create one worktree per unit, +dispatch the DAG in parallel where independent, collect worker_done, and rerun +each unit's checks yourself before merging. New decisions use orchestration +ask/reply to the supervisor: answer from recorded decisions, or defer with a +recommended option while the worker continues other work. Send failed checks +back to the same worker session. Update unit states with status unitUpdates +(pending -> running -> verifying -> merged, or unresolved); merged needs recorded +supervisor verification. Without Orca, execute the same units sequentially in one +session. Only under the recorded Jira/PR policy, comment results and PR link and +transition issues. Final report includes the unit table, DAG, ERD and unresolved +items; never claim completion from split or command generation alone. + + + +# /devup [sources] [goal] + +Arguments: $ARGUMENTS - optional Figma links, Jira keys, spec URLs/files and a +one-sentence goal. With one Devup Bridge plugin, its selection supplies the +Figma anchor when a Figma implementation has no link. + +This is the devup-implementation-plan workflow, started explicitly. Follow it +in this order and do not skip a step. The devup-implementation-plan skill has +the full rules; these are the steps. + 1. **Connection.** `devup_figma_auth` with `action: "status"`. With a Bridge plugin attached nothing needs a login; do not ask for one. For disconnected, out-of-view or mismatched-host states, load `figma-bridge-setup` and perform its computer-use steps yourself, then verify status and a small read. -2. **Skills.** `devup_skills` with `action: "status"` and this `projectRoot`; - if anything is missing or outdated, `action: "install"`, then load the - installed skills (devup-ui, devfive-frontend, devup-implementation-plan). -3. **Project context.** `devup_project_context` with `scope: "theme"` (the - tokens the code must use) and `scope: "routes"` (the pages that exist, and - `frameworks`: which app runs on vinext or next). No app yet: the plan - starts from `dev-five-git/service-template`. With several apps the plan - picks the one the frames are for by meaning - the public site unless the - frames clearly say admin. -4. **Screen plan.** `devup_screen_plan` with `action: "draft"`, `projectRoot`, - the link, the notes and `extraQuestions` - every question you would - otherwise have to guess at any point in the job (form target, contact - details, external links, legal pages, data source, language). It returns - the screens, the excluded areas, the defaults it will fill in, and every - open question - routes, sections and structure, the site questions - (favicon, share image, site name and URL, analytics, scroll reveal) and - yours. -5. **Show the proposal table, ask once.** Show draft's `proposalMarkdown` - (every open decision with its recommended answer) in the language the user - first wrote in, and ask ONE question with your question tool: accept, or +2. **Skills.** `devup_skills` with `action: "status"` and this `projectRoot`; + if anything is missing or outdated, `action: "install"`, then load the + installed skills (devup-ui, devfive-frontend, devup-implementation-plan). +3. **Project context.** `devup_project_context` with `scope: "theme"` (the + tokens the code must use) and `scope: "routes"` (the pages that exist, and + `frameworks`: which app runs on vinext or next). No app yet: the plan + starts from `dev-five-git/service-template`. With several apps the plan + picks the one the frames are for by meaning - the public site unless the + frames clearly say admin. +4. **Screen plan.** `devup_screen_plan` with `action: "draft"`, `projectRoot`, + the link, the notes and `extraQuestions` - every question you would + otherwise have to guess at any point in the job (form target, contact + details, external links, legal pages, data source, language). It returns + the screens, the excluded areas, the defaults it will fill in, and every + open question - routes, sections and structure, the site questions + (favicon, share image, site name and URL, analytics, scroll reveal) and + yours. +5. **Show the proposal table, ask once.** Show draft's `proposalMarkdown` + (every open decision with its recommended answer) in the language the user + first wrote in, and ask ONE question with your question tool: accept, or name the rows to change. Collect known decisions in one question call. -6. **Confirm.** `devup_screen_plan` with `action: "confirm"`, the `planId`, - `acceptRecommended: true` and only the changed rows in `answers`. If - confirm reports two screens on one URL, retry with its - `details.retryAnswers` merged into your answers. -7. **Latest first, before implementing, without asking.** - a. At the repository root, run `bunx @dependency-check-updates/cli -d -u - --rm`. It updates every manifest in the repository to the latest - versions - every bun workspace and every Cargo.toml - in one recoverable - batch (`--recover rollback` restores the originals), and `--rm` removes - each lockfile and installed directory (`bun.lock`, `node_modules/`, - `Cargo.lock`, `target/`) so the next install resolves everything fresh. - b. `bun install`, then build, lint and typecheck. If the update breaks - something, fix the code to the new APIs. Pin a version back only when - no fix exists, and record the pin with its reason. - c. Before using a library API, check its current docs (context7 or the - library's site), not memory: what you remember is assumed stale. - Record the outcome with `devup_screen_plan` `assume` (`dependencyUpdate: - { bumped, pins }`); status shows it as the dependencies line. Skip only - when `.devup/screen-plan.config.json` says `updateDependencies: false`. +6. **Confirm.** `devup_screen_plan` with `action: "confirm"`, the `planId`, + `acceptRecommended: true` and only the changed rows in `answers`. If + confirm reports two screens on one URL, retry with its + `details.retryAnswers` merged into your answers. +7. **Latest first, before implementing, without asking.** + a. At the repository root, run `bunx @dependency-check-updates/cli -d -u + --rm`. It updates every manifest in the repository to the latest + versions - every bun workspace and every Cargo.toml - in one recoverable + batch (`--recover rollback` restores the originals), and `--rm` removes + each lockfile and installed directory (`bun.lock`, `node_modules/`, + `Cargo.lock`, `target/`) so the next install resolves everything fresh. + b. `bun install`, then build, lint and typecheck. If the update breaks + something, fix the code to the new APIs. Pin a version back only when + no fix exists, and record the pin with its reason. + c. Before using a library API, check its current docs (context7 or the + library's site), not memory: what you remember is assumed stale. + Record the outcome with `devup_screen_plan` `assume` (`dependencyUpdate: + { bumped, pins }`); status shows it as the dependencies line. Skip only + when `.devup/screen-plan.config.json` says `updateDependencies: false`. 8. **Extract components before pages.** The first implementation unit after confirm exports used `COMPONENT_SET`/`COMPONENT` nodes with `outputs: ["componentTsx"]` to `components/common` or `components/layout`. @@ -166,48 +166,48 @@ the full rules; these are the steps. file mapping and explicit rendered targets, not DOM Figma attributes. Unsupported section exports or instance overrides remain reported limitations. **Implement URL by URL.** `devup_figma_export` with - `projectRoot` and each screen's `frameIds`; `pageScaffold` takes its route - from the plan. Add the plan's `defaults` as you go: each interactive - element's role recipe (hover, pressed, focus ring, current page, expanded, - disabled, loading - restrained: at most two properties per state, - 150-250ms, existing tokens only), one shared transition, `_motionReduce`, - view transitions by the plan's mechanism, root and per-page `metadata` with - openGraph, `lang`, alt text, image dimensions, a not-found page. Run + `projectRoot` and each screen's `frameIds`; `pageScaffold` takes its route + from the plan. Add the plan's `defaults` as you go: each interactive + element's role recipe (hover, pressed, focus ring, current page, expanded, + disabled, loading - restrained: at most two properties per state, + 150-250ms, existing tokens only), one shared transition, `_motionReduce`, + view transitions by the plan's mechanism, root and per-page `metadata` with + openGraph, `lang`, alt text, image dimensions, a not-found page. Run `devup_ui_validate` with `projectRoot` on what you wrote. Ordinary choices use `action: "assume"`; new user-owned decisions use the question tool. -9. **Audit plan.** `devup_screen_plan` with `action: "auditPlan"`, - `baseUrl` and `artifacts` (every export artifactId of the plan's frames). - One plan per URL: health, overflow and clipped text at every width; the - page, each section and each asset against its Figma render and the size - and position of each section, card and asset at the drawn widths - - exact, half a pixel of rounding and no tolerance; merged pages per width - (`data-figma-node-ids`); dynamic routes at concrete URLs; hover - and focus; a view-transition check, last. Export whatever - `missingReferences` names and run auditPlan again. -10. **Run, status, fix - URL by URL.** Run each plan with the runner it wrote, - then `action: "status"` with `auditPlanPath` and `auditReportPath`. Run - every `visualComparisons` call and pass the results back as - `visualResults`. Fix each finding (its class, node, file and fix hint) in - `workQueue` order and re-run. A geometry mismatch is `code-drift` (restore - the export's values) or `converter-bug` (repro in `.devup/converter-bugs/`, - reported, never retried). After `maxFixAttempts` (default 3) a finding - is unresolved with its evidence. Runner cannot install: - `toolingUnavailable`, and carry on. +9. **Audit plan.** `devup_screen_plan` with `action: "auditPlan"`, + `baseUrl` and `artifacts` (every export artifactId of the plan's frames). + One plan per URL: health, overflow and clipped text at every width; the + page, each section and each asset against its Figma render and the size + and position of each section, card and asset at the drawn widths - + exact, half a pixel of rounding and no tolerance; merged pages per width + (`data-figma-node-ids`); dynamic routes at concrete URLs; hover + and focus; a view-transition check, last. Export whatever + `missingReferences` names and run auditPlan again. +10. **Run, status, fix - URL by URL.** Run each plan with the runner it wrote, + then `action: "status"` with `auditPlanPath` and `auditReportPath`. Run + every `visualComparisons` call and pass the results back as + `visualResults`. Fix each finding (its class, node, file and fix hint) in + `workQueue` order and re-run. A geometry mismatch is `code-drift` (restore + the export's values) or `converter-bug` (repro in `.devup/converter-bugs/`, + reported, never retried). After `maxFixAttempts` (default 3) a finding + is unresolved with its evidence. Runner cannot install: + `toolingUnavailable`, and carry on. 11. **Continue independent work.** An ordinary choice mid-run: `action: "defer"` with the - recommended option, keep working, take it with `assume` at the end; a - destructive one stays unbuilt. -12. **Done = zero fails.** A real Chromium render is mandatory before claiming - any page is done. Build and serve the generated app, execute the Playwright - runner, read its report and compare the actual screenshots with independent - Figma references. Static checks, TSX snapshots, a generated audit plan and a - capture check's `pass` do not prove visual correctness. Verify background - images, colors and loaded assets; inspect page/section/asset diffs and feed - every visual result back to status. Missing references, unavailable browser - tooling, failed assets and not-run comparisons mean incomplete. Preserve the - screenshots, reference identity, report and diffs in the final evidence. - Every check and comparison passes at the drawn - widths and health and overflow pass at every required width. Report filed - converter bugs and unresolved findings with evidence as incomplete. Also: every + recommended option, keep working, take it with `assume` at the end; a + destructive one stays unbuilt. +12. **Done = zero fails.** A real Chromium render is mandatory before claiming + any page is done. Build and serve the generated app, execute the Playwright + runner, read its report and compare the actual screenshots with independent + Figma references. Static checks, TSX snapshots, a generated audit plan and a + capture check's `pass` do not prove visual correctness. Verify background + images, colors and loaded assets; inspect page/section/asset diffs and feed + every visual result back to status. Missing references, unavailable browser + tooling, failed assets and not-run comparisons mean incomplete. Preserve the + screenshots, reference identity, report and diffs in the final evidence. + Every check and comparison passes at the drawn + widths and health and overflow pass at every required width. Report filed + converter bugs and unresolved findings with evidence as incomplete. Also: every URL implemented, the production build free of `data-figma-node-id`. DevupUI strips audit attributes by default; `DEVUP_AUDIT=1` keeps them. Figma node attributes are off by default; opt in with `figmaNodeIds: true` @@ -221,36 +221,36 @@ Formatting is owned by eslint-plugin-devup's `devup(prettier)` rule inside oxlin Do not run `bunx prettier --write` or biome: their defaults produce a different style (semicolons and double quotes), undoing the project's lint formatting. Then run `bun run build`, `bun run lint`, `bun tsc --noEmit` - per changed app, `bun test` (on Windows `bun test` and `cargo test`), - `devup_ui_validate` without errors, site completeness filled. -13. **Final report.** End with status's `finalReport` and `summary`: URL x - width (health, geometry, page, section, asset, interaction, screenshot, - diffs), unresolved findings with evidence, pending decisions taken, - assumptions, design fixes, exclusions, defaults and the dependencies line. - -Hard rules: visually verify every screen, section and asset at every drawn -width; never stop to wait, reorder the work; done = every required cell passes; -report unresolved cells with evidence as incomplete; layout is exact, a mismatch is drift or a -converter bug. - + per changed app, `bun test` (on Windows `bun test` and `cargo test`), + `devup_ui_validate` without errors, site completeness filled. +13. **Final report.** End with status's `finalReport` and `summary`: URL x + width (health, geometry, page, section, asset, interaction, screenshot, + diffs), unresolved findings with evidence, pending decisions taken, + assumptions, design fixes, exclusions, defaults and the dependencies line. + +Hard rules: visually verify every screen, section and asset at every drawn +width; never stop to wait, reorder the work; done = every required cell passes; +report unresolved cells with evidence as incomplete; layout is exact, a mismatch is drift or a +converter bug. + Run the runtime audit for every confirmed URL at every required width (default 1440/1100/858/390), read its report, fix and rerun until every cell passes. Missing cells are not-run and block done; require rendered completeness and page/section/asset comparisons at drawn widths. Ask known site, structure and URL decisions up front in one question call, not per-section rounds. New user-owned decisions during the build still use the question tool. - -## Staged execution and shared work first - -`devup_project_plan split` is staged, not one flat chain per feature: - -1. Stage 0, supervisor alone: shared parts that repeat across routes (Header, Footer, a hero or sub-navigation that appears on many frames, navigation data), the `devup.json` tokens, one export of every shared asset, and the backend foundation (vespertide models, entities, migrations, `openapi.json`, client). Nothing else starts before it is merged. -2. Stage 1, parallel: one worker per top-level menu or Figma group. A feature that uses tables gets one `model-` unit per table (shared by every feature that uses it), then api, client and front units. A feature with no tables gets only a front unit. Never run two units that edit the same table at once. -3. Stage 2, supervisor: front-to-API wiring check, `devup_stack_diff` with zero drift, runtime audit, visual compare. - -Run only as many workers at once as there are independent groups, not as many as there are routes. Each brief must be usable on its own: the frames to export with their URLs, the exports already done, the shared components to import (path and props), what the worker must not change, and the checks to rerun. A worker that needs a shared component changed does not edit it; it reports a shared-change request (what, why, diff) to the supervisor, who applies it once and tells every unit that depends on it. - -## ERD and endpoint contract before the front - -Confirm the ERD together with the screens, before any page is written. From the Figma frames derive the data each screen needs: repeating cards or table rows (list endpoint with page, sort, category and query parameters), detail layouts (detail endpoint), forms (create endpoint and field rules), search boxes, filters, sorts and page numbers. Show the user one table of entities and columns, the endpoint contract per screen, and the Mermaid ERD, and confirm it once. - -A screen is not finished while its data is a hard-coded array, a `TODO` or "replace when API exists" comment, a control with no handler, or a form that changes local state without a network call. If the user answers "wire to the API now", record the `operationId` and check that it exists in `openapi.json` before you call the screen done; if it does not, say the screen is unfinished instead of faking it. Anything left on a fixture must be recorded with `defer` and listed as unfinished in the final report. A public asset a page references that is missing from `public/` is a failure to fix by exporting it, not something to skip. - + +## Staged execution and shared work first + +`devup_project_plan split` is staged, not one flat chain per feature: + +1. Stage 0, supervisor alone: shared parts that repeat across routes (Header, Footer, a hero or sub-navigation that appears on many frames, navigation data), the `devup.json` tokens, one export of every shared asset, and the backend foundation (vespertide models, entities, migrations, `openapi.json`, client). Nothing else starts before it is merged. +2. Stage 1, parallel: one worker per top-level menu or Figma group. A feature that uses tables gets one `model-
` unit per table (shared by every feature that uses it), then api, client and front units. A feature with no tables gets only a front unit. Never run two units that edit the same table at once. +3. Stage 2, supervisor: front-to-API wiring check, `devup_stack_diff` with zero drift, runtime audit, visual compare. + +Run only as many workers at once as there are independent groups, not as many as there are routes. Each brief must be usable on its own: the frames to export with their URLs, the exports already done, the shared components to import (path and props), what the worker must not change, and the checks to rerun. A worker that needs a shared component changed does not edit it; it reports a shared-change request (what, why, diff) to the supervisor, who applies it once and tells every unit that depends on it. + +## ERD and endpoint contract before the front + +Confirm the ERD together with the screens, before any page is written. From the Figma frames derive the data each screen needs: repeating cards or table rows (list endpoint with page, sort, category and query parameters), detail layouts (detail endpoint), forms (create endpoint and field rules), search boxes, filters, sorts and page numbers. Show the user one table of entities and columns, the endpoint contract per screen, and the Mermaid ERD, and confirm it once. + +A screen is not finished while its data is a hard-coded array, a `TODO` or "replace when API exists" comment, a control with no handler, or a form that changes local state without a network call. If the user answers "wire to the API now", record the `operationId` and check that it exists in `openapi.json` before you call the screen done; if it does not, say the screen is unfinished instead of faking it. Anything left on a fixture must be recorded with `defer` and listed as unfinished in the final report. A public asset a page references that is missing from `public/` is a failure to fix by exporting it, not something to skip. + ## Decisions go through the question tool This rule takes precedence over "never ask during the build" and "never wait" diff --git a/crates/devup-mcp/src/server/visual_compare.rs b/crates/devup-mcp/src/server/visual_compare.rs index ce8cd2b0..8f9e3325 100644 --- a/crates/devup-mcp/src/server/visual_compare.rs +++ b/crates/devup-mcp/src/server/visual_compare.rs @@ -1,978 +1,978 @@ -//! Pure PNG comparison and consumer-attested renderer environment assessment. -//! PNG bytes stay in memory and use the existing bounded artifact resource store. -use super::{ - artifacts::{ArtifactRequestKey, ArtifactStore}, - delivery::{ProjectedOutput, choose_delivery_for_result}, - output::OutputPolicy, - tools::VisualCompareInput, -}; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use devup_mcp_figma::{ - CollectedPayload, CollectionRequest, CollectionScope, CollectionStats, DevupError, ErrorCode, - FigmaTarget, PayloadCompleteness, Snapshot, -}; -use devup_mcp_visual::{CompareOptions, VisualStatus, compare_png_bytes}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(untagged)] -pub enum VisualReference { - Asset(AssetReference), - Artifact(ArtifactReference), - Path(PathReference), -} -/// A validated native Figma node PNG captured by devup_figma_export. Raw fill -/// captures cannot serve as a reference for their own canvas appearance. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = serde_json::json!({"not": {}})))] -pub struct AssetReference { - pub artifact_id: String, - pub asset_id: String, -} -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[schemars(extend("additionalProperties" = serde_json::json!({"not": {}})))] -pub struct ArtifactReference { - pub artifact_id: String, -} -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(deny_unknown_fields)] -#[schemars(extend("additionalProperties" = serde_json::json!({"not": {}})))] -pub struct PathReference { - pub path: String, -} - -/// Consumer declarations are echoed, never fetched or executed. Optional fields -/// allow an incomplete run manifest to produce an explicit inconclusive result. -#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct VisualEnvironment { - pub schema_version: Option, - pub tsx_resource: Option, - pub reference_resource: Option, - pub viewport: Option, - pub theme_path: Option, - pub asset_directory: Option, - pub font_manifest: Option>, - pub renderer: Option, - /// Optional independently expected renderer pin; disagreement is invalid. - pub expected_renderer: Option, - pub devup_ui_version: Option, - pub actual_png: Option, - /// Consumer must set environment-invalid for missing fonts/assets, failed - /// readiness, console/runtime errors, or any mismatch with its pinned CI. - #[schemars(extend("enum" = ["valid", "environment-invalid"]))] - pub environment_status: Option, - pub os: Option, - pub locale: Option, - pub timezone: Option, -} -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct ResourcePin { - pub uri: String, - pub sha256: String, -} -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct Viewport { - pub width: u32, - pub height: u32, - pub device_scale_factor: f64, -} -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct FontPin { - pub family: String, - pub sha256: String, -} -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct RendererPin { - pub name: String, - pub version: String, -} - -fn invalid(message: &str) -> DevupError { - DevupError::new(ErrorCode::DevupCodegenFailed, message, false) -} -fn hash(bytes: &[u8]) -> String { - Sha256::digest(bytes) - .iter() - .map(|b| format!("{b:02x}")) - .collect() -} -fn pinned(value: &str) -> bool { - !value.trim().is_empty() -} -fn sha(value: &str) -> bool { - value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) -} - -fn assess_environment( - input: &VisualCompareInput, - dimensions: [u32; 2], - reference_hash: &str, -) -> Value { - let mut issues = Vec::new(); - if let Some(e) = &input.environment { - if e.schema_version != Some(1) { - issues.push("schemaVersion must be 1"); - } - if e.environment_status.as_deref() != Some("valid") { - issues.push("consumer did not attest a valid environment"); - } - for (value, issue) in [ - (&e.theme_path, "themePath missing"), - (&e.asset_directory, "assetDirectory missing"), - (&e.devup_ui_version, "devupUiVersion missing"), - (&e.os, "OS pin missing"), - (&e.locale, "locale missing"), - (&e.timezone, "timezone missing"), - ] { - if !value.as_deref().is_some_and(pinned) { - issues.push(issue); - } - } - if e.actual_png.as_deref() != Some(input.actual.as_str()) { - issues.push("actualPng must identify the supplied actual path"); - } - if !e - .viewport - .as_ref() - .is_some_and(|v| [v.width, v.height] == dimensions && v.device_scale_factor == 1.0) - { - issues.push("viewport or deviceScaleFactor missing or mismatched; capture reference dimensions at scale 1"); - } - if !e - .renderer - .as_ref() - .is_some_and(|r| pinned(&r.name) && pinned(&r.version)) - { - issues.push("renderer name/version pin missing"); - } - if e.expected_renderer.is_some() && e.expected_renderer != e.renderer { - issues.push("renderer pin mismatch"); - } - if !e - .font_manifest - .as_ref() - .is_some_and(|fonts| fonts.iter().all(|f| pinned(&f.family) && sha(&f.sha256))) - { - issues.push("fontManifest missing or font hashes invalid"); - } - if !e - .tsx_resource - .as_ref() - .is_some_and(|r| pinned(&r.uri) && sha(&r.sha256)) - { - issues.push("tsxResource URI/hash missing or invalid"); - } - if !e - .reference_resource - .as_ref() - .is_some_and(|r| pinned(&r.uri) && r.sha256.eq_ignore_ascii_case(reference_hash)) - { - issues.push("referenceResource URI/hash missing or mismatched"); - } - } else { - issues.push("consumer environment manifest missing"); - } - json!({"status":if issues.is_empty(){"valid"}else{"environment-invalid"},"issues":issues, - "declaration":input.environment,"trust":"consumer-attested; PNGs cannot verify fonts, assets, readiness, runtime errors, or CI pins"}) -} - -pub(super) async fn compare( - mut input: VisualCompareInput, - policy: &OutputPolicy, - store: &ArtifactStore, -) -> Result { - let delivery = input.delivery.parse()?; - let options = CompareOptions { - max_changed_ratio: input.threshold.unwrap_or(0.005), - ..CompareOptions::default() - }; - if !(0.0..=1.0).contains(&options.max_changed_ratio) { - return Err(invalid("threshold must be between 0 and 1 inclusive.")); - } - // Resolve every supplied path before any PNG read. - let actual_target = policy.resolve(&input.actual)?; - let reference_target = match &input.reference { - VisualReference::Path(r) => Some(policy.resolve(&r.path)?), - _ => None, - }; - let (reference, provenance, artifact) = match &input.reference { - VisualReference::Asset(r) => { - if r.asset_id.trim().is_empty() { - return Err(invalid( - "assetId must identify a captured native PNG asset.", - )); - } - let artifact = store.get_for_export(&r.artifact_id).await?; - let asset = artifact.payload.assets.iter().find(|a| a.asset_id == r.asset_id) - .ok_or_else(|| invalid("Reference asset is not captured in this artifact; export it with renderMode node and format png first."))?; - let info = asset.render_info.as_ref(); - if asset.status != devup_mcp_figma::AssetStatus::Exported - || asset.format != Some(devup_mcp_figma::AssetFormat::Png) - || asset.mime_type.as_deref() != Some("image/png") - || info.is_none_or(|i| i["representation"] != "native-node") - { - return Err(invalid( - "Asset reference requires a successful native-node PNG capture; raw fills, fallback geometry, failed or unknown captures are not canvas references.", - )); - } - let bytes = STANDARD - .decode(asset.data_base64.as_deref().unwrap_or_default()) - .map_err(|_| invalid("Reference asset base64 is invalid."))?; - if bytes.is_empty() - || bytes.len() > 16 * 1024 * 1024 - || asset.byte_length != Some(bytes.len()) - || asset.sha256.as_deref() != Some(hash(&bytes).as_str()) - { - return Err(invalid( - "Reference asset length or hash mismatch; reacquire it.", - )); - } - let provenance = json!({"kind":"asset","artifactId":r.artifact_id,"assetId":r.asset_id,"nodeId":asset.node_id, - "referenceSha256":asset.sha256,"target":artifact.payload.target,"sourceVersion":artifact.payload.source_version, - "renderMode":asset.render_mode,"renderInfo":asset.render_info,"scope":"native-node-export; ancestor canvas compositing unverified"}); - (bytes, provenance, Some(artifact)) - } - VisualReference::Artifact(r) => { - let artifact = store.get_for_export(&r.artifact_id).await?; - let png = artifact.payload.reference_png.as_ref().ok_or_else(|| DevupError::with_details( - ErrorCode::DevupFigmaHandoffInvalid, - "This artifact holds no referencePng. Call devup_figma_export with the original url and outputs containing referencePng, then retry with the returned artifactId.", false, - json!({"recoveryState":"recoverable","nextAction":{"tool":"devup_figma_export","outputs":["referencePng"]}})))?; - if png.byte_length > 16 * 1024 * 1024 - || png.data_base64.len() > (16 * 1024 * 1024_usize).div_ceil(3) * 4 - { - return Err(invalid( - "Cached referencePng exceeds the compressed byte limit.", - )); - } - let bytes = STANDARD.decode(&png.data_base64).map_err(|_| { - invalid("Cached referencePng is invalid; reacquire it with devup_figma_export.") - })?; - if bytes.len() != png.byte_length || hash(&bytes) != png.sha256 { - return Err(invalid( - "Cached referencePng hash mismatch; reacquire it with devup_figma_export.", - )); - } - let provenance = json!({"kind":"artifact","artifactId":artifact.artifact_id,"referenceSha256":png.sha256, - "target":artifact.payload.target,"sourceVersion":artifact.payload.source_version,"expiresAtEpochSeconds":artifact.expires_at_epoch_seconds}); - (bytes, provenance, Some(artifact)) - } - VisualReference::Path(_) => { - let target = reference_target.as_ref().expect("resolved reference"); - let bytes = target.read_bounded(16 * 1024 * 1024)?; - let provenance = - json!({"kind":"path","path":target.display_path(),"referenceSha256":hash(&bytes)}); - (bytes, provenance, None) - } - }; - let actual = actual_target.read_bounded(16 * 1024 * 1024)?; - let actual_hash = hash(&actual); - let asset_comparison = matches!(&input.reference, VisualReference::Asset(_)); - let webp_actual = image::guess_format(&actual).ok() == Some(image::ImageFormat::WebP); - if webp_actual && !asset_comparison { - return Err(invalid( - "WebP actuals require a native PNG asset reference (artifactId plus assetId). Page/card screenshots and their environment checks continue to require PNG.", - )); - } - let diff_target = input - .diff_path - .as_deref() - .map(|path| policy.resolve(path)) - .transpose()?; - let include_diff = input.include_diff || diff_target.is_some(); - let reference_hash = hash(&reference); - let (report, diff) = tokio::task::spawn_blocking(move || { - let actual = if webp_actual { - use image::ImageDecoder; - let mut limits = image::Limits::default(); limits.max_image_width=Some(8192); limits.max_image_height=Some(8192); limits.max_alloc=Some(64*1024*1024); - let mut decoder=image::codecs::webp::WebPDecoder::new(std::io::Cursor::new(actual)).map_err(|_| "Invalid WebP actual.")?; - decoder.set_limits(limits).map_err(|_| "WebP actual exceeds bounded decode limits.")?; - if decoder.has_animation() {return Err("Animated WebP actuals cannot establish a static asset comparison; provide a static WebP or an explicit PNG screenshot.");} - let (w,h)=decoder.dimensions(); - if u64::from(w)*u64::from(h)>devup_mcp_figma::MAX_WEBP_PIXELS { return Err("WebP actual exceeds 4194304 decoded pixels."); } - let image=image::DynamicImage::from_decoder(decoder).map_err(|_| "Malformed WebP actual.")?; - let mut png=std::io::Cursor::new(Vec::new());image.write_to(&mut png,image::ImageFormat::Png).map_err(|_| "WebP comparison pixel preparation failed.")?;png.into_inner() - } else {actual}; - compare_png_bytes(&reference,&actual,&options,include_diff).map_err(|_| "Invalid PNG or threshold; PNG limits are 16 MiB compressed, 8192 pixels per axis, and 64 MiB decoded.") - }) - .await.map_err(|_| invalid("PNG comparison could not complete."))? - .map_err(invalid)?; - if report.status == VisualStatus::InvalidDimensions { - return Err(DevupError::with_details( - ErrorCode::DevupCodegenFailed, - "PNG dimensions mismatch; render actual.png at reference dimensions without scaling.", - false, - json!({"verdict":"inconclusive","environment":{"status":"environment-invalid","issues":["image dimensions mismatch"]}, - "referenceDimensions":report.reference_dimensions,"actualDimensions":report.actual_dimensions,"reference":provenance}), - )); - } - // A reference this server captured from Figma and still holds is pinned - // by what the server read, so a consumer need not restate its hash. - let mut filled = Vec::new(); - if let (VisualReference::Artifact(reference), Some(declared)) = - (&input.reference, input.environment.as_mut()) - && declared.reference_resource.is_none() - { - declared.reference_resource = Some(ResourcePin { - uri: format!("devup://artifact/{}", reference.artifact_id), - sha256: reference_hash.clone(), - }); - filled.push("referenceResource"); - } - let mut environment = if asset_comparison { - json!({"status":"valid","scope":"asset-pixels-only","issues":[],"trust":"server-validated native PNG reference and allowlisted actual raster; no browser environment required"}) - } else { - assess_environment(&input, report.reference_dimensions, &reference_hash) - }; - if !filled.is_empty() { - environment["filledByServer"] = json!(filled); - } - let passed = report.passed(); - let verdict = if environment["status"] != "valid" { - "inconclusive" - } else if passed { - "pass" - } else { - "fail" - }; - let mut visual = - serde_json::to_value(report).map_err(|_| invalid("Cannot serialize visual metrics."))?; - visual["passed"] = json!(passed); - let mut result = - json!({"verdict":verdict,"visual":visual,"environment":environment,"reference":provenance}); - result["comparisonScope"] = json!(if asset_comparison { - "asset-pixels-against-native-node-export" - } else { - "consumer-render-against-reference" - }); - result["actual"] = json!({"path":actual_target.display_path(),"sha256":actual_hash}); - result["actual"]["mimeType"] = json!(if webp_actual { - "image/webp" - } else { - "image/png" - }); - result["actual"]["pixelMetricsInput"] = json!(if webp_actual { - "decoded-WebP; temporary PNG encoding for comparison" - } else { - "PNG" - }); - if asset_comparison { - result["canvasFidelity"] = json!("unverified-outside-reference-node-subtree"); - } - if let (Some(target), Some(bytes)) = (diff_target, diff.as_ref()) { - let display = target.display_path().display().to_string(); - let mut transaction = super::output::OutputTransaction::new(); - transaction.stage("diff", target, bytes)?; - transaction.commit()?; - result["diffPath"] = json!(display); - } - let diff = diff.filter(|_| input.include_diff); - if let Some(bytes) = diff { - result["diffPng"] = json!({"mimeType":"image/png","dataBase64":STANDARD.encode(&bytes)}); - let outputs = vec![ProjectedOutput::binary("diff.png", "image/png", bytes)]; - if !choose_delivery_for_result(delivery, &result, &outputs)?.inline { - // Paths also need a bounded, expiring resource owner. Its random - // identity and content-free payload never contain PNGs in cache keys. - let artifact = match artifact { - Some(a) => a, - None => { - let target = FigmaTarget { - file_key: format!("visual-{:032x}", rand::random::()), - node_id: None, - branch_key: None, - }; - let request = CollectionRequest::new(target.clone(), CollectionScope::Node); - let payload = CollectedPayload { - target: target.clone(), - scope: CollectionScope::Node, - metadata: json!({"kind":"visual-comparison"}), - snapshot: Snapshot { - file_key: target.file_key, - version: None, - roots: vec![], - nodes: BTreeMap::new(), - diagnostics: vec![], - }, - variables: None, - styles: None, - completeness: PayloadCompleteness::ResolvedValuesOnly, - source_version: None, - stats: CollectionStats::default(), - assets: vec![], - reference_png: None, - failures: vec![], - }; - store - .insert(ArtifactRequestKey::from_collection(&request), payload) - .await? - } - }; - let projection_key = format!("visual-{:032x}", rand::random::()); - let manifests = store - .attach_outputs(&artifact.artifact_id, &projection_key, outputs) - .await?; - result.as_object_mut().expect("object").remove("diffPng"); - result["resources"]=json!(manifests.iter().map(|m|json!({"uri":m.manifest_uri,"name":m.name,"mimeType":m.mime_type,"size":m.raw_bytes,"contentHash":m.sha256,"expiresAt":super::format_epoch_rfc3339(m.expires_at_epoch_seconds)})).collect::>()); - } - } - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - async fn run( - input: Value, - policy: &OutputPolicy, - store: &ArtifactStore, - ) -> Result { - let input = serde_json::from_value(input) - .map_err(|_| invalid("Invalid visual comparison input."))?; - compare(input, policy, store).await - } - use serde_json::json; - - struct Fixture(std::path::PathBuf); - impl Fixture { - fn new() -> Self { - let root = std::env::temp_dir().join(format!( - "visual-tool-{}-{}", - std::process::id(), - rand::random::() - )); - std::fs::create_dir_all(&root).unwrap(); - Self(root) - } - fn png(&self, name: &str, png: &str) { - std::fs::write(self.0.join(name), STANDARD.decode(png).unwrap()).unwrap(); - } - fn policy(&self) -> OutputPolicy { - OutputPolicy::from_roots(vec![self.0.clone()]).unwrap() - } - } - impl Drop for Fixture { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - fn reference_hash() -> String { - hash(&STANDARD.decode(WHITE).unwrap()) - } - fn environment() -> Value { - json!({"schemaVersion":1,"tsxResource":{"uri":"devup://artifact/test/manifest","sha256":"a".repeat(64)},"referenceResource":{"uri":"devup://artifact/test/manifest","sha256":reference_hash()},"viewport":{"width":1,"height":1,"deviceScaleFactor":1},"themePath":"devup.json","assetDirectory":"public/assets","fontManifest":[{"family":"Test","sha256":"b".repeat(64)}],"renderer":{"name":"playwright-chromium","version":"1"},"devupUiVersion":"1","actualPng":"actual.png","environmentStatus":"valid","os":"test","locale":"en-US","timezone":"UTC"}) - } - fn input() -> Value { - json!({"actual":"actual.png","reference":{"path":"reference.png"},"environment":environment()}) - } - const WHITE: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg=="; - const BLACK: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg=="; - const WIDE: &str = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAC0lEQVR4nGP4DwUAI+UH+Yo0eLMAAAAASUVORK5CYII="; - fn fixture() -> Fixture { - let f = Fixture::new(); - f.png("actual.png", WHITE); - f.png("reference.png", WHITE); - f - } - #[tokio::test] - async fn dimension_mismatch_refused() { - let f = fixture(); - f.png("actual.png", WIDE); - let e = run(input(), &f.policy(), &ArtifactStore::default()) - .await - .unwrap_err(); - assert!(e.message.contains("dimensions"), "{e}"); - } - #[tokio::test] - async fn environment_overrides_exact_pixels() { - let f = fixture(); - let mut i = input(); - i["environment"]["environmentStatus"] = json!("environment-invalid"); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["visual"]["passed"], true); - assert_eq!(r["environment"]["status"], "environment-invalid"); - assert_eq!(r["verdict"], "inconclusive"); - } - #[tokio::test] - async fn missing_environment_inconclusive() { - let f = fixture(); - let mut i = input(); - i.as_object_mut().unwrap().remove("environment"); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "inconclusive"); - } - - async fn asset_store(representation: &str, broken_hash: bool) -> (ArtifactStore, String) { - let store = ArtifactStore::default(); - let mut payload = super::super::artifacts::w3_tests::payload(); - let bytes = STANDARD.decode(WHITE).unwrap(); - payload.assets.push(serde_json::from_value(json!({"assetId":"picture:fills:0","nodeId":"picture","field":"fills/0","sourceKind":"image-fill", - "format":"png","scale":1,"status":"exported","mimeType":"image/png","byteLength":bytes.len(),"dataBase64":WHITE, - "sha256":if broken_hash {"0".repeat(64)}else{hash(&bytes)},"renderInfo":{"representation":representation}})).unwrap()); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), - payload, - ) - .await - .unwrap(); - (store, artifact.artifact_id.clone()) - } - - #[tokio::test] - async fn asset_comparison_has_validated_native_reference_and_pixel_scope() { - let f = fixture(); - let (store, id) = asset_store("native-node", false).await; - let i = json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}); - let matched = run(i.clone(), &f.policy(), &store).await.unwrap(); - assert_eq!(matched["verdict"], "pass"); - assert_eq!(matched["reference"]["assetId"], "picture:fills:0"); - assert_eq!(matched["reference"]["referenceSha256"], reference_hash()); - assert_eq!(matched["actual"]["sha256"], reference_hash()); - assert_eq!( - matched["comparisonScope"], - "asset-pixels-against-native-node-export" - ); - f.png("actual.png", BLACK); - let mismatch = run(i.clone(), &f.policy(), &store).await.unwrap(); - assert_eq!(mismatch["verdict"], "fail"); - assert_eq!(mismatch["visual"]["changedRatio"], 1.0); - assert_eq!(mismatch["visual"]["totalPixels"], 1); - f.png("actual.png", WIDE); - assert!( - run(i.clone(), &f.policy(), &store) - .await - .unwrap_err() - .message - .contains("dimensions") - ); - std::fs::write(f.0.join("actual.png"), b"invalid PNG").unwrap(); - assert!( - run(i, &f.policy(), &store) - .await - .unwrap_err() - .message - .contains("Invalid PNG") - ); - } - - #[tokio::test] - async fn webp_actual_uses_native_png_reference_and_keeps_original_byte_hash_and_scope() { - let f = fixture(); - let (store, id) = asset_store("native-node", false).await; - let mut renditions = Vec::new(); - for (png, verdict) in [(WHITE, "pass"), (BLACK, "fail")] { - let request = devup_mcp_figma::AssetRequest { - usage_aspect_ratio: None, - max_width: None, - srcset: false, - visible_scope_node_id: None, - asset_id: "picture:fills:0".into(), - node_id: "picture".into(), - field: "fills/0".into(), - image_hash: Some("source".into()), - format: devup_mcp_figma::AssetFormat::Webp, - scale: 1.0, - render_mode: devup_mcp_figma::AssetRenderMode::Node, - quality: Some(80), - lossless: Some(true), - }; - let asset = devup_mcp_figma::exported_asset_from_bytes( - &request, - &STANDARD.decode(png).unwrap(), - ) - .unwrap(); - let bytes = STANDARD.decode(asset.data_base64.unwrap()).unwrap(); - renditions.push(bytes.clone()); - std::fs::write(f.0.join("actual.webp"), &bytes).unwrap(); - let value=run(json!({"actual":"actual.webp","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}),&f.policy(),&store).await.unwrap(); - assert_eq!(value["verdict"], verdict); - assert_eq!(value["actual"]["mimeType"], "image/webp"); - assert_eq!(value["actual"]["sha256"], hash(&bytes)); - assert_eq!( - value["comparisonScope"], - "asset-pixels-against-native-node-export" - ); - assert_eq!( - value["reference"]["renderInfo"]["representation"], - "native-node" - ); - } - let error = run( - json!({"actual":"actual.webp","reference":{"path":"reference.png"}}), - &f.policy(), - &store, - ) - .await - .unwrap_err(); - assert!(error.message.contains("Page/card screenshots")); - fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec { - let mut out = kind.to_vec(); - out.extend_from_slice(&(data.len() as u32).to_le_bytes()); - out.extend_from_slice(data); - if !data.len().is_multiple_of(2) { - out.push(0); - } - out - } - let mut animation = b"WEBP".to_vec(); - animation.extend(chunk(b"VP8X", &[2, 0, 0, 0, 0, 0, 0, 0, 0, 0])); - animation.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); - for encoded in renditions { - let mut frame = vec![0; 16]; - frame[12] = 100; - frame.extend_from_slice(&encoded[12..]); - animation.extend(chunk(b"ANMF", &frame)); - } - let mut encoded = b"RIFF".to_vec(); - encoded.extend_from_slice(&(animation.len() as u32).to_le_bytes()); - encoded.extend(animation); - std::fs::write(f.0.join("animated.webp"), encoded).unwrap(); - let error=run(json!({"actual":"animated.webp","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}),&f.policy(),&store).await.unwrap_err(); - assert!(error.message.contains("Animated WebP"), "{}", error.message); - } - - #[tokio::test] - async fn asset_comparison_rejects_missing_raw_unknown_and_corrupt_reference() { - let f = fixture(); - for (representation, broken) in [ - ("original-fill", false), - ("unknown", false), - ("not-exported", false), - ("native-node", true), - ] { - let (store, id) = asset_store(representation, broken).await; - let i = json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"picture:fills:0"}}); - assert!(run(i, &f.policy(), &store).await.is_err()); - let missing = - json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"missing"}}); - assert!( - run(missing, &f.policy(), &store) - .await - .unwrap_err() - .message - .contains("not captured") - ); - } - } - #[tokio::test] - async fn threshold_pass_and_fail() { - let f = fixture(); - f.png("actual.png", BLACK); - let mut i = input(); - i["threshold"] = json!(1.0); - let r = run(i.clone(), &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "pass"); - i["threshold"] = json!(0.005); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "fail"); - assert_eq!(r["visual"]["changedRatio"], 1.0); - assert!(r.get("diffPath").is_none()); - } - #[tokio::test] - async fn a_diff_path_writes_the_diff_png_as_evidence() { - let f = fixture(); - f.png("actual.png", BLACK); - let mut i = input(); - i["threshold"] = json!(0.005); - i["diffPath"] = json!(f.0.join("evidence/diff.png")); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "fail"); - let written = r["diffPath"].as_str().unwrap(); - assert!(written.ends_with("diff.png"), "{r}"); - assert!(f.0.join("evidence/diff.png").exists()); - assert!(r.get("diffPng").is_none(), "includeDiff was not asked for"); - } - #[tokio::test] - async fn out_of_root_actual_rejected() { - let f = fixture(); - let mut i = input(); - i["actual"] = json!(std::env::temp_dir().join("outside.png")); - let e = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap_err(); - assert!(e.message.contains("allowed root"), "{e}"); - } - #[tokio::test] - async fn artifact_without_reference_has_recovery() { - let f = fixture(); - let store = ArtifactStore::default(); - let a = store - .insert( - super::super::artifacts::ArtifactRequestKey::from_collection( - &super::super::artifacts::w3_tests::request(), - ), - super::super::artifacts::w3_tests::payload(), - ) - .await - .unwrap(); - let mut i = input(); - i["reference"] = json!({"artifactId":a.artifact_id}); - let e = run(i, &f.policy(), &store).await.unwrap_err(); - assert!( - e.message.contains("referencePng") && e.message.contains("devup_figma_export"), - "{e}" - ); - } - #[tokio::test] - async fn diff_delivered_as_readable_resource() { - let f = fixture(); - let store = ArtifactStore::default(); - let mut i = input(); - i["delivery"] = json!("resource"); - i["includeDiff"] = json!(true); - let r = run(i, &f.policy(), &store).await.unwrap(); - assert!(r.get("diffPng").is_none()); - let m = &r["resources"][0]; - assert!(m["uri"].as_str().unwrap().starts_with("devup://artifact/")); - let manifests = store.output_manifests().await; - let bytes = store - .read_output_chunk(&manifests[0].artifact_id, &manifests[0].output_id, 0) - .await - .unwrap(); - assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n")); - } - - #[tokio::test] - async fn incomplete_manifest_inconclusive() { - let f = fixture(); - for field in [ - "renderer", - "fontManifest", - "viewport", - "tsxResource", - "referenceResource", - "themePath", - "assetDirectory", - "os", - "locale", - "timezone", - "devupUiVersion", - ] { - let mut i = input(); - i["environment"].as_object_mut().unwrap().remove(field); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "inconclusive", "{field}"); - } - } - #[tokio::test] - async fn declared_viewport_mismatch_inconclusive() { - let f = fixture(); - let mut i = input(); - i["environment"]["viewport"]["width"] = json!(2); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "inconclusive"); - assert_eq!(r["visual"]["passed"], true); - } - #[tokio::test] - async fn reference_requires_exactly_one_selector() { - for reference in [json!({}), json!({"path":"reference.png","artifactId":"id"})] { - let mut i = input(); - i["reference"] = reference; - assert!(serde_json::from_value::(i).is_err()); - } - } - #[tokio::test] - async fn traversal_and_ads_rejected() { - let f = fixture(); - for path in [ - "../actual.png", - "actual.png:stream", - "Z:/actual.png", - r"\\server\share\actual.png", - ] { - let mut i = input(); - i["actual"] = json!(path); - let e = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap_err(); - assert!(!e.message.contains("not implemented")); - } - } - - #[test] - fn bounded_read_preserves_png_bytes() { - let f = fixture(); - let bytes = f - .policy() - .resolve("actual.png") - .unwrap() - .read_bounded(1024) - .unwrap(); - assert_eq!(bytes, STANDARD.decode(WHITE).unwrap()); - } - #[test] - fn bounded_read_refuses_oversize() { - let f = fixture(); - let e = f - .policy() - .resolve("actual.png") - .unwrap() - .read_bounded(4) - .unwrap_err(); - assert_eq!(e.code, ErrorCode::DevupFigmaResponseTooLarge); - } - #[test] - fn symlink_escape_refused_before_read() { - let f = fixture(); - let outside = fixture(); - #[cfg(windows)] - // Like output_policy's directory-symlink test, this requires the host - // to allow symlink creation. Windows reports missing privilege as 1314 - // (ErrorKind::Uncategorized on some toolchains), not PermissionDenied. - if let Err(error) = std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")) { - if error.raw_os_error() == Some(1314) { - eprintln!("symlink escape test unavailable: Windows symlink privilege is missing"); - return; - } - panic!("could not create test symlink: {error}"); - } - #[cfg(unix)] - std::os::unix::fs::symlink(&outside.0, f.0.join("escape")).unwrap(); - assert!( - f.policy() - .resolve("escape/actual.png") - .and_then(|t| t.read_bounded(1024)) - .is_err() - ); - // A leaf link must also be rejected, including when swapped after resolve. - let target = f.policy().resolve("late.png").unwrap(); - #[cfg(windows)] - std::os::windows::fs::symlink_file(outside.0.join("actual.png"), f.0.join("late.png")) - .unwrap(); - #[cfg(unix)] - std::os::unix::fs::symlink(outside.0.join("actual.png"), f.0.join("late.png")).unwrap(); - assert!(target.read_bounded(1024).is_err()); - } - - #[tokio::test] - async fn artifact_reference_preserves_provenance() { - let f = fixture(); - let store = ArtifactStore::default(); - let mut payload = super::super::artifacts::w3_tests::payload(); - payload.reference_png = Some(devup_mcp_figma::ReferencePng { - mime_type: "image/png".into(), - data_base64: WHITE.into(), - byte_length: STANDARD.decode(WHITE).unwrap().len(), - sha256: reference_hash(), - note: None, - }); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), - payload, - ) - .await - .unwrap(); - let mut i = input(); - i["reference"] = json!({"artifactId":artifact.artifact_id}); - let result = run(i, &f.policy(), &store).await.unwrap(); - assert_eq!(result["verdict"], "pass"); - assert_eq!(result["reference"]["artifactId"], artifact.artifact_id); - assert_eq!(result["reference"]["referenceSha256"], reference_hash()); - assert_eq!(result["visual"]["maxChangedRatio"], 0.005); - } - /// The server holds the reference it captured, so it pins it itself; a - /// reference path is the consumer's file and still needs their pin. - #[tokio::test] - async fn a_held_reference_is_pinned_by_the_server() { - let f = fixture(); - let store = ArtifactStore::default(); - let mut payload = super::super::artifacts::w3_tests::payload(); - payload.reference_png = Some(devup_mcp_figma::ReferencePng { - mime_type: "image/png".into(), - data_base64: WHITE.into(), - byte_length: STANDARD.decode(WHITE).unwrap().len(), - sha256: reference_hash(), - note: None, - }); - let artifact = store - .insert( - ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), - payload, - ) - .await - .unwrap(); - let mut i = input(); - i["reference"] = json!({"artifactId":artifact.artifact_id}); - i["environment"] - .as_object_mut() - .unwrap() - .remove("referenceResource"); - let result = run(i, &f.policy(), &store).await.unwrap(); - assert_eq!(result["verdict"], "pass", "{result}"); - assert_eq!( - result["environment"]["filledByServer"], - json!(["referenceResource"]) - ); - - let mut by_path = input(); - by_path["environment"] - .as_object_mut() - .unwrap() - .remove("referenceResource"); - let result = run(by_path, &f.policy(), &store).await.unwrap(); - assert_eq!(result["verdict"], "inconclusive"); - assert!(result["environment"].get("filledByServer").is_none()); - } - - #[tokio::test] - async fn renderer_and_reference_hash_mismatch_override_pass() { - let f = fixture(); - let mut i = input(); - i["environment"]["expectedRenderer"] = - json!({"name":"playwright-chromium","version":"different"}); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "inconclusive"); - assert_eq!(r["visual"]["passed"], true); - let mut i = input(); - i["environment"]["referenceResource"]["sha256"] = json!("c".repeat(64)); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert_eq!(r["verdict"], "inconclusive"); - } - #[tokio::test] - async fn inline_diff_and_omitted_diff_follow_request() { - let f = fixture(); - let r = run(input(), &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert!(r.get("diffPng").is_none()); - let mut i = input(); - i["includeDiff"] = json!(true); - let r = run(i, &f.policy(), &ArtifactStore::default()) - .await - .unwrap(); - assert!( - STANDARD - .decode(r["diffPng"]["dataBase64"].as_str().unwrap()) - .unwrap() - .starts_with(b"\x89PNG") - ); - assert!(r.get("resources").is_none()); - } -} +//! Pure PNG comparison and consumer-attested renderer environment assessment. +//! PNG bytes stay in memory and use the existing bounded artifact resource store. +use super::{ + artifacts::{ArtifactRequestKey, ArtifactStore}, + delivery::{ProjectedOutput, choose_delivery_for_result}, + output::OutputPolicy, + tools::VisualCompareInput, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use devup_mcp_figma::{ + CollectedPayload, CollectionRequest, CollectionScope, CollectionStats, DevupError, ErrorCode, + FigmaTarget, PayloadCompleteness, Snapshot, +}; +use devup_mcp_visual::{CompareOptions, VisualStatus, compare_png_bytes}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum VisualReference { + Asset(AssetReference), + Artifact(ArtifactReference), + Path(PathReference), +} +/// A validated native Figma node PNG captured by devup_figma_export. Raw fill +/// captures cannot serve as a reference for their own canvas appearance. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = serde_json::json!({"not": {}})))] +pub struct AssetReference { + pub artifact_id: String, + pub asset_id: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[schemars(extend("additionalProperties" = serde_json::json!({"not": {}})))] +pub struct ArtifactReference { + pub artifact_id: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(extend("additionalProperties" = serde_json::json!({"not": {}})))] +pub struct PathReference { + pub path: String, +} + +/// Consumer declarations are echoed, never fetched or executed. Optional fields +/// allow an incomplete run manifest to produce an explicit inconclusive result. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct VisualEnvironment { + pub schema_version: Option, + pub tsx_resource: Option, + pub reference_resource: Option, + pub viewport: Option, + pub theme_path: Option, + pub asset_directory: Option, + pub font_manifest: Option>, + pub renderer: Option, + /// Optional independently expected renderer pin; disagreement is invalid. + pub expected_renderer: Option, + pub devup_ui_version: Option, + pub actual_png: Option, + /// Consumer must set environment-invalid for missing fonts/assets, failed + /// readiness, console/runtime errors, or any mismatch with its pinned CI. + #[schemars(extend("enum" = ["valid", "environment-invalid"]))] + pub environment_status: Option, + pub os: Option, + pub locale: Option, + pub timezone: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ResourcePin { + pub uri: String, + pub sha256: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Viewport { + pub width: u32, + pub height: u32, + pub device_scale_factor: f64, +} +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct FontPin { + pub family: String, + pub sha256: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct RendererPin { + pub name: String, + pub version: String, +} + +fn invalid(message: &str) -> DevupError { + DevupError::new(ErrorCode::DevupCodegenFailed, message, false) +} +fn hash(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} +fn pinned(value: &str) -> bool { + !value.trim().is_empty() +} +fn sha(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn assess_environment( + input: &VisualCompareInput, + dimensions: [u32; 2], + reference_hash: &str, +) -> Value { + let mut issues = Vec::new(); + if let Some(e) = &input.environment { + if e.schema_version != Some(1) { + issues.push("schemaVersion must be 1"); + } + if e.environment_status.as_deref() != Some("valid") { + issues.push("consumer did not attest a valid environment"); + } + for (value, issue) in [ + (&e.theme_path, "themePath missing"), + (&e.asset_directory, "assetDirectory missing"), + (&e.devup_ui_version, "devupUiVersion missing"), + (&e.os, "OS pin missing"), + (&e.locale, "locale missing"), + (&e.timezone, "timezone missing"), + ] { + if !value.as_deref().is_some_and(pinned) { + issues.push(issue); + } + } + if e.actual_png.as_deref() != Some(input.actual.as_str()) { + issues.push("actualPng must identify the supplied actual path"); + } + if !e + .viewport + .as_ref() + .is_some_and(|v| [v.width, v.height] == dimensions && v.device_scale_factor == 1.0) + { + issues.push("viewport or deviceScaleFactor missing or mismatched; capture reference dimensions at scale 1"); + } + if !e + .renderer + .as_ref() + .is_some_and(|r| pinned(&r.name) && pinned(&r.version)) + { + issues.push("renderer name/version pin missing"); + } + if e.expected_renderer.is_some() && e.expected_renderer != e.renderer { + issues.push("renderer pin mismatch"); + } + if !e + .font_manifest + .as_ref() + .is_some_and(|fonts| fonts.iter().all(|f| pinned(&f.family) && sha(&f.sha256))) + { + issues.push("fontManifest missing or font hashes invalid"); + } + if !e + .tsx_resource + .as_ref() + .is_some_and(|r| pinned(&r.uri) && sha(&r.sha256)) + { + issues.push("tsxResource URI/hash missing or invalid"); + } + if !e + .reference_resource + .as_ref() + .is_some_and(|r| pinned(&r.uri) && r.sha256.eq_ignore_ascii_case(reference_hash)) + { + issues.push("referenceResource URI/hash missing or mismatched"); + } + } else { + issues.push("consumer environment manifest missing"); + } + json!({"status":if issues.is_empty(){"valid"}else{"environment-invalid"},"issues":issues, + "declaration":input.environment,"trust":"consumer-attested; PNGs cannot verify fonts, assets, readiness, runtime errors, or CI pins"}) +} + +pub(super) async fn compare( + mut input: VisualCompareInput, + policy: &OutputPolicy, + store: &ArtifactStore, +) -> Result { + let delivery = input.delivery.parse()?; + let options = CompareOptions { + max_changed_ratio: input.threshold.unwrap_or(0.005), + ..CompareOptions::default() + }; + if !(0.0..=1.0).contains(&options.max_changed_ratio) { + return Err(invalid("threshold must be between 0 and 1 inclusive.")); + } + // Resolve every supplied path before any PNG read. + let actual_target = policy.resolve(&input.actual)?; + let reference_target = match &input.reference { + VisualReference::Path(r) => Some(policy.resolve(&r.path)?), + _ => None, + }; + let (reference, provenance, artifact) = match &input.reference { + VisualReference::Asset(r) => { + if r.asset_id.trim().is_empty() { + return Err(invalid( + "assetId must identify a captured native PNG asset.", + )); + } + let artifact = store.get_for_export(&r.artifact_id).await?; + let asset = artifact.payload.assets.iter().find(|a| a.asset_id == r.asset_id) + .ok_or_else(|| invalid("Reference asset is not captured in this artifact; export it with renderMode node and format png first."))?; + let info = asset.render_info.as_ref(); + if asset.status != devup_mcp_figma::AssetStatus::Exported + || asset.format != Some(devup_mcp_figma::AssetFormat::Png) + || asset.mime_type.as_deref() != Some("image/png") + || info.is_none_or(|i| i["representation"] != "native-node") + { + return Err(invalid( + "Asset reference requires a successful native-node PNG capture; raw fills, fallback geometry, failed or unknown captures are not canvas references.", + )); + } + let bytes = STANDARD + .decode(asset.data_base64.as_deref().unwrap_or_default()) + .map_err(|_| invalid("Reference asset base64 is invalid."))?; + if bytes.is_empty() + || bytes.len() > 16 * 1024 * 1024 + || asset.byte_length != Some(bytes.len()) + || asset.sha256.as_deref() != Some(hash(&bytes).as_str()) + { + return Err(invalid( + "Reference asset length or hash mismatch; reacquire it.", + )); + } + let provenance = json!({"kind":"asset","artifactId":r.artifact_id,"assetId":r.asset_id,"nodeId":asset.node_id, + "referenceSha256":asset.sha256,"target":artifact.payload.target,"sourceVersion":artifact.payload.source_version, + "renderMode":asset.render_mode,"renderInfo":asset.render_info,"scope":"native-node-export; ancestor canvas compositing unverified"}); + (bytes, provenance, Some(artifact)) + } + VisualReference::Artifact(r) => { + let artifact = store.get_for_export(&r.artifact_id).await?; + let png = artifact.payload.reference_png.as_ref().ok_or_else(|| DevupError::with_details( + ErrorCode::DevupFigmaHandoffInvalid, + "This artifact holds no referencePng. Call devup_figma_export with the original url and outputs containing referencePng, then retry with the returned artifactId.", false, + json!({"recoveryState":"recoverable","nextAction":{"tool":"devup_figma_export","outputs":["referencePng"]}})))?; + if png.byte_length > 16 * 1024 * 1024 + || png.data_base64.len() > (16 * 1024 * 1024_usize).div_ceil(3) * 4 + { + return Err(invalid( + "Cached referencePng exceeds the compressed byte limit.", + )); + } + let bytes = STANDARD.decode(&png.data_base64).map_err(|_| { + invalid("Cached referencePng is invalid; reacquire it with devup_figma_export.") + })?; + if bytes.len() != png.byte_length || hash(&bytes) != png.sha256 { + return Err(invalid( + "Cached referencePng hash mismatch; reacquire it with devup_figma_export.", + )); + } + let provenance = json!({"kind":"artifact","artifactId":artifact.artifact_id,"referenceSha256":png.sha256, + "target":artifact.payload.target,"sourceVersion":artifact.payload.source_version,"expiresAtEpochSeconds":artifact.expires_at_epoch_seconds}); + (bytes, provenance, Some(artifact)) + } + VisualReference::Path(_) => { + let target = reference_target.as_ref().expect("resolved reference"); + let bytes = target.read_bounded(16 * 1024 * 1024)?; + let provenance = + json!({"kind":"path","path":target.display_path(),"referenceSha256":hash(&bytes)}); + (bytes, provenance, None) + } + }; + let actual = actual_target.read_bounded(16 * 1024 * 1024)?; + let actual_hash = hash(&actual); + let asset_comparison = matches!(&input.reference, VisualReference::Asset(_)); + let webp_actual = image::guess_format(&actual).ok() == Some(image::ImageFormat::WebP); + if webp_actual && !asset_comparison { + return Err(invalid( + "WebP actuals require a native PNG asset reference (artifactId plus assetId). Page/card screenshots and their environment checks continue to require PNG.", + )); + } + let diff_target = input + .diff_path + .as_deref() + .map(|path| policy.resolve(path)) + .transpose()?; + let include_diff = input.include_diff || diff_target.is_some(); + let reference_hash = hash(&reference); + let (report, diff) = tokio::task::spawn_blocking(move || { + let actual = if webp_actual { + use image::ImageDecoder; + let mut limits = image::Limits::default(); limits.max_image_width=Some(8192); limits.max_image_height=Some(8192); limits.max_alloc=Some(64*1024*1024); + let mut decoder=image::codecs::webp::WebPDecoder::new(std::io::Cursor::new(actual)).map_err(|_| "Invalid WebP actual.")?; + decoder.set_limits(limits).map_err(|_| "WebP actual exceeds bounded decode limits.")?; + if decoder.has_animation() {return Err("Animated WebP actuals cannot establish a static asset comparison; provide a static WebP or an explicit PNG screenshot.");} + let (w,h)=decoder.dimensions(); + if u64::from(w)*u64::from(h)>devup_mcp_figma::MAX_WEBP_PIXELS { return Err("WebP actual exceeds 4194304 decoded pixels."); } + let image=image::DynamicImage::from_decoder(decoder).map_err(|_| "Malformed WebP actual.")?; + let mut png=std::io::Cursor::new(Vec::new());image.write_to(&mut png,image::ImageFormat::Png).map_err(|_| "WebP comparison pixel preparation failed.")?;png.into_inner() + } else {actual}; + compare_png_bytes(&reference,&actual,&options,include_diff).map_err(|_| "Invalid PNG or threshold; PNG limits are 16 MiB compressed, 8192 pixels per axis, and 64 MiB decoded.") + }) + .await.map_err(|_| invalid("PNG comparison could not complete."))? + .map_err(invalid)?; + if report.status == VisualStatus::InvalidDimensions { + return Err(DevupError::with_details( + ErrorCode::DevupCodegenFailed, + "PNG dimensions mismatch; render actual.png at reference dimensions without scaling.", + false, + json!({"verdict":"inconclusive","environment":{"status":"environment-invalid","issues":["image dimensions mismatch"]}, + "referenceDimensions":report.reference_dimensions,"actualDimensions":report.actual_dimensions,"reference":provenance}), + )); + } + // A reference this server captured from Figma and still holds is pinned + // by what the server read, so a consumer need not restate its hash. + let mut filled = Vec::new(); + if let (VisualReference::Artifact(reference), Some(declared)) = + (&input.reference, input.environment.as_mut()) + && declared.reference_resource.is_none() + { + declared.reference_resource = Some(ResourcePin { + uri: format!("devup://artifact/{}", reference.artifact_id), + sha256: reference_hash.clone(), + }); + filled.push("referenceResource"); + } + let mut environment = if asset_comparison { + json!({"status":"valid","scope":"asset-pixels-only","issues":[],"trust":"server-validated native PNG reference and allowlisted actual raster; no browser environment required"}) + } else { + assess_environment(&input, report.reference_dimensions, &reference_hash) + }; + if !filled.is_empty() { + environment["filledByServer"] = json!(filled); + } + let passed = report.passed(); + let verdict = if environment["status"] != "valid" { + "inconclusive" + } else if passed { + "pass" + } else { + "fail" + }; + let mut visual = + serde_json::to_value(report).map_err(|_| invalid("Cannot serialize visual metrics."))?; + visual["passed"] = json!(passed); + let mut result = + json!({"verdict":verdict,"visual":visual,"environment":environment,"reference":provenance}); + result["comparisonScope"] = json!(if asset_comparison { + "asset-pixels-against-native-node-export" + } else { + "consumer-render-against-reference" + }); + result["actual"] = json!({"path":actual_target.display_path(),"sha256":actual_hash}); + result["actual"]["mimeType"] = json!(if webp_actual { + "image/webp" + } else { + "image/png" + }); + result["actual"]["pixelMetricsInput"] = json!(if webp_actual { + "decoded-WebP; temporary PNG encoding for comparison" + } else { + "PNG" + }); + if asset_comparison { + result["canvasFidelity"] = json!("unverified-outside-reference-node-subtree"); + } + if let (Some(target), Some(bytes)) = (diff_target, diff.as_ref()) { + let display = target.display_path().display().to_string(); + let mut transaction = super::output::OutputTransaction::new(); + transaction.stage("diff", target, bytes)?; + transaction.commit()?; + result["diffPath"] = json!(display); + } + let diff = diff.filter(|_| input.include_diff); + if let Some(bytes) = diff { + result["diffPng"] = json!({"mimeType":"image/png","dataBase64":STANDARD.encode(&bytes)}); + let outputs = vec![ProjectedOutput::binary("diff.png", "image/png", bytes)]; + if !choose_delivery_for_result(delivery, &result, &outputs)?.inline { + // Paths also need a bounded, expiring resource owner. Its random + // identity and content-free payload never contain PNGs in cache keys. + let artifact = match artifact { + Some(a) => a, + None => { + let target = FigmaTarget { + file_key: format!("visual-{:032x}", rand::random::()), + node_id: None, + branch_key: None, + }; + let request = CollectionRequest::new(target.clone(), CollectionScope::Node); + let payload = CollectedPayload { + target: target.clone(), + scope: CollectionScope::Node, + metadata: json!({"kind":"visual-comparison"}), + snapshot: Snapshot { + file_key: target.file_key, + version: None, + roots: vec![], + nodes: BTreeMap::new(), + diagnostics: vec![], + }, + variables: None, + styles: None, + completeness: PayloadCompleteness::ResolvedValuesOnly, + source_version: None, + stats: CollectionStats::default(), + assets: vec![], + reference_png: None, + failures: vec![], + }; + store + .insert(ArtifactRequestKey::from_collection(&request), payload) + .await? + } + }; + let projection_key = format!("visual-{:032x}", rand::random::()); + let manifests = store + .attach_outputs(&artifact.artifact_id, &projection_key, outputs) + .await?; + result.as_object_mut().expect("object").remove("diffPng"); + result["resources"]=json!(manifests.iter().map(|m|json!({"uri":m.manifest_uri,"name":m.name,"mimeType":m.mime_type,"size":m.raw_bytes,"contentHash":m.sha256,"expiresAt":super::format_epoch_rfc3339(m.expires_at_epoch_seconds)})).collect::>()); + } + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + async fn run( + input: Value, + policy: &OutputPolicy, + store: &ArtifactStore, + ) -> Result { + let input = serde_json::from_value(input) + .map_err(|_| invalid("Invalid visual comparison input."))?; + compare(input, policy, store).await + } + use serde_json::json; + + struct Fixture(std::path::PathBuf); + impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "visual-tool-{}-{}", + std::process::id(), + rand::random::() + )); + std::fs::create_dir_all(&root).unwrap(); + Self(root) + } + fn png(&self, name: &str, png: &str) { + std::fs::write(self.0.join(name), STANDARD.decode(png).unwrap()).unwrap(); + } + fn policy(&self) -> OutputPolicy { + OutputPolicy::from_roots(vec![self.0.clone()]).unwrap() + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + fn reference_hash() -> String { + hash(&STANDARD.decode(WHITE).unwrap()) + } + fn environment() -> Value { + json!({"schemaVersion":1,"tsxResource":{"uri":"devup://artifact/test/manifest","sha256":"a".repeat(64)},"referenceResource":{"uri":"devup://artifact/test/manifest","sha256":reference_hash()},"viewport":{"width":1,"height":1,"deviceScaleFactor":1},"themePath":"devup.json","assetDirectory":"public/assets","fontManifest":[{"family":"Test","sha256":"b".repeat(64)}],"renderer":{"name":"playwright-chromium","version":"1"},"devupUiVersion":"1","actualPng":"actual.png","environmentStatus":"valid","os":"test","locale":"en-US","timezone":"UTC"}) + } + fn input() -> Value { + json!({"actual":"actual.png","reference":{"path":"reference.png"},"environment":environment()}) + } + const WHITE: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg=="; + const BLACK: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAX+XDSwAAAABJRU5ErkJggg=="; + const WIDE: &str = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAC0lEQVR4nGP4DwUAI+UH+Yo0eLMAAAAASUVORK5CYII="; + fn fixture() -> Fixture { + let f = Fixture::new(); + f.png("actual.png", WHITE); + f.png("reference.png", WHITE); + f + } + #[tokio::test] + async fn dimension_mismatch_refused() { + let f = fixture(); + f.png("actual.png", WIDE); + let e = run(input(), &f.policy(), &ArtifactStore::default()) + .await + .unwrap_err(); + assert!(e.message.contains("dimensions"), "{e}"); + } + #[tokio::test] + async fn environment_overrides_exact_pixels() { + let f = fixture(); + let mut i = input(); + i["environment"]["environmentStatus"] = json!("environment-invalid"); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["visual"]["passed"], true); + assert_eq!(r["environment"]["status"], "environment-invalid"); + assert_eq!(r["verdict"], "inconclusive"); + } + #[tokio::test] + async fn missing_environment_inconclusive() { + let f = fixture(); + let mut i = input(); + i.as_object_mut().unwrap().remove("environment"); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "inconclusive"); + } + + async fn asset_store(representation: &str, broken_hash: bool) -> (ArtifactStore, String) { + let store = ArtifactStore::default(); + let mut payload = super::super::artifacts::w3_tests::payload(); + let bytes = STANDARD.decode(WHITE).unwrap(); + payload.assets.push(serde_json::from_value(json!({"assetId":"picture:fills:0","nodeId":"picture","field":"fills/0","sourceKind":"image-fill", + "format":"png","scale":1,"status":"exported","mimeType":"image/png","byteLength":bytes.len(),"dataBase64":WHITE, + "sha256":if broken_hash {"0".repeat(64)}else{hash(&bytes)},"renderInfo":{"representation":representation}})).unwrap()); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), + payload, + ) + .await + .unwrap(); + (store, artifact.artifact_id.clone()) + } + + #[tokio::test] + async fn asset_comparison_has_validated_native_reference_and_pixel_scope() { + let f = fixture(); + let (store, id) = asset_store("native-node", false).await; + let i = json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}); + let matched = run(i.clone(), &f.policy(), &store).await.unwrap(); + assert_eq!(matched["verdict"], "pass"); + assert_eq!(matched["reference"]["assetId"], "picture:fills:0"); + assert_eq!(matched["reference"]["referenceSha256"], reference_hash()); + assert_eq!(matched["actual"]["sha256"], reference_hash()); + assert_eq!( + matched["comparisonScope"], + "asset-pixels-against-native-node-export" + ); + f.png("actual.png", BLACK); + let mismatch = run(i.clone(), &f.policy(), &store).await.unwrap(); + assert_eq!(mismatch["verdict"], "fail"); + assert_eq!(mismatch["visual"]["changedRatio"], 1.0); + assert_eq!(mismatch["visual"]["totalPixels"], 1); + f.png("actual.png", WIDE); + assert!( + run(i.clone(), &f.policy(), &store) + .await + .unwrap_err() + .message + .contains("dimensions") + ); + std::fs::write(f.0.join("actual.png"), b"invalid PNG").unwrap(); + assert!( + run(i, &f.policy(), &store) + .await + .unwrap_err() + .message + .contains("Invalid PNG") + ); + } + + #[tokio::test] + async fn webp_actual_uses_native_png_reference_and_keeps_original_byte_hash_and_scope() { + let f = fixture(); + let (store, id) = asset_store("native-node", false).await; + let mut renditions = Vec::new(); + for (png, verdict) in [(WHITE, "pass"), (BLACK, "fail")] { + let request = devup_mcp_figma::AssetRequest { + usage_aspect_ratio: None, + max_width: None, + srcset: false, + visible_scope_node_id: None, + asset_id: "picture:fills:0".into(), + node_id: "picture".into(), + field: "fills/0".into(), + image_hash: Some("source".into()), + format: devup_mcp_figma::AssetFormat::Webp, + scale: 1.0, + render_mode: devup_mcp_figma::AssetRenderMode::Node, + quality: Some(80), + lossless: Some(true), + }; + let asset = devup_mcp_figma::exported_asset_from_bytes( + &request, + &STANDARD.decode(png).unwrap(), + ) + .unwrap(); + let bytes = STANDARD.decode(asset.data_base64.unwrap()).unwrap(); + renditions.push(bytes.clone()); + std::fs::write(f.0.join("actual.webp"), &bytes).unwrap(); + let value=run(json!({"actual":"actual.webp","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}),&f.policy(),&store).await.unwrap(); + assert_eq!(value["verdict"], verdict); + assert_eq!(value["actual"]["mimeType"], "image/webp"); + assert_eq!(value["actual"]["sha256"], hash(&bytes)); + assert_eq!( + value["comparisonScope"], + "asset-pixels-against-native-node-export" + ); + assert_eq!( + value["reference"]["renderInfo"]["representation"], + "native-node" + ); + } + let error = run( + json!({"actual":"actual.webp","reference":{"path":"reference.png"}}), + &f.policy(), + &store, + ) + .await + .unwrap_err(); + assert!(error.message.contains("Page/card screenshots")); + fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut out = kind.to_vec(); + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(data); + if !data.len().is_multiple_of(2) { + out.push(0); + } + out + } + let mut animation = b"WEBP".to_vec(); + animation.extend(chunk(b"VP8X", &[2, 0, 0, 0, 0, 0, 0, 0, 0, 0])); + animation.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); + for encoded in renditions { + let mut frame = vec![0; 16]; + frame[12] = 100; + frame.extend_from_slice(&encoded[12..]); + animation.extend(chunk(b"ANMF", &frame)); + } + let mut encoded = b"RIFF".to_vec(); + encoded.extend_from_slice(&(animation.len() as u32).to_le_bytes()); + encoded.extend(animation); + std::fs::write(f.0.join("animated.webp"), encoded).unwrap(); + let error=run(json!({"actual":"animated.webp","reference":{"artifactId":id,"assetId":"picture:fills:0"},"threshold":0}),&f.policy(),&store).await.unwrap_err(); + assert!(error.message.contains("Animated WebP"), "{}", error.message); + } + + #[tokio::test] + async fn asset_comparison_rejects_missing_raw_unknown_and_corrupt_reference() { + let f = fixture(); + for (representation, broken) in [ + ("original-fill", false), + ("unknown", false), + ("not-exported", false), + ("native-node", true), + ] { + let (store, id) = asset_store(representation, broken).await; + let i = json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"picture:fills:0"}}); + assert!(run(i, &f.policy(), &store).await.is_err()); + let missing = + json!({"actual":"actual.png","reference":{"artifactId":id,"assetId":"missing"}}); + assert!( + run(missing, &f.policy(), &store) + .await + .unwrap_err() + .message + .contains("not captured") + ); + } + } + #[tokio::test] + async fn threshold_pass_and_fail() { + let f = fixture(); + f.png("actual.png", BLACK); + let mut i = input(); + i["threshold"] = json!(1.0); + let r = run(i.clone(), &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "pass"); + i["threshold"] = json!(0.005); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "fail"); + assert_eq!(r["visual"]["changedRatio"], 1.0); + assert!(r.get("diffPath").is_none()); + } + #[tokio::test] + async fn a_diff_path_writes_the_diff_png_as_evidence() { + let f = fixture(); + f.png("actual.png", BLACK); + let mut i = input(); + i["threshold"] = json!(0.005); + i["diffPath"] = json!(f.0.join("evidence/diff.png")); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "fail"); + let written = r["diffPath"].as_str().unwrap(); + assert!(written.ends_with("diff.png"), "{r}"); + assert!(f.0.join("evidence/diff.png").exists()); + assert!(r.get("diffPng").is_none(), "includeDiff was not asked for"); + } + #[tokio::test] + async fn out_of_root_actual_rejected() { + let f = fixture(); + let mut i = input(); + i["actual"] = json!(std::env::temp_dir().join("outside.png")); + let e = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap_err(); + assert!(e.message.contains("allowed root"), "{e}"); + } + #[tokio::test] + async fn artifact_without_reference_has_recovery() { + let f = fixture(); + let store = ArtifactStore::default(); + let a = store + .insert( + super::super::artifacts::ArtifactRequestKey::from_collection( + &super::super::artifacts::w3_tests::request(), + ), + super::super::artifacts::w3_tests::payload(), + ) + .await + .unwrap(); + let mut i = input(); + i["reference"] = json!({"artifactId":a.artifact_id}); + let e = run(i, &f.policy(), &store).await.unwrap_err(); + assert!( + e.message.contains("referencePng") && e.message.contains("devup_figma_export"), + "{e}" + ); + } + #[tokio::test] + async fn diff_delivered_as_readable_resource() { + let f = fixture(); + let store = ArtifactStore::default(); + let mut i = input(); + i["delivery"] = json!("resource"); + i["includeDiff"] = json!(true); + let r = run(i, &f.policy(), &store).await.unwrap(); + assert!(r.get("diffPng").is_none()); + let m = &r["resources"][0]; + assert!(m["uri"].as_str().unwrap().starts_with("devup://artifact/")); + let manifests = store.output_manifests().await; + let bytes = store + .read_output_chunk(&manifests[0].artifact_id, &manifests[0].output_id, 0) + .await + .unwrap(); + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n")); + } + + #[tokio::test] + async fn incomplete_manifest_inconclusive() { + let f = fixture(); + for field in [ + "renderer", + "fontManifest", + "viewport", + "tsxResource", + "referenceResource", + "themePath", + "assetDirectory", + "os", + "locale", + "timezone", + "devupUiVersion", + ] { + let mut i = input(); + i["environment"].as_object_mut().unwrap().remove(field); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "inconclusive", "{field}"); + } + } + #[tokio::test] + async fn declared_viewport_mismatch_inconclusive() { + let f = fixture(); + let mut i = input(); + i["environment"]["viewport"]["width"] = json!(2); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "inconclusive"); + assert_eq!(r["visual"]["passed"], true); + } + #[tokio::test] + async fn reference_requires_exactly_one_selector() { + for reference in [json!({}), json!({"path":"reference.png","artifactId":"id"})] { + let mut i = input(); + i["reference"] = reference; + assert!(serde_json::from_value::(i).is_err()); + } + } + #[tokio::test] + async fn traversal_and_ads_rejected() { + let f = fixture(); + for path in [ + "../actual.png", + "actual.png:stream", + "Z:/actual.png", + r"\\server\share\actual.png", + ] { + let mut i = input(); + i["actual"] = json!(path); + let e = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap_err(); + assert!(!e.message.contains("not implemented")); + } + } + + #[test] + fn bounded_read_preserves_png_bytes() { + let f = fixture(); + let bytes = f + .policy() + .resolve("actual.png") + .unwrap() + .read_bounded(1024) + .unwrap(); + assert_eq!(bytes, STANDARD.decode(WHITE).unwrap()); + } + #[test] + fn bounded_read_refuses_oversize() { + let f = fixture(); + let e = f + .policy() + .resolve("actual.png") + .unwrap() + .read_bounded(4) + .unwrap_err(); + assert_eq!(e.code, ErrorCode::DevupFigmaResponseTooLarge); + } + #[test] + fn symlink_escape_refused_before_read() { + let f = fixture(); + let outside = fixture(); + #[cfg(windows)] + // Like output_policy's directory-symlink test, this requires the host + // to allow symlink creation. Windows reports missing privilege as 1314 + // (ErrorKind::Uncategorized on some toolchains), not PermissionDenied. + if let Err(error) = std::os::windows::fs::symlink_dir(&outside.0, f.0.join("escape")) { + if error.raw_os_error() == Some(1314) { + eprintln!("symlink escape test unavailable: Windows symlink privilege is missing"); + return; + } + panic!("could not create test symlink: {error}"); + } + #[cfg(unix)] + std::os::unix::fs::symlink(&outside.0, f.0.join("escape")).unwrap(); + assert!( + f.policy() + .resolve("escape/actual.png") + .and_then(|t| t.read_bounded(1024)) + .is_err() + ); + // A leaf link must also be rejected, including when swapped after resolve. + let target = f.policy().resolve("late.png").unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_file(outside.0.join("actual.png"), f.0.join("late.png")) + .unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(outside.0.join("actual.png"), f.0.join("late.png")).unwrap(); + assert!(target.read_bounded(1024).is_err()); + } + + #[tokio::test] + async fn artifact_reference_preserves_provenance() { + let f = fixture(); + let store = ArtifactStore::default(); + let mut payload = super::super::artifacts::w3_tests::payload(); + payload.reference_png = Some(devup_mcp_figma::ReferencePng { + mime_type: "image/png".into(), + data_base64: WHITE.into(), + byte_length: STANDARD.decode(WHITE).unwrap().len(), + sha256: reference_hash(), + note: None, + }); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), + payload, + ) + .await + .unwrap(); + let mut i = input(); + i["reference"] = json!({"artifactId":artifact.artifact_id}); + let result = run(i, &f.policy(), &store).await.unwrap(); + assert_eq!(result["verdict"], "pass"); + assert_eq!(result["reference"]["artifactId"], artifact.artifact_id); + assert_eq!(result["reference"]["referenceSha256"], reference_hash()); + assert_eq!(result["visual"]["maxChangedRatio"], 0.005); + } + /// The server holds the reference it captured, so it pins it itself; a + /// reference path is the consumer's file and still needs their pin. + #[tokio::test] + async fn a_held_reference_is_pinned_by_the_server() { + let f = fixture(); + let store = ArtifactStore::default(); + let mut payload = super::super::artifacts::w3_tests::payload(); + payload.reference_png = Some(devup_mcp_figma::ReferencePng { + mime_type: "image/png".into(), + data_base64: WHITE.into(), + byte_length: STANDARD.decode(WHITE).unwrap().len(), + sha256: reference_hash(), + note: None, + }); + let artifact = store + .insert( + ArtifactRequestKey::from_collection(&super::super::artifacts::w3_tests::request()), + payload, + ) + .await + .unwrap(); + let mut i = input(); + i["reference"] = json!({"artifactId":artifact.artifact_id}); + i["environment"] + .as_object_mut() + .unwrap() + .remove("referenceResource"); + let result = run(i, &f.policy(), &store).await.unwrap(); + assert_eq!(result["verdict"], "pass", "{result}"); + assert_eq!( + result["environment"]["filledByServer"], + json!(["referenceResource"]) + ); + + let mut by_path = input(); + by_path["environment"] + .as_object_mut() + .unwrap() + .remove("referenceResource"); + let result = run(by_path, &f.policy(), &store).await.unwrap(); + assert_eq!(result["verdict"], "inconclusive"); + assert!(result["environment"].get("filledByServer").is_none()); + } + + #[tokio::test] + async fn renderer_and_reference_hash_mismatch_override_pass() { + let f = fixture(); + let mut i = input(); + i["environment"]["expectedRenderer"] = + json!({"name":"playwright-chromium","version":"different"}); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "inconclusive"); + assert_eq!(r["visual"]["passed"], true); + let mut i = input(); + i["environment"]["referenceResource"]["sha256"] = json!("c".repeat(64)); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert_eq!(r["verdict"], "inconclusive"); + } + #[tokio::test] + async fn inline_diff_and_omitted_diff_follow_request() { + let f = fixture(); + let r = run(input(), &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert!(r.get("diffPng").is_none()); + let mut i = input(); + i["includeDiff"] = json!(true); + let r = run(i, &f.policy(), &ArtifactStore::default()) + .await + .unwrap(); + assert!( + STANDARD + .decode(r["diffPng"]["dataBase64"].as_str().unwrap()) + .unwrap() + .starts_with(b"\x89PNG") + ); + assert!(r.get("resources").is_none()); + } +} From 8fadc149a090549183b82498a3e560d3b7a71a61 Mon Sep 17 00:00:00 2001 From: leader Date: Fri, 30 Oct 2026 20:05:15 +0900 Subject: [PATCH 2/2] chore(skills): manifest records the LF byte counts and hashes --- crates/devup-mcp/src/server/skills/manifest.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/devup-mcp/src/server/skills/manifest.json b/crates/devup-mcp/src/server/skills/manifest.json index 9e6f8201..1845952e 100644 --- a/crates/devup-mcp/src/server/skills/manifest.json +++ b/crates/devup-mcp/src/server/skills/manifest.json @@ -89,8 +89,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 42690, - "sha256": "75b574c93e738f9d3d6b2b39d09bfb66f5e29ed34263afadfc945c37d064dcd8" + "bytes": 42158, + "sha256": "6b206c3e1cd1b5d9a9da806b19f393caf71c61f55a5e3008ca0172bda44e85b3" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup-implementation-plan", @@ -107,8 +107,8 @@ "documents": [ { "path": "SKILL.md", - "bytes": 21649, - "sha256": "6231e3f3c366b0c6e15bb589ee71246710458ba6ac404980ab6890af1845404c" + "bytes": 21433, + "sha256": "fc82f3767b2e09eee1dee8b422f87f62840c49c67441a172c4924e7101ce2892" } ], "sourceUrl": "https://github.com/dev-five-git/devup-mcp/blob/HEAD/crates/devup-mcp/src/server/skills/devup",