Files
Marcos Paulo e876035fc6 fix: reset the change log on every project switch
Editor::load replaced the change log with a fresh one, so rev restarted at 0
when a project was opened from the app. Once the new project caught up, an
agent's old `since` looked current and get_changes reported a partial list
instead of `truncated`. The log now carries across load and resets there,
which covers the app, the session and MCP alike.

The MCP-only reset after create_file goes: that tool creates a project
without opening it. Tests now open the project with open_file and read the
rev before writing instead of assuming the log starts at 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:50:07 -03:00

328 lines
14 KiB
Rust

//! 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": "<div layer-name=\"Card A\" style=\"padding:16px;background:var(--accent)\"><p style=\"font-size:16px\">A</p></div><div layer-name=\"Card B\" style=\"padding:16px;background:#eeeeee\"><p style=\"font-size:16px\">B</p></div>"}),
);
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": "<div layer-name=\"Card F\" style=\"padding:16px;background:#eeeeee\"><p style=\"font-size:10px\">Tiny</p></div>"}),
);
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<String> {
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<String> {
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();
let id = call(&mut h, "create_file", json!({"name": "Second"}))["fileId"].clone();
call(&mut h, "open_file", json!({"fileId": id}));
let r = call(&mut h, "get_changes", json!({"since": rev}));
assert_eq!(r["truncated"], true, "{r}");
}
/// The app opens projects through the session, not a tool call. Once the new project's edits
/// bring rev back up to an agent's old `since`, that agent must still be told to start over.
#[test]
fn a_project_opened_outside_a_tool_call_resets_the_log() {
let mut h = host();
design(&mut h);
let rev = call(&mut h, "get_basic_info", json!({}))["rev"].as_u64().unwrap();
h.session.create(&mut h.ed, "Second").unwrap();
design(&mut h);
let r = call(&mut h, "get_changes", json!({"since": rev}));
assert_eq!(r["truncated"], true, "{r}");
}