diff --git a/PROGRESS.md b/PROGRESS.md index 575f4f4..2c54c20 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -231,6 +231,17 @@ are credited in `THIRD_PARTY.md`. * Agent view lists runs and supports review, whole-run revert, per-change undo and accept all. Review adds canvas outlines and a finish toast; palette commands review or revert the last run. * Id-paired page diffs power review; History version compare UI remains todo. Completed with full restore (including variables, modes and tokens), tool edit classification, and wasm support. +### 2026-10-28: Incremental lint + +Agents re-linted whole pages after every edit. A bounded change log (`src/editor/changes.rs`, the +last 10000 layer changes, each with the client that made it) now records which layers every +commit, undo and redo touched; variable edits count as touching the layers bound to them. Write +tools return `rev`, `get_basic_info` reports it, `get_changes {since, page?, exclude?, by?}` lists +the changes, and `lint_design {since | changedOnly}` checks only those layers plus their +ancestors, descendants and siblings (`src/mcp/incremental.rs`), with `covered` saying what ran. +Tests: `tests/incremental.rs`, unit tests next to the code, the attribution check in +`tests/mcp.rs::parallel_sessions_get_their_own_runs`. + ### 2026-10-21: Design libraries and offline CLI * Variables are now collections with modes, slash groups, four types and aliases, stored in `design.json` (project version 2; 0.4.0 refuses migrated projects). Each page keeps a derived cache; every edit is one undo step per page, and the page-only removals are gone. Per-page active mode per collection. diff --git a/README.md b/README.md index d4ea49c..d5db512 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Projects live in `~/Documents/papercraft//` (`project.json`, one JSON file ## Agents -PaperCraft exposes **86 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app. +PaperCraft exposes **87 tools** (`src/mcp/tools.rs`) three ways. They run the same code inside the app. | Way | Notes | |---|---| diff --git a/ROADMAP.md b/ROADMAP.md index bc89b3f..786238b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -32,7 +32,7 @@ Agents can already build a design system through the tools; people cannot yet ma | # | Item | Status | Notes | |---|---|---|---| -| 15 | MCP and CLI tool surface | **done** | 86 tools, one undo step per call, `papercraft-cli list / schema / call`. | +| 15 | MCP and CLI tool surface | **done** | 87 tools, one undo step per call, `papercraft-cli list / schema / call`. | | 16 | Agent skills | **done** | `skills/papercraft` and `skills/papercraft-design`. | | 17 | Accessibility annotations | todo | Reading order, roles and alt text on layers, checked by the linter. | | 18 | Code export from frames | **done** | `get_jsx` supports `format: "inline"`, `"tailwind"` (Tailwind utility classes), and `"html"` (HTML + CSS rules with `:root` variables). | diff --git a/src/editor/changes.rs b/src/editor/changes.rs new file mode 100644 index 0000000..d168be3 --- /dev/null +++ b/src/editor/changes.rs @@ -0,0 +1,356 @@ +//! A bounded log of which layers changed at which revision, so an agent can ask "what changed +//! since I last looked" (`get_changes`) and lint only that (`lint_design {since}`). +//! +//! The revision here is the log's own counter, not `Editor::revision`: that one also moves when +//! the camera does and once per batch of MCP calls, so a read would bump it. This one moves only +//! when a committed edit, undo or redo changes layers. + +use super::Editor; +use crate::model::{Document, NodeId}; +use std::collections::{BTreeSet, HashMap, VecDeque}; +use std::sync::Arc; + +/// Entries kept; a caller older than the oldest one gets `truncated` and does a full pass. +pub const MAX_ENTRIES: usize = 10_000; + +/// The MCP client behind a change. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Who { + /// Name and session: two agents with the same client name stay apart. + pub key: String, + /// What the canvas tag shows ("Claude Code", "Claude Code \u{b7} 2"). + pub label: String, +} + +/// One layer changed at one revision. +#[derive(Debug, Clone, PartialEq)] +struct Entry { + rev: u64, + page: String, + id: NodeId, + /// None for the person using the app (and for calls made without a client). + by: Option>, +} + +/// Which changes `since` returns. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Filter<'a> { + All, + /// Everything but this client key's own changes. + Exclude(&'a str), + /// Only this client's changes, by label or key. + Only(&'a str), +} + +/// Layers one client changed on one page since the asked revision. +#[derive(Debug, Clone, PartialEq)] +pub struct Group { + pub page: String, + pub by: Option, + /// The latest revision in the group. + pub rev: u64, + /// Each layer once, in first-change order. + pub ids: Vec, +} + +/// The answer to "what changed since revision N". +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Since { + /// The log no longer reaches back to the asked revision (or it is from the future): the + /// caller cannot trust `changes` and should look at everything. + pub truncated: bool, + pub changes: Vec, +} + +#[derive(Debug, Clone)] +pub struct ChangeLog { + rev: u64, + entries: VecDeque, + /// Highest revision of an entry dropped for space: asking since less than this is incomplete. + floor: u64, + max: usize, + /// The MCP client whose call is running, set by the run tracker around each call. + pub client: Option>, + /// Revision of the last whole-page lint per (client key, page id), for `changedOnly`. + linted: HashMap<(String, String), u64>, +} + +impl Default for ChangeLog { + fn default() -> Self { + ChangeLog::with_capacity(MAX_ENTRIES) + } +} + +impl ChangeLog { + pub fn with_capacity(max: usize) -> ChangeLog { + ChangeLog { rev: 0, entries: VecDeque::new(), floor: 0, max, client: None, linted: HashMap::new() } + } + + /// The revision after the latest change. + pub fn rev(&self) -> u64 { + self.rev + } + + /// Logs `ids` of `page` as changed in a new revision by the running client. Nothing + /// changed, no new revision. + pub fn record(&mut self, page: &str, ids: impl IntoIterator) { + let mut ids = ids.into_iter().peekable(); + if ids.peek().is_none() { + return; + } + self.rev += 1; + for id in ids { + self.entries.push_back(Entry { rev: self.rev, page: page.to_string(), id, by: self.client.clone() }); + } + while self.entries.len() > self.max { + if let Some(dropped) = self.entries.pop_front() { + self.floor = self.floor.max(dropped.rev); + } + } + } + + /// Forgets everything (another project was opened): every caller must start over. + pub fn reset(&mut self) { + self.rev += 1; + self.floor = self.rev; + self.entries.clear(); + self.linted.clear(); + } + + /// Layers changed after revision `since`, optionally only on `page`, grouped by page and + /// client in first-change order. + pub fn since(&self, since: u64, page: Option<&str>, filter: Filter) -> Since { + if since > self.rev || since < self.floor { + return Since { truncated: true, changes: Vec::new() }; + } + let keep = |e: &Entry| match filter { + Filter::All => true, + Filter::Exclude(k) => e.by.as_ref().is_none_or(|w| w.key != k), + Filter::Only(k) => e.by.as_ref().is_some_and(|w| w.key == k || w.label == k), + }; + let mut changes: Vec = Vec::new(); + for e in self.entries.iter().filter(|e| e.rev > since && page.is_none_or(|p| p == e.page) && keep(e)) { + let by = e.by.as_ref().map(|w| w.label.clone()); + let at = changes.iter().position(|g| g.page == e.page && g.by == by); + let i = at.unwrap_or_else(|| { + changes.push(Group { page: e.page.clone(), by, rev: e.rev, ids: Vec::new() }); + changes.len() - 1 + }); + let g = &mut changes[i]; + g.rev = g.rev.max(e.rev); + if !g.ids.contains(&e.id) { + g.ids.push(e.id); + } + } + Since { truncated: false, changes } + } + + /// Notes that the running client linted all of `page` as of the current revision. + pub fn note_lint(&mut self, page: &str) { + if let Some(c) = &self.client { + self.linted.insert((c.key.clone(), page.to_string()), self.rev); + } + } + + /// Revision of the running client's last whole-page lint of `page`. + pub fn last_lint(&self, page: &str) -> Option { + let key = self.client.as_ref()?.key.clone(); + self.linted.get(&(key, page.to_string())).copied() + } +} + +/// Layers of `doc` bound to a variable in `names` (CSS names), or to any variable when `names` +/// is None: through a fill that follows it or a field binding. Sorted by id. +pub fn bound_layers(doc: &Document, names: Option<&BTreeSet>) -> Vec { + let hit = |v: &str| names.is_none_or(|n| n.contains(v)); + let mut out: Vec = doc + .nodes + .values() + .filter(|n| n.style.fills.iter().filter_map(|p| p.var()).any(hit) || n.bindings.values().any(|v| hit(v))) + .map(|n| n.id) + .collect(); + out.sort(); + out +} + +impl Editor { + /// Logs the layers bound to the variables `names` (all variables when None) on every page: + /// an edit to a variable changes how they look or lay out even when no layer was written. + pub fn log_variable_users(&mut self, names: Option<&BTreeSet>) { + self.finish_loading(); + for i in 0..self.pages.len() { + let ids = bound_layers(self.page_doc(i), names); + let page = self.pages[i].id.clone(); + self.changes.record(&page, ids); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geom::Rect; + use crate::model::{Color, Node, NodeKind, Paint}; + + #[test] + fn bound_layers_follow_fills_and_field_bindings() { + let mut doc = Document::default(); + let mut add = |f: &dyn Fn(&mut Node)| { + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 10.0, 10.0)); + f(&mut n); + doc.nodes.insert(id, n); + id + }; + let fill = add(&|n| n.style.fills = vec![Paint::solid_var(Color::rgb(1, 2, 3), "brand")]); + let gap = add(&|n| { + n.bindings.insert("gap".into(), "space-4".into()); + }); + let _plain = add(&|n| n.style.fills = vec![Paint::solid(Color::rgb(1, 2, 3))]); + let some = |s: &str| Some(BTreeSet::from([s.to_string()])); + assert_eq!(bound_layers(&doc, some("brand").as_ref()), vec![fill]); + assert_eq!(bound_layers(&doc, some("space-4").as_ref()), vec![gap]); + assert!(bound_layers(&doc, some("other").as_ref()).is_empty()); + assert_eq!(bound_layers(&doc, None), vec![fill, gap]); + } + + fn ids(v: &[u64]) -> Vec { + v.iter().map(|n| NodeId(*n)).collect() + } + + fn who(key: &str, label: &str) -> Option> { + Some(Arc::new(Who { key: key.into(), label: label.into() })) + } + + fn all(log: &ChangeLog, since: u64) -> Since { + log.since(since, None, Filter::All) + } + + #[test] + fn each_recorded_edit_is_a_revision() { + let mut log = ChangeLog::default(); + assert_eq!(log.rev(), 0); + log.record("p1", ids(&[1, 2])); + log.record("p1", ids(&[3])); + assert_eq!(log.rev(), 2); + let g = &all(&log, 0).changes; + assert_eq!((g.len(), g[0].ids.clone(), g[0].rev), (1, ids(&[1, 2, 3]), 2)); + assert_eq!(all(&log, 1).changes[0].ids, ids(&[3])); + assert!(all(&log, 2).changes.is_empty()); + } + + #[test] + fn an_empty_edit_keeps_the_revision() { + let mut log = ChangeLog::default(); + log.record("p1", []); + assert_eq!(log.rev(), 0); + } + + #[test] + fn a_layer_changed_twice_is_listed_once() { + let mut log = ChangeLog::default(); + log.record("p1", ids(&[1])); + log.record("p1", ids(&[1, 2])); + assert_eq!(all(&log, 0).changes[0].ids, ids(&[1, 2])); + } + + #[test] + fn changes_can_be_limited_to_a_page() { + let mut log = ChangeLog::default(); + log.record("p1", ids(&[1])); + log.record("p2", ids(&[1])); + let s = log.since(0, Some("p2"), Filter::All); + assert_eq!((s.changes.len(), s.changes[0].page.as_str()), (1, "p2")); + assert_eq!(all(&log, 0).changes.len(), 2); + } + + #[test] + fn asking_older_than_the_oldest_entry_is_truncated() { + let mut log = ChangeLog::with_capacity(3); + log.record("p", ids(&[1])); // rev 1 + log.record("p", ids(&[2, 3])); // rev 2 + assert!(!all(&log, 0).truncated); + log.record("p", ids(&[4])); // rev 3 drops the rev 1 entry + assert!(all(&log, 0).truncated); + // Everything after the dropped revision is still complete. + let s = all(&log, 1); + assert!(!s.truncated); + assert_eq!(s.changes[0].ids, ids(&[2, 3, 4])); + } + + #[test] + fn the_log_stays_bounded() { + let mut log = ChangeLog::with_capacity(5); + for n in 0..20 { + log.record("p", ids(&[n])); + } + assert_eq!(log.entries.len(), 5); + assert_eq!(all(&log, 15).changes[0].ids, ids(&[15, 16, 17, 18, 19])); + assert!(all(&log, 14).truncated); + } + + #[test] + fn a_future_revision_is_truncated() { + let mut log = ChangeLog::default(); + log.record("p", ids(&[1])); + assert!(all(&log, 9).truncated); + } + + #[test] + fn reset_forces_a_full_pass() { + let mut log = ChangeLog::default(); + log.record("p", ids(&[1])); + let old = log.rev(); + log.reset(); + assert!(all(&log, old).truncated); + assert!(!all(&log, log.rev()).truncated); + } + + #[test] + fn lint_revisions_are_per_client_session_and_page() { + let mut log = ChangeLog::default(); + log.record("p", ids(&[1])); + assert_eq!(log.last_lint("p"), None, "no client, nothing remembered"); + log.client = who("rmcp#1", "rmcp"); + log.note_lint("p"); + assert_eq!(log.last_lint("p"), Some(1)); + assert_eq!(log.last_lint("q"), None); + // Same client name, another session. + log.client = who("rmcp#2", "rmcp \u{b7} 2"); + assert_eq!(log.last_lint("p"), None); + } + + #[test] + fn two_clients_changes_are_attributed_separately() { + let mut log = ChangeLog::default(); + log.client = who("rmcp#1", "rmcp"); + log.record("p", ids(&[1, 2])); + log.client = who("rmcp#2", "rmcp \u{b7} 2"); + log.record("p", ids(&[2, 3])); + log.client = None; + log.record("p", ids(&[4])); + let g = all(&log, 0).changes; + assert_eq!(g.len(), 3); + assert_eq!((g[0].by.as_deref(), g[0].ids.clone(), g[0].rev), (Some("rmcp"), ids(&[1, 2]), 1)); + assert_eq!((g[1].by.as_deref(), g[1].ids.clone(), g[1].rev), (Some("rmcp \u{b7} 2"), ids(&[2, 3]), 2)); + assert_eq!((g[2].by.clone(), g[2].ids.clone()), (None, ids(&[4]))); + } + + #[test] + fn filters_pick_one_client_or_everyone_else() { + let mut log = ChangeLog::default(); + log.client = who("a#1", "A"); + log.record("p", ids(&[1])); + log.client = who("b#1", "B"); + log.record("p", ids(&[2])); + log.client = None; + log.record("p", ids(&[3])); + let others = log.since(0, None, Filter::Exclude("a#1")); + let labels: Vec<_> = others.changes.iter().map(|g| g.by.as_deref()).collect(); + assert_eq!(labels, vec![Some("B"), None], "the person's edits are someone else's too"); + let only = log.since(0, None, Filter::Only("B")); + assert_eq!(only.changes.len(), 1); + assert_eq!(only.changes[0].ids, ids(&[2])); + assert_eq!(log.since(0, None, Filter::Only("b#1")).changes, only.changes); + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs index d601f5f..2b36530 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -3,6 +3,7 @@ //! It consumes platform-agnostic [`InputEvent`]s, so every interaction can be scripted in tests. pub mod annotations; +pub mod changes; pub mod commands; pub mod design; pub mod hit; @@ -317,6 +318,8 @@ pub struct Editor { /// While set, every committed change appends the (page id, node) pairs it changed: the agent /// run tracker attributes nodes to the run that made the call. pub touch_log: Option>, + /// Which layers changed at which revision (`get_changes`, incremental lint). + pub changes: changes::ChangeLog, } impl Default for Editor { @@ -393,6 +396,7 @@ impl Editor { clipboard_style: None, cam_moved: None, touch_log: None, + changes: changes::ChangeLog::default(), clipboard_io: None, clipboard_written: None, } @@ -436,24 +440,40 @@ impl Editor { self.flush_cross_pages(); } - /// Appends the nodes `rec` changed to `touch_log`. Always true, so it chains before a commit. + /// Appends the nodes `rec` changed to `touch_log` and the change log. Always true, so it + /// chains before a commit. pub(crate) fn log_touched(&mut self, rec: &Recording) -> bool { - let Some(page) = self.touch_log.as_ref().and_then(|_| self.pages.get(self.edit_page_index())) else { + let Some(page) = self.pages.get(self.edit_page_index()).map(|p| p.id.clone()) else { return true; }; - let page = page.id.clone(); - let changed: Vec<_> = rec + let changed: Vec = rec .touched() .iter() .filter(|id| rec.before(**id).map(Option::as_ref) != Some(self.doc.nodes.get(id))) - .map(|id| (page.clone(), *id)) + .copied() .collect(); + self.changes.record(&page, changed.iter().copied()); if let Some(log) = &mut self.touch_log { - log.extend(changed); + log.extend(changed.into_iter().map(|id| (page.clone(), id))); } true } + /// A restored design system moves variables under their layers, which the step's own + /// layers do not list. + fn log_history_design(&mut self, design_restored: bool) { + if design_restored { + self.log_variable_users(None); + } + } + + /// Logs the layers an undo or redo restored. + fn log_history(&mut self, nodes: &[NodeId]) { + if let Some(page) = self.pages.get(self.edit_page_index()).map(|p| p.id.clone()) { + self.changes.record(&page, nodes.iter().copied()); + } + } + /// Run one edit as a complete transaction. pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Tx) -> R) -> R { self.begin(label); @@ -494,8 +514,11 @@ impl Editor { let mut changed = Changed::Nodes(Arc::new([])); if let Some(mut u) = self.history.undo(&mut self.doc) { self.status = format!("Undo {}", u.label); + self.log_history(&u.nodes); + let design = u.design.is_some(); changed = Changed::Nodes(std::mem::take(&mut u.nodes).into()); self.restore_project_state(u); + self.log_history_design(design); } self.after_history(changed); } @@ -507,8 +530,11 @@ impl Editor { let mut changed = Changed::Nodes(Arc::new([])); if let Some(mut u) = self.history.redo(&mut self.doc) { self.status = format!("Redo {}", u.label); + self.log_history(&u.nodes); + let design = u.design.is_some(); changed = Changed::Nodes(std::mem::take(&mut u.nodes).into()); self.restore_project_state(u); + self.log_history_design(design); } self.after_history(changed); } diff --git a/src/mcp/exec.rs b/src/mcp/exec.rs index c561516..1235795 100644 --- a/src/mcp/exec.rs +++ b/src/mcp/exec.rs @@ -261,6 +261,11 @@ fn resolve_all( /// Run one tool. Errors are returned as messages for the agent. pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult { + let r = dispatch(host, name, args); + super::incremental::after_call(host.editor(), name, args, r) +} + +fn dispatch(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult { let empty = Map::new(); let a = Args(args.as_object().unwrap_or(&empty)); match name { @@ -341,6 +346,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult { "set_text_content" => set_text_content(ed, &a), "get_variables" => get_variables(ed, &a), "lint_design" => lint_design(ed, &a), + "get_changes" => super::incremental::get_changes(ed, a.0), "get_feedback" => get_feedback(ed, &a), "add_feedback" => add_feedback(ed, &a), "resolve_feedback" => resolve_feedback(ed, &a), @@ -745,6 +751,7 @@ fn basic_info(host: &mut dyn Host, a: &Args) -> ToolResult { "page": {"id": ed.pages[page].id, "name": ed.pages[page].name, "isActive": page == ed.active_page}, "pages": pages, "nodeCount": doc.nodes.len(), + "rev": ed.changes.rev(), "artboards": artboards, "screens": crate::editor::screens::declared(doc).iter().map(|id| { let n = doc.get(*id).unwrap(); @@ -5012,18 +5019,35 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult { Some(v) => v.iter().filter_map(Value::as_f64).collect(), None => super::lint::DEFAULT_SCALE.to_vec(), }; + let whole_page = a.str("nodeId").is_none_or(|n| n.is_empty() || n == "root"); + let since = a.num("since").filter(|s| *s >= 0.0).map(|s| s as u64); + let scope = super::incremental::lint_scope(ed, page, &roots, since, a.bool("changedOnly").unwrap_or(false)); + let total: usize = roots.iter().map(|r| ed.page_doc(page).subtree(*r).len()).sum(); let fixed = if a.bool("fix").unwrap_or(false) { ed.with_page(page, |ed| { - ed.edit("Auto-fix design lint", |tx| { - super::lint::autofix(tx, &roots, &scale) + ed.edit("Auto-fix design lint", |tx| match &scope.ids { + None => super::lint::autofix(tx, &roots, &scale), + Some(ids) => { + let mut ids: Vec<_> = ids.iter().copied().collect(); + ids.sort(); + ids.into_iter().filter(|id| super::lint::autofix_node(tx, *id, &scale)).count() + } }) }) } else { 0 }; + // The next `changedOnly` lint starts from here. A subtree lint leaves the rest unchecked. + if whole_page { + let page_id = ed.pages[page].id.clone(); + ed.changes.note_lint(&page_id); + } let doc = ed.page_doc(page); let mut issues = super::lint::lint(doc, &roots, &scale); issues.extend(super::lint::not_an_instance(doc, &roots, &super::lint::component_mains(ed))); + if let Some(ids) = &scope.ids { + issues.retain(|i| ids.contains(&i.node)); + } // Layers hidden behind a drawer or modal in their screen are not fix-me findings: nobody sees them. let before = issues.len(); issues.retain(|i| !crate::editor::screens::coverage(doc, i.node).is_some_and(|c| c.is_hidden())); @@ -5042,9 +5066,13 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult { "name": doc.get(i.node).map(|n| n.name.clone()), "message": i.message, "fix": i.fix}) }) .collect(); + let checked = scope.ids.as_ref().map_or(total, |ids| ids.len()); text(json!({ "errors": count("error"), "warnings": count("warning"), "info": count("info"), "fixedNodes": fixed, + "rev": ed.changes.rev(), + "covered": {"checked": checked, "total": total, "since": scope.since, + "partial": scope.ids.is_some(), "fullPassBecause": scope.fallback}, "byRule": by_rule, "issues": list, "truncated": issues.len() > 200, "skippedHidden": skipped_hidden, diff --git a/src/mcp/incremental.rs b/src/mcp/incremental.rs new file mode 100644 index 0000000..af6fa87 --- /dev/null +++ b/src/mcp/incremental.rs @@ -0,0 +1,234 @@ +//! Incremental lint: `get_changes`, the `rev` every write tool reports, and the scope +//! `lint_design {since, changedOnly}` checks. The log itself is `editor::changes`. + +use super::exec::{find_page, node_key}; +use super::{Out, ToolResult, tools}; +use crate::design_system::sanitize_name; +use crate::editor::Editor; +use crate::editor::changes::Filter; +use crate::model::{Document, NodeId}; +use serde_json::{Map, Value, json}; +use std::collections::{BTreeSet, HashSet}; + +/// What a lint pass covers. +#[derive(Debug, Clone, PartialEq)] +pub struct Scope { + /// The layers to check, or None for all of them. + pub ids: Option>, + /// The revision the scope was computed from. + pub since: Option, + /// Why a `since` or `changedOnly` request was answered with a full pass. + pub fallback: Option<&'static str>, +} + +impl Scope { + fn full(fallback: Option<&'static str>) -> Scope { + Scope { ids: None, since: None, fallback } + } +} + +/// The layers a change to `changed` can affect: themselves, their ancestors (overflow, layout), +/// descendants, and siblings (spacing, reading order). Layers that no longer exist are skipped; +/// their parent is in the log too, because deleting a child edits its parent's list. +pub fn expand(doc: &Document, changed: &[NodeId]) -> HashSet { + let mut out = HashSet::new(); + for id in changed.iter().filter(|id| doc.get(**id).is_some()) { + out.insert(*id); + out.extend(doc.ancestors(*id)); + out.extend(doc.subtree(*id)); + let parent = doc.get(*id).and_then(|n| n.parent); + out.extend(doc.children(parent).iter().copied()); + } + out +} + +/// The scope for linting `roots` of page `page`: everything, or what changed since `since` +/// (or since the running client's last whole-page lint when `changed_only`). +pub fn lint_scope(ed: &Editor, page: usize, roots: &[NodeId], since: Option, changed_only: bool) -> Scope { + let page_id = &ed.pages[page].id; + let since = match (since, changed_only) { + (Some(s), _) => s, + (None, true) => match ed.changes.last_lint(page_id) { + Some(s) => s, + None => return Scope::full(Some("no earlier lint by this client")), + }, + (None, false) => return Scope::full(None), + }; + let log = ed.changes.since(since, Some(page_id), Filter::All); + if log.truncated { + return Scope::full(Some("change log no longer reaches that revision")); + } + let doc = ed.page_doc(page); + let changed: Vec = log.changes.iter().flat_map(|g| g.ids.iter().copied()).collect(); + let inside: HashSet = roots.iter().flat_map(|r| doc.subtree(*r)).collect(); + let ids = expand(doc, &changed).into_iter().filter(|id| inside.contains(id)).collect(); + Scope { ids: Some(ids), since: Some(since), fallback: None } +} + +/// Which variables a variable tool edits: Some(names) in CSS form, None for "any". +fn variable_names(name: &str, args: &Value) -> Option>> { + let keys = |field: &str| -> Option> { + let o = args.get(field)?; + let names: BTreeSet = match o { + Value::Object(m) => m.keys().map(|k| sanitize_name(k)).collect(), + Value::Array(a) => a.iter().filter_map(Value::as_str).map(sanitize_name).collect(), + _ => return None, + }; + (!names.is_empty()).then_some(names) + }; + match name { + "set_variables" => Some(keys("variables")), + "set_tokens" => Some(keys("tokens")), + "delete_variables" => Some(keys("names")), + "set_variable_mode" | "import_design_system" => Some(None), + _ => None, + } +} + +/// Post-processing of every tool call: variable edits count as touching the layers bound to +/// them, a new project starts a new log, and write tools report the revision they left. +pub fn after_call(ed: &mut Editor, name: &str, args: &Value, r: ToolResult) -> ToolResult { + let mut out = r?; + if matches!(name, "open_file" | "create_file") { + ed.changes.reset(); + } + if let Some(names) = variable_names(name, args) { + ed.log_variable_users(names.as_ref()); + } + if tools::edits_doc(name, args) { + add_rev(&mut out, ed.changes.rev()); + } + Ok(out) +} + +/// Puts `rev` into the result's JSON object, or appends a `{"rev"}` result when the tool +/// answered with something else. +fn add_rev(out: &mut Vec, rev: u64) { + if let Some(Out::Text(t)) = out.first_mut() + && let Ok(Value::Object(mut m)) = serde_json::from_str::(t) + { + m.insert("rev".into(), json!(rev)); + *t = serde_json::to_string_pretty(&Value::Object(m)).unwrap_or_default(); + return; + } + out.push(Out::Text(json!({"rev": rev}).to_string())); +} + +/// `get_changes`. +pub fn get_changes(ed: &mut Editor, args: &Map) -> ToolResult { + let since = args + .get("since") + .and_then(Value::as_f64) + .filter(|s| *s >= 0.0) + .ok_or("`since` must be a revision number (the `rev` of an earlier result)")? as u64; + let page = match args.get("page").and_then(Value::as_str).filter(|p| !p.is_empty()) { + Some(p) => Some(find_page(ed, p)?), + None => None, + }; + let me = ed.changes.client.clone(); + let by = args.get("by").and_then(Value::as_str).filter(|b| !b.is_empty()); + let filter = match (args.get("exclude").and_then(Value::as_str), by) { + (Some("self"), _) => match &me { + Some(w) => Filter::Exclude(&w.key), + None => return Err("`exclude: \"self\"` needs to know which client is asking; this connection is anonymous".into()), + }, + (Some(other), _) => return Err(format!("`exclude` must be \"self\", not `{other}`")), + (None, Some(b)) => Filter::Only(b), + (None, None) => Filter::All, + }; + let page_id = page.map(|p| ed.pages[p].id.clone()); + let log = ed.changes.since(since, page_id.as_deref(), filter); + let changes: Vec = log + .changes + .iter() + .filter_map(|g| { + let p = ed.pages.iter().position(|p| p.id == g.page)?; + let ids: Vec = g.ids.iter().map(|id| node_key(ed, p, *id)).collect(); + Some(json!({"page": g.page, "ids": ids, "by": g.by, "rev": g.rev})) + }) + .collect(); + Ok(vec![Out::Text( + serde_json::to_string_pretty(&json!({ + "rev": ed.changes.rev(), "since": since, "truncated": log.truncated, "changes": changes, + })) + .unwrap_or_default(), + )]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::geom::Rect; + use crate::model::{Node, NodeKind}; + + /// root frame 1 > [frame 2 > [3, 4], frame 5 > [6]] + fn tree() -> Document { + let mut doc = Document::default(); + let mut add = |parent: Option| { + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 10.0, 10.0)); + n.parent = parent; + doc.nodes.insert(id, n); + match parent { + Some(p) => doc.nodes.get_mut(&p).unwrap().children.push(id), + None => doc.root.push(id), + } + id + }; + let r = add(None); + let a = add(Some(r)); + add(Some(a)); + add(Some(a)); + let b = add(Some(r)); + add(Some(b)); + doc + } + + fn set(v: &[u64]) -> HashSet { + v.iter().map(|n| NodeId(*n)).collect() + } + + #[test] + fn a_change_reaches_ancestors_descendants_and_siblings() { + let doc = tree(); + // Layer 2: itself, root 1, children 3 and 4, sibling 5. Not 5's child 6. + assert_eq!(expand(&doc, &[NodeId(2)]), set(&[1, 2, 3, 4, 5])); + // Layer 3: its parent and root, its sibling 4; not the uncle's subtree. + assert_eq!(expand(&doc, &[NodeId(3)]), set(&[1, 2, 3, 4])); + } + + #[test] + fn a_top_level_layer_reaches_the_other_top_level_layers() { + let mut doc = tree(); + let id = doc.alloc_id(); + doc.nodes.insert(id, Node::new(id, NodeKind::Frame { clip: false }, Rect::new(0.0, 0.0, 1.0, 1.0))); + doc.root.push(id); + assert_eq!(expand(&doc, &[id]), set(&[1, 7])); + } + + #[test] + fn deleted_layers_are_skipped() { + assert!(expand(&tree(), &[NodeId(99)]).is_empty()); + } + + #[test] + fn variable_tools_name_the_variables_they_edit() { + let some = |v: &[&str]| Some(Some(v.iter().map(|s| s.to_string()).collect::>())); + assert_eq!(variable_names("set_variables", &json!({"variables": {"accent": "#fff", "--bg": "#000"}})), some(&["accent", "bg"])); + assert_eq!(variable_names("delete_variables", &json!({"names": ["accent"]})), some(&["accent"])); + assert_eq!(variable_names("set_variable_mode", &json!({"mode": "Dark"})), Some(None)); + assert_eq!(variable_names("set_variables", &json!({})), Some(None), "unreadable arguments mean any variable"); + assert_eq!(variable_names("update_styles", &json!({})), None); + } + + #[test] + fn rev_goes_into_a_json_object_or_a_result_of_its_own() { + let mut out = vec![Out::Text("{\"ok\": true}".into())]; + add_rev(&mut out, 7); + let Out::Text(t) = &out[0] else { panic!() }; + assert_eq!(serde_json::from_str::(t).unwrap(), json!({"ok": true, "rev": 7})); + let mut out = vec![Out::Text("done".into())]; + add_rev(&mut out, 8); + assert_eq!(out.len(), 2); + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index a1daee7..7523911 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -18,6 +18,7 @@ pub mod diff; pub mod enforcer; pub mod exec; pub mod html; +pub mod incremental; pub mod lint; #[cfg(not(target_arch = "wasm32"))] pub mod imagegen; diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 291df1b..8fed376 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -685,8 +685,16 @@ pub fn definitions() -> Vec { names (Frame, Rectangle), and hard-coded colors that equal a variable. Run it before \ finish_working_on_nodes and fix errors and warnings; `info` items are suggestions. \ Pass `fix: true` to automatically snap spacing/padding to 4px, relink matching color \ - variables, snap text sizes, and enlarge small touch targets.", + variables, snap text sizes, and enlarge small touch targets. After the first full pass, \ + lint only what you changed: `changedOnly: true` checks the layers changed since this \ + client's last whole-page lint, `since: ` since that revision (every write tool \ + returns `rev`); both add what a change can affect (ancestors, descendants, siblings, \ + layers bound to an edited variable), work with `fix`, and fall back to a full pass \ + when the log does not reach back or this client has not linted yet. The result's \ + `covered` says what was checked: {checked, total, since, partial, fullPassBecause}.", json!({"nodeId": {"type": "string", "description": "Subtree to check; omit for the whole page."}, + "since": {"type": "number", "description": "Check only layers changed after this revision (`rev` from a write tool, get_basic_info or get_changes), plus the layers a change affects."}, + "changedOnly": {"type": "boolean", "description": "Same as `since`, with the revision of this client's last whole-page lint of the page."}, "scale": {"type": "array", "items": {"type": "number"}, "description": "Allowed font sizes. Default 12, 14, 16, 20, 24, 32, 40, 48, 64."}, "fix": {"type": "boolean", "description": "Apply safe automatic fixes before reporting remaining issues."}, @@ -694,6 +702,25 @@ pub fn definitions() -> Vec { &[], false, ), + tool( + "get_changes", + "Which layers changed after revision `since`. Every write tool returns `rev`, the \ + revision after the call (get_basic_info reports the current one); each edit, undo and \ + redo adds layers to the log, and editing a variable counts as changing every layer \ + bound to it. Returns {rev, since, truncated, changes: [{page, ids, by, rev}]}: ids use \ + the same \":\" form as the other tools, `by` is the client that made them \ + (null for the person using the app), `rev` the latest revision in the group. Reads \ + change nothing. `truncated: true` means the log (the last 10000 layer changes) no \ + longer reaches back to `since`, or `since` is newer than `rev`: do a full pass instead. \ + `exclude: \"self\"` leaves out your own changes, to see what others changed; `by` keeps \ + one client's. Layers deleted since are listed too. Use it with lint_design `since`.", + json!({"since": {"type": "number", "description": "A revision you have seen; 0 for everything on record."}, + "page": {"type": "string", "description": "Only changes on this page (id)."}, + "exclude": {"type": "string", "enum": ["self"], "description": "\"self\": leave out changes made by this client."}, + "by": {"type": "string", "description": "Only changes made by this client (its `by` label)."}}), + &["since"], + true, + ), tool( "get_variables", "Variables of the page's project. Default JSON: `variables` (name -> hex), `tokens`, and \ @@ -1213,7 +1240,7 @@ fn classify(name: &str, args: &Value) -> Option { | "get_tree_summary" | "get_screenshot" | "get_jsx" | "get_design_spec" | "verify_code" | "get_computed_styles" | "get_fill_image" | "zoom_image" | "ask_user" | "fetch_reference_images" | "generate_image" | "report_issue" | "design_rules" | "delegate" | "find_nodes" | "get_font_family_info" | "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons" - | "diff_frames" | "publish_library" | "list_libraries" => false, + | "diff_frames" | "publish_library" | "list_libraries" | "get_changes" => false, "select_nodes" | "set_viewport" | "export" | "export_combined_pdf" | "export_design_system" | "open_file" | "create_file" | "rename_file" | "finish_working_on_nodes" => false, "lint_design" => args["fix"] == true, diff --git a/src/runs.rs b/src/runs.rs index 20c5edb..9382db2 100644 --- a/src/runs.rs +++ b/src/runs.rs @@ -518,7 +518,8 @@ impl Tracker { } } - /// Runs `exec::call`, recording it in `client`'s open run when it edits the document. + /// Runs `exec::call`, recording it in `client`'s open run when it edits the document. The + /// change log attributes what the call changes to `client`. pub fn call( &mut self, host: &mut dyn Host, @@ -526,6 +527,24 @@ impl Tracker { name: &str, args: &Value, now: u64, + ) -> ToolResult { + let who = crate::editor::changes::Who { + key: format!("{}#{}", client.name, client.session), + label: self.label(client), + }; + host.editor().changes.client = Some(std::sync::Arc::new(who)); + let r = self.call_attributed(host, client, name, args, now); + host.editor().changes.client = None; + r + } + + fn call_attributed( + &mut self, + host: &mut dyn Host, + client: &Client, + name: &str, + args: &Value, + now: u64, ) -> ToolResult { let dir = host.project().map(|p| p.dir.clone()); self.sync_project(now, dir.as_deref()); diff --git a/tests/incremental.rs b/tests/incremental.rs new file mode 100644 index 0000000..6981095 --- /dev/null +++ b/tests/incremental.rs @@ -0,0 +1,313 @@ +//! Incremental lint on a headless session: the change log behind `get_changes`, the `rev` +//! write tools report, and `lint_design {since, changedOnly}`. + +use papercraft::editor::changes::{ChangeLog, Who}; +use papercraft::mcp::{Out, exec}; +use papercraft::project::Store; +use serde_json::{Value, json}; +use std::sync::Arc; + +fn host() -> exec::SessionHost { + let s = Store::open(std::env::temp_dir().join(format!("papercraft-inc-{:016x}", fastrand::u64(..)))).unwrap(); + let mut h = exec::SessionHost::new(s); + h.session.create(&mut h.ed, "Incremental").unwrap(); + h +} + +fn call(h: &mut exec::SessionHost, name: &str, args: Value) -> Value { + match exec::call(h, name, &args) { + Ok(outs) => match outs.into_iter().next() { + Some(Out::Text(t)) => serde_json::from_str(&t).unwrap_or(Value::String(t)), + _ => Value::Null, + }, + Err(e) => panic!("{name} failed: {e}"), + } +} + +fn act_as(h: &mut exec::SessionHost, name: &str, session: &str) { + let label = name.to_string(); + h.ed.changes.client = Some(Arc::new(Who { key: format!("{name}#{session}"), label })); +} + +struct Design { + /// Layer ids by role. + card_a: String, + card_b: String, + far: String, +} + +/// Two artboards: "Near" with two cards (the first bound to the `accent` variable), "Far" with +/// one card holding 10px text, which lint flags. +fn design(h: &mut exec::SessionHost) -> Design { + call(h, "set_variables", json!({"variables": {"accent": "#2563eb"}})); + let near = call(h, "create_artboard", json!({"name": "Near", "styles": {"width": "390px", "height": "400px", "padding": "16px", "gap": 16}})); + let r = call( + h, + "write_html", + json!({"targetNodeId": near["id"], "mode": "insert-children", + "html": "

A

B

"}), + ); + let far = call(h, "create_artboard", json!({"name": "Far", "styles": {"width": "390px", "height": "400px", "padding": "16px"}})); + let f = call( + h, + "write_html", + json!({"targetNodeId": far["id"], "mode": "insert-children", + "html": "

Tiny

"}), + ); + Design { + card_a: r["createdNodeIds"][0].as_str().unwrap().into(), + card_b: r["createdNodeIds"][1].as_str().unwrap().into(), + far: f["createdNodeIds"][0].as_str().unwrap().into(), + } +} + +fn changed_ids(r: &Value) -> Vec { + r["changes"].as_array().unwrap().iter().flat_map(|g| g["ids"].as_array().unwrap().iter().map(|i| i.as_str().unwrap().to_string())).collect() +} + +fn issue_nodes(r: &Value) -> Vec { + r["issues"].as_array().unwrap().iter().map(|i| i["node"].as_str().unwrap().to_string()).collect() +} + +fn restyle(h: &mut exec::SessionHost, node: &str, styles: Value) -> Value { + call(h, "update_styles", json!({"updates": [{"nodeIds": [node], "styles": styles}]})) +} + +#[test] +fn write_tools_report_the_revision_and_reads_leave_it_alone() { + let mut h = host(); + let d = design(&mut h); + let r = restyle(&mut h, &d.card_b, json!({"opacity": 0.9})); + let rev = r["rev"].as_u64().expect("write result carries rev"); + assert!(rev > 0); + for (name, args) in [ + ("get_basic_info", json!({})), + ("get_node_info", json!({"nodeId": d.card_b})), + ("get_children", json!({"nodeId": d.card_b})), + ("get_variables", json!({})), + ("lint_design", json!({})), + ("get_changes", json!({"since": 0})), + ] { + call(&mut h, name, args); + } + let info = call(&mut h, "get_basic_info", json!({})); + assert_eq!(info["rev"], rev, "reads do not bump the revision"); + assert_eq!(call(&mut h, "get_changes", json!({"since": rev}))["changes"], json!([])); + let again = restyle(&mut h, &d.card_b, json!({"opacity": 0.8})); + assert!(again["rev"].as_u64().unwrap() > rev); +} + +#[test] +fn get_changes_lists_the_layers_a_call_touched() { + let mut h = host(); + let d = design(&mut h); + let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap(); + restyle(&mut h, &d.card_b, json!({"opacity": 0.5})); + let r = call(&mut h, "get_changes", json!({"since": rev})); + assert_eq!(r["truncated"], false); + assert_eq!(r["since"], rev); + assert_eq!(changed_ids(&r), vec![d.card_b.clone()], "{r}"); + assert!(r["rev"].as_u64().unwrap() > rev); + let page = h.ed.pages[0].id.clone(); + assert_eq!(call(&mut h, "get_changes", json!({"since": rev, "page": page}))["changes"], r["changes"]); + let other = call(&mut h, "create_page", json!({"name": "Other"})); + let other_id = other["id"].as_str().or(other["pageId"].as_str()).unwrap_or_default().to_string(); + if !other_id.is_empty() { + assert_eq!(call(&mut h, "get_changes", json!({"since": rev, "page": other_id}))["changes"], json!([])); + } +} + +#[test] +fn undo_and_redo_count_as_changes() { + let mut h = host(); + let d = design(&mut h); + restyle(&mut h, &d.card_b, json!({"opacity": 0.5})); + let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap(); + let r = call(&mut h, "undo", json!({})); + assert!(r["rev"].as_u64().unwrap() > rev); + assert!(changed_ids(&call(&mut h, "get_changes", json!({"since": rev}))).contains(&d.card_b)); + let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap(); + call(&mut h, "redo", json!({})); + assert!(changed_ids(&call(&mut h, "get_changes", json!({"since": rev}))).contains(&d.card_b)); +} + +#[test] +fn a_variable_edit_touches_every_layer_bound_to_it() { + let mut h = host(); + let d = design(&mut h); + let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap(); + call(&mut h, "set_variables", json!({"variables": {"accent": "#ef4444"}})); + let ids = changed_ids(&call(&mut h, "get_changes", json!({"since": rev}))); + assert!(ids.contains(&d.card_a), "the bound card changed: {ids:?}"); + assert!(!ids.contains(&d.card_b) && !ids.contains(&d.far), "unbound layers did not: {ids:?}"); +} + +#[test] +fn the_log_says_truncated_when_it_no_longer_reaches_back() { + let mut h = host(); + let d = design(&mut h); + h.ed.changes = ChangeLog::with_capacity(4); + for n in 0..6 { + restyle(&mut h, &d.card_b, json!({"opacity": 0.5 + n as f64 / 20.0})); + } + let r = call(&mut h, "get_changes", json!({"since": 0})); + assert_eq!(r["truncated"], true); + assert_eq!(r["changes"], json!([])); + // A lint that cannot rely on the log does a full pass. + let l = call(&mut h, "lint_design", json!({"since": 0})); + assert_eq!(l["covered"]["partial"], false, "{l}"); + assert_eq!(l["covered"]["checked"], l["covered"]["total"]); + assert!(l["covered"]["fullPassBecause"].is_string()); + let fresh = call(&mut h, "get_changes", json!({"since": r["rev"].as_u64().unwrap() - 1})); + assert_eq!(fresh["truncated"], false); +} + +#[test] +fn lint_since_checks_only_what_a_change_can_affect() { + let mut h = host(); + let d = design(&mut h); + let full = call(&mut h, "lint_design", json!({})); + assert_eq!(full["covered"]["partial"], false); + assert_eq!(full["covered"]["since"], Value::Null); + let total = full["covered"]["total"].as_u64().unwrap(); + let has_tiny = |r: &Value| r["issues"].as_array().unwrap().iter().any(|i| i["rule"] == "text-size"); + let full_count = full["issues"].as_array().unwrap().len(); + assert!(has_tiny(&full), "the 10px text is flagged: {full}"); + + let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap(); + restyle(&mut h, &d.card_b, json!({"opacity": 0.9})); + let r = call(&mut h, "lint_design", json!({"since": rev})); + let cov = &r["covered"]; + assert_eq!(cov["partial"], true); + assert_eq!(cov["since"], rev); + assert_eq!(cov["total"], total); + assert!(cov["checked"].as_u64().unwrap() < total, "{cov}"); + assert!(issue_nodes(&r).iter().all(|n| *n != d.far), "the far card was not checked"); + assert!(!has_tiny(&r), "the far card's finding is not repeated: {r}"); + assert!(r["issues"].as_array().unwrap().len() < full_count); + + // Touching the far card brings its finding back. + restyle(&mut h, &d.far, json!({"opacity": 0.9})); + let r = call(&mut h, "lint_design", json!({"since": rev})); + assert!(has_tiny(&r), "{r}"); +} + +#[test] +fn a_variable_edit_pulls_its_bound_layers_into_the_lint() { + let mut h = host(); + let d = design(&mut h); + let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap(); + call(&mut h, "set_variables", json!({"variables": {"accent": "#fde68a"}})); + let r = call(&mut h, "lint_design", json!({"since": rev})); + assert_eq!(r["covered"]["partial"], true); + let near_total = h.ed.doc.nodes.len(); + assert!(r["covered"]["checked"].as_u64().unwrap() as usize <= near_total); + let ids = changed_ids(&call(&mut h, "get_changes", json!({"since": rev}))); + assert!(ids.contains(&d.card_a)); +} + +#[test] +fn changed_only_follows_this_clients_last_lint() { + let mut h = host(); + let d = design(&mut h); + // Unknown client: nothing to compare with, so a full pass that says why. + let anon = call(&mut h, "lint_design", json!({"changedOnly": true})); + assert_eq!(anon["covered"]["partial"], false); + assert!(anon["covered"]["fullPassBecause"].is_string(), "{anon}"); + + act_as(&mut h, "rmcp", "s1"); + let first = call(&mut h, "lint_design", json!({"changedOnly": true})); + assert_eq!(first["covered"]["partial"], false, "this client has not linted yet"); + restyle(&mut h, &d.card_b, json!({"opacity": 0.9})); + let second = call(&mut h, "lint_design", json!({"changedOnly": true})); + assert_eq!(second["covered"]["partial"], true, "{second}"); + assert!(second["covered"]["checked"].as_u64().unwrap() < second["covered"]["total"].as_u64().unwrap()); + // Nothing changed since the last lint: nothing to check. + let third = call(&mut h, "lint_design", json!({"changedOnly": true})); + assert_eq!(third["covered"]["checked"], 0, "{third}"); + + // Another session of the same client name has its own memory. + act_as(&mut h, "rmcp", "s2"); + let other = call(&mut h, "lint_design", json!({"changedOnly": true})); + assert_eq!(other["covered"]["partial"], false); +} + +#[test] +fn a_subtree_lint_does_not_count_as_a_whole_page_lint() { + let mut h = host(); + let d = design(&mut h); + act_as(&mut h, "rmcp", "s1"); + call(&mut h, "lint_design", json!({"nodeId": d.card_b})); + let r = call(&mut h, "lint_design", json!({"changedOnly": true})); + assert_eq!(r["covered"]["partial"], false, "{r}"); +} + +#[test] +fn fix_works_on_the_scoped_set_only() { + let mut h = host(); + let d = design(&mut h); + let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap(); + // Both cards get an off-grid gap; only the near one is in scope. + restyle(&mut h, &d.card_b, json!({"gap": 13, "display": "flex", "flexDirection": "column"})); + restyle(&mut h, &d.far, json!({"gap": 13, "display": "flex", "flexDirection": "column"})); + let far_rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap(); + let r = call(&mut h, "lint_design", json!({"since": far_rev - 1, "fix": true})); + assert_eq!(r["covered"]["partial"], true); + let gap = |h: &exec::SessionHost, key: &str| { + let id = papercraft::model::NodeId(key.rsplit(':').next().unwrap().parse().unwrap()); + h.ed.doc.get(id).and_then(|n| n.layout).map(|l| l.gap) + }; + let (near, far) = (gap(&h, &d.card_b), gap(&h, &d.far)); + assert_eq!(far, Some(12.0), "the layer touched last was in scope: {r}"); + assert_eq!(near, Some(13.0), "the earlier edit was out of scope: {r}"); + assert!(r["rev"].as_u64().unwrap() > far_rev, "fixing is a change"); + let _ = rev; +} + +#[test] +fn changes_are_attributed_to_the_client_that_made_them() { + let mut h = host(); + let d = design(&mut h); + let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap(); + act_as(&mut h, "Claude Code", "s1"); + restyle(&mut h, &d.card_a, json!({"opacity": 0.9})); + act_as(&mut h, "Claude Code \u{b7} 2", "s2"); + restyle(&mut h, &d.card_b, json!({"opacity": 0.9})); + let r = call(&mut h, "get_changes", json!({"since": rev})); + let groups = r["changes"].as_array().unwrap(); + assert_eq!(groups.len(), 2, "{r}"); + assert_eq!(groups[0]["by"], "Claude Code"); + assert_eq!(groups[0]["ids"], json!([d.card_a])); + assert_eq!(groups[1]["by"], "Claude Code \u{b7} 2"); + assert_eq!(groups[1]["ids"], json!([d.card_b])); + assert!(groups[1]["rev"].as_u64() > groups[0]["rev"].as_u64()); + + // Session 2 asks what others changed, then session 1 does. + let others = call(&mut h, "get_changes", json!({"since": rev, "exclude": "self"})); + assert_eq!(changed_ids(&others), vec![d.card_a.clone()]); + act_as(&mut h, "Claude Code", "s1"); + let others = call(&mut h, "get_changes", json!({"since": rev, "exclude": "self"})); + assert_eq!(changed_ids(&others), vec![d.card_b.clone()]); + let by = call(&mut h, "get_changes", json!({"since": rev, "by": "Claude Code"})); + assert_eq!(changed_ids(&by), vec![d.card_a.clone()]); +} + +#[test] +fn exclude_self_needs_a_known_client() { + let mut h = host(); + design(&mut h); + let e = exec::call(&mut h, "get_changes", &json!({"since": 0, "exclude": "self"})).unwrap_err(); + assert!(e.contains("exclude"), "{e}"); + let e = exec::call(&mut h, "get_changes", &json!({})).unwrap_err(); + assert!(e.contains("since"), "{e}"); +} + +#[test] +fn opening_another_project_resets_the_log() { + let mut h = host(); + design(&mut h); + let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap(); + call(&mut h, "create_file", json!({"name": "Second"})); + let r = call(&mut h, "get_changes", json!({"since": rev})); + assert_eq!(r["truncated"], true, "{r}"); +} diff --git a/tests/mcp.rs b/tests/mcp.rs index 21b777a..a50ab1c 100644 --- a/tests/mcp.rs +++ b/tests/mcp.rs @@ -830,6 +830,11 @@ async fn parallel_sessions_get_their_own_runs() { for (client, name) in [(&a, "A1"), (&b, "B1"), (&a, "A2"), (&chat, "C1")] { text_of(&client.call_tool(frame(name)).await.unwrap()); } + // The change log tells the sessions apart: A sees only what B and the chat changed. + let others = text_of(&a.call_tool(tool("get_changes", json!({"since": 0, "exclude": "self"}))).await.unwrap()); + let mut by: Vec<&str> = others["changes"].as_array().unwrap().iter().map(|g| g["by"].as_str().unwrap()).collect(); + by.sort(); + assert_eq!(by, ["Agent B", "Chat"], "{others}"); for c in [a, b, chat] { c.cancel().await.unwrap(); }