From dd51f4316ce2838d20f80a2c0075e1ac7de1eebb Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 23:15:39 -0700 Subject: [PATCH 001/114] WIP red: a records coordinate rename, from the browser (improvise-fdk1) --- .../tests/browser_round_trip.rs | 161 ++++++++++++++++++ .../improvise-wasm-client/tests/lockstep.rs | 64 +++++++ 2 files changed, 225 insertions(+) diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 56ae17a9..d00c05d3 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -771,3 +771,164 @@ fn a_typed_number_crosses_the_serialized_wire_exactly() { "the host must store the number the user typed, not its neighbour" ); } + +// ── a coordinate rename over the wire (improvise-fdk1) ─────────────────── + +/// Every frame the host addressed, to whichever of the two clients it names. +fn route( + frames: &[Addressed], + a: (improvise_command::ui::project::SessionId, &mut Client), + b: (improvise_command::ui::project::SessionId, &mut Client), +) { + for Addressed { to, msg } in frames { + let json = serde_json::to_string(msg).expect("a server frame serializes"); + if *to == a.0 { + a.1.on_frame(&json).expect("a folds every frame"); + } + if *to == b.0 { + b.1.on_frame(&json).expect("b folds every frame"); + } + } +} + +/// Client A renames North's `Region` to `Zed` in records mode; when `racing`, +/// client B writes North's Revenue while A's editor is open. Evidence file. +fn rename_north(racing: bool) -> (Host, Vec) { + let mut host = host(); + let (mut a, mut b) = (Client::new(), Client::new()); + let (a_id, a_snap) = host.connect(10, 10); + let (b_id, b_snap) = host.connect(10, 10); + a.set_viewport(10, 10); + b.set_viewport(10, 10); + deliver(&mut a, &[a_snap]); + deliver(&mut b, &[b_snap]); + + let mut press_a = |host: &mut Host, a: &mut Client, b: &mut Client, key: &str| { + let Some(frame) = a.key(&BrowserKey::new(key)) else { + return Vec::new(); + }; + let json = serde_json::to_string(&frame).expect("a client frame serializes"); + let frames = send(host, a_id, &json); + route(&frames, (a_id, &mut *a), (b_id, &mut *b)); + frames + }; + + press_a(&mut host, &mut a, &mut b, "T"); + for (category, key) in [("_Index", "r"), ("_Dim", "c")] { + let at = a + .state() + .tiles + .tiles + .iter() + .position(|t| t.category == category) + .unwrap_or_else(|| panic!("the tile bar must carry {category}")); + while a.state().view.tile_cat_idx < at { + press_a(&mut host, &mut a, &mut b, "l"); + } + while a.state().view.tile_cat_idx > at { + press_a(&mut host, &mut a, &mut b, "h"); + } + press_a(&mut host, &mut a, &mut b, key); + } + press_a(&mut host, &mut a, &mut b, "Escape"); + assert!(a.state().grid.records, "A must be standing in records mode"); + + let coordinate = (0..a.state().grid.extent.cols) + .find(|&c| { + format!("{:?}", a.state().grid.cell(0, c).and_then(|x| x.id.clone())) + .starts_with("Some(Coordinate") + }) + .expect("records mode projects a coordinate column"); + while a.state().view.cursor().selected.1 < coordinate { + press_a(&mut host, &mut a, &mut b, "l"); + } + while a.state().view.cursor().selected.1 > coordinate { + press_a(&mut host, &mut a, &mut b, "h"); + } + press_a(&mut host, &mut a, &mut b, "i"); + assert!( + a.state().view.mode.is_editing(), + "A's editor must open on the coordinate, or nothing here is renamed" + ); + assert_eq!( + a.state().view.buffers.get("edit").map(String::as_str), + Some("North") + ); + + if racing { + let revenue = (0..b.state().grid.extent.cols) + .find(|&c| { + b.record_cell_at(0, c) + .is_some_and(|cell| cell.measure.as_deref() == Some("Revenue")) + }) + .expect("B sees North's Revenue"); + let frame = b.typed_cell_frame(0, revenue, "999").expect("B composes a write"); + let frames = send( + &mut host, + b_id, + &serde_json::to_string(&frame).expect("json"), + ); + assert!( + frames.iter().any(|f| f.to == b_id + && matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + "B's write must land, or A has nothing to be stale against: {frames:?}" + ); + route(&frames, (a_id, &mut a), (b_id, &mut b)); + } + + for key in ["Backspace"; 5] { + press_a(&mut host, &mut a, &mut b, key); + } + for key in ["Z", "e", "d"] { + press_a(&mut host, &mut a, &mut b, key); + } + let answers = press_a(&mut host, &mut a, &mut b, "Enter"); + (host, answers) +} + +fn region(host: &Host, item: &str) -> Option { + host.app() + .model_state + .workbook + .model + .get_cell(&CellKey::new(vec![ + ("Region".into(), item.into()), + ("_Measure".into(), "Revenue".into()), + ])) + .cloned() +} + +/// improvise-fdk1: the rename moves the record, claimed at the version the +/// editor opened on. Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_browser_renames_a_records_coordinate_and_the_host_moves_the_record() { + let (host, answers) = rename_north(false); + assert!( + answers + .iter() + .any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + "the rename must be accepted: {answers:?}" + ); + assert_eq!(region(&host, "Zed"), Some(CellValue::Number(1200.0))); + assert_eq!(region(&host, "North"), None, "the record moved, not copied"); +} + +/// improvise-fdk1 / improvise-0j0f: another client wrote the record while the +/// editor was open, so the rename is stale. Evidence file. +#[test] +fn a_coordinate_rename_another_client_moved_is_refused() { + let (host, answers) = rename_north(true); + assert!( + answers.iter().any(|f| matches!( + f.msg, + improvise_protocol::ServerFrame::Rejected(improvise_protocol::Rejection::RecordMoved(_)) + )), + "a rename composed against a record someone else wrote must be refused: {answers:?}" + ); + assert_eq!( + region(&host, "North"), + Some(CellValue::Number(999.0)), + "the other client's write must survive" + ); + assert_eq!(region(&host, "Zed"), None, "nothing was renamed"); +} diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 294b3fc0..fd12fa2a 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1950,3 +1950,67 @@ fn every_projected_edit_text_commits_back_to_its_value() { } } } + +// A coordinate rename (improvise-fdk1). + +/// Two records, North renamed past South so the sorted records order moves. +/// Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_locally_committed_coordinate_rename_agrees_with_the_host() { + let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE); + let two_records = || { + let mut app = furnished_host(); + let south = north_revenue().with("Region", "South"); + app.model_state + .workbook + .model + .set_cell(south, improvise_protocol::CellValue::Number(900.0)); + app.rebuild_layout(); + reduce_key(&mut app, r); + app + }; + let (mut app, mut twin) = (two_records(), two_records()); + let region = (0..app.layout.col_count()) + .find(|&c| app.layout.col_label(c) == "Region") + .expect("a Region column"); + let north = 0; + assert_eq!( + app.layout.record_row(north).unwrap().coords.get("Region"), + Some("North"), + "the fixture renames North, at row 0" + ); + for host in [&mut app, &mut twin] { + host.cursor_mut().selected = (north, region); + } + let mut client = records_client(&app); + + both_ends(&mut app, &mut twin, &mut client, "i"); + assert_eq!( + client.view.buffers.get("edit").map(String::as_str), + Some("North"), + "the editor opens on the coordinate it renames" + ); + for key in [ + "Backspace", + "Backspace", + "Backspace", + "Backspace", + "Backspace", + "Z", + "e", + "d", + ] { + both_ends(&mut app, &mut twin, &mut client, key); + } + both_ends(&mut app, &mut twin, &mut client, "Enter"); + + let text = document_text(&app); + assert!( + text.contains("Region=Zed") && !text.contains("Region=North"), + "the rename must move North's cells to Zed: {text}" + ); + assert!( + text.contains("Region=South"), + "South is a bystander and must survive: {text}" + ); +} From 5d9dcceaa09fccefeae70f3699d6efa1643ecc16 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 23:25:12 -0700 Subject: [PATCH 002/114] WIP: coordinate rename on the wire and in the browser (improvise-fdk1) --- .../src/command/cmd/commit.rs | 8 ++- crates/improvise-command/src/ui/effect.rs | 23 +++++++ crates/improvise-command/src/ui/project.rs | 26 +++++--- crates/improvise-command/src/ui/reduce.rs | 20 ++++++ crates/improvise-core/src/view/layout.rs | 12 ++++ crates/improvise-core/src/view/mod.rs | 2 +- crates/improvise-protocol/src/basis.rs | 16 +++++ crates/improvise-protocol/src/client.rs | 38 ++++++++++++ crates/improvise-protocol/src/reduce.rs | 13 ++++ crates/improvise-protocol/src/render.rs | 6 +- crates/improvise-protocol/src/resolve.rs | 62 ++++++++++++++++--- crates/improvise-wasm-client/src/lib.rs | 2 +- crates/improvise-wasm-client/src/render.rs | 7 ++- .../tests/browser_round_trip.rs | 2 +- 14 files changed, 212 insertions(+), 25 deletions(-) diff --git a/crates/improvise-command/src/command/cmd/commit.rs b/crates/improvise-command/src/command/cmd/commit.rs index 86e4e55d..177c479d 100644 --- a/crates/improvise-command/src/command/cmd/commit.rs +++ b/crates/improvise-command/src/command/cmd/commit.rs @@ -636,7 +636,13 @@ fn commit_plain_records_edit( return; }; match col { - RecordCol::Category(_) | RecordCol::Measure(_) => { + // The one door the wire's `RenameRecordCoordinate` also takes (improvise-fdk1). + RecordCol::Category(category) => effects.push(Box::new(effect::RenameRecordCoordinate { + record: row.coords.clone(), + category: category.clone(), + item: value.to_string(), + })), + RecordCol::Measure(_) => { effects.extend(effect::RecordRewrite::of(ctx.model, row, [(col, value)]).effects()); } RecordCol::NewMeasure => add_measure(value, effects), diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index 6eefb8f9..95156c1f 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -686,6 +686,29 @@ impl Effect for ApplyAndClearDrill { } } +/// Move the record at `record` to `category = item`: the key path's and the wire's +/// one door, through the same [`RecordRewrite`] a drill uses (improvise-fdk1). +#[derive(Debug)] +pub struct RenameRecordCoordinate { + pub record: CellKey, + pub category: String, + pub item: String, +} +impl Effect for RenameRecordCoordinate { + fn apply(&self, app: &mut App) { + let model = &app.model_state.workbook.model; + let row = crate::view::record_row(model, &self.record); + let col = RecordCol::Category(self.category.clone()); + let rewrite = RecordRewrite::of(model, &row, [(&col, self.item.as_str())]); + for effect in rewrite.effects() { + effect.apply(app); + } + } + fn kind(&self) -> EffectKind { + EffectKind::Model(ModelChange::CellData) + } +} + /// A records row's edits composed into the one record they mean, read off the model /// before anything is written. Why composed: `context/code/command.md`, `RecordRewrite`. #[derive(Debug, Default, PartialEq)] diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index ff23ca77..a64e130d 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -322,8 +322,13 @@ fn cell_id(app: &App, layout: &GridLayout, row: usize, col: usize) -> Option return Some(CellId::Create(CreateKind::Measure)), RecordCol::NewCategory => return Some(CellId::Create(CreateKind::Category)), // Renaming a coordinate moves every measure cell in the row, which is a - // different intent from `SetCell` and still the host's (gotcha 1). - RecordCol::Category(_) => return Some(CellId::Coordinate), + // different intent from `SetCell`: its own operands (gotcha 1, fdk1). + RecordCol::Category(category) => { + return Some(CellId::Coordinate { + record: RecordHash::of(record), + category: category.clone(), + }); + } }; return Some(CellId::Record(RecordCell { record: RecordHash::of(record), @@ -491,11 +496,10 @@ pub fn published_basis(app: &App, grid: &GridCache) -> Basis { .iter() .flatten() .filter_map(|cell| match cell.id.as_ref()? { - CellId::Record(rc) => Some((rc.record, app.record_version_of(rc.record))), - CellId::Aggregate(_) - | CellId::Derived { .. } - | CellId::Create(_) - | CellId::Coordinate => None, + CellId::Record(RecordCell { record, .. }) | CellId::Coordinate { record, .. } => { + Some((*record, app.record_version_of(*record))) + } + CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None, }) .collect(); basis::published(app.document_generation(), app.layout_version(), records) @@ -5158,10 +5162,14 @@ mod cell_identity { let region = column_named(&app, "Region"); let north = grid.cell(1, region).expect("the second row's coordinate"); assert_eq!(north.text, "North", "setup: a visible coordinate cell"); + let record = app.layout.record_row(1).expect("row 1").coords.clone(); assert_eq!( north.id, - Some(CellId::Coordinate), - "a drawn coordinate is named" + Some(CellId::Coordinate { + record: RecordHash::of(&record), + category: "Region".into(), + }), + "a drawn coordinate is named, with its own record and category (fdk1)" ); let nameless: Vec<(usize, usize)> = (0..app.layout.row_count()) .flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c))) diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index e59e54cf..41d2dc0f 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -147,6 +147,20 @@ pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec> { }; Box::new(effect::ClearCell(key)) } + ModelCommand::RenameRecordCoordinate { + record, + category, + item, + } => { + let Some(record) = app.resolve_record(*record) else { + return vec![]; + }; + Box::new(effect::RenameRecordCoordinate { + record, + category: category.clone(), + item: item.clone(), + }) + } ModelCommand::AddItem { category, item } => Box::new(effect::AddItem { category: category.clone(), item: item.clone(), @@ -664,6 +678,11 @@ pub(crate) mod tests { value: CellValue::Number(1.0), }, ModelCommand::ClearCell(record_cell(&a_stored_cell())), + ModelCommand::RenameRecordCoordinate { + record: record_cell(&a_stored_cell()).record, + category: "Region".into(), + item: "West".into(), + }, ModelCommand::AddItem { category: "Region".into(), item: "South".into(), @@ -744,6 +763,7 @@ pub(crate) mod tests { match cmd { ModelCommand::SetCell { .. } => "SetCell", ModelCommand::ClearCell(_) => "ClearCell", + ModelCommand::RenameRecordCoordinate { .. } => "RenameRecordCoordinate", ModelCommand::AddItem { .. } => "AddItem", ModelCommand::AddItemInGroup { .. } => "AddItemInGroup", ModelCommand::RemoveItem { .. } => "RemoveItem", diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index fbb3e312..a018d360 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -104,6 +104,18 @@ pub fn drill_snapshot(model: &Model, constraint: &CellKey) -> Vec { RecordRow::group(cells) } +/// The records row `record` is, as the records grid groups it; empty when no +/// cell stores it. The one lookup a rename by record uses (improvise-fdk1). +pub fn record_row(model: &Model, record: &CellKey) -> RecordRow { + drill_snapshot(model, record) + .into_iter() + .find(|row| &row.coords == record) + .unwrap_or_else(|| RecordRow { + coords: record.clone(), + values: Vec::new(), + }) +} + /// One step of a view configuration: **what** a view must be, with no answer /// to how a front end gets there. The model half of drill — core.md §8. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/improvise-core/src/view/mod.rs b/crates/improvise-core/src/view/mod.rs index e567d3c6..23aadd5e 100644 --- a/crates/improvise-core/src/view/mod.rs +++ b/crates/improvise-core/src/view/mod.rs @@ -5,7 +5,7 @@ pub mod types; pub use axis::{Axis, AxisColor}; pub use layout::{ AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow, ViewStep, - drill_constraint, drill_snapshot, drill_steps, records_spine, records_steps, + drill_constraint, drill_snapshot, drill_steps, record_row, records_spine, records_steps, synthetic_record_info, }; pub use types::{ diff --git a/crates/improvise-protocol/src/basis.rs b/crates/improvise-protocol/src/basis.rs index 58d0ce9e..43108c66 100644 --- a/crates/improvise-protocol/src/basis.rs +++ b/crates/improvise-protocol/src/basis.rs @@ -93,6 +93,22 @@ impl BasisCache { } } +impl BasisCache { + /// A basis for records whose counters were read EARLIER, such as when an + /// editor opened (improvise-0j0f). The document tiers are current. + pub fn claim_as_read( + &self, + read: impl IntoIterator, + ) -> Option { + let current = self.current.as_ref()?; + Some(Basis { + document_generation: current.document_generation, + layout_version: current.layout_version, + records: read.into_iter().collect(), + }) + } +} + impl WidgetCache for BasisCache { type Update = Basis; diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 599f21e4..9578f128 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -6,6 +6,7 @@ use crate::{ Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, reduce_view, settled_mode, }; +use improvise_core::model::record::RecordHash; use serde::Serialize; /// One client's whole state: the portable session slice plus one cache per @@ -43,6 +44,19 @@ pub struct ClientState { /// replay the rest. Why a queue: `context/code/clients.md`. #[serde(skip)] pub in_flight: std::collections::VecDeque, + /// The coordinate an open editor renames, captured as the user saw it. + /// When it is taken, and when not yet: `context/code/clients.md`. + #[serde(skip)] + pub opened: Option, +} + +/// A coordinate editor's target: the record, the category, and the record's +/// write counter when the editor opened (improvise-fdk1, improvise-0j0f). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OpenedCoordinate { + pub record: RecordHash, + pub category: String, + pub version: u64, } /// One frame this client has sent. `awaited` is true exactly when the host @@ -75,6 +89,7 @@ impl ClientState { } self.settle_mode(); self.settle_editor(); + self.settle_opened(); self.revision += 1; } @@ -87,6 +102,7 @@ impl ClientState { } self.settle_mode(); self.settle_editor(); + self.settle_opened(); self.revision += 1; } @@ -113,6 +129,7 @@ impl ClientState { } } self.settle_sent(); + self.settle_opened(); } /// The host applied **none** of the oldest awaited frame (ADR 001 clause @@ -127,6 +144,7 @@ impl ClientState { self.last_rejection = Some(rejection); self.settle_mode(); self.settle_editor(); + self.settle_opened(); self.revision += 1; } @@ -161,6 +179,26 @@ impl ClientState { self.view.mode = settled_mode(&self.view.mode, self.grid.records); } + /// Capture the coordinate an open editor renames, but only from a cache that + /// shows the host's answer to this client's own writes (improvise-fdk1). + fn settle_opened(&mut self) { + let answered = !self.in_flight.iter().any(|sent| sent.awaited); + self.opened = match (self.view.mode.is_editing(), self.opened.take()) { + (false, _) => None, + (true, Some(opened)) => Some(opened), + (true, None) if answered => crate::resolve::coordinate_at_cursor(self) + .and_then(|(record, category)| { + let version = self.basis.current()?.record(record)?; + Some(OpenedCoordinate { + record, + category, + version, + }) + }), + (true, None) => None, + }; + } + /// An editor that arrived without its buffer is seeded here, so what is /// drawn and what Enter commits read one buffer (improvise-ktlk). fn settle_editor(&mut self) { diff --git a/crates/improvise-protocol/src/reduce.rs b/crates/improvise-protocol/src/reduce.rs index 9e816f01..bc97eeac 100644 --- a/crates/improvise-protocol/src/reduce.rs +++ b/crates/improvise-protocol/src/reduce.rs @@ -86,6 +86,13 @@ pub enum ModelCommand { value: CellValue, }, ClearCell(RecordCell), + /// Move a whole record to a new coordinate, creating the item if need be. + /// Position-free: it names the record, never a row (improvise-fdk1). + RenameRecordCoordinate { + record: RecordHash, + category: String, + item: String, + }, AddItem { category: String, item: String, @@ -170,6 +177,7 @@ pub enum ModelCommand { pub fn records_named(cmd: &ModelCommand) -> Vec { match cmd { ModelCommand::SetCell { cell, .. } | ModelCommand::ClearCell(cell) => vec![cell.record], + ModelCommand::RenameRecordCoordinate { record, .. } => vec![*record], ModelCommand::AddItem { .. } | ModelCommand::AddItemInGroup { .. } @@ -606,6 +614,11 @@ mod tests { match cmd { ModelCommand::SetCell { cell, value: v } => record(cell) + value(v), ModelCommand::ClearCell(cell) => record(cell), + ModelCommand::RenameRecordCoordinate { + record: r, + category, + item, + } => surrogate(r) + name(category) + name(item), ModelCommand::AddItem { category, item } | ModelCommand::RemoveItem { category, item } => name(category) + name(item), ModelCommand::AddItemInGroup { diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 6397136e..8d30652f 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -55,9 +55,9 @@ pub enum CellId { /// A records-mode creating column: typing a **name** into it adds that /// measure or category and writes no cell at all (gotcha 1, improvise-poia). Create(CreateKind), - /// A records-mode coordinate. Renaming it moves every measure cell in the - /// row (gotcha 1), which no client composes: `protocol.md` §3 (improvise-exdg). - Coordinate, + /// A records-mode coordinate: the row's record and the category this column + /// names. Renaming it moves the whole record — `protocol.md` §3 (improvise-fdk1). + Coordinate { record: RecordHash, category: String }, } /// What a creating column creates. The name typed into it is the whole diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index d88b4e23..f1293d7b 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -736,15 +736,15 @@ fn cell_at_cursor(state: &ClientState) -> Option<&crate::CellDisplay> { /// declining out loud rather than upstream. See `context/code/clients.md`. fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { // The grid, not the mode: a records GRID is the observed fact and the mode - // follows it (improvise-bd9o, `context/code/clients.md`). - if state.grid.records { + // follows it (improvise-bd9o). A coordinate is the one records cell it commits. + if state.grid.records && coordinate_at_cursor(state).is_none() { return Some(vec![Command::SetStatus(not_yet("editing in records mode"))]); } let cell = cell_at_cursor(state)?; match cell.id.as_ref()? { // `edit_text`, never `text`: the drawn text has been through // `format_f64`. `tests/lockstep.rs` found exactly that. - CellId::Record(_) => Some(vec![ + CellId::Record(_) | CellId::Coordinate { .. } => Some(vec![ Command::SetBuffer { name: "edit".to_string(), value: cell.edit_text.clone().unwrap_or_default(), @@ -761,7 +761,17 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { // Both creating columns are records-only, so the guard above takes // them; declining rather than `None` keeps that a door, not a hole. CellId::Create(_) => Some(vec![Command::SetStatus(not_yet("creating a column"))]), - CellId::Coordinate => Some(vec![Command::SetStatus(not_yet("renaming a coordinate"))]), + } +} + +/// The record and category of the coordinate cell under the cursor, as the +/// projection names them — never borrowed from a sibling cell (ADR 002). +pub(crate) fn coordinate_at_cursor(state: &ClientState) -> Option<(crate::RecordHash, String)> { + match cell_at_cursor(state)?.id.as_ref()? { + CellId::Coordinate { record, category } => Some((*record, category.clone())), + CellId::Record(_) | CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => { + None + } } } @@ -869,7 +879,7 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option Option Option> { + let CellId::Coordinate { .. } = id else { + return None; + }; + let Some(opened) = state.opened.as_ref() else { + return Some(vec![Command::SetStatus( + "still waiting for the host to answer your last edit".to_string(), + )]); + }; + let mut commands = vec![Command::Model { + basis: state + .basis + .claim_as_read([(opened.record, opened.version)])?, + cmd: ModelCommand::RenameRecordCoordinate { + record: opened.record, + category: opened.category.clone(), + item: text.to_string(), + }, + }]; + commands.push(Command::ChangeMode(AppMode::Normal)); + commands.extend(advanced(state, edit_mode, advance)?); + Some(commands) +} + /// `CommitAndAdvance` over a creating column: a schema change over the typed /// NAME. Why +cat does not advance: clients.md (improvise-gcvb). fn create_column( @@ -955,7 +997,10 @@ fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option fn record_at_cursor(state: &ClientState) -> Option { match cell_at_cursor(state)?.id.as_ref()? { CellId::Record(cell) => Some(cell.record), - CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => { + CellId::Aggregate(_) + | CellId::Derived { .. } + | CellId::Create(_) + | CellId::Coordinate { .. } => { None } } @@ -1024,7 +1069,10 @@ fn model(state: &ClientState, cmd: ModelCommand) -> Option { fn record_cell_at_cursor(state: &ClientState) -> Option { match cell_at_cursor(state)?.id.as_ref()? { CellId::Record(cell) => Some(cell.clone()), - CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => { + CellId::Aggregate(_) + | CellId::Derived { .. } + | CellId::Create(_) + | CellId::Coordinate { .. } => { None } } diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs index e0873e6f..bf27d2b7 100644 --- a/crates/improvise-wasm-client/src/lib.rs +++ b/crates/improvise-wasm-client/src/lib.rs @@ -175,7 +175,7 @@ impl Client { CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) - | CellId::Coordinate => None, + | CellId::Coordinate { .. } => None, } } diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 24ca5e93..aae212d2 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -150,7 +150,7 @@ impl RenderCell { CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) - | CellId::Coordinate, + | CellId::Coordinate { .. }, ) | None => false, }, @@ -638,7 +638,10 @@ mod tests { ((1, 0), CellDisplay::text("pending")), ( (1, 2), - CellDisplay::text("North").with_id(CellId::Coordinate), + CellDisplay::text("North").with_id(CellId::Coordinate { + record: surrogate(), + category: "Region".into(), + }), ), ])), ], diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index d00c05d3..be4e7fd4 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -803,7 +803,7 @@ fn rename_north(racing: bool) -> (Host, Vec) { deliver(&mut a, &[a_snap]); deliver(&mut b, &[b_snap]); - let mut press_a = |host: &mut Host, a: &mut Client, b: &mut Client, key: &str| { + let press_a = |host: &mut Host, a: &mut Client, b: &mut Client, key: &str| { let Some(frame) = a.key(&BrowserKey::new(key)) else { return Vec::new(); }; From 5c81d8bc06135031690a163715add85c175197bc Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 23:38:52 -0700 Subject: [PATCH 003/114] WIP: coordinate rename green; tile-route walk admits the coordinate (improvise-fdk1) --- crates/improvise-command/src/ui/project.rs | 4 +++- .../tests/browser_round_trip.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index ea1cf06b..b6c9982b 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -5236,7 +5236,9 @@ mod cell_identity { /// so `Derived` is never right here (improvise-hoxy). fn names_its_column(col: Option<&RecordCol>, id: Option<&CellId>) -> bool { match (col, id) { - (Some(RecordCol::Category(_)), Some(CellId::Coordinate)) => true, + (Some(RecordCol::Category(c)), Some(CellId::Coordinate { category, .. })) => { + category == c + } (Some(RecordCol::Measure(m)), Some(CellId::Record(cell))) => { cell.measure.as_deref() == Some(m.as_str()) } diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 8aaebde0..958fcf72 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -471,6 +471,7 @@ fn the_tile_bar_route_into_records_mode_does_not_strand_the_user() { cols >= 3, "the fixture must reach past the coordinate column" ); + let mut coordinates = 0; for col in 0..cols { while client.state().view.cursor().selected.1 < col { press(&mut host, id, &mut client, "l"); @@ -483,6 +484,17 @@ fn the_tile_bar_route_into_records_mode_does_not_strand_the_user() { // a keypress this client might decline. client.state_mut().view.status_msg = "\u{0}unsaid".to_string(); press(&mut host, id, &mut client, "i"); + // A coordinate's Enter composes a rename since improvise-fdk1, so its + // editor is a door rather than a black hole. + if kind.starts_with("Some(Coordinate") { + coordinates += 1; + assert!( + client.state().view.mode.is_editing(), + "column {col} ({kind}): a coordinate's editor must open" + ); + press(&mut host, id, &mut client, "Escape"); + continue; + } assert_ne!( client.state().view.status_msg, "\u{0}unsaid", @@ -495,6 +507,10 @@ fn the_tile_bar_route_into_records_mode_does_not_strand_the_user() { client cannot commit — an Enter that is a black hole" ); } + assert!( + coordinates > 0 && coordinates < cols, + "the walk must cover both a coordinate and a declined column" + ); } // ── Ctrl-S, all the way to storage (improvise-jz6w) ───────────────────── From de439944ccaa531e13c23597e627d9054928a7ac Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 23:42:01 -0700 Subject: [PATCH 004/114] WIP: empty rename mirrors the host; the renamed-record chain on a records host (improvise-fdk1) --- crates/improvise-command/src/ui/effect.rs | 2 +- crates/improvise-command/src/ui/host.rs | 76 +++++++++++++++++++ crates/improvise-protocol/src/lib.rs | 4 +- crates/improvise-protocol/src/reduce.rs | 4 + crates/improvise-protocol/src/resolve.rs | 27 ++++--- .../improvise-wasm-client/tests/lockstep.rs | 34 +++++++++ 6 files changed, 134 insertions(+), 13 deletions(-) diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index 1db10aec..f0373502 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -11,7 +11,7 @@ use super::app::{App, AppMode, ViewFrame}; /// inverses) and re-exported here, where most callers already look for it. pub use super::app::Panel; -pub(crate) const RECORD_COORDS_CANNOT_BE_EMPTY: &str = "Record coordinates cannot be empty"; +pub(crate) use improvise_protocol::RECORD_COORDS_CANNOT_BE_EMPTY; /// What an effect changes, so a server can decide whether connected clients /// have to be told about it (improvise-mae). diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index f31b1d08..97bb8e6d 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -1380,6 +1380,82 @@ mod tests { ); } + /// improvise-fdk1: a surrogate names a record as it WAS, and a rename ends that + /// name. See `context/test-evidence/command.md`. + #[test] + fn a_write_to_a_record_its_own_frame_renamed_is_a_broken_chain() { + use improvise_protocol::ModelCommand; + + let mut host = host(); + host.app + .model_state + .workbook + .model + .set_cell(cell("North"), CellValue::Number(1.0)); + host.app.rebuild_layout(); + // A records host: its layout names positions, so an old surrogate cannot + // resolve through it. A pivot host differs — see the evidence. + crate::ui::reduce::reduce_key( + &mut host.app, + crate::command::key::KeyEvent::new( + crate::command::key::KeyCode::Char('R'), + crate::command::key::KeyModifiers::NONE, + ), + ); + assert!(host.app.layout.is_records_mode(), "setup: a records host"); + let (asker, _) = host.connect(10, 10); + let north = addressed("North").record; + let rename = || { + ModelCommand::RenameRecordCoordinate { + record: north, + category: "Region".into(), + item: "West".into(), + } + }; + let stored = |host: &Host, region: &str| { + host.app() + .model_state + .workbook + .model + .get_cell(&cell(region)) + .cloned() + }; + + let pair = vec![ + crate::ui::reduce::caught_up(host.app(), rename()), + crate::ui::reduce::caught_up( + host.app(), + ModelCommand::SetCell { + cell: addressed("North"), + value: CellValue::Number(7.0), + }, + ), + ]; + let answer = host.handle(asker, ClientFrame::Commands(pair)); + assert!( + answer.iter().any(|a| matches!( + a.msg, + ServerFrame::Rejected(Rejection::ChainBroken(h)) if h == north + )), + "the write names a record the rename moved away: {answer:?}" + ); + assert_eq!(stored(&host, "North"), Some(CellValue::Number(1.0))); + assert_eq!(stored(&host, "West"), None, "clause 4 undid the rename too"); + + // The control: the rename alone is accepted and moves the record, or + // the refusal above is "renames never work" and proves nothing. + let alone = crate::ui::reduce::caught_up(host.app(), rename()); + let answer = host.handle(asker, ClientFrame::Commands(vec![alone])); + assert!( + answer + .iter() + .any(|a| matches!(a.msg, ServerFrame::Accepted)), + "{answer:?}" + ); + assert_eq!(stored(&host, "West"), Some(CellValue::Number(1.0))); + assert_eq!(stored(&host, "North"), None); + } + // ── the broadcast ─────────────────────────────────────────────────── /// Which view a client is on is that client's (improvise-jytm): an accepted diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index 1e178f92..59758090 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -27,7 +27,9 @@ pub use frame::{ClientFrame, ServerFrame}; pub use help::{HelpLine, Hint, hint_line, hints, mode_label}; pub use key::{KeyCode, KeyEvent, KeyModifiers}; pub use keymap::{Binding, KeyPattern, Keymap, Keymaps, ModeKey}; -pub use reduce::{Command, HelpPage, ModelCommand, records_named, reduce_view}; +pub use reduce::{ + Command, HelpPage, ModelCommand, RECORD_COORDS_CANNOT_BE_EMPTY, records_named, reduce_view, +}; pub use render::{ AxisFrame, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, CellId, CreateKind, DocumentCache, FormulaCache, FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate, diff --git a/crates/improvise-protocol/src/reduce.rs b/crates/improvise-protocol/src/reduce.rs index bc97eeac..e74c6cb1 100644 --- a/crates/improvise-protocol/src/reduce.rs +++ b/crates/improvise-protocol/src/reduce.rs @@ -172,6 +172,10 @@ pub enum ModelCommand { }, } +/// Why a rename to an empty coordinate is refused. One home, read by the host's +/// refusal and by a client composing the same answer (improvise-fdk1). +pub const RECORD_COORDS_CANNOT_BE_EMPTY: &str = "Record coordinates cannot be empty"; + /// Which records a [`ModelCommand`] writes. The **one** home: the server /// checks against it and a client claims by it — `context/code/protocol.md` §2. pub fn records_named(cmd: &ModelCommand) -> Vec { diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index f1293d7b..3614e3d3 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -9,7 +9,7 @@ use crate::keymap::{Binding, Keymaps, ModeKey}; use crate::viewmodel::{CatTreeEntry, axis_change_status, flatten_cat_tree}; use crate::{ AppMode, CellId, CellValue, Command, CreateKind, GridExtent, HelpPage, KeyCode, KeyEvent, - ModelCommand, Panel, RecordCell, ViewState, records_named, + ModelCommand, Panel, RECORD_COORDS_CANNOT_BE_EMPTY, RecordCell, ViewState, records_named, }; /// The virtual category a new measure is filed under, spelled as @@ -907,16 +907,21 @@ fn rename_coordinate( "still waiting for the host to answer your last edit".to_string(), )]); }; - let mut commands = vec![Command::Model { - basis: state - .basis - .claim_as_read([(opened.record, opened.version)])?, - cmd: ModelCommand::RenameRecordCoordinate { - record: opened.record, - category: opened.category.clone(), - item: text.to_string(), - }, - }]; + // The host's key path refuses an empty coordinate, says why and advances, + // writing nothing; composing the write would undo the frame instead. + let mut commands = match text.is_empty() { + true => vec![Command::SetStatus(RECORD_COORDS_CANNOT_BE_EMPTY.to_string())], + false => vec![Command::Model { + basis: state + .basis + .claim_as_read([(opened.record, opened.version)])?, + cmd: ModelCommand::RenameRecordCoordinate { + record: opened.record, + category: opened.category.clone(), + item: text.to_string(), + }, + }], + }; commands.push(Command::ChangeMode(AppMode::Normal)); commands.extend(advanced(state, edit_mode, advance)?); Some(commands) diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index fd12fa2a..a08dbc25 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2014,3 +2014,37 @@ fn a_locally_committed_coordinate_rename_agrees_with_the_host() { "South is a bystander and must survive: {text}" ); } + +/// An empty coordinate is refused on the host's key path, which says why and +/// advances; the browser must say the same. Evidence: `context/test-evidence/clients.md`. +#[test] +fn an_empty_coordinate_rename_is_refused_the_way_the_host_refuses_it() { + let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE); + let records = || { + let mut app = furnished_host(); + reduce_key(&mut app, r); + app + }; + let (mut app, mut twin) = (records(), records()); + let region = (0..app.layout.col_count()) + .find(|&c| app.layout.col_label(c) == "Region") + .expect("a Region column"); + for host in [&mut app, &mut twin] { + host.cursor_mut().selected = (0, region); + } + let mut client = records_client(&app); + let before = document_text(&app); + + both_ends(&mut app, &mut twin, &mut client, "i"); + for key in ["Backspace"; 5] { + both_ends(&mut app, &mut twin, &mut client, key); + } + both_ends(&mut app, &mut twin, &mut client, "Enter"); + + assert_eq!(document_text(&app), before, "an empty rename writes nothing"); + assert!( + app.view_state.status_msg.contains("cannot be empty"), + "the host must have refused, or this proves nothing: {:?}", + app.view_state.status_msg + ); +} From bf0525524fca429eb66bb5c6fcdbdddd856a1322 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 23:44:16 -0700 Subject: [PATCH 005/114] WIP: reorder race and coordinate-only basis tests (improvise-fdk1) --- crates/improvise-command/src/ui/project.rs | 32 +++++++ .../tests/browser_round_trip.rs | 92 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index b6c9982b..144bb5a1 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -5202,6 +5202,38 @@ mod cell_identity { ); } + /// improvise-fdk1: a record named only by its coordinate cell is still + /// published, or its rename is RecordUnstated. `context/test-evidence/command.md`. + #[test] + fn a_record_named_only_by_its_coordinate_is_in_the_published_basis() { + let mut wb = Workbook::new("Anchors"); + wb.add_category("Region").unwrap(); + wb.add_item("Region", "North").unwrap(); + let north = CellKey::new(vec![("Region".to_string(), "North".to_string())]); + wb.model.set_cell(north.clone(), CellValue::Number(1.0)); + let mut app = App::new(wb, None); + app.handle_key(crate::command::key::KeyEvent::new( + crate::command::key::KeyCode::Char('R'), + crate::command::key::KeyModifiers::NONE, + )) + .expect("R enters records mode"); + let grid = grid_of(&app, WHOLE); + let ids: Vec = grid.cells.iter().flatten().filter_map(|c| c.id.clone()).collect(); + assert!( + !ids.iter().any(|id| matches!(id, CellId::Record(_))), + "setup: no measure column, so only the coordinate names the record: {ids:?}" + ); + assert!( + ids.iter().any(|id| matches!(id, CellId::Coordinate { .. })), + "setup: the coordinate is projected: {ids:?}" + ); + let basis = published_basis(&app, &grid); + assert!( + basis.record(RecordHash::of(&north)).is_some(), + "the coordinate's record must be published: {basis:?}" + ); + } + /// improvise-exdg: a coordinate cell is drawn and holds text, so it is not /// "no cell". `context/test-evidence/command.md`. #[test] diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 958fcf72..05b9a720 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1096,3 +1096,95 @@ fn a_coordinate_rename_another_client_moved_is_refused() { ); assert_eq!(region(&host, "Zed"), None, "nothing was renamed"); } + +/// Three records, so a rename that re-sorts the rows moves another record under +/// the cursor. +fn three_records_host() -> Host { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + for (item, value) in [("North", 1.0), ("South", 2.0), ("West", 3.0)] { + wb.add_item("Region", item).unwrap(); + wb.model.set_cell( + CellKey::new(vec![ + ("Region".into(), item.into()), + ("_Measure".into(), "Revenue".into()), + ]), + CellValue::Number(value), + ); + } + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + let mut app = App::new(wb, None); + app.rebuild_layout(); + Host::new(app) +} + +/// improvise-fdk1: the editor's target is captured once the host has answered +/// this client's own rename, not from the cache it rolled forward. Evidence file. +#[test] +fn a_second_rename_after_a_reorder_renames_the_row_the_host_shows() { + let mut host = three_records_host(); + let mut a = Client::new(); + a.set_viewport(10, 10); + let (id, snapshot) = host.connect(10, 10); + deliver(&mut a, &[snapshot]); + press(&mut host, id, &mut a, "T"); + tile_to(&mut host, id, &mut a, "_Index", "r"); + tile_to(&mut host, id, &mut a, "_Dim", "c"); + press(&mut host, id, &mut a, "Escape"); + assert!(a.state().grid.records, "setup: records mode"); + let coordinate = (0..a.state().grid.extent.cols) + .find(|&c| { + format!("{:?}", a.state().grid.cell(0, c).and_then(|x| x.id.clone())) + .starts_with("Some(Coordinate") + }) + .expect("a coordinate column"); + while a.state().view.cursor().selected.1 < coordinate { + press(&mut host, id, &mut a, "l"); + } + let text_at = |a: &Client, row: usize| { + a.state() + .grid + .cell(row, coordinate) + .map(|c| c.text.clone()) + .unwrap_or_default() + }; + assert_eq!(text_at(&a, 1), "South", "setup: South is row 1"); + assert_eq!(text_at(&a, 2), "West", "setup: West is row 2"); + press(&mut host, id, &mut a, "j"); + + // South -> Zed re-sorts the rows; the commit re-enters editing one row + // down, on West as this client's cache stood before the host answered. + press(&mut host, id, &mut a, "i"); + for key in ["Backspace"; 5] { + press(&mut host, id, &mut a, key); + } + for key in ["Z", "e", "d", "Enter"] { + press(&mut host, id, &mut a, key); + } + assert!(a.state().view.mode.is_editing(), "the commit re-enters editing"); + let row = a.state().view.cursor().selected.0; + let shown = text_at(&a, row); + assert_ne!( + shown, "West", + "the answered grid must put another record under the cursor than the \ + stale re-entry did, or nothing separates the two captures" + ); + + for key in ["A", "l", "p", "h", "a", "Enter"] { + press(&mut host, id, &mut a, key); + } + let values = [("North", 1.0), ("West", 3.0)]; + let (_, moved) = values + .iter() + .find(|(name, _)| *name == shown) + .unwrap_or_else(|| panic!("the cursor shows {shown:?}")); + assert_eq!( + region(&host, "Alpha"), + Some(CellValue::Number(*moved)), + "the second rename must move {shown}, the record the client showed" + ); + assert_eq!(region(&host, &shown), None); + assert_eq!(region(&host, "Zed"), Some(CellValue::Number(2.0))); +} From 9bea7c57b8611d10c1992577b8c203d044b028ce Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 07:41:14 -0700 Subject: [PATCH 006/114] Docs and evidence: the coordinate rename (improvise-fdk1) --- context/code/clients.md | 57 ++++++++++++++++++++- context/code/command.md | 26 ++++++++++ context/code/protocol.md | 38 +++++++++----- context/test-evidence/clients.md | 88 ++++++++++++++++++++++++++++++++ context/test-evidence/command.md | 32 ++++++++++++ 5 files changed, 227 insertions(+), 14 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 4af02584..0c60d261 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -698,7 +698,9 @@ fixture standing on one of them cannot see the other: even open an editor. Nothing happened and nothing was said. The ticket did not predict this one, and it is not a second bug: it is improvise-fdk1 seen from the key path, since a coordinate rename has no wire form precisely because the - `RecordHash` surrogate names records that already exist. + `RecordHash` surrogate names records that already exist. **fdk1 has since + closed it**: the coordinate is now the one records cell the browser edits. See + "A coordinate rename" below. So the guard goes at the **entrance** — the first statement of `edit_or_drill`, before the cell is even looked at — and not on the `CellId::Record` arm where @@ -2732,3 +2734,56 @@ files and refuses one that names the bundle directory, and its companion asserts `web/tests/bundle/` still holds a test that *does* — a scoping rule is uninteresting if nothing falls outside it. Evidence in `context/test-evidence/tooling.md`. + +## A coordinate rename (improvise-fdk1) + +The coordinate is the one records-mode cell the browser edits. `edit_or_drill` +still declines a records grid, except on a `CellId::Coordinate`. There it opens +the editor on the coordinate's `edit_text`, just as the host's `EditOrDrill` +does. `commit` sends a records grid to `rename_coordinate`, which composes +`ModelCommand::RenameRecordCoordinate` plus the mode change and the advance +that `CommitAndAdvance` makes. Every other records cell is still declined out +loud, and `the_tile_bar_route_into_records_mode_does_not_strand_the_user` walks +the whole row to check that both answers hold. + +**The target is captured when the editor opens, not when it commits.** The +record, the category and the record's write counter are captured into +`ClientState::opened` (session-local, never serialized). The commit renames that +record, and its basis claims that counter through `BasisCache::claim_as_read`, +the same door the inline editor's `EditTarget` uses (improvise-0j0f). So if +another client writes the record while the editor is open, the rename is refused +with `RecordMoved`, and the other client's value survives. + +**The capture waits for the host's answer, and that wait is the part easy to +get wrong.** A commit re-enters editing one row down in the same batch, and the +client folds that batch optimistically. At that moment its grid cache still +shows the rows as they were **before** its own rename. Records rows are sorted +by coordinate, so a rename moves its row, and the row now under the cursor +belongs to a different record on the host. So `settle_opened` captures only when +no awaited frame is in flight. It runs after every fold and after `accepted()`. +The host sends `Accepted` last, after the projections that describe the answer, +so the capture reads the refreshed cache. A commit made before any capture +exists (typing faster than the host answers) is declined out loud with "still +waiting". + +**After a rename, the browser's cursor goes to the origin, not one row down.** +The rename reframes the grid, so the host broadcasts `ResetCursor` to every +client, the asker included (gotcha 24). The TUI's key path stays one row down, +because `apply_effects` observes the reframe and `CommitAndAdvance` restores the +cursor afterwards. `tests/lockstep.rs` cannot see the difference: its twin host +has no `Sessions`, so nothing broadcasts. The difference predates this ticket +and applies to any browser write that reframes the grid. + +**An empty coordinate writes nothing, on both ends.** The host's key path +refuses it (`RecordRewrite` sets `refused`, and `App::refuse` does not abort), +says `RECORD_COORDS_CANNOT_BE_EMPTY`, and still advances. Composing the rename +would instead have the host undo the whole frame (`MutationRefused`) and show +the generic sentence. So the client composes the host's answer itself: the same +status, taken from the protocol's one copy of the message, plus the advance and +no write. + +**A retry after a refusal is a force.** A refusal replays the view slice, so the +editor reopens with the typed text. `settle_opened` then captures the record at +its **current** version, so a second `Enter` succeeds. That is the one-keystroke +force `RecordMoved` promises (gotcha 43). The user has just been told that +someone else changed the row. diff --git a/context/code/command.md b/context/code/command.md index a1eef6cd..04bb33b9 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -986,6 +986,32 @@ single edit the output is the effects the plain path used to emit, reordered fro One behaviour moved on the drill side: it used to discard `add_item`'s error, and now goes through `effect::AddItem`, which refuses when the category has gone. +**The wire half: `effect::RenameRecordCoordinate` (improvise-fdk1).** One effect +serves both doors. `model_effects` maps `ModelCommand::RenameRecordCoordinate` to +it, after resolving the surrogate. The plain records key path emits it for a +category column. It reads the record's row off the store at apply time +(`view::record_row`, which groups the same way the records layout does) and +applies `RecordRewrite::of` with the one edit. The key path used to call +`RecordRewrite::of` itself, with the layout's row. In plain records mode that +row is the store's, so behaviour is unchanged. What changes is that the key path +and the wire now take one door, so a host and a browser renaming the same record +cannot drift. The name matches the variant, as +`every_model_command_names_the_effect_of_the_same_name` requires. The collision +rule is kept (improvise-kndq): a rename onto an existing record still +overwrites it, on both paths. + +**What `[RenameRecordCoordinate(r), SetCell(r)]` means depends on the host's +view, and that is filed, not fixed (improvise-8id2).** A surrogate names +coordinates, not an object. After the rename, `r` names the vacated coordinates. +On a **records** host the layout names positions, so `resolve_record` has no +fallback, `broken_chain` answers `ChainBroken(r)`, and clause 4 undoes the +rename too. That case is pinned by +`a_write_to_a_record_its_own_frame_renamed_is_a_broken_chain`. On a **pivot** +host the empty cell is still drawn, so `record_in_layout` resolves `r` and the +write re-creates a record at the old coordinates: measured, not pinned. The +shipped browser composes one rename per frame, so only a hand-built client sends +this pair. It is the same fallback as improvise-ciw8. + ### `Save` / `SaveAs` abort the batch (improvise-8tk / improvise-lae) A failed save aborts the rest of the batch, so the `Quit` that `wq`/`ZZ` chains diff --git a/context/code/protocol.md b/context/code/protocol.md index 032feab5..be0c1fb0 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -624,19 +624,31 @@ belongs to the host. So absence was encoding a permission, the same conflation `a_coordinate_column_is_projected_with_no_identity`, asserted the bug and is gone. -`Coordinate` carries **nothing**. Editing a coordinate renames it across every -measure cell in the row (gotcha 1), and no client composes that operation: it -needs `record_col`, and the browser declines every records-mode edit before -reaching a cell. Giving the variant a `RecordHash` would hand a client a -surrogate with nothing to spend it on. The operand belongs with the command -that consumes it, whenever that command exists. Nothing a client can do has -changed. `render.rs::editable` is now an exhaustive `match` rather than a -`matches!`, so the next variant has to be classified. `typed_cell_command`, -`record_at_cursor` and `record_cell_at_cursor` still answer `None` for it. -So paste onto a coordinate is still a silent `Upstream` in the browser. The -TUI has declined it out loud since PR #184. That divergence is -improvise-kkcy, and a named kind is what lets the browser decline deliberately -now. +**Since improvise-fdk1 it carries `{ record, category }`**, the operands of +`ModelCommand::RenameRecordCoordinate`. Editing a coordinate renames it across +every measure cell in the row (gotcha 1). When exdg landed, no client composed +that operation, so the variant was left empty on the grounds that the operand +belongs with the command that consumes it. That command now exists, and both +operands come from the **projection**: + +- **`record` is the row's own record.** A client could borrow the hash from a + sibling `Record` cell in the same row. That would be a second derivation of + which cells share a record, which is what ADR 002 forbids. It would also have + nothing to borrow in a document with no measures, where the coordinate is + the only cell a row has. For the same reason `published_basis` counts a + `Coordinate`'s record, or a rename of such a row would be `RecordUnstated`. +- **`category` is the column's identity.** Its label is not: a category and a + measure may share a name (gotcha 1), so reading the category off the column + label would be a guess. + +`RenameRecordCoordinate` is position-free (improvise-hkbc): it names the record +by surrogate and the new coordinate by value. `records_named` names the record, +because a rename moves it (gotcha 43). Creating the item is internal to the +effect. `render.rs::editable` still answers `false` for a coordinate: the page's +inline editor writes stored cells only, and the rename's producer is the +keyboard path (`context/code/clients.md`). `typed_cell_command`, +`record_at_cursor` and `record_cell_at_cursor` still answer `None` for it, so +paste onto a coordinate is still improvise-kkcy. ### `CellDisplay::id` — `None` is not "aggregate" diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 15ff6714..c1fd88d1 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -4749,3 +4749,91 @@ claims at commit. The commit was then *accepted*. With the refusal assertion disabled, the host held `Some(Number(42.0))`, so A's commit had re-created the cell B cleared. Under that rival, the 0j0f test above failed as well. Both files were restored. + +## A coordinate rename from the browser (improvise-fdk1) + +The reasoning is in `context/code/clients.md`, "A coordinate rename". Every rival +below was applied by exact substitution, run, and reverted. Each is named with +the tests it failed. + +### a_locally_committed_coordinate_rename_agrees_with_the_host + +Two records, North and South. North is renamed to Zed with real keys: `i`, five +`Backspace`, `Z e d`, `Enter`. The host takes its `Cmd` path, a twin host +applies what the client composed, and `both_ends` compares the document, mode, +status and every cursor after each key. + +- **Red at `4e5299f6`:** it failed at `i`, "client and host disagree about the + mode". The client declined every records-mode edit, while the host opened an + editor. +- **Rival: the client composes the rename without the advance** (`no_advance`). + It fails here on the cursor. It also fails + `an_empty_coordinate_rename_is_refused_the_way_the_host_refuses_it` and + `a_second_rename_after_a_reorder_renames_the_row_the_host_shows`. +- **Rival: the effect copies instead of moving** (`copy_not_move`). It fails + here, but only through the final "Region=North is gone" assertion. The + document comparison itself passes, and that is by construction, not a gap: + the host's key path and the twin's wire path now apply the same + `effect::RenameRecordCoordinate`, so a fault in the effect moves both ends + together. Only the outcome assertions can see it. + +### an_empty_coordinate_rename_is_refused_the_way_the_host_refuses_it + +The host's key path refuses an empty coordinate with +`RECORD_COORDS_CANNOT_BE_EMPTY` and still advances. Red at the first fix: the +client composed the write, the twin refused it, and the status lines differed +("Records mode" against the refusal). The client now composes the host's answer +itself, with the status taken from the protocol's one copy of the message. The +test also asserts that the host really refused, so it cannot pass on a fixture +where nothing was empty. + +### a_browser_renames_a_records_coordinate_and_the_host_moves_the_record + +A real `Host`, and a client driven into records mode through the tile bar. It +renames North to Zed with keys. The rename must be accepted, Zed must hold +North's 1200, and North must be gone. This is the control for the refusal test +below, which reuses the same scenario helper (`rename_north`). + +- **Red at `4e5299f6`:** A's editor did not open. +- **Rival `copy_not_move`:** "the record moved, not copied". +- **Rival `wrong_surrogate`** (the projection names the record by a key that is + not the record's): fails here too, because the rename names nothing that + resolves. + +### a_coordinate_rename_another_client_moved_is_refused + +The same scenario, except that client B writes 999 to North's Revenue while A's +editor is open. A's rename must be `Rejected(RecordMoved)`, B's 999 must +survive, and no Zed may exist. The test also asserts that B's write was +accepted, or A would have nothing to be stale against. + +- **Rival `claim_at_commit`** (claim the version at commit, improvise-0j0f's + shape): fails **only** this test. The rename is accepted over B's write. +- **Rival `unnamed`** (`records_named` names no record for the rename): fails + here too, because the basis check never looks at the record. It also fails + `ui::host::tests::a_write_to_a_record_its_own_frame_renamed_is_a_broken_chain`. + +### a_second_rename_after_a_reorder_renames_the_row_the_host_shows + +Three records: North, South and West. The client renames South (row 1) to Zed, +which re-sorts the rows. `Enter` re-enters editing one row down, on West as the +client's cache stood **before** the host answered. The host's answer then +arrives, including a `ResetCursor` (see clients.md), and the test reads which +record the cursor shows **now**. It asserts that record is not West, so that +the two captures differ. The second rename, `Alpha`, must move the record the +client shows. + +- **Rival `capture_unanswered`** (capture the editor's target while the + client's own write is still in flight): fails **only** this test. The rename + moves West instead. +- The first version of this test renamed North, and there the stale capture and + the correct one landed on the same record, so it could not tell them apart. + It also asserted "one row down", which is false in the browser (the + `ResetCursor`). Both were caught by running it, before any rival. + +### the_tile_bar_route_into_records_mode_does_not_strand_the_user + +Changed by improvise-fdk1: on a coordinate column, `i` now opens an editor, +because its `Enter` composes a rename. Every other records column must still +decline out loud. The walk now also asserts that it covered at least one column +of each kind, so it cannot pass on a row made entirely of coordinates. diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index aad936d5..4ba8c071 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -5518,3 +5518,35 @@ one test covers the crate's feature. cannot see the feature removed from `improvise-command` alone, because `improvise-io`, a normal dependency, enables it in the same build. That case is held by `every_crate_that_can_parse_json_enables_float_roundtrip` (`tooling.md`). + +## A coordinate rename on the wire (improvise-fdk1) + +### a_write_to_a_record_its_own_frame_renamed_is_a_broken_chain + +The frame is `[RenameRecordCoordinate(North -> West), SetCell(North's surrogate)]`, +on a **records** host. The frame must be `Rejected(ChainBroken(North))`: after +the rename, North's surrogate resolves to nothing, and clause 4 undoes the +rename too, so North keeps 1 and no West exists. The control sends the rename +alone: it is accepted and moves the record, so the refusal cannot pass by +"renames never work". + +- **First run, on the default pivot host:** the pair was **accepted**. West got + 1, and the `SetCell` re-created North at 7. After the rename, North's empty + cell is still drawn in a pivot, so `record_in_layout` resolved the old + surrogate. What the pair means therefore depends on the host's view. That is + filed as improvise-8id2 rather than pinned, and this test is pinned to the + records host, where the rename's producer lives. +- **Rival `unnamed`** (`records_named` names no record for the rename): fails + here, because the frame check no longer asks about North. + +### ui::project::cell_identity::a_record_named_only_by_its_coordinate_is_in_the_published_basis + +A document with no measures, whose only record is anchored by a cell with no +`_Measure`. In records mode the row projects only a coordinate cell, and the +test asserts that is so, or it proves nothing. The published basis must still +carry the record's version. Otherwise a client renaming that row states no +version for it and is refused `RecordUnstated`. + +- **Rival `unpublished`** (`published_basis` counts only `Record` cells): fails + **only** this test. Every other fixture has measure cells, and those publish + the same record anyway. That is why this test exists. From 31e0190b61c6ca25ff02d903067c3116f17f740b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 07:42:20 -0700 Subject: [PATCH 007/114] Evidence: one heading per test (improvise-fdk1) --- context/test-evidence/clients.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index c1fd88d1..23aa912b 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -4831,9 +4831,9 @@ client shows. It also asserted "one row down", which is false in the browser (the `ResetCursor`). Both were caught by running it, before any rival. -### the_tile_bar_route_into_records_mode_does_not_strand_the_user - -Changed by improvise-fdk1: on a coordinate column, `i` now opens an editor, +**Amending the entry for `the_tile_bar_route_into_records_mode_does_not_strand_the_user` +above.** Column 0 of its table is out of date: since improvise-fdk1, on a +coordinate column `i` opens an editor, because its `Enter` composes a rename. Every other records column must still decline out loud. The walk now also asserts that it covered at least one column of each kind, so it cannot pass on a row made entirely of coordinates. From 6319a4be8937bfb2f5f28f80eef2a66dc6acf093 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 07:43:27 -0700 Subject: [PATCH 008/114] rustfmt (improvise-fdk1) --- crates/improvise-command/src/ui/host.rs | 10 ++++------ crates/improvise-command/src/ui/project.rs | 7 ++++++- crates/improvise-protocol/src/client.rs | 7 ++++--- crates/improvise-protocol/src/render.rs | 5 ++++- crates/improvise-protocol/src/resolve.rs | 12 +++++------- .../tests/browser_round_trip.rs | 13 ++++++++++--- crates/improvise-wasm-client/tests/lockstep.rs | 6 +++++- 7 files changed, 38 insertions(+), 22 deletions(-) diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 97bb8e6d..b8b3bfa0 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -1405,12 +1405,10 @@ mod tests { assert!(host.app.layout.is_records_mode(), "setup: a records host"); let (asker, _) = host.connect(10, 10); let north = addressed("North").record; - let rename = || { - ModelCommand::RenameRecordCoordinate { - record: north, - category: "Region".into(), - item: "West".into(), - } + let rename = || ModelCommand::RenameRecordCoordinate { + record: north, + category: "Region".into(), + item: "West".into(), }; let stored = |host: &Host, region: &str| { host.app() diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 144bb5a1..6cb8e141 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -5218,7 +5218,12 @@ mod cell_identity { )) .expect("R enters records mode"); let grid = grid_of(&app, WHOLE); - let ids: Vec = grid.cells.iter().flatten().filter_map(|c| c.id.clone()).collect(); + let ids: Vec = grid + .cells + .iter() + .flatten() + .filter_map(|c| c.id.clone()) + .collect(); assert!( !ids.iter().any(|id| matches!(id, CellId::Record(_))), "setup: no measure column, so only the coordinate names the record: {ids:?}" diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 9578f128..6d02dcc8 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -186,15 +186,16 @@ impl ClientState { self.opened = match (self.view.mode.is_editing(), self.opened.take()) { (false, _) => None, (true, Some(opened)) => Some(opened), - (true, None) if answered => crate::resolve::coordinate_at_cursor(self) - .and_then(|(record, category)| { + (true, None) if answered => { + crate::resolve::coordinate_at_cursor(self).and_then(|(record, category)| { let version = self.basis.current()?.record(record)?; Some(OpenedCoordinate { record, category, version, }) - }), + }) + } (true, None) => None, }; } diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 8d30652f..500a8258 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -57,7 +57,10 @@ pub enum CellId { Create(CreateKind), /// A records-mode coordinate: the row's record and the category this column /// names. Renaming it moves the whole record — `protocol.md` §3 (improvise-fdk1). - Coordinate { record: RecordHash, category: String }, + Coordinate { + record: RecordHash, + category: String, + }, } /// What a creating column creates. The name typed into it is the whole diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 3614e3d3..4b3a59dd 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -910,7 +910,9 @@ fn rename_coordinate( // The host's key path refuses an empty coordinate, says why and advances, // writing nothing; composing the write would undo the frame instead. let mut commands = match text.is_empty() { - true => vec![Command::SetStatus(RECORD_COORDS_CANNOT_BE_EMPTY.to_string())], + true => vec![Command::SetStatus( + RECORD_COORDS_CANNOT_BE_EMPTY.to_string(), + )], false => vec![Command::Model { basis: state .basis @@ -1005,9 +1007,7 @@ fn record_at_cursor(state: &ClientState) -> Option { CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) - | CellId::Coordinate { .. } => { - None - } + | CellId::Coordinate { .. } => None, } } @@ -1077,9 +1077,7 @@ fn record_cell_at_cursor(state: &ClientState) -> Option { CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) - | CellId::Coordinate { .. } => { - None - } + | CellId::Coordinate { .. } => None, } } diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 05b9a720..8bb693d6 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1026,7 +1026,9 @@ fn rename_north(racing: bool) -> (Host, Vec) { .is_some_and(|cell| cell.measure.as_deref() == Some("Revenue")) }) .expect("B sees North's Revenue"); - let frame = b.typed_cell_frame(0, revenue, "999").expect("B composes a write"); + let frame = b + .typed_cell_frame(0, revenue, "999") + .expect("B composes a write"); let frames = send( &mut host, b_id, @@ -1085,7 +1087,9 @@ fn a_coordinate_rename_another_client_moved_is_refused() { assert!( answers.iter().any(|f| matches!( f.msg, - improvise_protocol::ServerFrame::Rejected(improvise_protocol::Rejection::RecordMoved(_)) + improvise_protocol::ServerFrame::Rejected(improvise_protocol::Rejection::RecordMoved( + _ + )) )), "a rename composed against a record someone else wrote must be refused: {answers:?}" ); @@ -1163,7 +1167,10 @@ fn a_second_rename_after_a_reorder_renames_the_row_the_host_shows() { for key in ["Z", "e", "d", "Enter"] { press(&mut host, id, &mut a, key); } - assert!(a.state().view.mode.is_editing(), "the commit re-enters editing"); + assert!( + a.state().view.mode.is_editing(), + "the commit re-enters editing" + ); let row = a.state().view.cursor().selected.0; let shown = text_at(&a, row); assert_ne!( diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index a08dbc25..ca03a333 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2041,7 +2041,11 @@ fn an_empty_coordinate_rename_is_refused_the_way_the_host_refuses_it() { } both_ends(&mut app, &mut twin, &mut client, "Enter"); - assert_eq!(document_text(&app), before, "an empty rename writes nothing"); + assert_eq!( + document_text(&app), + before, + "an empty rename writes nothing" + ); assert!( app.view_state.status_msg.contains("cannot be empty"), "the host must have refused, or this proves nothing: {:?}", From 3a56daa509d2e08e742cdd8170bae2c9afd60dcc Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 07:47:29 -0700 Subject: [PATCH 009/114] WIP improvise-tej1: compose delete-formula-at-cursor in the browser Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../src/command/cmd/effect_cmds.rs | 16 +++-- .../src/command/cmd/panel.rs | 8 +-- crates/improvise-command/src/ui/effect.rs | 21 +++---- crates/improvise-command/src/ui/project.rs | 3 +- crates/improvise-command/src/ui/reduce.rs | 11 +--- crates/improvise-core/src/model/types.rs | 22 +++++++ crates/improvise-protocol/src/reduce.rs | 9 +-- crates/improvise-protocol/src/resolve.rs | 26 +++++++- .../improvise-wasm-client/tests/lockstep.rs | 59 +++++++++++++++++++ 9 files changed, 134 insertions(+), 41 deletions(-) diff --git a/crates/improvise-command/src/command/cmd/effect_cmds.rs b/crates/improvise-command/src/command/cmd/effect_cmds.rs index 55e8404e..3e1297f6 100644 --- a/crates/improvise-command/src/command/cmd/effect_cmds.rs +++ b/crates/improvise-command/src/command/cmd/effect_cmds.rs @@ -251,11 +251,17 @@ effect_cmd!( RemoveFormulaCmd, "remove-formula", |args: &[String]| require_args(args, 2), - |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::RemoveFormula { - target_category: args[0].clone(), - target: args[1].clone(), - })] + |args: &Vec, ctx: &CmdContext| -> Vec> { + let (category, target) = (&args[0], &args[1]); + let named = ctx + .model + .formulas() + .iter() + .find(|f| &f.target_category == category && &f.target == target); + match named { + Some(f) => vec![Box::new(effect::RemoveFormula { id: f.id() })], + None => vec![effect::refuse(format!("No formula for {target} in {category}"))], + } } ); diff --git a/crates/improvise-command/src/command/cmd/panel.rs b/crates/improvise-command/src/command/cmd/panel.rs index 4cc3dc70..21e51dac 100644 --- a/crates/improvise-command/src/command/cmd/panel.rs +++ b/crates/improvise-command/src/command/cmd/panel.rs @@ -402,7 +402,7 @@ mod tests { let effects = DeleteFormulaAtCursor.execute(&ctx); let dbg = effects_debug(&effects); assert!( - dbg.contains("RemoveFormula { target: \"Tax\", target_category: \"Type\" }"), + dbg.contains("target: \"Tax\", target_category: \"Type\""), "must remove the formula AT THE CURSOR (Tax, index 1), got: {dbg}" ); assert!( @@ -668,10 +668,8 @@ impl Cmd for DeleteFormulaAtCursor { let cursor = ctx.formula_cursor.min(formulas.len().saturating_sub(1)); if cursor < formulas.len() { let f = &formulas[cursor]; - let mut effects: Vec> = vec![Box::new(effect::RemoveFormula { - target: f.target.clone(), - target_category: f.target_category.clone(), - })]; + let mut effects: Vec> = + vec![Box::new(effect::RemoveFormula { id: f.id() })]; if cursor > 0 { effects.push(Box::new(effect::SetPanelCursor { panel: Panel::Formula, diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index 51ec609d..b2ac7125 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -231,17 +231,17 @@ impl Effect for AddFormula { } } +/// Remove the formula `id` names. A formula already gone is refused and stops +/// the batch, so a frame that named it is rolled back (improvise-tej1). #[derive(Debug)] pub struct RemoveFormula { - pub target: String, - pub target_category: String, + pub id: FormulaId, } impl Effect for RemoveFormula { fn apply(&self, app: &mut App) { - app.model_state - .workbook - .model - .remove_formula(&self.target, &self.target_category); + if let Err(e) = app.model_state.workbook.model.delete_formula(&self.id) { + refuse_and_stop(app, e.to_string()); + } } fn kind(&self) -> EffectKind { EffectKind::Model(ModelChange::CellData) @@ -1914,8 +1914,7 @@ mod tests { .apply(&mut app); assert!(!app.model_state.workbook.model.formulas().is_empty()); RemoveFormula { - target: "Clothing".to_string(), - target_category: "Type".to_string(), + id: crate::formula::parse_formula("Clothing = Food * 2", "Type").unwrap().id(), } .apply(&mut app); assert!(app.model_state.workbook.model.formulas().is_empty()); @@ -2890,8 +2889,7 @@ mod tests { }), ("RemoveFormula", || { Box::new(RemoveFormula { - target: "Profit".into(), - target_category: "_Measure".into(), + id: crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap().id(), }) }), ]; @@ -2914,8 +2912,7 @@ mod tests { } if label == "AddFormula" { app.apply_effects(vec![Box::new(RemoveFormula { - target: "Profit".into(), - target_category: "_Measure".into(), + id: crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap().id(), })]); } app.model_state.dirty = false; diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 76b227c5..4b21f2fd 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -3067,8 +3067,7 @@ mod tests { target_category: "_Measure".into(), }, ModelCommand::RemoveFormula { - target: "Profit".into(), - target_category: "_Measure".into(), + id: crate::formula::parse_formula("Profit = Revenue", "_Measure").unwrap().id(), }, ] } diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index a8658c9d..dbc0eaf2 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -171,13 +171,7 @@ pub fn model_effects(app: &App, cmd: &ModelCommand) -> Vec> { raw: raw.clone(), target_category: target_category.clone(), }), - ModelCommand::RemoveFormula { - target, - target_category, - } => Box::new(effect::RemoveFormula { - target: target.clone(), - target_category: target_category.clone(), - }), + ModelCommand::RemoveFormula { id } => Box::new(effect::RemoveFormula { id: id.clone() }), ModelCommand::ReplaceFormula { id, was, raw } => Box::new(effect::ReplaceFormula { id: id.clone(), was: was.clone(), @@ -687,8 +681,7 @@ pub(crate) mod tests { target_category: "_Measure".into(), }, ModelCommand::RemoveFormula { - target: "Profit".into(), - target_category: "_Measure".into(), + id: crate::formula::parse_formula("Profit = Revenue", "_Measure").unwrap().id(), }, ModelCommand::ReplaceFormula { id: crate::formula::parse_formula("Profit = Revenue", "_Measure") diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index e3772f1d..3ad05eba 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -417,6 +417,18 @@ impl Model { &self.formulas } + /// Remove the formula `id` names, refusing if none does — a delete that + /// finds nothing is a stale claim, not a no-op (improvise-tej1). + pub fn delete_formula(&mut self, id: &FormulaId) -> Result<()> { + let pos = self + .formulas + .iter() + .position(|f| f.id() == *id) + .ok_or_else(|| anyhow!("That formula no longer exists"))?; + self.formulas.remove(pos); + Ok(()) + } + pub fn formula(&self, id: &FormulaId) -> Option<&Formula> { self.formulas.iter().find(|f| f.id() == *id) } @@ -1935,6 +1947,16 @@ mod formula_tests { assert_eq!(targets(&m), vec!["Profit", "Margin"]); } + #[test] + fn delete_formula_removes_the_one_its_id_names_and_refuses_a_stale_id() { + let mut m = three_formulas(); + let tax = m.formulas[1].id(); + m.delete_formula(&tax).unwrap(); + assert_eq!(targets(&m), vec!["Profit", "Margin"]); + assert!(m.delete_formula(&tax).is_err(), "a second delete is stale"); + assert_eq!(targets(&m), vec!["Profit", "Margin"]); + } + #[test] fn a_formula_mentions_what_its_expression_or_its_where_names() { let margin = parse_formula("Margin = Profit / Revenue", "_Measure").unwrap(); diff --git a/crates/improvise-protocol/src/reduce.rs b/crates/improvise-protocol/src/reduce.rs index c7ba84dd..3ad7143d 100644 --- a/crates/improvise-protocol/src/reduce.rs +++ b/crates/improvise-protocol/src/reduce.rs @@ -103,9 +103,9 @@ pub enum ModelCommand { raw: String, target_category: String, }, + /// Remove the formula `id` names; refused if it is already gone. RemoveFormula { - target: String, - target_category: String, + id: FormulaId, }, /// Edit the formula `id` names in place, if it still reads `was` — an edit /// of that formula, not a remove-then-add (improvise-km71). @@ -628,10 +628,7 @@ mod tests { raw, target_category, } => name(raw) + name(target_category), - ModelCommand::RemoveFormula { - target, - target_category, - } => name(target) + name(target_category), + ModelCommand::RemoveFormula { id } => formula(id), ModelCommand::ReplaceFormula { id, was, raw } => formula(id) + name(was) + name(raw), ModelCommand::CleanEmptyRecords | ModelCommand::SortData => 0, ModelCommand::AddCategory(category) diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index aad030d1..9e62c6ec 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -339,7 +339,6 @@ fn declined(name: &str, args: &[String]) -> Option { "toggle-prune-empty" => "hiding empty rows and columns", "toggle-records-mode" => "records mode", "add-record-row" => "adding a record row", - "delete-formula-at-cursor" => "deleting a formula", // Reachable only behind a prefix, and a chord must be answered or it // strands its own disarm — improvise-hs0u, `context/code/clients.md`. "toggle-col-group-under-cursor" => "collapsing a column group", @@ -474,6 +473,7 @@ fn composed( }), // ── the formula panel ──────────────────────────────────────── "edit-formula-at-cursor" => Some(edit_formula_at(state, state.view.formula_cursor)), + "delete-formula-at-cursor" => delete_formula_at_cursor(state), "open-item-add-at-cursor" => Some(match cat_at_cursor(state) { Some(cat) => vec![Command::ChangeMode(AppMode::item_add(cat))], @@ -1229,6 +1229,29 @@ fn switch_view_at(state: &ClientState, index: usize) -> Option> { ]) } +/// `DeleteFormulaAtCursor::execute`: the cursor clamped to the list, the +/// formula named by its id, and the step back only away from the top. +fn delete_formula_at_cursor(state: &ClientState) -> Option> { + let formulas = &state.formulas.formulas; + let cursor = state.view.formula_cursor.min(formulas.len().saturating_sub(1)); + let Some(entry) = formulas.get(cursor) else { + return Some(Vec::new()); + }; + let mut commands = vec![model( + state, + ModelCommand::RemoveFormula { + id: entry.id.clone(), + }, + )?]; + if cursor > 0 { + commands.push(Command::SetPanelCursor { + panel: Panel::Formula, + cursor: cursor - 1, + }); + } + Some(commands) +} + /// `DeleteViewAtCursor::execute`, including the cursor step back that only /// happens away from the top of the list. fn delete_view_at_cursor(state: &ClientState) -> Option> { @@ -2364,7 +2387,6 @@ mod tests { "NORMAL: n search-navigate Silent", "NORMAL: z toggle-group-under-cursor Silent", // Honestly unavailable, and saying so in `declined`'s words. - "FORMULAS: Del/d delete-formula-at-cursor Declines", "NORMAL: > drill-into-cell Declines", "NORMAL: : enter-mode Declines", "NORMAL: P toggle-prune-empty Declines", diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 3bc41dbd..eb2dad00 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2043,3 +2043,62 @@ fn renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host() { assert_eq!(client.view.status_msg, "Formula updated"); assert_eq!(app.view_state.status_msg, "Renamed Tax to Levy"); } + +/// `d` in the formula panel, at a NONZERO cursor over three formulas: the +/// client names the formula by its id, and both ends step the cursor back. +#[test] +fn deleting_a_formula_from_the_panel_agrees_with_the_host() { + let mut app = furnished_host(); + add_formulas(&mut app); + app.view_state.formula_panel_open = true; + app.view_state.mode = AppMode::FormulaPanel; + app.view_state.formula_cursor = 1; + let mut twin = twin_of(&app); + add_formulas(&mut twin); + let mut client = furnished_client(&app, (2, 1)); + client.formulas = improvise_command::ui::project::formulas_of(&app); + + both_ends(&mut app, &mut twin, &mut client, "d"); + assert_eq!( + raws(&app), + vec![FORMULAS[0], FORMULAS[2]], + "`d` must remove the formula under cursor 1, not the first or the last" + ); + assert_eq!(app.view_state.formula_cursor, 0, "and step the cursor back"); +} + +/// The id the client names was deleted by someone else first: the frame is +/// refused whole and the document keeps what it had. +#[test] +fn deleting_a_formula_another_client_already_removed_is_refused_cleanly() { + let mut app = furnished_host(); + add_formulas(&mut app); + app.view_state.formula_panel_open = true; + app.view_state.mode = AppMode::FormulaPanel; + app.view_state.formula_cursor = 1; + let mut client = furnished_client(&app, (2, 1)); + client.formulas = improvise_command::ui::project::formulas_of(&app); + let event = BrowserKey::new("d").to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("`d` in the formula panel resolves here"); + }; + + let mut host = improvise_command::ui::host::Host::new(app); + let (id, _) = host.connect(2, 1); + let model = &mut host.app_mut().model_state.workbook.model; + model.remove_formula("Tax", "_Measure"); + let before = document_text(host.app()); + + let replies = host.handle(id, ClientFrame::Commands(cmds)); + assert!( + replies.iter().any(|a| matches!( + a.msg, + improvise_protocol::ServerFrame::Rejected( + improvise_protocol::Rejection::MutationRefused + ) + )), + "a delete of a formula that no longer exists must be refused: {replies:?}" + ); + assert_eq!(document_text(host.app()), before, "nothing else was removed"); + assert_eq!(raws(host.app()), vec![FORMULAS[0], FORMULAS[2]]); +} From ca19b7686d22560d846047ebe1ac5a8c1ea79dd6 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 07:53:18 -0700 Subject: [PATCH 010/114] A reopened cell editor shows the cell it landed on (improvise-fy9o) The cell-editing commits own their buffer clear at their head instead of the keymap welding one after them, so the re-seed survives and Enter, Enter writes nothing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../src/command/cmd/commit.rs | 73 ++++++++++++++++++- .../improvise-command/src/command/keymap.rs | 41 ++++++++--- crates/improvise-protocol/src/keymap.rs | 15 +++- crates/improvise-protocol/src/resolve.rs | 38 +++++++--- .../improvise-wasm-client/tests/lockstep.rs | 65 +++++++++++++++++ 5 files changed, 209 insertions(+), 23 deletions(-) diff --git a/crates/improvise-command/src/command/cmd/commit.rs b/crates/improvise-command/src/command/cmd/commit.rs index f235177c..2d97e428 100644 --- a/crates/improvise-command/src/command/cmd/commit.rs +++ b/crates/improvise-command/src/command/cmd/commit.rs @@ -5,7 +5,7 @@ use crate::ui::effect::{self, Effect}; use crate::view::RecordCol; use improvise_protocol::{OpenedEditor, untouched}; -use super::core::{Cmd, CmdContext, read_buffer}; +use super::core::{Cmd, CmdContext, read_buffer, write_buffer}; use super::grid::AddRecordRow; use super::navigation::{CursorState, EnterAdvance, viewport_effects}; @@ -805,6 +805,72 @@ mod tests { "the reopened editor did not capture the cell it landed on" ); } + + /// Every cell of the 2×2 fixture, by its key. + fn every_cell(app: &crate::ui::app::App) -> Vec> { + use crate::model::cell::CellKey; + let mut cells = Vec::new(); + for region in ["North", "West"] { + for measure in ["Cost", "Revenue"] { + let key = CellKey::new(vec![ + ("Region".into(), region.into()), + ("_Measure".into(), measure.into()), + ]); + cells.push(app.model_state.workbook.model.get_cell(&key).cloned()); + } + } + cells + } + + /// BUG (improvise-fy9o) — see `context/test-evidence/command.md`. + #[test] + fn enter_enter_leaves_every_cell_unchanged() { + use improvise_protocol::key::KeyCode; + for (records, start) in [(false, (1, 0)), (true, (1, 2))] { + let mut app = grid_holding(CellValue::Number(4.0), records); + if records { + app.view_state.mode = AppMode::RecordsNormal; + } + app.cursor_mut().selected = start; + let before = every_cell(&app); + press(&mut app, KeyCode::Char('i')); + press(&mut app, KeyCode::Enter); + let reopened = app.view_state.buffers.get("edit").cloned(); + press(&mut app, KeyCode::Enter); + assert_eq!( + every_cell(&app), + before, + "records={records}: i, Enter, Enter changed a cell (the reopened editor held {reopened:?})" + ); + } + } + + /// The reopened editor shows the cell it landed on, so typing extends it. + /// BUG (improvise-fy9o) — see `context/test-evidence/command.md`. + #[test] + fn typing_after_the_reopen_still_writes() { + use crate::model::cell::CellKey; + use improvise_protocol::key::KeyCode; + let mut app = grid_holding(CellValue::Number(4.0), false); + app.cursor_mut().selected = (1, 0); + press(&mut app, KeyCode::Char('i')); + press(&mut app, KeyCode::Enter); + assert_eq!( + app.view_state.buffers.get("edit").map(String::as_str), + Some("2"), + "the editor reopened on (North, Revenue) is not seeded with its value" + ); + press(&mut app, KeyCode::Char('5')); + press(&mut app, KeyCode::Enter); + let landed = CellKey::new(vec![ + ("Region".into(), "North".into()), + ("_Measure".into(), "Revenue".into()), + ]); + assert_eq!( + app.model_state.workbook.model.get_cell(&landed), + Some(&CellValue::Number(25.0)) + ); + } } // ── Commit commands (mode-specific buffer consumers) ──────────────────────── @@ -951,6 +1017,11 @@ impl Cmd for CommitAndAdvance { // Drop to normal **before** the commit, which can abort: tidy-up goes where // an abort cannot reach it (improvise-2zq, gotcha 21). effects.push(effect::change_mode(exit_mode_for(&self.edit_mode))); + // This command owns its buffer's clear, at the head, so the re-entry + // below re-seeds after it rather than being blanked (improvise-fy9o). + if let Some(mb) = self.edit_mode.minibuffer() { + effects.push(write_buffer(&mb.buffer_key, String::new())); + } // An untouched editor writes nothing; everything around the write still runs. let writes = !untouched(self.opened.as_ref(), &self.value); if writes { diff --git a/crates/improvise-command/src/command/keymap.rs b/crates/improvise-command/src/command/keymap.rs index d98b693c..300e4ef8 100644 --- a/crates/improvise-command/src/command/keymap.rs +++ b/crates/improvise-command/src/command/keymap.rs @@ -690,6 +690,22 @@ mod tests { } /// The buffer `append-char` is bound to in `mode`'s keymap. + /// The effects `name` builds in `mode` with `buffer` holding text, as Debug. + fn clears_itself(mode: &AppMode, name: &str, args: &[String], buffer: &str) -> String { + use crate::command::cmd::test_helpers::*; + let wb = two_cat_model(); + let layout = make_layout(&wb); + let reg = make_registry(); + let bufs: HashMap = [(buffer.to_string(), "typed".to_string())].into(); + let mut ctx = make_ctx(&wb, &layout, ®); + ctx.mode = mode; + ctx.buffers = &bufs; + let cmd = reg + .interactive(name, args, &ctx) + .unwrap_or_else(|e| panic!("{name} {args:?} does not build: {e}")); + effects_debug(&cmd.execute(&ctx)) + } + fn append_target(ks: &KeymapSet, mode: &AppMode) -> String { let key = ModeKey::from_app_mode(mode, false).expect("a text-entry mode key"); let km = ks.maps().get(&key).expect("every mode has a keymap"); @@ -764,15 +780,22 @@ mod tests { let km = ks.maps().get(&mode).unwrap(); for key in exits { - let Some(Binding::Sequence(steps)) = km.lookup(*key, none) else { - panic!("{mode:?}: {key:?} should be a commit/exit sequence"); - }; - assert!( - steps - .iter() - .any(|(n, a)| *n == "clear-buffer" && a.as_slice() == [buffer.to_string()]), - "{mode:?}: {key:?} must clear \"{buffer}\", got {steps:?}" - ); + match km.lookup(*key, none) { + Some(Binding::Sequence(steps)) => assert!( + steps.iter().any(|(n, a)| *n == "clear-buffer" + && a.as_slice() == [buffer.to_string()]), + "{mode:?}: {key:?} must clear \"{buffer}\", got {steps:?}" + ), + // A reopening commit owns its clear (improvise-fy9o): ask the command. + Some(Binding::Cmd { name, args }) => { + let dbg = clears_itself(&app_mode, name, args, &buffer); + assert!( + dbg.contains(&format!("SetBuffer {{ name: \"{buffer}\", value: \"\" }}")), + "{mode:?}: {key:?} runs {name} alone, which must clear \"{buffer}\": {dbg}" + ); + } + other => panic!("{mode:?}: {key:?} should commit or exit, got {other:?}"), + } } } } diff --git a/crates/improvise-protocol/src/keymap.rs b/crates/improvise-protocol/src/keymap.rs index 6b7aef43..aefd9027 100644 --- a/crates/improvise-protocol/src/keymap.rs +++ b/crates/improvise-protocol/src/keymap.rs @@ -347,6 +347,13 @@ impl TextEntry { self } + /// Bind `key` to a commit that clears the buffer **itself**, first, because + /// it reopens an editor a trailing clear would blank (improvise-fy9o). + pub fn commit_reopening(mut self, key: KeyCode, cmd: &'static str, args: Vec) -> Self { + self.km.bind_args(key, KeyModifiers::NONE, cmd, args); + self + } + /// Replace a spine binding — used only by CommandMode, whose Backspace /// exits the mode on an empty buffer instead of popping a character. pub fn rebind(mut self, key: KeyCode, cmd: &'static str) -> Self { @@ -851,8 +858,8 @@ impl Keymaps { // edit-mode as an argument, so the command stays mode-agnostic. ── let ed = Arc::new( TextEntry::new(&AppMode::editing(), "normal", global.clone()) - .commit(KeyCode::Enter, "commit-cell-edit", vec!["editing".into()]) - .commit( + .commit_reopening(KeyCode::Enter, "commit-cell-edit", vec!["editing".into()]) + .commit_reopening( KeyCode::Tab, "commit-and-advance-right", vec!["editing".into()], @@ -867,12 +874,12 @@ impl Keymaps { ModeKey::RecordsEditing, Arc::new( TextEntry::new(&AppMode::records_editing(), "records-normal", ed) - .commit( + .commit_reopening( KeyCode::Enter, "commit-cell-edit", vec!["records-editing".into()], ) - .commit( + .commit_reopening( KeyCode::Tab, "commit-and-advance-right", vec!["records-editing".into()], diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 0d964ec7..fb6d4bff 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -896,10 +896,19 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option Option { + edit_mode + .minibuffer() + .map(|mb| write_buffer(&mb.buffer_key, String::new())) +} + /// The write a commit makes: none when the buffer is the seed the editor /// captured as it opened, and only the write is skipped (improvise-ixxx). fn written(state: &ClientState, id: &CellId, text: &str) -> Option> { @@ -924,6 +933,7 @@ fn create_column( // `exit_mode_for`: the tidy-up goes FIRST, where an aborting // `AddCategory` cannot reach it (gotcha 21). let mut commands = vec![Command::ChangeMode(AppMode::Normal)]; + commands.extend(cleared(&edit_mode)); let name = text.trim(); if !name.is_empty() { commands.push(model( @@ -3355,18 +3365,28 @@ mod tests { } ); assert_eq!(commands[1], Command::ChangeMode(AppMode::Normal)); - assert_eq!(commands[2], Command::SetSelected { row: 1, col: 0 }); - assert!( - commands.contains(&Command::ChangeMode(AppMode::editing())), - "the advance re-enters editing on the cell it landed on: {commands:?}" - ); + // The commit owns its clear, at the head (improvise-fy9o). assert_eq!( - commands.last(), - Some(&Command::SetBuffer { + commands[2], + Command::SetBuffer { name: "edit".into(), value: String::new(), - }), - "the keymap welds `clear-buffer` to every commit: {commands:?}" + } + ); + assert_eq!(commands[3], Command::SetSelected { row: 1, col: 0 }); + assert_eq!( + commands.last(), + Some(&Command::ChangeMode(AppMode::editing())), + "the advance re-enters editing on the cell it landed on: {commands:?}" + ); + let last_write = commands.iter().rev().find_map(|c| match c { + Command::SetBuffer { name, value } if name == "edit" => Some(value.as_str()), + _ => None, + }); + assert_eq!( + last_write, + Some("900"), + "the reopened editor must show the cell it landed on, not blank: {commands:?}" ); } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 3dad0c9a..cb1b9a34 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1893,6 +1893,71 @@ fn an_untouched_browser_edit_of_a_misread_value_writes_nothing() { } } +/// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`. +#[test] +fn enter_enter_leaves_every_cell_unchanged_on_both_ends() { + let every_cell = |app: &App| -> Vec> { + [("North", "Cost"), ("North", "Revenue"), ("South", "Cost"), ("South", "Revenue")] + .iter() + .map(|(r, m)| app.model_state.workbook.model.get_cell(®ion_measure(r, m)).cloned()) + .collect() + }; + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (1, 0); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (1, 0); + let before = every_cell(&client_host); + let mut client = client_ready_to_edit(&client_host, (2, 2)); + for key in ["i", "Enter", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + apply_on(&mut client_host, &cmds); + reduce_key(&mut key_host, event); + assert_eq!( + client.view.buffers, key_host.view_state.buffers, + "{key}: client and host disagree about the buffers" + ); + } + assert_eq!(every_cell(&client_host), before, "the browser's Enter, Enter wrote"); + assert_eq!(every_cell(&key_host), before, "the host's Enter, Enter wrote"); +} + +/// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`. +#[test] +fn typing_into_the_reopened_editor_extends_the_cell_on_both_ends() { + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (1, 0); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (1, 0); + let mut client = client_ready_to_edit(&client_host, (2, 2)); + for key in ["i", "Enter", "5", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + apply_on(&mut client_host, &cmds); + reduce_key(&mut key_host, event); + } + for (end, app) in [("browser", &client_host), ("host", &key_host)] { + assert_eq!( + app.model_state + .workbook + .model + .get_cell(®ion_measure("North", "Revenue")), + Some(&improvise_protocol::CellValue::Number(25.0)), + "{end}: the digit typed into the reopened editor did not extend its seed" + ); + } +} + /// `i` then `Enter` on (South, Revenue) holding `value`, through the resolver /// and a real host: the seed is the stored value's, and the cell is unchanged. fn untouched_browser_edit(value: improvise_protocol::CellValue) { From 8490f34f981bc7fe867f3c4e37c2b7f51d4c0aad Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 07:53:48 -0700 Subject: [PATCH 011/114] A drill commit naming a removed category is refused and writes nothing (improvise-qz9p) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a drill was open, another client's RemoveCategory could remove a category the drill's rows name. On commit, AddItem refused, but the rewrite's clears and writes still ran, leaving a cell keyed under a category the model no longer has. A measure edit alone on such a row was worse: no AddItem, so no refusal, and the orphan was written silently. RecordRewrite::of now asks whether every category a row names, its coordinates and its renames, still exists. If one is gone, the row is unwritable: it refuses and carries no writes. merged drops every write when any row is unwritable, because a drill commit is atomic (design-principles §4). The effect refuses rather than stops (gotcha 21): ViewBack after it assumes nothing about the writes, and the category cannot come back through the drill, so stopping would trap the user. Reasoning: context/code/command.md, RecordRewrite. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/command.md | 33 ++++++ context/test-evidence/command.md | 30 +++++ crates/improvise-command/src/ui/effect.rs | 133 ++++++++++++++++++++-- 3 files changed, 186 insertions(+), 10 deletions(-) diff --git a/context/code/command.md b/context/code/command.md index a1eef6cd..efd1995e 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -986,6 +986,39 @@ single edit the output is the effects the plain path used to emit, reordered fro One behaviour moved on the drill side: it used to discard `add_item`'s error, and now goes through `effect::AddItem`, which refuses when the category has gone. +**A row naming a category the model no longer has is unwritable, and one +unwritable row makes the whole drill commit write nothing** (improvise-qz9p). +Another client's `RemoveCategory` can land while a drill is open. That removes the +category and every cell under it, and the drill's staged edits then address +coordinates nothing owns. The fix has two parts. + +- **The check is structural, not a reaction to `AddItem` refusing.** The ticket + framed it as "should a failed `AddItem` drop the row's writes", but that misses + the plainer case. A **measure edit alone** on such a row stages no `AddItem`, so + nothing refuses, and it still writes an orphan cell keyed under the removed + category. `RecordRewrite::of` therefore asks the model whether every category + the row names still exists, both its coordinates and its renames. If any is + gone, the row is `unwritable`: it refuses with `Unknown category '…'` and + carries no writes. This is ADR 001's "force never creates structure", applied + inside the drill. +- **Why the whole commit and not just that row.** The drill's contract is atomic + (`design-principles.md` §4: "`ApplyAndClearDrill` writes them all atomically … + No partial writes reach the model"). A per-row skip would commit the other + rows' edits and drop this one's, a partial write of exactly the kind that + contract rules out. `merged` therefore keeps only the refusals when any row is + unwritable. +- **`refuse`, not `refuse_and_stop`.** This follows #181's rule (gotcha 21): + abort only where a later effect in the batch assumes the failed one happened. + The effect after `ApplyAndClearDrill` in `view-back`'s batch is `ViewBack`, + which assumes nothing about the writes. Stopping would also keep the user in a + drill they cannot fix, because the category cannot come back through it, so + every exit would fail again. The staged edits are discarded, as on cancel, and + the refusal says why. + +A blank coordinate edit is a different kind of refusal. It is refused **alone**, +and the row's other edits still land, as before: that edit is invalid input, not +a missing structure. + ### `Save` / `SaveAs` abort the batch (improvise-8tk / improvise-lae) A failed save aborts the rest of the batch, so the `Quit` that `wq`/`ZZ` chains diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 3ffb456a..4ccf6b91 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -5599,3 +5599,33 @@ that is deliberate. Its helpers seed a client *after* mutating the host (mode, buffers, cursor), so a `Snapshot` would drop exactly the state those tests stage. This test is what makes the copy faithful at startup. +## `ui/effect.rs` — a drill commit into a category removed mid-drill (improvise-qz9p) + +### a_drill_rename_into_a_removed_category_is_refused_and_writes_nothing + +improvise-qz9p, the ticket's own probe. `Type` is removed while the drill is open, +and row 1 stages `Type → Drink` and `Cost = 999`. Before the fix the commit left +`{Drink/Jan/Cost = 999}`, a cell keyed under a category the model no longer has, +which no grid shows. `AddItem`'s refusal was visible, but the clears and writes +ran anyway. Red before the fix on the store assertion. It asserts both the +refusal (`last_batch_refused`) and the whole store, so a fix that only refuses +and a fix that only drops writes both fail it. Rival B (refuse, but still +write) fails here. + +### a_drill_measure_edit_on_a_record_whose_category_was_removed_is_refused + +improvise-qz9p, the case the ticket did not predict. A **measure edit alone** +stages no `AddItem`, so nothing refused at all, and the commit silently wrote +`Clothing/Jan/Cost = 999` under the removed `Type`. Red before the fix on the +refusal assertion. It is the test that separates the structural check from the +ticket's "drop the writes when `AddItem` refuses": rival C, which checks only +renamed categories, fails here and on the atomicity test. + +### a_drill_commit_with_one_unwritable_row_writes_none_of_its_rows + +improvise-qz9p. The drill is atomic (`design-principles.md` §4), so one +unwritable row stops every row. Row 0 names `Type`, and row 1 is a record with +no `Type` coordinate at all, so it stays writable after `Type` is removed. Both +rows stage a `Cost`. The expected store is row 1's untouched `-/Jan/Cost = 17`. +Rival A skips only the unwritable row, and it fails this test and nothing else +in the workspace, writing row 1's `999`. Rival B fails here too. diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index b119b7eb..1e069ecb 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -690,7 +690,9 @@ impl Effect for ApplyAndClearDrill { /// before anything is written. Why composed: `context/code/command.md`, `RecordRewrite`. #[derive(Debug, Default, PartialEq)] pub(crate) struct RecordRewrite { - refused: bool, + refusals: Vec, + /// A category the row names is gone: nothing of this rewrite may be written. + unwritable: bool, items: Vec<(String, String)>, clears: Vec, sets: Vec<(CellKey, CellValue)>, @@ -704,12 +706,14 @@ impl RecordRewrite { row: &RecordRow, edits: impl IntoIterator, ) -> Self { - let mut refused = false; + let mut refusals = Vec::new(); let mut renames: BTreeMap<&str, &str> = BTreeMap::new(); let mut measures: BTreeMap<&str, Option> = BTreeMap::new(); for (col, value) in edits { match col { - RecordCol::Category(_) if value.is_empty() => refused = true, + RecordCol::Category(_) if value.is_empty() => { + refusals.push(RECORD_COORDS_CANNOT_BE_EMPTY.to_string()) + } RecordCol::Category(category) => { renames.insert(category.as_str(), value); } @@ -721,6 +725,40 @@ impl RecordRewrite { RecordCol::NewMeasure | RecordCol::NewCategory => {} } } + let gone: std::collections::BTreeSet<&str> = row + .coords + .0 + .iter() + .map(|(category, _)| category.as_str()) + .chain(renames.keys().copied()) + .filter(|category| model.category(category).is_none()) + .collect(); + match gone.is_empty() { + true => Self::compose(model, row, refusals, renames, measures), + false => Self::unwritable(gone), + } + } + + /// A row naming a category the model no longer has: refused, and nothing + /// written, because a write would create a cell no category owns (improvise-qz9p). + fn unwritable(gone: std::collections::BTreeSet<&str>) -> Self { + Self { + refusals: gone + .into_iter() + .map(|category| format!("Unknown category '{category}'")) + .collect(), + unwritable: true, + ..Self::default() + } + } + + fn compose( + model: &crate::model::Model, + row: &RecordRow, + refusals: Vec, + renames: BTreeMap<&str, &str>, + mut measures: BTreeMap<&str, Option>, + ) -> Self { let renamed = |key: &CellKey| { renames.iter().fold(key.clone(), |key, (category, item)| { key.with(*category, *item) @@ -737,7 +775,7 @@ impl RecordRewrite { .collect(), }; let mut rewrite = RecordRewrite { - refused, + refusals, items: renames .iter() .map(|(category, item)| (category.to_string(), item.to_string())) @@ -762,20 +800,29 @@ impl RecordRewrite { rewrite } - /// Several rows' rewrites as one: every clear lands before any write, so a - /// swap is a swap, and where two rows land on one cell the later row wins. + /// Several rows' rewrites as one: every clear before any write, the later row winning + /// a shared cell, nothing written if any row is unwritable. `context/code/command.md`. pub(crate) fn merged(rewrites: impl IntoIterator) -> Self { - rewrites.into_iter().fold(Self::default(), |mut all, one| { - all.refused |= one.refused; + let all = rewrites.into_iter().fold(Self::default(), |mut all, one| { + all.refusals.extend(one.refusals); + all.unwritable |= one.unwritable; all.items.extend(one.items); all.clears.extend(one.clears); all.sets.extend(one.sets); all - }) + }); + match all.unwritable { + true => Self { + refusals: all.refusals, + unwritable: true, + ..Self::default() + }, + false => all, + } } pub(crate) fn effects(self) -> Vec> { - let refusal = self.refused.then(|| refuse(RECORD_COORDS_CANNOT_BE_EMPTY)); + let refusal = self.refusals.into_iter().map(refuse); let items = self .items .into_iter() @@ -3433,6 +3480,72 @@ mod tests { ); } + // ── A category removed while the drill is open (improvise-qz9p) ───── + + /// Leave the drill after staging `edits` into a drill whose `Type` was removed + /// underneath it; the store left behind, and whether the commit was refused. + fn commit_after_removing_type( + mut app: App, + edits: &[(usize, RecordCol, &str)], + ) -> (std::collections::BTreeSet, bool) { + app.model_state.workbook.remove_category("Type"); + for (row, col, value) in edits { + stage(&mut app, *row, col.clone(), value); + } + app.apply_effects(vec![Box::new(ApplyAndClearDrill)]); + (drill_store(&app), app.last_batch_refused()) + } + + #[test] + fn a_drill_rename_into_a_removed_category_is_refused_and_writes_nothing() { + let (store, refused) = commit_after_removing_type( + two_record_drill(), + &[(1, category("Type"), "Drink"), (1, measure("Cost"), "999")], + ); + assert!(refused, "the commit must say it could not be applied"); + assert_eq!(store, std::collections::BTreeSet::new(), "no orphan cell"); + } + + #[test] + fn a_drill_measure_edit_on_a_record_whose_category_was_removed_is_refused() { + let (store, refused) = + commit_after_removing_type(two_record_drill(), &[(1, measure("Cost"), "999")]); + assert!(refused, "the commit must say it could not be applied"); + assert_eq!(store, std::collections::BTreeSet::new(), "no orphan cell"); + } + + #[test] + fn a_drill_commit_with_one_unwritable_row_writes_none_of_its_rows() { + let mut app = test_app(); + let typed = drill_cell("Food", "Jan", "Cost"); + let untyped = CellKey::new(vec![ + ("Month".into(), "Jan".into()), + ("_Measure".into(), "Cost".into()), + ]); + let cells = [ + (typed, CellValue::Number(2.0)), + (untyped, CellValue::Number(17.0)), + ]; + for (key, value) in cells.clone() { + app.model_state.workbook.model.set_cell(key, value); + } + let records = RecordRow::group(cells); + assert_eq!(records.len(), 2); + assert_eq!(records[1].coords.get("Type"), None, "row 1 names no Type"); + StartDrill(records).apply(&mut app); + + let (store, refused) = commit_after_removing_type( + app, + &[(0, measure("Cost"), "5"), (1, measure("Cost"), "999")], + ); + assert!(refused, "the commit must say it could not be applied"); + assert_eq!( + store, + ["-/Jan/Cost = 17".to_string()].into_iter().collect(), + "row 1 is writable, but a drill commit is atomic" + ); + } + // ── Toggle effects ────────────────────────────────────────────────── #[test] From 7d45b11d1482e646b321526a274f9878509ef0e2 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 07:54:27 -0700 Subject: [PATCH 012/114] improvise-tej1: delete the formula under the cursor from the browser The browser composes delete-formula-at-cursor now that FormulaCache carries each formula's id. RemoveFormula names its formula by FormulaId on the wire and in the effect, and a delete whose id no longer resolves is refused with the batch stopped rather than silently doing nothing, so a frame naming a formula another client removed is rolled back whole. The remove-formula script command resolves its names to an id and refuses when nothing matches. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 22 +++++++++++++------ context/code/core.md | 3 ++- context/test-evidence/clients.md | 19 ++++++++++++++++ .../src/command/cmd/effect_cmds.rs | 4 +++- crates/improvise-command/src/ui/effect.rs | 12 +++++++--- crates/improvise-command/src/ui/project.rs | 4 +++- crates/improvise-command/src/ui/reduce.rs | 4 +++- crates/improvise-protocol/src/resolve.rs | 5 ++++- .../improvise-wasm-client/tests/lockstep.rs | 6 ++++- 9 files changed, 63 insertions(+), 16 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index a7becead..9579df39 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -447,9 +447,10 @@ the status after. - `toggle-prune-empty` words its status off `View::prune_empty`, which is in the `Workbook`, in no cache, and not in `ViewState`. -- `delete-formula-at-cursor` needs `(target, target_category)`; `FormulaCache` - carries the formula's raw *text*, and recovering the two from it means - reimplementing `parse_formula` on the client. +- `delete-formula-at-cursor` needed `(target, target_category)` while + `FormulaCache` carried only the formula's raw *text*. **Closed by + improvise-tej1**: since km71 the cache carries a `FormulaId` per formula, and + `ModelCommand::RemoveFormula` now takes that id. - `view-back` was listed here too, and **improvise-cfly removed it**: once PR #115 put `Command::ViewBack` in the view half there was nothing left to enrich. See "The three improvise-e24v listed as free" below. @@ -642,8 +643,9 @@ The classes that remain: and `group_of(idx)` is exactly `ToggleGroup`'s `(category, group)`. The one thing to get right is that the frame is **windowed** and the cursor is absolute, so a client must reconcile the scroll offset first, as `render.rs` - does for the selection. `delete-formula-at-cursor` stays: it names a position - in the formula panel, not in the grid. + does for the selection. `delete-formula-at-cursor` left this list with + improvise-tej1: the position it names is in the formula panel, and the id + the panel's cache now carries is enough to resolve it. - **Anything touching storage or an arbitrary command line.** The host's by definition. - **The records-mode commit** — *except the two creating columns*. A wide-records @@ -1019,8 +1021,14 @@ recoverable without reimplementing `parse_formula` on the client — the gap `decision(improvise-e24v)` recorded for `delete-formula-at-cursor`. km71 closed it the gotcha 47 way, by **enriching the projection**: `FormulaCache::formulas` is `Vec`. The id is opaque on purpose -(`context/code/core.md` §14). `delete-formula-at-cursor` is still declined in -the browser; it now needs only its resolver. +(`context/code/core.md` §18). improvise-tej1 then composed +`delete-formula-at-cursor` in the browser on the same id. `RemoveFormula` +names its formula by `FormulaId` on the wire and in the effect, and a delete +whose id no longer resolves is **refused with the batch stopped**, where it was +a silent no-op: a frame that named a formula someone else removed is rolled +back whole (`Host::mutating_frame`) and answered `Rejected(MutationRefused)`. +The script command `remove-formula ` resolves its names to +an id in `execute` and refuses out loud when nothing matches. One divergence is pinned rather than closed: a rename. The host's `ReplaceFormula` rewrites the status to say what was renamed and which formulas diff --git a/context/code/core.md b/context/code/core.md index 2f844f6d..1817cc63 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -1243,7 +1243,8 @@ outside `improvise-formula` can read a name out of it. That is deliberate. The stable-identity work (improvise-sboz, ADR 005) will make a formula's identity something other than its names, and every signature that already says `FormulaId` — `Model::formula`, `Model::replace_formula`, -`ModelCommand::ReplaceFormula`, `FormulaSlot::Existing`, `FormulaEntry` — then +`ModelCommand::ReplaceFormula`, `ModelCommand::RemoveFormula` (since improvise-tej1), +`Model::delete_formula`, `FormulaSlot::Existing`, `FormulaEntry` — then changes by changing this one struct. A name pair spread across those signatures would have made the swap a sweep. diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index fba4cc7a..c37e8161 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -4762,3 +4762,22 @@ claims at commit. The commit was then *accepted*. With the refusal assertion disabled, the host held `Some(Number(42.0))`, so A's commit had re-created the cell B cleared. Under that rival, the 0j0f test above failed as well. Both files were restored. + +### deleting_a_formula_from_the_panel_agrees_with_the_host + +improvise-tej1. Three formulas with the cursor on index 1, so "the one under the +cursor" is neither the first nor the last. Rivals: the resolver deletes +`formulas.first()` instead of the clamped cursor row, and this test fails on the +document. The resolver never steps the cursor back, and this test fails on the +cursor comparison in `both_ends`, which is the only thing that step decides. + +### deleting_a_formula_another_client_already_removed_is_refused_cleanly + +improvise-tej1. The client composes its delete, then the host loses `Tax` before +the frame arrives. Rival: `effect::RemoveFormula` ignores a missing id, which is +exactly what the old name-keyed `Model::remove_formula` did. The frame is then +accepted with nothing removed, and this test fails because no +`Rejected(MutationRefused)` comes back. The assertion that the document is +unchanged is the other half: a refusal that also removed a neighbour would fail +there. + diff --git a/crates/improvise-command/src/command/cmd/effect_cmds.rs b/crates/improvise-command/src/command/cmd/effect_cmds.rs index 3e1297f6..645a587c 100644 --- a/crates/improvise-command/src/command/cmd/effect_cmds.rs +++ b/crates/improvise-command/src/command/cmd/effect_cmds.rs @@ -260,7 +260,9 @@ effect_cmd!( .find(|f| &f.target_category == category && &f.target == target); match named { Some(f) => vec![Box::new(effect::RemoveFormula { id: f.id() })], - None => vec![effect::refuse(format!("No formula for {target} in {category}"))], + None => vec![effect::refuse(format!( + "No formula for {target} in {category}" + ))], } } ); diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index b2ac7125..c5e3e06b 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -1914,7 +1914,9 @@ mod tests { .apply(&mut app); assert!(!app.model_state.workbook.model.formulas().is_empty()); RemoveFormula { - id: crate::formula::parse_formula("Clothing = Food * 2", "Type").unwrap().id(), + id: crate::formula::parse_formula("Clothing = Food * 2", "Type") + .unwrap() + .id(), } .apply(&mut app); assert!(app.model_state.workbook.model.formulas().is_empty()); @@ -2889,7 +2891,9 @@ mod tests { }), ("RemoveFormula", || { Box::new(RemoveFormula { - id: crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap().id(), + id: crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure") + .unwrap() + .id(), }) }), ]; @@ -2912,7 +2916,9 @@ mod tests { } if label == "AddFormula" { app.apply_effects(vec![Box::new(RemoveFormula { - id: crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap().id(), + id: crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure") + .unwrap() + .id(), })]); } app.model_state.dirty = false; diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 4b21f2fd..e139f9d4 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -3067,7 +3067,9 @@ mod tests { target_category: "_Measure".into(), }, ModelCommand::RemoveFormula { - id: crate::formula::parse_formula("Profit = Revenue", "_Measure").unwrap().id(), + id: crate::formula::parse_formula("Profit = Revenue", "_Measure") + .unwrap() + .id(), }, ] } diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index dbc0eaf2..d952e625 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -681,7 +681,9 @@ pub(crate) mod tests { target_category: "_Measure".into(), }, ModelCommand::RemoveFormula { - id: crate::formula::parse_formula("Profit = Revenue", "_Measure").unwrap().id(), + id: crate::formula::parse_formula("Profit = Revenue", "_Measure") + .unwrap() + .id(), }, ModelCommand::ReplaceFormula { id: crate::formula::parse_formula("Profit = Revenue", "_Measure") diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 9e62c6ec..c3175acf 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1233,7 +1233,10 @@ fn switch_view_at(state: &ClientState, index: usize) -> Option> { /// formula named by its id, and the step back only away from the top. fn delete_formula_at_cursor(state: &ClientState) -> Option> { let formulas = &state.formulas.formulas; - let cursor = state.view.formula_cursor.min(formulas.len().saturating_sub(1)); + let cursor = state + .view + .formula_cursor + .min(formulas.len().saturating_sub(1)); let Some(entry) = formulas.get(cursor) else { return Some(Vec::new()); }; diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index eb2dad00..197de279 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2099,6 +2099,10 @@ fn deleting_a_formula_another_client_already_removed_is_refused_cleanly() { )), "a delete of a formula that no longer exists must be refused: {replies:?}" ); - assert_eq!(document_text(host.app()), before, "nothing else was removed"); + assert_eq!( + document_text(host.app()), + before, + "nothing else was removed" + ); assert_eq!(raws(host.app()), vec![FORMULAS[0], FORMULAS[2]]); } From 8ee5fade27dfc6b3b2cae3611373b2fc625d4e2b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 07:57:01 -0700 Subject: [PATCH 013/114] fy9o: corner lockstep guard, evidence and protocol.md; file improvise-jmti Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/protocol.md | 27 ++++++- context/test-evidence/clients.md | 42 +++++++++-- context/test-evidence/command.md | 31 +++++++- .../improvise-command/src/command/keymap.rs | 16 +++-- .../improvise-wasm-client/tests/lockstep.rs | 70 +++++++++++++++++-- 5 files changed, 166 insertions(+), 20 deletions(-) diff --git a/context/code/protocol.md b/context/code/protocol.md index 9dcfe3a5..ceaf182a 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -1976,7 +1976,32 @@ edit are the same string **by construction** rather than by agreement (improvise-k8h) — the keymap never spells a buffer name. `commit(key, cmd, args)` welds the command to its `clear-buffer` so no caller -can bind a commit without its clear (improvise-w3q). `rebind` exists for +can bind a commit without its clear (improvise-w3q). + +**The two cell-editing commit keys are the exception, and the clear moved rather +than disappeared** (improvise-fy9o). `commit-cell-edit` and +`commit-and-advance-right` reopen an editor on the cell they advance to, and the +welded clear ran **after** them, so it blanked the re-seeded buffer. The +reopened editor looked empty, and a second Enter committed `""` and cleared the +landed cell. The user's ruling is that the reopened editor shows the landed +cell's current value, so with improvise-ixxx's untouched rule an unchanged Enter +writes nothing. `commit_reopening(key, cmd, args)` therefore binds the command +alone, and the command clears its own buffer **at its head**: +- in the TUI, `CommitAndAdvance` does it right after the mode change; +- in the browser, `resolve::cleared` does it, reached from both `commit` and + `create_column`. + +Being at the head is also gotcha 21's rule: a head clear survives an abort +where the trailing weld did not. The corner commit and the `+cat` insertion +reopen nothing, and they still leave no text behind. + +Reordering the weld to `[clear-buffer, cmd]` was tried first and rejected. Four +readers take a `Sequence`'s **first** step as its identity: `keymap::runs`, +`help.rs`, and two resolver audits. So every hint on those keys started +reporting `clear-buffer`. `text_entry_modes_clear_the_buffer_they_type_into` +keeps its guarantee by asking a plain-`Cmd` commit key's command what it emits. + +`rebind` exists for exactly one case: CommandMode's Backspace, which exits the mode on an empty `:` line instead of popping a character that isn't there. diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 69b9bd5e..4fc118e8 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -273,11 +273,14 @@ with an empty vec, so that would be a keypress the user sees do nothing at all ### enter_commits_the_cell_advances_and_re_enters_editing -Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]`, and every -part of that sequence shows up in what the client composes — **including the -trailing clear that empties the buffer `EnterEditAtCursor` has just seeded**. -That ordering looks wrong and is exactly what the TUI does; mirroring it line -for line is the contract. +Enter in `Editing` used to be `[commit-cell-edit editing, clear-buffer edit]`, +and the trailing clear emptied the buffer the advance had just re-seeded. That +was improvise-fy9o. Since the fix, Enter is `commit-cell-edit` alone, and the +commit clears its own buffer at its head. The test pins that order: the write, +then `ChangeMode(Normal)`, then `SetBuffer(edit, "")`, then `SetSelected`. It +ends with the re-entry, and the **last** write to `edit` is the landed cell's +`"900"`. **Rival:** `resolve::commit` without its head clear fails this test, +and `a_commit_at_the_corner_leaves_no_buffer_behind_on_both_ends` too. --- @@ -4908,3 +4911,32 @@ the keyboard paths a trimmed comparison cannot be seen through the keys, since any whitespace edit also sets `touched`, so this unit test and the mouse path are where it shows. The mouse test now also asserts that `"7 "` against the seed `"7"` is a write. + +### `enter_enter_leaves_every_cell_unchanged_on_both_ends` + +**improvise-fy9o**, browser and host in lockstep. On the 2×2 `precise_host`, +starting at (South, Cost), row 1 and column 0, the test presses `i`, Enter, +Enter. The client resolves each key, folds it and applies it to one host, and +`reduce_key` drives a second host. After each key the client's buffers must +equal the key host's, and at the end every cell must be unchanged on both +hosts. + +Failed before the fix: (North, Revenue) was cleared, because the welded clear +had blanked the reopened editor. **Rival:** the editing commit keys bound +through the welding `.commit` again fails this test, the typing test below, and +both TUI tests. + +### `typing_into_the_reopened_editor_extends_the_cell_on_both_ends` + +The other half of the ruling: the reopened editor shows the landed cell, so +typing extends it. `i`, Enter, `5`, Enter must store `25` at (North, Revenue) +on both ends. It fails under the weld rival, because the digit went into a +blank buffer and stored `5`. + +### `a_commit_at_the_corner_leaves_no_buffer_behind_on_both_ends` + +Moving the clear from the keymap into the command must not leave text behind +where nothing reopens. At the bottom-right corner, `i`, `9`, Enter must leave +`edit` as `""` on both ends, with the mode no longer editing. **Rival:** the +browser without `cleared` leaves `"49"` and fails here. The equivalent TUI rival +fails `text_entry_modes_clear_the_buffer_they_type_into`. diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 4037430c..776bfe3a 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -2861,6 +2861,11 @@ The buffer is derived from each mode rather than restated in the test — `every_text_entry_mode_types_into_the_buffer_its_prompt_names` owns pinning what it should be (improvise-k8h). +**Amended by improvise-fy9o.** A commit key bound to a plain command now passes only if that command, built in +the mode with text in the buffer, emits `SetBuffer { name: , value: "" }` +itself. **Rival:** `CommitAndAdvance` without its head clear. Only this test +fails, which makes it the TUI's guard against leftover text. + ### minibuffer_enter_dispatches_to_a_registered_commit improvise-6t3: the three commit commands collapsed into a single @@ -5636,8 +5641,9 @@ The buffer after `Enter` is **not** asserted. The keymap welds `clear-buffer` after every commit key, so it empties the buffer `EnterEditAtCursor` has just seeded, and the reopened editor opens blank while its captured seed is `"2"`. That is existing behaviour (`enter_commits_the_cell_advances_and_re_enters_editing` -in `clients.md`). An `Enter` there with nothing typed therefore clears the landed cell, -measured here; that is filed as improvise-fy9o rather than fixed here. +in `clients.md`). An `Enter` there with nothing typed therefore cleared the landed +cell. It was filed as improvise-fy9o and is now fixed (see +`enter_enter_leaves_every_cell_unchanged`). ### `an_editor_typed_back_to_its_seed_still_writes` @@ -5678,3 +5684,24 @@ editor's seed. This test is only possible because `clear-buffer` and keys typed into another buffer do not mark the editor. A rule that marked on every buffer write would make the guard redundant, and no test could pin it (`protocol.md` §3). + +### `enter_enter_leaves_every_cell_unchanged` + +**improvise-fy9o.** The keymap welded `clear-buffer` after every commit key, so +it blanked the editor `CommitAndAdvance` had just reopened. The next Enter then +committed `""` and cleared the landed cell. The test presses `i`, Enter, Enter +on the 2×2 fixture: +- in pivot, from (1, 0); +- in records, from (1, 2), the West row's Cost column. + +Every cell must be unchanged. Both cases failed before the fix, clearing +(North, Revenue). The records case was confirmed on its own, because the loop +stops at the first failure. **Rival:** the editing keys bound through the +welding `.commit` again fails both. + +### `typing_after_the_reopen_still_writes` + +After `i` and Enter from (1, 0), the reopened editor must hold `"2"`, the value +of (North, Revenue). Typing `5` and pressing Enter must then store `25`. Before +the fix the buffer was `""`, so the typed digit replaced the value instead of +extending it. diff --git a/crates/improvise-command/src/command/keymap.rs b/crates/improvise-command/src/command/keymap.rs index 300e4ef8..5c741c75 100644 --- a/crates/improvise-command/src/command/keymap.rs +++ b/crates/improvise-command/src/command/keymap.rs @@ -781,16 +781,20 @@ mod tests { for key in exits { match km.lookup(*key, none) { - Some(Binding::Sequence(steps)) => assert!( - steps.iter().any(|(n, a)| *n == "clear-buffer" - && a.as_slice() == [buffer.to_string()]), - "{mode:?}: {key:?} must clear \"{buffer}\", got {steps:?}" - ), + Some(Binding::Sequence(steps)) => { + assert!( + steps.iter().any(|(n, a)| *n == "clear-buffer" + && a.as_slice() == [buffer.to_string()]), + "{mode:?}: {key:?} must clear \"{buffer}\", got {steps:?}" + ) + } // A reopening commit owns its clear (improvise-fy9o): ask the command. Some(Binding::Cmd { name, args }) => { let dbg = clears_itself(&app_mode, name, args, &buffer); assert!( - dbg.contains(&format!("SetBuffer {{ name: \"{buffer}\", value: \"\" }}")), + dbg.contains(&format!( + "SetBuffer {{ name: \"{buffer}\", value: \"\" }}" + )), "{mode:?}: {key:?} runs {name} alone, which must clear \"{buffer}\": {dbg}" ); } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index cb1b9a34..2b1f95a8 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1897,10 +1897,21 @@ fn an_untouched_browser_edit_of_a_misread_value_writes_nothing() { #[test] fn enter_enter_leaves_every_cell_unchanged_on_both_ends() { let every_cell = |app: &App| -> Vec> { - [("North", "Cost"), ("North", "Revenue"), ("South", "Cost"), ("South", "Revenue")] - .iter() - .map(|(r, m)| app.model_state.workbook.model.get_cell(®ion_measure(r, m)).cloned()) - .collect() + [ + ("North", "Cost"), + ("North", "Revenue"), + ("South", "Cost"), + ("South", "Revenue"), + ] + .iter() + .map(|(r, m)| { + app.model_state + .workbook + .model + .get_cell(®ion_measure(r, m)) + .cloned() + }) + .collect() }; let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); client_host.cursor_mut().selected = (1, 0); @@ -1923,8 +1934,55 @@ fn enter_enter_leaves_every_cell_unchanged_on_both_ends() { "{key}: client and host disagree about the buffers" ); } - assert_eq!(every_cell(&client_host), before, "the browser's Enter, Enter wrote"); - assert_eq!(every_cell(&key_host), before, "the host's Enter, Enter wrote"); + assert_eq!( + every_cell(&client_host), + before, + "the browser's Enter, Enter wrote" + ); + assert_eq!( + every_cell(&key_host), + before, + "the host's Enter, Enter wrote" + ); +} + +/// A commit that reopens nothing still leaves no text behind, on either end: +/// the clear moved from the keymap into the commit (improvise-fy9o). +#[test] +fn a_commit_at_the_corner_leaves_no_buffer_behind_on_both_ends() { + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + assert_eq!( + key_host.cursor().selected, + (1, 1), + "the fixture is not at the corner" + ); + let mut client = client_ready_to_edit(&client_host, (2, 2)); + for key in ["i", "9", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + apply_on(&mut client_host, &cmds); + reduce_key(&mut key_host, event); + } + assert!( + !key_host.view_state.mode.is_editing(), + "the corner must not reopen" + ); + for (end, buffers) in [ + ("browser", &client.view.buffers), + ("host", &key_host.view_state.buffers), + ] { + assert_eq!( + buffers.get("edit").map(String::as_str), + Some(""), + "{end}: the committed text was left in the buffer" + ); + } } /// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`. From cee61b94b93d0d49cc7211c8913255ca4c4c5228 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:06:15 -0700 Subject: [PATCH 014/114] tooling: check-ci-graph fails when a checkers.sh suite runs in no CI step improvise-dak5: ci.yml names its checker suites by hand, so a new scripts/X-test.sh ran locally and never in CI. check-ci-graph.py now asks `checkers.sh --list` for the suites, so the naming rule keeps one home, and reads every checkers.sh invocation in ci.yml. A bare run covers every suite, --self-test covers `checkers`, and a YAML comment is never a run. A suite planted in the real tree fails the check, naming it, and adding it to the step clears it. Seven new self-test cases. Rivals: counting comments fails the comment case; answering "everything runs" fails five. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 11 +++ context/test-evidence/tooling.md | 31 +++++++++ scripts/check-ci-graph.py | 114 +++++++++++++++++++++++++++++-- 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index 6b09fc0f..37c5af36 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -259,6 +259,17 @@ the deploy's and pass. `README.md` therefore states the rule and deliberately **does not list the jobs**: a second copy with nothing holding it to the first is what went stale, twice. +It holds a second rule of the same kind: every suite `scripts/checkers.sh +--list` names must run in some CI step (improvise-dak5). `ci.yml` names its +suites by hand, because they need different jobs and environments (`kb` runs +under a nix trealla, `node-test` in the `js` job), so a single bare +`checkers.sh` was not an option. The check does not re-derive what a suite is. +It asks `checkers.sh --list`, so the naming rule keeps its one home. It then +reads every `checkers.sh` invocation in the workflow. A bare one covers every +suite, and `--self-test` covers `checkers`. A YAML comment is skipped, because a +bare mention there would otherwise read as "everything runs", which is the +failure that looks like success. + ### pr-watch and mergeability (improvise-edpl, improvise-zlj2) `scripts/pr-watch` is what the orchestrator's merge procedure stands on, and diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 3304bd5e..9d869c3e 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -763,3 +763,34 @@ to spare a harness directory. `opfs.spec.js` instead asserts `/opfs/index.html` is served, naming the fix. Injected by running `web/assemble.sh harness/serve` alone: 4 chromium failures, each carrying that message, where it used to fail with no hint of the cause. + +### Every checkers.sh suite runs in CI — `scripts/check-ci-graph.py` (improvise-dak5) + +**What was unguarded.** `ci.yml` names its `checkers.sh` suites by hand. A new +`scripts/X-test.sh` beside `scripts/X` is picked up by `checkers.sh` locally +and never runs in CI unless someone adds it to a step. Found on #226, where +`bd-audit-ids` had to be added by hand. + +**Planted in the real tree.** `scripts/zz-planted.sh` plus +`scripts/zz-planted-test.sh` appear in `checkers.sh --list`, and +`check-ci-graph.py` then exits 1 with *`zz-planted` is a checkers.sh suite that +no CI step runs; add it to ci.yml*. As a control, appending `zz-planted` to the +named step clears it (exit 0, "all 9 checkers.sh suites run in CI"). Both files +and the `ci.yml` edit were then removed, and the real run is back to 8 suites, +exit 0. + +**Seven self-test suite lists.** They cover: a suite no step runs; suites named +across several steps (one inside `nix shell … --command`); a bare run covering +everything; `--self-test` covering `checkers` and nothing else; a bare mention +in a YAML comment, which must *not* count as a bare run; names continued onto +the next line with a backslash; and a step naming a suite that does not exist. + +**Rivals, each run against `--self-test`:** +- **YAML comments no longer skipped:** exactly the comment case fails, because + the bare mention there reads as "everything runs" and the missing suite + passes. +- **`suites_run` always answering "everything"** (the vacuous shape): five + cases fail, every one that expects a suite to be named. + +The script was restored after each (`cmp` against a saved copy), and the +self-test is back to "5 graphs and 7 suite lists judged". diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index c3fc9911..bc17019f 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -6,10 +6,15 @@ README name three of seven while the rest read as ungated (improvise-x7cq). Stating it was not enough -- a new job was added during review of PR #83 and nothing noticed, so the rule is a gate. See context/code/tooling.md. +Second rule: every suite `scripts/checkers.sh --list` knows must run in some +CI step. ci.yml names its suites by hand, so a new `*-test.sh` ran locally and +never in CI until someone remembered to add it (improvise-dak5). + `--self-test` checks the checker: each case is a graph this must refuse. """ import re +import subprocess import sys WORKFLOW = ".github/workflows/ci.yml" @@ -60,6 +65,95 @@ def verdict(jobs, needs): return out +_CHECKERS = re.compile(r"(?:^|[\s/])scripts/checkers\.sh\b(.*)$") +ALL = object() + + +def suites_run(text): + """The suite names CI runs, or ALL when some step runs checkers.sh bare. + + A YAML comment is skipped: a bare mention there must not read as "every + suite runs". A trailing backslash continues onto the next line. + """ + lines = text.split("\n") + names, i = set(), 0 + while i < len(lines): + raw = lines[i] + i += 1 + if raw.lstrip().startswith("#"): + continue + m = _CHECKERS.search(raw) + if not m: + continue + rest = m.group(1) + while rest.rstrip().endswith("\\") and i < len(lines): + rest = rest.rstrip()[:-1] + " " + lines[i] + i += 1 + rest = re.split(r"&&|\|\||[;|#]", rest)[0] + args = rest.split() + if "--self-test" in args: + names.add("checkers") + picked = [a for a in args if not a.startswith("-")] + if not picked and "--self-test" not in args: + return ALL + names.update(picked) + return names + + +def suite_verdict(suites, ran): + """The complaints about suites CI never runs, or runs by a wrong name.""" + if ran is ALL: + return [] + out = [f"`{s}` is a checkers.sh suite that no CI step runs; add it to ci.yml" + for s in sorted(set(suites) - ran)] + out += [f"ci.yml runs checkers.sh `{s}`, which is not a suite" + for s in sorted(ran - set(suites))] + return out + + +def list_suites(): + """The suites, from checkers.sh itself, so its naming rule has one home.""" + out = subprocess.run(["scripts/checkers.sh", "--list"], capture_output=True, + text=True, check=True).stdout + return [s for s in out.split("\n") if s.strip()] + + +# (name, suites checkers.sh knows, workflow text, the complaints it must draw). +SUITE_SELF_TEST = [ + ("a suite no step runs is named", + ["kb", "zz-new"], " run: ./scripts/checkers.sh kb\n", + ["`zz-new` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + + ("every suite named across steps is accepted", + ["kb", "pr-watch", "checkers"], + " run: nix shell x --command ./scripts/checkers.sh kb\n" + " run: ./scripts/checkers.sh --self-test\n" + " run: ./scripts/checkers.sh pr-watch\n", []), + + ("a bare run covers every suite", + ["kb", "zz-new"], " run: ./scripts/checkers.sh\n", []), + + ("--self-test covers the checkers suite and nothing else", + ["checkers", "kb"], " run: ./scripts/checkers.sh --self-test\n", + ["`kb` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + + ("a bare mention in a comment does not count as running everything", + ["kb", "zz-new"], + " # Every suite goes through scripts/checkers.sh\n" + " run: ./scripts/checkers.sh kb\n", + ["`zz-new` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + + ("names continued onto the next line count, and are not a bare run", + ["kb", "pr-watch", "zz-new"], + " run: ./scripts/checkers.sh \\\n kb pr-watch\n", + ["`zz-new` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + + ("a step naming a suite that does not exist is caught", + ["kb"], " run: ./scripts/checkers.sh kb ghost\n", + ["ci.yml runs checkers.sh `ghost`, which is not a suite"]), +] + + # (name, workflow text, the complaints it must draw). SELF_TEST = [ ("a job nobody gates is named", @@ -95,28 +189,40 @@ def self_test(): got = verdict(jobs, needs) if got != want: failures.append(f" {name}: expected {want}, saw {got}") + for name, suites, text, want in SUITE_SELF_TEST: + got = suite_verdict(suites, suites_run(text)) + if got != want: + failures.append(f" {name}: expected {want}, saw {got}") if failures: print("ci-graph self-test FAILED:", file=sys.stderr) print("\n".join(failures), file=sys.stderr) return 1 - print(f"ci-graph self-test: {len(SELF_TEST)} graphs judged") + print(f"ci-graph self-test: {len(SELF_TEST)} graphs and " + f"{len(SUITE_SELF_TEST)} suite lists judged") return 0 def main(): if "--self-test" in sys.argv[1:]: return self_test() - jobs, needs = read_graph(open(WORKFLOW, encoding="utf8").read()) + text = open(WORKFLOW, encoding="utf8").read() + jobs, needs = read_graph(text) if len(jobs) < 2: print(f"error: {WORKFLOW}: found {len(jobs)} jobs; the parse is wrong", file=sys.stderr) return 2 - complaints = verdict(jobs, needs) + suites = list_suites() + if not suites: + print("error: checkers.sh --list named no suites; the listing is wrong", + file=sys.stderr) + return 2 + complaints = verdict(jobs, needs) + suite_verdict(suites, suites_run(text)) if complaints: for c in complaints: print(f"ci-graph: {c}", file=sys.stderr) return 1 - print(f"ci-graph: {DEPLOY} needs all {len(jobs) - 1} other jobs") + print(f"ci-graph: {DEPLOY} needs all {len(jobs) - 1} other jobs; " + f"all {len(suites)} checkers.sh suites run in CI") return 0 From a79ed50878977317abddeef27d8f715efc556a31 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:16:02 -0700 Subject: [PATCH 015/114] Pin the renames half of the qz9p check; say every staged edit was discarded (improvise-6r2y, improvise-o9q7) 6r2y: a sparse drill row with no Type coordinate that stages a Type rename after Type is removed reaches the unwritable check only through its renames. Red with .chain(renames.keys()) removed: an orphan Drink/Jan/Cost cell is written. o9q7: when any row is unwritable, merged keeps only the unwritable rows' reasons, as one refusal saying every staged edit was discarded, so a later writable row's blank-coordinate refusal cannot overwrite it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/command.md | 17 ++++- context/test-evidence/command.md | 41 ++++++++++++ crates/improvise-command/src/ui/effect.rs | 82 ++++++++++++++++++----- 3 files changed, 119 insertions(+), 21 deletions(-) diff --git a/context/code/command.md b/context/code/command.md index efd1995e..7c042044 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -997,7 +997,9 @@ coordinates nothing owns. The fix has two parts. the plainer case. A **measure edit alone** on such a row stages no `AddItem`, so nothing refuses, and it still writes an orphan cell keyed under the removed category. `RecordRewrite::of` therefore asks the model whether every category - the row names still exists, both its coordinates and its renames. If any is + the row names still exists, both its coordinates and its renames (a sparse + row with no coordinate for a category it renames reaches the check only + through the renames; improvise-6r2y). If any is gone, the row is `unwritable`: it refuses with `Unknown category '…'` and carries no writes. This is ADR 001's "force never creates structure", applied inside the drill. @@ -1005,8 +1007,17 @@ coordinates nothing owns. The fix has two parts. (`design-principles.md` §4: "`ApplyAndClearDrill` writes them all atomically … No partial writes reach the model"). A per-row skip would commit the other rows' edits and drop this one's, a partial write of exactly the kind that - contract rules out. `merged` therefore keeps only the refusals when any row is - unwritable. + contract rules out. So when any row is unwritable, `merged` writes nothing. +- **What the user is told (improvise-o9q7).** `merged` first kept every row's + refusals. Refusals are applied in row order and each one overwrites the + status line, so a later writable row's own refusal, such as a blank + coordinate, replaced the one that mattered. The user read "Record coordinates + cannot be empty" when in fact every staged edit had been thrown away. So when + any row is unwritable, `merged` keeps only the unwritable rows' reasons and + combines them into one refusal that says so: + `Unknown category 'Type': every staged edit was discarded`. Sibling refusals + are dropped because nothing those rows staged is written, so their reasons + describe edits that are no longer pending. - **`refuse`, not `refuse_and_stop`.** This follows #181's rule (gotcha 21): abort only where a later effect in the batch assumes the failed one happened. The effect after `ApplyAndClearDrill` in `view-back`'s batch is `ViewBack`, diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 4ccf6b91..c9f8f2dd 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -5629,3 +5629,44 @@ no `Type` coordinate at all, so it stays writable after `Type` is removed. Both rows stage a `Cost`. The expected store is row 1's untouched `-/Jan/Cost = 17`. Rival A skips only the unwritable row, and it fails this test and nothing else in the workspace, writing row 1's `999`. Rival B fails here too. + +## `ui/effect.rs` — the renames half and the refusal wording of the qz9p check (improvise-6r2y, improvise-o9q7) + +### a_sparse_drill_row_renamed_into_a_removed_category_is_refused_and_writes_nothing + +improvise-6r2y. The qz9p check asks whether every category a row names still +exists, and it collects those names from **two** sources: the row's coordinates +and the categories it renames. Before this test, deleting the renames half +(`.chain(renames.keys().copied())`) left the lib green. The rename tests all +use rows that already carry a `Type` coordinate, so the coordinates half caught +them anyway. + +This test uses the one row that reaches the renames half only: a sparse record +`Jan/Cost = 17` with no `Type` coordinate, which stages `Type → Drink` after +`Type` is removed. + +Fault injected: the `.chain(...)` line commented out. The test failed on the +store assertion, because the commit wrote an orphan `Drink/Jan/Cost = 17`. The +refusal assertion alone would not have caught it: `AddItem` into the missing +category refuses by itself, so `refused` is true under the fault. The store +assertion is what makes the test work. + +### an_unwritable_drill_commit_reports_the_discard_not_a_later_rows_refusal + +improvise-o9q7. Row 0 names the removed `Type` and stages `Cost = 5`. Row 1, the +sparse record, stages a blank `Month`. `merged` used to keep both rows' +refusals. They were applied in row order, so the status line ended on row 1's +"Record coordinates cannot be empty". That hid the fact that the whole commit +had been discarded. + +This test was red before the fix: the status line was exactly +`Record coordinates cannot be empty`. It asserts three things: +- the status names `Unknown category 'Type'`; +- the status says `every staged edit was discarded`; +- the status does **not** carry the blank-coordinate refusal. + +Reading from those three assertions, not by injecting them, each of these +partial fixes would fail the test: +- one that only rewords the message; +- one that only filters out the writable rows' refusals; +- one that emits the combined refusal first and the siblings' refusals after it. diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index 1e069ecb..fd7662bf 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -803,21 +803,32 @@ impl RecordRewrite { /// Several rows' rewrites as one: every clear before any write, the later row winning /// a shared cell, nothing written if any row is unwritable. `context/code/command.md`. pub(crate) fn merged(rewrites: impl IntoIterator) -> Self { - let all = rewrites.into_iter().fold(Self::default(), |mut all, one| { - all.refusals.extend(one.refusals); - all.unwritable |= one.unwritable; - all.items.extend(one.items); - all.clears.extend(one.clears); - all.sets.extend(one.sets); - all - }); - match all.unwritable { - true => Self { - refusals: all.refusals, - unwritable: true, - ..Self::default() - }, - false => all, + let rewrites: Vec = rewrites.into_iter().collect(); + match rewrites.iter().any(|one| one.unwritable) { + true => Self::discarded(rewrites.into_iter().filter(|one| one.unwritable)), + false => rewrites.into_iter().fold(Self::default(), |mut all, one| { + all.refusals.extend(one.refusals); + all.items.extend(one.items); + all.clears.extend(one.clears); + all.sets.extend(one.sets); + all + }), + } + } + + /// One refusal naming every gone category and saying the whole commit was dropped, + /// so no writable row's refusal can overwrite it (improvise-o9q7). + fn discarded(unwritable: impl Iterator) -> Self { + let why: std::collections::BTreeSet = + unwritable.flat_map(|one| one.refusals).collect(); + let why: Vec = why.into_iter().collect(); + Self { + refusals: vec![format!( + "{}: every staged edit was discarded", + why.join("; ") + )], + unwritable: true, + ..Self::default() } } @@ -3514,8 +3525,9 @@ mod tests { assert_eq!(store, std::collections::BTreeSet::new(), "no orphan cell"); } - #[test] - fn a_drill_commit_with_one_unwritable_row_writes_none_of_its_rows() { + /// A drill over `Food/Jan/Cost = 2` (row 0) and a sparse `Jan/Cost = 17` naming no + /// Type (row 1). + fn typed_and_untyped_drill() -> App { let mut app = test_app(); let typed = drill_cell("Food", "Jan", "Cost"); let untyped = CellKey::new(vec![ @@ -3533,9 +3545,43 @@ mod tests { assert_eq!(records.len(), 2); assert_eq!(records[1].coords.get("Type"), None, "row 1 names no Type"); StartDrill(records).apply(&mut app); + app + } + #[test] + fn a_sparse_drill_row_renamed_into_a_removed_category_is_refused_and_writes_nothing() { let (store, refused) = commit_after_removing_type( - app, + typed_and_untyped_drill(), + &[(1, category("Type"), "Drink")], + ); + assert!(refused, "the commit must say it could not be applied"); + assert_eq!( + store, + ["-/Jan/Cost = 17".to_string()].into_iter().collect(), + "row 1 names Type only through its rename; no orphan Drink cell" + ); + } + + #[test] + fn an_unwritable_drill_commit_reports_the_discard_not_a_later_rows_refusal() { + let mut app = typed_and_untyped_drill(); + app.model_state.workbook.remove_category("Type"); + stage(&mut app, 0, measure("Cost"), "5"); + stage(&mut app, 1, category("Month"), ""); + app.apply_effects(vec![Box::new(ApplyAndClearDrill)]); + let status = &app.view_state.status_msg; + assert!(status.contains("Unknown category 'Type'"), "{status}"); + assert!( + status.contains("every staged edit was discarded"), + "{status}" + ); + assert!(!status.contains(RECORD_COORDS_CANNOT_BE_EMPTY), "{status}"); + } + + #[test] + fn a_drill_commit_with_one_unwritable_row_writes_none_of_its_rows() { + let (store, refused) = commit_after_removing_type( + typed_and_untyped_drill(), &[(0, measure("Cost"), "5"), (1, measure("Cost"), "999")], ); assert!(refused, "the commit must say it could not be applied"); From c1417359776ba212fa6e987f58ac8a8c61e64ff1 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:36:13 -0700 Subject: [PATCH 016/114] tooling: check-ci-graph asks checkers.sh what a step runs (improvise-maax) The #228 review and Codex's P1 found the suite check failing open. A bare scripts/checkers.sh after an inline comment, in a step name or as echo's argument read as "every suite runs". --list alone read as every suite, and --self-test's discarded names, and --list's, counted as run. checkers.sh gains --would-run, honoured only as the first argument. It reports what the rest of the arguments would run, from the real argument loop and name resolution. check-ci-graph.py only finds invocations: in run: text, comments stripped by bash's rule, command position, every command on the line, quotes removed. It then asks --would-run about each. An unparseable line contributes nothing and fails closed. 16 suite self-test cases, 8 of which the old parser gets wrong. Six rivals each fail a case only they can. checkers-test.sh gains four --would-run cases. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 22 +++- context/test-evidence/tooling.md | 59 +++++++++ scripts/check-ci-graph.py | 215 +++++++++++++++++++++++-------- scripts/checkers-test.sh | 29 +++++ scripts/checkers.sh | 30 +++-- 5 files changed, 287 insertions(+), 68 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index 37c5af36..5f1aa084 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -264,11 +264,23 @@ It holds a second rule of the same kind: every suite `scripts/checkers.sh suites by hand, because they need different jobs and environments (`kb` runs under a nix trealla, `node-test` in the `js` job), so a single bare `checkers.sh` was not an option. The check does not re-derive what a suite is. -It asks `checkers.sh --list`, so the naming rule keeps its one home. It then -reads every `checkers.sh` invocation in the workflow. A bare one covers every -suite, and `--self-test` covers `checkers`. A YAML comment is skipped, because a -bare mention there would otherwise read as "everything runs", which is the -failure that looks like success. +It asks `checkers.sh --list`, so the naming rule keeps its one home. + +**Nor does it re-derive what an invocation runs** (improvise-maax). The first +version did, and got `checkers.sh`'s argument rules wrong in the direction that +passes. It read `--list` as "run everything", when `--list` runs nothing. It +counted the names after `--self-test`, which `checkers.sh` throws away. And it +read any mention of `scripts/checkers.sh` as a command: in a trailing comment, +a step `name:`, or `echo`'s argument. Now `checkers.sh --would-run ` +answers what `` would run, from the same argument loop and name +resolution the real run uses. `--would-run` is honoured only as the first +argument, so a CI step that spells it anywhere is a usage error rather than a +green step that ran nothing. The check itself only finds invocations. It reads +the shell text of `run:` values alone, strips a comment by bash's rule (a `#` +at the start of a word, outside quotes, so `nixpkgs#trealla` survives), splits +at `;`, `&&` and `|`, and counts a command only when `checkers.sh` is its +command word or what `nix shell … --command` runs. A line `shlex` cannot parse +contributes nothing, which fails closed. ### pr-watch and mergeability (improvise-edpl, improvise-zlj2) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 8d321b3a..c05731ec 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -841,3 +841,62 @@ the next line with a backslash; and a step naming a suite that does not exist. The script was restored after each (`cmp` against a saved copy), and the self-test is back to "5 graphs and 7 suite lists judged". + +### The suite check asks checkers.sh what a step runs — `scripts/check-ci-graph.py` (improvise-maax) + +**What broke.** The adversarial review of #228 at `cee61b94`, and ChatGPT +Codex's P1 on it, found the first version failing open in four shapes. Each let +the check pass while a suite never ran: +- a bare `scripts/checkers.sh` outside a command read as "every suite runs": + after an inline `#` (Codex), in a step `name:`, or as `echo`'s argument; +- `checkers.sh --list` alone read as every suite, though `--list` runs nothing; +- `--self-test zz-new kb` counted `zz-new` and `kb`, though `--self-test` + discards them; +- `--list kb zz-new` counted both. + +Two more were loud but wrong: only the first invocation on a line counted, and +quoted names kept their quotes. + +**The fix moves the argument rules back into `checkers.sh`.** It gains +`--would-run`, which is honoured only as the first argument and reports rather +than runs. `check-ci-graph.py` finds invocations: `run:` text only, comments +stripped by bash's rule, command position only. It then asks `--would-run` +about each one. + +**Every new case run against the OLD parser** (`/tmp/ccg-old.py`, the file at +`cee61b94`): eight of the sixteen suite cases give the wrong answer there. The +five false passes are `--list` alone, the inline comment (Codex), the non-run +block scalar, the step `name:` and `echo`'s argument. Wrong in a noisier way: +`--self-test zz-new kb` (the old parser counts `kb`, so it reports only a +bogus "zz-new is not a suite"), `--list kb zz-new` (the same), and `a && b` +(the second suite is missed). + +**A vacuity fixed before the rivals ran.** As first written, the bare-mention +cases required `zz-new`, which no real `checkers.sh` ever runs. A false bare +run then still left exactly `zz-new` unrun, the expected answer, so the +any-word-position rival passed all sixteen. The cases now require `kb` and +`pr-watch`, and the real step runs only `kb`. A false bare run is then the only +way `pr-watch` counts as run. + +**Rivals, one function swapped in at a time and each run against the real +`--self-test`:** +- **no comment stripping:** fails only "a suite named inside a trailing shell + comment is not run"; +- **every line scanned** (run: prefixes still recognised): fails "a + command-shaped line in a non-run block scalar" and the block-run case; +- **`checkers.sh` accepted in any word position:** fails only "a bare mention + as echo's argument"; +- **argv modelled naively** (names, else everything): fails the `--self-test` + case and both `--list` cases; +- **first invocation per line only:** fails "every invocation on a line + counts" and the block-run case; +- **`--would-run` honoured in any position**, in `checkers.sh` itself: fails + `checkers-test.sh`'s "--would-run anywhere but first is a usage error" (exit + 0, wanted 4) and the parser's "a step spelling --would-run runs nothing". + +One more rival crashed instead: an early every-line rival hit `shlex`'s +"No escaped character" on a lone trailing backslash. So a line that cannot be +parsed now contributes no invocation, which fails closed. Everything was +restored (`cmp` for `checkers.sh`) and re-run: `--self-test` reports 16 suite +lists and `checkers-test.sh` 17/17. A planted `zz-planted` suite still fails +the real run, naming it. diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index bc17019f..b70048eb 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -8,12 +8,14 @@ nothing noticed, so the rule is a gate. See context/code/tooling.md. Second rule: every suite `scripts/checkers.sh --list` knows must run in some CI step. ci.yml names its suites by hand, so a new `*-test.sh` ran locally and -never in CI until someone remembered to add it (improvise-dak5). +never in CI until someone remembered to add it (improvise-dak5). What a step +runs is asked of `checkers.sh --would-run`, never re-derived (improvise-maax). `--self-test` checks the checker: each case is a graph this must refuse. """ import re +import shlex import subprocess import sys @@ -65,49 +67,116 @@ def verdict(jobs, needs): return out -_CHECKERS = re.compile(r"(?:^|[\s/])scripts/checkers\.sh\b(.*)$") -ALL = object() +_RUN = re.compile(r"^(\s*)(?:-\s+)?run:\s?(.*)$") +_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") +_OPERATOR = set(";&|()") + + +def run_scripts(text): + """The shell text of every `run:` value. Nothing else in ci.yml is a + command, so a step `name:` or a YAML comment never reads as one.""" + lines, out, i = text.split("\n"), [], 0 + while i < len(lines): + m = _RUN.match(lines[i]) + i += 1 + if not m: + continue + indent, value = len(m.group(1)), m.group(2).strip() + if value[:1] not in ("|", ">"): + out.append(value) + continue + body = [] + while i < len(lines) and (not lines[i].strip() or + len(lines[i]) - len(lines[i].lstrip()) > indent): + body.append(lines[i]) + i += 1 + out.append("\n".join(body)) + return out + + +def strip_comment(line): + """Bash's rule: `#` opens a comment only at the start of a word and outside + quotes, so `nixpkgs#trealla` survives and `echo x # checkers.sh` does not.""" + quote, prev = None, " " + for j, c in enumerate(line): + if quote: + if c == quote: + quote = None + elif c in "'\"": + quote = c + elif c == "#" and (prev.isspace() or prev in _OPERATOR): + return line[:j] + prev = c + return line + + +def simple_commands(script): + """Each simple command in a script, as its words with quotes removed.""" + for line in script.replace("\\\n", " ").split("\n"): + lexer = shlex.shlex(strip_comment(line), posix=True, punctuation_chars=True) + lexer.whitespace_split = True + lexer.commenters = "" + try: + tokens = list(lexer) + except ValueError: + continue # unparseable: no invocation, so it fails closed + words = [] + for tok in tokens: + if set(tok) <= _OPERATOR: + if words: + yield words + words = [] + else: + words.append(tok) + if words: + yield words + + +def checkers_invocations(text): + """The arguments of every command that IS checkers.sh: the command word + after any VAR=value, or the program `nix shell ... --command` runs.""" + for script in run_scripts(text): + for words in simple_commands(script): + k = 0 + while k < len(words) and _ASSIGN.match(words[k]): + k += 1 + if "--command" in words[k:]: + k = words.index("--command", k) + 1 + if k < len(words) and words[k].endswith("scripts/checkers.sh"): + yield words[k + 1:] + + +def would_run(args): + """What checkers.sh itself says these arguments run. Its argument rules are + not restated here: --list and --self-test mean what the script makes them.""" + out = subprocess.run(["scripts/checkers.sh", "--would-run", *args], + capture_output=True, text=True).stdout + ran, missing = set(), set() + for line in out.split("\n"): + if line.startswith("would-run: "): + ran.add(line[len("would-run: "):]) + elif line.startswith("missing: "): + missing.add(line[len("missing: "):]) + return ran, missing def suites_run(text): - """The suite names CI runs, or ALL when some step runs checkers.sh bare. - - A YAML comment is skipped: a bare mention there must not read as "every - suite runs". A trailing backslash continues onto the next line. - """ - lines = text.split("\n") - names, i = set(), 0 - while i < len(lines): - raw = lines[i] - i += 1 - if raw.lstrip().startswith("#"): - continue - m = _CHECKERS.search(raw) - if not m: - continue - rest = m.group(1) - while rest.rstrip().endswith("\\") and i < len(lines): - rest = rest.rstrip()[:-1] + " " + lines[i] - i += 1 - rest = re.split(r"&&|\|\||[;|#]", rest)[0] - args = rest.split() - if "--self-test" in args: - names.add("checkers") - picked = [a for a in args if not a.startswith("-")] - if not picked and "--self-test" not in args: - return ALL - names.update(picked) - return names + """(suites some step runs, names a step asks for that are not suites).""" + ran, missing = set(), set() + for args in checkers_invocations(text): + r, m = would_run(args) + ran |= r + missing |= m + return ran, missing -def suite_verdict(suites, ran): - """The complaints about suites CI never runs, or runs by a wrong name.""" - if ran is ALL: - return [] +def suite_verdict(suites, run): + """The complaints about suites CI never runs, or asks for by a wrong name.""" + ran, missing = run out = [f"`{s}` is a checkers.sh suite that no CI step runs; add it to ci.yml" for s in sorted(set(suites) - ran)] out += [f"ci.yml runs checkers.sh `{s}`, which is not a suite" - for s in sorted(ran - set(suites))] + for s in sorted(missing)] return out @@ -118,35 +187,71 @@ def list_suites(): return [s for s in out.split("\n") if s.strip()] -# (name, suites checkers.sh knows, workflow text, the complaints it must draw). +# (name, suites to require, workflow text, the complaints it must draw). Every +# case asks the REAL checkers.sh. A false "bare run" must be the ONLY way pr-watch runs. +_UNRUN = "`zz-new` is a checkers.sh suite that no CI step runs; add it to ci.yml" +_NO_PW = "`pr-watch` is a checkers.sh suite that no CI step runs; add it to ci.yml" +_KB_PW = ["kb", "pr-watch", "zz-new"] +_REQ = ["kb", "pr-watch"] +_ONLY_KB = " run: ./scripts/checkers.sh kb\n" SUITE_SELF_TEST = [ ("a suite no step runs is named", - ["kb", "zz-new"], " run: ./scripts/checkers.sh kb\n", - ["`zz-new` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + _KB_PW, " run: ./scripts/checkers.sh kb pr-watch\n", [_UNRUN]), - ("every suite named across steps is accepted", + ("suites named across steps, one inside nix shell --command, are accepted", ["kb", "pr-watch", "checkers"], - " run: nix shell x --command ./scripts/checkers.sh kb\n" + " run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh kb\n" " run: ./scripts/checkers.sh --self-test\n" - " run: ./scripts/checkers.sh pr-watch\n", []), + " - run: ./scripts/checkers.sh pr-watch\n", []), - ("a bare run covers every suite", - ["kb", "zz-new"], " run: ./scripts/checkers.sh\n", []), + ("a bare run covers every real suite", + ["kb", "pr-watch", "checkers"], " run: ./scripts/checkers.sh\n", []), - ("--self-test covers the checkers suite and nothing else", - ["checkers", "kb"], " run: ./scripts/checkers.sh --self-test\n", + ("--self-test runs checkers and DISCARDS the names beside it", + ["checkers", "kb"], " run: ./scripts/checkers.sh --self-test zz-new kb\n", ["`kb` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), - ("a bare mention in a comment does not count as running everything", - ["kb", "zz-new"], - " # Every suite goes through scripts/checkers.sh\n" - " run: ./scripts/checkers.sh kb\n", - ["`zz-new` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + ("--list alone runs nothing", + ["kb"], " run: ./scripts/checkers.sh --list\n", + ["`kb` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), - ("names continued onto the next line count, and are not a bare run", - ["kb", "pr-watch", "zz-new"], - " run: ./scripts/checkers.sh \\\n kb pr-watch\n", - ["`zz-new` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + ("--list with names runs nothing either", + ["kb"], " run: ./scripts/checkers.sh --list kb zz-new\n", + ["`kb` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + + ("a bare mention after an inline shell comment is not a run (Codex, #228)", + _REQ, " run: echo skipped # ./scripts/checkers.sh\n" + _ONLY_KB, [_NO_PW]), + + ("a suite named inside a trailing shell comment is not run", + _REQ, " run: ./scripts/checkers.sh kb # pr-watch\n", [_NO_PW]), + + ("a command-shaped line in a non-run block scalar is not a run", + _REQ, + " - name: notes\n with:\n notes: |\n" + " ./scripts/checkers.sh\n " + _ONLY_KB, [_NO_PW]), + + ("a bare mention in a step name is not a run", + _REQ, " - name: Runs scripts/checkers.sh\n " + _ONLY_KB, [_NO_PW]), + + ("a bare mention as echo's argument is not a run", + _REQ, " run: echo scripts/checkers.sh\n" + _ONLY_KB, [_NO_PW]), + + ("a bare mention in a YAML comment is not a run", + _REQ, " # Every suite goes through scripts/checkers.sh\n" + _ONLY_KB, + [_NO_PW]), + + ("every invocation on a line counts, and quotes are removed", + _REQ, + " run: ./scripts/checkers.sh kb && ./scripts/checkers.sh 'pr-watch'\n", []), + + ("a block run is read, continuation lines included", + _REQ, + " run: |\n set -e\n ./scripts/checkers.sh \\\n" + " kb pr-watch\n - name: next\n", []), + + ("a step spelling --would-run runs nothing, wherever it puts it", + _REQ, _ONLY_KB + " run: ./scripts/checkers.sh pr-watch --would-run\n" + " run: ./scripts/checkers.sh --would-run pr-watch\n", [_NO_PW]), ("a step naming a suite that does not exist is caught", ["kb"], " run: ./scripts/checkers.sh kb ghost\n", diff --git a/scripts/checkers-test.sh b/scripts/checkers-test.sh index 01995e07..6c9b8c37 100755 --- a/scripts/checkers-test.sh +++ b/scripts/checkers-test.sh @@ -176,5 +176,34 @@ listed="$(tr '\n' ' ' < "$work/out")" && ok "an X-test.sh with no X beside it is not a suite" \ || bad "an X-test.sh with no X beside it is not a suite" "got [$listed]" +# --------------------------------------------------------------------------- +# 8. --would-run reports what the rest of the arguments run, and runs nothing. +# check-ci-graph.py asks it, so each argument rule below is answered HERE. +d="$(sandbox wouldrun)" +for n in alpha beta; do + suite "$d" "$n" < "$work/out" 2>&1 +got="$(tr '\n' ' ' < "$work/out")" +[ "$got" = "would-run: alpha would-run: beta " ] && [ ! -e "$work/ran-alpha" ] \ + && ok "--would-run with no names reports every suite and runs none" \ + || bad "--would-run with no names reports every suite and runs none" "got [$got]" +"$d/checkers.sh" --would-run --self-test alpha > "$work/out" 2>&1 +got="$(tr '\n' ' ' < "$work/out")" +[ "$got" = "missing: checkers " ] \ + && ok "--would-run --self-test reports checkers and discards the names" \ + || bad "--would-run --self-test reports checkers and discards the names" "got [$got]" +"$d/checkers.sh" --would-run --list alpha > "$work/out" 2>&1 +grep -q '^would-run:' "$work/out" \ + && bad "--would-run --list reports nothing as run" "$(tr '\n' ' ' < "$work/out")" \ + || ok "--would-run --list reports nothing as run" +expect "--would-run anywhere but first is a usage error" 4 "$d/checkers.sh" alpha --would-run +[ ! -e "$work/ran-alpha" ] && ok "and it runs nothing" \ + || bad "and it runs nothing" "alpha ran" + printf '\ncheckers-test: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] diff --git a/scripts/checkers.sh b/scripts/checkers.sh index f598cfdc..49b5c06a 100755 --- a/scripts/checkers.sh +++ b/scripts/checkers.sh @@ -27,6 +27,10 @@ usage: checkers.sh [--list] [--self-test] [name ...] --list print the suite names and exit --self-test run scripts/checkers-test.sh, which injects the faults this runner exists to catch + --would-run as the FIRST argument only: run nothing, and print + `would-run: ` (or `missing: `) for each suite + the rest of the arguments would run. check-ci-graph.py + asks this, so the argument rules have one home exit 0 every suite passed exit 1 a suite failed, or a shebang is not `#!/usr/bin/env bash` @@ -107,8 +111,10 @@ run_suite() { } main() { - local self_test=0 + local self_test=0 would_run=0 local -a names=() + # Only as the FIRST argument, so a CI step spelling it anywhere is an error. + if [ "${1:-}" = --would-run ]; then would_run=1; shift; fi while [ $# -gt 0 ]; do case "$1" in --list) suites; return 0 ;; @@ -120,6 +126,21 @@ main() { shift done + if [ "$self_test" = 1 ]; then + names=(checkers) + elif [ ${#names[@]} -eq 0 ]; then + while IFS= read -r line; do names+=("$line"); done < <(suites) + fi + + local n file + if [ "$would_run" = 1 ]; then + for n in ${names[@]+"${names[@]}"}; do + if [ -f "$HERE/$n-test.sh" ]; then printf 'would-run: %s\n' "$n" + else printf 'missing: %s\n' "$n"; fi + done + return 0 + fi + local pinned; pinned="$(find_modern_bash)" if [ -z "$pinned" ]; then echo 'checkers: no bash >= 5 on PATH and nix cannot supply one.' >&2 @@ -132,13 +153,6 @@ main() { local rc=0 check_shebangs || rc=1 - if [ "$self_test" = 1 ]; then - names=(checkers) - elif [ ${#names[@]} -eq 0 ]; then - while IFS= read -r line; do names+=("$line"); done < <(suites) - fi - - local n file for n in ${names[@]+"${names[@]}"}; do file="$HERE/$n-test.sh" if [ ! -f "$file" ]; then From 85c483bd24b54f0d5356e23cbecf936c2127119e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:38:25 -0700 Subject: [PATCH 017/114] Tab at the rightmost column reopens on the value just written; pin the +cat clear (improvise-3tt4, improvise-5wai) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/clients.md | 37 +++++++++ crates/improvise-protocol/src/resolve.rs | 26 ++++-- .../improvise-wasm-client/tests/lockstep.rs | 83 +++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 826f8cd4..cb64a664 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5035,3 +5035,40 @@ where nothing reopens. At the bottom-right corner, `i`, `9`, Enter must leave `edit` as `""` on both ends, with the mode no longer editing. **Rival:** the browser without `cleared` leaves `"49"` and fails here. The equivalent TUI rival fails `text_entry_modes_clear_the_buffer_they_type_into`. + +### `tab_at_the_rightmost_column_reopens_on_the_value_just_written` + +**improvise-3tt4**, found by the #227 review at `a1b23c05`. `resolve::advanced` +re-seeded the reopened editor from the client's cache, on the argument that the +advance always lands on a *different* cell. That is false for Tab at the +rightmost column: `Advance::Right` clamps back onto the cell the frame has just +written, and that cell's cache entry is the pre-write value. + +The test uses the lockstep 2×2 fixture and starts at (0, 1), which is North / +Revenue and holds 2. It presses `i 9 Tab 5 Enter` on a browser client and on a +key-driven host, and compares the buffers after every key. It failed at +`a1b23c05` on `Tab`: the browser buffer was `"2"` and the host's was `"29"`. +Pressing `5 Enter` would then have written 25 over the 29 the user had just +committed. Both ends must end at 295. + +**Fix:** `landed_seed` seeds the landing cell from the committed value's edit +text when the advance lands on the cursor's own cell and the commit wrote. In +every other case it uses the cache. No other advance lands on the written cell: +`Down` moves to the next row or the next column, or reopens nothing at the +corner. + +### `tab_at_the_rightmost_column_reopens_on_the_stored_spelling` + +This pins what "the committed value" means in the fix above: the stored value's +edit text, as `typed()` would store it, not the raw typed string. `i Backspace 0 +0 7 Tab` at (0, 1) must leave `"7"` in both buffers. **Rival:** seeding from the +raw text fails with `"007"` against the host's `"7"`. + +### `a_category_committed_at_plus_cat_leaves_no_buffer_behind_on_both_ends` + +**improvise-5wai**, found by the #227 review. `+cat` reopens nothing, so the +commit's own head clear (`resolve::cleared` in `create_column`) is the only +thing that empties the buffer. The test types `Season` at the `+cat` column of +the records pair, presses Enter on both ends, and requires the category to have +landed and both buffers to be `""`. **Rival:** `let _ = cleared(&edit_mode)` in +`create_column` fails only this test, with the browser keeping `"Season"`. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 330a37e9..2659b1f9 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -898,7 +898,8 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option Option> { +fn advanced( + state: &ClientState, + edit_mode: AppMode, + advance: Advance, + wrote: Option<&str>, +) -> Option> { let GridExtent { rows, cols } = state.grid.extent; let row_max = rows.saturating_sub(1); let col_max = cols.saturating_sub(1); @@ -983,12 +989,20 @@ fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option return Some(Vec::new()); }; let mut commands = scrolled_to(state, nr, nc); - // `EnterEditAtCursor`: seed from where we landed, then change mode. The - // new cell is a different cell, so a stale cache is correct here. - commands.extend(opened_on(seed_at(state, nr, nc), edit_mode)); + // `EnterEditAtCursor`: seed from where we landed, then change mode. + commands.extend(opened_on(landed_seed(state, (nr, nc), wrote), edit_mode)); Some(commands) } +/// The seed at the cell an advance lands on. The cache is stale only where the +/// frame just wrote, which is the cursor's own cell (improvise-3tt4). +fn landed_seed(state: &ClientState, (row, col): (usize, usize), wrote: Option<&str>) -> String { + match wrote.filter(|_| state.view.cursor().selected == (row, col)) { + Some(text) => typed(text).map(|v| v.edit_text()).unwrap_or_default(), + None => seed_at(state, row, col), + } +} + /// The surrogate naming the record under the cursor, or `None` when the /// cursor is not on a writable cell. fn record_at_cursor(state: &ClientState) -> Option { diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 89353591..ae224fe8 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1680,6 +1680,30 @@ fn a_locally_committed_creating_column_agrees_with_the_host() { } } +/// improvise-5wai: `+cat` reopens nothing, so its commit's own clear is all +/// that empties the buffer, on both ends. Evidence file. +#[test] +fn a_category_committed_at_plus_cat_leaves_no_buffer_behind_on_both_ends() { + let (mut app, mut twin) = records_pair(); + let client = furnished_client(&app, (12, 12)); + let at = creating_cell(&client, improvise_protocol::CreateKind::Category); + let mut client = client; + typing_at(&mut app, &mut twin, &mut client, at, "Season"); + client = furnished_client(&app, (12, 12)); + client.view = app.view_state.clone(); + assert_eq!(read_edit(&client.view.buffers), "Season", "nothing was typed, so this proves nothing"); + + both_ends(&mut app, &mut twin, &mut client, "Enter"); + + assert!(document_text(&app).contains("Season"), "the commit did not land"); + assert_eq!(read_edit(&app.view_state.buffers), "", "the host left the name behind"); + assert_eq!(read_edit(&client.view.buffers), "", "the browser left the name behind"); +} + +fn read_edit(buffers: &std::collections::HashMap) -> &str { + buffers.get("edit").map(String::as_str).unwrap_or("") +} + /// The twin of the commit test above, for the door rather than the exit /// (improvise-bd9o). Evidence file. #[test] @@ -2228,6 +2252,65 @@ fn a_commit_at_the_corner_leaves_no_buffer_behind_on_both_ends() { } } +/// BUG (improvise-3tt4) — see `context/test-evidence/clients.md`. +#[test] +fn tab_at_the_rightmost_column_reopens_on_the_value_just_written() { + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (0, 1); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (0, 1); + let mut client = client_ready_to_edit(&client_host, (2, 2)); + for key in ["i", "9", "Tab", "5", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + apply_on(&mut client_host, &cmds); + reduce_key(&mut key_host, event); + assert_eq!( + client.view.buffers, key_host.view_state.buffers, + "{key}: client and host disagree about the buffers" + ); + } + for (end, app) in [("browser", &client_host), ("host", &key_host)] { + assert_eq!( + app.model_state + .workbook + .model + .get_cell(®ion_measure("North", "Revenue")), + Some(&improvise_protocol::CellValue::Number(295.0)), + "{end}: the reopened editor did not extend the value Tab had just written" + ); + } +} + +/// The reopened seed is the STORED value's edit text, not the typed string: +/// `007` is stored as 7 on both ends (improvise-3tt4). Evidence file. +#[test] +fn tab_at_the_rightmost_column_reopens_on_the_stored_spelling() { + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (0, 1); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (0, 1); + let mut client = client_ready_to_edit(&client_host, (2, 2)); + for key in ["i", "Backspace", "0", "0", "7", "Tab"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key locally"); + }; + for cmd in &cmds { + client.fold(cmd); + } + apply_on(&mut client_host, &cmds); + reduce_key(&mut key_host, event); + } + assert_eq!(key_host.view_state.buffers.get("edit").map(String::as_str), Some("7")); + assert_eq!(client.view.buffers, key_host.view_state.buffers); +} + /// BUG (improvise-fy9o) — see `context/test-evidence/clients.md`. #[test] fn typing_into_the_reopened_editor_extends_the_cell_on_both_ends() { From 3475b029560d4c5417cc3f977fe5470fffe60ea4 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:39:57 -0700 Subject: [PATCH 018/114] improvise-9trl: pin the remove-formula refusal and its exit code A script that removes a formula that is not there now refuses and exits 1, where it used to exit 0. The None arm had no test, so reverting it to a silent no-op left the suite green. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/cli.md | 10 +++++++ context/test-evidence/command.md | 10 +++++++ .../src/command/cmd/effect_cmds.rs | 28 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/context/code/cli.md b/context/code/cli.md index 0200feca..ba99b444 100644 --- a/context/code/cli.md +++ b/context/code/cli.md @@ -319,3 +319,13 @@ are listed in improvise-i3o5. `q` on a dirty document is deliberately left a report: headless, its text is false — the CLI saves anyway (improvise-ep5o). The two rivals udt2 named both stay green: a successful `:w` and a successful `fit-measure` report through `set_status` and exit 0. + +**`remove-formula` of a formula that is not there now exits 1** (improvise-tej1, +improvise-9trl). It used to exit 0: the effect was keyed by names and removing +nothing was a silent no-op. Since tej1, `RemoveFormula` names its formula by +`FormulaId`, and `remove-formula ` resolves the two names +to an id in `execute`. When nothing matches it `refuse`s with `No formula for + in `, so a script that removes a formula it never added now +fails where it used to pass. That is the §10 rule applied: nothing changed, so +the request was not met. `remove_formula_of_a_missing_formula_is_a_refusal` +pins it, with a present formula as the control. diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 156de170..2b2029ba 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6008,3 +6008,13 @@ from the landed cell and untouched. flag. This test fails alone. Every other test stays green, which was the gap. Once improvise-fy9o re-seeds the reopened editor, a stale `touched` would make every untouched Tab or Enter write its seed. + +### remove_formula_of_a_missing_formula_is_a_refusal + +improvise-9trl, from the review of #223. The script command's `None` arm refuses, +which is what makes `main.rs` exit 1, and nothing tested it. Rival: `None => +vec![]`, the silent no-op the command had before. With it, the whole suite +stayed green, and this test now fails on `last_batch_refused`. The control +arm, a formula that is present, must not refuse and must remove it, so a +command that refused every call cannot pass. + diff --git a/crates/improvise-command/src/command/cmd/effect_cmds.rs b/crates/improvise-command/src/command/cmd/effect_cmds.rs index 645a587c..44d93e32 100644 --- a/crates/improvise-command/src/command/cmd/effect_cmds.rs +++ b/crates/improvise-command/src/command/cmd/effect_cmds.rs @@ -67,6 +67,34 @@ mod tests { assert_eq!(view.categories_on(Axis::Column), vec!["Type", "Month"]); } + /// improvise-9trl: a script naming a formula that is not there is refused, + /// so `main.rs` exits 1. It was a silent no-op that exited 0. + #[test] + fn remove_formula_of_a_missing_formula_is_a_refusal() { + let mut m = two_cat_model(); + let tax = crate::formula::parse_formula("Tax = Food * 0.1", "Type").unwrap(); + m.model.add_formula(tax); + let layout = make_layout(&m); + let reg = make_registry(); + let ctx = make_ctx(&m, &layout, ®); + let run = |args: [&str; 2]| { + let args = args.iter().map(|s| s.to_string()).collect(); + let mut app = crate::ui::app::App::new(m.clone(), None); + app.apply_effects(RemoveFormulaCmd(args).execute(&ctx)); + app + }; + let missing = run(["Type", "Profit"]); + assert!( + missing.last_batch_refused(), + "{}", + missing.view_state.status_msg + ); + assert_eq!(missing.model_state.workbook.model.formulas().len(), 1); + let present = run(["Type", "Tax"]); + assert!(!present.last_batch_refused(), "the control must not refuse"); + assert!(present.model_state.workbook.model.formulas().is_empty()); + } + #[test] fn write_cmd_without_args_saves() { let m = two_cat_model(); From 301b1f8fc1a90e068b7d7ab5eb027bf45ed27288 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:42:58 -0700 Subject: [PATCH 019/114] WIP: panel.rs declines and dispatch's arg-error arm refuse (improvise-i3o5) --- .../src/command/cmd/panel.rs | 6 +-- .../improvise-command/src/command/keymap.rs | 2 +- crates/improvise-command/src/ui/app.rs | 54 +++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/crates/improvise-command/src/command/cmd/panel.rs b/crates/improvise-command/src/command/cmd/panel.rs index 6793c910..529b828a 100644 --- a/crates/improvise-command/src/command/cmd/panel.rs +++ b/crates/improvise-command/src/command/cmd/panel.rs @@ -705,7 +705,7 @@ impl Cmd for CycleAxisAtCursor { .extend(cycled.map(|axis| effect::set_status(axis_change_status(&cat_name, axis)))); effects } else { - vec![effect::set_status( + vec![effect::refuse( "Move cursor to a category header to change axis".to_string(), )] } @@ -723,7 +723,7 @@ impl Cmd for OpenItemAddAtCursor { if let Some(cat_name) = ctx.cat_at_cursor() { vec![effect::change_mode(AppMode::item_add(cat_name))] } else { - vec![effect::set_status( + vec![effect::refuse( "No category selected. Press n to add a category first.", )] } @@ -815,7 +815,7 @@ impl Cmd for DeleteCategoryAtCursor { effects.extend(retreat_from_deleted_item(ctx)); effects } - None => vec![effect::set_status("No category to delete")], + None => vec![effect::refuse("No category to delete")], } } } diff --git a/crates/improvise-command/src/command/keymap.rs b/crates/improvise-command/src/command/keymap.rs index 50768f91..440a3986 100644 --- a/crates/improvise-command/src/command/keymap.rs +++ b/crates/improvise-command/src/command/keymap.rs @@ -61,7 +61,7 @@ fn effects_of( Ok(acc) }), }; - Some(built.unwrap_or_else(|e| vec![crate::ui::effect::set_status(e)])) + Some(built.unwrap_or_else(|e| vec![crate::ui::effect::refuse(e)])) } /// The shared keymap table plus the registry that gives its names meaning. diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index 1a670ee7..28f93863 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -3285,6 +3285,23 @@ mod tests { app } + /// One key through the keymap, as the TUI delivers it; true when it refused. + fn press_key(app: &mut App, code: KeyCode) -> bool { + app.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) + .unwrap(); + app.last_batch_refused() + } + + fn panel_cursor_at(mut app: App, at: usize) -> App { + app.view_state.cat_panel_cursor = at; + app + } + + fn yanked(mut app: App) -> App { + app.view_state.yanked = Some(CellValue::Number(5.5)); + app + } + fn with_a_second_view(mut app: App) -> App { app.model_state.workbook.create_view("Second"); app @@ -3486,6 +3503,43 @@ mod tests { two_col_model, |app| commit_in(app, AppMode::command_mode(), "command", "add-category Z"), ), + // improvise-i3o5: the category panel's three "nothing at the cursor" + // declines, and a bound key whose command will not build. + ( + "cycle-axis-at-cursor past the panel's last row", + || panel_cursor_at(two_col_model(), 99), + |app| run_line(app, "cycle-axis-at-cursor"), + "Move cursor to a category header", + || panel_cursor_at(two_col_model(), 1), + |app| run_line(app, "cycle-axis-at-cursor"), + ), + ( + "open-item-add-at-cursor past the panel's last row", + || panel_cursor_at(two_col_model(), 99), + |app| run_line(app, "open-item-add-at-cursor"), + "No category selected", + || panel_cursor_at(two_col_model(), 1), + |app| run_line(app, "open-item-add-at-cursor"), + ), + ( + "a bound key whose command will not build (p onto an aggregate)", + || yanked(aggregated_grid(true)), + |app| press_key(app, KeyCode::Char('p')), + crate::command::cmd::core::AGGREGATE_REFUSAL, + || yanked(two_col_model()), + |app| { + app.cursor_mut().selected = (1, 1); + press_key(app, KeyCode::Char('p')) + }, + ), + ( + "delete-category-at-cursor past the panel's last row", + || panel_cursor_at(two_col_model(), 99), + |app| run_line(app, "delete-category-at-cursor"), + "No category to delete", + || panel_cursor_at(two_col_model(), 1), + |app| run_line(app, "delete-category-at-cursor"), + ), ]; for (what, fail_setup, fails, says, ok_setup, succeeds) in cases { let mut app = fail_setup(); From 2fa34359e35c7d6db6d2f8f4e2737ccf2afd91df Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:44:44 -0700 Subject: [PATCH 020/114] context: panel and dispatch refusals; a bound key that will not build is a refusal (improvise-i3o5) --- context/code/cli.md | 28 ++++++++++++++++++++++++---- context/test-evidence/command.md | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/context/code/cli.md b/context/code/cli.md index 0200feca..0c0e2ec6 100644 --- a/context/code/cli.md +++ b/context/code/cli.md @@ -312,10 +312,30 @@ carries it or has to agree with it. The TUI colouring the ticket hoped for is still possible later, and would want the severity *on the message*; that is a separate change with a separate staleness problem. -**The classification is per writer, and it is not total.** Every refusal in -`ui/effect.rs` and in `cmd/{commit,fit,grid,mode,search,text_buffer}.rs` goes -through `refuse`. Those still written with `set_status` exit 0 as before, and -are listed in improvise-i3o5. `q` on a dirty document is deliberately left a +**The classification is per writer.** Every refusal in `ui/effect.rs`, in +`cmd/{cell,commit,fit,grid,mode,panel,search,text_buffer}.rs` and in +`command/keymap.rs::dispatch` goes through `refuse` (improvise-i3o5). The +sweeps that pin refusals revert `refuse(` calls, so they **cannot see a +refusal written as `set_status`**. The last of those were found by reading +every `set_status` on a decline path. + +**A bound key whose command will not build is a refusal** (the orchestrator's +ruling on improvise-i3o5). The key asked for something that did not happen, +which is this section's definition. So `dispatch` answers a constructor's +`Err` with `refuse(e)` rather than `set_status(e)`. That arm is where every +key-path decline lands, including `p`, `x` and `i` onto an aggregate, a fold, +a coordinate column or inside a drill. + +Before converting it, I checked who reads the flag on the key path: +- `last_batch_refused` is read in production only by `Host::mutating_frame`, + which handles frames and not keys, and by `main.rs`'s headless loop, which + handles `:` lines and not keys. +- `Refuse` is `ViewOnly`, like `SetStatus`, so the `Dirty` a key returns and + the projections built from it are unchanged. +- The browser lockstep suite compares `ViewState`, including `status_msg`, + never the flag, and `Refuse` writes the same message. + +The whole workspace stayed green with the change. `q` on a dirty document is deliberately left a report: headless, its text is false — the CLI saves anyway (improvise-ep5o). The two rivals udt2 named both stay green: a successful `:w` and a successful `fit-measure` report through `set_status` and exit 0. diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 07abd55a..5749ad42 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6021,3 +6021,25 @@ from the landed cell and untouched. flag. This test fails alone. Every other test stays green, which was the gap. Once improvise-fy9o re-seeds the reopened editor, a stale `touched` would make every untouched Tab or Enter write its seed. + +#### improvise-i3o5: the category panel's declines, and a bound key that will not build + +Four more cases in `every_refusal_site_refuses_its_failure_and_only_its_failure`. +The first three are `cycle-axis-at-cursor`, `open-item-add-at-cursor` and +`delete-category-at-cursor`, each run with the panel cursor past the tree's last +row (99). Each twin runs the same command at row 1, deliberately not row 0. + +The fourth is `p` pressed through `App::handle_key` on an aggregate, so the +paste constructor's `Err` reaches `command/keymap.rs::dispatch`. Its twin is `p` +onto a stored record at (1, 1). The expected text is `AGGREGATE_REFUSAL`, which +`dispatch` prefixes with the command name ("paste: …"). + +**Red at `bb5dfe5`**, one site at a time: each case failed with its own text and +`refused=false` until its own site was converted. + +**Per-site revert:** `/tmp/refuse-fu/sweep.py`, with `command/keymap.rs` added to +its file list, reverts each of the four `refuse(` calls alone and runs +`cargo test --workspace --no-fail-fast`. Four of four fail this test, and +nothing else fails. The full suite stayed green with `dispatch` converted, which +is the empirical half of the check that nothing relied on a failed key leaving +`last_batch_refused` false. From a093d126bc13547f04a9c72f5b20ad6001f1e9f5 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:44:56 -0700 Subject: [PATCH 021/114] Let the client bundle's wasm-opt accept saturating float conversion; rustfmt landed_seed parses committed text with typed(), which puts i64.trunc_sat_f64_s in the client bundle; the bundled wasm-opt rejects it unless the feature is named, as the worker bundle already does. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-wasm-client/Cargo.toml | 5 ++-- .../improvise-wasm-client/tests/lockstep.rs | 28 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/improvise-wasm-client/Cargo.toml b/crates/improvise-wasm-client/Cargo.toml index 1abfbb14..131e552c 100644 --- a/crates/improvise-wasm-client/Cargo.toml +++ b/crates/improvise-wasm-client/Cargo.toml @@ -45,6 +45,7 @@ improvise-ws-server = { path = "../improvise-ws-server" } # The `wasm-opt` wasm-pack downloads predates bulk-memory being enabled by # default, while LLVM emits `memory.copy` regardless — so the validator # rejects the module unless the feature is named. Naming it here rather than -# turning `wasm-opt` off keeps the optimizer in the loop. +# turning `wasm-opt` off keeps the optimizer in the loop. `nontrapping-float-to-int` +# is the worker bundle's second flag, needed here once the client parses typed text. [package.metadata.wasm-pack.profile.release] -wasm-opt = ["-O", "--enable-bulk-memory"] +wasm-opt = ["-O", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"] diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index ae224fe8..af63c60f 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1691,13 +1691,28 @@ fn a_category_committed_at_plus_cat_leaves_no_buffer_behind_on_both_ends() { typing_at(&mut app, &mut twin, &mut client, at, "Season"); client = furnished_client(&app, (12, 12)); client.view = app.view_state.clone(); - assert_eq!(read_edit(&client.view.buffers), "Season", "nothing was typed, so this proves nothing"); + assert_eq!( + read_edit(&client.view.buffers), + "Season", + "nothing was typed, so this proves nothing" + ); both_ends(&mut app, &mut twin, &mut client, "Enter"); - assert!(document_text(&app).contains("Season"), "the commit did not land"); - assert_eq!(read_edit(&app.view_state.buffers), "", "the host left the name behind"); - assert_eq!(read_edit(&client.view.buffers), "", "the browser left the name behind"); + assert!( + document_text(&app).contains("Season"), + "the commit did not land" + ); + assert_eq!( + read_edit(&app.view_state.buffers), + "", + "the host left the name behind" + ); + assert_eq!( + read_edit(&client.view.buffers), + "", + "the browser left the name behind" + ); } fn read_edit(buffers: &std::collections::HashMap) -> &str { @@ -2307,7 +2322,10 @@ fn tab_at_the_rightmost_column_reopens_on_the_stored_spelling() { apply_on(&mut client_host, &cmds); reduce_key(&mut key_host, event); } - assert_eq!(key_host.view_state.buffers.get("edit").map(String::as_str), Some("7")); + assert_eq!( + key_host.view_state.buffers.get("edit").map(String::as_str), + Some("7") + ); assert_eq!(client.view.buffers, key_host.view_state.buffers); } From bdf2f2b76e95a7a2d2c3bd4c0ab2a9c0e9a582d3 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:48:12 -0700 Subject: [PATCH 022/114] Cite improvise-cwfj, the bead this remainder is tracked as --- context/code/cli.md | 4 ++-- context/test-evidence/command.md | 2 +- crates/improvise-command/src/ui/app.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/context/code/cli.md b/context/code/cli.md index 0c0e2ec6..d3fbbf5c 100644 --- a/context/code/cli.md +++ b/context/code/cli.md @@ -314,13 +314,13 @@ separate change with a separate staleness problem. **The classification is per writer.** Every refusal in `ui/effect.rs`, in `cmd/{cell,commit,fit,grid,mode,panel,search,text_buffer}.rs` and in -`command/keymap.rs::dispatch` goes through `refuse` (improvise-i3o5). The +`command/keymap.rs::dispatch` goes through `refuse` (improvise-i3o5, improvise-cwfj). The sweeps that pin refusals revert `refuse(` calls, so they **cannot see a refusal written as `set_status`**. The last of those were found by reading every `set_status` on a decline path. **A bound key whose command will not build is a refusal** (the orchestrator's -ruling on improvise-i3o5). The key asked for something that did not happen, +ruling, improvise-cwfj). The key asked for something that did not happen, which is this section's definition. So `dispatch` answers a constructor's `Err` with `refuse(e)` rather than `set_status(e)`. That arm is where every key-path decline lands, including `p`, `x` and `i` onto an aggregate, a fold, diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 5749ad42..005f1167 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6022,7 +6022,7 @@ flag. This test fails alone. Every other test stays green, which was the gap. Once improvise-fy9o re-seeds the reopened editor, a stale `touched` would make every untouched Tab or Enter write its seed. -#### improvise-i3o5: the category panel's declines, and a bound key that will not build +#### improvise-cwfj: the category panel's declines, and a bound key that will not build Four more cases in `every_refusal_site_refuses_its_failure_and_only_its_failure`. The first three are `cycle-axis-at-cursor`, `open-item-add-at-cursor` and diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index 28f93863..02e2ed71 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -3503,7 +3503,7 @@ mod tests { two_col_model, |app| commit_in(app, AppMode::command_mode(), "command", "add-category Z"), ), - // improvise-i3o5: the category panel's three "nothing at the cursor" + // improvise-cwfj: the category panel's three "nothing at the cursor" // declines, and a bound key whose command will not build. ( "cycle-axis-at-cursor past the panel's last row", From 66bd3fcbf234eba4b2206387c01190820feb94ae Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:48:54 -0700 Subject: [PATCH 023/114] Tile select ranks by CategoryKind, and the docs and tests catch up (oplp follow-ups) improvise-bqm0: opening_tile fell back to tile 0, _Index, when a view had no Regular category, so T Enter still emptied that grid. TileEntry now carries the host's CategoryKind in place of `regular: bool`, and opening_tile ranks Regular, then _Measure, then Label, then _Index/_Dim, leftmost on a tie, through an exhaustive match. A bool could not express the fallback without a client sniffing names. CategoryKind is re-exported from the protocol so the wasm client names it through its one dependency. improvise-52od: in every fixture the first Regular tile was also the first tile on Row, so a rival `position(axis == Row)` passed the workspace. A protocol unit test on a records-shaped bar and a lockstep case in records mode now fail that rival; a no-regular unit test and lockstep case fail #231's own `first regular, else 0`. improvise-q69m: context/code/command.md said tile 0 is _Index and that the fix would be the tile order, and clients.md's Rule 3 counterexample named the leftmost tile. Both now describe opening_tile, and command.md says why placing the cursor beat reordering: click targets are indices, and the offered order is #192's pinned characterization. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 8 +- context/code/command.md | 42 +++++++++-- context/test-evidence/clients.md | 19 +++++ context/test-evidence/protocol.md | 19 +++++ crates/improvise-command/src/ui/project.rs | 6 +- crates/improvise-protocol/src/client.rs | 2 +- crates/improvise-protocol/src/lib.rs | 2 + crates/improvise-protocol/src/reduce.rs | 2 +- crates/improvise-protocol/src/render.rs | 74 ++++++++++++++++--- crates/improvise-protocol/src/resolve.rs | 8 +- crates/improvise-wasm-client/src/render.rs | 8 +- .../improvise-wasm-client/tests/lockstep.rs | 67 +++++++++++++++++ 12 files changed, 228 insertions(+), 29 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index b4062cca..321b5881 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -1053,9 +1053,11 @@ implemented rule is "clicking the already-selected tile cycles it; clicking any other selects it", where *already selected* is `mode == TileSelect` **and** `tile_cat_idx == index`. The tempting summaries — "a click never mutates on its own", "a **second** click cycles" — both have the same counterexample, found on -review: `T` enters `TileSelect` leaving `tile_cat_idx` at **0**, so a click on -the **leftmost** tile is a click on the already-selected one and cycles on the -*first* gesture. Two adjacent tiles therefore answer the identical first click +review: `T` enters `TileSelect` with `tile_cat_idx` at the tile +`opening_tile` picks (the first Regular category, improvise-oplp; see +`context/code/command.md`), so a click on **that** tile is a click on the +already-selected one and cycles on the *first* gesture. Before improvise-oplp +this was the leftmost tile, `_Index`. Two adjacent tiles therefore answer the identical first click differently, and that is correct rather than a hole — a click does what `Enter` does there, and `Enter` in `TileSelect` cycles the tile under the cursor. The resolver cannot see *how* the state arose and must not try to. diff --git a/context/code/command.md b/context/code/command.md index 05f0cefc..f64aeb55 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -2980,11 +2980,43 @@ for three reasons, each checked rather than argued: `renders_each_tile_with_its_axis_label` fail, as well as the two characterization tests here. -What the default cursor does cost, stated: one cycle key on tile 0 puts `_Index` -on Row alone, and in a pivot view that empties the grid (0 rows on -`web/demo.improv`). The status line says `_Index → Row`, and three more presses -undo it. If that turns out to matter, the fix is the order the tile bar lists -categories in, not taking entries out of it. +**Where the tile cursor opens (improvise-oplp, improvise-bqm0).** It used to +open on tile 0, which is `_Index`, so one cycle key (`T Enter`, read as +"confirm", or `T r`) put `_Index` alone on Row and emptied a pivot grid (0 rows +on `web/demo.improv`). The browser worker saves every accepted model command, so +there the empty grid became the stored document at once. + +The fix places the cursor and keeps the order. Both ends call +`improvise_protocol::opening_tile`, which opens on the best-ranked tile (the +leftmost on a tie): a Regular category first, then `_Measure`, then a Label, and +`_Index`/`_Dim` last. **Placing the cursor beat reordering the tiles** because +the order is itself a decided, tested output. The browser's click targets are +tile *indices*, so reordering would move every click position. And +`the_tile_bar_offers_every_category_by_name_measure_and_records_axes_included` +pins the offered list, which is the characterization #192 made. Moving the cursor +changes only where one keypress lands. + +**The ranking, and why it is an enum and not a bool.** `TileEntry` carries the +host's `CategoryKind`, because a client cannot tell `_Index` from a user category +without sniffing names (design-principles §2). #231 first carried +`regular: bool`, which answers only one question. It could not say what to open +on when a view has *no* Regular category, and so fell back to tile 0, `_Index`, +which is the original bug (improvise-bqm0, confirmed on a document holding only +`_Measure` data). The rank is an exhaustive `match`, so a new kind forces a +decision. `_Measure` is the fallback, not the primary: it is on an axis in every +shipped document, and `Enter` reads as "confirm" on the user's own dimension. It +ranks above a Label because a Label is a high-cardinality per-row field, and +cycling it onto Row would explode the grid rather than empty it. In practice the +fallback is always `_Measure`, which every document has. + +**The tests had to separate the rule from its neighbours** (improvise-52od). In +every early fixture the first Regular tile was also the first tile on Row, so a +rival `position(axis == Row)` passed the whole workspace. Records mode is where +they part: `_Index` holds Row there, and the rival cycles it, dropping the user +out of records mode. `tile_select_opens_on_a_regular_category_even_when_a_virtual_holds_row` +and `in_records_mode_tile_select_opens_on_a_regular_category_on_both_ends` pin +that; the evidence is in `context/test-evidence/protocol.md` and +`clients.md`. **`_Measure` must stay tileable.** It is on an axis in every shipped document, so `regular_category_names()`, the advice for *prompts*, is the wrong filter here. diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 8965c5ec..fc1bcacc 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5046,3 +5046,22 @@ on row 1. The test presses `i`, types `9`, and asserts the first editor really is touched. Enter must then land on (2, 0), and the folded `opened_editor` must be exactly `OpenedEditor::on("800")`. **Injection:** `resolve::advanced` carrying the previous editor's `touched` flag. This test fails alone. + +### in_records_mode_tile_select_opens_on_a_regular_category_on_both_ends + +improvise-52od, on both ends. `records_pair()` puts host and twin in records mode +(`_Index` on Row, `_Dim` on Column). Then `T Enter` through `both_ends`, and the +case asserts that `_Index` and `_Dim` kept their records axes, that `Region` (the +tile `T` opens on) moved, and that the twin agrees. Injected rival +`opening_tile = position(axis == Row)`: this case fails, and so does the +protocol unit test. `tile_select_opens_on_a_regular_category_on_both_ends` +*still passes* under that rival, which is the ticket's finding in one line: its +fixture had the two rules agree. + +### with_no_regular_category_tile_select_opens_on_measure_on_both_ends + +improvise-bqm0, on both ends. A document holding only `_Measure` data (one item, +one cell), then `T Enter`. `_Index` must keep its axis, and `_Measure` must be +the tile that moved. Injected #231's rule (`first Regular, else 0`): this case +fails, because tile select opened on `_Index` and cycled it onto Row, which is +the bug reproduced headless in the ticket. diff --git a/context/test-evidence/protocol.md b/context/test-evidence/protocol.md index aa7d04d9..31b04ece 100644 --- a/context/test-evidence/protocol.md +++ b/context/test-evidence/protocol.md @@ -1191,3 +1191,22 @@ compares **content in order**: the drawn keys rows must equal `help_bindings()` looked up through the same `keys`, row for row. The same injection now fails. What still passes is a bypass that draws exactly what the builders asked for, in the order they asked — which is harmless by definition. + +### tile_select_opens_on_a_regular_category_even_when_a_virtual_holds_row + +improvise-52od. `opening_tile`'s rule is "the best-ranked tile", and in every +earlier fixture its top rank, the first Regular tile, was also the first tile on +Row. So the rival `position(|t| t.axis == Axis::Row).unwrap_or(0)`, which needs +no wire field at all, passed the whole workspace. The fixture is a records-shaped +bar: `_Index` on Row, `_Dim` on Column, `_Measure` on None, `Region` on Page and +`Product` on Row. The rule answers 3 (`Region`). The Row rival answers 0 +(`_Index`), and a "first Regular on Row" rival answers 4 (`Product`), so all +three are separated. Injected: the Row rival fails this test. + +### with_no_regular_category_tile_select_opens_on_measure_not_index + +improvise-bqm0. #231's rule, `position(regular).unwrap_or(0)`, fell back to tile +0, which is `_Index`, when a view has no Regular category, which is exactly the +bug #231 fixed. The fixture is `_Index`, `_Dim`, a Label and `_Measure`. The rule +answers 3 (`_Measure`), then 2 (the Label) with `_Measure` removed, and 0 for an +empty bar. Injected: #231's rule answers 0 and fails. The Row rival fails too. diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 1eaf9c85..f9be4e45 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use improvise_core::format::FormatPlan; use improvise_core::model::Model; -use improvise_core::model::category::MEASURE_CATEGORY; +use improvise_core::model::category::{CategoryKind, MEASURE_CATEGORY}; use improvise_core::model::cell::CellValue; use improvise_core::model::record::{AggregateHash, RecordHash}; use improvise_core::view::layout::{AxisEntry, GridLayout, RecordCol}; @@ -417,7 +417,9 @@ pub fn tiles_of(model: &Model, view: &View) -> Vec { .map(|cat| TileEntry { category: cat.to_string(), axis: view.try_axis_of(cat).unwrap_or(Axis::None), - regular: model.category(cat).is_some_and(|c| c.kind.is_regular()), + kind: model + .category(cat) + .map_or_else(|| CategoryKind::of_name(cat), |c| c.kind), }) .collect() } diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index a2615b56..3aff5edc 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -281,7 +281,7 @@ mod tests { ]))); live.fold(&Command::Project(Projection::TileBar(vec![TileEntry { category: "Region".into(), - regular: true, + kind: crate::CategoryKind::Regular, axis: Axis::Row, }]))); live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary { diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index a57ae7fa..147a0bb5 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -43,6 +43,8 @@ pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary}; // `improvise-core`'s, because the index that resolves one lives in // `DataStore`. Re-exported so a client depends on the protocol alone. pub use improvise_core::model::record::{AggregateHash, RecordHash}; +// A tile carries its kind so a client can rank it without sniffing names. +pub use improvise_core::model::category::CategoryKind; pub use improvise_core::view::layout::PageAxis; // On the wire in `ModelCommand::SetCell`, so a client needs to name it diff --git a/crates/improvise-protocol/src/reduce.rs b/crates/improvise-protocol/src/reduce.rs index 0ea044f7..50c75e90 100644 --- a/crates/improvise-protocol/src/reduce.rs +++ b/crates/improvise-protocol/src/reduce.rs @@ -504,7 +504,7 @@ mod tests { }])), Command::Project(Projection::TileBar(vec![TileEntry { category: "Region".into(), - regular: true, + kind: crate::CategoryKind::Regular, axis: Axis::Page, }])), // One populated value per payload family: `RecordCell`, `Axis`, diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index b14c4838..a6f25c10 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use unicode_width::UnicodeWidthStr; +use improvise_core::model::category::CategoryKind; use improvise_core::model::cell::CellValue; use improvise_core::model::record::{AggregateHash, RecordHash}; use improvise_core::view::Axis; @@ -767,19 +768,29 @@ impl WidgetCache for ViewListCache { } } -/// One tile in the tile bar. `regular` is the host's `CategoryKind` answer: a -/// client cannot tell `_Index` from a user category without sniffing names. +/// One tile in the tile bar. `kind` is the host's answer: a client cannot tell +/// `_Index` from a user category without sniffing names (improvise-bqm0). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TileEntry { pub category: String, pub axis: Axis, - pub regular: bool, + pub kind: CategoryKind, } -/// Where tile select opens: the first regular category, so `T Enter` cannot -/// put `_Index` alone on Row (improvise-oplp). Virtuals stay reachable. +/// Where tile select opens: the best-ranked tile, the leftmost on a tie, so +/// `T Enter` never puts `_Index` alone on Row. `context/code/command.md`. pub fn opening_tile(tiles: &[TileEntry]) -> usize { - tiles.iter().position(|tile| tile.regular).unwrap_or(0) + let rank = |kind: CategoryKind| match kind { + CategoryKind::Regular => 0, + CategoryKind::VirtualMeasure => 1, + CategoryKind::Label => 2, + CategoryKind::VirtualIndex | CategoryKind::VirtualDim => 3, + }; + tiles + .iter() + .enumerate() + .min_by_key(|(i, tile)| (rank(tile.kind), *i)) + .map_or(0, |(i, _)| i) } /// The tile bar's tileable categories and axes (improvise-pca). Its overlap @@ -905,6 +916,51 @@ pub struct Snapshot { mod tests { use super::*; + fn tile(category: &str, axis: Axis, kind: CategoryKind) -> TileEntry { + TileEntry { + category: category.to_string(), + axis, + kind, + } + } + + /// Records mode puts `_Index` on Row and the first regular elsewhere, so + /// "first regular" and "first on Row" part (improvise-52od, protocol.md). + #[test] + fn tile_select_opens_on_a_regular_category_even_when_a_virtual_holds_row() { + let tiles = [ + tile("_Index", Axis::Row, CategoryKind::VirtualIndex), + tile("_Dim", Axis::Column, CategoryKind::VirtualDim), + tile("_Measure", Axis::None, CategoryKind::VirtualMeasure), + tile("Region", Axis::Page, CategoryKind::Regular), + tile("Product", Axis::Row, CategoryKind::Regular), + ]; + assert_eq!(opening_tile(&tiles), 3, "Region, the first regular tile"); + } + + /// No regular category: open on `_Measure`, never on `_Index` or `_Dim` + /// (improvise-bqm0). Why this ranking: `context/code/command.md`. + #[test] + fn with_no_regular_category_tile_select_opens_on_measure_not_index() { + let tiles = [ + tile("_Index", Axis::None, CategoryKind::VirtualIndex), + tile("_Dim", Axis::None, CategoryKind::VirtualDim), + tile("Note", Axis::None, CategoryKind::Label), + tile("_Measure", Axis::Column, CategoryKind::VirtualMeasure), + ]; + assert_eq!( + opening_tile(&tiles), + 3, + "_Measure outranks a label and the records axes" + ); + assert_eq!( + opening_tile(&tiles[..3]), + 2, + "a label outranks _Index and _Dim" + ); + assert_eq!(opening_tile(&[]), 0, "an empty bar opens at 0"); + } + fn valued(value: CellValue) -> CellDisplay { CellDisplay { value: Some(value), @@ -1507,7 +1563,7 @@ mod tests { let tiles = TileBarCache::folded(&[vec![TileEntry { category: "Region".into(), axis: Axis::Page, - regular: true, + kind: crate::CategoryKind::Regular, }]]); assert_eq!(tiles.tiles[0].axis, Axis::Page); } @@ -1905,7 +1961,7 @@ mod tests { Projection::TileBar(vec![TileEntry { category: "Region".into(), axis: Axis::Page, - regular: true, + kind: crate::CategoryKind::Regular, }]), ]; for projection in &projections { @@ -1942,7 +1998,7 @@ mod tests { Projection::TileBar(vec![TileEntry { category: "Region".into(), axis: Axis::Page, - regular: true, + kind: crate::CategoryKind::Regular, }]), ], }; diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 2f8d5b91..cadf4105 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1548,12 +1548,12 @@ mod tests { crate::TileEntry { category: "Region".into(), axis: Axis::Row, - regular: true, + kind: crate::CategoryKind::Regular, }, crate::TileEntry { category: "Product".into(), axis: Axis::Column, - regular: true, + kind: crate::CategoryKind::Regular, }, ]; state @@ -2209,7 +2209,7 @@ mod tests { .map(|category| crate::TileEntry { category: category.into(), axis: Axis::Row, - regular: true, + kind: crate::CategoryKind::Regular, }) .collect(); state.view.tile_cat_idx = 1; @@ -3101,7 +3101,7 @@ mod tests { tiles.tiles.tiles = vec![crate::TileEntry { category: "Region".into(), axis: improvise_core::view::Axis::Row, - regular: true, + kind: crate::CategoryKind::Regular, }]; assert_eq!( resolve(&tiles, ev(KeyCode::Char('h'))), diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 08279308..e9c420da 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -802,12 +802,12 @@ mod tests { TileEntry { category: "Region".into(), axis: Axis::Row, - regular: true, + kind: improvise_protocol::CategoryKind::Regular, }, TileEntry { category: "Product".into(), axis: Axis::None, - regular: true, + kind: improvise_protocol::CategoryKind::Regular, }, ]))); client @@ -1131,12 +1131,12 @@ mod tests { TileEntry { category: "Quarter".into(), axis: Axis::Page, - regular: true, + kind: improvise_protocol::CategoryKind::Regular, }, TileEntry { category: "Region".into(), axis: Axis::Column, - regular: true, + kind: improvise_protocol::CategoryKind::Regular, }, ]))); let tiles = client.model().tiles; diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 081bd722..3ef54548 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -901,6 +901,73 @@ fn tile_select_opens_on_a_regular_category_on_both_ends() { } } +/// Records mode: `_Index` holds Row, so a "first tile on Row" rule would cycle +/// it and drop the user out of records mode (improvise-52od, clients.md). +#[test] +fn in_records_mode_tile_select_opens_on_a_regular_category_on_both_ends() { + let (mut app, mut twin) = records_pair(); + let mut client = furnished_client(&app, (8, 8)); + let axis_of = |app: &App, cat: &str| app.view().axis_of(cat); + assert!( + app.view_state.mode.is_records(), + "the fixture must be in records mode" + ); + assert_eq!(axis_of(&app, "_Index"), Axis::Row); + let region_before = axis_of(&app, "Region"); + + both_ends(&mut app, &mut twin, &mut client, "T"); + both_ends(&mut app, &mut twin, &mut client, "Enter"); + + assert_eq!(axis_of(&app, "_Index"), Axis::Row, "T Enter moved _Index"); + assert_eq!(axis_of(&app, "_Dim"), Axis::Column, "T Enter moved _Dim"); + assert_ne!( + axis_of(&app, "Region"), + region_before, + "Region is the tile T opens on" + ); + assert_eq!(axis_of(&twin, "Region"), axis_of(&app, "Region")); +} + +/// A document with no regular category: tile select must still not open on +/// `_Index` (improvise-bqm0). Why `_Measure`: `context/code/command.md`. +#[test] +fn with_no_regular_category_tile_select_opens_on_measure_on_both_ends() { + let bare = || { + let mut wb = Workbook::new("Test"); + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + wb.model.set_cell( + improvise_command::model::cell::CellKey::new(vec![( + "_Measure".to_string(), + "Revenue".to_string(), + )]), + improvise_protocol::CellValue::Number(5.0), + ); + let mut app = App::new(wb, None); + app.term_height = 2 + 8; + app.rebuild_layout(); + app + }; + let (mut app, mut twin) = (bare(), bare()); + let mut client = furnished_client(&app, (2, 1)); + let axis_of = |app: &App, cat: &str| app.view().axis_of(cat); + let index_before = axis_of(&app, "_Index"); + + both_ends(&mut app, &mut twin, &mut client, "T"); + both_ends(&mut app, &mut twin, &mut client, "Enter"); + + assert_eq!( + axis_of(&app, "_Index"), + index_before, + "T Enter moved _Index" + ); + assert_ne!( + axis_of(&app, "_Measure"), + Axis::Column, + "_Measure is the tile T opens on" + ); +} + /// The category panel's four bindings, each of which reads the flattened tree /// both ends share (improvise-edp): Space, `a`, Enter and `d`. #[test] From 42aeeb6b034bc16bba70ffcfee564a7254e994f0 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:53:43 -0700 Subject: [PATCH 024/114] rustfmt Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/command/cmd/core.rs | 4 +++- crates/improvise-protocol/src/resolve.rs | 7 +++---- crates/improvise-wasm-client/tests/lockstep.rs | 10 ++++++++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/improvise-command/src/command/cmd/core.rs b/crates/improvise-command/src/command/cmd/core.rs index 9f9a8fb3..1e8e59c3 100644 --- a/crates/improvise-command/src/command/cmd/core.rs +++ b/crates/improvise-command/src/command/cmd/core.rs @@ -115,7 +115,9 @@ impl<'a> CmdContext<'a> { Some(CellId::Record(_)) => self.stored_cell_key(), Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()), Some(CellId::Derived { target }) => Err(derived_refusal(&target)), - Some(CellId::Create(_) | CellId::Coordinate { .. }) => Err(NO_VALUE_REFUSAL.to_string()), + Some(CellId::Create(_) | CellId::Coordinate { .. }) => { + Err(NO_VALUE_REFUSAL.to_string()) + } None => Err(match self.cell_key() { Some(_) => NO_VALUE_REFUSAL, None => NO_CELL, diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index df3a1829..c012cd84 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -761,10 +761,9 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { match cell.id.as_ref()? { // `edit_text`, never `text`: the drawn text has been through // `format_f64`. `tests/lockstep.rs` found exactly that. - CellId::Record(_) | CellId::Coordinate { .. } => Some(opened_on( - cell.edit_text.clone().unwrap_or_default(), - mode, - )), + CellId::Record(_) | CellId::Coordinate { .. } => { + Some(opened_on(cell.edit_text.clone().unwrap_or_default(), mode)) + } CellId::Aggregate(_) => Some(vec![Command::SetStatus(format!( "an aggregate is edited by drilling in — {}", not_yet("drill") diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index c6ffff38..d3cac87b 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2368,7 +2368,9 @@ fn an_untouched_coordinate_enter_writes_nothing_on_either_end() { panic!("Enter: the client must answer this key locally"); }; assert!( - !cmds.iter().any(|c| matches!(c, improvise_protocol::Command::Model { .. })), + !cmds + .iter() + .any(|c| matches!(c, improvise_protocol::Command::Model { .. })), "an untouched coordinate Enter composed a write: {cmds:?}" ); assert!( @@ -2376,7 +2378,11 @@ fn an_untouched_coordinate_enter_writes_nothing_on_either_end() { "Enter did not take the commit path, so this proves nothing: {cmds:?}" ); both_ends(&mut app, &mut twin, &mut client, "Enter"); - assert_eq!(document_text(&app), before, "an untouched Enter changed the document"); + assert_eq!( + document_text(&app), + before, + "an untouched Enter changed the document" + ); } /// An empty coordinate is refused on the host's key path, which says why and From de1524c093b5cc1b475af8f6dedc0726769c216b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:56:55 -0700 Subject: [PATCH 025/114] CategoryKind is a wire type again: TileEntry carries it improvise-tt22 merged into gh/main while this branch was open and dropped serde from core's document types, CategoryKind included, keeping only the wire types (Axis, PageAxis). TileEntry now carries a CategoryKind, so it joins them. formula-and-io.md's list of what remains is updated to say so. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/formula-and-io.md | 7 +++++-- crates/improvise-core/src/model/category.rs | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/context/code/formula-and-io.md b/context/code/formula-and-io.md index eacb06f1..4db85da2 100644 --- a/context/code/formula-and-io.md +++ b/context/code/formula-and-io.md @@ -488,8 +488,11 @@ The serde derives that existed only for that path went with it: `Workbook`, `DataStore`'s manual impls and the formula AST. So did indexmap's `serde` feature in `improvise-core`, `improvise-io` and the root crate, and `improvise-formula`'s serde dependency. Each was removed and the whole workspace rebuilt, so the -compiler named every other consumer. Two remain, and both are the wire: -`Axis` (whose `rename_all = "lowercase"` is its wire spelling) and `PageAxis`. +compiler named every other consumer. What remains is the wire: +`Axis` (whose `rename_all = "lowercase"` is its wire spelling), `PageAxis`, and +`CategoryKind`, which went back on when `TileEntry` began carrying it +(improvise-bqm0; `context/code/command.md`). It returned as a wire type, not as +a document one: nothing loads a `Category` through serde. `CellValue` was never a candidate, since it is carried on the wire too. JSON *import* (`StartImportWizard`, `ImportJsonHeadless`) is a separate feature and is untouched. diff --git a/crates/improvise-core/src/model/category.rs b/crates/improvise-core/src/model/category.rs index e915e37f..31d881c6 100644 --- a/crates/improvise-core/src/model/category.rs +++ b/crates/improvise-core/src/model/category.rs @@ -1,4 +1,5 @@ use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; pub type CategoryId = usize; pub type ItemId = usize; @@ -49,7 +50,7 @@ impl Group { /// Regular categories store their items explicitly; virtual ones are /// synthesized at query time by the layout layer. context/code/core.md §9. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] pub enum CategoryKind { #[default] Regular, From 0d14f49e51421a5fdc616da6e60cc541ba8eedcb Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:15:38 -0700 Subject: [PATCH 026/114] WIP red: a retry after RecordGone, and a rename's unchecked destination (improvise-20ek, improvise-milr) --- .../tests/browser_round_trip.rs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 8bb693d6..d46ea514 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1195,3 +1195,167 @@ fn a_second_rename_after_a_reorder_renames_the_row_the_host_shows() { assert_eq!(region(&host, &shown), None); assert_eq!(region(&host, "Zed"), Some(CellValue::Number(2.0))); } + +// ── races on a coordinate rename (improvise-20ek, improvise-milr) ─────── + +type Sid = improvise_command::ui::project::SessionId; + +/// A client driven into records mode through the tile bar, its cursor on the +/// coordinate column of row 0. Returns the session, the client and the column. +fn records_client_at_coordinate(host: &mut Host) -> (Sid, Client, usize) { + let mut a = Client::new(); + a.set_viewport(10, 10); + let (id, snapshot) = host.connect(10, 10); + deliver(&mut a, &[snapshot]); + press(host, id, &mut a, "T"); + tile_to(host, id, &mut a, "_Index", "r"); + tile_to(host, id, &mut a, "_Dim", "c"); + press(host, id, &mut a, "Escape"); + assert!(a.state().grid.records, "setup: records mode"); + let coordinate = (0..a.state().grid.extent.cols) + .find(|&c| { + format!("{:?}", a.state().grid.cell(0, c).and_then(|x| x.id.clone())) + .starts_with("Some(Coordinate") + }) + .expect("a coordinate column"); + while a.state().view.cursor().selected.1 < coordinate { + press(host, id, &mut a, "l"); + } + (id, a, coordinate) +} + +fn retype(host: &mut Host, id: Sid, a: &mut Client, text: &str) { + for _ in 0..8 { + press(host, id, a, "Backspace"); + } + for ch in text.chars() { + press(host, id, a, &ch.to_string()); + } +} + +/// Another session's frame, applied, with what it owes `a` delivered to `a`. +fn from_another_client(host: &mut Host, a_id: Sid, a: &mut Client, cmd: ModelCommandArg) { + let (b_id, _) = host.connect(10, 10); + let frame = improvise_command::ui::reduce::caught_up(host.app(), cmd); + let relayed = host.handle(b_id, ClientFrame::command(frame)); + assert!( + relayed.iter().any(|f| f.to == b_id + && matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + "the other client's write must land: {relayed:?}" + ); + let for_a: Vec = relayed.into_iter().filter(|f| f.to == a_id).collect(); + deliver(a, &for_a); +} + +type ModelCommandArg = improvise_protocol::ModelCommand; + +/// improvise-20ek: the record an editor opened on is gone, and a retry must not +/// rename whatever moved under the cursor. Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_retry_after_record_gone_renames_no_other_record() { + use improvise_protocol::{CellId, ModelCommand, Rejection, ServerFrame}; + let mut host = three_records_host(); + let (a_id, mut a, coordinate) = records_client_at_coordinate(&mut host); + assert_eq!(a.state().grid.cell(0, coordinate).unwrap().text, "North"); + let Some(CellId::Coordinate { record: north, .. }) = + a.state().grid.cell(0, coordinate).unwrap().id.clone() + else { + panic!("setup: a coordinate id") + }; + press(&mut host, a_id, &mut a, "i"); + retype(&mut host, a_id, &mut a, "Zed"); + + from_another_client( + &mut host, + a_id, + &mut a, + ModelCommand::RenameRecordCoordinate { + record: north, + category: "Region".into(), + item: "Yak".into(), + }, + ); + + let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes"); + let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap()); + deliver(&mut a, &answer); + assert!( + answer + .iter() + .any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::RecordGone(_)))), + "the first Enter is RecordGone: {answer:?}" + ); + let row = a.state().view.cursor().selected.0; + assert!( + a.state().view.mode.is_editing() + && a.state().grid.cell(row, coordinate).unwrap().text != "Yak", + "setup: the editor is back, over a record other than the one renamed away" + ); + + press(&mut host, a_id, &mut a, "Enter"); + assert_eq!(region(&host, "Zed"), None, "the retry renamed a record never opened"); + assert_eq!(region(&host, "South"), Some(CellValue::Number(2.0))); + assert_eq!(region(&host, "West"), Some(CellValue::Number(3.0))); + assert_eq!(region(&host, "Yak"), Some(CellValue::Number(1.0))); +} + +/// improvise-milr: the rename's destination is a record too, and a write to it +/// this client never saw must refuse the rename. Evidence file. +#[test] +fn a_rename_onto_a_record_another_client_just_wrote_is_refused() { + use improvise_protocol::{ModelCommand, Rejection, ServerFrame}; + let mut host = three_records_host(); + let (a_id, mut a, coordinate) = records_client_at_coordinate(&mut host); + let revenue = (0..a.state().grid.extent.cols) + .find(|&c| { + a.record_cell_at(1, c) + .is_some_and(|x| x.measure.as_deref() == Some("Revenue")) + }) + .expect("South's revenue"); + let south = a.record_cell_at(1, revenue).unwrap(); + assert_eq!(a.state().grid.cell(1, coordinate).unwrap().text, "South"); + press(&mut host, a_id, &mut a, "i"); + retype(&mut host, a_id, &mut a, "South"); + + from_another_client( + &mut host, + a_id, + &mut a, + ModelCommand::SetCell { + cell: south.clone(), + value: CellValue::Number(999.0), + }, + ); + + let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes"); + let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap()); + assert!( + answer.iter().any(|f| matches!( + f.msg, + ServerFrame::Rejected(Rejection::RecordMoved(h)) if h == south.record + )), + "the destination moved under this client: {answer:?}" + ); + assert_eq!(region(&host, "South"), Some(CellValue::Number(999.0))); + assert_eq!(region(&host, "North"), Some(CellValue::Number(1.0))); +} + +/// The control for the test above: a destination this client HAS seen at its +/// current version is overwritten, which is improvise-kndq's policy, unchanged. +#[test] +fn a_rename_onto_a_record_this_client_has_seen_still_overwrites_it() { + let mut host = three_records_host(); + let (a_id, mut a, _) = records_client_at_coordinate(&mut host); + press(&mut host, a_id, &mut a, "i"); + retype(&mut host, a_id, &mut a, "South"); + let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes"); + let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap()); + assert!( + answer + .iter() + .any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + "{answer:?}" + ); + assert_eq!(region(&host, "South"), Some(CellValue::Number(1.0)), "North's value"); + assert_eq!(region(&host, "North"), None); +} From 3e435e88dfcf3d3b53aadf4d9e9ad916e1c355e7 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:20:36 -0700 Subject: [PATCH 027/114] Refuse a rename retry after RecordGone; check the rename's destination improvise-20ek: a RecordGone on the record a rename named pins the editor's target as Gone, so a retry declines instead of renaming whatever row the cursor now sits on. RecordMoved still recaptures, which is the force. improvise-milr: the server adds the rename's destination record, when it exists and differs from the source, to the records a frame answers for (records_at_stake). The client cannot compute that hash (ADR 002), so it claims every record counter it held when the editor opened. Also: ModelCommand has 25 variants, not 24. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/ui/reduce.rs | 26 +++++++++-- crates/improvise-protocol/src/client.rs | 53 +++++++++++++++++++---- crates/improvise-protocol/src/resolve.rs | 22 +++++++--- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index 2b2b1562..bde90602 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -283,16 +283,36 @@ pub fn stale_basis(app: &App, basis: &Basis, cmd: &ModelCommand) -> Option Vec { + let mut records = records_named(cmd); + if let ModelCommand::RenameRecordCoordinate { + record, + category, + item, + } = cmd + { + let destination = app + .resolve_record(*record) + .map(|source| RecordHash::of(&source.with(category, item))) + .filter(|dest| dest != record) + .filter(|dest| app.model_state.workbook.model.data.resolve_record(*dest).is_some()); + records.extend(destination); + } + records +} + /// The record tier for a command inside a frame whose document tiers are /// already answered, skipping the records the frame is about to create. fn stale_records_in_a_frame(app: &App, basis: &Basis, cmd: &ModelCommand) -> Option { - records_named(cmd) + records_at_stake(app, cmd) .into_iter() .filter(|hash| !will_be_created_by_the_frame(app, basis, *hash)) .find_map(|hash| stale_record(app, basis, hash)) @@ -321,7 +341,7 @@ pub fn caught_up(app: &App, cmd: ModelCommand) -> Command { basis: Basis { document_generation: app.document_generation(), layout_version: app.layout_version(), - records: records_named(&cmd) + records: records_at_stake(app, &cmd) .into_iter() .map(|h| (h, app.record_version_of(h))) .collect(), diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 80f6f2bb..69ad1fec 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -2,7 +2,8 @@ //! The two properties this dispatch owes: `context/code/clients.md`. use crate::{ - BasisCache, CategoryTreeCache, Command, DocumentCache, FormulaCache, GridCache, Projection, + BasisCache, CategoryTreeCache, Command, DocumentCache, FormulaCache, GridCache, ModelCommand, + Projection, Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, reduce_view, settled_mode, }; @@ -47,16 +48,25 @@ pub struct ClientState { /// The coordinate an open editor renames, captured as the user saw it. /// When it is taken, and when not yet: `context/code/clients.md`. #[serde(skip)] - pub opened: Option, + pub opened: Option, } -/// A coordinate editor's target: the record, the category, and the record's -/// write counter when the editor opened (improvise-fdk1, improvise-0j0f). +/// What an open coordinate editor renames. `Gone` is a refusal's answer, kept +/// so a retry cannot silently retarget (improvise-20ek, `clients.md`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Opened { + Target(OpenedCoordinate), + Gone(RecordHash), +} + +/// A coordinate editor's target, and every record counter this client held when +/// it opened: the destination is among them if shown (improvise-fdk1, -milr). #[derive(Debug, Clone, PartialEq, Eq)] pub struct OpenedCoordinate { pub record: RecordHash, pub category: String, pub version: u64, + pub seen: Vec<(RecordHash, u64)>, } /// One frame this client has sent. `awaited` is true exactly when the host @@ -138,8 +148,11 @@ impl ClientState { // A refused *save* answers no `Commands` frame, so it must not consume // one's entry — `Rejection::refused_an_edit` is that question. if rejection.refused_an_edit() { - self.in_flight.pop_front(); + let dropped = self.in_flight.pop_front(); self.replay(); + if let Some(gone) = renamed_away(dropped.as_ref(), rejection) { + self.opened = Some(Opened::Gone(gone)); + } } self.last_rejection = Some(rejection); self.settle_mode(); @@ -188,12 +201,13 @@ impl ClientState { (true, Some(opened)) => Some(opened), (true, None) if answered => { crate::resolve::coordinate_at_cursor(self).and_then(|(record, category)| { - let version = self.basis.current()?.record(record)?; - Some(OpenedCoordinate { + let basis = self.basis.current()?; + Some(Opened::Target(OpenedCoordinate { record, category, - version, - }) + version: basis.record(record)?, + seen: basis.records.clone(), + })) }) } (true, None) => None, @@ -231,6 +245,27 @@ impl ClientState { } } +/// The record a refused frame was renaming, when the refusal says that very +/// record is gone. Only this refusal pins the editor: `RecordMoved` is a force. +fn renamed_away(sent: Option<&Sent>, rejection: Rejection) -> Option { + let Rejection::RecordGone(gone) = rejection else { + return None; + }; + sent? + .cmds + .iter() + .any(|cmd| { + matches!( + cmd, + Command::Model { + cmd: ModelCommand::RenameRecordCoordinate { record, .. }, + .. + } if *record == gone + ) + }) + .then_some(gone) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index c012cd84..fa07428b 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -4,7 +4,7 @@ use improvise_core::view::Axis; use serde::{Deserialize, Serialize}; -use crate::client::ClientState; +use crate::client::{ClientState, Opened, OpenedCoordinate}; use crate::frame::ClientFrame; use crate::keymap::{Binding, Keymaps, ModeKey}; use crate::viewmodel::{CatTreeEntry, axis_change_status, flatten_cat_tree}; @@ -915,6 +915,9 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option vec![Command::Model { - basis: state - .basis - .claim_as_read([(opened.record, opened.version)])?, + (false, false, Some(Opened::Gone(_))) => { + return Some(vec![Command::SetStatus(RECORD_RENAMED_AWAY.to_string())]); + } + (false, false, Some(Opened::Target(opened))) => vec![Command::Model { + basis: state.basis.claim_as_read(claimed(opened))?, cmd: ModelCommand::RenameRecordCoordinate { record: opened.record, category: opened.category.clone(), @@ -954,6 +958,14 @@ fn rename_coordinate( Some(commands) } +/// The rename's claim: its own record, then every record shown when the editor +/// opened, so the destination is claimed if seen (improvise-milr). +fn claimed(opened: &OpenedCoordinate) -> impl Iterator + '_ { + let others = opened.seen.iter().copied(); + std::iter::once((opened.record, opened.version)) + .chain(others.filter(|(h, _)| *h != opened.record)) +} + /// The write a commit makes: none when the buffer is the seed the editor /// captured as it opened, and only the write is skipped (improvise-ixxx). fn written(state: &ClientState, id: &CellId, text: &str) -> Option> { From efca099caeece81d9c6ba5054e859723c73e786f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:40:37 -0700 Subject: [PATCH 028/114] improvise-hv0r/jpba: relays settle after their frame; pin the echo's audience and kinds Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/ui/host.rs | 147 ++++++++++++++++++++++++ crates/improvise-protocol/src/client.rs | 33 +++++- 2 files changed, 177 insertions(+), 3 deletions(-) diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 0028ace2..67e7c176 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -1801,6 +1801,153 @@ mod tests { } } + /// A client connected to `host`, bootstrapped from its own `Snapshot`. + fn connected_client(host: &mut Host) -> (SessionId, improvise_protocol::ClientState) { + let (id, snapshot) = host.connect(10, 10); + let mut client = improvise_protocol::ClientState::default(); + match snapshot.msg { + ServerFrame::Snapshot(s) => client.bootstrap(*s), + other => panic!("setup: connect answers a Snapshot, got {other:?}"), + } + (id, client) + } + + /// A frame sent the way the page sends one, and every answer folded the way the + /// page folds it: accepted, refused or relayed. + fn exchange( + host: &mut Host, + id: SessionId, + client: &mut improvise_protocol::ClientState, + frame: Vec, + ) -> Vec { + client.fold_outgoing(&frame); + let answer = host.handle(id, ClientFrame::Commands(frame)); + for msg in to(&answer, id) { + match msg { + ServerFrame::Commands(cmds) => cmds.iter().for_each(|c| client.fold(c)), + ServerFrame::Accepted => client.accepted(), + ServerFrame::Rejected(r) => client.refused(r), + other => panic!("an unexpected answer: {other:?}"), + } + } + answer + } + + /// Everything but projections one session heard, in arrival order. + fn relayed_to(frames: &[Addressed], id: SessionId) -> Vec { + to(frames, id) + .into_iter() + .flat_map(|msg| match msg { + ServerFrame::Commands(cmds) => cmds, + _ => Vec::new(), + }) + .filter(|c| !matches!(c, Command::Project(_))) + .collect() + } + + /// improvise-hv0r: the echo is the asker's alone, is every placement AFTER the + /// reset and nothing before it. Evidence: `context/test-evidence/command.md`. + #[test] + fn a_reframing_frame_echoes_only_its_later_placements_and_only_to_the_asker() { + use improvise_protocol::AppMode; + + let mut host = host(); + let (asker, mut client) = connected_client(&mut host); + let (bystander, mut other) = connected_client(&mut host); + // The bystander sits where an echo of the asker's placement would move it. + exchange( + &mut host, + bystander, + &mut other, + vec![Command::SetSelected { row: 0, col: 1 }], + ); + + let add = ModelCommand::AddCategory("Product".into()); + let frame = vec![ + Command::SetRowOffset(1), + Command::ChangeMode(AppMode::Normal), + crate::ui::reduce::caught_up(host.app(), add), + Command::SetSelected { row: 1, col: 0 }, + Command::SetColOffset(1), + ]; + let answer = exchange(&mut host, asker, &mut client, frame); + + assert_eq!( + relayed_to(&answer, asker), + vec![ + Command::ResetCursor, + Command::SetSelected { row: 1, col: 0 }, + Command::SetColOffset(1), + ], + "the asker is owed the reset, then exactly the placements that followed it" + ); + assert_eq!( + relayed_to(&answer, bystander), + vec![Command::ResetCursor], + "a bystander is told its cursor is stale, never where somebody else put theirs" + ); + let mirror = host.sessions.get(asker).unwrap().view().cursor(); + assert_eq!( + (mirror.selected, mirror.row_offset, mirror.col_offset), + ((1, 0), 0, 1) + ); + assert_eq!( + client.view.cursor(), + mirror, + "the asker's client and its mirror disagree" + ); + } + + /// improvise-jpba: a refusal rolled the client back past the relays it had + /// heard, while its mirror kept them. Evidence: `context/test-evidence/command.md`. + #[test] + fn a_refusal_after_a_reframe_rolls_back_to_where_the_mirror_is() { + use improvise_protocol::AppMode; + + // A bare reframe, and the ticket's placed shape with a pre-reset offset. + for placed in [false, true] { + let mut host = host(); + let (asker, mut client) = connected_client(&mut host); + exchange( + &mut host, + asker, + &mut client, + vec![Command::SetSelected { row: 1, col: 0 }], + ); + // Composed now, sent after the reframe: stale, and so refused. + let other = ModelCommand::AddCategory("Other".into()); + let stale = crate::ui::reduce::caught_up(host.app(), other); + + let product = ModelCommand::AddCategory("Product".into()); + let reframe = crate::ui::reduce::caught_up(host.app(), product); + let frame = match placed { + false => vec![Command::ChangeMode(AppMode::Normal), reframe], + true => vec![ + Command::SetRowOffset(1), + Command::ChangeMode(AppMode::Normal), + reframe, + Command::SetSelected { row: 1, col: 0 }, + ], + }; + exchange(&mut host, asker, &mut client, frame); + let answer = exchange(&mut host, asker, &mut client, vec![stale]); + assert!( + to(&answer, asker) + .iter() + .any(|m| matches!(m, ServerFrame::Rejected(_))), + "setup, placed={placed}: the stale frame must be refused: {answer:?}" + ); + + let mirror = host.sessions.get(asker).unwrap().view().cursor(); + assert_eq!( + client.view.cursor(), + mirror, + "placed={placed}: the refusal rolled the client back past a relay" + ); + assert_eq!(client.settled.cursor(), mirror, "placed={placed}: settled"); + } + } + /// A refused batch is answered to the **asker and nobody else** (ADR 001): /// nothing happened, so there is nothing to tell the others. #[test] diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index a2615b56..495f3413 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -51,6 +51,9 @@ pub struct ClientState { pub struct Sent { pub cmds: Vec, pub awaited: bool, + /// Heard from the host rather than sent: it settles after the frame it + /// arrived during, never with it (improvise-jpba, `clients.md`). + pub relayed: bool, } impl ClientState { @@ -78,9 +81,32 @@ impl ClientState { self.revision += 1; } - /// Fold one command. Total and deterministic: a command naming something - /// the client does not have is dropped, never a panic. + /// Fold one command **the host sent**, and keep it for the settled slice. + /// Total: a command naming something absent is dropped, never a panic. pub fn fold(&mut self, cmd: &Command) { + self.settle_relay(cmd); + self.apply(cmd); + } + + /// A relay the host has already applied to this client's mirror, so a + /// later refusal must not roll it back. Where it queues: `clients.md`. + fn settle_relay(&mut self, cmd: &Command) { + let behind = 1 + self.in_flight.iter().skip(1).take_while(|s| s.relayed).count(); + match (cmd, self.in_flight.is_empty()) { + (Command::Project(_), _) => {} + (_, true) => reduce_view(&mut self.settled, cmd), + (_, false) => self.in_flight.insert( + behind, + Sent { + cmds: vec![cmd.clone()], + awaited: false, + relayed: true, + }, + ), + } + } + + fn apply(&mut self, cmd: &Command) { match cmd { Command::Project(projection) => self.apply_projection(projection), other => reduce_view(&mut self.view, other), @@ -97,10 +123,11 @@ impl ClientState { self.in_flight.push_back(Sent { cmds: cmds.to_vec(), awaited, + relayed: false, }); self.settle_sent(); for cmd in cmds { - self.fold(cmd); + self.apply(cmd); } } From d6706774a22e8c63fc4a7b6f1a872c6fd12793ea Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:59:18 -0700 Subject: [PATCH 029/114] tooling: CI runs checkers.sh --ci ; the gate reads a table, not shell improvise-st8q, improvise-tvte, from the #228 re-review at c1417359. The maax parser still failed open. A bare checkers.sh inside a heredoc or a folded run: value counted as every suite, and step and job if: were ignored. Every round of parsing met another shell or YAML form, so the parsing is gone. checkers.sh holds a job-to-suites table (ci_suites), and each CI job makes one call, `checkers.sh --ci `. check-ci-graph.py counts a step only when all of these hold: it sits inside steps: at step-key indentation, its whole run: value is that call, the call names its own job, and no if:, continue-on-error:, shell: or defaults: covers it. Every other mention of scripts/checkers.sh is reported. A key with an inline value owns deeper lines, and an open quote owns lines until it closes, so heredocs, folds and quoted values are content, never structure. There are 19 suite self-test cases and 7 rivals, each failing a case only it can. The reviewer's heredoc, fold and if: attacks on the real ci.yml now exit 1. checkers-test.sh gains five --ci cases. context/code/tooling.md corrects two overstated claims ("--would-run anywhere is a usage error", "fails closed"). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .github/workflows/ci.yml | 29 +-- context/code/tooling.md | 59 +++-- context/test-evidence/tooling.md | 62 ++++++ scripts/check-ci-graph.py | 367 +++++++++++++++++++------------ scripts/checkers-test.sh | 13 ++ scripts/checkers.sh | 29 ++- 6 files changed, 375 insertions(+), 184 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4646ac2d..0e1b9526 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: # assembly still exited 0 (improvise-w8ih). It guards the assemble step # below, so it fails under this job's name rather than another's. - name: The stamp gate refuses what it claims to - run: ./scripts/checkers.sh assemble-stamp + run: ./scripts/checkers.sh --ci build - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown @@ -248,7 +248,7 @@ jobs: # The guard above is only worth its line if its refusals are themselves # checked -- the step it replaces was green while asserting nothing. - name: The node test runner refuses what it claims to - run: ./scripts/checkers.sh node-test + run: ./scripts/checkers.sh --ci js kb: name: Knowledge base consistency @@ -263,27 +263,12 @@ jobs: run: | nix shell nixpkgs#trealla --command tpl prolog-sessions/kb.pl \ -g "(kb_consistent -> halt(0) ; (kb_report, halt(1)))" - # scripts/kb is the only thing that writes kb.pl, so its refusals are as - # load-bearing as kb_consistent itself. Without this the replacement - # writer could regress green -- the review that found the directive - # bypass also found that nothing ran this suite. - - name: The kb tool refuses what it claims to - run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh kb # Every suite goes through scripts/checkers.sh, which pins a bash >= 5 - # and scrubs the environment, so the shell and the variables a suite - # passes under here are the ones it passes under locally (improvise-f9qf). - - name: The checker runner refuses what it claims to - run: ./scripts/checkers.sh --self-test - # pr-watch is driven entirely from fixtures. The release preflight is the - # one guard whose absence is invisible until a tag is public and - # immutable. pr-reviews stands between a merge and an unread review, and - # was blind to inline comments for as long as it existed (improvise-xgra). - # fmt-workspaces is here because its own failure mode is the bug it - # exists for: a usage error wearing a formatting failure's exit code. - # bd-audit-ids is the dead-ID audit, whose recipe once read a bd that - # never ran as a clean audit (improvise-52eq). - - name: The remaining checkers refuse what they claim to - run: ./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids + # and scrubs the environment (improvise-f9qf). Which suites this job runs + # is the `kb` row of the CI table in checkers.sh, deliberately not listed + # here. check-ci-graph.py holds that table to `checkers.sh --list`. + - name: Every checker in scripts/ refuses what it claims to + run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh --ci kb # --------------------------------------------------------------------------- # Lints the one crate the `test` job cannot reach. diff --git a/context/code/tooling.md b/context/code/tooling.md index 5f1aa084..c81e2f13 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -266,21 +266,50 @@ under a nix trealla, `node-test` in the `js` job), so a single bare `checkers.sh` was not an option. The check does not re-derive what a suite is. It asks `checkers.sh --list`, so the naming rule keeps its one home. -**Nor does it re-derive what an invocation runs** (improvise-maax). The first -version did, and got `checkers.sh`'s argument rules wrong in the direction that -passes. It read `--list` as "run everything", when `--list` runs nothing. It -counted the names after `--self-test`, which `checkers.sh` throws away. And it -read any mention of `scripts/checkers.sh` as a command: in a trailing comment, -a step `name:`, or `echo`'s argument. Now `checkers.sh --would-run ` -answers what `` would run, from the same argument loop and name -resolution the real run uses. `--would-run` is honoured only as the first -argument, so a CI step that spells it anywhere is a usage error rather than a -green step that ran nothing. The check itself only finds invocations. It reads -the shell text of `run:` values alone, strips a comment by bash's rule (a `#` -at the start of a word, outside quotes, so `nixpkgs#trealla` survives), splits -at `;`, `&&` and `|`, and counts a command only when `checkers.sh` is its -command word or what `nix shell … --command` runs. A line `shlex` cannot parse -contributes nothing, which fails closed. +**What CI runs is a table in `checkers.sh`, not something read out of shell** +(improvise-st8q, improvise-tvte). Two rounds of review each found the gate +passing while a suite never ran, and every finding was the gate +*interpreting* `ci.yml`. It read `--list` as "run everything". It counted the +names after `--self-test`. Then it read a bare `scripts/checkers.sh` inside a +heredoc, a folded `>` value or a multi-line string as a command, and it ignored +`if:`. Each fix added parsing, and shell and YAML always had another form. So +the parsing went. `checkers.sh` holds a job→suites table, `ci_suites`, and each +CI job has exactly one step whose whole `run:` value is +`./scripts/checkers.sh --ci `. A `nix shell --command` prefix is +allowed. `--ci` runs that row and nothing else, and `--ci` beside a suite name +or `--self-test` is a usage error. `checkers.sh --would-run --ci ` reports +the row without running it, so the table has one home. + +The gate reads no shell. It counts a step only when all of these hold: +- the step sits in a job's `steps:` sequence, at step-key indentation; +- its `run:` value is exactly that call; +- the call names its own job; +- neither the step nor the job carries `if:`, `continue-on-error:` or `shell:`; +- no `defaults:` block, at workflow or job level, covers it, since that can + change the shell the step runs under. + +The only YAML it models is what makes a line structure rather than content. A +key with an inline value owns every following line indented deeper, and an +open quote owns every line until it closes. So a heredoc, a folded or plain +continuation, a quoted value or a whole fake job inside a top-level block +scalar is skipped as content. Any line outside a YAML comment that mentions +`scripts/checkers.sh` and is not a counted step is **reported**, not ignored. +The union of the counted jobs' rows must cover `checkers.sh --list`. + +**What this does not close, stated rather than guarded.** Once a counted step +is reached, the gate trusts that it runs the script it names. An earlier step +in the same job that rewrote `scripts/checkers.sh` or its table would defeat +that. Shell control flow *around* the call cannot hide in the counted step, +because its value is exactly the call. But the gate cannot see a job-level +`needs:` whose failure skips the job, which is why `pages-deploy` must still +`needs:` every job. + +**Two claims earlier versions of this section made were wrong.** "`--would-run` +anywhere but first is a usage error" was false: `checkers.sh --list +--would-run` exits 0 at `--list` before it reaches the second flag. And "a line +`shlex` cannot parse fails closed" was true of that one line, but read as a +property of the gate, which at that point still passed on heredocs and folded +values. ### pr-watch and mergeability (improvise-edpl, improvise-zlj2) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index ba9f01af..bc0da11b 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -937,3 +937,65 @@ parsed now contributes no invocation, which fails closed. Everything was restored (`cmp` for `checkers.sh`) and re-run: `--self-test` reports 16 suite lists and `checkers-test.sh` 17/17. A planted `zz-planted` suite still fails the real run, naming it. + +### The suite gate reads a table, not shell — `scripts/check-ci-graph.py` (improvise-st8q, improvise-tvte) + +**What broke, twice over.** The #228 re-review at `c1417359` showed the maax +parser still failing open. On the real `ci.yml`, two changes each turned +exit 1 into exit 0: +- a `GITHUB_STEP_SUMMARY` heredoc holding a bare `./scripts/checkers.sh`; +- folding the kb step as `run: >`. + +That is because `run:` values were read one line at a time. `"--command" in +words` also matched any command at all. And step and job `if:` were ignored +(tvte). Each round of parsing had been answered by another shell or YAML form. + +**The structural fix.** `checkers.sh` owns a job→suites table, and each CI job +makes one call, `checkers.sh --ci `. The gate parses no shell. It counts a +step only when all of these hold: +- the step sits at step-key indentation inside `steps:`; +- its whole `run:` value is that call; +- the call names its own job; +- the step carries no `if:`, `continue-on-error:`, `shell:` or covering + `defaults:`. + +Every other line mentioning `scripts/checkers.sh` is reported. A key with an +inline value owns deeper lines, and an open quote owns lines until it closes, +so content is never read as structure. + +**The reviewer's attacks replayed on the real `ci.yml`** (kb step mutated, +restored after each): +- the heredoc: exit 1, naming the line and the seven unrun suites; +- `run: >`: exit 1, the same; +- `if:` on the kb step: exit 1, "conditional (if)". + +**Self-test: 19 suite lists.** Every case asks the real `checkers.sh` table. + +**Rivals, each a source edit loaded and run against the real `--self-test`, +each failing a case only it can:** +- **No quote tracking:** fails "an exact run: line inside a multi-line quoted + value does not count" and nothing else. The first quoted case could not + isolate this rule, because its content line was not `run:`-shaped anyway. + It was replaced. +- **No owner rule:** fails "a whole job inside a top-level block scalar does not + count". +- **Disqualifiers ignored:** fails the step `if:`, job `if:`, job `if:` after + its steps, `continue-on-error` and both `defaults:` cases. +- **Any job may run any row:** fails "a job running another job's row is + refused". +- **The call may be embedded in other shell:** fails "shell control flow around + the call does not count". +- **Stray mentions not reported:** fails the nine cases that expect a line to + be named. +- **Steps counted outside `steps:`:** fails "a step-shaped item under a job key + other than steps: does not count". The first form of this rival passed, + because it changed only where a step starts while the step-key match still + required `steps:`. The rival was rewritten to treat every job key with an + empty value as opening steps. + +**`checkers-test.sh` gains five `--ci` cases.** The rival that drops "`--ci` +takes a job and nothing else" fails two of them, `--ci` beside a suite name +and beside `--self-test`, each exit 0 where 4 was wanted. + +**Stated, not guarded:** a step earlier in the same job that rewrites +`scripts/checkers.sh` or its table (`context/code/tooling.md`). diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index b70048eb..bdbc57b4 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -8,14 +8,14 @@ nothing noticed, so the rule is a gate. See context/code/tooling.md. Second rule: every suite `scripts/checkers.sh --list` knows must run in some CI step. ci.yml names its suites by hand, so a new `*-test.sh` ran locally and -never in CI until someone remembered to add it (improvise-dak5). What a step -runs is asked of `checkers.sh --would-run`, never re-derived (improvise-maax). +never in CI until someone remembered to add it (improvise-dak5). Each job runs +`checkers.sh --ci `, whose table lives in checkers.sh; no shell is parsed +here, and what a row runs is asked of `--would-run` (improvise-maax, st8q). `--self-test` checks the checker: each case is a graph this must refuse. """ import re -import shlex import subprocess import sys @@ -67,88 +67,113 @@ def verdict(jobs, needs): return out -_RUN = re.compile(r"^(\s*)(?:-\s+)?run:\s?(.*)$") -_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") -_OPERATOR = set(";&|()") +_JOB_KEY = re.compile(r"^ ([A-Za-z_][\w-]*):(.*)$") +_STEP_START = re.compile(r"^ - ") +_STEP_KEY = re.compile(r"^ (?:- | )([A-Za-z_][\w-]*):(.*)$") +_ANY_KEY = re.compile(r"^(\s*)(?:- )?([A-Za-z_][\w-]*):(.*)$") +_CI_CALL = re.compile(r"^(?:nix shell \S+ --command )?\./scripts/checkers\.sh --ci ([\w-]+)$") +_DISQUALIFY = ("if", "continue-on-error", "shell") +_JOB_DISQUALIFY = _DISQUALIFY + ("defaults",) -def run_scripts(text): - """The shell text of every `run:` value. Nothing else in ci.yml is a - command, so a step `name:` or a YAML comment never reads as one.""" - lines, out, i = text.split("\n"), [], 0 - while i < len(lines): - m = _RUN.match(lines[i]) - i += 1 - if not m: - continue - indent, value = len(m.group(1)), m.group(2).strip() - if value[:1] not in ("|", ">"): - out.append(value) - continue - body = [] - while i < len(lines) and (not lines[i].strip() or - len(lines[i]) - len(lines[i].lstrip()) > indent): - body.append(lines[i]) - i += 1 - out.append("\n".join(body)) - return out +def _inline_value(rest): + """A key's inline value, without a YAML comment (`#` after whitespace).""" + return re.sub(r"\s+#.*$", "", rest).strip() -def strip_comment(line): - """Bash's rule: `#` opens a comment only at the start of a word and outside - quotes, so `nixpkgs#trealla` survives and `echo x # checkers.sh` does not.""" - quote, prev = None, " " - for j, c in enumerate(line): - if quote: - if c == quote: +def _opens_quote(value): + """True when a quoted inline value is not closed on its own line.""" + if value[:1] not in ("'", '"') or len(value) < 2: + return value[:1] in ("'", '"') + q, body = value[0], value[1:] + if q == "'": + return not body.replace("''", "").endswith("'") + return not re.sub(r'\\.', "", body).endswith('"') + + +def ci_steps(text): + """(job, value, disqualifiers) for every step of every job, and every line + mentioning scripts/checkers.sh that is not a step's whole `run:` value. + + Nothing is read as shell. A key with an inline value owns every following + line indented deeper, which is how a block scalar, a folded value, a plain + continuation or a heredoc inside one is skipped whole (improvise-st8q).""" + steps, stray = [], [] + job, in_steps, job_flags, step = None, False, set(), None + workflow_flags = set() + owner, quote = None, None + + def close_step(): + if step is not None: + steps.append(step) + + for n, raw in enumerate(text.split("\n"), 1): + indent = len(raw) - len(raw.lstrip()) + if quote is not None: + if quote in raw: quote = None - elif c in "'\"": - quote = c - elif c == "#" and (prev.isspace() or prev in _OPERATOR): - return line[:j] - prev = c - return line - - -def simple_commands(script): - """Each simple command in a script, as its words with quotes removed.""" - for line in script.replace("\\\n", " ").split("\n"): - lexer = shlex.shlex(strip_comment(line), posix=True, punctuation_chars=True) - lexer.whitespace_split = True - lexer.commenters = "" - try: - tokens = list(lexer) - except ValueError: - continue # unparseable: no invocation, so it fails closed - words = [] - for tok in tokens: - if set(tok) <= _OPERATOR: - if words: - yield words - words = [] + if "scripts/checkers.sh" in raw: + stray.append(n) + continue + if owner is not None and (not raw.strip() or indent > owner): + if "scripts/checkers.sh" in raw: + stray.append(n) + continue + owner = None + if not raw.strip() or raw.lstrip().startswith("#"): + continue + if raw.startswith("defaults:"): + workflow_flags.add("workflow defaults") + m = _JOB.match(raw) + if m: + close_step() + step = None + job, in_steps, job_flags = m.group(1), False, set() + continue + m = _JOB_KEY.match(raw) + if m and job is not None: + close_step() + step = None + key, value = m.group(1), _inline_value(m.group(2)) + in_steps = key == "steps" and value == "" + if key in _JOB_DISQUALIFY: + job_flags.add(key) + for s in steps: + if s["job"] == job: + s["flags"].add("job " + key) + if in_steps and _STEP_START.match(raw): + close_step() + step = {"job": job, "run": None, "flags": {"job " + f for f in job_flags}, + "line": n} + m = _STEP_KEY.match(raw) if in_steps and step is not None else None + if m: + key, value = m.group(1), _inline_value(m.group(2)) + if key == "run": + step["run"] = value + if "scripts/checkers.sh" in raw and not _CI_CALL.match(value): + stray.append(n) + elif key in _DISQUALIFY: + step["flags"].add(key) + elif "scripts/checkers.sh" in raw: + stray.append(n) + elif "scripts/checkers.sh" in raw: + stray.append(n) + k = _ANY_KEY.match(raw) + if k and _inline_value(k.group(3)): + value = _inline_value(k.group(3)) + if _opens_quote(value): + quote = value[0] else: - words.append(tok) - if words: - yield words - - -def checkers_invocations(text): - """The arguments of every command that IS checkers.sh: the command word - after any VAR=value, or the program `nix shell ... --command` runs.""" - for script in run_scripts(text): - for words in simple_commands(script): - k = 0 - while k < len(words) and _ASSIGN.match(words[k]): - k += 1 - if "--command" in words[k:]: - k = words.index("--command", k) + 1 - if k < len(words) and words[k].endswith("scripts/checkers.sh"): - yield words[k + 1:] + owner = len(k.group(1)) + (2 if raw.lstrip().startswith("- ") else 0) + close_step() + for s in steps: + s["flags"] |= workflow_flags + return steps, stray def would_run(args): - """What checkers.sh itself says these arguments run. Its argument rules are - not restated here: --list and --self-test mean what the script makes them.""" + """What checkers.sh itself says these arguments run. Its argument rules and + its CI table are not restated here (improvise-maax).""" out = subprocess.run(["scripts/checkers.sh", "--would-run", *args], capture_output=True, text=True).stdout ran, missing = set(), set() @@ -160,22 +185,33 @@ def would_run(args): return ran, missing -def suites_run(text): - """(suites some step runs, names a step asks for that are not suites).""" - ran, missing = set(), set() - for args in checkers_invocations(text): - r, m = would_run(args) - ran |= r - missing |= m - return ran, missing +def _unrun(s): + return f"`{s}` is a checkers.sh suite that no CI step runs; add it to ci.yml" -def suite_verdict(suites, run): - """The complaints about suites CI never runs, or asks for by a wrong name.""" - ran, missing = run - out = [f"`{s}` is a checkers.sh suite that no CI step runs; add it to ci.yml" - for s in sorted(set(suites) - ran)] - out += [f"ci.yml runs checkers.sh `{s}`, which is not a suite" +def suite_verdict(suites, text): + """The complaints: suites no counted step runs, and every step or line that + does not count, each named so the fix is obvious rather than silent.""" + steps, stray = ci_steps(text) + out, ran, missing = [], set(), set() + for s in steps: + m = _CI_CALL.match(s["run"] or "") + if not m: + continue + if m.group(1) != s["job"]: + out.append(f"job `{s['job']}` runs `checkers.sh --ci {m.group(1)}`; " + "a job runs only its own row of the CI table") + elif s["flags"]: + out.append(f"job `{s['job']}`'s `checkers.sh --ci` step is conditional " + f"({', '.join(sorted(s['flags']))}), so it does not count") + else: + r, mi = would_run(["--ci", s["job"]]) + ran |= r + missing |= mi + out += [f"ci.yml line {n} mentions scripts/checkers.sh outside a step whose " + "whole `run:` is `./scripts/checkers.sh --ci `" for n in stray] + out += [_unrun(s) for s in sorted(set(suites) - ran)] + out += [f"checkers.sh's CI table names `{s}`, which is not a suite" for s in sorted(missing)] return out @@ -187,75 +223,118 @@ def list_suites(): return [s for s in out.split("\n") if s.strip()] +def _wf(*jobs): + return "jobs:\n" + "".join(jobs) + + +def _job(name, *steps, head=""): + return f" {name}:\n runs-on: x\n{head} steps:\n" + "".join(steps) + + +_BUILD = _job("build", " - run: ./scripts/checkers.sh --ci build\n") +_REQ = ["assemble-stamp", "pr-watch"] +_NO_PW = [_unrun("pr-watch")] + + +def _stray(n): + return (f"ci.yml line {n} mentions scripts/checkers.sh outside a step whose " + "whole `run:` is `./scripts/checkers.sh --ci `") + + # (name, suites to require, workflow text, the complaints it must draw). Every -# case asks the REAL checkers.sh. A false "bare run" must be the ONLY way pr-watch runs. -_UNRUN = "`zz-new` is a checkers.sh suite that no CI step runs; add it to ci.yml" -_NO_PW = "`pr-watch` is a checkers.sh suite that no CI step runs; add it to ci.yml" -_KB_PW = ["kb", "pr-watch", "zz-new"] -_REQ = ["kb", "pr-watch"] -_ONLY_KB = " run: ./scripts/checkers.sh kb\n" +# case asks the REAL checkers.sh table, where `kb`'s row holds pr-watch. SUITE_SELF_TEST = [ - ("a suite no step runs is named", - _KB_PW, " run: ./scripts/checkers.sh kb pr-watch\n", [_UNRUN]), + ("two jobs each running their own row are accepted", _REQ, + _wf(_BUILD, _job("kb", " - run: nix shell nixpkgs#trealla --command " + "./scripts/checkers.sh --ci kb # the kb row\n")), []), - ("suites named across steps, one inside nix shell --command, are accepted", - ["kb", "pr-watch", "checkers"], - " run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh kb\n" - " run: ./scripts/checkers.sh --self-test\n" - " - run: ./scripts/checkers.sh pr-watch\n", []), + ("a job that never calls its row leaves its suites unrun", _REQ, + _wf(_BUILD, _job("kb", " - run: echo nothing\n")), _NO_PW), - ("a bare run covers every real suite", - ["kb", "pr-watch", "checkers"], " run: ./scripts/checkers.sh\n", []), + ("a call inside a heredoc in a block run: does not count (st8q)", _REQ, + _wf(_BUILD, _job("kb", " - run: |\n cat >> x <<'EOF'\n" + " ./scripts/checkers.sh --ci kb\n EOF\n")), + [_stray(11)] + _NO_PW), - ("--self-test runs checkers and DISCARDS the names beside it", - ["checkers", "kb"], " run: ./scripts/checkers.sh --self-test zz-new kb\n", - ["`kb` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + ("a folded run: does not count (st8q)", _REQ, + _wf(_BUILD, _job("kb", " - run: >\n ./scripts/checkers.sh --ci kb\n")), + [_stray(10)] + _NO_PW), - ("--list alone runs nothing", - ["kb"], " run: ./scripts/checkers.sh --list\n", - ["`kb` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + ("a plain continuation line does not count", _REQ, + _wf(_BUILD, _job("kb", " - run: echo\n ./scripts/checkers.sh --ci kb\n")), + [_stray(10)] + _NO_PW), - ("--list with names runs nothing either", - ["kb"], " run: ./scripts/checkers.sh --list kb zz-new\n", - ["`kb` is a checkers.sh suite that no CI step runs; add it to ci.yml"]), + ("an exact run: line inside a multi-line quoted value does not count", _REQ, + _wf(_BUILD, _job("kb", " - name: \"a\n" + " run: ./scripts/checkers.sh --ci kb\n b\"\n")), + [_stray(10)] + _NO_PW), - ("a bare mention after an inline shell comment is not a run (Codex, #228)", - _REQ, " run: echo skipped # ./scripts/checkers.sh\n" + _ONLY_KB, [_NO_PW]), + ("a whole job inside a top-level block scalar does not count", _REQ, + _wf(_BUILD) + "notes: |\n kb:\n steps:\n" + " - run: ./scripts/checkers.sh --ci kb\n", + [_stray(9)] + _NO_PW), - ("a suite named inside a trailing shell comment is not run", - _REQ, " run: ./scripts/checkers.sh kb # pr-watch\n", [_NO_PW]), + ("a step-shaped line inside a job-level block scalar does not count", _REQ, + _wf(_BUILD, _job("kb", " - run: echo\n", + head=" notes: |\n - run: ./scripts/checkers.sh --ci kb\n")), + [_stray(9)] + _NO_PW), - ("a command-shaped line in a non-run block scalar is not a run", - _REQ, - " - name: notes\n with:\n notes: |\n" - " ./scripts/checkers.sh\n " + _ONLY_KB, [_NO_PW]), + ("a step-shaped item under a job key other than steps: does not count", _REQ, + _wf(_BUILD, " kb:\n services:\n - run: ./scripts/checkers.sh --ci kb\n" + " steps:\n - run: echo\n"), + [_stray(8)] + _NO_PW), - ("a bare mention in a step name is not a run", - _REQ, " - name: Runs scripts/checkers.sh\n " + _ONLY_KB, [_NO_PW]), + ("a step under if: does not count (tvte)", _REQ, + _wf(_BUILD, _job("kb", " - if: false\n run: ./scripts/checkers.sh --ci kb\n")), + ["job `kb`'s `checkers.sh --ci` step is conditional (if), so it does not count"] + + _NO_PW), - ("a bare mention as echo's argument is not a run", - _REQ, " run: echo scripts/checkers.sh\n" + _ONLY_KB, [_NO_PW]), + ("a job under if: does not count (tvte)", _REQ, + _wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n", + head=" if: false\n")), + ["job `kb`'s `checkers.sh --ci` step is conditional (job if), so it does not count"] + + _NO_PW), - ("a bare mention in a YAML comment is not a run", - _REQ, " # Every suite goes through scripts/checkers.sh\n" + _ONLY_KB, - [_NO_PW]), + ("a job-level if: after its steps still disqualifies them", _REQ, + _wf(_BUILD, " kb:\n steps:\n - run: ./scripts/checkers.sh --ci kb\n" + " if: false\n"), + ["job `kb`'s `checkers.sh --ci` step is conditional (job if), so it does not count"] + + _NO_PW), - ("every invocation on a line counts, and quotes are removed", - _REQ, - " run: ./scripts/checkers.sh kb && ./scripts/checkers.sh 'pr-watch'\n", []), + ("a job's defaults: can change the shell, so it does not count", _REQ, + _wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n", + head=" defaults:\n run:\n shell: python {0}\n")), + ["job `kb`'s `checkers.sh --ci` step is conditional (job defaults), " + "so it does not count"] + _NO_PW), - ("a block run is read, continuation lines included", - _REQ, - " run: |\n set -e\n ./scripts/checkers.sh \\\n" - " kb pr-watch\n - name: next\n", []), + ("workflow-level defaults: disqualifies every step", ["assemble-stamp"], + "defaults:\n run:\n shell: python {0}\n" + _wf(_BUILD), + ["job `build`'s `checkers.sh --ci` step is conditional (workflow defaults), " + "so it does not count", _unrun("assemble-stamp")]), - ("a step spelling --would-run runs nothing, wherever it puts it", - _REQ, _ONLY_KB + " run: ./scripts/checkers.sh pr-watch --would-run\n" - " run: ./scripts/checkers.sh --would-run pr-watch\n", [_NO_PW]), + ("continue-on-error does not count", _REQ, + _wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n" + " continue-on-error: true\n")), + ["job `kb`'s `checkers.sh --ci` step is conditional (continue-on-error), " + "so it does not count"] + _NO_PW), - ("a step naming a suite that does not exist is caught", - ["kb"], " run: ./scripts/checkers.sh kb ghost\n", - ["ci.yml runs checkers.sh `ghost`, which is not a suite"]), + ("shell control flow around the call does not count", _REQ, + _wf(_BUILD, _job("kb", " - run: true || ./scripts/checkers.sh --ci kb\n")), + [_stray(9)] + _NO_PW), + + ("a job running another job's row is refused", _REQ, + _wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci build\n")), + ["job `kb` runs `checkers.sh --ci build`; a job runs only its own row of the CI table"] + + _NO_PW), + + ("a bare mention in a step name is refused", _REQ, + _wf(_BUILD, _job("kb", " - name: runs scripts/checkers.sh\n" + " run: ./scripts/checkers.sh --ci kb\n")), + [_stray(9)]), + + ("a YAML comment mentioning checkers.sh is fine", _REQ, + _wf(_BUILD, _job("kb", " # every suite goes through scripts/checkers.sh\n" + " - run: ./scripts/checkers.sh --ci kb\n")), []), ] @@ -295,7 +374,7 @@ def self_test(): if got != want: failures.append(f" {name}: expected {want}, saw {got}") for name, suites, text, want in SUITE_SELF_TEST: - got = suite_verdict(suites, suites_run(text)) + got = suite_verdict(suites, text) if got != want: failures.append(f" {name}: expected {want}, saw {got}") if failures: @@ -321,7 +400,7 @@ def main(): print("error: checkers.sh --list named no suites; the listing is wrong", file=sys.stderr) return 2 - complaints = verdict(jobs, needs) + suite_verdict(suites, suites_run(text)) + complaints = verdict(jobs, needs) + suite_verdict(suites, text) if complaints: for c in complaints: print(f"ci-graph: {c}", file=sys.stderr) diff --git a/scripts/checkers-test.sh b/scripts/checkers-test.sh index 6c9b8c37..2eb4a30a 100755 --- a/scripts/checkers-test.sh +++ b/scripts/checkers-test.sh @@ -205,5 +205,18 @@ expect "--would-run anywhere but first is a usage error" 4 "$d/checkers.sh" alph [ ! -e "$work/ran-alpha" ] && ok "and it runs nothing" \ || bad "and it runs nothing" "alpha ran" +# --------------------------------------------------------------------------- +# 9. --ci runs that job's row of the CI table and nothing else. The +# sandbox has none of the real suites, so a row reads back as `missing:`. +"$d/checkers.sh" --would-run --ci js > "$work/out" 2>&1 +got="$(tr '\n' ' ' < "$work/out")" +[ "$got" = "missing: node-test " ] && ok "--ci reads that job's row of the table" \ + || bad "--ci reads that job's row of the table" "got [$got]" +expect "--ci with a job the table does not have is exit 4" 4 "$d/checkers.sh" --would-run --ci nope +expect "--ci with nothing after it is exit 4" 4 "$d/checkers.sh" --would-run --ci +expect "--ci beside a suite name is exit 4, not a union" 4 \ + "$d/checkers.sh" --would-run --ci js alpha +expect "--ci beside --self-test is exit 4" 4 "$d/checkers.sh" --would-run --self-test --ci js + printf '\ncheckers-test: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] diff --git a/scripts/checkers.sh b/scripts/checkers.sh index 49b5c06a..e7ec3b7c 100755 --- a/scripts/checkers.sh +++ b/scripts/checkers.sh @@ -19,7 +19,7 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { cat >&2 <<'EOF' -usage: checkers.sh [--list] [--self-test] [name ...] +usage: checkers.sh [--list] [--self-test] [--ci ] [name ...] name a suite to run, spelled without the -test.sh suffix (e.g. `pr-reviews` for scripts/pr-reviews-test.sh). @@ -27,6 +27,9 @@ usage: checkers.sh [--list] [--self-test] [name ...] --list print the suite names and exit --self-test run scripts/checkers-test.sh, which injects the faults this runner exists to catch + --ci run the suites the CI table gives that job of + .github/workflows/ci.yml, and nothing else. Each CI job runs + exactly this, so the table is the one list of what CI runs --would-run as the FIRST argument only: run nothing, and print `would-run: ` (or `missing: `) for each suite the rest of the arguments would run. check-ci-graph.py @@ -110,8 +113,19 @@ run_suite() { env "${env_args[@]}" "$pinned" "$file" } +# The CI table: the suites each job of .github/workflows/ci.yml runs, by that +# job's `checkers.sh --ci ` step. check-ci-graph.py holds it to --list. +ci_suites() { + case "$1" in + build) echo assemble-stamp ;; + js) echo node-test ;; + kb) echo kb checkers pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids ;; + *) return 1 ;; + esac +} + main() { - local self_test=0 would_run=0 + local self_test=0 would_run=0 ci_job="" local -a names=() # Only as the FIRST argument, so a CI step spelling it anywhere is an error. if [ "${1:-}" = --would-run ]; then would_run=1; shift; fi @@ -119,6 +133,7 @@ main() { case "$1" in --list) suites; return 0 ;; --self-test) self_test=1 ;; + --ci) ci_job="${2:-}"; [ -n "$ci_job" ] || { usage; return 4; }; shift ;; -h|--help) usage; return 4 ;; -*) printf 'checkers: unknown option %s\n' "$1" >&2; usage; return 4 ;; *) names+=("$1") ;; @@ -126,7 +141,15 @@ main() { shift done - if [ "$self_test" = 1 ]; then + if [ -n "$ci_job" ]; then + if [ "$self_test" = 1 ] || [ ${#names[@]} -gt 0 ]; then + echo 'checkers: --ci takes a job and nothing else' >&2; return 4 + fi + local table + table="$(ci_suites "$ci_job")" || { + printf 'checkers: no CI job %s in the table\n' "$ci_job" >&2; return 4; } + read -r -a names <<< "$table" + elif [ "$self_test" = 1 ]; then names=(checkers) elif [ ${#names[@]} -eq 0 ]; then while IFS= read -r line; do names+=("$line"); done < <(suites) From dc944ea24bbe096154bf686c73c3671b02ece70c Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:24:45 -0700 Subject: [PATCH 030/114] Document the RecordGone retry and the rename destination's basis Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 50 ++++++++++++++++++++----- context/code/command.md | 30 +++++++++++++++ context/code/protocol.md | 4 +- context/test-evidence/clients.md | 64 ++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 11 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 0e19784c..20a223e4 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -2796,8 +2796,9 @@ writes nothing.** `edit_or_drill` opens both through `opened_on`, so both record every cell it opens. `rename_coordinate` then applies improvise-ixxx's `untouched` rule before anything else: `i` then `Enter` composes the mode change and the advance and no rename, which is what the host's `CommitAndAdvance` does. -The untouched check comes before the "still waiting" decline. An untouched -`Enter` writes nothing either way, so it has no need of a basis capture. +The untouched check comes before the "still waiting" decline and the `Gone` +decline below. An untouched `Enter` writes nothing either way, so it needs no +basis capture and cannot retarget. **Two captures, one editor.** When an editor opens, two things are recorded, and they answer different questions: @@ -2806,9 +2807,10 @@ they answer different questions: text it opened on, and whether the user has changed it**. It is portable state. It is composed optimistically at the keypress and folded the same way on the host, where `CommitAndAdvance` reads it. -- `ClientState::opened` (`OpenedCoordinate { record, category, version }`) - answers **which record the rename names, and at which version the basis - claims it**. It is session-local and never serialized. +- `ClientState::opened` (`Opened::Target(OpenedCoordinate { record, category, + version, seen })`, or `Opened::Gone` after a `RecordGone`, below) answers + **which record the rename names, and which record versions its basis + claims**. It is session-local and never serialized. Neither answers "is an editor open": the mode does, for both. `opened_editor` is not cleared when the editor closes, only replaced when the next one opens. @@ -2825,8 +2827,36 @@ the host's value would have no record in it. Gating the capture on `enter-mode` records no opening, yet the host still commits a rename from it, so the gate would strand that path on "still waiting". -**A retry after a refusal is a force.** A refusal replays the view slice, so the -editor reopens with the typed text. `settle_opened` then captures the record at -its **current** version, so a second `Enter` succeeds. That is the one-keystroke -force `RecordMoved` promises (gotcha 43). The user has just been told that -someone else changed the row. +**A retry after `RecordMoved` is a force.** A refusal replays the view slice, +so the editor reopens with the typed text. `settle_opened` then captures the +record at its **current** version, so a second `Enter` succeeds. That is the +one-keystroke force `RecordMoved` promises (gotcha 43). The user has just been +told that someone else changed the row. + +**A retry after `RecordGone` sends nothing (improvise-20ek).** `RecordGone` on +the record the refused rename named means another client renamed or deleted that +row. The replayed editor then sits over a **different** record, and recapturing +it would rename that one with text typed for another row. So `refused()` pins +`ClientState::opened` to `Opened::Gone(record)`, and `rename_coordinate` answers +`Enter` with a status and no write. Esc leaves the editor, and opening it again +captures afresh. This keeps "force never creates structure" (gotcha 43) from +turning into "force picks another target". The pin applies only to that +rejection on that record. The reviewer's rival on PR #221 kept the opened target +through every refusal instead, and that breaks the reorder case above: after a +successful rename the next editor must capture the row it now shows. + +**The destination is a record too (improvise-milr).** A rename onto an existing +coordinate overwrites that record (improvise-kndq), so it is a write to the +destination as well, and the destination has to answer to the basis. The +server decides that (`records_at_stake`, `context/code/command.md`). The client +cannot name the destination: it would need the hash of coordinates it has never +been sent, which ADR 002 forbids. So it claims **every record counter it held +when the editor opened**, captured into `OpenedCoordinate::seen` at the same +moment as the target. Two consequences follow: + +- If another client writes the destination while the editor is open, the rename + is refused with `RecordMoved`. This holds even when the write has already + reached this client's cache, because the claim is the version at open time. +- If the destination is a record this client was never shown (outside its + window, say), the rename is refused with `RecordUnstated`. Overwriting a + record you have not seen is exactly the lost update the check exists to stop. diff --git a/context/code/command.md b/context/code/command.md index 2cc2e1b9..d4ab8b20 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -368,6 +368,36 @@ Only `CellData` variants that carry a `RecordCell` name a record. The item and formula commands are `CellData` too but name no *record*: they move the axes a record's coordinates are drawn from, which is what the layout tier is for. +### `records_at_stake` — `records_named` plus a rename's destination + +`ui/reduce.rs::records_at_stake(app, cmd)` is what `stale_basis`, +`stale_records_in_a_frame` and `caught_up` ask. It is `records_named(cmd)`, plus +one extra record for `RenameRecordCoordinate`: the **destination**, meaning the +source's coordinates with the category replaced by the new item. That record is +added only when it exists in the document and differs from the source +(improvise-milr). A rename onto an existing record overwrites it +(improvise-kndq), so without this a write another client made to the +destination was lost without any check. + +**Why not in `records_named` itself,** which is what the ticket asked for. +`records_named` is pure and lives in `improvise-protocol`: `&ModelCommand -> +Vec`, with no document. The destination is not in the command. +Deriving it needs the source's coordinates, which only the document holds, +and "when it exists" is also a question about the document. Moving +`records_named` behind an `&App` would take away what makes it useful to the +client. `BasisCache::claim` and `caught_up` rely on it being pure, so a client +can claim exactly what the server will check. So the protocol keeps the +command's own records, and the server adds what only the document can say. + +The price is on the client side. It cannot compute the destination's hash (ADR +002: a client never computes a surrogate), so it claims every record counter it +held when the editor opened (`context/code/clients.md`, "A coordinate rename"). +An existing destination the client was never shown is refused with +`RecordUnstated`. That is a refusal, not an overwrite, and it is the right +direction to err. `broken_chain` still asks `records_named`: whether a later +command's record was renamed away by an earlier one in the frame is a question +about the source only. + ### `stale_basis` `None` means apply it. The three tiers are checked in order of how much they diff --git a/context/code/protocol.md b/context/code/protocol.md index 6ffdd95e..6f0c5773 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -647,7 +647,9 @@ operands come from the **projection**: `RenameRecordCoordinate` is position-free (improvise-hkbc): it names the record by surrogate and the new coordinate by value. `records_named` names the record, -because a rename moves it (gotcha 43). Creating the item is internal to the +because a rename moves it (gotcha 43). The destination, when it exists, is +added by the server's `records_at_stake` rather than here, because only the +document can derive it (improvise-milr, `context/code/command.md`). Creating the item is internal to the effect. `render.rs::editable` still answers `false` for a coordinate: the page's inline editor writes stored cells only, and the rename's producer is the keyboard path (`context/code/clients.md`). `typed_cell_command`, diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 78e72fb8..e750891f 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5158,3 +5158,67 @@ that did nothing. - **Rival: the coordinate editor opens with `SetBuffer` and `ChangeMode` only** (#221's shape before the merge). Fails only this test: the client records no opening, the host does, and the first assertion catches the difference. + +### a_retry_after_record_gone_renames_no_other_record + +improvise-20ek. Client A opens the editor on North's coordinate and types `Zed`. +Client B then renames North to `Yak`, so A's `Enter` is refused with +`RecordGone`. The refusal replays A's view, and the editor reopens over a row +that is no longer North. The second `Enter` must rename nothing: afterwards +South, West and Yak must hold their values and `Zed` must not exist. + +- **Red at the first commit of this branch:** the retry renamed South to Zed. + `settle_opened` had recaptured whatever record sat under the cursor, the same + recapture that makes `RecordMoved` a one-keystroke force. +- **Rival `never_set_gone`** (answer `RecordGone` like any other refusal): + fails only this test. +- **The setup assertion is load-bearing.** It checks that the editor is back + **and** over a record other than the one renamed away. Without that, a + fixture where the cursor still sat on the renamed record could not tell a + retarget from a correct decline. +- The reviewer's probe on PR #221 + (`probe_a_retry_after_record_gone_renames_no_other_record`) is the same + scenario and passes on this fix. + +**Amending the entry for +`a_second_rename_after_a_reorder_renames_the_row_the_host_shows` above.** It is +the test that separates this fix from its obvious rival. The reviewer's rival +`never_clear_opened` keeps the opened target through every refusal and every +return to `Normal`. It passes the `RecordGone` test above, and it fails this +one only: "the second rename must move North, the record the client showed". +The second rename still names South's old record, which the first rename moved +away, so nothing moves. So the fix has to distinguish the +two refusals. `RecordGone` on the renamed record pins `Opened::Gone`, while +`RecordMoved` and a normal return keep the recapture. + +### a_rename_onto_a_record_another_client_just_wrote_is_refused + +improvise-milr. A opens the editor on North and types `South`, which is the +coordinate of an existing record. B then writes South's Revenue as 999, and A +**receives** that projection before pressing `Enter`. The rename must be refused +with `RecordMoved(south)`, South must still hold 999, and North must still hold 1. + +- **Red at the first commit of this branch:** the rename was accepted, and B's + 999 was overwritten by North's 1 (kndq's overwrite rule, applied without any + check). +- **Rival `no_destination`** (the server checks the source record only): fails + only this test. +- **Rival `seen_at_commit`** (the client claims the other records as its cache + holds them at `Enter`, not at open): fails only this test. This was the first + version of the fix, and running this test is what caught it. Delivering B's + projection to A is the step that separates the two: a cache that has already + folded the write claims the new version and passes the check. +- **Rival `claim_source_only`** (the client claims just the opened record): + fails this test and the control below. Here it is refused with + `RecordUnstated` instead of `RecordMoved`. + +### a_rename_onto_a_record_this_client_has_seen_still_overwrites_it + +The control for the test above, and kndq's policy unchanged. With nobody else +writing, renaming North onto South is accepted, South holds North's 1, and North +is gone. + +- **Rival `claim_source_only`:** fails here with `RecordUnstated`. The server + now answers for the destination, so a client that does not claim it cannot + rename onto an existing record at all. This test is the only one that fails + if the client stops claiming what it saw. From 542dd9ce2eeb73ef3037fd194f21bdc98a80e755 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:45:32 -0700 Subject: [PATCH 031/114] improvise-jpba: a relay heard while a frame is in flight outlives its refusal; evidence and docs Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 37 ++++++++++ context/code/command.md | 16 +++++ context/test-evidence/command.md | 92 +++++++++++++++++++++++++ crates/improvise-command/src/ui/host.rs | 50 ++++++++++++++ crates/improvise-protocol/src/client.rs | 7 +- 5 files changed, 201 insertions(+), 1 deletion(-) diff --git a/context/code/clients.md b/context/code/clients.md index b4062cca..a6b63109 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -1338,6 +1338,43 @@ the first discards the second, which the host *did* apply. A frame the host will not answer settles as soon as nothing older is waiting — only from the front, because settling it out of turn would lose the order. +**What the host *relays* is settled too, and it settles after the frame it +arrived during (improvise-jpba).** `settled` used to take only this client's +own frames. Relays never reached it: a `ResetCursor`, the `SwitchView` sent when +the view a client is on is deleted, and the placement echoes after a reframe +(improvise-wdmp). The host has already applied each of them to this client's +mirror, so the next refusal rebuilt `view` from a settled slice that had never +heard them. A client whose reframe was accepted, and whose next frame was then +refused, went back to its pre-reset cursor while its mirror stayed at the +origin. That is the same client/mirror split wdmp closed, reopened by the +rollback. `fold` is the door for what the host sends (`fold_outgoing` is the +door for what this client sends), so `fold` now records every non-projection +command. It goes straight into `settled` when nothing is in flight. Otherwise +it is queued as an unawaited `Sent { relayed: true }` **directly behind the +head frame**, after any relays already queued there. `accepted` and +`settle_sent` then settle it in the right order, and `replay` replays it. + +Two simpler shapes were rejected, and each fails the Host-level test: + +- **Advancing `settled` directly** gets the order wrong. The head frame's own + commands settle only when it is accepted, which is *after* the relays that + arrived during it. So for `[SetRowOffset(1), , SetSelected]`, + `settled` ends at `row_offset 1` while the mirror is at 0, because the reset + was settled before the offset it cancelled. +- **Appending the relay to the head frame's own commands** loses it whenever + that frame is refused. The frame is dropped, and the relay goes with it. + +Why "behind the head" is right: the host answers frames in order, so a relay +that arrives before the head's answer happened on the host before or during +the head frame. A relay from the head frame itself (its own reset and echoes) +is only ever sent when that frame is accepted, since `undo_frame` sends no +relays, and it belongs after the frame's commands. A relay from **another** +client's frame that the host processed *before* the head also lands behind the +head, which is one position late. That is a pre-existing pipelining residue +and is not addressed here. A reframe by someone else before the head frame +usually makes the head stale anyway, which the host refuses, and on refusal +the relative order of the two no longer matters. + What this does **not** do is force. A rejected batch now keeps its values and leaves the user where they were; re-composing the same intent against a current basis at one keystroke is still `improvise-3al1`, and it is unblocked rather diff --git a/context/code/command.md b/context/code/command.md index 926c143f..b70c37bd 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -2306,6 +2306,22 @@ reframes and then *switches views* before placing would reset the view the client ends on rather than the one it reframed. No resolver composes that frame today. +The echo has two limits, and each is asserted because each has a plausible +wrong neighbour (improvise-hv0r). **It goes to the asker alone.** A bystander on +the reframed view is owed the reset, but never the asker's placement, since +that would be another client moving where I am looking. **It covers every +placing kind**, not only `SetSelected`. A records `+` commit carries an offset +after its mutation, so an echo narrowed to the selection would leave the +client's offset reset and its mirror's set. +`a_reframing_frame_echoes_only_its_later_placements_and_only_to_the_asker` +asserts the exact relay list for each session. The wdmp test above cannot see +either rival. + +The echo also needs the client to *keep* what it hears. A relay is applied to +the mirror at once, so the client's own rollback must not undo it: see +`context/code/clients.md`, "What the host *relays* is settled too" +(improvise-jpba). + What is *not* broadcast is a position: another client's transpose must not move where I am looking — that is UI and it is mine (improvise-g0f). It may only declare that where I was looking has stopped meaning anything, which is a fact about the diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 53d679c9..f9dc159f 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6101,3 +6101,95 @@ case fails. Second, the ticket's suggested fix, which drops the asker's reset and the client never heard. That rival also fails `a_geometry_command_relays_the_cursor_reset_to_the_asker_too` and the ws-server's `the_other_document_would_also_have_a_cursor_to_invalidate`. + +### a_reframing_frame_echoes_only_its_later_placements_and_only_to_the_asker + +improvise-hv0r. `a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed` +pins the wdmp echo for one shape: a single `SetSelected` after the reframe, and no +second session. Two wrong echoes pass it. Echoing to **every** session moves +bystanders to the asker's placement. Echoing **only `SetSelected`** leaves the +client's offsets reset while its mirror's are set, and offsets after a mutation +are reachable through a records `+` commit. This test uses the reviewer's shape: + +- A bystander is seated at `(0, 1)`, where a wrongly widened echo would move it. +- The asker sends `[SetRowOffset(1), ChangeMode, AddCategory, SetSelected(1,0), SetColOffset(1)]`. +- The asker must be relayed exactly `[ResetCursor, SetSelected(1,0), SetColOffset(1)]`: + the reset, then the later placements, and not the pre-reset `SetRowOffset`. +- The bystander must be relayed exactly `[ResetCursor]`. +- The asker's mirror must be at `(1, 0)` with offsets `(0, 1)`, and the client's + cursor must equal it. + +It passed at the base, since the fix it pins is #230's. Two rivals were each +injected in `Host::mutating_frame`: + +- **Echo to every session** (each placement also sent to every other session id) + fails the bystander assertion. The bystander hears the asker's `SetSelected` + and `SetColOffset`. +- **`places_cursor` narrowed to `SetSelected`** fails the asker assertion, with + `[ResetCursor, SetSelected { row: 1, col: 0 }]`. + +`a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed` +passed under both, which is the gap this test closes. + +### a_refusal_after_a_reframe_rolls_back_to_where_the_mirror_is + +improvise-jpba. `ClientState::refused` rebuilds `view` from `settled` plus +what is still in flight. `settled` used to take only the client's own frames +and never a relay: `ResetCursor`, the deleted-view `SwitchView`, or wdmp's +placement echoes. The host applies each relay to the client's mirror, so after an +accepted reframe the next refused frame put the client back at its pre-reset +cursor while its mirror stayed reset, reopening the split wdmp closed. + +The test drives a real `Host` and a real `ClientState` through `fold_outgoing`, +folding every answer: `Commands` through `fold`, `Accepted` through `accepted`, +and `Rejected` through `refused`. The steps: + +1. Seat the cursor at `(1, 0)`. +2. Compose a stale `AddCategory("Other")` against the current basis. +3. Send an accepted reframe. +4. Send the stale frame, which is refused (asserted, as setup). +5. Assert that both `client.view` and `client.settled` equal the mirror's cursor. + +It runs two shapes: a bare reframe, and the placed frame +`[SetRowOffset(1), ChangeMode, AddCategory, SetSelected(1,0)]`. At the base +the bare shape failed: the client was at `(1, 0)` and the mirror at `(0, 0)`. + +Four rivals, each injected in `crates/improvise-protocol/src/client.rs`: + +- **The old fold**, which records no relay: the bare shape fails as at the base. +- **Relays advance `settled` directly** (the ticket's first option taken + literally): the bare shape passes, but the placed shape fails with + `row_offset: 1` against the mirror's 0. The reset settled before the frame + whose offset it cancelled. +- **The relay queued in front of the head frame**: the placed shape fails, with + the client at `(0, 0)`. +- **Appending the relay to the head frame's own commands** passes this test. The + host sends a frame's own relays only when it accepts that frame, so here + nothing is dropped. The next test catches this rival. + +### a_relay_heard_while_a_frame_is_in_flight_survives_its_refusal + +improvise-jpba, the case the test above cannot reach: + +1. My frame is in flight. +2. **Another** client's accepted reframe relays me a `ResetCursor`. +3. The host then refuses my frame as stale, and the refusal is the reason it + is stale. + +The mirror is reset and the client must stay reset. The test seats the asker +at `(1, 0)`. It folds the stale frame outgoing without sending it, lets a +second client reframe, and folds that reframe's relays into the asker. Only +then does it send the stale frame and fold the refusal. At the base the client +was back at `(1, 0)` while the mirror was at `(0, 0)`. Four rivals: + +- **The old fold**: fails the same way. +- **Appending the relay to the head frame's cmds**: fails with the client at + `(1, 0)`. The relay is dropped with the refused frame. +- **The relay queued in front of the head frame**: fails with the client at + `(1, 0)`. The refusal pops the relay instead of the frame. +- **Relays advance `settled` directly**: passes this test, and is caught by the + placed shape of the test above. + +So neither test alone separates the fix from all four rivals, and together +they do. + diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 67e7c176..4ee6c502 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -1948,6 +1948,56 @@ mod tests { } } + /// improvise-jpba: another client's reset, heard while my frame is in flight, + /// outlives that frame's refusal. Evidence: `context/test-evidence/command.md`. + #[test] + fn a_relay_heard_while_a_frame_is_in_flight_survives_its_refusal() { + use improvise_protocol::AppMode; + + let mut host = host(); + let (asker, mut client) = connected_client(&mut host); + let (other, mut theirs) = connected_client(&mut host); + exchange( + &mut host, + asker, + &mut client, + vec![Command::SetSelected { row: 1, col: 0 }], + ); + + let mine = ModelCommand::AddCategory("Other".into()); + let mine = vec![crate::ui::reduce::caught_up(host.app(), mine)]; + client.fold_outgoing(&mine); + + let product = ModelCommand::AddCategory("Product".into()); + let reframe = crate::ui::reduce::caught_up(host.app(), product); + let frame = vec![Command::ChangeMode(AppMode::Normal), reframe]; + let relayed = exchange(&mut host, other, &mut theirs, frame); + for msg in to(&relayed, asker) { + if let ServerFrame::Commands(cmds) = msg { + cmds.iter().for_each(|c| client.fold(c)); + } + } + + let answer = host.handle(asker, ClientFrame::Commands(mine)); + let refusal = to(&answer, asker).into_iter().find_map(|m| match m { + ServerFrame::Rejected(r) => Some(r), + _ => None, + }); + client.refused(refusal.expect("setup: my stale frame must be refused")); + + let mirror = host.sessions.get(asker).unwrap().view().cursor(); + assert_eq!( + mirror.selected, + (0, 0), + "setup: the other client's reframe reset me" + ); + assert_eq!( + client.view.cursor(), + mirror, + "the refusal undid a relay the mirror kept" + ); + } + /// A refused batch is answered to the **asker and nobody else** (ADR 001): /// nothing happened, so there is nothing to tell the others. #[test] diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 495f3413..a00209b2 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -91,7 +91,12 @@ impl ClientState { /// A relay the host has already applied to this client's mirror, so a /// later refusal must not roll it back. Where it queues: `clients.md`. fn settle_relay(&mut self, cmd: &Command) { - let behind = 1 + self.in_flight.iter().skip(1).take_while(|s| s.relayed).count(); + let behind = 1 + self + .in_flight + .iter() + .skip(1) + .take_while(|s| s.relayed) + .count(); match (cmd, self.in_flight.is_empty()) { (Command::Project(_), _) => {} (_, true) => reduce_view(&mut self.settled, cmd), From d5da9747ee9080f19cd56ba2cd4d21123b5253ac Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:26:59 -0700 Subject: [PATCH 032/114] rustfmt Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/ui/reduce.rs | 9 ++++++++- crates/improvise-protocol/src/client.rs | 5 ++--- .../tests/browser_round_trip.rs | 17 +++++++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index bde90602..3ecb6b83 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -303,7 +303,14 @@ pub fn records_at_stake(app: &App, cmd: &ModelCommand) -> Vec { .resolve_record(*record) .map(|source| RecordHash::of(&source.with(category, item))) .filter(|dest| dest != record) - .filter(|dest| app.model_state.workbook.model.data.resolve_record(*dest).is_some()); + .filter(|dest| { + app.model_state + .workbook + .model + .data + .resolve_record(*dest) + .is_some() + }); records.extend(destination); } records diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 69ad1fec..065e0da4 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -3,9 +3,8 @@ use crate::{ BasisCache, CategoryTreeCache, Command, DocumentCache, FormulaCache, GridCache, ModelCommand, - Projection, - Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, reduce_view, - settled_mode, + Projection, Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, + reduce_view, settled_mode, }; use improvise_core::model::record::RecordHash; use serde::Serialize; diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index d46ea514..aea0e5c6 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1239,8 +1239,9 @@ fn from_another_client(host: &mut Host, a_id: Sid, a: &mut Client, cmd: ModelCom let frame = improvise_command::ui::reduce::caught_up(host.app(), cmd); let relayed = host.handle(b_id, ClientFrame::command(frame)); assert!( - relayed.iter().any(|f| f.to == b_id - && matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + relayed + .iter() + .any(|f| f.to == b_id && matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), "the other client's write must land: {relayed:?}" ); let for_a: Vec = relayed.into_iter().filter(|f| f.to == a_id).collect(); @@ -1293,7 +1294,11 @@ fn a_retry_after_record_gone_renames_no_other_record() { ); press(&mut host, a_id, &mut a, "Enter"); - assert_eq!(region(&host, "Zed"), None, "the retry renamed a record never opened"); + assert_eq!( + region(&host, "Zed"), + None, + "the retry renamed a record never opened" + ); assert_eq!(region(&host, "South"), Some(CellValue::Number(2.0))); assert_eq!(region(&host, "West"), Some(CellValue::Number(3.0))); assert_eq!(region(&host, "Yak"), Some(CellValue::Number(1.0))); @@ -1356,6 +1361,10 @@ fn a_rename_onto_a_record_this_client_has_seen_still_overwrites_it() { .any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), "{answer:?}" ); - assert_eq!(region(&host, "South"), Some(CellValue::Number(1.0)), "North's value"); + assert_eq!( + region(&host, "South"), + Some(CellValue::Number(1.0)), + "North's value" + ); assert_eq!(region(&host, "North"), None); } From 92515d6271a5858b9a1d1fcbf89a7a01300dff8a Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 08:28:01 -0700 Subject: [PATCH 033/114] Keep rename_coordinate's doc comment on the function Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-protocol/src/resolve.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index fa07428b..7884e5a6 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -913,11 +913,11 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option Date: Wed, 23 Sep 2026 09:04:47 -0700 Subject: [PATCH 034/114] A fast, offline gate for a stale harness/opfs-contract lock (improvise-zs2r) --- .agents/skills/wave/agent-rules.md | 1 + .github/workflows/ci.yml | 9 +++- context/code/tooling.md | 48 +++++++++++++++++++ scripts/check-harness-lock-test.sh | 77 ++++++++++++++++++++++++++++++ scripts/check-harness-lock.sh | 73 ++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 2 deletions(-) create mode 100755 scripts/check-harness-lock-test.sh create mode 100755 scripts/check-harness-lock.sh diff --git a/.agents/skills/wave/agent-rules.md b/.agents/skills/wave/agent-rules.md index 7358f704..9d6554a7 100644 --- a/.agents/skills/wave/agent-rules.md +++ b/.agents/skills/wave/agent-rules.md @@ -56,6 +56,7 @@ deleted worktree. Tidying history before the PR is fine; losing it is not. ``` direnv exec . ./scripts/fmt-workspaces.sh # NOT cargo fmt --all; exit 2 = the gate did not run +direnv exec . ./scripts/check-harness-lock.sh # offline; a Cargo.toml change can stale harness/opfs-contract's lock direnv exec . cargo clippy --workspace --all-targets -- -D warnings direnv exec . cargo test --workspace --no-fail-fast direnv exec . python3 scripts/check-comment-runs.py --self-test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4646ac2d..9b026c5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,10 @@ jobs: # roots. Exit 2 here is a usage error, never a verdict (improvise-b4ks). - name: Every workspace root is formatted run: ./scripts/fmt-workspaces.sh + # A manifest-only change to a crate the harness reaches by path stales + # its own lock, which only the slow `build` job saw (improvise-zs2r). + - name: The harness lock matches the manifests it resolves against + run: ./scripts/check-harness-lock.sh --allow-network - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace # The comment cap and the test-evidence keys (improvise-9c8c); without @@ -281,9 +285,10 @@ jobs: # fmt-workspaces is here because its own failure mode is the bug it # exists for: a usage error wearing a formatting failure's exit code. # bd-audit-ids is the dead-ID audit, whose recipe once read a bd that - # never ran as a clean audit (improvise-52eq). + # never ran as a clean audit (improvise-52eq). check-harness-lock must + # never read "could not resolve" as "stale" (improvise-zs2r). - name: The remaining checkers refuse what they claim to - run: ./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids + run: ./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids check-harness-lock # --------------------------------------------------------------------------- # Lints the one crate the `test` job cannot reach. diff --git a/context/code/tooling.md b/context/code/tooling.md index 6b09fc0f..81e94475 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -706,3 +706,51 @@ configuration.** A script may declare `#!/usr/bin/env bash`, pass the gate, and still be handed to dash by whatever invokes it. The only defence there is reading, which is how this one was found — by grepping for the *call* after fixing the *declaration*, rather than stopping at the gate going green. + +## 6. The harness lock gate (improvise-zs2r) + +`harness/opfs-contract` declares its own `[workspace]`, so it has its own +`Cargo.lock`. It reaches `improvise-io`, and through it `improvise-core` and +`improvise-formula`, by path. So a change to one of those crates' manifests, +such as removing a dependency or a feature, can stale that lock while the root +lock is updated and every host gate stays green. Before this gate, the only +thing that noticed was `harness/build.sh`'s `cargo metadata --locked`, in the +slow `build` job, and #214 and #224 both found out that way. + +`scripts/check-harness-lock.sh` runs that same `cargo metadata --locked` +against the harness manifest. It runs in the `test` job (with +`--allow-network`) and locally, where it is **offline** by default. + +**Why offline is sound, measured rather than assumed.** `--locked --offline` +resolves from the local registry cache and fails on a real mismatch with its +own message: "cannot update the lock file … because --locked was passed". A +dependency missing from the cache fails differently, with a resolution error +naming the index ("location searched: crates.io index"). Both were measured: +a manifest-only change to `improvise-formula` produced the first, and an empty +`CARGO_HOME` produced the second. So the script can tell the two apart, and it +does: + +- exit 0: the lock is current; +- exit 1: stale, and the fix is named; +- exit 2: the gate did not run. + +That follows `fmt-workspaces.sh`'s rule that "did not run" is never collapsed +into a verdict. Offline, then, it never needs the network and never lies: +- a developer whose cache is warm, which is anyone who has built the root + workspace, gets a real answer; +- one whose cache is cold is told the gate did not run, not that the lock is + stale. + +**The one residual, stated.** A manifest change that adds a crate never +downloaded is, offline, a resolution failure. So it reads as exit 2, not +exit 1. The developer is still stopped, but told the gate did not run rather +than that the lock is stale. CI runs with `--allow-network` and answers it +properly. + +`scripts/check-harness-lock-test.sh` pins the three answers against path-only +fixture workspaces, which need no registry at all. The fixtures have the same +shape as the harness: a root that reaches a crate by path, whose manifest then +changes. Two rivals were run: +- reading every failure as stale fails the offline case (exit 1, wanted 2); +- dropping `--locked` fails the stale case (exit 0, wanted 1). Without + `--locked`, cargo quietly rewrites the lock and reports success. diff --git a/scripts/check-harness-lock-test.sh b/scripts/check-harness-lock-test.sh new file mode 100755 index 00000000..dbed9edf --- /dev/null +++ b/scripts/check-harness-lock-test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# check_the_checker for scripts/check-harness-lock.sh (improvise-zs2r). The +# fixtures are path-only crates, so every case runs offline with no cache. +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RUN="$here/check-harness-lock.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +pass=0; fail=0 +ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); } +bad() { printf ' FAIL %s (exit %s, wanted %s)\n' "$1" "$2" "$3"; fail=$((fail+1)); } + +# $1 name, $2 expected exit, $3 expected stderr substring ("" for none), rest: args +run_case() { + local name="$1" want="$2" says="$3"; shift 3 + local err + err="$("$RUN" "$@" 2>&1 >/dev/null)" + local got=$? + if [ "$got" != "$want" ]; then + bad "$name" "$got" "$want" + printf '%s\n' "$err" | sed 's/^/ /' + elif [ -n "$says" ] && [[ "$err" != *"$says"* ]]; then + bad "$name: stderr did not say $says" "$got" "$want" + else + ok "$name" + fi +} + +# $1 dir, $2 crate name, $3 extra [dependencies] lines, $4 "workspace" or "" +crate() { + mkdir -p "$1/src" + : > "$1/src/lib.rs" + { + printf '[package]\nname = "%s"\nversion = "0.0.0"\nedition = "2021"\n' "$2" + [ "${4:-}" = workspace ] && printf '\n[workspace]\n' + printf '\n[dependencies]\n%s' "$3" + } > "$1/Cargo.toml" +} + +# A two-crate workspace shaped like the harness: `app` is its own root and +# reaches `lib` by path, so `lib`'s manifest is one the lock resolves against. +fixture() { + local root="$1" + crate "$root/lib" lib "" + crate "$root/extra" extra "" + crate "$root/app" app 'lib = { path = "../lib" } +' workspace + cargo generate-lockfile --offline --manifest-path "$root/app/Cargo.toml" 2>/dev/null +} + +fixture "$work/current" +run_case "a current lock passes" 0 "" "$work/current/app/Cargo.toml" + +# The shape improvise-zs2r names: only a PATH DEPENDENCY's manifest changed. +fixture "$work/stale" +crate "$work/stale/lib" lib 'extra = { path = "../extra" } +' +run_case "a manifest-only change to a path dependency is stale" 1 "STALE" \ + "$work/stale/app/Cargo.toml" + +# Offline and unresolvable is NOT a stale lock: the gate did not run. +fixture "$work/cold" +crate "$work/cold/lib" lib 'zs2r-no-such-crate-anywhere = "1" +' +run_case "an offline resolution failure is not a verdict" 2 "did not run" \ + "$work/cold/app/Cargo.toml" + +run_case "a missing manifest is a usage error" 2 "no manifest" \ + "$work/nowhere/Cargo.toml" +run_case "two manifests is a usage error" 2 "" \ + "$work/current/app/Cargo.toml" "$work/stale/app/Cargo.toml" +run_case "an unknown option is a usage error" 2 "unknown option" --bogus + +printf '\ncheck-harness-lock: %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" = 0 ] diff --git a/scripts/check-harness-lock.sh b/scripts/check-harness-lock.sh new file mode 100755 index 00000000..8acf87dc --- /dev/null +++ b/scripts/check-harness-lock.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Fails in seconds when harness/opfs-contract/Cargo.lock no longer matches the +# manifests it resolves against. Reasoning: context/code/tooling.md, "The +# harness lock gate" (improvise-zs2r). +set -uo pipefail + +usage() { + cat >&2 <<'EOF' +usage: check-harness-lock.sh [--allow-network] [manifest] + + manifest the Cargo.toml whose lock is checked; defaults to + harness/opfs-contract/Cargo.toml beside this script + --allow-network resolve without --offline (CI); the default never + touches the network + +exit 0 the lock is current +exit 1 the lock is stale: a manifest it resolves against changed +exit 2 the gate did not run: usage error, no cargo, or cargo could not + resolve at all (offline, with a dependency missing from the local + cache). NO verdict was produced. Never collapse 2 into 1. +EOF +} + +main() { + local network=0 manifest="" + while [ $# -gt 0 ]; do + case "$1" in + --allow-network) network=1 ;; + -h|--help) usage; return 2 ;; + -*) printf 'check-harness-lock: unknown option %s\n' "$1" >&2; usage; return 2 ;; + *) + if [ -n "$manifest" ]; then usage; return 2; fi + manifest="$1" ;; + esac + shift + done + if [ -z "$manifest" ]; then + manifest="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/harness/opfs-contract/Cargo.toml" + fi + if [ ! -f "$manifest" ]; then + printf 'check-harness-lock: no manifest at %s\n' "$manifest" >&2 + return 2 + fi + if ! command -v cargo >/dev/null 2>&1; then + echo 'check-harness-lock: no cargo on PATH' >&2 + return 2 + fi + + local -a args=(metadata --locked --format-version 1 --manifest-path "$manifest") + if [ "$network" = 0 ]; then args+=(--offline); fi + + local err rc + err="$(cargo "${args[@]}" 2>&1 >/dev/null)" + rc=$? + if [ "$rc" = 0 ]; then + printf 'check-harness-lock: %s is current\n' "${manifest%Cargo.toml}Cargo.lock" + return 0 + fi + # The one message that means "resolution succeeded and disagrees with the lock". + if printf '%s' "$err" | grep -q 'cannot update the lock file .* because --locked was passed'; then + printf 'check-harness-lock: STALE: %s\n' "${manifest%Cargo.toml}Cargo.lock" >&2 + printf ' Run: cargo update -w --manifest-path %s, and commit the lock.\n' "$manifest" >&2 + return 1 + fi + printf 'check-harness-lock: the gate did not run; cargo could not resolve:\n%s\n' "$err" >&2 + if [ "$network" = 0 ]; then + echo ' Offline, this is usually a dependency missing from the local cache.' >&2 + echo ' Retry with --allow-network, or fetch once and rerun.' >&2 + fi + return 2 +} + +main "$@" From 96ecfd6018d6e609b0bf796511ebfe7d1ef9761f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 10:53:59 -0700 Subject: [PATCH 035/114] After a write, the reopened editor takes its seed from the host's answer (improvise-3uzg) landed_seed assumed the client's cache was stale only at the cell the frame wrote. Under prune_empty a write can take its row away and renumber the rest, so the advance landed on a position the cache still filled with another record. The advance after a write now opens the editor awaiting its seed, and the client settles it from the grid it holds once nothing it sent is unanswered. A seed never overwrites typing that arrived first. This also removes CellValue::edit_text from the client bundle, so the nontrapping-float-to-int wasm-opt flag a093d126 added is no longer needed and is reverted (improvise-xbgm). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-protocol/src/client.rs | 91 +++++++++++++++++++ crates/improvise-protocol/src/resolve.rs | 61 +++++++++---- crates/improvise-wasm-client/Cargo.toml | 5 +- .../improvise-wasm-client/tests/lockstep.rs | 85 ++++++++++++++--- 4 files changed, 207 insertions(+), 35 deletions(-) diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 790e4994..6d7d3fa8 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -113,6 +113,8 @@ impl ClientState { } } self.settle_sent(); + self.settle_editor(); + self.revision += 1; } /// The host applied **none** of the oldest awaited frame (ADR 001 clause @@ -175,6 +177,22 @@ impl ClientState { self.view.buffers.insert(key, seed.clone()); self.view.opened_editor = Some(crate::OpenedEditor::on(seed)); } + self.settle_awaited_seed(); + } + + /// An editor opened after a write takes its seed from the grid the host + /// answered with, never from the cache the write made stale (improvise-3uzg). + fn settle_awaited_seed(&mut self) { + let waiting = self + .view + .opened_editor + .as_ref() + .is_some_and(|opened| opened.awaiting && !opened.touched); + let answered = !self.in_flight.iter().any(|sent| sent.awaited); + if let (true, true, Some(seed)) = (waiting, answered, crate::resolve::edit_seed(self)) { + self.view.buffers.insert("edit".to_string(), seed.clone()); + self.view.opened_editor = Some(crate::OpenedEditor::on(seed)); + } } /// The widget dispatch: a projection names the widget it is addressed to. @@ -397,6 +415,79 @@ mod tests { ); } + /// A write, then the editor its advance reopens with the seed still to come. + fn write_then_awaiting_editor() -> Vec { + let mut frame = commit_like("written"); + frame.extend([ + Command::SetBuffer { + name: "edit".into(), + value: String::new(), + }, + Command::SetOpenedEditor(Some(crate::OpenedEditor::awaiting())), + Command::ChangeMode(crate::AppMode::editing()), + ]); + frame + } + + /// One row, one column, the cell's stored text `edit_text`. + fn answered_with(state: &mut ClientState, edit_text: &str) { + let mut cell = CellDisplay::number(edit_text); + cell.edit_text = Some(edit_text.into()); + for cmd in [ + GridUpdate::RowFrame(AxisFrame::flat(["North"])), + GridUpdate::ColFrame(AxisFrame::flat(["Revenue"])), + GridUpdate::Cells(vec![((0, 0), cell)]), + ] { + state.fold(&Command::Project(Projection::Grid(cmd))); + } + } + + /// BUG (improvise-3uzg) — see `context/test-evidence/clients.md`. + #[test] + fn an_awaited_seed_waits_for_the_answer_and_never_overwrites_typing() { + let mut state = ClientState::default(); + answered_with(&mut state, "2"); + state.fold_outgoing(&write_then_awaiting_editor()); + assert_eq!(state.view.buffers.get("edit").map(String::as_str), Some("")); + answered_with(&mut state, "3"); + assert_eq!( + state.view.buffers.get("edit").map(String::as_str), + Some(""), + "seeded while the write was still unanswered" + ); + state.accepted(); + assert_eq!( + state.view.buffers.get("edit").map(String::as_str), + Some("3"), + "the answered editor must open on the host's grid" + ); + assert_eq!( + state.view.opened_editor, + Some(crate::OpenedEditor::on("3".into())) + ); + + let mut typed_ahead = ClientState::default(); + answered_with(&mut typed_ahead, "2"); + typed_ahead.fold_outgoing(&write_then_awaiting_editor()); + typed_ahead.fold_outgoing(&[ + Command::SetBuffer { + name: "edit".into(), + value: "5".into(), + }, + Command::SetOpenedEditor(crate::resolve::edited( + typed_ahead.view.opened_editor.as_ref(), + "edit", + )), + ]); + answered_with(&mut typed_ahead, "3"); + typed_ahead.accepted(); + assert_eq!( + typed_ahead.view.buffers.get("edit").map(String::as_str), + Some("5"), + "a seed arriving after the user typed must not overwrite the typing" + ); + } + /// A frame the host does not answer must not consume an answer, or the /// queue addresses the wrong frame. Evidence file. #[test] diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 2659b1f9..51e6ad34 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -898,8 +898,8 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option, + writes: bool, ) -> Option> { let GridExtent { rows, cols } = state.grid.extent; let row_max = rows.saturating_sub(1); @@ -990,17 +990,26 @@ fn advanced( }; let mut commands = scrolled_to(state, nr, nc); // `EnterEditAtCursor`: seed from where we landed, then change mode. - commands.extend(opened_on(landed_seed(state, (nr, nc), wrote), edit_mode)); + // After a write the cache may name other cells than the host's grid does, so + // the seed waits for the host's answer (improvise-3uzg, `protocol.md` §3). + commands.extend(match writes { + true => awaiting_seed(edit_mode), + false => opened_on(seed_at(state, nr, nc), edit_mode), + }); Some(commands) } -/// The seed at the cell an advance lands on. The cache is stale only where the -/// frame just wrote, which is the cursor's own cell (improvise-3tt4). -fn landed_seed(state: &ClientState, (row, col): (usize, usize), wrote: Option<&str>) -> String { - match wrote.filter(|_| state.view.cursor().selected == (row, col)) { - Some(text) => typed(text).map(|v| v.edit_text()).unwrap_or_default(), - None => seed_at(state, row, col), - } +/// Open the cell editor with its seed still to come: the client settles it from +/// the host's answer, once nothing this client sent is unanswered (improvise-3uzg). +fn awaiting_seed(mode: AppMode) -> Vec { + vec![ + Command::SetBuffer { + name: EDIT_BUFFER.to_string(), + value: String::new(), + }, + Command::SetOpenedEditor(Some(OpenedEditor::awaiting())), + Command::ChangeMode(mode), + ] } /// The surrogate naming the record under the cursor, or `None` when the @@ -1043,6 +1052,9 @@ pub struct OpenedEditor { pub seed: String, /// Whether the user has edited the buffer since it opened (improvise-2054). pub touched: bool, + /// The seed is still to come from the host's answer (improvise-3uzg). + #[serde(default)] + pub awaiting: bool, } impl OpenedEditor { @@ -1051,6 +1063,16 @@ impl OpenedEditor { Self { seed, touched: false, + awaiting: false, + } + } + + /// An editor opened before its seed is known: empty until it settles. + pub fn awaiting() -> Self { + Self { + seed: String::new(), + touched: false, + awaiting: true, } } } @@ -3394,14 +3416,17 @@ mod tests { Some(&Command::ChangeMode(AppMode::editing())), "the advance re-enters editing on the cell it landed on: {commands:?}" ); - let last_write = commands.iter().rev().find_map(|c| match c { - Command::SetBuffer { name, value } if name == "edit" => Some(value.as_str()), - _ => None, - }); + assert!( + commands.contains(&Command::SetOpenedEditor(Some(OpenedEditor::awaiting()))), + "after a write the reopened editor awaits the host's seed: {commands:?}" + ); + for command in &commands { + client.fold(command); + } assert_eq!( - last_write, + client.view.buffers.get("edit").map(String::as_str), Some("900"), - "the reopened editor must show the cell it landed on, not blank: {commands:?}" + "an answered client seeds the reopened editor from the cell it landed on" ); } diff --git a/crates/improvise-wasm-client/Cargo.toml b/crates/improvise-wasm-client/Cargo.toml index 131e552c..1abfbb14 100644 --- a/crates/improvise-wasm-client/Cargo.toml +++ b/crates/improvise-wasm-client/Cargo.toml @@ -45,7 +45,6 @@ improvise-ws-server = { path = "../improvise-ws-server" } # The `wasm-opt` wasm-pack downloads predates bulk-memory being enabled by # default, while LLVM emits `memory.copy` regardless — so the validator # rejects the module unless the feature is named. Naming it here rather than -# turning `wasm-opt` off keeps the optimizer in the loop. `nontrapping-float-to-int` -# is the worker bundle's second flag, needed here once the client parses typed text. +# turning `wasm-opt` off keeps the optimizer in the loop. [package.metadata.wasm-pack.profile.release] -wasm-opt = ["-O", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"] +wasm-opt = ["-O", "--enable-bulk-memory"] diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index af63c60f..0ed4ba92 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2277,13 +2277,7 @@ fn tab_at_the_rightmost_column_reopens_on_the_value_just_written() { let mut client = client_ready_to_edit(&client_host, (2, 2)); for key in ["i", "9", "Tab", "5", "Enter"] { let event = BrowserKey::new(key).to_key_event().unwrap(); - let KeyOutcome::Local(cmds) = resolve(&client, event) else { - panic!("{key}: the client must answer this key locally"); - }; - for cmd in &cmds { - client.fold(cmd); - } - apply_on(&mut client_host, &cmds); + sent_and_answered(&mut client, &mut client_host, event, (2, 2)); reduce_key(&mut key_host, event); assert_eq!( client.view.buffers, key_host.view_state.buffers, @@ -2313,13 +2307,7 @@ fn tab_at_the_rightmost_column_reopens_on_the_stored_spelling() { let mut client = client_ready_to_edit(&client_host, (2, 2)); for key in ["i", "Backspace", "0", "0", "7", "Tab"] { let event = BrowserKey::new(key).to_key_event().unwrap(); - let KeyOutcome::Local(cmds) = resolve(&client, event) else { - panic!("{key}: the client must answer this key locally"); - }; - for cmd in &cmds { - client.fold(cmd); - } - apply_on(&mut client_host, &cmds); + sent_and_answered(&mut client, &mut client_host, event, (2, 2)); reduce_key(&mut key_host, event); } assert_eq!( @@ -2444,3 +2432,72 @@ fn every_projected_edit_text_commits_back_to_its_value() { } } } + +/// North/South/West = 1/2/3 on one measure, with `prune_empty` on: emptying a +/// row's only value takes the row away and renumbers the rest. +fn pruned_host() -> App { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + for region in ["North", "South", "West"] { + wb.add_item("Region", region).unwrap(); + } + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + wb.active_view_mut().prune_empty = true; + for (region, value) in [("North", 1.0), ("South", 2.0), ("West", 3.0)] { + wb.model.set_cell( + region_measure(region, "Revenue"), + improvise_protocol::CellValue::Number(value), + ); + } + let mut app = App::new(wb, None); + app.term_height = 3 + 8; + app.rebuild_layout(); + app +} + +/// One key the way a real browser sends it: folded as an outgoing frame, applied +/// by the host, then answered with the host's own projection and `Accepted`. +fn sent_and_answered( + client: &mut ClientState, + host: &mut App, + event: KeyEvent, + window: (usize, usize), +) { + let KeyOutcome::Local(cmds) = resolve(client, event) else { + panic!("{event:?}: the client must answer this key locally"); + }; + client.fold_outgoing(&cmds); + apply_on(host, &cmds); + client.grid = client_of(host, window).grid; + improvise_protocol::WidgetCache::apply(&mut client.basis, &published_basis(host, window)); + client.accepted(); +} + +/// BUG (improvise-3uzg) — see `context/test-evidence/clients.md`. +#[test] +fn a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up() { + let window = (3, 3); + let mut client_host = pruned_host(); + let mut key_host = pruned_host(); + let mut client = client_ready_to_edit(&client_host, window); + for key in ["i", "Backspace", "Enter", "5", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + sent_and_answered(&mut client, &mut client_host, event, window); + reduce_key(&mut key_host, event); + assert_eq!( + client.view.buffers, key_host.view_state.buffers, + "{key}: client and host disagree about the buffers" + ); + } + assert_eq!( + document_text(&client_host), + document_text(&key_host), + "the browser wrote a value built from another record's number" + ); + assert!( + document_text(&key_host).contains("= 35"), + "setup: the host's reopened editor extends West's 3" + ); +} From 9975347b38f3256d101ceaa9a8bd005c25d8ac5c Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 10:54:36 -0700 Subject: [PATCH 036/114] In a drill, tile select's fallback is a Label or _Index, not _Measure (improvise-h3cv) command.md said the fallback is always _Measure. In _Drill, drill_steps puts _Measure on Filter and tileable_cats drops Filter, so it is not a tile. The Label rank and the _Index fallback are live exactly there. Two TUI tests drive a real drill (>) and T: with a Label on None the cursor opens on it, without one on _Index. Rivals: #231's first-regular-else-zero and a Label ranked below _Index both fail the first. They are not lockstep tests: a browser declines >. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/command.md | 12 +++++- context/test-evidence/command.md | 21 +++++++++++ crates/improvise-command/src/ui/app.rs | 52 ++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/context/code/command.md b/context/code/command.md index c5c5e16f..c2054f28 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -3028,8 +3028,16 @@ which is the original bug (improvise-bqm0, confirmed on a document holding only decision. `_Measure` is the fallback, not the primary: it is on an axis in every shipped document, and `Enter` reads as "confirm" on the user's own dimension. It ranks above a Label because a Label is a high-cardinality per-row field, and -cycling it onto Row would explode the grid rather than empty it. In practice the -fallback is always `_Measure`, which every document has. +cycling it onto Row would explode the grid rather than empty it. The fallback is +**not** always `_Measure` (improvise-h3cv). In the `_Drill` view `drill_steps` +puts every coordinate of the drilled key on `Axis::Filter`, `_Measure` included, +and `tileable_cats` drops Filter, so `_Measure` is not a tile there. A drill's +tiles are `_Index`, `_Dim` and whatever sits on None, so the Label rank and the +`_Index` fallback are live exactly in a drill: a Label opens if there is one, +and otherwise `_Index`, because nothing better is on offer. +`in_a_drill_tile_select_opens_on_a_label_not_on_index` and +`in_a_drill_with_nothing_better_tile_select_opens_on_index` drive both through +the TUI's own keys. They are not lockstep tests, because a browser declines `>`. **The tests had to separate the rule from its neighbours** (improvise-52od). In every early fixture the first Regular tile was also the first tile on Row, so a diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 70cb9478..6cf32203 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6151,3 +6151,24 @@ case fails. Second, the ticket's suggested fix, which drops the asker's reset and the client never heard. That rival also fails `a_geometry_command_relays_the_cursor_reset_to_the_asker_too` and the ws-server's `the_other_document_would_also_have_a_cursor_to_invalidate`. + +### in_a_drill_tile_select_opens_on_a_label_not_on_index + +improvise-h3cv. `context/code/command.md` claimed tile select's fallback is +always `_Measure`. In a drill `_Measure` is on Filter and is not a tile, so the +tiles are `_Index`, `_Dim` and a Label `Note` on None. The test drills with `>` +on a one-cell document, asserts `_Measure` really is on Filter (so the fixture +cannot silently stop being a drill), presses `T`, and requires the cursor on +`Note`. Two rivals, each injected into `opening_tile` and restored after: #231's +`position(kind == Regular).unwrap_or(0)` opens on `_Index` and fails; ranking a +Label below `_Index`/`_Dim` also opens on `_Index` and fails. Neither rival fails +the companion test below. + +### in_a_drill_with_nothing_better_tile_select_opens_on_index + +improvise-h3cv's other half. The same drill with no Label leaves only `_Index` +and `_Dim`, so the ranking has nothing better and the cursor opens on `_Index`, +the first of the two. This pins that the ranking does not panic or pick `_Dim` +when every tile has the lowest rank. It is a characterization, not a bug guard. +Neither this nor its sibling is a lockstep test, because the browser declines +`>` (drill-into-cell), so only the TUI can reach a drill. diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index b323249e..1cea2166 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -5122,3 +5122,55 @@ mod formula_edit_tests { assert_eq!(buffer(&app), Some("Sum = East + West")); } } + +/// Where `T` opens inside a drill, where `_Measure` is on Filter and so is no tile +/// (improvise-h3cv). `context/test-evidence/command.md`. +#[cfg(test)] +mod drill_tile_select { + use super::*; + use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; + use crate::model::cell::{CellKey, CellValue}; + use crate::view::Axis; + use crate::workbook::Workbook; + + fn drilled(with_label: bool) -> App { + let mut wb = Workbook::new("Drill"); + wb.add_category("Region").unwrap(); + wb.add_item("Region", "North").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + if with_label { + wb.add_label_category("Note").unwrap(); + } + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + wb.model.set_cell( + CellKey::new(vec![ + ("Region".into(), "North".into()), + ("_Measure".into(), "Revenue".into()), + ]), + CellValue::Number(5.0), + ); + let mut app = App::new(wb, None); + let key = |c| KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE); + app.handle_key(key('>')).expect("drill"); + assert_eq!(app.view().axis_of("_Measure"), Axis::Filter, "a real drill"); + app.handle_key(key('T')).expect("tile select"); + app + } + + fn opened_on(app: &App) -> String { + let names = app.model_state.workbook.model.category_names(); + let tiles = app.view().tileable_cats(&names); + tiles[app.view_state.tile_cat_idx].to_string() + } + + #[test] + fn in_a_drill_tile_select_opens_on_a_label_not_on_index() { + assert_eq!(opened_on(&drilled(true)), "Note"); + } + + #[test] + fn in_a_drill_with_nothing_better_tile_select_opens_on_index() { + assert_eq!(opened_on(&drilled(false)), "_Index"); + } +} From baabe1a9b10c6750bd193a6d8710d6545dee1658 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 10:59:41 -0700 Subject: [PATCH 037/114] Evidence and the argument for seeding a reopened editor from the host's answer (improvise-3uzg, improvise-xbgm) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/protocol.md | 36 ++++++++++++++++ context/test-evidence/clients.md | 54 ++++++++++++++++++++---- crates/improvise-protocol/src/resolve.rs | 1 - 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/context/code/protocol.md b/context/code/protocol.md index 4e4354ff..0355e861 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -2016,6 +2016,42 @@ Being at the head is also gotcha 21's rule: a head clear survives an abort where the trailing weld did not. The corner commit and the `+cat` insertion reopen nothing, and they still leave no text behind. +**After a write, the browser's reopened editor takes its seed from the host's +answer, not from its cache** (improvise-3uzg). The first fix (improvise-3tt4) +seeded from the cache everywhere except the cell the frame had just written. +That assumed the write changed the meaning of only that one cell, which is +false. Under `prune_empty`, emptying the last value in a row takes the row +away. The advance then lands on a renumbered position, and the cache still +holds the old record there. The browser opened on South's `2` while the TUI +opened on West's `3`, and typing `5` wrote 25 into West. Any write can change +what another position means, and the client cannot tell which. So the rule is +stated once, over the write, never over a list of cases: +- **A commit that writes** reopens its editor as `OpenedEditor::awaiting()`, an + empty buffer whose seed is still to come. `create_column` is included, + because `AddItem` adds a column. +- **`ClientState::settle_awaited_seed`** fills the seed from the grid the + client holds, but only once no frame it sent is still unanswered. The answer + folds the host's projection before `Accepted`, so the grid is the host's by + then. `accepted()` therefore settles. +- **A commit that writes nothing** still seeds from the cache at once. Nothing + changed, so the cache is current. + +Asking the host to send the seed was the alternative. It would have needed a +new message, and the host already sends it: the landed cell's `edit_text` in +the projection it owes this client anyway. Two things are accepted costs: +- Until the answer arrives, the editor is empty. Over a worker that takes + well under a frame. +- A key typed before the answer arrives marks the editor touched, and the late + seed then does not overwrite it. What the user sees is then what gets + written, which is never another record's number. + +The TUI is unchanged: its `RebuildLayout` in front of `EnterEditAtCursor` +already reads the renumbered grid (gotcha 23). This also took +`CellValue::edit_text` out of the client bundle. That call formats an `f64`, +which is what compiled to `i64.trunc_sat_f64_s`, so `a093d126`'s +`nontrapping-float-to-int` flag on the wasm-client is reverted. A `?ws=` page +loads only the client bundle, so it works without the feature. + Reordering the weld to `[clear-buffer, cmd]` was tried first and rejected. Four readers take a `Sequence`'s **first** step as its identity: `keymap::runs`, `help.rs`, and two resolver audits. So every hint on those keys started diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index c5ec8721..a846ea1e 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5146,18 +5146,21 @@ key-driven host, and compares the buffers after every key. It failed at Pressing `5 Enter` would then have written 25 over the 29 the user had just committed. Both ends must end at 295. -**Fix:** `landed_seed` seeds the landing cell from the committed value's edit -text when the advance lands on the cursor's own cell and the commit wrote. In -every other case it uses the cache. No other advance lands on the written cell: -`Down` moves to the next row or the next column, or reopens nothing at the -corner. +**Fix, superseded by improvise-3uzg:** the first fix, `landed_seed`, seeded +from the committed text on the cursor's own cell. It assumed that cell was the +only stale one, which prune disproved. After a write the editor now awaits the +host's answer, so this test drives each key through `sent_and_answered`: the +key is folded as an outgoing frame, the host applies it and projects, then +`Accepted` arrives. Both rivals below still fail it. ### `tab_at_the_rightmost_column_reopens_on_the_stored_spelling` -This pins what "the committed value" means in the fix above: the stored value's -edit text, as `typed()` would store it, not the raw typed string. `i Backspace 0 -0 7 Tab` at (0, 1) must leave `"7"` in both buffers. **Rival:** seeding from the -raw text fails with `"007"` against the host's `"7"`. +This pins what "the committed value" means: the stored value's edit text, not +the raw typed string. `i Backspace 0 0 7 Tab` at (0, 1) must leave `"7"` in both +buffers. **Rival** (under the 3tt4 fix): seeding from the raw text failed with +`"007"` against `"7"`. Under the 3uzg fix the seed comes from the host's +projection, which has only the stored value to give. Opening the editor at once +from the cache fails this test. ### `a_category_committed_at_plus_cat_leaves_no_buffer_behind_on_both_ends` @@ -5186,3 +5189,36 @@ Rival injected: `claim_as_read` ignoring the captured generation and claiming the current one. That is the pre-fix behaviour, restated after the fix. Only this test failed in the whole workspace. The layout tier staying current is still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`. + +### `a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up` + +**improvise-3uzg**, found by the #227 review at `a093d126`. Fixture: +North/South/West = 1/2/3 on one measure, `prune_empty` on, window 3×3. Keys +`i Backspace Enter 5 Enter`, each through `sent_and_answered` on the browser and +through `reduce_key` on the TUI host. The buffers must agree after every key, +and the two documents must be identical at the end. The Backspace-Enter empties +North, which prunes its row, and the advance lands on row 1, which is now West. + +Red at `a093d126`: after the first Enter the browser buffer was `"2"` (South, the +stale cache) and the host's was `"3"`. At gh/main both reopened blank. +**Rivals** (each fails this test and the two `tab_at_the_rightmost_column_*` +tests): +- `settle_awaited_seed` not waiting for unanswered frames, so it seeds while + folding its own outgoing frame. +- `accepted()` not settling, so the seed never arrives. +- The resolver seeding from the cache immediately after a write. + +### `client::tests::an_awaited_seed_waits_for_the_answer_and_never_overwrites_typing` + +The fold half of improvise-3uzg, without a host. The steps: +1. A write frame with an awaiting editor is sent over a cache that says `2`. +2. The host's projection saying `3` arrives. The buffer must still be empty. +3. `Accepted` arrives. The buffer and the opened editor must be seeded `3`. +4. In a second client, a keystroke `5` arrives before the answer. After + `Accepted` the buffer must still be `5`. + +**Rivals:** +- Seeding while a frame is unanswered fails this test and the three lockstep + tests. +- `accepted()` not settling fails the same four. +- Ignoring `touched` fails this test alone. It is the one guard on type-ahead. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 7efa0885..cdfbe558 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1009,7 +1009,6 @@ fn advanced( return Some(Vec::new()); }; let mut commands = scrolled_to(state, nr, nc); - // `EnterEditAtCursor`: seed from where we landed, then change mode. // After a write the cache may name other cells than the host's grid does, so // the seed waits for the host's answer (improvise-3uzg, `protocol.md` §3). commands.extend(match writes { From e0dec9660eb97060532735763dc0bbb7124f106c Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:03:53 -0700 Subject: [PATCH 038/114] improvise-3kfc: a refusal settles what queued behind it; lockstep folds its own commands outgoing Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 6 ++ context/test-evidence/clients.md | 29 +++++++ crates/improvise-protocol/src/client.rs | 61 +++++++++++++++ .../improvise-wasm-client/tests/lockstep.rs | 76 +++++-------------- 4 files changed, 115 insertions(+), 57 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index a6b63109..7d642c38 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -1353,6 +1353,12 @@ command. It goes straight into `settled` when nothing is in flight. Otherwise it is queued as an unawaited `Sent { relayed: true }` **directly behind the head frame**, after any relays already queued there. `accepted` and `settle_sent` then settle it in the right order, and `replay` replays it. +**Every answer settles, the refusal included** (improvise-3kfc). `refused` +pops the head and then calls `settle_sent` before it replays. Without that call, +an unawaited entry behind a refused head became the front while still unsettled, +and the next answer popped it instead of its own frame. That entry can be a +relay or this client's own view-only frame, and the view-only case predated +relays. Two simpler shapes were rejected, and each fails the Host-level test: diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 8965c5ec..7a75970e 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5046,3 +5046,32 @@ on row 1. The test presses `i`, types `9`, and asserts the first editor really is touched. Enter must then land on (2, 0), and the folded `opened_editor` must be exactly `OpenedEditor::on("800")`. **Injection:** `resolve::advanced` carrying the previous editor's `touched` flag. This test fails alone. + +### a_relay_behind_a_refused_frame_settles_before_the_next_answer + +improvise-3kfc, found in review of #246 at `542dd9c`. `ClientState::refused` +popped the head of `in_flight` and replayed, but never called `settle_sent`. +So an unawaited entry queued behind the refused head became the new front, still +unsettled. The next answer then popped *it* instead of the frame the answer was +for. #246 made this reachable through relays, which are queued directly behind +the head (improvise-jpba). The probe: + +1. Two awaited frames are in flight, placing the cursor at rows 2 and 3. +2. A `ResetCursor` relay arrives and is queued as `[F1, R, F2]`. +3. F1 is refused, leaving `[R, F2]`. +4. F2 is refused. + +At `542dd9c` the second refusal popped R, not F2, so the client ended at F2's +refused placement. It failed with `left: (3, 0)` against `(0, 0)`, and `view` +split from `settled`. The fix is one line: `settle_sent()` right after the +`pop_front` in `refused`. + +### a_view_only_frame_behind_a_refused_frame_settles_before_the_next_answer + +improvise-3kfc, the same root, and one that predates #246. The client's own +view-only frame (`[SetSelected]`, never answered) sits behind an awaited head: +`[F1, U, F2]`. F1 is refused, then F2 is refused. Before the fix the second +refusal popped U and kept F2. It failed with `left: (4, 0)` (F2's refused +placement) against `(2, 0)`, which is U's placement and the one the host +applied. Same fix. + diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index a00209b2..4702eab9 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -154,6 +154,7 @@ impl ClientState { // one's entry — `Rejection::refused_an_edit` is that question. if rejection.refused_an_edit() { self.in_flight.pop_front(); + self.settle_sent(); self.replay(); } self.last_rejection = Some(rejection); @@ -430,6 +431,66 @@ mod tests { ); } + /// An awaited frame that also places the cursor at `row`. + fn placing_commit(row: usize) -> Vec { + let mut frame = commit_like("placed"); + frame.push(Command::SetSelected { row, col: 0 }); + frame + } + + /// improvise-3kfc: a relay queued behind a refused head must settle, or the + /// next answer pops it instead of its frame. Evidence: clients.md. + #[test] + fn a_relay_behind_a_refused_frame_settles_before_the_next_answer() { + let mut state = ClientState::default(); + state.fold_outgoing(&placing_commit(2)); + state.fold_outgoing(&placing_commit(3)); + state.fold(&Command::ResetCursor); + + state.refused(Rejection::LayoutMoved); + state.refused(Rejection::LayoutMoved); + + assert_eq!( + state.view.cursor().selected, + (0, 0), + "both placements were refused" + ); + assert_eq!( + state.view.cursor(), + state.settled.cursor(), + "view and settled split" + ); + assert!(state.in_flight.is_empty(), "both frames were answered"); + } + + /// improvise-3kfc, the root on gh/main: this client's own view-only frame + /// behind a refused head. Evidence: clients.md. + #[test] + fn a_view_only_frame_behind_a_refused_frame_settles_before_the_next_answer() { + let mut state = ClientState::default(); + state.fold_outgoing(&placing_commit(3)); + state.fold_outgoing(&[Command::SetSelected { row: 2, col: 0 }]); + state.fold_outgoing(&placing_commit(4)); + + state.refused(Rejection::LayoutMoved); + state.refused(Rejection::LayoutMoved); + + assert_eq!( + state.view.cursor().selected, + (2, 0), + "the unanswered move stands" + ); + assert_eq!( + state.view.cursor(), + state.settled.cursor(), + "view and settled split" + ); + assert!( + state.in_flight.is_empty(), + "both awaited frames were answered" + ); + } + /// A frame the host does not answer must not consume an answer, or the /// queue addresses the wrong frame. Evidence file. #[test] diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 081bd722..9cb7c3df 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -46,9 +46,7 @@ fn a_locally_typed_character_lands_where_the_host_would_put_it() { improvise_protocol::mode_label(&mode) ); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); assert_eq!( @@ -92,9 +90,7 @@ fn a_locally_popped_character_lands_where_the_host_would_put_it() { improvise_protocol::mode_label(&mode) ); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); assert_eq!( @@ -237,9 +233,7 @@ fn a_locally_resolved_cursor_move_lands_where_the_host_would_put_it() { let event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); match resolve(&client, event) { KeyOutcome::Local(cmds) => { - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); } // At the bottom the move is a no-op and there is nothing to say. KeyOutcome::Ignored => {} @@ -310,9 +304,7 @@ fn a_locally_resolved_page_scroll_lands_where_the_host_would_put_it() { for (press, (event, want_row)) in presses.into_iter().enumerate() { match resolve(&client, event) { KeyOutcome::Local(cmds) => { - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); } other => panic!("press {press}: the client must answer {event:?}, got {other:?}"), } @@ -342,9 +334,7 @@ fn a_locally_resolved_jump_to_the_last_row_agrees_with_the_host() { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("G must resolve locally"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); assert_eq!(client.view.cursor().selected, (5, 0)); @@ -367,9 +357,7 @@ fn the_g_chord_returns_to_the_first_row_on_both_ends() { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("{event:?} must resolve locally; an Upstream is a black hole"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); assert_eq!( client.view.transient_prefix, app.view_state.transient_prefix, @@ -428,9 +416,7 @@ fn a_locally_resolved_panel_cursor_agrees_with_the_host() { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("press {press}: the category panel cursor must resolve locally"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); assert_eq!( client.view.cat_panel_cursor, app.view_state.cat_panel_cursor, @@ -467,9 +453,7 @@ fn a_locally_resolved_tile_cursor_agrees_with_the_host() { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("press {press}: the tile cursor must resolve locally"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); assert_eq!( client.view.tile_cat_idx, app.view_state.tile_cat_idx, @@ -545,9 +529,7 @@ fn entering_typing_and_cancelling_an_edit_agrees_with_the_host() { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("{key}: the client must answer this key"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); assert_eq!( @@ -613,9 +595,7 @@ fn the_buffer_i_seeds_is_the_value_the_host_would_seed() { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("i on a record cell must resolve locally"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); assert_eq!( @@ -643,9 +623,7 @@ fn a_locally_committed_edit_leaves_both_ends_in_the_same_place() { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("{key}: the client must answer this key"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); assert_eq!( @@ -758,9 +736,7 @@ fn both_ends(app: &mut App, twin: &mut App, client: &mut ClientState, key: &str) let KeyOutcome::Local(cmds) = resolve(client, event) else { panic!("{key}: the client must answer this key locally"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); apply_on(twin, &cmds); reduce_key(app, event); @@ -1194,9 +1170,7 @@ fn a_formula_the_client_cannot_parse_diverges_on_the_status_line() { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("Enter in FormulaEdit resolves here"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); assert_eq!( @@ -1436,9 +1410,7 @@ fn in_lockstep(app: &mut App, client: &mut ClientState, key: KeyEvent, what: &st let KeyOutcome::Local(cmds) = resolve(client, key) else { panic!("{what}: the client must answer this key itself"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(app, key); let window = client.window; let fresh = grid_of( @@ -1495,9 +1467,7 @@ fn deliver( match addressed.msg { improvise_protocol::ServerFrame::Snapshot(snapshot) => client.bootstrap(*snapshot), improvise_protocol::ServerFrame::Commands(cmds) => { - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); } improvise_protocol::ServerFrame::Rejected(r) => client.refused(r), improvise_protocol::ServerFrame::Accepted => client.accepted(), @@ -1540,9 +1510,7 @@ fn switching_between_a_records_view_and_a_pivot_view_settles_both_ways() { improvise_protocol::Command::SwitchView(target.to_string()), improvise_protocol::Command::ChangeMode(stale), ]; - for cmd in &composed { - client.fold(cmd); - } + client.fold_outgoing(&composed); deliver( host.handle(session, ClientFrame::Commands(composed)), session, @@ -1817,9 +1785,7 @@ fn press(app: &mut App, twin: &mut App, client: &mut ClientState, key: char) { let KeyOutcome::Local(cmds) = resolve(client, event) else { panic!("{key}: the client must answer this key locally"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); apply_on(twin, &cmds); reduce_key(app, event); } @@ -1926,9 +1892,7 @@ fn yy_over_an_aggregate_copies_the_sum_the_host_copies() { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("y must resolve locally over an aggregate too"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); reduce_key(&mut app, event); } assert_eq!( @@ -2194,9 +2158,7 @@ fn untouched_browser_edit(value: improvise_protocol::CellValue) { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("{key}: the client must answer this key locally"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); apply_on(&mut host, &cmds); if key == "i" { assert_eq!( From 1501def5c29b359492e2709b24e10e419adb2748 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:06:47 -0700 Subject: [PATCH 039/114] check-ci-graph: a continued run: or a skipped need does not count A plain scalar folds a deeper-indented next line into run:, so `|| true` there made the counted call fail-open (improvise-1be2). A job that needs a job under if: is skipped with it, directly or transitively (improvise-bbwa). The unrun message now points at the ci_suites row. tooling.md names the checkout-with-ref hole. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 20 +++++++--- context/test-evidence/tooling.md | 39 +++++++++++++++++++ scripts/check-ci-graph.py | 64 ++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index c81e2f13..14cb4d25 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -282,11 +282,17 @@ the row without running it, so the table has one home. The gate reads no shell. It counts a step only when all of these hold: - the step sits in a job's `steps:` sequence, at step-key indentation; -- its `run:` value is exactly that call; +- its `run:` value is exactly that call, on one line. A plain scalar folds a + deeper-indented next line into the value, so `|| true` there would become part + of the command. A `run:` that continues onto another line is not counted + (improvise-1be2); - the call names its own job; - neither the step nor the job carries `if:`, `continue-on-error:` or `shell:`; - no `defaults:` block, at workflow or job level, covers it, since that can - change the shell the step runs under. + change the shell the step runs under; +- no job it `needs:`, directly or through other jobs, carries a job-level + `if:`. GitHub skips a job whose need was skipped, so a false `if:` two jobs + away skips the call as surely as one on its own job (improvise-bbwa). The only YAML it models is what makes a line structure rather than content. A key with an inline value owns every following line indented deeper, and an @@ -299,10 +305,12 @@ The union of the counted jobs' rows must cover `checkers.sh --list`. **What this does not close, stated rather than guarded.** Once a counted step is reached, the gate trusts that it runs the script it names. An earlier step in the same job that rewrote `scripts/checkers.sh` or its table would defeat -that. Shell control flow *around* the call cannot hide in the counted step, -because its value is exactly the call. But the gate cannot see a job-level -`needs:` whose failure skips the job, which is why `pages-deploy` must still -`needs:` every job. +that. So would an earlier `actions/checkout` step with a `ref:`, which +replaces the tree, so the counted step runs another revision's `checkers.sh` +and its table. Shell control flow *around* the call cannot hide in the counted +step, because its value is exactly the call. A needed job that *fails* also +skips the job, and the gate does not model that. The failure is itself red in +CI, and it is why `pages-deploy` must still `needs:` every job. **Two claims earlier versions of this section made were wrong.** "`--would-run` anywhere but first is a usage error" was false: `checkers.sh --list diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index bc0da11b..a95786ad 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -999,3 +999,42 @@ and beside `--self-test`, each exit 0 where 4 was wanted. **Stated, not guarded:** a step earlier in the same job that rewrites `scripts/checkers.sh` or its table (`context/code/tooling.md`). + +### A continued run: and a skipped need do not count — `scripts/check-ci-graph.py` (improvise-1be2, improvise-bbwa) + +**What broke (1be2, P2, fail-open).** The gate matched `run:` against its own +physical line only. A plain YAML scalar folds a deeper-indented next line into +the value, so a kb step whose `run:` continued onto a line reading `|| true` +ran `./scripts/checkers.sh --ci kb || true`. It counted as the exact call, and +the gate reported "all 9 suites run". A failing suite could then never fail CI. + +**What broke (bbwa, P3).** The gate disqualified `if:` on the step and on its +own job, but not on a job it `needs:`. GitHub skips a job whose need was +skipped, so `if: false` on a needed job meant the call never ran, while the +gate counted it. + +**Fix.** A `run:` whose value owns a following non-blank line is not counted, +and the step is named. `needs:` is read in all three forms (`[a, b]`, a +scalar, a block list) and walked transitively. A job-level `if:` anywhere in +that closure disqualifies the step as "conditional (needs K, which has if)". +The "no CI step runs" message now names the fix site: a row of `ci_suites` in +`scripts/checkers.sh`, not `ci.yml`. + +**Red first.** The three new cases (a continuation line, `needs:` inline and +as a block list) each failed with `saw []`, which is the false pass. + +**Self-test: 23 suite lists.** One of the 23 is the two-hop case, added after +the rival below survived the first 22. + +**Rivals, each a source edit run against the real `--self-test`:** +- **The continuation is not marked:** fails the 1be2 case only. +- **No needs flag:** fails the three needs cases. +- **Block-list `needs:` not read:** fails "the same, with needs: written as a + list" only. +- **No transitive walk:** passed all 22 cases at first, because every needs + case was one hop. "The same, two needs: away" (`kb` needs `mid`, `mid` needs + `gate`) was added, and the rival now fails it alone. + +**Stated, not guarded:** a needed job that *fails* also skips the job, and an +earlier `actions/checkout` with `ref:` replaces the tree +(`context/code/tooling.md`). diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index bdbc57b4..2debf16f 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -101,7 +101,8 @@ def ci_steps(text): steps, stray = [], [] job, in_steps, job_flags, step = None, False, set(), None workflow_flags = set() - owner, quote = None, None + owner, owner_step, quote = None, None, None + needs, if_jobs, in_needs = {}, set(), False def close_step(): if step is not None: @@ -118,8 +119,10 @@ def ci_steps(text): if owner is not None and (not raw.strip() or indent > owner): if "scripts/checkers.sh" in raw: stray.append(n) + if owner_step is not None and raw.strip(): + owner_step["multiline"] = True continue - owner = None + owner, owner_step = None, None if not raw.strip() or raw.lstrip().startswith("#"): continue if raw.startswith("defaults:"): @@ -128,7 +131,10 @@ def ci_steps(text): if m: close_step() step = None - job, in_steps, job_flags = m.group(1), False, set() + job, in_steps, job_flags, in_needs = m.group(1), False, set(), False + continue + if in_needs and re.match(r"^ - ", raw): + needs[job].add(_inline_value(raw.strip()[2:])) continue m = _JOB_KEY.match(raw) if m and job is not None: @@ -136,6 +142,11 @@ def ci_steps(text): step = None key, value = m.group(1), _inline_value(m.group(2)) in_steps = key == "steps" and value == "" + in_needs = key == "needs" and value == "" + if key == "needs": + needs[job] = {x.strip() for x in value.strip("[]").split(",") if x.strip()} + if key == "if": + if_jobs.add(job) if key in _JOB_DISQUALIFY: job_flags.add(key) for s in steps: @@ -165,12 +176,25 @@ def ci_steps(text): quote = value[0] else: owner = len(k.group(1)) + (2 if raw.lstrip().startswith("- ") else 0) + owner_step = step if m and m.group(1) == "run" else None close_step() for s in steps: s["flags"] |= workflow_flags + s["flags"] |= {f"needs {j}, which has if" for j in _needed(s["job"], needs) & if_jobs} return steps, stray +def _needed(job, needs): + """Every job `job` needs, transitively: a skipped one skips it too (improvise-bbwa).""" + seen, todo = set(), list(needs.get(job, ())) + while todo: + j = todo.pop() + if j not in seen: + seen.add(j) + todo += needs.get(j, ()) + return seen + + def would_run(args): """What checkers.sh itself says these arguments run. Its argument rules and its CI table are not restated here (improvise-maax).""" @@ -186,7 +210,8 @@ def would_run(args): def _unrun(s): - return f"`{s}` is a checkers.sh suite that no CI step runs; add it to ci.yml" + return (f"`{s}` is a checkers.sh suite that no CI step runs; add it to a row of " + "ci_suites in scripts/checkers.sh") def suite_verdict(suites, text): @@ -201,6 +226,9 @@ def suite_verdict(suites, text): if m.group(1) != s["job"]: out.append(f"job `{s['job']}` runs `checkers.sh --ci {m.group(1)}`; " "a job runs only its own row of the CI table") + elif s.get("multiline"): + out.append(f"job `{s['job']}`'s `checkers.sh --ci` step's run: continues " + "onto another line, so it does not count") elif s["flags"]: out.append(f"job `{s['job']}`'s `checkers.sh --ci` step is conditional " f"({', '.join(sorted(s['flags']))}), so it does not count") @@ -284,6 +312,34 @@ SUITE_SELF_TEST = [ " steps:\n - run: echo\n"), [_stray(8)] + _NO_PW), + ("the call with a continuation line does not count (1be2)", _REQ, + _wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n" + " || true\n")), + ["job `kb`'s `checkers.sh --ci` step's run: continues onto another line, " + "so it does not count"] + _NO_PW), + + ("a job that needs a job under if: does not count (bbwa)", _REQ, + _wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n", + _job("kb", " - run: ./scripts/checkers.sh --ci kb\n", + head=" needs: [build, gate]\n")), + ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), " + "so it does not count"] + _NO_PW), + + ("the same, with needs: written as a list", _REQ, + _wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n", + _job("kb", " - run: ./scripts/checkers.sh --ci kb\n", + head=" needs:\n - gate\n")), + ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), " + "so it does not count"] + _NO_PW), + + ("the same, two needs: away", _REQ, + _wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n", + " mid:\n needs: gate\n steps:\n - run: echo\n", + _job("kb", " - run: ./scripts/checkers.sh --ci kb\n", + head=" needs: [mid]\n")), + ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), " + "so it does not count"] + _NO_PW), + ("a step under if: does not count (tvte)", _REQ, _wf(_BUILD, _job("kb", " - if: false\n run: ./scripts/checkers.sh --ci kb\n")), ["job `kb`'s `checkers.sh --ci` step is conditional (if), so it does not count"] From 39821d5fb45d52f6745a95f4ac9da6c4d8d03fdd Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:09:37 -0700 Subject: [PATCH 040/114] Compose the rename capture with vndi's generation and wdmp's placement claim_as_read now takes the document generation read at open (#219, improvise-vndi), so OpenedCoordinate captures it and the rename claims it. A swap closes the rename editor, so no rival of that is observable; a_document_swap_closes_an_open_rename_editor pins the precondition. Since #230 (improvise-wdmp) the asker keeps its placement after a reframing write, so the reorder test's re-entered editor can sit on Zed; its value table now includes Zed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 9 ++- context/test-evidence/clients.md | 32 +++++++++ crates/improvise-protocol/src/client.rs | 7 +- crates/improvise-protocol/src/resolve.rs | 7 +- .../tests/browser_round_trip.rs | 68 ++++++++++++++++--- 5 files changed, 109 insertions(+), 14 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index de694d72..12bd469d 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -2837,6 +2837,13 @@ and the advance and no rename, which is what the host's `CommitAndAdvance` does. The untouched check comes before the "still waiting" decline. An untouched `Enter` writes nothing either way, so it has no need of a basis capture. +**The capture carries the document generation it was read in (improvise-vndi),** +and the rename claims it through `claim_as_read`, as the inline `EditTarget` +does. It cannot be observed: a document swap's rebase `Snapshot` closes the +rename editor and `settle_opened` drops the capture, so no rename crosses a +generation. `a_document_swap_closes_an_open_rename_editor` pins that +precondition. + **Two captures, one editor.** When an editor opens, two things are recorded, and they answer different questions: @@ -2844,7 +2851,7 @@ they answer different questions: text it opened on, and whether the user has changed it**. It is portable state. It is composed optimistically at the keypress and folded the same way on the host, where `CommitAndAdvance` reads it. -- `ClientState::opened` (`OpenedCoordinate { record, category, version }`) +- `ClientState::opened` (`OpenedCoordinate { record, category, version, document_generation }`) answers **which record the rename names, and at which version the basis claims it**. It is session-local and never serialized. diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index e9dd3831..96102811 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5229,3 +5229,35 @@ Rival injected: `claim_as_read` ignoring the captured generation and claiming the current one. That is the pre-fix behaviour, restated after the fix. Only this test failed in the whole workspace. The layout tier staying current is still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`. + +### a_document_swap_closes_an_open_rename_editor + +Added when #221 merged #219 (improvise-vndi). vndi changed `claim_as_read` to +take the document generation captured when an editor opened, so an inline edit +opened before a `LoadModel` is refused `DocumentReplaced` rather than written +into the new document. The rename capture (`OpenedCoordinate`) now records +`document_generation` at open for the same reason, and the rename claims it. + +**No rival of that choice is observable, and this test says why.** B loads a +document while A's rename editor is open. The rebase `Snapshot` rebuilds A's +view from a fresh mirror: A lands in `Normal`, outside records mode (`_Records` +is virtual and never persisted), and `settle_opened` drops the capture. So a +rename editor never survives a generation change, and claiming the current +generation instead of the captured one fails nothing. The first version of this +test asserted `DocumentReplaced` and got no frame at all, which is how that was +found. The inline editor differs because its `EditTarget` lives in the page, +outside `ViewState`, and survives the rebase. + +What the test pins is the precondition: the swap closes the editor, drops the +capture, and renames nothing (North survives, no Zed). If a later change lets a +`Snapshot` preserve an open editor, this test fails, and a real test of the +captured generation becomes both possible and necessary. + +**Amending the entry for +`a_second_rename_after_a_reorder_renames_the_row_the_host_shows` above.** Since +#230 (improvise-wdmp) the asker keeps its own placement after a reframing +write, where it used to be reset to the origin. So the re-entered editor now +sits on row 2, which in the answered grid is Zed, the record the first rename +produced. The test's principle is unchanged: the second rename moves the row +the client shows, and that row differs from the stale capture (West). Only its +value table assumed Zed could not come up, so Zed was added to it. diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 80f6f2bb..df670a92 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -57,6 +57,8 @@ pub struct OpenedCoordinate { pub record: RecordHash, pub category: String, pub version: u64, + /// The document the editor opened on, so a swap refuses it (improvise-vndi). + pub document_generation: u64, } /// One frame this client has sent. `awaited` is true exactly when the host @@ -188,11 +190,12 @@ impl ClientState { (true, Some(opened)) => Some(opened), (true, None) if answered => { crate::resolve::coordinate_at_cursor(self).and_then(|(record, category)| { - let version = self.basis.current()?.record(record)?; + let basis = self.basis.current()?; Some(OpenedCoordinate { record, category, - version, + version: basis.record(record)?, + document_generation: basis.document_generation, }) }) } diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index d02983b2..cdb2c78c 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -960,9 +960,10 @@ fn rename_coordinate( )]); } (false, false, Some(opened)) => vec![Command::Model { - basis: state - .basis - .claim_as_read([(opened.record, opened.version)])?, + basis: state.basis.claim_as_read( + opened.document_generation, + [(opened.record, opened.version)], + )?, cmd: ModelCommand::RenameRecordCoordinate { record: opened.record, category: opened.category.clone(), diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 71699eb1..279cb473 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1019,10 +1019,22 @@ fn route( } } -/// Client A renames North's `Region` to `Zed` in records mode; when `racing`, -/// client B writes North's Revenue while A's editor is open. Evidence file. -fn rename_north(racing: bool) -> (Host, Vec) { +/// What client B does while A's rename editor is open. +#[derive(PartialEq)] +enum Race { + Quiet, + WriteRevenue, + SwapDocument, +} + +/// Client A renames North's `Region` to `Zed` in records mode, while B does +/// `race`. Evidence file. +fn rename_north(race: Race) -> (Host, Vec) { let mut host = host(); + let path = std::path::PathBuf::from("other.improv"); + host.app_mut().storage = Box::new(improvise_command::storage::MemoryFs::new()); + let other = host.app().model_state.workbook.clone(); + improvise_command::persistence::save_to(&*host.app().storage, &other, &path).unwrap(); let (mut a, mut b) = (Client::new(), Client::new()); let (a_id, a_snap) = host.connect(10, 10); let (b_id, b_snap) = host.connect(10, 10); @@ -1083,7 +1095,26 @@ fn rename_north(racing: bool) -> (Host, Vec) { Some("North") ); - if racing { + if race == Race::SwapDocument { + let generation = host.app().document_generation(); + let load = improvise_command::ui::reduce::caught_up( + host.app(), + improvise_protocol::ModelCommand::LoadModel(path), + ); + let frames = host.handle(b_id, ClientFrame::command(load)); + assert_eq!( + host.app().document_generation(), + generation + 1, + "setup: the load must really replace the document" + ); + route(&frames, (a_id, &mut a), (b_id, &mut b)); + assert!( + !a.state().view.mode.is_editing() && a.state().opened.is_none(), + "the rebase must close A's rename editor and drop its capture" + ); + return (host, frames); + } + if race == Race::WriteRevenue { let revenue = (0..b.state().grid.extent.cols) .find(|&c| { b.record_cell_at(0, c) @@ -1116,6 +1147,22 @@ fn rename_north(racing: bool) -> (Host, Vec) { (host, answers) } +/// improvise-vndi for a rename: a document swap closes the rename editor, so +/// no rename crosses it. Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_document_swap_closes_an_open_rename_editor() { + let (host, _) = rename_north(Race::SwapDocument); + assert_eq!( + region(&host, "Zed"), + None, + "the loaded document was renamed" + ); + assert!( + region(&host, "North").is_some(), + "North must survive the swap" + ); +} + fn region(host: &Host, item: &str) -> Option { host.app() .model_state @@ -1132,7 +1179,7 @@ fn region(host: &Host, item: &str) -> Option { /// editor opened on. Evidence: `context/test-evidence/clients.md`. #[test] fn a_browser_renames_a_records_coordinate_and_the_host_moves_the_record() { - let (host, answers) = rename_north(false); + let (host, answers) = rename_north(Race::Quiet); assert!( answers .iter() @@ -1147,7 +1194,7 @@ fn a_browser_renames_a_records_coordinate_and_the_host_moves_the_record() { /// editor was open, so the rename is stale. Evidence file. #[test] fn a_coordinate_rename_another_client_moved_is_refused() { - let (host, answers) = rename_north(true); + let (host, answers) = rename_north(Race::WriteRevenue); assert!( answers.iter().any(|f| matches!( f.msg, @@ -1246,7 +1293,8 @@ fn a_second_rename_after_a_reorder_renames_the_row_the_host_shows() { for key in ["A", "l", "p", "h", "a", "Enter"] { press(&mut host, id, &mut a, key); } - let values = [("North", 1.0), ("West", 3.0)]; + // Zed is reachable since improvise-wdmp: the asker keeps its own placement. + let values = [("North", 1.0), ("Zed", 2.0), ("West", 3.0)]; let (_, moved) = values .iter() .find(|(name, _)| *name == shown) @@ -1257,5 +1305,9 @@ fn a_second_rename_after_a_reorder_renames_the_row_the_host_shows() { "the second rename must move {shown}, the record the client showed" ); assert_eq!(region(&host, &shown), None); - assert_eq!(region(&host, "Zed"), Some(CellValue::Number(2.0))); + assert_eq!( + region(&host, "West").is_some(), + shown != "West", + "West untouched" + ); } From 29d2adef6b1e8e98ba0a5a6dd0471c180884d8f6 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:15:15 -0700 Subject: [PATCH 041/114] Evidence: never_clear_opened also fails the swap test Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/clients.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 171042a8..35992a61 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5325,3 +5325,10 @@ sits on row 2, which in the answered grid is Zed, the record the first rename produced. The test's principle is unchanged: the second rename moves the row the client shows, and that row differs from the stale capture (West). Only its value table assumed Zed could not come up, so Zed was added to it. + +**Amending the entry for `a_document_swap_closes_an_open_rename_editor` above.** +On the 20ek/milr branch the reviewer's rival `never_clear_opened`, which keeps +the capture through every refusal and every return to `Normal`, fails this test +as well as the reorder test: its capture survives the swap. So the test does +see one regression class, a capture that outlives its editor, even though no +rival of the captured *generation* is observable. From d6e366dceb90ec4b76b6ffbe69dd9bbd39f5e2b0 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:20:03 -0700 Subject: [PATCH 042/114] Group runs nest: markers name their level, pruning re-marks (improvise-e265) On an axis with two grouped categories, runs nest: North in Upper holds Jan in Q1, and after Q1 ends North/Mar is still in Upper. The markers carried no level, and every reader was flat, so the nearest marker decided. Pruning an inner header dropped its GroupEnd too, and a surviving row changed group. The unpruned answer was lossy as well: North/Mar was in no group while it was drawn under Upper. The user ruled per-level membership, with z toggling the innermost group (the one nearest the cursor). So: - AxisEntry::GroupHeader and GroupEnd carry a level. expand_category reads it off its prefix, which holds one item per outer category. Membership is one open run per level, and a row's group is the innermost one (row_group_for/col_group_for, hence z). - prune_axis_items re-marks the survivors instead of filtering the markers. Before each kept item it emits the markers that make the output's open runs equal the input's, closing deeper runs innermost-out first. So each survivor keeps exactly its runs, no header is kept over nothing, no trailing GroupEnd is left, and when nothing is pruned the output equals the input. This replaces the pending/close_runs_of/ run_open machinery. - The wire: FrameGroup gains level (an index into AxisFrame::cats), and FrameEntry::GroupEnd becomes GroupEnd { level }. groups()/group_of read the same per-level rule, and group_runs is unchanged (h0iv). - axis_frame carries the open run at each level above a scrolled window, not the single nearest header, so a window opening inside North/Q1 still knows Upper. Tests: - an enumeration over every funded mask x collapse subset of a two- and a three-level fixture (about 4,000 cases), asserting that pruning moves no row's group, that no header is drawn over nothing and no axis ends on a marker, and that nothing-pruned is marker-for-marker equal; - the ruling at the layout and through z; - frame/layout agreement at every window offset; - the nested window carry. Rivals, each injected and restored, are in the evidence files: flat membership, a pruner that owes no GroupEnd, ends passed through eagerly, headers kept eagerly, nearest-only window carry, and a flat frame. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 3 +- context/code/command.md | 15 +- context/code/core.md | 46 +++ context/code/protocol.md | 41 ++- context/test-evidence/command.md | 29 ++ context/test-evidence/core.md | 58 +++ crates/improvise-command/src/ui/app.rs | 6 +- crates/improvise-command/src/ui/project.rs | 132 ++++++- crates/improvise-command/src/ui/viewport.rs | 4 +- crates/improvise-core/src/view/layout.rs | 371 ++++++++++++++++---- crates/improvise-protocol/src/render.rs | 52 +-- crates/improvise-wasm-client/src/render.rs | 3 +- src/ui/grid.rs | 2 +- 13 files changed, 633 insertions(+), 129 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 73c7cbde..4ed30a9e 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -639,7 +639,8 @@ The classes that remain: longer blocked** and are simply not built here yet (improvise-e24v): since improvise-anr9 `GridCache` carries an `AxisFrame` per axis, so `cats[0]` + `label(idx, 0)` is exactly what `HideSelectedRowItem` reads off `GridLayout`, - and `group_of(idx)` is exactly `ToggleGroup`'s `(category, group)`. The one + and `group_of(idx)` gives `ToggleGroup`'s `(category, group)` as + `(cats[level], name)` (improvise-e265). The one thing to get right is that the frame is **windowed** and the cursor is absolute, so a client must reconcile the scroll offset first, as `render.rs` does for the selection. `delete-formula-at-cursor` stays: it names a position diff --git a/context/code/command.md b/context/code/command.md index 2122603e..895795a5 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -1873,11 +1873,14 @@ Three edge rules, and every one of them is about a group header, because a heade occupies a row without consuming a data index (gotcha 1's cousin: the data index counts items, the visual position counts entries). -- **One header above the window comes with it.** A column window opening inside - `Q1` must still be able to say the column is in `Q1` (`AxisFrame::group_of`), so - the nearest header above the first in-window item is carried — and only that one, - or the payload grows with the groups a deep scroll passed. `AxisFrame::body_from` - then declines to *paint* it, which is the cut `GridLayout::data_row_to_visual` +- **The open run at each level above the window comes with it.** A column window + opening inside `Q1` must still be able to say the column is in `Q1` + (`AxisFrame::group_of`), so the header of every run still open above the first + in-window item is carried: one per level, never the closed ones, or the payload + grows with the groups a deep scroll passed. It used to be the single nearest + header, which lost `Upper` for a window opening inside `North/Q1` + (improvise-e265, `a_window_opening_inside_a_nested_run_carries_every_level`). + `AxisFrame::body_from` then declines to *paint* them, which is the cut `GridLayout::data_row_to_visual` always made. In the loop, `entries.is_empty()` is true exactly until the first in-window item lands, which is what makes the compaction correct. - **Consecutive headers *inside* the window all come.** @@ -1886,7 +1889,7 @@ counts items, the visual position counts entries). one would lose the only row saying those items exist. (Seen, not yet acted on, in a browser: `z` is `Upstream` and `web/app.js` puts no listener on a group header.) **Above** the window it is the other way round: nothing there is - painted, so only the nearest is kept. + painted, so only the open run at each level is kept. - **A trailing header comes only when the window reaches the last item.** A collapsed group at the bottom of the grid has no items to hang off and is still a row the user must see; but a window scrolled to the middle must not be handed the diff --git a/context/code/core.md b/context/code/core.md index 88f3c81a..ac81e836 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -254,6 +254,52 @@ A group header survives only when a data item that follows it survives. In column pass still reads the original grid. Records mode is exempt entirely — the user drilled in to see all the raw data. +### Group runs nest, and pruning re-marks rather than filters (improvise-e265) + +On an axis with two grouped categories the runs **nest**: `North` in `Upper` +holds `Jan` in `Q1`, and after `Q1` ends `North/Mar` is still in `Upper`. So a +marker names its level. `GroupHeader { level, .. }` opens a run at `level` and +closes every deeper one; `GroupEnd { level }` closes `level` and every deeper +one. `expand_category` reads the level off its prefix, which holds one item per +outer category, so it is derived rather than passed. A row's group, for +`row_group_for`/`col_group_for` and so for `z`, is the **innermost** run open +at it. That is the user's ruling on the ticket: membership is per level, and +`z` toggles the group nearest the cursor. + +The marker used to carry no level, and every reader was flat: the nearest +marker decided. Under a flat reading, pruning and membership could not both be +right. Before #200 a pruned axis could draw a header over nothing; after it, a +surviving row changed group when an inner header was pruned, because the +inner `GroupEnd` went with it (45,806 of 300,580 enumerated cases, every one +that shape). The unpruned answer was also lossy: `North/Mar` was in no group +while it was drawn under the `Upper` header row. + +**Pruning therefore re-marks the survivors instead of filtering the markers.** +`prune_axis_items` tracks which run the input has open at each level (keyed by +the header's position, so two runs under two equal headers stay two, as +improvise-h0iv requires) and which run the kept entries have open. Before each +surviving item, `remark` emits a header wherever a different run should be open +and a `GroupEnd` wherever none should. Three properties follow by construction: + +- each survivor keeps exactly the runs it was in; +- no header is emitted without a member, because headers are emitted only + before an item; +- no trailing `GroupEnd`, because a `GroupEnd` is also emitted only before an + item. + +This replaced the `pending`/`close_runs_of`/`run_open` machinery, which is +where improvise-gd83, u6gc, lhi4 and b5zh each landed. When nothing is pruned +the output is the input, marker for marker, so the exact-sequence tests from +those tickets still pass unchanged. That equality needs one rule: `remark` +closes the deeper runs innermost-out before it touches a level, as +`expand_category`'s trailing `GroupEnd`s do. Without it the output is +equivalent but not equal (`End0` where the input has `End1, End0`), and the +enumeration test asserts equality whenever every row survives. + +A header's level also names its category (`cats[level]`) on the wire, where +`FrameGroup` carries `level` and not the category (improvise-7xyx removed that +field): see `context/code/protocol.md` §3a. + ## 7. Axes: `Page` and `Filter` are two different questions `Axis` is `Row | Column | Page | Filter | None`. `Filter` names a coordinate the diff --git a/context/code/protocol.md b/context/code/protocol.md index 2056b567..01836132 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -959,8 +959,8 @@ future renderer and a measured regression. every call. It is deliberately not made cheaper, because a fast per-position form is an invitation to the loop `header_labels` exists to replace. -- `group_of` walks `entries` twice over, once to place `idx` and once - backwards. +- `group_of` is `groups()` at one position, so it walks the whole frame + (improvise-e265). The one-pass forms a draw loop wants: @@ -984,9 +984,10 @@ drawing the *wrong* header, not a shorter one. always shows its full tuple, or a scrolled viewport loses its group context at the top edge (improvise-jjx). -**`body_from(idx)`** drops the group header that owns the first drawn item: a -frame opening mid-group carries that header so `group_of` can answer for it, -but it is *above* the first drawn row and must not be painted as one. This is +**`body_from(idx)`** drops the group headers that own the first drawn item: a +frame opening mid-group carries the open run at every level so `group_of` can +answer for it (improvise-e265), but those headers are *above* the first drawn +row and must not be painted as rows. This is the same cut `GridLayout::data_row_to_visual` has always made. Two things about its argument, and the first of them **used to be the whole @@ -1390,8 +1391,8 @@ on *entering* a group and nothing on leaving one, so an ungrouped item after a grouped run inherited it (`improvise-gd83`). The §3 paragraph that recorded the old rule — and forbade this change — is corrected there, not merely superseded here. `AxisEntry::GroupEnd` is -the boundary, carried onto the wire as `FrameEntry::GroupEnd`; `groups()` clears -`current` on it and `group_of` stops walking. It is emitted only on +the boundary, carried onto the wire as `FrameEntry::GroupEnd`; `groups()` closes +its level on it, and every deeper one. It is emitted only on `Some -> None`, never between two groups — the next header already ends the previous run — and at the end of an expansion whose last item was grouped, which is the across-prefix arm `cross_product` creates. @@ -1399,8 +1400,32 @@ is the across-prefix arm `cross_product` creates. Nothing draws a `GroupEnd`. Both front ends skip it and it consumes no data index, so it is invisible except through `group_of`/`groups`. +**Runs nest, so both markers name a level (improvise-e265).** `FrameGroup` +carries `level`, an index into `AxisFrame::cats`, and `FrameEntry::GroupEnd` is +`GroupEnd { level }`. `groups()` keeps one open run per level: a group at L +opens L and closes deeper runs, and a `GroupEnd` at L closes L and deeper. Each +position's answer is its **innermost** open run. So with `Region` over `Month`, +`North/Mar` after `Q1` ends is in `Upper`, and it was in nothing while a flat +reading let `Q1`'s end close `Upper` too. The user ruled per-level membership, +and that `z` toggles the innermost group. The layout reads its markers by the +same rule (`context/code/core.md` §6), and +`the_frame_agrees_with_the_layout_on_every_rows_innermost_group` holds the two +together at every window offset. + +The level goes on the `GroupEnd` as well as on the header because the markers +are cut by three producers (expansion, pruning and windowing), and a marker +that names its own level can be read correctly inside any fragment of the +sequence. The header's level also gives a client the group's category, +`cats[level]`, which a browser `z` needs to compose `ToggleGroup`. That is not +the dead field improvise-7xyx removed: `level` is read by `groups()`, and the +category name is not carried twice. + +`group_runs` is unchanged. It coalesces whatever `groups()` answers, by header +identity (improvise-h0iv), so the band still paints one group per position, now +the innermost. One band per level would be a separate feature. + **`AxisFrame::first` is the absolute data index of the frame's first item.** The -windowing rule carries the nearest header *above* a scrolled window so +windowing rule carries the open header at each level *above* a scrolled window so `group_of` can answer, and `body_from` drops it on the way to the screen. At the top of an axis there is no "above", so that same leading header is content — a collapsed first group has no other affordance (`improvise-0o6x`). `idx` alone diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 5960c542..afac0070 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6208,3 +6208,32 @@ case fails. Second, the ticket's suggested fix, which drops the asker's reset and the client never heard. That rival also fails `a_geometry_command_relays_the_cursor_reset_to_the_asker_too` and the ws-server's `the_other_document_would_also_have_a_cursor_to_invalidate`. + +### the_frame_agrees_with_the_layout_on_every_rows_innermost_group + +improvise-e265, on the wire. Both front ends read membership off the frame, so +at every window offset of a `Region`-over-`Month` grid, `AxisFrame::groups()` +must name the same `(cats[level], name)` as `GridLayout::row_group_for`. A +precondition asserts that `North/Mar` is in `Upper`, because two flat readings +would agree on `None` and prove nothing. That precondition is what fails at the +base. Rivals, each injected and restored, all fail it: +- a flat `groups()` that clears every level on any `GroupEnd`; +- a window that carries only the nearest header; +- flat layout membership. + +### a_window_opening_inside_a_nested_run_carries_every_level + +improvise-e265's windowing rule. A window opening at `North/Feb`, inside +`North/Q1`, must carry both `Upper` and `Q1` above its first item. Otherwise +`North/Mar`, after `Q1` ends, loses `Upper`. The rival that carries only the +nearest header, which was `axis_frame`'s rule before this change, fails both +the shape and the `group_of` assertion. The flat-frame rival fails the +`group_of` assertion. + +### z_on_a_row_past_an_inner_run_folds_the_outer_group + +The user's ruling on improvise-e265, through `handle_key`. `z` on `North/Jan` +folds `Q1`, the innermost group, and not `Upper`. `z` on `North/Mar` folds +`Upper`, and not `Q1`. The flat-membership rival fails it: `z` on `North/Mar` +found no group and did nothing. It is not a lockstep test, because a browser's +`z` is still a no-op (improvise-uln5). diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 26c16963..a1e02841 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1511,3 +1511,61 @@ are each compared with the same document with Month on `Axis::None`. At `Flag` at `Some(0.0)`, false rather than absent, so a fix that dropped the cell cannot pass. Rival: `read_coordinate` ignoring the reach (`if false && …`). This test fails. + +### pruning_does_not_move_a_surviving_row_to_another_group + +improvise-e265. On a two-level grouped axis, `prune_empty` used to change a +surviving row's group. Pruning the inner header dropped its `GroupEnd` too, and +under the flat reading the ungrouped inner item then joined the outer group. +The test enumerates every funded-cell mask and every collapse subset of two +fixtures: +- `Region` over `Month` (two outer items, four inner, three groups); +- `Region` over `Month` over `Kind` (three levels). + +That is about 4,000 cases in both prune states, built once in a `OnceLock` for +both enumerating tests. For every surviving row it asserts that the innermost +group (`innermost_group`, which `row_group_for` is) is the same pruned as +unpruned. When every row survives +it also asserts the pruned `row_items` equal the unpruned ones, marker for +marker. + +Failed at base 202e7eac on `mask 0b100` (only `North/Mar` funded): pruned said +`Upper`, unpruned said `None`. Rivals, each injected and restored: +- a flat `innermost_group` (every marker read at level 0) fails it; +- a pruner that owes no `GroupEnd` (u6gc's shape: a run whose closing header + was pruned is left open) fails it; +- a pruner that keeps headers as they arrive fails it. + +The equality half was added after the first version of `remark` emitted `End0` +where the input had `End1, End0`. That output was equivalent but not equal, and +the new assertion caught it. + +### a_pruned_axis_draws_no_header_over_nothing_and_ends_on_no_marker + +improvise-e265's other property, over the same enumeration. Every kept header +must be followed, past any deeper headers, by a data item, and the axis must +not end on a `GroupEnd`. Both failed at base 202e7eac: `mask 0b1` left a +trailing `GroupEnd`, and the ticket counted the header-over-nothing shape on +main before #200. + +Rivals: +- a pruner that keeps headers as they arrive fails the first half; +- a pruner that passes `GroupEnd`s through as they arrive (the pre-e265 shape) + fails the second. + +In the shipped pruner both properties hold by construction, because markers are +emitted only before an item. So the obvious rival, deleting a trailing-marker +strip, has nothing to delete. An earlier draft had that strip, and it was dead +code. + +### a_row_after_an_inner_groups_end_is_still_in_the_outer_group + +The user's ruling on improvise-e265, at the layout: membership is per level, +and `row_group_for` answers the innermost open group. With nothing pruned or +folded: +- `North/Jan` is in `Month`/`Q1`, the innermost; +- `North/Mar` is in `Region`/`Upper`, because `Q1` ended and `Upper` did not; +- `South/Mar` is in nothing. + +Failed at base 202e7eac on the second assertion: the flat reading said `None`. +The flat-membership rival fails it again. diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index 00fcf442..62e91a05 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -95,6 +95,7 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 { crate::view::layout::AxisEntry::GroupHeader { cat_name, group_name, + .. } => { 0u8.hash(&mut h); cat_name.hash(&mut h); @@ -108,7 +109,10 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 { } // Drawn as nothing, but it ends a group's run (improvise-gd83), // so where it sits is what a row's group attribution depends on. - crate::view::layout::AxisEntry::GroupEnd => 2u8.hash(&mut h), + crate::view::layout::AxisEntry::GroupEnd { level } => { + 2u8.hash(&mut h); + level.hash(&mut h); + } } } } diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index c40392e8..63231285 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -210,29 +210,41 @@ fn axis_frame( let mut data_idx = 0usize; let mut entries: Vec = Vec::new(); let mut pending: Vec = Vec::new(); + // Before the window opens, only the runs still open matter, one per level: + // O(levels), however many groups a deep scroll passed (improvise-e265). + let mut carried: Vec> = Vec::new(); for entry in items { match entry { AxisEntry::GroupHeader { + level, cat_name, group_name, } => { - // Before the window opens only the NEAREST header matters; keeping - // the run would make the payload O(groups above a deep scroll). - if entries.is_empty() { - pending.clear(); - } - pending.push(FrameEntry::Group(FrameGroup { + let group = FrameEntry::Group(FrameGroup { + level: *level, name: group_name.clone(), collapsed: view.is_group_collapsed(cat_name, group_name), - })); + }); + match entries.is_empty() { + true => { + carried.truncate(*level); + carried.resize(*level, None); + carried.push(Some(group)); + } + false => pending.push(group), + } } - // Queued like a header, never clearing one: a COLLAPSED group has - // no item to carry it and reaches the frame only through `pending`. - AxisEntry::GroupEnd => pending.push(FrameEntry::GroupEnd), + AxisEntry::GroupEnd { level } => match entries.is_empty() { + true => carried.truncate(*level), + false => pending.push(FrameEntry::GroupEnd { level: *level }), + }, AxisEntry::DataItem { tuple, .. } => { let idx = data_idx; data_idx += 1; if window.contains(&idx) { + if entries.is_empty() { + entries.extend(carried.drain(..).flatten()); + } entries.append(&mut pending); entries.push(FrameEntry::Item(tuple.clone())); } @@ -240,6 +252,9 @@ fn axis_frame( } } if window.end >= total { + if entries.is_empty() { + entries.extend(carried.drain(..).flatten()); + } entries.append(&mut pending); } AxisFrame { @@ -1391,7 +1406,7 @@ mod tests { .iter() .map(|e| match e { FrameEntry::Group(g) => format!("[{}]", g.name), - FrameEntry::GroupEnd => "[/]".to_string(), + FrameEntry::GroupEnd { .. } => "[/]".to_string(), FrameEntry::Item(v) => v.join("+"), }) .collect() @@ -1528,6 +1543,101 @@ mod tests { assert_eq!(late.name, "Late", "the NEAREST header wins membership"); } + /// `Region` over `Month` on rows, `North` in `Upper` and `Jan`/`Feb` in `Q1`: + /// runs nest, so an inner `GroupEnd` must not end the outer run (e265). + fn app_with_nested_groups() -> App { + let mut wb = Workbook::new("Nested"); + wb.add_category("Region").unwrap(); + wb.add_category("Month").unwrap(); + wb.add_item_in_group("Region", "North", "Upper").unwrap(); + wb.add_item("Region", "South").unwrap(); + wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); + wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.add_item("Month", "Mar").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("Month", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + let mut app = App::new(wb, None); + app.rebuild_layout(); + app + } + + fn rows_from(offset: usize) -> Viewport { + Viewport { + row_offset: offset, + col_offset: 0, + rows: 20, + cols: 20, + } + } + + /// Both ends read membership off the frame, so at every window it must say what + /// the layout says. Evidence: context/test-evidence/command.md. + #[test] + fn the_frame_agrees_with_the_layout_on_every_rows_innermost_group() { + let app = app_with_nested_groups(); + let rows = app.layout.row_count(); + assert_eq!(rows, 6); + for offset in 0..rows { + let frame = grid_of(&app, rows_from(offset)).row_frame; + let from_frame: Vec> = frame + .groups() + .into_iter() + .map(|g| g.map(|g| (frame.cats[g.level].clone(), g.name.clone()))) + .collect(); + let from_layout: Vec> = (offset..rows) + .map(|r| app.layout.row_group_for(r)) + .collect(); + assert_eq!(from_frame, from_layout, "window from row {offset}"); + } + assert_eq!( + app.layout.row_group_for(2), + Some(("Region".into(), "Upper".into())), + "precondition: North/Mar is in Upper, or agreeing on None proves nothing" + ); + } + + /// A window opening inside `North/Q1` carries the open run at EVERY level, or + /// `North/Mar` loses `Upper` once `Q1` ends. context/test-evidence/command.md. + #[test] + fn a_window_opening_inside_a_nested_run_carries_every_level() { + let app = app_with_nested_groups(); + let frame = grid_of(&app, rows_from(1)).row_frame; + assert_eq!( + frame_shape(&frame)[..5], + ["[Upper]", "[Q1]", "North+Feb", "[/]", "North+Mar"], + ); + assert_eq!(frame.group_of(1).map(|g| g.name.as_str()), Some("Upper")); + } + + /// The user's ruling on improvise-e265: `z` toggles the innermost group, and a + /// row after an inner run's end is still in the outer one. + #[test] + fn z_on_a_row_past_an_inner_run_folds_the_outer_group() { + use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; + let z = || KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE); + let collapsed = |app: &App, cat, group| app.view().is_group_collapsed(cat, group); + + let mut app = app_with_nested_groups(); + app.cursor_mut().selected = (0, 0); + app.handle_key(z()).expect("z"); + assert!( + collapsed(&app, "Month", "Q1"), + "North/Jan: the innermost is Q1" + ); + assert!(!collapsed(&app, "Region", "Upper")); + + let mut app = app_with_nested_groups(); + app.cursor_mut().selected = (2, 0); + app.handle_key(z()).expect("z"); + assert!( + collapsed(&app, "Region", "Upper"), + "North/Mar: Q1 ended, Upper did not" + ); + assert!(!collapsed(&app, "Month", "Q1")); + } + fn app_with_data() -> App { let mut wb = Workbook::new("Test"); wb.add_category("Region").unwrap(); diff --git a/crates/improvise-command/src/ui/viewport.rs b/crates/improvise-command/src/ui/viewport.rs index 1eb87d84..90b8c849 100644 --- a/crates/improvise-command/src/ui/viewport.rs +++ b/crates/improvise-command/src/ui/viewport.rs @@ -122,7 +122,7 @@ fn header_level_seed(layout: &GridLayout, n: usize) -> Vec { let mut widths = vec![0u16; n]; let items = layout.col_items.iter().filter_map(|e| match e { AxisEntry::DataItem { tuple, .. } => Some(tuple), - AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None, + AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None, }); for (ci, levels) in items.enumerate().take(n) { for level in levels { @@ -151,7 +151,7 @@ pub fn compute_col_widths(model: &Model, layout: &GridLayout, plan: &FormatPlan) pub fn compute_row_header_width(layout: &GridLayout) -> u16 { let items = layout.row_items.iter().filter_map(|e| match e { AxisEntry::DataItem { tuple, .. } => Some(tuple), - AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None, + AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None, }); header_level_widths(items, layout.row_cats.len().max(1)) .iter() diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index a1754645..b3d6d1a5 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -191,13 +191,16 @@ pub enum RecordCol { /// and collapse state show; a collapsed group is ONE `DataItem` (core.md §14). #[derive(Debug, Clone, PartialEq)] pub enum AxisEntry { + /// Opens a run at `level` (the category's place on the axis) and closes + /// every run inside it. Membership is per level: core.md §14, improvise-e265. GroupHeader { + level: usize, cat_name: String, group_name: String, }, - /// The run opened by the last `GroupHeader` ends here (improvise-gd83). + /// Ends the run at `level` and every run inside it (improvise-gd83, e265). /// Nothing is drawn for it; it exists so membership is not inherited. - GroupEnd, + GroupEnd { level: usize }, /// `collapsed` is not derivable from `tuple`: a group and an item may share a /// name. Set for a folded group and for everything expanded beneath one. DataItem { tuple: Vec, collapsed: bool }, @@ -218,7 +221,7 @@ impl AxisEntry { fn data_items(items: &[AxisEntry]) -> impl Iterator> { items.iter().filter_map(|e| match e { AxisEntry::DataItem { tuple, .. } => Some(tuple), - AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None, + AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None, }) } @@ -228,7 +231,7 @@ fn folds(items: &[AxisEntry], idx: usize) -> bool { .iter() .filter_map(|e| match e { AxisEntry::DataItem { collapsed, .. } => Some(*collapsed), - AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd => None, + AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None, }) .nth(idx) .unwrap_or(false) @@ -251,8 +254,9 @@ fn run_start( AxisEntry::GroupHeader { cat_name, group_name, + .. } => armed |= cat_name == cat && group_name == group, - AxisEntry::GroupEnd => {} + AxisEntry::GroupEnd { .. } => {} AxisEntry::DataItem { tuple, .. } => { if armed && tuple.get(..level) == Some(prefix) { return Some(data_idx); @@ -283,47 +287,31 @@ fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option { .nth(data_idx) } -/// A header ends the pending run of its OWN category and every run nested inside -/// it; an outer category's header is still waiting for an item (improvise-b5zh). -fn close_runs_of(pending: &mut Vec, header: &AxisEntry) { - let cat_of = |e: &AxisEntry| match e { - AxisEntry::GroupHeader { cat_name, .. } => Some(cat_name.clone()), - AxisEntry::GroupEnd | AxisEntry::DataItem { .. } => None, - }; - if let Some(at) = pending.iter().position(|h| cat_of(h) == cat_of(header)) { - pending.truncate(at); - } +/// Each open run, by level, as `(position of its header, the header)`. A header +/// at L replaces L and closes deeper runs; a `GroupEnd` at L closes L and deeper. +type OpenRuns = Vec>; + +fn open_at(runs: &mut OpenRuns, level: usize, run: Option<(usize, AxisEntry)>) { + runs.truncate(level); + runs.resize(level, None); + runs.extend(run.map(Some)); } -/// Drop the data items `keep` rejects, keeping a header only if an item under -/// it survives. A predicate — why: core.md §6. +/// Drop the data items `keep` rejects, then re-mark the survivors so each keeps +/// exactly the runs it was in. Why re-marked, not filtered: core.md §6, e265. fn prune_axis_items(items: &mut Vec, keep: impl Fn(usize) -> bool) { let mut kept: Vec = Vec::new(); - // Headers no kept item has reached yet, outermost first. - let mut pending: Vec = Vec::new(); - let mut run_open = false; + // What the input has open at each level, and what `kept` has open so far. + let mut input: OpenRuns = Vec::new(); + let mut output: Vec> = Vec::new(); let mut data_idx = 0usize; - for entry in items.drain(..) { - match &entry { - // Another group's header is where `expand_category` stops emitting a - // `GroupEnd` (improvise-gd83), so it closes that group's run. - AxisEntry::GroupHeader { .. } => { - close_runs_of(&mut pending, &entry); - pending.push(entry); - } - // Kept whenever a run is open in `kept`, even where the header it - // closed was pruned: an earlier one is then re-opened (u6gc). - AxisEntry::GroupEnd => { - pending.pop(); - if run_open { - kept.push(entry); - run_open = false; - } - } + for (at, entry) in items.drain(..).enumerate() { + match entry { + AxisEntry::GroupHeader { level, .. } => open_at(&mut input, level, Some((at, entry))), + AxisEntry::GroupEnd { level } => open_at(&mut input, level, None), AxisEntry::DataItem { .. } => { if keep(data_idx) { - run_open |= !pending.is_empty(); - kept.append(&mut pending); + remark(&input, &mut output, &mut kept); kept.push(entry); } data_idx += 1; @@ -333,6 +321,63 @@ fn prune_axis_items(items: &mut Vec, keep: impl Fn(usize) -> bool) { *items = kept; } +/// Emit the markers that make `kept`'s open runs equal the input's, outermost +/// first: a header where a different run is open, a `GroupEnd` where none is. +fn remark(input: &OpenRuns, output: &mut Vec>, kept: &mut Vec) { + for level in 0..input.len().max(output.len()) { + let want = input.get(level).cloned().flatten(); + let have = output.get(level).copied().flatten(); + if want.as_ref().map(|(at, _)| *at) == have { + continue; + } + // Deeper runs end first, innermost out, as `expand_category` ends them. + for deeper in (level + 1..output.len()).rev() { + if output[deeper].is_some() { + kept.push(AxisEntry::GroupEnd { level: deeper }); + } + } + output.truncate(level); + output.resize(level, None); + match want { + Some((at, header)) => { + kept.push(header); + output.push(Some(at)); + } + None => kept.push(AxisEntry::GroupEnd { level }), + } + } +} + +/// The innermost run open at the data item `idx` — the group nearest it, which +/// is what `z` toggles (improvise-e265's ruling). +fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> { + let mut open: OpenRuns = Vec::new(); + let mut data_idx = 0usize; + for (at, entry) in items.iter().enumerate() { + match entry { + AxisEntry::GroupHeader { level, .. } => { + open_at(&mut open, *level, Some((at, entry.clone()))) + } + AxisEntry::GroupEnd { level } => open_at(&mut open, *level, None), + AxisEntry::DataItem { .. } if data_idx == idx => { + return open.into_iter().rev().find_map(|run| match run { + Some(( + _, + AxisEntry::GroupHeader { + cat_name, + group_name, + .. + }, + )) => Some((cat_name, group_name)), + _ => None, + }); + } + AxisEntry::DataItem { .. } => data_idx += 1, + } + } + None +} + /// One category constraining which records are visible: what it is fixed to, /// and — when the user may page it — what `[`/`]` cycle. core.md §7. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -726,22 +771,10 @@ impl GridLayout { data_index_to_visual(&self.col_items, data_col) } - /// The `(cat_name, group_name)` of the nearest `GroupHeader` preceding - /// the Nth data row. + /// The `(cat_name, group_name)` of the innermost group the Nth data row + /// is in — what `z` toggles (improvise-e265). pub fn row_group_for(&self, data_row: usize) -> Option<(String, String)> { - let vi = self.data_row_to_visual(data_row)?; - self.row_items[..vi] - .iter() - .rev() - .find_map(|e| match e { - AxisEntry::GroupHeader { - cat_name, - group_name, - } => Some(Some((cat_name.clone(), group_name.clone()))), - AxisEntry::GroupEnd => Some(None), - AxisEntry::DataItem { .. } => None, - }) - .flatten() + innermost_group(&self.row_items, data_row) } /// Whether the cell at `(row, col)` folds a collapsed group: an aggregate @@ -776,22 +809,10 @@ impl GridLayout { data_items(&self.col_items).nth(col).map(Vec::as_slice) } - /// The `(cat_name, group_name)` of the nearest `GroupHeader` preceding - /// the Nth data column. + /// The `(cat_name, group_name)` of the innermost group the Nth data + /// column is in. pub fn col_group_for(&self, data_col: usize) -> Option<(String, String)> { - let vi = self.data_col_to_visual(data_col)?; - self.col_items[..vi] - .iter() - .rev() - .find_map(|e| match e { - AxisEntry::GroupHeader { - cat_name, - group_name, - } => Some(Some((cat_name.clone(), group_name.clone()))), - AxisEntry::GroupEnd => Some(None), - AxisEntry::DataItem { .. } => None, - }) - .flatten() + innermost_group(&self.col_items, data_col) } } @@ -807,6 +828,8 @@ fn expand_category( let Some(cat) = model.category(cat_name) else { return vec![]; }; + // The prefix holds one item per outer category, so its length is this level. + let level = prefix.len(); let mut result = Vec::new(); let mut last_group: Option<&str> = None; // A group's items need not be contiguous, so a collapsed one folds ONCE. @@ -831,10 +854,11 @@ fn expand_category( // NO group — another group's header already ends the run. if item_group != last_group { if last_group.is_some() && item_group.is_none() { - result.push(AxisEntry::GroupEnd); + result.push(AxisEntry::GroupEnd { level }); } if let Some(g) = item_group { result.push(AxisEntry::GroupHeader { + level, cat_name: cat_name.to_string(), group_name: g.to_string(), }); @@ -858,7 +882,7 @@ fn expand_category( // The expansion is the end of the run too: the next prefix's items follow // immediately and must not inherit it (improvise-gd83). if last_group.is_some() { - result.push(AxisEntry::GroupEnd); + result.push(AxisEntry::GroupEnd { level }); } result } @@ -877,12 +901,14 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec AxisEntry::DataItem { tuple, collapsed } => { expand_category(model, view, cat_name, tuple, collapsed) } - marker @ (AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd) => vec![marker], + marker @ (AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. }) => { + vec![marker] + } }) .collect(); } // The axis itself ends the last run; a trailing marker names nothing. - while matches!(result.last(), Some(AxisEntry::GroupEnd)) { + while matches!(result.last(), Some(AxisEntry::GroupEnd { .. })) { result.pop(); } result @@ -931,6 +957,7 @@ mod tests { let mut items = vec![ AxisEntry::item(vec!["A".into()]), AxisEntry::GroupHeader { + level: 0, cat_name: "C".into(), group_name: "G".into(), }, @@ -955,6 +982,7 @@ mod tests { fn prune_axis_items_drops_a_header_whose_items_all_go() { let mut items = vec![ AxisEntry::GroupHeader { + level: 0, cat_name: "C".into(), group_name: "G".into(), }, @@ -1668,6 +1696,7 @@ mod tests { layout.row_items, vec![ AxisEntry::GroupHeader { + level: 0, cat_name: "Month".into(), group_name: "Q1".into() }, @@ -1675,7 +1704,7 @@ mod tests { tuple: vec!["Q1".into()], collapsed: true }, - AxisEntry::GroupEnd, + AxisEntry::GroupEnd { level: 0 }, AxisEntry::item(vec!["Sep".into()]), ], "Q1 folds to one funded row under its header, and Mar pruned" @@ -2028,3 +2057,197 @@ mod tests { assert_eq!(drill_steps(&key, &none_cats), expected); } } + +/// Group membership on a multi-level axis is per level, and pruning cannot move +/// it (improvise-e265). `context/test-evidence/core.md`. +#[cfg(test)] +mod group_membership { + use super::{AxisEntry, GridLayout, data_items, innermost_group}; + use crate::model::cell::{CellKey, CellValue}; + use crate::view::Axis; + use crate::workbook::Workbook; + use std::sync::OnceLock; + + type Level<'a> = (&'a str, &'a [(&'a str, Option<&'a str>)]); + + const TWO: &[Level] = &[ + ("Region", &[("North", Some("Upper")), ("South", None)]), + ( + "Month", + &[ + ("Jan", Some("Q1")), + ("Feb", Some("Q1")), + ("Mar", None), + ("Apr", Some("Q2")), + ], + ), + ]; + + const THREE: &[Level] = &[ + ("Region", &[("North", Some("Upper")), ("South", None)]), + ("Month", &[("Jan", Some("Q1")), ("Mar", None)]), + ("Kind", &[("Food", Some("Goods")), ("Tools", None)]), + ]; + + /// Every row-coordinate tuple of `levels`, outermost first. + fn tuples(levels: &[Level]) -> Vec> { + levels.iter().fold(vec![vec![]], |acc, (_, items)| { + acc.iter() + .flat_map(|t| { + items.iter().map(move |(item, _)| { + let mut t = t.clone(); + t.push(item.to_string()); + t + }) + }) + .collect() + }) + } + + /// `levels` on Row, one column, the tuples `mask` sets funded, the groups + /// `collapsed` names folded. + fn book(levels: &[Level], mask: u32, collapsed: &[(&str, &str)], prune: bool) -> Workbook { + let mut wb = Workbook::new("T"); + for (cat, items) in levels { + wb.add_category(*cat).unwrap(); + for (item, group) in items.iter() { + match group { + Some(g) => wb.add_item_in_group(cat, *item, *g).unwrap(), + None => wb.add_item(cat, *item).unwrap(), + }; + } + wb.active_view_mut().set_axis(cat, Axis::Row); + } + wb.add_category("Col").unwrap(); + wb.add_item("Col", "X").unwrap(); + wb.active_view_mut().set_axis("Col", Axis::Column); + for (i, tuple) in tuples(levels).iter().enumerate() { + if mask & (1 << i) != 0 { + let mut coords: Vec<(String, String)> = levels + .iter() + .zip(tuple) + .map(|((cat, _), item)| (cat.to_string(), item.clone())) + .collect(); + coords.push(("Col".into(), "X".into())); + wb.model + .set_cell(CellKey::new(coords), CellValue::Number(1.0)); + } + } + for (cat, group) in collapsed { + wb.active_view_mut().toggle_group_collapse(cat, group); + } + wb.active_view_mut().prune_empty = prune; + wb + } + + fn layout(wb: &Workbook) -> GridLayout { + GridLayout::with_frozen_records(&wb.model, wb.active_view(), None) + } + + /// One enumerated case: its name, then the unpruned and pruned row entries. + type Case = (String, Vec, Vec); + + /// Every (mask, collapse subset) of `levels`, unpruned and pruned. + fn every_case(levels: &[Level], groups: &[(&str, &str)]) -> Vec { + let cells = tuples(levels).len(); + let mut out = Vec::new(); + for folds in 0..(1u32 << groups.len()) { + let collapsed: Vec<(&str, &str)> = (0..groups.len()) + .filter(|g| folds & (1 << g) != 0) + .map(|g| groups[g]) + .collect(); + for mask in 1..(1u32 << cells) { + let case = format!("mask {mask:#b}, collapsed {collapsed:?}"); + let mut wb = book(levels, mask, &collapsed, false); + let full = layout(&wb).row_items; + wb.active_view_mut().prune_empty = true; + out.push((case, full, layout(&wb).row_items)); + } + } + out + } + + /// Built once for both enumerating tests: the enumeration is the cost. + fn cases() -> &'static [Case] { + static CASES: OnceLock> = OnceLock::new(); + CASES.get_or_init(|| { + let mut all = every_case( + TWO, + &[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")], + ); + all.extend(every_case( + THREE, + &[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")], + )); + all + }) + } + + #[test] + fn pruning_does_not_move_a_surviving_row_to_another_group() { + for (case, full, pruned) in cases() { + let full_rows: Vec<&Vec> = data_items(full).collect(); + let pruned_rows: Vec<&Vec> = data_items(pruned).collect(); + if pruned_rows.len() == full_rows.len() { + assert_eq!(pruned, full, "{case}: nothing pruned"); + } + for (r, tuple) in pruned_rows.iter().enumerate() { + let u = full_rows + .iter() + .position(|t| t == tuple) + .expect("a pruned row is a row of the unpruned grid"); + assert_eq!( + innermost_group(pruned, r), + innermost_group(full, u), + "{case}: row {tuple:?}\n pruned {pruned:?}\n full {full:?}", + ); + } + } + } + + #[test] + fn a_pruned_axis_draws_no_header_over_nothing_and_ends_on_no_marker() { + for (case, _, items) in cases() { + for (i, entry) in items.iter().enumerate() { + if let AxisEntry::GroupHeader { .. } = entry { + let next = items[i + 1..] + .iter() + .find(|e| !matches!(e, AxisEntry::GroupHeader { .. })); + assert!( + matches!(next, Some(AxisEntry::DataItem { .. })), + "{case}: header {entry:?} has no member in {items:?}" + ); + } + } + assert!( + !matches!(items.last(), Some(AxisEntry::GroupEnd { .. })), + "{case}: trailing marker in {items:?}" + ); + } + } + + /// The user's ruling on improvise-e265: membership is per level, and `z` + /// names the innermost open group — the one nearest the cursor. + #[test] + fn a_row_after_an_inner_groups_end_is_still_in_the_outer_group() { + // North/Jan (0) and North/Mar (2) funded; nothing is pruned or folded. + let wb = book(TWO, 0b101, &[], false); + let l = layout(&wb); + let at = |tuple: [&str; 2]| { + (0..l.row_count()) + .find(|&r| l.row_tuple(r) == Some(&[tuple[0].into(), tuple[1].into()][..])) + .unwrap() + }; + assert_eq!( + l.row_group_for(at(["North", "Jan"])), + Some(("Month".into(), "Q1".into())), + "the innermost open group" + ); + assert_eq!( + l.row_group_for(at(["North", "Mar"])), + Some(("Region".into(), "Upper".into())), + "Q1 ended; Upper did not" + ); + assert_eq!(l.row_group_for(at(["South", "Mar"])), None); + } +} diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 88e043db..7739e1ac 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -198,6 +198,9 @@ pub const MAX_ROW_HEADER_W: u16 = 24; /// because it is a fact about the *view*, which only the server holds. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FrameGroup { + /// Which header level opened it — an index into [`AxisFrame::cats`]. Runs + /// nest, so membership is per level (improvise-e265). + pub level: usize, pub name: String, pub collapsed: bool, } @@ -207,9 +210,11 @@ pub struct FrameGroup { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum FrameEntry { Group(FrameGroup), - /// The run opened by the last `Group` ends here — `AxisEntry::GroupEnd` - /// carried onto the wire (improvise-gd83). Nothing is drawn for it. - GroupEnd, + /// The run at `level`, and every run inside it, ends here — `AxisEntry::GroupEnd` + /// on the wire (improvise-gd83, e265). Nothing is drawn for it. + GroupEnd { + level: usize, + }, /// One label per header level, outermost first. Data indices — the /// `row`/`col` of every other field on [`GridCache`] — count only these. Item(Vec), @@ -247,7 +252,7 @@ impl AxisFrame { pub fn items(&self) -> impl Iterator> { self.entries.iter().filter_map(|e| match e { FrameEntry::Item(v) => Some(v), - FrameEntry::Group(_) | FrameEntry::GroupEnd => None, + FrameEntry::Group(_) | FrameEntry::GroupEnd { .. } => None, }) } @@ -336,34 +341,29 @@ impl AxisFrame { .collect() } - /// The group each data item sits under, in order — [`AxisFrame::group_of`] - /// for every position, in one pass rather than a scan apiece. + /// The innermost group each data item sits in, in order — one pass. A + /// group at L closes deeper runs; so does a `GroupEnd` at L (improvise-e265). pub fn groups(&self) -> Vec> { - let mut current: Option<&FrameGroup> = None; + let mut open: Vec> = Vec::new(); let mut out = Vec::new(); for entry in &self.entries { match entry { - FrameEntry::Group(g) => current = Some(g), - FrameEntry::GroupEnd => current = None, - FrameEntry::Item(_) => out.push(current), + FrameEntry::Group(g) => { + open.truncate(g.level); + open.resize(g.level, None); + open.push(Some(g)); + } + FrameEntry::GroupEnd { level } => open.truncate(*level), + FrameEntry::Item(_) => out.push(open.iter().rev().find_map(|g| *g)), } } out } - /// The item's group **membership** — the nearest preceding header, unless - /// a `GroupEnd` closed it first (improvise-gd83). Not for a draw loop; §3. + /// The item's innermost group — [`AxisFrame::groups`] at one position. It + /// walks the whole frame, so it is not for a draw loop; §3. pub fn group_of(&self, idx: usize) -> Option<&FrameGroup> { - let visual = self.visual_of(idx)?; - self.entries[..visual] - .iter() - .rev() - .find_map(|e| match e { - FrameEntry::Group(g) => Some(Some(g)), - FrameEntry::GroupEnd => Some(None), - FrameEntry::Item(_) => None, - }) - .flatten() + self.groups().get(idx).copied().flatten() } /// The runs of adjacent positions sharing a group — one home, both front @@ -1171,6 +1171,7 @@ mod tests { FrameEntry::Item(vec!["A".into(), "x".into()]), FrameEntry::Item(vec!["A".into(), "y".into()]), FrameEntry::Group(FrameGroup { + level: 0, name: "Late".into(), collapsed: true, }), @@ -1183,6 +1184,7 @@ mod tests { /// ungrouped between them — improvise-h0iv's reachable shape. fn repeated_group_frame() -> AxisFrame { let q1 = || FrameGroup { + level: 1, name: "Q1".into(), collapsed: false, }; @@ -1208,12 +1210,13 @@ mod tests { cats: vec!["Month".into()], entries: vec![ FrameEntry::Group(FrameGroup { + level: 0, name: "Q1".into(), collapsed: false, }), FrameEntry::Item(vec!["Jan".into()]), FrameEntry::Item(vec!["Feb".into()]), - FrameEntry::GroupEnd, + FrameEntry::GroupEnd { level: 0 }, FrameEntry::Item(vec!["Sep".into()]), ], } @@ -1245,10 +1248,11 @@ mod tests { cats: vec!["Month".into()], entries: vec![ FrameEntry::Group(FrameGroup { + level: 0, name: "Q1".into(), collapsed: true, }), - FrameEntry::GroupEnd, + FrameEntry::GroupEnd { level: 0 }, FrameEntry::Item(vec!["Sep".into()]), ], }; diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 8535f78b..036b0360 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -252,7 +252,7 @@ pub fn render_model(state: &ClientState) -> RenderModel { // `GroupEnd` is a boundary, not a row: it draws nothing and // consumes no data index (improvise-gd83). let row = match entry { - FrameEntry::GroupEnd => return Some(None), + FrameEntry::GroupEnd { .. } => return Some(None), FrameEntry::Group(group) => RenderRow::Group { label: group.name.clone(), collapsed: group.collapsed, @@ -570,6 +570,7 @@ mod tests { /// one fixture answers every editability question. fn group(name: &str, collapsed: bool) -> FrameEntry { FrameEntry::Group(improvise_protocol::FrameGroup { + level: 0, name: name.into(), collapsed, }) diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 2f61de43..a9118e65 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -236,7 +236,7 @@ impl<'a> GridWidget<'a> { match entry { // A boundary, not a row: nothing is painted and no line is // consumed (improvise-gd83). - FrameEntry::GroupEnd => continue, + FrameEntry::GroupEnd { .. } => continue, FrameEntry::Group(group) => { let label = format!("{} {}", indicator(group.collapsed), group.name); buf.set_string( From 67438b8f660d5769a61afb7754cffa27a72ecf52 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:23:28 -0700 Subject: [PATCH 043/114] test: guard every field App::restore puts back; assert the records coordinate by value improvise-4h8a: restore_puts_back_every_field_the_snapshot_took writes all thirteen AppSnapshot fields, asserts each moved, restores, and asserts each is back. Dropping any one assignment from App::restore, or restoring view_state as only active_view + status_msg, fails it naming the field. improvise-sfwy: records_mode_records_display_returns_values asserted the Region column was non-empty; it now asserts Some("North"). A GARBAGE or category-name fault passed the old assertion and fails the new one. Test-only; production code is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/command.md | 34 ++++++++++ context/test-evidence/core.md | 16 +++++ crates/improvise-command/src/ui/app.rs | 81 ++++++++++++++++++++++++ crates/improvise-core/src/view/layout.rs | 7 +- 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 5960c542..03073e5c 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6208,3 +6208,37 @@ case fails. Second, the ticket's suggested fix, which drops the asker's reset and the client never heard. That rival also fails `a_geometry_command_relays_the_cursor_reset_to_the_asker_too` and the ws-server's `the_other_document_would_also_have_a_cursor_to_invalidate`. + +### restore_puts_back_every_field_the_snapshot_took + +**What was unguarded (improvise-4h8a).** `App::restore` puts back all thirteen +fields of `AppSnapshot`. The evidence for improvise-yqpd said "all six now +round-trip", but only two were pinned by any test. At PR #109 the adversarial +review replaced `self.view_state = snapshot.view_state` with a restore of only +`active_view` and `status_msg`, the two fields a test asserted. The whole +workspace stayed green. So did removing `cursor_epoch`, `abort_effects`, +`last_reshaped_views`, `last_reframed_views`, `written_view_frames` and +`last_reframed_elsewhere` from `restore`. The code was correct. What was +missing was anything that stops a later simplification from breaking it. + +**The test.** It snapshots a fresh `App` and writes every field through +`write_every_field`. It first asserts that each field **moved**, so a fixture +that forgot a field fails loudly instead of passing that field vacuously. +Then it restores and asserts each field equals what the snapshot saw. Fields +are compared through `Debug`, because `Workbook` has no `PartialEq`. The +`view_state` write moves `mode` and the cursor and leaves `active_view` and +`status_msg` alone. So the ticket's rival cannot pass by restoring only the +two fields it knows about. + +**Rivals: fourteen, each a source edit run against this test.** Removing any +one of the thirteen assignments in `App::restore` fails with +"restore left `` as the frame wrote it". The ticket's own rival +(`view_state` restored as `active_view` + `status_msg` only) fails on +`view_state`. The file was restored after each and compared byte for byte. + +### restore_puts_back_the_mode_and_cursor_not_only_the_view_name_and_status + +The user-visible half of the same guard. After a refused frame, the host's mode +and cursor are back where they were. It is asserted by value (`Normal`, +`(0, 0)`), not only as a difference from the snapshot, so it reads as a claim +about what the user sees. The ticket's rival fails it too. diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 26c16963..9768e8ac 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1511,3 +1511,19 @@ are each compared with the same document with Month on `Axis::None`. At `Flag` at `Some(0.0)`, false rather than absent, so a fix that dropped the cell cannot pass. Rival: `read_coordinate` ignoring the reach (`if false && …`). This test fails. + +### records_mode_records_display_returns_values + +**What was vacuous (improvise-sfwy).** The category-column half asserted only +`!display.is_empty()`, where the fixture's value is `North`. The ticket named +the test under its old name, `records_mode_resolve_display_returns_values`. +improvise-umi4 renamed it when `resolve_display` was removed, and kept the +weak assertion. + +**Fault first, then the fix** (the improvise-jkxr procedure). Two faults were +injected into `records_value`'s `RecordCol::Category` arm. The first returns +`"GARBAGE"`. The second returns the category's *name*, `Region`, a plausible +wrong answer. Under the old assertion the test **passed** with each fault. +Now it asserts `Some("North")` exactly. With the faults it fails: +`left: Some("GARBAGE")` and `left: Some("Region")`. Without them it passes. +The production code is unchanged. diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index 00fcf442..fd300e88 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -5135,3 +5135,84 @@ mod formula_edit_tests { assert_eq!(buffer(&app), Some("Sum = East + West")); } } + +#[cfg(test)] +mod snapshot_restore { + use super::*; + + fn every_field(app: &App) -> Vec<(&'static str, String)> { + vec![ + ("workbook", format!("{:?}", app.model_state.workbook)), + ("dirty", format!("{:?}", app.model_state.dirty)), + ( + "document_generation", + format!("{:?}", app.document_generation), + ), + ("layout_version", format!("{:?}", app.layout_version)), + ("cursor_epoch", format!("{:?}", app.cursor_epoch)), + ("view_state", format!("{:?}", app.view_state)), + ("abort_effects", format!("{:?}", app.abort_effects)), + ("batch_aborted", format!("{:?}", app.batch_aborted)), + ("batch_refused", format!("{:?}", app.batch_refused)), + ( + "last_reshaped_views", + format!("{:?}", app.last_reshaped_views), + ), + ( + "last_reframed_views", + format!("{:?}", app.last_reframed_views), + ), + ( + "written_view_frames", + format!("{:?}", app.written_view_frames), + ), + ( + "last_reframed_elsewhere", + format!("{:?}", app.last_reframed_elsewhere), + ), + ] + } + + fn write_every_field(app: &mut App) { + app.model_state.workbook.add_category("Region").unwrap(); + app.model_state.dirty = !app.model_state.dirty; + app.document_generation += 1; + app.layout_version += 1; + app.cursor_epoch += 1; + app.view_state.mode = AppMode::Help; + app.cursor_mut().selected = (3, 2); + app.abort_effects = !app.abort_effects; + app.batch_aborted = !app.batch_aborted; + app.batch_refused = !app.batch_refused; + app.last_reshaped_views.insert("Moved".into()); + app.last_reframed_views.insert("Moved".into()); + app.written_view_frames.insert("Moved".into(), 7); + app.last_reframed_elsewhere.insert("Moved".into()); + } + + #[test] + fn restore_puts_back_every_field_the_snapshot_took() { + let mut app = App::new(Workbook::new("T"), None); + let before = every_field(&app); + let snapshot = app.snapshot(); + write_every_field(&mut app); + for ((name, was), (_, now)) in before.iter().zip(every_field(&app)) { + assert_ne!(*was, now, "the fixture did not move `{name}`"); + } + app.restore(snapshot); + for ((name, was), (_, now)) in before.iter().zip(every_field(&app)) { + assert_eq!(*was, now, "restore left `{name}` as the frame wrote it"); + } + } + + #[test] + fn restore_puts_back_the_mode_and_cursor_not_only_the_view_name_and_status() { + let mut app = App::new(Workbook::new("T"), None); + let snapshot = app.snapshot(); + app.view_state.mode = AppMode::Help; + app.cursor_mut().selected = (3, 2); + app.restore(snapshot); + assert_eq!(app.view_state.mode, AppMode::Normal); + assert_eq!(app.cursor().selected, (0, 0)); + } +} diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index a1754645..42c32d95 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -1063,10 +1063,9 @@ mod tests { // Category column resolves to the coordinate value let region_col = cols.iter().position(|c| c == "Region").unwrap(); - let display = layout.records_display(0, region_col).unwrap(); - assert!( - !display.is_empty(), - "Region column should resolve to a value" + assert_eq!( + layout.records_display(0, region_col).as_deref(), + Some("North") ); } From 56ac72a291998e55471b3e7b54b0db0b852172e4 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 11:29:01 -0700 Subject: [PATCH 044/114] Null the default excludes and attributes files for scratch repos improvise-vwjj, found reviewing #248. GIT_CONFIG_GLOBAL=/dev/null empties the config files. It does not stop git falling back to $XDG_CONFIG_HOME/git/ignore (or ~/.config/git/ignore) when core.excludesFile is unset, and git/attributes has the same kind of default. So a *.gz line in the user's default ignore file still failed a_gzipped_document_is_walked. run_git now passes -c core.excludesFile=/dev/null and -c core.attributesFile=/dev/null. A -c argument outranks every config file and every default path. The new test re-runs the gzip shape test under an XDG_CONFIG_HOME whose git/ignore holds *.gz, and again with a git/attributes line that breaks git add. Each half was red on its own. Stacked on #248 (fix/53zz-scratch-repo-env). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tui.md | 37 +++++++++++++++++++++++++++++++++ tests/shipped_documents_load.rs | 25 ++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index 0ad20193..b3a6efab 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -545,6 +545,43 @@ Rivals: - Drop `GIT_CONFIG_NOSYSTEM=1`: red, in the system run. The system scope is there so that this half of the fix has a witness at all. +### a_scratch_repo_ignores_the_users_default_ignore_and_attributes_files + +improvise-vwjj, found reviewing PR #248 at `f5cfc7ed`. The test above sets +`core.excludesFile` *inside* a config file, so emptying the config files looks +like a complete fix. It is not. When `core.excludesFile` is unset, git falls +back to `$XDG_CONFIG_HOME/git/ignore`, or `~/.config/git/ignore` when +`XDG_CONFIG_HOME` is unset. No config file is involved, so +`GIT_CONFIG_GLOBAL=/dev/null` leaves the fallback in force. It is also the +commonest place to keep a global ignore, because it needs no config line. +`core.attributesFile` has the same kind of default, `git/attributes`. + +The test points `XDG_CONFIG_HOME` at a temp directory and re-runs the gzip +shape test in a child twice: + +- once with `git/ignore` holding `*.gz`; +- once with `git/attributes` holding `*.gz working-tree-encoding=UTF-16`. + +The attributes line is chosen because it breaks `git add`: the one-byte file +cannot be decoded as UTF-16. An attribute that only changes line endings would +leave the add succeeding, and the case would witness nothing. + +Before the fix (at `f5cfc7ed`) the ignore run failed at `git add`. With only +`-c core.excludesFile=/dev/null` added, the attributes run failed. So each half +of the fix is witnessed separately. + +The fix is two `-c` arguments in `run_git`: `core.excludesFile=/dev/null` and +`core.attributesFile=/dev/null`. A `-c` argument outranks every config file and +every default path, so the `~/.config` fallback is closed by the same two lines. +This test sets only `XDG_CONFIG_HOME`, so the `~/.config` path is covered by +that argument, not exercised. + +Rivals, each run against the fixed file: + +- Drop `core.excludesFile=/dev/null`: red, `fails under a default git/ignore`. +- Drop `core.attributesFile=/dev/null`: red, `fails under a default + git/attributes`. + --- ## `tests/web_demo_model.rs` diff --git a/tests/shipped_documents_load.rs b/tests/shipped_documents_load.rs index c2c28add..f226f1f0 100644 --- a/tests/shipped_documents_load.rs +++ b/tests/shipped_documents_load.rs @@ -74,6 +74,8 @@ fn run_git(root: &Path, args: &[&str]) { let status = git(root) .env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_NOSYSTEM", "1") + .args(["-c", "core.excludesFile=/dev/null"]) + .args(["-c", "core.attributesFile=/dev/null"]) .args(args) .status() .expect("git runs"); @@ -216,3 +218,26 @@ fn a_scratch_repo_ignores_the_users_global_and_system_excludes() { ); } } + +/// improvise-vwjj: git's DEFAULT ignore and attributes files need no config line. +/// Evidence: `context/test-evidence/tui.md`. +#[test] +fn a_scratch_repo_ignores_the_users_default_ignore_and_attributes_files() { + let files = [ + ("ignore", "*.gz\n"), + ("attributes", "*.gz working-tree-encoding=UTF-16\n"), + ]; + for (name, text) in files { + let xdg = tempfile::tempdir().expect("a temp dir"); + write(xdg.path(), &format!("git/{name}"), text); + let out = rerun_with_env( + "a_gzipped_document_is_walked", + &[("XDG_CONFIG_HOME", xdg.path())], + ); + assert!( + out.status.success(), + "the gzip shape test fails under a default git/{name}: {}", + String::from_utf8_lossy(&out.stdout) + ); + } +} From 6b3577a09e6cdb9e03b0a0b26f038919af01be09 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 16:57:06 -0700 Subject: [PATCH 045/114] fold memo owns its agg and covers; grid_body's sharing is guarded (pntu, 0jw5) improvise-pntu: FoldMemo was keyed by (key, span) while the aggregation and covers were call arguments, so one memo across two covers answered the first (5.0 where 2.0 was right). FoldMemo::new(agg, covers) now owns both and the _in functions read them off it; GridLayout::fold_memo builds the grid's. The mismatched call no longer compiles. improvise-0jw5: a test in improvise-command counts fold evaluations across one grid_body call (18) against a fresh-memo control (90). A per-cell or per-total fresh memo fails it. The counter is exposed through a new test-support feature on improvise-core, enabled only as a dev-dependency. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/core.md | 11 +++ context/test-evidence/command.md | 34 ++++++++++ context/test-evidence/core.md | 35 ++++++++++ crates/improvise-command/Cargo.toml | 1 + crates/improvise-command/src/ui/viewport.rs | 75 ++++++++++++++++++++- crates/improvise-core/Cargo.toml | 5 ++ crates/improvise-core/src/model/types.rs | 71 ++++++++++--------- crates/improvise-core/src/view/layout.rs | 64 ++++++++++++++++-- 8 files changed, 258 insertions(+), 38 deletions(-) diff --git a/context/code/core.md b/context/code/core.md index 6ab014a7..0628459e 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -941,6 +941,17 @@ every member. Five decisions: always names the row categories and a total's never does. It is kept because `evaluate_spanning_in` is public, and a caller sharing one memo across two spans would otherwise read the wrong fold. No test can see it today. + **The aggregation and the covers are the memo's, not the call's** + (improvise-pntu). They are the same argument as the span, but they were + left as parameters. So one memo passed with covers=all and then with Feb + hidden returned the first fold's answer (5.0 where 2.0 was right). Widening + the key instead would have needed a closure's identity, and a closure has + none worth hashing. So `FoldMemo::new(agg, covers)` owns both, and + `evaluate_covering_in`/`evaluate_spanning_in` read them off the memo. A + mismatched pair can no longer be passed. `GridLayout::fold_memo` builds the + one a grid shares. What stays open is a memo built for one layout and + passed to another's `evaluate_in`. That hands over a consistent pair, only + for a different grid. - **A fold covers the members the grid draws** (improvise-f2oo). Hidden members are not in it. So a fold is exactly the rows it replaces, the total of a column's total does not move on `z`, and a group whose every member is hidden diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index b81f783d..43b90213 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6118,3 +6118,37 @@ four sites. It reverts each `effect::refuse(` to `effect::set_status(` alone and runs `cargo test --workspace --no-fail-fast`. Four of four fail this test, and nothing else fails. None of the four needs an abort: each is the only effect its command emits, and `p`/`i` are single bindings, not sequences. + +### one_grid_build_evaluates_each_fold_formula_once_for_cells_and_totals + +**What was unguarded (improvise-0jw5).** PR #247's whole effect is +`viewport::grid_body` making one fold memo and passing it to every cell and +every total. Replacing the per-cell memo with a fresh one passed the whole +workspace. `a_fold_row_evaluates_each_formula_once_across_the_row` calls +`GridLayout::evaluate_in` with a memo of its own. So it pins the layout-level +API, not the build that is supposed to use it. The totals' memo had no guard +at all. + +**The test.** It uses a folded F0..F8 chain, as in the layout test. It counts +fold-formula evaluations across **one `grid_body` call** and asserts **18**: +F0..F8 once for the Q1 fold row and once for the totals, whose keys omit +Month. A **setup** assertion first counts the same cells and totals, each on a +fresh memo, and requires **90** (45 and 45). That makes 18 an observed saving +and not a coincidence of the fixture. + +**The setup assertion paid for itself on the first run.** The counter lives in +improvise-core and was `#[cfg(test)]`, which is false when core is compiled as +a dependency. The first run read **0** for both counts. A test comparing the two runs, +"shared <= separate", would have passed as 0 <= 0 under every rival, forever. +The absolute setup value is what refused it. The counter is now `pub` under `cfg(any(test, feature = +"test-support"))`, and the increment is gated the same way. improvise-core's +new `test-support` feature is enabled only by improvise-command's +dev-dependency, following improvise-26dm's precedent. It is off in every +shipped build. + +**Rivals, each a source edit run against this test:** +- `cell_value` handed `&layout.fold_memo()` per cell: fails. +- The totals handed `&layout.fold_memo()` per column: fails. + +Each rival re-derives one half's chain, so the count moves off 18. The file +was restored after each and compared byte for byte. diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index a91b2fb5..2e1d4f8e 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1525,3 +1525,38 @@ had collapsed into one column could not pass the 9 by accident. Rival: `evaluate_covering_in` ignoring the memo it is given and making a fresh one. Injected into a full workspace run, it fails this test (45 against 9) and nothing else. + +### a_memo_answers_for_the_covers_it_was_built_with + +**What broke (improvise-pntu).** Found reviewing PR #247, and it was also +ChatGPT Codex's P2 there. `FoldMemo` was keyed by (fold key, span categories), +while `evaluate_covering_in` took the aggregation and the covers as separate +arguments. With `margin_book(["R = Revenue / Cost"])`, key +`{Month=Q1, _Measure=R}` and `Exact`, one memo was passed first with covers=all +and then with Feb hidden. The second call returned **5.0** (40 / 8, the first +fold's answer) where a fresh memo gives **2.0** (10 / 5, Jan alone). + +**Red first.** The test was first written against the old API, exactly as the +reviewer gave the repro, and it failed `left: Some(Number(5.0)), right: +Some(Number(2.0))`. The fix moves agg and covers into the memo +(`FoldMemo::new(agg, covers)`), so that shape **no longer compiles**. The +signature is the guard. The test now builds one memo per covers and asserts +three things. Each memo gives its own fold's value. The Feb-hidden memo agrees +with a fresh `evaluate_covering`. The covers=all memo still says 5.0 afterwards. + +**Rival:** `FoldMemo::new` storing `|_, _| true` in place of the covers it was +given. This test fails, because Jan alone reads 5.0. + +### a_memo_answers_for_the_aggregation_it_was_built_with + +pntu's other half. `{_Measure=Revenue}` names no month. So under an `Exact` +memo it is not a stored cell (`None`), and under an `OverHidden` memo it sums +Jan and Feb (`40.0`). + +**Rivals:** +- `evaluate_covering_in` passing `Aggregation::Exact` instead of the memo's + agg: this test fails. +- The same fault in `evaluate_spanning_in`: caught by two existing tests, + `a_max_measures_total_does_not_move_when_a_row_group_collapses` and + `an_average_measures_total_is_the_mean_over_its_records`. So it needed no + new test. diff --git a/crates/improvise-command/Cargo.toml b/crates/improvise-command/Cargo.toml index acc61a1f..bc1ef6e9 100644 --- a/crates/improvise-command/Cargo.toml +++ b/crates/improvise-command/Cargo.toml @@ -23,5 +23,6 @@ unicode-width = "^0.2" web-time = "1" [dev-dependencies] +improvise-core = { path = "../improvise-core", features = ["test-support"] } improvise-protocol = { path = "../improvise-protocol", features = ["test-support"] } tempfile = "3" diff --git a/crates/improvise-command/src/ui/viewport.rs b/crates/improvise-command/src/ui/viewport.rs index 4c8c48d6..edf28307 100644 --- a/crates/improvise-command/src/ui/viewport.rs +++ b/crates/improvise-command/src/ui/viewport.rs @@ -49,7 +49,7 @@ pub fn grid_body( let mut widths = header_level_seed(layout, n); // One memo for the whole build: a fold every cell and total reaches is // evaluated once (improvise-h6z1). The model does not change during it. - let memo = FoldMemo::default(); + let memo = layout.fold_memo(); // A column mixing measures has no total to show: `context/code/command.md`. let measures_on_rows = layout.row_cats.iter().any(|c| c == MEASURE_CATEGORY); let totalled = !records && !measures_on_rows && layout.row_count() > 0 && n > 0; @@ -190,3 +190,76 @@ pub fn compute_visible_cols( } count.max(1) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::types::FOLD_FORMULA_EVALUATIONS; + use crate::view::Axis; + use crate::workbook::Workbook; + + /// Q1 = {Jan, Feb} collapsed, Q2 = {Apr}; F0 = Revenue and F(i) = F(i-1) twice. + fn folded_chain() -> Workbook { + let mut wb = Workbook::new("T"); + wb.add_category("Month").unwrap(); + wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); + wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + for (month, revenue) in [("Jan", 10.0), ("Feb", 30.0)] { + let key = CellKey::new(vec![ + ("Month".into(), month.into()), + ("_Measure".into(), "Revenue".into()), + ]); + wb.model.set_cell(key, CellValue::Number(revenue)); + } + wb.model + .add_formula(crate::formula::parse_formula("F0 = Revenue", "_Measure").unwrap()); + for i in 1..=8 { + let text = format!("F{i} = F{p} + F{p}", p = i - 1); + wb.model + .add_formula(crate::formula::parse_formula(&text, "_Measure").unwrap()); + } + wb.active_view_mut().set_axis("Month", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.model.recompute_formulas(&wb.active_view().none_cats()); + wb + } + + fn evaluations(run: impl FnOnce()) -> usize { + FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0)); + run(); + FOLD_FORMULA_EVALUATIONS.with(|n| n.get()) + } + + /// improvise-0jw5: context/test-evidence/command.md. + #[test] + fn one_grid_build_evaluates_each_fold_formula_once_for_cells_and_totals() { + let wb = folded_chain(); + let view = wb.active_view(); + let layout = GridLayout::new(&wb.model, view); + let plan = FormatPlan::of(&wb.model, view); + let (rows, cols) = (0..layout.row_count(), 0..layout.col_count()); + let apart = evaluations(|| { + for (r, c) in rows.clone().flat_map(|r| cols.clone().map(move |c| (r, c))) { + let key = layout.cell_key(r, c).unwrap(); + layout.evaluate(&wb.model, &key); + } + for c in cols.clone() { + layout.column_total(&wb.model, c, &layout.fold_memo()); + } + }); + let together = evaluations(|| { + grid_body(&wb.model, &layout, &plan, rows.clone(), cols.clone()); + }); + assert_eq!( + apart, 90, + "setup: 45 for the Q1 row's cells, 45 for the totals" + ); + assert_eq!( + together, 18, + "F0..F8 once for the Q1 row, once for the totals" + ); + } +} diff --git a/crates/improvise-core/Cargo.toml b/crates/improvise-core/Cargo.toml index 790fb647..383fb843 100644 --- a/crates/improvise-core/Cargo.toml +++ b/crates/improvise-core/Cargo.toml @@ -17,6 +17,11 @@ serde = { version = "1", features = ["derive"] } # wasm-clean. siphasher = "1" +[features] +# Exposes the fold-evaluation counter to other crates' tests (improvise-0jw5). +# Off in every shipped build; enabled only by improvise-command's dev-dependency. +test-support = [] + [dev-dependencies] proptest = "1" serde_json = "1" diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index cdb39642..f9cf3577 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -138,28 +138,42 @@ fn agg_values( } // How many formulas a fold has evaluated: what improvise-o1y0's regression counts. -#[cfg(test)] +#[cfg(any(test, feature = "test-support"))] thread_local! { - pub(crate) static FOLD_FORMULA_EVALUATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; + pub static FOLD_FORMULA_EVALUATIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Each (fold key, span) evaluated once across one grid build (improvise-o1y0, -/// improvise-h6z1). Valid for one model state only: `context/code/core.md` §14. -#[derive(Default)] -pub struct FoldMemo(std::cell::RefCell), MemoEntry>>); +/// Each (fold key, span) evaluated once per grid build, under the agg and covers it +/// owns (improvise-o1y0, h6z1, pntu). One model state only: `context/code/core.md` §14. +pub struct FoldMemo<'a> { + agg: Aggregation, + covers: OwnedCovers<'a>, + seen: std::cell::RefCell), MemoEntry>>, +} + +/// The members a memo's folds cover, owned so a caller cannot pass another set. +type OwnedCovers<'a> = Box bool + 'a>; /// `None` while in progress, which is how a cycle is seen (improvise-o1y0). type MemoEntry = Option>; -impl FoldMemo { +impl<'a> FoldMemo<'a> { + pub fn new(agg: Aggregation, covers: impl Fn(&str, &str) -> bool + 'a) -> Self { + Self { + agg, + covers: Box::new(covers), + seen: Default::default(), + } + } + fn get(&self, key: &CellKey, span: Span) -> Option { let at = (key.clone(), span.over.to_vec()); - self.0.borrow().get(&at).cloned() + self.seen.borrow().get(&at).cloned() } fn put(&self, key: &CellKey, span: Span, entry: MemoEntry) { let at = (key.clone(), span.over.to_vec()); - self.0.borrow_mut().insert(at, entry); + self.seen.borrow_mut().insert(at, entry); } } @@ -183,7 +197,7 @@ enum Reach<'a> { Fold { agg: Aggregation, span: Span<'a>, - memo: &'a FoldMemo, + memo: &'a FoldMemo<'a>, }, } @@ -635,21 +649,17 @@ impl Model { agg: Aggregation, covers: Covers, ) -> Option { - let span = Span { covers, over: &[] }; - self.evaluate_fold(key, agg, span, &FoldMemo::default()) + self.evaluate_covering_in(key, &FoldMemo::new(agg, covers)) } - /// [`Self::evaluate_covering`] sharing `memo` with every other evaluation of one - /// grid build, which must not outlive the model state it read (improvise-h6z1). - pub fn evaluate_covering_in( - &self, - key: &CellKey, - agg: Aggregation, - covers: Covers, - memo: &FoldMemo, - ) -> Option { - let span = Span { covers, over: &[] }; - self.evaluate_fold(key, agg, span, memo) + /// [`Self::evaluate_covering`] under `memo`'s aggregation and covers, sharing it + /// with one grid build; it must not outlive the model state (improvise-h6z1, pntu). + pub fn evaluate_covering_in(&self, key: &CellKey, memo: &FoldMemo) -> Option { + let span = Span { + covers: &*memo.covers, + over: &[], + }; + self.evaluate_fold(key, memo.agg, span, memo) } /// The value `key` has as ONE fold over every covered item of the `over` @@ -661,20 +671,21 @@ impl Model { agg: Aggregation, covers: Covers, ) -> Option { - self.evaluate_spanning_in(key, over, agg, covers, &FoldMemo::default()) + self.evaluate_spanning_in(key, over, &FoldMemo::new(agg, covers)) } - /// [`Self::evaluate_spanning`] sharing one grid build's `memo` (improvise-h6z1). + /// [`Self::evaluate_spanning`] under one grid build's `memo` (improvise-h6z1, pntu). pub fn evaluate_spanning_in( &self, key: &CellKey, over: &[String], - agg: Aggregation, - covers: Covers, memo: &FoldMemo, ) -> Option { - let span = Span { covers, over }; - self.evaluate_fold(key, agg, span, memo) + let span = Span { + covers: &*memo.covers, + over, + }; + self.evaluate_fold(key, memo.agg, span, memo) } /// A key naming a group folds; any other key reads as before. A formula is @@ -711,7 +722,7 @@ impl Model { Some(None) => Some(CellValue::Error("circular".into())), None => { memo.put(key, span, None); - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] FOLD_FORMULA_EVALUATIONS.with(|n| n.set(n.get() + 1)); let reach = Reach::Fold { agg, span, memo }; let value = self.eval_formula_folded(formula, key, members, agg, reach); diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index 3e03f924..dd44d721 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -658,22 +658,27 @@ impl GridLayout { /// The value of a pivot key as THIS grid reads it: aggregated over its None /// axis, and a fold over the members it draws (core.md §14). One evaluation. pub fn evaluate(&self, model: &Model, key: &CellKey) -> Option { - self.evaluate_in(model, key, &FoldMemo::default()) + self.evaluate_in(model, key, &self.fold_memo()) + } + + /// A memo holding this grid's aggregation and the members it draws: the one + /// a build shares across its cells and totals (improvise-h6z1, pntu). + pub fn fold_memo(&self) -> FoldMemo<'_> { + FoldMemo::new(Aggregation::of(&self.none_cats), |cat, item| { + !self.hidden.get(cat).is_some_and(|h| h.contains(item)) + }) } /// [`Self::evaluate`] sharing one grid build's `memo` (improvise-h6z1). pub fn evaluate_in(&self, model: &Model, key: &CellKey, memo: &FoldMemo) -> Option { - let drawn = |cat: &str, item: &str| !self.hidden.get(cat).is_some_and(|h| h.contains(item)); - model.evaluate_covering_in(key, Aggregation::of(&self.none_cats), &drawn, memo) + model.evaluate_covering_in(key, memo) } /// A column's total: its cell as if every drawn row were ONE fold, so a /// formula is evaluated over the pooled rows (improvise-wm20, core.md §14). pub fn column_total(&self, model: &Model, col: usize, memo: &FoldMemo) -> Option { let key = self.column_key(col)?; - let drawn = |cat: &str, item: &str| !self.hidden.get(cat).is_some_and(|h| h.contains(item)); - let agg = Aggregation::of(&self.none_cats); - model.evaluate_spanning_in(&key, &self.row_cats, agg, &drawn, memo) + model.evaluate_spanning_in(&key, &self.row_cats, memo) } /// Display text for (row, col) in either mode. @@ -909,6 +914,7 @@ mod tests { AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, data_items, prune_axis_items, synthetic_record_info, }; + use crate::model::Aggregation; use crate::model::cell::{CellKey, CellValue}; use crate::model::types::{FOLD_FORMULA_EVALUATIONS, FoldMemo}; use crate::view::Axis; @@ -1867,7 +1873,7 @@ mod tests { .filter_map(|c| layout.cell_key(q1, c)) .collect(); let evaluations = |shared: bool| { - let memo = FoldMemo::default(); + let memo = layout.fold_memo(); FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0)); let values: Vec<_> = row_keys .iter() @@ -1888,6 +1894,50 @@ mod tests { assert_eq!(together, 9, "F0..F8 once each across the whole row"); } + /// improvise-pntu: context/test-evidence/core.md. + #[test] + fn a_memo_answers_for_the_covers_it_was_built_with() { + let wb = margin_book(&["R = Revenue / Cost"]); + let key = CellKey::new(vec![ + ("Month".into(), "Q1".into()), + ("_Measure".into(), "R".into()), + ]); + let all = FoldMemo::new(Aggregation::Exact, |_, _| true); + let no_feb = FoldMemo::new(Aggregation::Exact, |_, item| item != "Feb"); + let pooled = wb.model.evaluate_covering_in(&key, &all); + let jan_only = wb.model.evaluate_covering_in(&key, &no_feb); + assert_eq!( + pooled, + Some(CellValue::Number(5.0)), + "40 / 8 over Jan and Feb" + ); + assert_eq!( + jan_only, + Some(CellValue::Number(2.0)), + "10 / 5 over Jan alone" + ); + let fresh = wb + .model + .evaluate_covering(&key, Aggregation::Exact, &|_, item| item != "Feb"); + assert_eq!(jan_only, fresh, "a memo agrees with a fresh evaluation"); + assert_eq!(wb.model.evaluate_covering_in(&key, &all), pooled); + } + + /// improvise-pntu's other half: the aggregation is the memo's too. + #[test] + fn a_memo_answers_for_the_aggregation_it_was_built_with() { + let wb = margin_book(&[]); + let key = CellKey::new(vec![("_Measure".into(), "Revenue".into())]); + let exact = FoldMemo::new(Aggregation::Exact, |_, _| true); + let over = FoldMemo::new(Aggregation::OverHidden, |_, _| true); + assert_eq!(wb.model.evaluate_covering_in(&key, &exact), None); + assert_eq!( + wb.model.evaluate_covering_in(&key, &over), + Some(CellValue::Number(40.0)), + "Jan and Feb summed" + ); + } + /// improvise-eccl — see context/test-evidence/core.md. #[test] fn a_folds_ratio_formula_is_the_ratio_of_its_folded_inputs() { From c349d14dd6d31d44d8abc74609ac2190d1684a34 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 16:57:50 -0700 Subject: [PATCH 046/114] lockstep: deliver folds host frames through fold, not fold_outgoing improvise-xoxh, found in the delta review of #246. deliver hands a client what the HOST sent, so a relay must go through fold, which queues it directly behind the awaited head. fold_outgoing queues it at the back as the client's own frame. That is the pre-jpba ordering, so a test built on deliver could not see a jpba regression. The bug was latent, because deliver's one caller sent only view-only frames. deliver_queues_a_host_relay_behind_the_awaited_head was red at 790a33ba. With deliver fixed, putting the pre-jpba ordering back in settle_relay fails this test and nothing else in the lockstep binary. The renaming test that came in through the gh/main merge now folds its own commands through fold_outgoing. It also asserts that the commit is the one awaited frame, so the two doors are told apart. Stacked on #246 (fix/hv0r-jpba-relays). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/clients.md | 35 +++++++++++++++++++ .../improvise-wasm-client/tests/lockstep.rs | 34 +++++++++++++++--- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 3adaf2de..483eeb77 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -613,6 +613,41 @@ keypress directly rather than the client's `Command::Model` — but `BasisCache::claim` withholds until it has *something*, so a client without one resolves no commit at all. +### deliver_queues_a_host_relay_behind_the_awaited_head + +improvise-xoxh, found in the delta review of PR #246 at `790a33ba`. `deliver` +is the test helper that hands a client what the **host** sent. `e0dec966` +switched it from `fold` to `fold_outgoing` together with the loops that fold +the client's **own** commands, but a host relay goes through `fold` +(`context/code/clients.md`). + +The bug was latent, because `deliver`'s only caller sent view-only frames: with +nothing awaited, both doors settle at once. With an awaited frame in flight they +differ. `fold` queues the relay directly behind the awaited head, marked +`relayed`. `fold_outgoing` pushes it at the back, as if the client had sent it. +That is the pre-jpba ordering, so a test built on `deliver` could not see a jpba +regression. + +The test puts two entries in flight: an awaited head, then one of the client's +own frames that is not awaited. It delivers one relayed command and asserts the +exact queue: head, relay (`relayed: true`), own frame. Red at `790a33ba`: the +relay arrived last, with `relayed: false`. + +Rivals, each run against the fixed tree: + +- `deliver` back on `fold_outgoing`: red. This is the red state above. +- The library's `settle_relay` changed to push at the back (the pre-jpba + ordering), with `deliver` fixed: red. In the whole `lockstep` binary, this test + is the only failure. Before it existed, a `deliver`-based test could not see + that rival at all. + +The same PR moves +`renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host`, which +arrived through the gh/main merge, onto `fold_outgoing`. It also adds one +assertion, that the commit is the one awaited frame in flight. Without that, +nothing in the test tells the two doors apart. Rival: the loop over `fold` put +back. It fails with `the commit awaits the host's answer`. + --- ## `harness/` — the browser specs diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index e7559c55..a776122d 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1467,7 +1467,7 @@ fn deliver( match addressed.msg { improvise_protocol::ServerFrame::Snapshot(snapshot) => client.bootstrap(*snapshot), improvise_protocol::ServerFrame::Commands(cmds) => { - client.fold_outgoing(&cmds); + cmds.iter().for_each(|cmd| client.fold(cmd)); } improvise_protocol::ServerFrame::Rejected(r) => client.refused(r), improvise_protocol::ServerFrame::Accepted => client.accepted(), @@ -1475,6 +1475,32 @@ fn deliver( } } +/// improvise-xoxh: `deliver` models the HOST, so a relay it hands over queues +/// behind the awaited head. Evidence: `context/test-evidence/clients.md`. +#[test] +fn deliver_queues_a_host_relay_behind_the_awaited_head() { + use improvise_protocol::Command; + use improvise_protocol::client::Sent; + let sent = |cmd: Command, awaited: bool, relayed: bool| Sent { + cmds: vec![cmd], + awaited, + relayed, + }; + let head = sent(Command::SetSelected { row: 1, col: 0 }, true, false); + let own = sent(Command::SetSelected { row: 2, col: 0 }, false, false); + let mut client = ClientState::default(); + client.in_flight.extend([head.clone(), own.clone()]); + let session = improvise_command::ui::project::SessionId(0); + let relay = Command::SetSelected { row: 3, col: 0 }; + let frame = improvise_command::ui::host::Addressed { + to: session, + msg: improvise_protocol::ServerFrame::Commands(vec![relay.clone()]), + }; + deliver(vec![frame], session, &mut client); + let queued: Vec = client.in_flight.iter().cloned().collect(); + assert_eq!(queued, vec![head, sent(relay, false, true), own]); +} + /// improvise-e5pf, the route the first fix missed: a **view switch** changes /// records-ness. See `context/test-evidence/clients.md`. #[test] @@ -2307,12 +2333,12 @@ fn renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host() { let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("Enter in the formula editor resolves here"); }; - for cmd in &cmds { - client.fold(cmd); - } + client.fold_outgoing(&cmds); apply_on(&mut twin, &cmds); reduce_key(&mut app, event); + let awaiting: Vec = client.in_flight.iter().map(|s| s.awaited).collect(); + assert_eq!(awaiting, vec![true], "the commit awaits the host's answer"); assert_eq!(document_text(&twin), document_text(&app)); assert_eq!( raws(&app), From 3fcd407b08381810a7cd4bd8005c75f302b3ec7f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 16:58:39 -0700 Subject: [PATCH 047/114] A reopened editor awaits any unanswered write, settles only while editing, and states its awaiting flag (improvise-mdfc, improvise-4cb5, improvise-mgis) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit improvise-mdfc: an untouched commit seeded the reopened editor from the cache even while an earlier write from this client was unanswered, so Tab Tab at the rightmost column stored 505 where the TUI stores 5095. `advanced` now awaits when this frame writes or any in-flight frame is awaited. The lockstep test sends three Tabs before answering any. improvise-4cb5: `settle_awaited_seed` also requires an editing mode, so an Esc before the answer no longer leaves the seed in Normal's buffer. improvise-mgis: `OpenedEditor::awaiting` loses `serde(default)`; a frame missing it fails to parse as the missing field (protocol.md §3, 1wl0). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/protocol.md | 27 +++++- context/test-evidence/clients.md | 56 ++++++++++++ crates/improvise-protocol/src/client.rs | 34 +++++-- crates/improvise-protocol/src/resolve.rs | 41 +++++++-- .../improvise-wasm-client/tests/lockstep.rs | 88 +++++++++++++++++++ 5 files changed, 233 insertions(+), 13 deletions(-) diff --git a/context/code/protocol.md b/context/code/protocol.md index 0355e861..b0c34642 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -2033,8 +2033,27 @@ stated once, over the write, never over a list of cases: client holds, but only once no frame it sent is still unanswered. The answer folds the host's projection before `Accepted`, so the grid is the host's by then. `accepted()` therefore settles. -- **A commit that writes nothing** still seeds from the cache at once. Nothing - changed, so the cache is current. +- **A commit that writes nothing** awaits as well while any earlier frame's + write is unanswered. Otherwise it seeds from the cache at once. "Nothing + changed, so the cache is current" was the rule here, and it was false + (improvise-mdfc). *This* commit changed nothing, but an earlier one might + have, and the cache predates it until the answer arrives. Tab Tab pressed + quickly at the rightmost column opened the second editor on the value from + before the first Tab's write: 505 where the TUI stores 5095. So the test is + "does this frame write, or is a write still in flight", which is + `writes || in_flight.iter().any(|sent| sent.awaited)`. It checks *any* + in-flight write, not the newest frame: a frame that writes nothing stays in + the queue behind an unanswered write, so the newest frame can be one of + those. +- **The seed settles only into an open editor** (improvise-4cb5). An Esc typed + before the answer arrives leaves the awaiting `opened_editor` in place. The + late seed would then fill the edit buffer in `Normal` mode, where the TUI's is + empty. +- **`awaiting` has no `serde(default)`** (improvise-mgis), for the reason + `AxisFrame::first` has none (§3a, improvise-1wl0). `awaiting: false` is a + claim, "the seed is settled". A peer that dropped the field would hand over an + editor that stays empty forever, and whose Enter writes nothing. A missing + field is therefore a parse failure. Asking the host to send the seed was the alternative. It would have needed a new message, and the host already sends it: the landed cell's `edit_text` in @@ -2043,7 +2062,9 @@ the projection it owes this client anyway. Two things are accepted costs: well under a frame. - A key typed before the answer arrives marks the editor touched, and the late seed then does not overwrite it. What the user sees is then what gets - written, which is never another record's number. + written. That is never another record's number only because of the mdfc + rule above: before it, an untouched commit behind an unanswered write opened + on the stale cache, and typing then extended the wrong number. The TUI is unchanged: its `RebuildLayout` in front of `EnterEditAtCursor` already reads the renumbered grid (gotcha 23). This also took diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index a846ea1e..1390ab0b 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5222,3 +5222,59 @@ The fold half of improvise-3uzg, without a host. The steps: tests. - `accepted()` not settling fails the same four. - Ignoring `touched` fails this test alone. It is the one guard on type-ahead. + +### `a_tab_sent_before_the_last_write_is_answered_reopens_on_that_write` + +**improvise-mdfc**, found by the #227 review at `baabe1a9`. Fixture: +`precise_host`, cursor (0,1) on North/Revenue = 2 (the rightmost column), window +2×2. `i 9` go through `sent_and_answered`. Then **three** Tabs are sent through +`sent`, and none is answered until all three are out. The first writes 29 and +the other two write nothing. After the answers are delivered in order, the +browser's buffers must equal the TUI host's. `5 Enter` then has to store 295 on +both ends. + +Every earlier lockstep test answered each key before the next was sent, so the +client's cache was always the host's by the time it resolved a key. That is why +none of them saw this. The hazard needs a key resolved while a write is still +unanswered. + +Red at `baabe1a9`: after the answers the browser buffer was `"2"` (the cache from +before the write) and the host's was `"29"`. In Chromium over the worker, with +`tall.improv` R01/Cost = 50 and `i 9 Tab Tab 5 Enter`, the browser stored 505 +where the TUI stores 5095. + +**Rivals:** +- The fix reverted (await only when this commit writes) fails this test alone. + The workspace was otherwise green at `baabe1a9`. +- Awaiting only when the **newest** in-flight frame is awaited + (`in_flight.back()`) fails this test, and the other 53 lockstep tests pass. That is why it sends three Tabs, + not two: with two, the newest in-flight frame *is* the write, and `back()` and + `any()` cannot be told apart. + +### `client::tests::an_awaited_seed_arriving_after_escape_leaves_the_buffer_empty` + +**improvise-4cb5**. A write frame with an awaiting editor is sent, and then Esc +(`SetBuffer edit ""`, `ChangeMode Normal`) is sent before the answer arrives. +The host's projection says `3`, and then `Accepted` arrives. The mode must be +`Normal` and the edit buffer must be empty. + +Red before the fix: the buffer was `"3"`. `settle_awaited_seed` asked only +whether the opened editor was awaiting and untouched, and Esc leaves +`opened_editor` in place. The seed `3` is distinct from the empty buffer Esc +leaves, so the assertion can only pass if nothing was written. + +**Rival:** the mode check removed (the code at `baabe1a9`) fails this test, and +the rest of `improvise-protocol`'s lib passes. + +### `resolve::opened_editor_wire_tests::a_frame_whose_editor_lost_its_awaiting_flag_does_not_parse` + +**improvise-mgis**. A `ClientFrame::Commands` carrying +`SetOpenedEditor(Some(OpenedEditor::awaiting()))` is serialized, and the +`awaiting` key is removed from the JSON. The test asserts the key was there, and +that `seed` is still there, so the mutated value is a well-formed editor minus +one field. It must then fail to parse **as the missing field**, not as broken +JSON. + +**Rival:** `#[serde(default)]` restored. The frame then parses, with +`awaiting: false`, and the test fails at `expect_err`. Before this test existed, +deleting the attribute left the whole workspace green, so nothing pinned it. diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index e580f7de..a1c724c7 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -183,11 +183,12 @@ impl ClientState { /// An editor opened after a write takes its seed from the grid the host /// answered with, never from the cache the write made stale (improvise-3uzg). fn settle_awaited_seed(&mut self) { - let waiting = self - .view - .opened_editor - .as_ref() - .is_some_and(|opened| opened.awaiting && !opened.touched); + let waiting = self.view.mode.is_editing() + && self + .view + .opened_editor + .as_ref() + .is_some_and(|opened| opened.awaiting && !opened.touched); let answered = !self.in_flight.iter().any(|sent| sent.awaited); if let (true, true, Some(seed)) = (waiting, answered, crate::resolve::edit_seed(self)) { self.view.buffers.insert("edit".to_string(), seed.clone()); @@ -489,6 +490,29 @@ mod tests { ); } + /// BUG (improvise-4cb5) — see `context/test-evidence/clients.md`. + #[test] + fn an_awaited_seed_arriving_after_escape_leaves_the_buffer_empty() { + let mut state = ClientState::default(); + answered_with(&mut state, "2"); + state.fold_outgoing(&write_then_awaiting_editor()); + state.fold_outgoing(&[ + Command::SetBuffer { + name: "edit".into(), + value: String::new(), + }, + Command::ChangeMode(crate::AppMode::Normal), + ]); + answered_with(&mut state, "3"); + state.accepted(); + assert_eq!(state.view.mode, crate::AppMode::Normal); + assert_eq!( + state.view.buffers.get("edit").map(String::as_str), + Some(""), + "the late seed was written into the buffer of an editor Esc had closed" + ); + } + /// A frame the host does not answer must not consume an answer, or the /// queue addresses the wrong frame. Evidence file. #[test] diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index cdfbe558..df46c7d8 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1009,9 +1009,10 @@ fn advanced( return Some(Vec::new()); }; let mut commands = scrolled_to(state, nr, nc); - // After a write the cache may name other cells than the host's grid does, so - // the seed waits for the host's answer (improvise-3uzg, `protocol.md` §3). - commands.extend(match writes { + // After a write — this frame's or an unanswered earlier one's — the cache may + // be stale, so the seed waits for the host (improvise-3uzg/mdfc, `protocol.md` §3). + let stale = writes || state.in_flight.iter().any(|sent| sent.awaited); + commands.extend(match stale { true => awaiting_seed(edit_mode), false => opened_on(seed_at(state, nr, nc), edit_mode), }); @@ -1071,8 +1072,8 @@ pub struct OpenedEditor { pub seed: String, /// Whether the user has edited the buffer since it opened (improvise-2054). pub touched: bool, - /// The seed is still to come from the host's answer (improvise-3uzg). - #[serde(default)] + /// The seed is still to come from the host's answer (improvise-3uzg). Not + /// `serde(default)`: `false` is a claim — `protocol.md` §3, improvise-mgis. pub awaiting: bool, } @@ -4064,3 +4065,33 @@ mod tests { ); } } + +#[cfg(test)] +mod opened_editor_wire_tests { + use super::OpenedEditor; + use crate::{ClientFrame, Command}; + + /// improvise-mgis: `awaiting: false` is the claim that the seed is settled, + /// so a frame that dropped it is refused. `context/test-evidence/clients.md`. + #[test] + fn a_frame_whose_editor_lost_its_awaiting_flag_does_not_parse() { + let frame = ClientFrame::Commands(vec![Command::SetOpenedEditor(Some( + OpenedEditor::awaiting(), + ))]); + let mut json = serde_json::to_value(&frame).expect("a ClientFrame serializes"); + let opened = json + .pointer_mut("/Commands/0/SetOpenedEditor") + .and_then(serde_json::Value::as_object_mut) + .expect("the editor is where this test expects it"); + assert!( + opened.remove("awaiting").is_some() && opened.contains_key("seed"), + "the field must have been there, and the rest of the editor must remain" + ); + let err = serde_json::from_value::(json) + .expect_err("an editor that states no awaiting flag states no claim"); + assert!( + err.to_string().contains("missing field `awaiting`"), + "it must fail as the MISSING FIELD, not as broken JSON: {err}" + ); + } +} diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 8950f2ca..5ca971a8 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2549,6 +2549,94 @@ fn a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up() { ); } +/// What the host owes a frame it has applied: its projection, its basis, and +/// `Accepted` when the frame was awaited. Held back so the next key overtakes it. +struct Unanswered { + grid: improvise_protocol::GridCache, + basis: improvise_protocol::Basis, + awaited: bool, +} + +fn sent( + client: &mut ClientState, + host: &mut App, + event: KeyEvent, + window: (usize, usize), +) -> Unanswered { + let KeyOutcome::Local(cmds) = resolve(client, event) else { + panic!("{event:?}: the client must answer this key locally"); + }; + client.fold_outgoing(&cmds); + apply_on(host, &cmds); + let awaited = cmds + .iter() + .any(|c| matches!(c, improvise_protocol::Command::Model { .. })); + let (grid, basis) = (client_of(host, window).grid, published_basis(host, window)); + Unanswered { + grid, + basis, + awaited, + } +} + +fn answered(client: &mut ClientState, answer: Unanswered) { + client.grid = answer.grid; + improvise_protocol::WidgetCache::apply(&mut client.basis, &answer.basis); + if answer.awaited { + client.accepted(); + } +} + +/// BUG (improvise-mdfc) — see `context/test-evidence/clients.md`. +#[test] +fn a_tab_sent_before_the_last_write_is_answered_reopens_on_that_write() { + let window = (2, 2); + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (0, 1); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (0, 1); + let mut client = client_ready_to_edit(&client_host, window); + let tab = BrowserKey::new("Tab").to_key_event().unwrap(); + for key in ["i", "9"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + sent_and_answered(&mut client, &mut client_host, event, window); + reduce_key(&mut key_host, event); + } + // Three Tabs, so a frame that writes nothing sits between the write and the last. + let answers: Vec = (0..3) + .map(|_| sent(&mut client, &mut client_host, tab, window)) + .collect(); + let awaited: Vec = answers.iter().map(|a| a.awaited).collect(); + assert_eq!( + awaited, + [true, false, false], + "setup: only the first Tab writes" + ); + for answer in answers { + reduce_key(&mut key_host, tab); + answered(&mut client, answer); + } + assert_eq!( + client.view.buffers, key_host.view_state.buffers, + "the second editor opened on the cache the first write had made stale" + ); + for key in ["5", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + sent_and_answered(&mut client, &mut client_host, event, window); + reduce_key(&mut key_host, event); + } + for (end, app) in [("browser", &client_host), ("host", &key_host)] { + assert_eq!( + app.model_state + .workbook + .model + .get_cell(®ion_measure("North", "Revenue")), + Some(&improvise_protocol::CellValue::Number(295.0)), + "{end}: the browser overwrote the value Tab had just written" + ); + } +} + const FORMULAS: [&str; 3] = [ "Profit = Revenue * 2", "Tax = Revenue * 0.1", From 322de3705312cff89b63f6aceb81a9cd9fbfbc5d Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:01:33 -0700 Subject: [PATCH 048/114] Init scratch repos with an empty template directory improvise-pr3i, found reviewing #255. git init copies GIT_TEMPLATE_DIR into the new .git. That variable is not in --local-env-vars, and it bypasses config, so a template whose info/exclude holds *.gz still broke a_gzipped_document_is_walked at git add. scratch_repo now runs `git init -q --template=`, which copies no template at all, including a distribution's compiled-in default. Stripping the variable would still copy that default. Also: rerun_with_env's callers print the child's stderr as well as its stdout (told), because the child's panic goes to stderr. Stacked on #255 (fix/vwjj-default-excludes). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tui.md | 28 ++++++++++++++++++++++++++ tests/shipped_documents_load.rs | 35 ++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index b3a6efab..43292938 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -582,6 +582,34 @@ Rivals, each run against the fixed file: - Drop `core.attributesFile=/dev/null`: red, `fails under a default git/attributes`. +### a_scratch_repo_ignores_the_users_template_dir + +improvise-pr3i, found reviewing PR #255 at `56ac72a2`. This is a third path to the +53zz symptom. `git init` copies a template directory into the new `.git`, and +`GIT_TEMPLATE_DIR` names one. The variable is not in +`git rev-parse --local-env-vars`, so `git(root)` does not remove it. It also +bypasses config, so `GIT_CONFIG_GLOBAL=/dev/null` (which nulls +`init.templateDir`) does not reach it either. A template whose `info/exclude` +holds `*.gz` therefore became the scratch repository's own exclude file, and +`git add` refused the gzipped document. + +The test points `GIT_TEMPLATE_DIR` at such a template and re-runs +`a_gzipped_document_is_walked` in a child. Red at `56ac72a2`, failing at `git add` +with `Use -f if you really want to add them`. That line appears in the failure +message only because this PR also makes `rerun_with_env`'s callers print the +child's **stderr** (`told`). The child's panic goes to stderr, and before this +change the message printed an empty stdout. + +The fix is `git init -q --template=`. An empty template directory copies nothing, +whatever the environment, config or compiled-in default says. The obvious +alternative, `env_remove("GIT_TEMPLATE_DIR")`, passes this test equally well, so +the choice between them is made by argument, not by the test. Removing the +variable still lets git copy its compiled-in default template, which a +distribution may have edited. `--template=` closes that too, for the same reason +the `-c` arguments in `run_git` beat relying on the absence of a config file. + +Rival: drop `--template=`. Red, as above. + --- ## `tests/web_demo_model.rs` diff --git a/tests/shipped_documents_load.rs b/tests/shipped_documents_load.rs index f226f1f0..01142d7a 100644 --- a/tests/shipped_documents_load.rs +++ b/tests/shipped_documents_load.rs @@ -66,7 +66,7 @@ fn git(root: &Path) -> std::process::Command { /// A scratch repository, so each way of shipping a document is set up for real. fn scratch_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().expect("a temp dir"); - run_git(dir.path(), &["init", "-q"]); + run_git(dir.path(), &["init", "-q", "--template="]); dir } @@ -165,11 +165,36 @@ fn rerun_with_env(test: &str, env: &[(&str, &Path)]) -> std::process::Output { let stdout = String::from_utf8_lossy(&out.stdout); assert!( stdout.contains("running 1 test"), - "{test} did not run: {stdout}" + "{test} did not run: {}", + told(&out) ); out } +/// Everything a child test said, both streams: its panic lands on stderr. +fn told(out: &std::process::Output) -> String { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + format!("\n--- stdout\n{stdout}\n--- stderr\n{stderr}") +} + +/// improvise-pr3i: `git init` copies `GIT_TEMPLATE_DIR`, which no config null +/// reaches. Evidence: `context/test-evidence/tui.md`. +#[test] +fn a_scratch_repo_ignores_the_users_template_dir() { + let templates = tempfile::tempdir().expect("a temp dir"); + write(templates.path(), "info/exclude", "*.gz\n"); + let out = rerun_with_env( + "a_gzipped_document_is_walked", + &[("GIT_TEMPLATE_DIR", templates.path())], + ); + assert!( + out.status.success(), + "the gzip shape test fails under a template info/exclude: {}", + told(&out) + ); +} + /// improvise-53zz: a hook in a linked worktree exports `GIT_DIR`, which beats /// `-C`. Evidence: `context/test-evidence/tui.md`. #[test] @@ -186,7 +211,7 @@ fn a_scratch_repo_ignores_an_inherited_git_dir() { assert!( out.status.success(), "the shape test fails under an inherited GIT_DIR: {}", - String::from_utf8_lossy(&out.stdout) + told(&out) ); let staged = git(outer.path()) .args(["ls-files"]) @@ -214,7 +239,7 @@ fn a_scratch_repo_ignores_the_users_global_and_system_excludes() { assert!( out.status.success(), "the gzip shape test fails under a `*.gz` exclude in {scope}: {}", - String::from_utf8_lossy(&out.stdout) + told(&out) ); } } @@ -237,7 +262,7 @@ fn a_scratch_repo_ignores_the_users_default_ignore_and_attributes_files() { assert!( out.status.success(), "the gzip shape test fails under a default git/{name}: {}", - String::from_utf8_lossy(&out.stdout) + told(&out) ); } } From 3eafd4dec7bc6dc83e6a3de6dcfed2f26aadd57f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:02:13 -0700 Subject: [PATCH 049/114] check-ci-graph: refuse a key or needs: it cannot read plainly (w0j4) Quoted, anchored and multi-line needs: values, and quoted, spaced or explicit ? keys, all skipped kb on a PR while the gate reported every suite run. Rather than parse more YAML, every structural line inside jobs: must take one plain form or the gate names it and fails. Also a 1be2 case at the one-column boundary, which the owner+1 rival passed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 21 ++++++ context/test-evidence/tooling.md | 51 +++++++++++++ scripts/check-ci-graph.py | 125 ++++++++++++++++++++++++++++++- 3 files changed, 193 insertions(+), 4 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index 14cb4d25..543d36c9 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -302,6 +302,27 @@ scalar is skipped as content. Any line outside a YAML comment that mentions `scripts/checkers.sh` and is not a counted step is **reported**, not ignored. The union of the counted jobs' rows must cover `checkers.sh --list`. +**A key the gate cannot read plainly is refused, not parsed** (improvise-w0j4). +YAML has many spellings of one key or one list. `"if":`, `'continue-on-error':`, +`if :`, and `? if` then `: false` all mean the same key. `needs: ["gate"]`, +`needs: 'gate'`, `- "gate"`, `needs: &d [gate]`, and a flow list split over +two lines all name `gate`. The gate read none of them. Each one skipped `kb` +on a PR while the gate reported every suite run. + +Teaching it each spelling is the treadmill st8q stepped off. So every +structural line inside `jobs:` must take one plain form, or the gate names the +line and fails: +- a top-level key; +- a job name; +- a job key; +- a step key; +- a `needs:` value that is empty (a block list follows), one bare name, or a + one-line flow list of bare names; +- a block-list item that is a bare name. + +Content under a job key other than `steps:` and `needs:`, such as `env:` or +`strategy:`, is not structure the gate reads, so it is not checked. + **What this does not close, stated rather than guarded.** Once a counted step is reached, the gate trusts that it runs the script it names. An earlier step in the same job that rewrote `scripts/checkers.sh` or its table would defeat diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index a95786ad..cce90568 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1038,3 +1038,54 @@ the rival below survived the first 22. **Stated, not guarded:** a needed job that *fails* also skips the job, and an earlier `actions/checkout` with `ref:` replaces the tree (`context/code/tooling.md`). + +### The gate refuses a key or needs: it cannot read plainly — `scripts/check-ci-graph.py` (improvise-w0j4) + +**What broke (P2, fail-open).** Found in the delta review of #228 at +`2aafff3b`. The bbwa fix read `needs:` names as raw text, and it recognised job +and step keys only in the bare `key:` form. The review put a `gate` job with +`if: false` in the real `ci.yml` and gave `kb` a `needs:` on it. Every one of +these spellings still exited 0 with "all 9 suites run": +- `needs: ["gate"]`, `needs: 'gate'`, and a block item `- "gate"`; +- `needs: &d [gate]`, and `needs: &d` followed by a block list; +- a flow list split over two lines. + +yq read `kb` as needing `gate` in every case. The disqualifier keys had the +same gap: `"if":`, `'continue-on-error':`, `if :`, and `? if` then `: false`. + +**Fix: the review's suggestion, refuse rather than parse.** Every structural +line inside `jobs:` must be one plain form. That means a job name, a job key, a +step key, a `needs:` value that is empty, one bare name or a one-line flow list +of bare names, or a bare block item. Top-level keys must be plain everywhere. +Anything else is reported, naming the line, and the gate fails. + +**Red first.** Eleven refusal cases were added. All eleven failed with `saw []` +against `2aafff3b`, which is the false pass. Two controls, `needs: [ gate ] # +c` and `needs: gate`, still read `gate` and are conditional. Two more cases, +a quoted job name and a quoted top-level key, were added with the fix. + +**Replayed on the real `ci.yml`.** Each attack was mutated in, run, and +restored, with a byte comparison after the last. All eleven spellings now exit +1, and the plain `needs: [gate]` control exits 1 as conditional. + +**The 1be2 boundary.** The earlier case put the continuation two columns past +the key. So a rival that marks multiline only past `owner + 1` passed all 23 +cases. On the real `ci.yml`, a 9-space `|| true` under the kb step's `run:` at +column 8 then exited 0. The new case continues exactly one column past the +key. The review's rival fails it and nothing else. On the real `ci.yml` the +9-space continuation now exits 1. + +**Rivals, each a source edit run against the real `--self-test` (39 cases):** +- `needs:` value accepted as anything: fails the five inline or anchored or + split `needs:` cases. +- Block items accepted as anything: fails the quoted-item case. +- Step keys not checked: fails the quoted step key. +- Job keys not checked: fails `"if":`, `'continue-on-error':`, `if :`, and `?`. +- Job names not checked: fails the quoted job name. +- Top-level keys not checked: fails the quoted top-level key. +- Refusals collected but not reported: fails all thirteen. +- The review's `owner + 1` rival: fails the boundary case alone. + +**Stated, not guarded.** Content under job keys other than `steps:` and +`needs:` is not checked. Neither is anything deeper than a step's own keys. +The gate reads no structure there. diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index 2debf16f..bc7f0a9a 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -76,6 +76,28 @@ _DISQUALIFY = ("if", "continue-on-error", "shell") _JOB_DISQUALIFY = _DISQUALIFY + ("defaults",) +_NAME = r"[A-Za-z_][\w-]*" +_TOP_KEY = re.compile(rf"^{_NAME}:(\s|$)") +_NEEDS_VALUE = re.compile(rf"^(|{_NAME}|\[\s*{_NAME}(\s*,\s*{_NAME})*\s*\])$") +_NEEDS_ITEM = re.compile(rf"^ - {_NAME}$") + + +def _readable(line, indent, in_steps, in_needs): + """Whether a structural line inside `jobs:` is one this gate can read. Anything + else is refused, not parsed: context/code/tooling.md (improvise-w0j4).""" + if indent == 2: + return bool(_JOB.match(line)) + if indent == 4: + m = _JOB_KEY.match(line) + return bool(m) and (m.group(1) != "needs" + or bool(_NEEDS_VALUE.match(_inline_value(m.group(2))))) + if in_needs: + return bool(_NEEDS_ITEM.match(line)) + if in_steps: + return indent in (6, 8) and bool(_STEP_KEY.match(line)) + return indent > 4 + + def _inline_value(rest): """A key's inline value, without a YAML comment (`#` after whitespace).""" return re.sub(r"\s+#.*$", "", rest).strip() @@ -98,8 +120,9 @@ def ci_steps(text): Nothing is read as shell. A key with an inline value owns every following line indented deeper, which is how a block scalar, a folded value, a plain continuation or a heredoc inside one is skipped whole (improvise-st8q).""" - steps, stray = [], [] + steps, stray, unread = [], [], [] job, in_steps, job_flags, step = None, False, set(), None + in_jobs = False workflow_flags = set() owner, owner_step, quote = None, None, None needs, if_jobs, in_needs = {}, set(), False @@ -125,6 +148,13 @@ def ci_steps(text): owner, owner_step = None, None if not raw.strip() or raw.lstrip().startswith("#"): continue + line = re.sub(r"\s+#.*$", "", raw) + if indent == 0: + in_jobs = line.startswith("jobs:") + if not _TOP_KEY.match(line): + unread.append(n) + elif in_jobs and indent <= 8 and not _readable(line, indent, in_steps, in_needs): + unread.append(n) if raw.startswith("defaults:"): workflow_flags.add("workflow defaults") m = _JOB.match(raw) @@ -181,7 +211,7 @@ def ci_steps(text): for s in steps: s["flags"] |= workflow_flags s["flags"] |= {f"needs {j}, which has if" for j in _needed(s["job"], needs) & if_jobs} - return steps, stray + return steps, stray, unread def _needed(job, needs): @@ -214,11 +244,16 @@ def _unrun(s): "ci_suites in scripts/checkers.sh") +def _unread(n): + return (f"ci.yml line {n} is a key or `needs:` value this gate cannot read as a " + "plain name, so it cannot trust the graph; spell it plainly") + + def suite_verdict(suites, text): """The complaints: suites no counted step runs, and every step or line that does not count, each named so the fix is obvious rather than silent.""" - steps, stray = ci_steps(text) - out, ran, missing = [], set(), set() + steps, stray, unread = ci_steps(text) + out, ran, missing = [_unread(n) for n in unread], set(), set() for s in steps: m = _CI_CALL.match(s["run"] or "") if not m: @@ -269,6 +304,19 @@ def _stray(n): "whole `run:` is `./scripts/checkers.sh --ci `") +_GATE = " gate:\n if: false\n steps:\n - run: echo\n" +_KB_RUN = " - run: ./scripts/checkers.sh --ci kb\n" +_NEEDS_GATE = ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), " + "so it does not count"] + _NO_PW + + +def _unreadable(name, text, *markers, rest=()): + """A case whose workflow spells a key or a `needs:` value the gate must refuse.""" + lines = text.split("\n") + at = [next(i for i, l in enumerate(lines, 1) if m in l) for m in markers] + return (name, _REQ, text, [_unread(n) for n in at] + list(rest)) + + # (name, suites to require, workflow text, the complaints it must draw). Every # case asks the REAL checkers.sh table, where `kb`'s row holds pr-watch. SUITE_SELF_TEST = [ @@ -332,6 +380,75 @@ SUITE_SELF_TEST = [ ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), " "so it does not count"] + _NO_PW), + ("the 1be2 continuation exactly one column past the key does not count (w0j4)", _REQ, + _wf(_BUILD, _job("kb", _KB_RUN + " || true\n")), + ["job `kb`'s `checkers.sh --ci` step's run: continues onto another line, " + "so it does not count"] + _NO_PW), + + ("control: a padded flow list with a comment is still read (w0j4)", _REQ, + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: [ gate ] # c\n")), + _NEEDS_GATE), + + ("control: a plain scalar needs: is still read (w0j4)", _REQ, + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n")), + _NEEDS_GATE), + + _unreadable("a quoted name in a flow needs: is refused (w0j4)", + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=' needs: ["gate"]\n')), + 'needs: ["gate"]'), + + _unreadable("a quoted scalar needs: is refused (w0j4)", + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: 'gate'\n")), + "needs: 'gate'"), + + _unreadable("a quoted item in a block needs: is refused (w0j4)", + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=' needs:\n - "gate"\n')), + '- "gate"'), + + _unreadable("an anchored flow needs: is refused (w0j4)", + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: &d [gate]\n")), + "needs: &d"), + + _unreadable("an anchored block needs: is refused (w0j4)", + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: &d\n - gate\n")), + "needs: &d"), + + _unreadable("a flow needs: over two lines is refused (w0j4)", + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: [build,\n gate]\n")), + "needs: [build,"), + + _unreadable("a quoted job key is refused (w0j4)", + _wf(_BUILD, _job("kb", _KB_RUN, head=' "if": false\n')), + '"if"'), + + _unreadable("a quoted step key is refused (w0j4)", + _wf(_BUILD, _job("kb", " - \"if\": false\n" + " run: ./scripts/checkers.sh --ci kb\n")), + '"if"'), + + _unreadable("a single-quoted continue-on-error is refused (w0j4)", + _wf(_BUILD, _job("kb", _KB_RUN, head=" 'continue-on-error': true\n")), + "'continue-on-error'"), + + _unreadable("a key with a space before its colon is refused (w0j4)", + _wf(_BUILD, _job("kb", _KB_RUN, head=" if : false\n")), + "if : false"), + + _unreadable("a quoted job name is refused (w0j4)", + _wf(_BUILD, ' "gate":\n if: false\n steps:\n - run: echo\n', + _job("kb", _KB_RUN)), + '"gate"', + rest=["job `build`'s `checkers.sh --ci` step is conditional (job if), " + "so it does not count", _unrun("assemble-stamp")]), + + _unreadable("a quoted top-level key is refused (w0j4)", + '"defaults":\n run:\n shell: bash\n' + _wf(_BUILD, _job("kb", _KB_RUN)), + '"defaults"'), + + _unreadable("an explicit ? key is refused (w0j4)", + _wf(_BUILD, _job("kb", _KB_RUN, head=" ? if\n : false\n")), + "? if", ": false"), + ("the same, two needs: away", _REQ, _wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n", " mid:\n needs: gate\n steps:\n - run: echo\n", From 86b3d72c707c957f86c52584559de9a9d71a997b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:02:29 -0700 Subject: [PATCH 050/114] Guard App::restore's layout rebuild with a host-level test improvise-8wy9: deleting self.rebuild_layout() from App::restore kept the workspace green. A refused frame that removes an empty row is rolled back, and the next frame's write to that row must resolve against the restored grid; without the rebuild it is refused RecordGone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/command.md | 31 ++++++++++++ crates/improvise-command/src/ui/host.rs | 65 +++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 5960c542..3383baaf 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6208,3 +6208,34 @@ case fails. Second, the ticket's suggested fix, which drops the asker's reset and the client never heard. That rival also fails `a_geometry_command_relays_the_cursor_reset_to_the_asker_too` and the ws-server's `the_other_document_would_also_have_a_cursor_to_invalidate`. + +### a_frame_after_a_rolled_back_reshape_resolves_against_the_restored_grid + +improvise-8wy9, found in the adversarial review of PR #254. `App::restore` ends +with `self.rebuild_layout()`, because `app.layout` is derived state and not part +of `AppSnapshot`. Deleting that line kept the whole workspace green: +`snapshot_restore`'s tests compare only the snapshot's own fields, and nothing +drove a frame after a rollback. Yet `Host::undo_frame` calls `restore` and does +not rebuild. `App::resolve_aggregate`, `record_in_layout` and `unwritable` +all read `self.layout`, so the next frame would resolve its surrogates against +the grid of the frame that was rolled back. + +The test drives the host's own path. The fixture stores **no** cells, so South is +a record known only by its position in the layout. A frame +`[RemoveItem(South), SetCell(South)]` reshapes the grid, is refused +`ChainBroken`, and is rolled back. The test then asserts two things: + +- the host's layout has two rows again (the direct check); +- a next frame writing South's empty cell is accepted and stores 7. This is the + consequence the bead names. + +**Rival** (the bead's: delete `self.rebuild_layout();` from `App::restore`). Run +across the whole workspace, it fails **only** this test, at the row count (1 +against 2). With the row-count assertion removed, the behavioural half fails +alone: the write to South is refused `RecordGone`, resolved against the grid in +which South had been removed. So each half discriminates on its own, and the +behavioural one is the reason the rebuild exists. + +Placed in `ui/host.rs` rather than in `ui/app.rs`'s `snapshot_restore` module +(which PR #254 adds), both to avoid that conflict and because the consequence is +the host's. diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 8245456a..d287cffb 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -968,6 +968,71 @@ mod tests { ); } + /// After a refused frame reshaped the grid, the NEXT frame resolves against the + /// restored layout (improvise-8wy9). Evidence: `context/test-evidence/command.md`. + #[test] + fn a_frame_after_a_rolled_back_reshape_resolves_against_the_restored_grid() { + use improvise_protocol::ModelCommand; + + let mut host = host(); + let (asker, _) = host.connect(10, 10); + assert_eq!(host.app().layout.row_count(), 2, "setup: North and South"); + let basis = frame_basis(&host, &["South"]); + let refused = host.handle( + asker, + ClientFrame::Commands(vec![ + Command::Model { + basis: basis.clone(), + cmd: ModelCommand::RemoveItem { + category: "Region".into(), + item: "South".into(), + }, + }, + Command::Model { + basis, + cmd: ModelCommand::SetCell { + cell: addressed("South"), + value: CellValue::Number(5.0), + }, + }, + ]), + ); + assert!( + refused + .iter() + .any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::ChainBroken(_)))), + "setup: the frame that removed South must be refused and rolled back: {refused:?}" + ); + assert_eq!( + host.app().layout.row_count(), + 2, + "the restored document has South again, so its grid must too" + ); + + // South stores nothing: it is known only by its position in the layout. + let answer = host.model( + asker, + ModelCommand::SetCell { + cell: addressed("South"), + value: CellValue::Number(7.0), + }, + ); + assert!( + !answer + .iter() + .any(|a| matches!(a.msg, ServerFrame::Rejected(_))), + "a write to South was refused against the rolled-back grid: {answer:?}" + ); + assert_eq!( + host.app() + .model_state + .workbook + .model + .get_cell(&cell("South")), + Some(&CellValue::Number(7.0)) + ); + } + /// The rollback reaches the MIRRORS, not only the document — the cost the /// ticket called its least obvious. See `context/test-evidence/command.md`. #[test] From 4ff79ff21ef9ea088a8bf6e4904a5b3677696739 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:04:25 -0700 Subject: [PATCH 051/114] WIP red: a rename retry after a crossing LayoutMoved retargets (improvise-9ww8) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../tests/browser_round_trip.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 7d31d179..60e7e44a 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1484,3 +1484,92 @@ fn a_rename_onto_a_record_this_client_has_seen_still_overwrites_it() { ); assert_eq!(region(&host, "North"), None); } + +/// Another session's frame, applied, with what it owes `a` HELD rather than +/// delivered: `a` has not heard it yet (improvise-9ww8). +fn held_from_another_client(host: &mut Host, a_id: Sid, cmd: ModelCommandArg) -> Vec { + let (b_id, _) = host.connect(10, 10); + let frame = improvise_command::ui::reduce::caught_up(host.app(), cmd); + let relayed = host.handle(b_id, ClientFrame::command(frame)); + assert!( + relayed + .iter() + .any(|f| f.to == b_id && matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + "the other client's write must land: {relayed:?}" + ); + relayed.into_iter().filter(|f| f.to == a_id).collect() +} + +/// A's rename crosses B's rename of North in flight: A composes Enter before +/// hearing B, then hears B's relays and its own refusal, in that order. +fn crossing_rename(row: usize) -> Host { + use improvise_protocol::{CellId, ModelCommand, Rejection, ServerFrame}; + let mut host = three_records_host(); + let (a_id, mut a, coordinate) = records_client_at_coordinate(&mut host); + let Some(CellId::Coordinate { record: north, .. }) = + a.state().grid.cell(0, coordinate).unwrap().id.clone() + else { + panic!("setup: North's coordinate id") + }; + for _ in 0..row { + press(&mut host, a_id, &mut a, "j"); + } + press(&mut host, a_id, &mut a, "i"); + retype(&mut host, a_id, &mut a, "Zed"); + + let held = held_from_another_client( + &mut host, + a_id, + ModelCommand::RenameRecordCoordinate { + record: north, + category: "Region".into(), + item: "Yak".into(), + }, + ); + let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes"); + let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap()); + deliver(&mut a, &held); + deliver(&mut a, &answer); + assert!( + answer + .iter() + .any(|f| matches!(f.msg, ServerFrame::Rejected(Rejection::LayoutMoved))), + "setup: the crossing is refused on the layout tier, before any record: {answer:?}" + ); + assert!( + a.state().view.mode.is_editing(), + "setup: the editor is back" + ); + + press(&mut host, a_id, &mut a, "Enter"); + host +} + +/// improvise-9ww8, the source gone: B renamed the very record A opened on. +/// Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_retry_after_a_crossing_rename_of_its_record_renames_no_other_record() { + let host = crossing_rename(0); + assert_eq!( + region(&host, "Zed"), + None, + "the retry renamed a record never opened" + ); + assert_eq!(region(&host, "Yak"), Some(CellValue::Number(1.0))); + assert_eq!(region(&host, "South"), Some(CellValue::Number(2.0))); + assert_eq!(region(&host, "West"), Some(CellValue::Number(3.0))); +} + +/// improvise-9ww8, the source alive: B's rename only moves A's record off the +/// row under the cursor. Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_retry_after_a_crossing_reorder_renames_no_other_record() { + let host = crossing_rename(1); + assert_eq!( + region(&host, "West"), + Some(CellValue::Number(3.0)), + "the retry renamed West, the record that moved under the cursor" + ); + assert_ne!(region(&host, "Zed"), Some(CellValue::Number(3.0))); + assert_eq!(region(&host, "Yak"), Some(CellValue::Number(1.0))); +} From 59072d810580176cbaab53bbea8ec691a9a94098 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:04:39 -0700 Subject: [PATCH 052/114] improvise-gk51: red test, a projected Record the host then refuses Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-command/src/ui/project.rs | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index c40392e8..bf4fa3c6 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -5284,6 +5284,50 @@ mod cell_identity { } } + /// improvise-gk51: a cell projected as a `Record` is one a wire write may land + /// on. Evidence: `context/test-evidence/command.md`. + #[test] + fn every_projected_record_is_writable() { + let mut app = records_app(); + // A stored record whose Region names no item, as `set-cell` can make. + app.model_state.workbook.model.set_cell( + CellKey::new(vec![ + ("Region".to_string(), "Mars".to_string()), + ("_Measure".to_string(), "Revenue".to_string()), + ]), + CellValue::Number(5.0), + ); + app.rebuild_layout(); + let grid = grid_of(&app, WHOLE); + let cells: Vec<(usize, usize, CellDisplay)> = (0..app.layout.row_count()) + .flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c))) + .filter_map(|(r, c)| grid.cell(r, c).map(|cell| (r, c, cell.clone()))) + .collect(); + assert!( + cells.iter().any(|(_, _, cell)| cell.text == "Mars"), + "setup: the non-item record is drawn" + ); + let records: Vec<(usize, usize, RecordCell)> = cells + .iter() + .filter_map(|(r, c, cell)| match &cell.id { + Some(CellId::Record(record)) => Some((*r, *c, record.clone())), + _ => None, + }) + .collect(); + assert!( + records.len() >= 4, + "every East and North measure cell is still a Record: {records:?}" + ); + let refused: Vec<(usize, usize, String)> = records + .into_iter() + .filter_map(|(r, c, record)| app.unwritable(&record).map(|why| (r, c, why))) + .collect(); + assert!( + refused.is_empty(), + "projected as Record, then refused: {refused:?}" + ); + } + /// The version counter is keyed by the surrogate the client holds, so one /// identity answers ADR 002's question and ADR 001's. #[test] From 97a9379f7967b82550c52adf2ae6f61a2b264969 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:06:23 -0700 Subject: [PATCH 053/114] bd-audit-ids: deleted means a deletion git will record improvise-cota, found in the delta review of #244. The tdfd fix skipped an absent tracked path whenever `ls-files -d` listed it. `ls-files -d` also lists an absent --assume-unchanged path. `status` hides that path, and `commit -a` never records the deletion, so its citations stayed in HEAD unread and the audit exited 0. The deletion oracle is now `diff --name-only --diff-filter=D`. It honours both index flags and still lists a plain unstaged delete. The new case in bd-audit-ids-test.sh was red at 33d563a9. A rival that treats every absent path as unread fails only the deliberate deleted-file control. Stacked on #244 (tooling/lqs0-audit-exits). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tooling.md | 29 +++++++++++++++++++++++++++++ scripts/bd-audit-ids | 10 +++++----- scripts/bd-audit-ids-test.sh | 12 ++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index f496a3cd..d522ba5c 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -919,3 +919,32 @@ scanned" alone, so the deleted-file case is what stops the fix from over-refusing. The rival ran from an ignored copy inside the worktree. A copy under `/tmp` fails "the real tree does not pass against a silent bd" for a reason unrelated to the rival: there is no repository there. + +### Deleted means git will record it — `scripts/bd-audit-ids-test.sh` (improvise-cota) + +**What broke.** The tdfd fix above asked `git ls-files -d` whether an absent path +was deleted. `ls-files -d` also lists an absent path marked +`--assume-unchanged`. But `git status` shows nothing for that path, and +`commit -a` would never record the deletion, so its citations stay in `HEAD`. +The audit skipped the file and exited 0. This is the tdfd shape again: absent, +not a deletion git will record, and never read. It was found in the delta +review of PR #244 at `33d563a9`. + +**Fix, the reviewer's oracle.** A path counts as deleted only when +`git diff --name-only --diff-filter=D` lists it. That compares the index +with the working tree and honours both index flags: it hides a +skip-worktree path and an assume-unchanged path alike, and lists a plain +unstaged delete. A staged `git rm` never reaches the question, because the path +has already left the index that `ls-files --cached` enumerates. + +**Red first.** "an assume-unchanged file missing from the tree is exit 4, and +named" exited 0 against `33d563a9`, reporting "all 1 cited ids resolve". + +**Rivals.** +- The `ls-files -d` oracle, which is the red state above. +- Every absent path reported as unread (`elif true`). It fails "a tracked file + deleted from the working tree is not scanned" and nothing else, so the + deliberate skip of a real deletion survives the stricter oracle. + +This rival was edited in place in the worktree script, not copied to `/tmp`, for +the reason the tdfd entry gives. diff --git a/scripts/bd-audit-ids b/scripts/bd-audit-ids index 6c91d4a0..c6561c04 100755 --- a/scripts/bd-audit-ids +++ b/scripts/bd-audit-ids @@ -13,8 +13,8 @@ exit 0 every cited id is a ticket bd lists exit 1 at least one cited id is not; each is printed exit 3 bd could not answer: missing, failing, or listing nothing exit 4 usage error, not inside a git repository, or a cited file - could not be read or is absent (only a file `git ls-files -d` - reports as deleted is skipped) + could not be read or is absent (only a file `git diff + --diff-filter=D` reports as deleted is skipped) The comparison is POSITIVE: a cited id passes only by appearing in what `bd list --all` returns. The recipe this replaced looked for bd's "not found" @@ -43,13 +43,13 @@ top=$(git rev-parse --show-toplevel 2>/dev/null) || { exit 4 } cd "$top" -# A deleted tracked file is skipped ON PURPOSE: its ids are leaving the tree. -# Only `ls-files -d` says deleted; any other absent path is exit 4 (lqs0, tdfd). +# A deleted tracked file is skipped ON PURPOSE: its ids are leaving the tree. Only +# `diff --diff-filter=D` says deleted; any other absent path is exit 4 (tdfd, cota). : > "$work/absent" git ls-files -z --cached --others --exclude-standard -- "${ROOTS[@]}" | while IFS= read -r -d '' f; do if [ -e "$f" ] || [ -L "$f" ]; then printf '%s\0' "$f" - elif [ -z "$(git --literal-pathspecs ls-files -d -- "$f")" ]; then + elif [ -z "$(git --literal-pathspecs diff --name-only --diff-filter=D -- "$f")" ]; then printf '%s\n' "$f" >> "$work/absent" fi done > "$work/files" diff --git a/scripts/bd-audit-ids-test.sh b/scripts/bd-audit-ids-test.sh index 5a6275bd..45dfe996 100755 --- a/scripts/bd-audit-ids-test.sh +++ b/scripts/bd-audit-ids-test.sh @@ -132,6 +132,18 @@ git -C "$r2" update-index --no-skip-worktree context/b.md && ok "a skip-worktree file missing from the tree is exit 4, and named" \ || bad "a skip-worktree file missing from the tree is exit 4, and named" "exit $got: $(head -c 200 "$work/out")" +# ls-files -d DOES list an absent assume-unchanged path, yet status hides it and +# commit -a never records the deletion (improvise-cota). +git -C "$r2" update-index --assume-unchanged context/b.md +mv "$r2/context/b.md" "$work/b.md.away" +(cd "$r2" && "$AUDIT") > "$work/out" 2>&1 +got=$? +mv "$work/b.md.away" "$r2/context/b.md" +git -C "$r2" update-index --no-assume-unchanged context/b.md +[ "$got" = 4 ] && grep -q 'context/b.md' "$work/out" \ + && ok "an assume-unchanged file missing from the tree is exit 4, and named" \ + || bad "an assume-unchanged file missing from the tree is exit 4, and named" "exit $got: $(head -c 200 "$work/out")" + mkdir -p "$work/not-a-repo" (cd "$work/not-a-repo" && GIT_CEILING_DIRECTORIES="$work" "$AUDIT") > "$work/out" 2>&1 got=$? From de4a7d786279c58224b38284df521935cdc6b3eb Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:07:53 -0700 Subject: [PATCH 054/114] =?UTF-8?q?protocol.md=20=C2=A73:=20name=20the=20t?= =?UTF-8?q?ype-ahead=20divergence=20that=20remains=20after=20mdfc=20(impro?= =?UTF-8?q?vise-n4i9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured in Chromium over the worker: with keys pressed back to back, `i 9 Tab Tab 5 Enter` stores 5 or 5095 depending on whether `5` beats the answer; with a pause, 5095 in 5 of 5 runs. Never the stale 505. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/protocol.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/context/code/protocol.md b/context/code/protocol.md index b0c34642..0a9c6920 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -2064,7 +2064,11 @@ the projection it owes this client anyway. Two things are accepted costs: seed then does not overwrite it. What the user sees is then what gets written. That is never another record's number only because of the mdfc rule above: before it, an untouched commit behind an unanswered write opened - on the stale cache, and typing then extended the wrong number. + on the stale cache, and typing then extended the wrong number. What is left + is a divergence from the TUI rather than a wrong record. A key that beats the + answer replaces the value the TUI would have extended: back to back, + `i 9 Tab Tab 5 Enter` stores 5 or 5095 in Chromium over the worker + (improvise-n4i9). The TUI is unchanged: its `RebuildLayout` in front of `EnterEditAtCursor` already reads the renumbered grid (gotcha 23). This also took From 2ea2407456f9c2c5d2c857eb9c3393458bd06fbe Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:08:02 -0700 Subject: [PATCH 055/114] Pin the column group toggle to the cursor; guard run identity in e265's enumeration (improvise-uv9e, improvise-6u6s) uv9e: ToggleGroupAtCursor { is_row: false } (gz) had no command-level test, and a version reading col_group_for(0) passed the workspace. A new test seats the cursor at (0, 2) with Month on the columns and requires Q2. Reading column 0, or reading the row half of the cursor, both fail it. The row half was already closed: the grid tests sit on row 2, and injecting row_group_for(0) fails five of them. The browser's z is still Upstream with no resolver arm (improvise-uln5), so it has no cursor reading to test. 6u6s: the e265 enumeration could not see run identity. A pruner keying runs by (level, category, group name) instead of header position passed. The enumeration now: - compares every level's run name and whether each survivor shares a run with the previous one; - adds three fixtures: a non-contiguous group, a level with no groups, and two adjacent outer groups; - builds each layout once per collapse subset and drives prune_axis_items with a keep predicate per row set: 4,992 cases. The name-keyed rival now fails at fixture 2, mask 0b101. open_runs is the one reader of the markers. improvise-core's lib went from 0.86-0.92s to 0.17-0.23s on an Apple M4 Max under load average 39-77. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/core.md | 8 + context/test-evidence/command.md | 23 ++ context/test-evidence/core.md | 85 ++++-- .../improvise-command/src/command/cmd/grid.rs | 35 +++ crates/improvise-core/src/view/layout.rs | 289 +++++++++++------- 5 files changed, 307 insertions(+), 133 deletions(-) diff --git a/context/code/core.md b/context/code/core.md index ac81e836..322d4dc2 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -296,6 +296,14 @@ closes the deeper runs innermost-out before it touches a level, as equivalent but not equal (`End0` where the input has `End1, End0`), and the enumeration test asserts equality whenever every row survives. +**How it is tested** (improvise-6u6s). An enumeration compares each +surviving row's whole open-run stack, and whether it shares a run with the +previous survivor, pruned against unpruned. It runs over five fixtures, +including a non-contiguous group, a level with no groups, and two adjacent +outer groups. Without the non-contiguous fixture, a pruner that keyed runs by +name instead of header position passed every test. `open_runs` is the one +reading of the markers: `innermost_group` and the tests both use it. + A header's level also names its category (`cats[level]`) on the wire, where `FrameGroup` carries `level` and not the category (improvise-7xyx removed that field): see `context/code/protocol.md` §3a. diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index afac0070..d9c1cea6 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6237,3 +6237,26 @@ folds `Q1`, the innermost group, and not `Upper`. `z` on `North/Mar` folds `Upper`, and not `Q1`. The flat-membership rival fails it: `z` on `North/Mar` found no group and did nothing. It is not a lockstep test, because a browser's `z` is still a no-op (improvise-uln5). + +### a_column_toggle_names_the_group_under_the_cursors_column + +improvise-uv9e. `ToggleGroupAtCursor { is_row: false }` +(`toggle-col-group-under-cursor`, bound to `gz`) had no command-level test. A +version that read `col_group_for(0)` passed the whole workspace: 1,946 passed, +0 failed, with that rival injected. The fixture puts `Month`, with `Q1 = {Jan, +Feb}` and `Q2 = {Apr, May}`, on the columns and seats the cursor at `(0, 2)`, +on `Apr`. That is off the origin on the axis the toggle reads, and on the origin +of the other. The command must name `Q2`, and applying it must fold `Q2` and +leave `Q1`. + +Rivals, each injected into `ToggleGroupAtCursor::execute` and restored, over +the whole workspace: +- `col_group_for(0)`: only this test fails; +- `col_group_for(ctx.selected.0)`, the row half of the cursor: only this test + fails. + +The **row** half of improvise-uv9e was already closed when this landed. The +ticket was written at `a4910eb4`, when every row test sat at `(0, 0)`. Since +then, the grid tests seat the cursor on `Apr` (row 2). Injecting +`row_group_for(0)` fails five of them, plus `z_on_a_row_past_an_inner_run_folds_the_outer_group` +from improvise-e265. diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index a1e02841..a291fd37 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1512,41 +1512,68 @@ are each compared with the same document with Month on `Axis::None`. At cannot pass. Rival: `read_coordinate` ignoring the reach (`if false && …`). This test fails. -### pruning_does_not_move_a_surviving_row_to_another_group +### pruning_keeps_every_surviving_rows_runs_at_every_level -improvise-e265. On a two-level grouped axis, `prune_empty` used to change a -surviving row's group. Pruning the inner header dropped its `GroupEnd` too, and -under the flat reading the ungrouped inner item then joined the outer group. -The test enumerates every funded-cell mask and every collapse subset of two -fixtures: -- `Region` over `Month` (two outer items, four inner, three groups); -- `Region` over `Month` over `Kind` (three levels). +improvise-e265, strengthened by improvise-6u6s. On a two-level grouped axis, +`prune_empty` used to change a surviving row's group. Pruning the inner header +dropped its `GroupEnd` too, and under the flat reading the ungrouped inner item +then joined the outer group. -That is about 4,000 cases in both prune states, built once in a `OnceLock` for -both enumerating tests. For every surviving row it asserts that the innermost -group (`innermost_group`, which `row_group_for` is) is the same pruned as -unpruned. When every row survives -it also asserts the pruned `row_items` equal the unpruned ones, marker for -marker. +**What it asserts.** For every surviving row and at every level: +- the run the row is in names the same `(category, group)` pruned as unpruned + (the whole open-run stack, from `open_runs`, not only the innermost); +- whether it is in the **same run** as the previous survivor is the same + answer pruned as unpruned, where "the same run" means the same header entry. -Failed at base 202e7eac on `mask 0b100` (only `North/Mar` funded): pruned said -`Upper`, unpruned said `None`. Rivals, each injected and restored: -- a flat `innermost_group` (every marker read at level 0) fails it; -- a pruner that owes no `GroupEnd` (u6gc's shape: a run whose closing header - was pruned is left open) fails it; -- a pruner that keeps headers as they arrive fails it. +When every row survives, the pruned entries must equal the unpruned ones, +marker for marker. + +**The fixtures** are five, each under every collapse subset of its groups: +- `Region` over `Month`; +- three levels, `Region` over `Month` over `Kind`; +- `Month = [Jan in Q1, Mar, Feb in Q1]`, a non-contiguous group, so one parent + holds two `Q1` runs (improvise-h0iv); +- a middle level with no groups at all (`Kind` between `Region` and `Month`); +- two outer groups side by side (`North` in `Upper`, `South` in `Lower`). + +**How it is built** (improvise-6u6s). Each fixture's layout is built once per +collapse subset, with no cells, so every row is present. `prune_axis_items` is +then called directly with a keep predicate for every non-empty set of rows. +That makes 4,992 cases. The first version built two `Workbook`s per funded-cell +mask, about 4,000 cases, and put `improvise-core`'s lib at 0.86–0.92s on an +Apple M4 Max (16 cores) under load average 39–77, against about 0.16s without +it. The reviewer measured 0.94–2.46s under heavier load, once past the 2s +tripwire. Now the lib runs in 0.17–0.23s on the same machine, against 0.16–0.18s +with the module skipped. `prune_empty` is still covered, because it calls the +same pruner, and `prune_empty_keeps_a_row_past_an_inner_run_in_the_outer_group` +pins that wiring. + +**Red and rivals.** The e265 form failed at base 202e7eac, on `mask 0b100` with +only `North/Mar` funded: pruned said `Upper`, unpruned said `None`. Rivals, each +injected and restored: +- a flat reader (every marker read at level 0) fails it; +- a pruner that owes no `GroupEnd` fails it (u6gc's shape: a run whose closing + header was pruned is left open); +- a pruner that keeps headers as they arrive fails it; +- **runs keyed by `(level, category, group name)` instead of header position** + fails it, at `fixture 2, mask 0b101, collapsed []: run identity at level 1, + row 1`. Pruning `Mar` must leave North's two `Q1` runs as two, and the rival + merges them. + +That last rival passed every test in #250 (improvise-6u6s). Those fixtures had +no non-contiguous group and compared only the innermost group, so they could not +see a merged run. The equality half was added after the first version of `remark` emitted `End0` where the input had `End1, End0`. That output was equivalent but not equal, and -the new assertion caught it. +the assertion caught it. ### a_pruned_axis_draws_no_header_over_nothing_and_ends_on_no_marker -improvise-e265's other property, over the same enumeration. Every kept header +improvise-e265's other property, over the same 4,992 cases. Every kept header must be followed, past any deeper headers, by a data item, and the axis must -not end on a `GroupEnd`. Both failed at base 202e7eac: `mask 0b1` left a -trailing `GroupEnd`, and the ticket counted the header-over-nothing shape on -main before #200. +not end on a `GroupEnd`. At base 202e7eac, `mask 0b1` left a trailing +`GroupEnd`. The ticket counted the header-over-nothing shape on main before #200. Rivals: - a pruner that keeps headers as they arrive fails the first half; @@ -1558,6 +1585,14 @@ emitted only before an item. So the obvious rival, deleting a trailing-marker strip, has nothing to delete. An earlier draft had that strip, and it was dead code. +### prune_empty_keeps_a_row_past_an_inner_run_in_the_outer_group + +The enumeration drives `prune_axis_items` directly, so this test pins that +`prune_empty` reaches it. With only `North/Mar` funded, the pruned grid has one +row, and it is in `Upper`. It is a wiring test, not a discriminating one: #200's +pruner also answered `Upper` here. What it catches is a `prune_empty` that +stops pruning (the row count) or a reader that loses the outer run. + ### a_row_after_an_inner_groups_end_is_still_in_the_outer_group The user's ruling on improvise-e265, at the layout: membership is per level, diff --git a/crates/improvise-command/src/command/cmd/grid.rs b/crates/improvise-command/src/command/cmd/grid.rs index 24a2fb65..3b142c6b 100644 --- a/crates/improvise-command/src/command/cmd/grid.rs +++ b/crates/improvise-command/src/command/cmd/grid.rs @@ -177,6 +177,41 @@ mod tests { ); } + /// The column twin had no command-level test: an `is_row: false` toggle that + /// read column 0 passed the workspace (improvise-uv9e). Evidence file. + #[test] + fn a_column_toggle_names_the_group_under_the_cursors_column() { + use crate::command::key::{KeyCode, KeyModifiers}; + use crate::ui::app::App; + use crate::view::Axis; + + let mut wb = two_groups_on_the_row_axis(); + wb.active_view_mut().set_axis("Month", Axis::Column); + wb.active_view_mut().set_axis("Type", Axis::Row); + let mut app = App::new(wb, None); + app.rebuild_layout(); + // Row 0 and column 2: off the origin on the axis the toggle reads, and on + // the origin of the other, so reading the wrong half is seen too. + app.cursor_mut().selected = (0, 2); + assert_eq!(app.layout.col_label(2), "Apr", "the cursor sits on Apr"); + let effects = { + let ctx = app.cmd_context(KeyCode::Char('z'), KeyModifiers::NONE); + ToggleGroupAtCursor { is_row: false }.execute(&ctx) + }; + let dbg = effects_debug(&effects); + assert!( + dbg.contains(r#"category: "Month", group: "Q2""#), + "Apr is in Q2, got: {dbg}" + ); + app.apply_effects(effects); + let view = app.model_state.workbook.active_view(); + assert!(view.is_group_collapsed("Month", "Q2")); + assert!( + !view.is_group_collapsed("Month", "Q1"), + "the neighbour stays" + ); + } + /// Evidence: `context/test-evidence/command.md`. #[test] fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() { diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index b3d6d1a5..92034329 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -348,34 +348,38 @@ fn remark(input: &OpenRuns, output: &mut Vec>, kept: &mut Vec Option<(String, String)> { - let mut open: OpenRuns = Vec::new(); - let mut data_idx = 0usize; +/// Each data item's open runs, by level, as the position of the header that +/// opened each: the one reading of the markers (improvise-e265). +fn open_runs(items: &[AxisEntry]) -> Vec>> { + let mut open: Vec> = Vec::new(); + let mut out = Vec::new(); for (at, entry) in items.iter().enumerate() { match entry { AxisEntry::GroupHeader { level, .. } => { - open_at(&mut open, *level, Some((at, entry.clone()))) + open.truncate(*level); + open.resize(*level, None); + open.push(Some(at)); } - AxisEntry::GroupEnd { level } => open_at(&mut open, *level, None), - AxisEntry::DataItem { .. } if data_idx == idx => { - return open.into_iter().rev().find_map(|run| match run { - Some(( - _, - AxisEntry::GroupHeader { - cat_name, - group_name, - .. - }, - )) => Some((cat_name, group_name)), - _ => None, - }); - } - AxisEntry::DataItem { .. } => data_idx += 1, + AxisEntry::GroupEnd { level } => open.truncate(*level), + AxisEntry::DataItem { .. } => out.push(open.clone()), } } - None + out +} + +/// The innermost run open at the data item `idx` — the group nearest it, which +/// is what `z` toggles (improvise-e265's ruling). +fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> { + let runs = open_runs(items); + let at = runs.get(idx)?.iter().rev().find_map(|run| *run)?; + match &items[at] { + AxisEntry::GroupHeader { + cat_name, + group_name, + .. + } => Some((cat_name.clone(), group_name.clone())), + AxisEntry::GroupEnd { .. } | AxisEntry::DataItem { .. } => None, + } } /// One category constraining which records are visible: what it is fixed to, @@ -2059,16 +2063,15 @@ mod tests { } /// Group membership on a multi-level axis is per level, and pruning cannot move -/// it (improvise-e265). `context/test-evidence/core.md`. +/// it (improvise-e265, improvise-6u6s). `context/test-evidence/core.md`. #[cfg(test)] mod group_membership { - use super::{AxisEntry, GridLayout, data_items, innermost_group}; - use crate::model::cell::{CellKey, CellValue}; + use super::{AxisEntry, GridLayout, data_items, open_runs, prune_axis_items}; use crate::view::Axis; use crate::workbook::Workbook; - use std::sync::OnceLock; type Level<'a> = (&'a str, &'a [(&'a str, Option<&'a str>)]); + type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)]); const TWO: &[Level] = &[ ("Region", &[("North", Some("Upper")), ("South", None)]), @@ -2089,24 +2092,55 @@ mod group_membership { ("Kind", &[("Food", Some("Goods")), ("Tools", None)]), ]; - /// Every row-coordinate tuple of `levels`, outermost first. - fn tuples(levels: &[Level]) -> Vec> { - levels.iter().fold(vec![vec![]], |acc, (_, items)| { - acc.iter() - .flat_map(|t| { - items.iter().map(move |(item, _)| { - let mut t = t.clone(); - t.push(item.to_string()); - t - }) - }) - .collect() - }) - } + /// `Q1` is not contiguous, so one parent holds two `Q1` runs (h0iv). + const NONCONTIGUOUS: &[Level] = &[ + ("Region", &[("North", Some("Upper")), ("South", None)]), + ( + "Month", + &[("Jan", Some("Q1")), ("Mar", None), ("Feb", Some("Q1"))], + ), + ]; - /// `levels` on Row, one column, the tuples `mask` sets funded, the groups - /// `collapsed` names folded. - fn book(levels: &[Level], mask: u32, collapsed: &[(&str, &str)], prune: bool) -> Workbook { + /// A middle level with no groups at all. + const HOLE: &[Level] = &[ + ("Region", &[("North", Some("Upper")), ("South", None)]), + ("Kind", &[("Food", None), ("Tools", None)]), + ("Month", &[("Jan", Some("Q1")), ("Mar", None)]), + ]; + + /// Two outer groups side by side, with no ungrouped item between them. + const ADJACENT: &[Level] = &[ + ( + "Region", + &[ + ("North", Some("Upper")), + ("South", Some("Lower")), + ("West", None), + ], + ), + ("Month", &[("Jan", Some("Q1")), ("Mar", None)]), + ]; + + const FIXTURES: &[Fixture] = &[ + ( + TWO, + &[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")], + ), + ( + THREE, + &[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")], + ), + (NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")]), + (HOLE, &[("Region", "Upper"), ("Month", "Q1")]), + ( + ADJACENT, + &[("Region", "Upper"), ("Region", "Lower"), ("Month", "Q1")], + ), + ]; + + /// `levels` on Row, one column, the groups `collapsed` names folded. No cell + /// is set: the unpruned layout has every row, and pruning is driven directly. + fn book(levels: &[Level], collapsed: &[(&str, &str)]) -> Workbook { let mut wb = Workbook::new("T"); for (cat, items) in levels { wb.add_category(*cat).unwrap(); @@ -2121,22 +2155,9 @@ mod group_membership { wb.add_category("Col").unwrap(); wb.add_item("Col", "X").unwrap(); wb.active_view_mut().set_axis("Col", Axis::Column); - for (i, tuple) in tuples(levels).iter().enumerate() { - if mask & (1 << i) != 0 { - let mut coords: Vec<(String, String)> = levels - .iter() - .zip(tuple) - .map(|((cat, _), item)| (cat.to_string(), item.clone())) - .collect(); - coords.push(("Col".into(), "X".into())); - wb.model - .set_cell(CellKey::new(coords), CellValue::Number(1.0)); - } - } for (cat, group) in collapsed { wb.active_view_mut().toggle_group_collapse(cat, group); } - wb.active_view_mut().prune_empty = prune; wb } @@ -2144,70 +2165,106 @@ mod group_membership { GridLayout::with_frozen_records(&wb.model, wb.active_view(), None) } - /// One enumerated case: its name, then the unpruned and pruned row entries. - type Case = (String, Vec, Vec); + /// One case: its name, the kept-row mask, and the unpruned and pruned rows. + struct Case { + name: String, + mask: u32, + full: Vec, + pruned: Vec, + } - /// Every (mask, collapse subset) of `levels`, unpruned and pruned. - fn every_case(levels: &[Level], groups: &[(&str, &str)]) -> Vec { - let cells = tuples(levels).len(); + /// Every collapse subset of every fixture, one layout each, pruned by every + /// non-empty set of kept rows. The layout is the cost, so it is built once. + fn cases() -> Vec { let mut out = Vec::new(); - for folds in 0..(1u32 << groups.len()) { - let collapsed: Vec<(&str, &str)> = (0..groups.len()) - .filter(|g| folds & (1 << g) != 0) - .map(|g| groups[g]) - .collect(); - for mask in 1..(1u32 << cells) { - let case = format!("mask {mask:#b}, collapsed {collapsed:?}"); - let mut wb = book(levels, mask, &collapsed, false); - let full = layout(&wb).row_items; - wb.active_view_mut().prune_empty = true; - out.push((case, full, layout(&wb).row_items)); + for (fixture, (levels, groups)) in FIXTURES.iter().enumerate() { + for folds in 0..(1u32 << groups.len()) { + let collapsed: Vec<(&str, &str)> = (0..groups.len()) + .filter(|g| folds & (1 << g) != 0) + .map(|g| groups[g]) + .collect(); + let full = layout(&book(levels, &collapsed)).row_items; + let rows = data_items(&full).count(); + for mask in 1..(1u32 << rows) { + let mut pruned = full.clone(); + prune_axis_items(&mut pruned, |i| mask & (1 << i) != 0); + out.push(Case { + name: format!("fixture {fixture}, mask {mask:#b}, collapsed {collapsed:?}"), + mask, + full: full.clone(), + pruned, + }); + } } } out } - /// Built once for both enumerating tests: the enumeration is the cost. - fn cases() -> &'static [Case] { - static CASES: OnceLock> = OnceLock::new(); - CASES.get_or_init(|| { - let mut all = every_case( - TWO, - &[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")], - ); - all.extend(every_case( - THREE, - &[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")], - )); - all - }) + /// The `(category, group)` a run's header names. + fn named(items: &[AxisEntry], run: Option) -> Option<(&str, &str)> { + match &items[run?] { + AxisEntry::GroupHeader { + cat_name, + group_name, + .. + } => Some((cat_name, group_name)), + AxisEntry::GroupEnd { .. } | AxisEntry::DataItem { .. } => None, + } + } + + fn at(runs: &[Option], level: usize) -> Option { + runs.get(level).copied().flatten() } #[test] - fn pruning_does_not_move_a_surviving_row_to_another_group() { - for (case, full, pruned) in cases() { - let full_rows: Vec<&Vec> = data_items(full).collect(); - let pruned_rows: Vec<&Vec> = data_items(pruned).collect(); - if pruned_rows.len() == full_rows.len() { - assert_eq!(pruned, full, "{case}: nothing pruned"); + fn pruning_keeps_every_surviving_rows_runs_at_every_level() { + for case in cases() { + let Case { + name, + mask, + full, + pruned, + } = &case; + let kept: Vec = (0..data_items(full).count()) + .filter(|i| mask & (1 << i) != 0) + .collect(); + let (full_runs, pruned_runs) = (open_runs(full), open_runs(pruned)); + assert_eq!(pruned_runs.len(), kept.len(), "{name}: the survivors"); + if kept.len() == full_runs.len() { + assert_eq!(pruned, full, "{name}: nothing pruned"); } - for (r, tuple) in pruned_rows.iter().enumerate() { - let u = full_rows - .iter() - .position(|t| t == tuple) - .expect("a pruned row is a row of the unpruned grid"); - assert_eq!( - innermost_group(pruned, r), - innermost_group(full, u), - "{case}: row {tuple:?}\n pruned {pruned:?}\n full {full:?}", - ); + let levels = full_runs.iter().map(Vec::len).max().unwrap_or(0); + for (r, &u) in kept.iter().enumerate() { + for level in 0..levels { + assert_eq!( + named(pruned, at(&pruned_runs[r], level)), + named(full, at(&full_runs[u], level)), + "{name}: row {r}, level {level}\n pruned {pruned:?}\n full {full:?}" + ); + if r == 0 { + continue; + } + let same = |runs: &[Vec>], a: usize, b: usize| { + at(&runs[a], level).is_some() && at(&runs[a], level) == at(&runs[b], level) + }; + assert_eq!( + same(&pruned_runs, r - 1, r), + same(&full_runs, kept[r - 1], u), + "{name}: run identity at level {level}, row {r}\n pruned {pruned:?}\n full {full:?}" + ); + } } } } #[test] fn a_pruned_axis_draws_no_header_over_nothing_and_ends_on_no_marker() { - for (case, _, items) in cases() { + for Case { + name, + pruned: items, + .. + } in cases() + { for (i, entry) in items.iter().enumerate() { if let AxisEntry::GroupHeader { .. } = entry { let next = items[i + 1..] @@ -2215,24 +2272,40 @@ mod group_membership { .find(|e| !matches!(e, AxisEntry::GroupHeader { .. })); assert!( matches!(next, Some(AxisEntry::DataItem { .. })), - "{case}: header {entry:?} has no member in {items:?}" + "{name}: header {entry:?} has no member in {items:?}" ); } } assert!( !matches!(items.last(), Some(AxisEntry::GroupEnd { .. })), - "{case}: trailing marker in {items:?}" + "{name}: trailing marker in {items:?}" ); } } + /// `prune_empty` reaches the same pruner from funded cells: the ticket's own + /// shape, only `North/Mar` funded, keeps `North/Mar` in `Upper`. + #[test] + fn prune_empty_keeps_a_row_past_an_inner_run_in_the_outer_group() { + let mut wb = book(TWO, &[]); + let cell = crate::model::cell::CellKey::new(vec![ + ("Region".into(), "North".into()), + ("Month".into(), "Mar".into()), + ("Col".into(), "X".into()), + ]); + wb.model + .set_cell(cell, crate::model::cell::CellValue::Number(1.0)); + wb.active_view_mut().prune_empty = true; + let l = layout(&wb); + assert_eq!(l.row_count(), 1); + assert_eq!(l.row_group_for(0), Some(("Region".into(), "Upper".into()))); + } + /// The user's ruling on improvise-e265: membership is per level, and `z` /// names the innermost open group — the one nearest the cursor. #[test] fn a_row_after_an_inner_groups_end_is_still_in_the_outer_group() { - // North/Jan (0) and North/Mar (2) funded; nothing is pruned or folded. - let wb = book(TWO, 0b101, &[], false); - let l = layout(&wb); + let l = layout(&book(TWO, &[])); let at = |tuple: [&str; 2]| { (0..l.row_count()) .find(|&r| l.row_tuple(r) == Some(&[tuple[0].into(), tuple[1].into()][..])) From 3d374d8b3e6ab120ac64a62b21fb6b7e6cc258ce Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:09:25 -0700 Subject: [PATCH 056/114] improvise-gk51 WIP: one predicate for the projection and the refusal; CellId::NonItem Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../improvise-command/src/command/cmd/core.rs | 4 +- crates/improvise-command/src/ui/app.rs | 7 ++-- crates/improvise-command/src/ui/project.rs | 38 +++++++++++++------ crates/improvise-core/src/model/types.rs | 9 +++++ crates/improvise-protocol/src/lib.rs | 3 +- crates/improvise-protocol/src/render.rs | 9 +++++ crates/improvise-protocol/src/resolve.rs | 19 +++++++--- crates/improvise-wasm-client/src/lib.rs | 3 +- crates/improvise-wasm-client/src/render.rs | 3 +- 9 files changed, 70 insertions(+), 25 deletions(-) diff --git a/crates/improvise-command/src/command/cmd/core.rs b/crates/improvise-command/src/command/cmd/core.rs index e3f9e876..c42a2453 100644 --- a/crates/improvise-command/src/command/cmd/core.rs +++ b/crates/improvise-command/src/command/cmd/core.rs @@ -11,7 +11,7 @@ use crate::ui::app::AppMode; use crate::ui::effect::{Effect, Panel}; use crate::view::{Axis, GridLayout, View}; use crate::workbook::Workbook; -use improvise_protocol::CellId; +use improvise_protocol::{CellId, non_item_refusal}; /// What a command says when the cursor sits on a collapsed group's row (§14). pub const FOLD_REFUSAL: &str = "Collapsed group — press z to expand it first"; @@ -102,6 +102,7 @@ impl<'a> CmdContext<'a> { match self.cursor_cell_id() { Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()), Some(CellId::Derived { target }) => Err(derived_refusal(&target)), + Some(CellId::NonItem { category, item }) => Err(non_item_refusal(&category, &item)), Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => { self.cell_key().ok_or_else(|| NO_CELL.to_string()) } @@ -116,6 +117,7 @@ impl<'a> CmdContext<'a> { Some(CellId::Aggregate(_)) => Err(self.aggregate_refusal()), Some(CellId::Derived { target }) => Err(derived_refusal(&target)), Some(CellId::Create(_) | CellId::Coordinate) => Err(NO_VALUE_REFUSAL.to_string()), + Some(CellId::NonItem { category, item }) => Err(non_item_refusal(&category, &item)), None => Err(match self.cell_key() { Some(_) => NO_VALUE_REFUSAL, None => NO_CELL, diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index 66472d45..576187e3 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -595,10 +595,9 @@ impl App { /// (improvise-b114, improvise-nacd). See `context/code/command.md`. fn non_item_refusal(&self, key: &CellKey) -> Option { let model = &self.model_state.workbook.model; - key.0.iter().find_map(|(cat, name)| { - let item = model.effective_item_names(cat).contains(name); - (!item).then(|| format!("{name} is no item of {cat}, so there is no one cell to write")) - }) + model + .first_non_item(key) + .map(|(cat, name)| improvise_protocol::non_item_refusal(cat, name)) } /// A record the store does not hold is known only by its layout position, and diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index bf4fa3c6..0b0fc0e6 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -329,10 +329,16 @@ pub(crate) fn cell_id( // different intent from `SetCell` and still the host's (gotcha 1). RecordCol::Category(_) => return Some(CellId::Coordinate), }; - return Some(CellId::Record(RecordCell { - record: RecordHash::of(record), - measure, - })); + // The refusal's own question, asked here too, so no cell is offered + // for a write the host would then refuse (improvise-gk51). + let key = layout.record_cell_key(row, col)?; + return Some(match model.first_non_item(&key) { + Some((category, item)) => non_item(category, item), + None => CellId::Record(RecordCell { + record: RecordHash::of(record), + measure, + }), + }); } let key = layout.cell_key(row, col)?; // A folded group is an aggregate whatever is on None: no record carries @@ -348,17 +354,26 @@ pub(crate) fn cell_id( let derived = model.formula_for(&key).map(|f| CellId::Derived { target: f.target.clone(), }); + let stored = || match model.first_non_item(&key) { + Some((category, item)) => non_item(category, item), + None => CellId::Record(RecordCell { + measure: key.get(MEASURE_CATEGORY).map(String::from), + record: RecordHash::of(&key), + }), + }; Some(match aggregated { true => CellId::Aggregate(AggregateHash::of(&key)), - false => derived.unwrap_or_else(|| { - CellId::Record(RecordCell { - measure: key.get(MEASURE_CATEGORY).map(String::from), - record: RecordHash::of(&key), - }) - }), + false => derived.unwrap_or_else(stored), }) } +fn non_item(category: &str, item: &str) -> CellId { + CellId::NonItem { + category: category.to_string(), + item: item.to_string(), + } +} + /// The categories the category panel draws: **every** category, virtual ones /// included, with their items and their axis (improvise-vjms, gotcha 7). pub fn category_rows(model: &Model, view: &View) -> Vec { @@ -505,7 +520,8 @@ pub fn published_basis(app: &App, grid: &GridCache) -> Basis { CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) - | CellId::Coordinate => None, + | CellId::Coordinate + | CellId::NonItem { .. } => None, }) .collect(); basis::published(app.document_generation(), app.layout_version(), records) diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index eb1ac3a4..4f476d66 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -589,6 +589,15 @@ impl Model { } } + /// The first coordinate of `key` naming no item of its category: the one + /// question a write's refusal and a cell's projection share (improvise-gk51). + pub fn first_non_item<'k>(&self, key: &'k CellKey) -> Option<(&'k str, &'k str)> { + key.0 + .iter() + .find(|(cat, name)| !self.effective_item_names(cat).contains(name)) + .map(|(cat, name)| (cat.as_str(), name.as_str())) + } + /// Category names excluding virtual categories (_Index, _Dim). pub fn regular_category_names(&self) -> Vec<&str> { self.categories diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index 84b55876..f79fa800 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -33,7 +33,8 @@ pub use render::{ DocumentCache, FormulaCache, FormulaEntry, FrameEntry, FrameGroup, GridCache, GridExtent, GridUpdate, MAX_ROW_HEADER_W, MIN_ROW_HEADER_W, Projection, RecordCell, Snapshot, TileBarCache, TileEntry, ViewListCache, ViewSummary, WidgetCache, WizardCache, WizardDateField, - WizardFieldKind, WizardProposal, WizardStepKind, header_level_widths, opening_tile, + WizardFieldKind, WizardProposal, WizardStepKind, header_level_widths, non_item_refusal, + opening_tile, }; pub use resolve::{ Click, KeyOutcome, OpenedEditor, Request, click, edited, read_buffer, resolve, typed, diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 88e043db..f3db9ff3 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -59,6 +59,15 @@ pub enum CellId { /// A records-mode coordinate. Renaming it moves every measure cell in the /// row (gotcha 1), which no client composes: `protocol.md` §3 (improvise-exdg). Coordinate, + /// A stored cell whose `category` coordinate names no item of it, so no write + /// can address it: `context/code/command.md` (improvise-gk51). + NonItem { category: String, item: String }, +} + +/// Why a cell whose coordinate names no item takes no write. One wording for the +/// projection's decline and the host's refusal (improvise-gk51). +pub fn non_item_refusal(category: &str, item: &str) -> String { + format!("{item} is no item of {category}, so there is no one cell to write") } /// What a creating column creates. The name typed into it is the whole diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 981b5227..53083517 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -793,6 +793,9 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { // them; declining rather than `None` keeps that a door, not a hole. CellId::Create(_) => Some(vec![Command::SetStatus(not_yet("creating a column"))]), CellId::Coordinate => Some(vec![Command::SetStatus(not_yet("renaming a coordinate"))]), + CellId::NonItem { category, item } => Some(vec![Command::SetStatus( + crate::non_item_refusal(category, item), + )]), } } @@ -1004,9 +1007,11 @@ fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option fn record_at_cursor(state: &ClientState) -> Option { match cell_at_cursor(state)?.id.as_ref()? { CellId::Record(cell) => Some(cell.record), - CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => { - None - } + CellId::Aggregate(_) + | CellId::Derived { .. } + | CellId::Create(_) + | CellId::Coordinate + | CellId::NonItem { .. } => None, } } @@ -1112,9 +1117,11 @@ fn model(state: &ClientState, cmd: ModelCommand) -> Option { fn record_cell_at_cursor(state: &ClientState) -> Option { match cell_at_cursor(state)?.id.as_ref()? { CellId::Record(cell) => Some(cell.clone()), - CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) | CellId::Coordinate => { - None - } + CellId::Aggregate(_) + | CellId::Derived { .. } + | CellId::Create(_) + | CellId::Coordinate + | CellId::NonItem { .. } => None, } } diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs index 8d214b40..9a6b84ee 100644 --- a/crates/improvise-wasm-client/src/lib.rs +++ b/crates/improvise-wasm-client/src/lib.rs @@ -201,7 +201,8 @@ impl Client { CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) - | CellId::Coordinate => None, + | CellId::Coordinate + | CellId::NonItem { .. } => None, } } diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 8535f78b..b0edfebc 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -147,7 +147,8 @@ impl RenderCell { CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) - | CellId::Coordinate, + | CellId::Coordinate + | CellId::NonItem { .. }, ) | None => false, }, From ff8ea2390945dab10d876f0dd9c6fa9a5155cdef Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:13:58 -0700 Subject: [PATCH 057/114] A rename retry after any refusal may rename only the record it named improvise-9ww8: a crossing rename by another client bumps layout_version, so the refusal is LayoutMoved, not RecordGone, and the retry recaptured whatever record was under the cursor. refused() now pins Opened::Retry(r) for any refused rename of r; the recapture becomes the target only if it is r, else Gone(r). RecordMoved on the same record stays a force. The explicit RecordGone pin is subsumed and removed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 51 +++++++++----- context/test-evidence/clients.md | 60 +++++++++++++++++ crates/improvise-protocol/src/client.rs | 67 +++++++++---------- crates/improvise-protocol/src/resolve.rs | 7 +- .../tests/browser_round_trip.rs | 47 +++++++++++++ 5 files changed, 175 insertions(+), 57 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 50a8d995..059b2444 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -2853,8 +2853,8 @@ they answer different questions: state. It is composed optimistically at the keypress and folded the same way on the host, where `CommitAndAdvance` reads it. - `ClientState::opened` (`Opened::Target(OpenedCoordinate { record, category, - version, seen, document_generation })`, or `Opened::Gone` after a - `RecordGone`, below) answers **which record the rename names, and which + version, seen, document_generation })`, or `Opened::Retry`/`Opened::Gone` + after a refused rename, below) answers **which record the rename names, and which record versions and document its basis claims**. It is session-local and never serialized. @@ -2873,23 +2873,38 @@ the host's value would have no record in it. Gating the capture on `enter-mode` records no opening, yet the host still commits a rename from it, so the gate would strand that path on "still waiting". -**A retry after `RecordMoved` is a force.** A refusal replays the view slice, -so the editor reopens with the typed text. `settle_opened` then captures the -record at its **current** version, so a second `Enter` succeeds. That is the -one-keystroke force `RecordMoved` promises (gotcha 43). The user has just been -told that someone else changed the row. +**A retry after a refusal may rename only the record it named (improvise-20ek, +improvise-9ww8).** A refusal replays the view slice, so the editor reopens with +the typed text over whatever row the cursor now shows. The rule is about that +row: -**A retry after `RecordGone` sends nothing (improvise-20ek).** `RecordGone` on -the record the refused rename named means another client renamed or deleted that -row. The replayed editor then sits over a **different** record, and recapturing -it would rename that one with text typed for another row. So `refused()` pins -`ClientState::opened` to `Opened::Gone(record)`, and `rename_coordinate` answers -`Enter` with a status and no write. Esc leaves the editor, and opening it again -captures afresh. This keeps "force never creates structure" (gotcha 43) from -turning into "force picks another target". The pin applies only to that -rejection on that record. The reviewer's rival on PR #221 kept the opened target -through every refusal instead, and that breaks the reorder case above: after a -successful rename the next editor must capture the row it now shows. +- `refused()` pins `ClientState::opened` to `Opened::Retry(record)`, where + `record` is the one the refused rename named. The refusal's kind plays no part. +- Once the host has answered, `settle_opened` captures what is under the cursor. + If that is the same record, it becomes the target at its **current** version, + so a second `Enter` goes through. That is the one-keystroke force + `RecordMoved` promises (gotcha 43), and the user has just been told someone + else changed the row. +- If it is any other record, or none, `opened` becomes `Opened::Gone(record)`, + and `rename_coordinate` answers `Enter` with a status and no write. Esc + leaves the editor, and opening it again captures afresh. + +**Why the rule names the record rather than a rejection.** The first version +(20ek) pinned only on `RecordGone` of the renamed record. But `stale_basis` +checks the layout tier before the record tier, and another client's rename +bumps `layout_version`. So when A's `Enter` crosses B's rename in flight, which +takes nothing more than ordinary latency, the refusal is `LayoutMoved`, not +`RecordGone`. The retry then renamed whatever record had moved under the +cursor. That happened both when B had renamed A's own record, and when B's +rename merely re-sorted the rows so that a different, still-living record sat +under the cursor. So "is the source gone" is not the discriminator either. The +only question that separates a force from a retarget is whether the capture is +the record the user typed for. With that rule the explicit `RecordGone` pin is +subsumed: after `RecordGone(r)` the capture can never be `r`. It was removed +once a rival that dropped it failed no test. The reviewer's rival on PR #221, +which kept the opened target through every refusal, still breaks the reorder +case: after a *successful* rename, the next editor must capture the row it now +shows. **The destination is a record too (improvise-milr).** A rename onto an existing coordinate overwrites that record (improvise-kndq), so it is a write to the diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 35992a61..0c4a2392 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5332,3 +5332,63 @@ the capture through every refusal and every return to `Normal`, fails this test as well as the reorder test: its capture survives the swap. So the test does see one regression class, a capture that outlives its editor, even though no rival of the captured *generation* is observable. + +### a_retry_after_a_crossing_rename_of_its_record_renames_no_other_record + +improvise-9ww8, found by the adversarial review of PR #251 at `29d2adef`. The +earlier fix pinned the editor only on `RecordGone` of the renamed record. But +`stale_basis` checks the layout tier before the record tier, and another +client's rename bumps `layout_version`. So a crossing is refused `LayoutMoved`, +and the retry recaptured whatever record was under the cursor. + +The test makes the crossing explicit. A opens North's coordinate and types `Zed`. +B renames North to `Yak`, and B's relays to A are **held**. A then composes +`Enter` without having heard B. Only after that does A receive B's relays and +then its own refusal, in the order a single socket delivers them. The setup +asserts that the refusal is `LayoutMoved`, which is the tier the old pin could +not see. The retry `Enter` must rename nothing: afterwards Yak holds 1, South 2, +West 3, and there is no Zed. + +- **Red at `4ff79ff2`:** the retry renamed South to Zed, the reviewer's probe 1. +- **Rival `no_pin_at_all`** (no pin after a refusal): fails this test, the + reorder-crossing test below and `a_retry_after_record_gone_renames_no_other_record`. +- **Rival `retry_any_capture`** (after a refusal, accept whatever capture is + under the cursor): fails the same three. +- The existing 20ek test delivers B's rename to A **before** `Enter`, so A's + layout claim is current there and only the record tier can fire. That is why + it could not see this. + +### a_retry_after_a_crossing_reorder_renames_no_other_record + +improvise-9ww8, the reviewer's probe 2, which shows that "is the source gone" is +not the discriminator. The crossing is the same, but A edits **South** (row 1) +and B renames North. South survives, but the re-sort moves it to row 0, so the +replayed editor sits over West. The retry must not rename West: West keeps its +3, Zed is never West's 3, and Yak holds 1. + +- **Red at `4ff79ff2`:** West was renamed to Zed while South still existed. +- **Rivals `no_pin_at_all` and `retry_any_capture`:** both fail this test. + +### a_retry_after_a_crossing_write_to_its_own_record_is_a_force + +The control for the two tests above. A rule of "decline after any refusal" would +pass both of them, so this test separates the fix from that rule. The crossing +is a `SetCell` by B on North's Revenue. It is refused `RecordMoved(north)` (a +write reshapes nothing, so only the record tier fires). The replayed editor is +still over North, so the retry is the one-keystroke force: Zed holds B's 9, and +North is gone. + +- **Rival `always_decline`** (every retry after a refusal declines): fails only + this test. +- **Rival `never_clear_opened`** (the #221 reviewer's rival: keep the opened + target through everything): fails this test as well as the reorder and swap + tests. Its stale target claims North at the version it opened with, so the + retry is refused again rather than forced. + +**Amending the entry for `a_retry_after_record_gone_renames_no_other_record` +above.** Its rival `never_set_gone` no longer exists as written. The explicit +`RecordGone` pin it removed has itself been removed, because the general rule +subsumes it: after `RecordGone(r)` the capture can never be `r`, so it becomes +`Gone(r)` anyway. A rival that dropped the explicit pin (`gone_as_retry`) failed +no test, which is how the redundancy was found. The test's live rivals are now +`no_pin_at_all` and `retry_any_capture`. diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index ece22cb8..66a2ac03 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -50,11 +50,12 @@ pub struct ClientState { pub opened: Option, } -/// What an open coordinate editor renames. `Gone` is a refusal's answer, kept -/// so a retry cannot silently retarget (improvise-20ek, `clients.md`). +/// What an open coordinate editor renames. After a refusal the retry may name +/// only the record it named (`Retry`) or none (`Gone`): improvise-20ek, -9ww8. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Opened { Target(OpenedCoordinate), + Retry(RecordHash), Gone(RecordHash), } @@ -151,9 +152,7 @@ impl ClientState { if rejection.refused_an_edit() { let dropped = self.in_flight.pop_front(); self.replay(); - if let Some(gone) = renamed_away(dropped.as_ref(), rejection) { - self.opened = Some(Opened::Gone(gone)); - } + self.opened = renamed(dropped.as_ref()).map(Opened::Retry); } self.last_rejection = Some(rejection); self.settle_mode(); @@ -199,23 +198,29 @@ impl ClientState { let answered = !self.in_flight.iter().any(|sent| sent.awaited); self.opened = match (self.view.mode.is_editing(), self.opened.take()) { (false, _) => None, + (true, Some(Opened::Retry(record))) if answered => Some(match self.captured() { + Some(target) if target.record == record => Opened::Target(target), + _ => Opened::Gone(record), + }), (true, Some(opened)) => Some(opened), - (true, None) if answered => { - crate::resolve::coordinate_at_cursor(self).and_then(|(record, category)| { - let basis = self.basis.current()?; - Some(Opened::Target(OpenedCoordinate { - record, - category, - version: basis.record(record)?, - seen: basis.records.clone(), - document_generation: basis.document_generation, - })) - }) - } + (true, None) if answered => self.captured().map(Opened::Target), (true, None) => None, }; } + /// The coordinate under the cursor, with every counter this client holds. + fn captured(&self) -> Option { + let (record, category) = crate::resolve::coordinate_at_cursor(self)?; + let basis = self.basis.current()?; + Some(OpenedCoordinate { + record, + category, + version: basis.record(record)?, + seen: basis.records.clone(), + document_generation: basis.document_generation, + }) + } + /// An editor that arrived without its buffer is seeded here, so what is /// drawn and what Enter commits read one buffer (improvise-ktlk). fn settle_editor(&mut self) { @@ -247,25 +252,15 @@ impl ClientState { } } -/// The record a refused frame was renaming, when the refusal says that very -/// record is gone. Only this refusal pins the editor: `RecordMoved` is a force. -fn renamed_away(sent: Option<&Sent>, rejection: Rejection) -> Option { - let Rejection::RecordGone(gone) = rejection else { - return None; - }; - sent? - .cmds - .iter() - .any(|cmd| { - matches!( - cmd, - Command::Model { - cmd: ModelCommand::RenameRecordCoordinate { record, .. }, - .. - } if *record == gone - ) - }) - .then_some(gone) +/// The record a refused frame renamed, if it renamed one (improvise-9ww8). +fn renamed(sent: Option<&Sent>) -> Option { + sent?.cmds.iter().find_map(|cmd| match cmd { + Command::Model { + cmd: ModelCommand::RenameRecordCoordinate { record, .. }, + .. + } => Some(*record), + _ => None, + }) } #[cfg(test)] diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 032a8f57..03fc254d 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -934,8 +934,9 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option vec![Command::SetStatus( RECORD_COORDS_CANNOT_BE_EMPTY.to_string(), )], - (false, false, None) => { + (false, false, None | Some(Opened::Retry(_))) => { return Some(vec![Command::SetStatus( "still waiting for the host to answer your last edit".to_string(), )]); diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 60e7e44a..bf9ea0b1 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1573,3 +1573,50 @@ fn a_retry_after_a_crossing_reorder_renames_no_other_record() { assert_ne!(region(&host, "Zed"), Some(CellValue::Number(3.0))); assert_eq!(region(&host, "Yak"), Some(CellValue::Number(1.0))); } + +/// The control for the two crossings above: `RecordMoved` on the SAME record is +/// still a one-keystroke force. Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_retry_after_a_crossing_write_to_its_own_record_is_a_force() { + use improvise_protocol::{ModelCommand, Rejection, ServerFrame}; + let mut host = three_records_host(); + let (a_id, mut a, coordinate) = records_client_at_coordinate(&mut host); + assert_eq!(a.state().grid.cell(0, coordinate).unwrap().text, "North"); + let revenue = (0..a.state().grid.extent.cols) + .find(|&c| { + a.record_cell_at(0, c) + .is_some_and(|x| x.measure.as_deref() == Some("Revenue")) + }) + .expect("North's revenue"); + let north = a.record_cell_at(0, revenue).unwrap(); + press(&mut host, a_id, &mut a, "i"); + retype(&mut host, a_id, &mut a, "Zed"); + + let held = held_from_another_client( + &mut host, + a_id, + ModelCommand::SetCell { + cell: north.clone(), + value: CellValue::Number(9.0), + }, + ); + let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes"); + let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap()); + deliver(&mut a, &held); + deliver(&mut a, &answer); + assert!( + answer.iter().any(|f| matches!( + f.msg, + ServerFrame::Rejected(Rejection::RecordMoved(h)) if h == north.record + )), + "setup: the crossing write moves the record A is renaming: {answer:?}" + ); + + press(&mut host, a_id, &mut a, "Enter"); + assert_eq!( + region(&host, "Zed"), + Some(CellValue::Number(9.0)), + "the retry must rename North, carrying B's write" + ); + assert_eq!(region(&host, "North"), None); +} From adb6b814161ad5823570c95bdcc61df10466b93f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:19:01 -0700 Subject: [PATCH 058/114] improvise-gk51: the pivot branch too, a shipped-document enumeration, and the argument Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/command.md | 34 +++++++++ context/code/protocol.md | 10 ++- context/test-evidence/command.md | 49 +++++++++++++ crates/improvise-command/src/ui/project.rs | 81 ++++++++++++++++------ tests/shipped_documents_load.rs | 45 ++++++++++++ 5 files changed, 197 insertions(+), 22 deletions(-) diff --git a/context/code/command.md b/context/code/command.md index 185beca6..1ef55b3b 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -3657,6 +3657,40 @@ The question it asks is the projection's own, never a second derivation of it: `Record`. A write whose whole key the host's grid does not draw is not refused here, as it was not before this rule existed. +**The projection asks the first rule too (improvise-gk51).** The non-item rule +asked the document, while `project::cell_id` did not ask at all. In records mode +every measure cell of a stored record was a `Record`, including a record whose +`Region=Mars` names no item. `set-cell`, an undeclared `## Data` item and a drill +commit after an item was removed all make such records. So the host offered a +write it then refused. The two ends now share one predicate, +`Model::first_non_item`: `App::non_item_refusal` refuses with it, and +`cell_id` declines with it, in both branches. The pivot branch needs it as well. +A page selection naming no item (`set_page_selection` does not validate) puts +the stale item into every cell's key, and that too was a `Record` the host +refused. + +Such a cell projects as a new kind, **`CellId::NonItem { category, item }`**, +and not as one of the existing ones. Each existing kind would lie about it: + +- `Aggregate` invites a drill, and its refusal says the cell "combines several + records". This is one stored cell. +- `Coordinate` tells the user that renaming is not built yet. +- `Derived` names a formula that does not exist. +- `None` means "no cell" (`protocol.md` §3), and this cell holds a value. + +The variant carries the offending coordinate, so every decline says the same +sentence: `improvise_protocol::non_item_refusal`, which the host's refusal uses +too. The two front ends agree because the TUI's `value_cell_key` and +`committable_cell_key` also read `cursor_cell_id`: `p`, `x` and a commit on +such a cell are refused with that sentence in the terminal as well. A +`NonItem` publishes no record version, since no basis can claim a write it +could never make. + +Option (b) on the bead, closing the sources, is not done here. It is +improvise-98pd. With (a) in place, a source that still writes a non-item +produces a cell that is declined everywhere, not one that is offered and then +refused. + **Why the `vec![]` arm stays.** A reviewer asked whether a `SetCell` whose cell does not resolve at all, which `model_effects` turns into `vec![]` and so applies nothing, should also be a `Rejection`. Through a `Host`, it already is, diff --git a/context/code/protocol.md b/context/code/protocol.md index 2056b567..da8f0e1f 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -660,10 +660,16 @@ now. That is `Some(CellId::Aggregate(..))`, and the distinction is the whole point of the enum. `None` means the slot holds **no projected cell**: a `GridCache` position padded by `fit_to_labels`, or one blanked by a reframing. Every -records-mode column is some kind of cell (`Record`, `Derived`, `Create` or -`Coordinate`). Conflating the two either hides a cell or offers a +records-mode column is some kind of cell (`Record`, `Derived`, `Create`, +`Coordinate` or `NonItem`). Conflating the two either hides a cell or offers a write the type system exists to make unconstructible. +`NonItem { category, item }` is a stored value whose `category` coordinate +names no item of it (improvise-gk51). It is drawn, and it takes no write. The +host would refuse one, so it is not offered as a `Record`. Why it is a kind of +its own rather than one of the others: `context/code/command.md`, "The +projection asks the first rule too". + ### `CellDisplay::edit_text` — the difference corrupts data What an editor opened on this cell should start with: the value as the user diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 031b74ec..7e622450 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6276,3 +6276,52 @@ case fails. Second, the ticket's suggested fix, which drops the asker's reset and the client never heard. That rival also fails `a_geometry_command_relays_the_cursor_reset_to_the_asker_too` and the ws-server's `the_other_document_would_also_have_a_cursor_to_invalidate`. + +### every_projected_record_is_writable + +improvise-gk51, found reviewing #252 at `47b166ee`. #252's `non_item_refusal` +refuses a wire write unless every coordinate names an item, but +`project::cell_id` never asked. In records mode every measure cell of a stored +record was projected as `CellId::Record`, including a record whose +`Region=Mars` names no item. So the host offered a write and then refused it. + +The fixture is `records_app()` plus a stored `{Region=Mars, _Measure=Revenue}`, +the record `set-cell` writes. The test enumerates every projected cell, takes +the `Record`s and asks `App::unwritable` of each. The refused list must be +empty, and it also asserts at least four `Record`s remain (East and North). +**Red at `47b166ee`:** `(2, 2)` and `(2, 3)`, the Mars row's Revenue and Cost, +were refused with "Mars is no item of Region, so there is no one cell to +write". + +Rivals, each injected in `cell_id`: + +- **The records branch does not ask:** this test fails with the same two cells. +- **Every records-mode measure cell declined (over-refusal):** this test fails + on "every East and North measure cell is still a Record: 0". That is the half + that keeps "decline everything" from passing. + +### every_projected_record_is_writable_on_a_stale_page + +improvise-gk51, the pivot branch, which the ticket did not predict. +`View::set_page_selection` does not validate, so a selection naming no item puts +that item into every cell's key. + +The fixture is Region on page, Month on row and `_Measure` on column, with one +item each. Its control is the same workbook at a live page, where there is at +least one `Record` and none is refused. Then the selection is set to `Mars`. The +test asserts the grid draws a cell projected as `NonItem { Region, Mars }`, so +the stale page is not empty, and that no projected `Record` is refused. + +**Rival: the pivot branch does not ask.** This test fails at the `NonItem` +assertion. With that assertion removed, it fails with +`(0, 0, "Mars is no item of Region, …")`, so the pivot branch had the ticket's +bug too. + +### every_record_a_shipped_document_projects_is_writable + +improvise-gk51's enumeration over real documents. It takes every tracked +`.improv` in the pivot view it opens in, then again in records mode (`R`), and +asserts that every projected `Record` is writable. It passes at the base too: +the reviewer counted 518 cells across the shipped documents and found no +non-item record among them. So it is a **non-regression guard**, not a +reproduction; the two fixtures above are the reproductions. diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 0b0fc0e6..2cadc05f 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -5300,8 +5300,26 @@ mod cell_identity { } } - /// improvise-gk51: a cell projected as a `Record` is one a wire write may land - /// on. Evidence: `context/test-evidence/command.md`. + /// Every cell `app` projects as a `Record`, and those the host then refuses + /// a write to. The refused list must be empty (improvise-gk51). + fn records_and_refusals(app: &App) -> (usize, Vec<(usize, usize, String)>) { + let grid = grid_of(app, WHOLE); + let records: Vec<(usize, usize, RecordCell)> = (0..app.layout.row_count()) + .flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c))) + .filter_map(|(r, c)| match grid.cell(r, c)?.id.clone()? { + CellId::Record(record) => Some((r, c, record)), + _ => None, + }) + .collect(); + let refused = records + .iter() + .filter_map(|(r, c, record)| app.unwritable(record).map(|why| (*r, *c, why))) + .collect(); + (records.len(), refused) + } + + /// improvise-gk51: a records-mode cell projected as a `Record` is one a wire + /// write may land on. Evidence: `context/test-evidence/command.md`. #[test] fn every_projected_record_is_writable() { let mut app = records_app(); @@ -5315,29 +5333,52 @@ mod cell_identity { ); app.rebuild_layout(); let grid = grid_of(&app, WHOLE); - let cells: Vec<(usize, usize, CellDisplay)> = (0..app.layout.row_count()) + let drawn = (0..app.layout.row_count()) .flat_map(|r| (0..app.layout.col_count()).map(move |c| (r, c))) - .filter_map(|(r, c)| grid.cell(r, c).map(|cell| (r, c, cell.clone()))) - .collect(); + .any(|(r, c)| grid.cell(r, c).is_some_and(|cell| cell.text == "Mars")); + assert!(drawn, "setup: the non-item record is drawn"); + let (records, refused) = records_and_refusals(&app); assert!( - cells.iter().any(|(_, _, cell)| cell.text == "Mars"), - "setup: the non-item record is drawn" + records >= 4, + "every East and North measure cell is still a Record: {records}" ); - let records: Vec<(usize, usize, RecordCell)> = cells - .iter() - .filter_map(|(r, c, cell)| match &cell.id { - Some(CellId::Record(record)) => Some((*r, *c, record.clone())), - _ => None, - }) - .collect(); assert!( - records.len() >= 4, - "every East and North measure cell is still a Record: {records:?}" + refused.is_empty(), + "projected as Record, then refused: {refused:?}" ); - let refused: Vec<(usize, usize, String)> = records - .into_iter() - .filter_map(|(r, c, record)| app.unwritable(&record).map(|why| (r, c, why))) - .collect(); + } + + /// improvise-gk51, the pivot branch: a page selection naming no item puts a + /// non-item in every cell's key. Evidence: `context/test-evidence/command.md`. + #[test] + fn every_projected_record_is_writable_on_a_stale_page() { + let mut wb = Workbook::new("Pivot"); + wb.add_category("Region").unwrap(); + wb.add_category("Month").unwrap(); + wb.add_item("Region", "East").unwrap(); + wb.add_item("Month", "Jan").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + let view = wb.active_view_mut(); + view.set_axis("Region", Axis::Page); + view.set_axis("Month", Axis::Row); + view.set_axis("_Measure", Axis::Column); + let control = App::new(wb.clone(), None); + let (records, refused) = records_and_refusals(&control); + assert!( + records >= 1 && refused.is_empty(), + "setup: a live page is a Record" + ); + wb.active_view_mut().set_page_selection("Region", "Mars"); + let app = App::new(wb, None); + let named = grid_of(&app, WHOLE).cells.iter().flatten().any(|cell| { + cell.id + == Some(CellId::NonItem { + category: "Region".into(), + item: "Mars".into(), + }) + }); + assert!(named, "the stale page's cells are drawn, and named for it"); + let (_, refused) = records_and_refusals(&app); assert!( refused.is_empty(), "projected as Record, then refused: {refused:?}" diff --git a/tests/shipped_documents_load.rs b/tests/shipped_documents_load.rs index 777b1b1b..648af93f 100644 --- a/tests/shipped_documents_load.rs +++ b/tests/shipped_documents_load.rs @@ -50,6 +50,51 @@ fn every_shipped_document_loads() { } } +/// improvise-gk51: in every shipped document, pivot and records mode, a cell +/// projected as a `Record` is one the host would accept a write to. +#[test] +fn every_record_a_shipped_document_projects_is_writable() { + use improvise::command::key::{KeyCode, KeyEvent, KeyModifiers}; + use improvise::protocol::CellId; + use improvise::ui::app::App; + use improvise_command::ui::project::{Viewport, grid_of}; + + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut records = 0; + let mut refused = Vec::new(); + for path in shipped_documents(root) { + let workbook = improvise::persistence::load(&path).expect("every shipped document loads"); + let mut app = App::new(workbook, None); + for _mode in ["pivot", "records"] { + let whole = Viewport { + row_offset: 0, + col_offset: 0, + rows: app.layout.row_count(), + cols: app.layout.col_count(), + }; + let grid = grid_of(&app, whole); + for cell in grid.cells.iter().flatten() { + if let Some(CellId::Record(record)) = &cell.id { + records += 1; + if let Some(why) = app.unwritable(record) { + refused.push(format!("{}: {why}", path.display())); + } + } + } + let r = KeyEvent::new(KeyCode::Char('R'), KeyModifiers::NONE); + let _ = app.handle_key(r); + } + } + assert!( + records > 0, + "precondition: the shipped documents project records" + ); + assert!( + refused.is_empty(), + "projected as Record, then refused: {refused:?}" + ); +} + /// A scratch repository, so each way of shipping a document is set up for real. fn scratch_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().expect("a temp dir"); From e4b5ce9046e5152c071479677ca596e9fca0c797 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:19:22 -0700 Subject: [PATCH 059/114] A relay heard while frames are in flight rebases the view on them (improvise-xd3q) A pruning commit is answered with ResetCursor and an echo of its own placement. A frame sent before that answer had already been folded optimistically, and fold applied the relays straight onto view on top of it. The echo is per frame, so the later placement was never re-sent: the client sat at (1,0) while its mirror sat at (2,0), and the next write landed on West where the TUI writes East. #246 already queues each relay behind the head frame, so the queue holds the right order. fold now rebuilds view as settled plus everything still in flight whenever a relay queues, the same replay a refusal uses. Replaying from settled, rather than re-applying what queued behind the relay, is pinned by a ViewBack test: re-applying stepped back twice. Stacked on #246 (fix/hv0r-jpba-relays, 790a33ba). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 22 ++++ context/test-evidence/clients.md | 27 +++++ context/test-evidence/command.md | 39 ++++++ crates/improvise-command/src/ui/host.rs | 111 ++++++++++++++++-- crates/improvise-protocol/src/client.rs | 15 ++- .../improvise-wasm-client/tests/lockstep.rs | 109 +++++++++++++++++ 6 files changed, 314 insertions(+), 9 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index b7037cec..e7ebf6b5 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -1382,6 +1382,28 @@ and the next answer popped it instead of its own frame. That entry can be a relay or this client's own view-only frame, and the view-only case predated relays. +**A relay that queues also rebases `view` (improvise-xd3q).** Recording a relay +in `settled` fixed only the refusal. On the accepted path, `fold` still applied +the relay straight onto `view`, on top of the frames this client had already +sent behind the one being answered. A pruning commit is answered with +`ResetCursor` and an echo of its own placement (improvise-wdmp). A `j` sent +before that answer arrived had already been folded, and the reset clobbered it. +The echo re-sent only the pruning frame's placement, because the host's +`asker_reset` is per frame. So the client sat at (1,0) while its mirror sat at +(2,0), and the next write landed on West where the TUI writes East. The queue +already held the right order, `[head, reset, echo, j]`, so `fold` now rebuilds +`view` with the same `replay` a refusal uses whenever a relay is queued. That is +the settled slice plus everything still in flight, in order. With nothing in +flight, the relay is applied directly, as before. + +It is a **replay from `settled`**, not "apply the relay, then re-apply what +queued behind it". Re-applying reruns every frame behind the relay a second +time. That looks the same for idempotent setters like `SetSelected`, but +`ViewBack` pops a stack, and applying it twice stepped a client back two views +while its mirror stepped back one. The host could not have fixed this. It +cannot know whether a frame was composed before or after the client heard the +reset, and only the client knows what it sent. + Two simpler shapes were rejected, and each fails the Host-level test: - **Advancing `settled` directly** gets the order wrong. The head frame's own diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 3adaf2de..31c05364 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5146,3 +5146,30 @@ refusal popped U and kept F2. It failed with `left: (4, 0)` (F2's refused placement) against `(2, 0)`, which is U's placement and the one the host applied. Same fix. + +### a_move_sent_before_a_pruning_commit_is_answered_writes_where_the_tui_writes + +improvise-xd3q, the ticket's harm end to end: a real `Host`, a `ClientState`, +and the TUI driven key by key through `reduce_key`. Fixture: +North/South/West/East = 1/2/3/4 on one measure, `prune_empty` on, window 4×4. + +1. `i Backspace` are sent and answered one at a time. +2. `Enter`, which empties North and prunes its row, is sent, and so are + `Escape` and `j`. **None is answered until all three are out.** +3. The answers are folded in order, through `fold`. +4. The client's cursor must equal the mirror's, and the mirror must equal the + TUI's. +5. `i 5 Enter` then has to leave the two documents identical. + +The ticket's repro pipelined a second `Enter`. On this base, without #227, that +Enter is a clearing write the client cannot compose, so it goes `Upstream` +(fy9o's world). `Escape j` exercises the same client mechanism with keys the +client resolves. + +**Rival, the fix reverted:** the client ends at `(1,0)` against `(2,0)`. With the +cursor assertion disabled, the document assertion fails as the ticket +described: the browser wrote **West = 35** where the TUI wrote **East = 45**. + +This file folds host frames through its own `heard` helper and not through +`deliver`. On #246, `deliver` folds the host's commands with `fold_outgoing`, +which queues them as frames this client sent (reported on #246). diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 673e843f..ec477a97 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6300,3 +6300,42 @@ was back at `(1, 0)` while the mirror was at `(0, 0)`. Four rivals: So neither test alone separates the fix from all four rivals, and together they do. + +### a_placement_sent_before_a_reframe_is_answered_outlives_its_reset + +improvise-xd3q, found in the #227 review. No refusal is involved. + +1. The asker sends a reframe: an `AddCategory` followed by `SetSelected (1,0)`. +2. **Before** that frame's answer arrives, it sends a second frame, + `SetSelected (2,0)`. +3. Only then are both answers folded, in order. + +The host applies both frames, so the mirror ends at `(2,0)`. The reframe's +answer carries `ResetCursor` and an echo of `(1,0)`. That echo is per frame +(improvise-wdmp), so the second frame's placement is never re-sent. On #246's +head `790a33ba`, `fold` applied the relays straight onto `view` and the client +ended at `(1,0)`. The test also asserts that `settled` agrees with the mirror. + +**Rivals:** +- **The fix reverted** (`fold` always applies directly): fails this test and + the lockstep test `a_move_sent_before_a_pruning_commit_is_answered_writes_where_the_tui_writes`, + and nothing else in `improvise-command`'s lib or in lockstep. +- **Apply the relay, then re-apply the frames queued behind it**: *passes* + this test, because `SetSelected` is idempotent. The next test catches it. + +### a_view_step_sent_before_a_reframe_is_answered_steps_back_once + +improvise-xd3q, the property the test above cannot see: a frame queued behind +a relay is replayed **once**, from `settled`, and is never applied a second +time. + +The asker walks Default → Second → Third. It sends a reframe, and then +`ViewBack` before the reframe's answer arrives. The mirror steps back once, to +Second, with `[Default]` on its back stack. The test asserts that the setup +reframe relays `ResetCursor`, because without that there is nothing to replay +behind. + +**Rival:** "apply the relay, then re-apply what queued behind it" fails with +`("Default", [])` against `("Second", [Default])`. `ViewBack` ran twice. +Under the fix reverted, this test passes, because a reset does not move the +active view. The two tests together separate the fix from both rivals. diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index bda19184..53d8b5bc 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -1874,14 +1874,7 @@ mod tests { ) -> Vec { client.fold_outgoing(&frame); let answer = host.handle(id, ClientFrame::Commands(frame)); - for msg in to(&answer, id) { - match msg { - ServerFrame::Commands(cmds) => cmds.iter().for_each(|c| client.fold(c)), - ServerFrame::Accepted => client.accepted(), - ServerFrame::Rejected(r) => client.refused(r), - other => panic!("an unexpected answer: {other:?}"), - } - } + answered(&answer, id, client); answer } @@ -2000,6 +1993,108 @@ mod tests { } } + /// Fold the answers `answer` owes `id`, the way the page folds them. + fn answered(answer: &[Addressed], id: SessionId, client: &mut improvise_protocol::ClientState) { + for msg in to(answer, id) { + match msg { + ServerFrame::Commands(cmds) => cmds.iter().for_each(|c| client.fold(c)), + ServerFrame::Accepted => client.accepted(), + ServerFrame::Rejected(r) => client.refused(r), + other => panic!("an unexpected answer: {other:?}"), + } + } + } + + /// improvise-xd3q: a placement sent before a reframe's answer arrived must + /// survive that answer's reset. Evidence: `context/test-evidence/command.md`. + #[test] + fn a_placement_sent_before_a_reframe_is_answered_outlives_its_reset() { + use improvise_protocol::AppMode; + + let mut host = host(); + let (asker, mut client) = connected_client(&mut host); + let product = ModelCommand::AddCategory("Product".into()); + let reframe = vec![ + Command::ChangeMode(AppMode::Normal), + crate::ui::reduce::caught_up(host.app(), product), + Command::SetSelected { row: 1, col: 0 }, + ]; + let placement = vec![Command::SetSelected { row: 2, col: 0 }]; + + client.fold_outgoing(&reframe); + let first = host.handle(asker, ClientFrame::Commands(reframe)); + client.fold_outgoing(&placement); + let second = host.handle(asker, ClientFrame::Commands(placement)); + assert!( + relayed_to(&first, asker).contains(&Command::ResetCursor), + "setup: the reframe must reset the asker, or nothing here can clobber" + ); + answered(&first, asker, &mut client); + answered(&second, asker, &mut client); + + let mirror = host.sessions.get(asker).unwrap().view().cursor(); + assert_eq!( + mirror.selected, + (2, 0), + "setup: the host applied both frames" + ); + assert_eq!( + client.view.cursor(), + mirror, + "the reframe's reset clobbered a placement the client had already sent" + ); + assert_eq!(client.settled.cursor(), mirror, "settled"); + } + + /// improvise-xd3q: what was sent behind a reframe is replayed ONCE, from the + /// settled slice, never re-applied. Evidence: `context/test-evidence/command.md`. + #[test] + fn a_view_step_sent_before_a_reframe_is_answered_steps_back_once() { + use improvise_protocol::AppMode; + + let mut host = host(); + let (asker, mut client) = connected_client(&mut host); + for name in ["Second", "Third"] { + let create = ModelCommand::CreateView(name.into()); + let create = crate::ui::reduce::caught_up(host.app(), create); + exchange(&mut host, asker, &mut client, vec![create]); + exchange( + &mut host, + asker, + &mut client, + vec![Command::SwitchView(name.into())], + ); + } + let product = ModelCommand::AddCategory("Product".into()); + let reframe = vec![ + Command::ChangeMode(AppMode::Normal), + crate::ui::reduce::caught_up(host.app(), product), + ]; + let back = vec![Command::ViewBack]; + + client.fold_outgoing(&reframe); + let first = host.handle(asker, ClientFrame::Commands(reframe)); + client.fold_outgoing(&back); + let second = host.handle(asker, ClientFrame::Commands(back)); + assert!( + relayed_to(&first, asker).contains(&Command::ResetCursor), + "setup: the reframe must relay a reset, or nothing is queued to replay behind" + ); + answered(&first, asker, &mut client); + answered(&second, asker, &mut client); + + let mirror = host.sessions.get(asker).unwrap().view(); + assert_eq!( + mirror.active_view, "Second", + "setup: one step back from Third" + ); + assert_eq!( + (&client.view.active_view, &client.view.view_back_stack), + (&mirror.active_view, &mirror.view_back_stack), + "the frame sent behind the reframe was applied more than once" + ); + } + /// improvise-jpba: another client's reset, heard while my frame is in flight, /// outlives that frame's refusal. Evidence: `context/test-evidence/command.md`. #[test] diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 4702eab9..e5a432bf 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -85,7 +85,20 @@ impl ClientState { /// Total: a command naming something absent is dropped, never a panic. pub fn fold(&mut self, cmd: &Command) { self.settle_relay(cmd); - self.apply(cmd); + match (cmd, self.in_flight.is_empty()) { + (Command::Project(_), _) | (_, true) => self.apply(cmd), + // Queued behind the frame it answers, so what was sent after it + // replays on top of it (improvise-xd3q, `clients.md`). + (_, false) => self.rebase(), + } + } + + /// The view rebuilt as the settled slice plus everything still in flight. + fn rebase(&mut self) { + self.replay(); + self.settle_mode(); + self.settle_editor(); + self.revision += 1; } /// A relay the host has already applied to this client's mirror, so a diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index e7559c55..17bc7dd7 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2322,3 +2322,112 @@ fn renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host() { assert_eq!(client.view.status_msg, "Formula updated"); assert_eq!(app.view_state.status_msg, "Renamed Tax to Levy"); } + +/// North/South/West/East = 1/2/3/4 on one measure, `prune_empty` on: emptying +/// North takes its row away and renumbers the rest (improvise-xd3q). +fn four_pruned_regions() -> App { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + for region in ["North", "South", "West", "East"] { + wb.add_item("Region", region).unwrap(); + } + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + wb.active_view_mut().prune_empty = true; + for (region, value) in [("North", 1.0), ("South", 2.0), ("West", 3.0), ("East", 4.0)] { + wb.model.set_cell( + region_measure(region, "Revenue"), + improvise_protocol::CellValue::Number(value), + ); + } + let mut app = App::new(wb, None); + app.term_height = 4 + 8; + app.rebuild_layout(); + app +} + +/// One key resolved by the client and sent to `host`; its answer is returned, +/// NOT folded, so the next key can go out first. +fn sent_to_host( + client: &mut ClientState, + host: &mut improvise_command::ui::host::Host, + id: improvise_command::ui::project::SessionId, + key: &str, +) -> Vec { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let cmds = match resolve(client, event) { + KeyOutcome::Local(cmds) => cmds, + other => panic!("{key}: the client must answer this key locally, got {other:?}"), + }; + client.fold_outgoing(&cmds); + host.handle(id, ClientFrame::Commands(cmds)) +} + +/// Fold an answer the way the page does: the host's commands through `fold`. +fn heard( + client: &mut ClientState, + id: improvise_command::ui::project::SessionId, + answer: Vec, +) { + for addressed in answer.into_iter().filter(|a| a.to == id) { + match addressed.msg { + improvise_protocol::ServerFrame::Snapshot(snapshot) => client.bootstrap(*snapshot), + improvise_protocol::ServerFrame::Commands(cmds) => { + cmds.iter().for_each(|c| client.fold(c)) + } + improvise_protocol::ServerFrame::Rejected(r) => client.refused(r), + improvise_protocol::ServerFrame::Accepted => client.accepted(), + } + } +} + +/// BUG (improvise-xd3q) — see `context/test-evidence/clients.md`. +#[test] +fn a_move_sent_before_a_pruning_commit_is_answered_writes_where_the_tui_writes() { + let window = (4, 4); + let mut host = improvise_command::ui::host::Host::new(four_pruned_regions()); + let (id, snapshot) = host.connect(window.0, window.1); + let mut client = ClientState { + window, + ..ClientState::default() + }; + heard(&mut client, id, vec![snapshot]); + let mut tui = four_pruned_regions(); + let key = |tui: &mut App, k: &str| reduce_key(tui, BrowserKey::new(k).to_key_event().unwrap()); + for k in ["i", "Backspace"] { + let answer = sent_to_host(&mut client, &mut host, id, k); + heard(&mut client, id, answer); + key(&mut tui, k); + } + // Enter prunes North; Esc and j go out before its answer comes back. + let answers: Vec<_> = ["Enter", "Escape", "j"] + .into_iter() + .map(|k| { + key(&mut tui, k); + sent_to_host(&mut client, &mut host, id, k) + }) + .collect(); + answers.into_iter().for_each(|a| heard(&mut client, id, a)); + let mirror = host.sessions().get(id).unwrap().view().cursor(); + assert_eq!( + mirror.selected, + tui.cursor().selected, + "setup: the host's mirror followed the keys the TUI followed" + ); + assert_eq!( + client.view.cursor().selected, + mirror.selected, + "the pruning commit's reset clobbered where j had put the client" + ); + for k in ["i", "5", "Enter"] { + let answer = sent_to_host(&mut client, &mut host, id, k); + heard(&mut client, id, answer); + key(&mut tui, k); + } + assert_eq!( + document_text(host.app()), + document_text(&tui), + "the browser wrote a different record from the one the TUI wrote" + ); +} From fd221c6c6ca137a6fee0f4b7f7a0b5313c702c07 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:24:34 -0700 Subject: [PATCH 060/114] prune_empty shares one fold memo per build (metf) prune_empty evaluated every cell with a fresh memo, h6z1's shape on the same keypress path. Measured on an M4 Max: a pruned build of the folded chain went 705-900 ms -> ~350 ms at 18k and 6.4-6.6 s -> 3.0-5.0 s at 90k; the dense grid is unchanged within noise. A new bench line times the pruned build. The test counts 9 evaluations where a fresh memo per cell counts 45, and checks the pruned rows are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- benches/grid_projection.rs | 21 ++++++++- context/test-evidence/core.md | 33 ++++++++++++++ crates/improvise-core/src/view/layout.rs | 57 +++++++++++++++++++++--- 3 files changed, 103 insertions(+), 8 deletions(-) diff --git a/benches/grid_projection.rs b/benches/grid_projection.rs index 132a7c31..ac1c19e9 100644 --- a/benches/grid_projection.rs +++ b/benches/grid_projection.rs @@ -5,7 +5,7 @@ use std::time::{Duration, Instant}; use improvise::format::FormatPlan; use improvise::model::cell::{CellKey, CellValue}; -use improvise::view::Axis; +use improvise::view::{Axis, GridLayout}; use improvise::workbook::Workbook; use improvise_command::ui::app::App; use improvise_command::ui::project::{self, Viewport}; @@ -180,6 +180,21 @@ windowed {windowed:>11.3?} ratio {ratio:>4.1}x col_widths {widths:>11.3?} ({sh ); } +/// The layout build with `prune_empty` on: every cell evaluated to find the empty +/// rows and columns, on the same keypress path as `grid_of` (improvise-metf). +fn report_prune(label: &str, mut app: App, iters: u32) { + app.model_state.workbook.active_view_mut().prune_empty = true; + let model = &app.model_state.workbook.model; + let view = app.model_state.workbook.active_view(); + let mut best = Duration::MAX; + for _ in 0..iters.max(3) { + let start = Instant::now(); + std::hint::black_box(GridLayout::with_frozen_records(model, view, None)); + best = best.min(start.elapsed()); + } + println!("{label:<22} layout build with prune_empty {best:>11.3?}"); +} + fn main() { println!("project::grid_of, one call = one TUI frame's grid cache"); println!("a 60fps frame budget is 16.7ms; the TUI redraws on each keypress\n"); @@ -190,4 +205,8 @@ fn main() { println!("\nfolded rows and a five-formula chain (improvise-h6z1)\n"); report("folded chain ~18k", chain_app(1000, 30), 5); report("folded chain ~90k", chain_app(5000, 30), 3); + println!("\nthe same, pruned (improvise-metf)\n"); + report_prune("dense 18k", dense_app(150, 20, 6), 5); + report_prune("folded chain ~18k", chain_app(1000, 30), 5); + report_prune("folded chain ~90k", chain_app(5000, 30), 3); } diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 2e1d4f8e..858f8927 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1560,3 +1560,36 @@ Jan and Feb (`40.0`). `a_max_measures_total_does_not_move_when_a_row_group_collapses` and `an_average_measures_total_is_the_mean_over_its_records`. So it needed no new test. + +### pruning_evaluates_each_fold_formula_once_and_keeps_the_same_rows + +**What was slow (improvise-metf).** `GridLayout::prune_empty` evaluated every +cell with its own fresh fold memo. That is h6z1's shape, and it sits on the same +keypress path, since `with_frozen_records` prunes whenever the view asks. Each +column of a folded chain re-derived the chain below it. + +**Measured first**, on an Apple M4 Max (64 GB) running other agents' builds. +The bench is `cargo bench --bench grid_projection`, whose new "pruned" lines time +a `with_frozen_records` build with `prune_empty` on. There were two back-to-back +pairs, and the second put the old line back for the "before" half: + +| Case | Before (fresh memo per cell) | After (one memo per build) | +|---|---|---| +| dense 18k | 90 ms, 88 ms | 82 ms, 100 ms | +| folded chain ~18k | 900 ms, 705 ms | 355 ms, 351 ms | +| folded chain ~90k | 6.40 s, 6.62 s | 3.00 s, 4.96 s | + +It pays on the folded chain: about 2.0× to 2.5× at 18k, and 1.3× to 2.1× at 90k. +The dense grid has no folds and moves only within noise. So `prune_empty` now +shares one memo per build, taken from `GridLayout::fold_memo` and dropped before +the axes are pruned. + +**The test.** It uses `margin_book`'s F0..F8 chain with Q1 collapsed. It counts +fold-formula evaluations across one pruned `with_frozen_records` build and +asserts **9**. It also asserts that the pruned rows are exactly the rows a fresh +`evaluate` per cell would keep, so sharing the memo cannot change what is +pruned. And it asserts that the Q1 fold row survives, so the count is not +measured over an empty grid. + +**Rival:** the old line, `self.evaluate(model, &k)` per cell. This test fails: +`left: 45, right: 9`. diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index dd44d721..91e39265 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -611,14 +611,18 @@ impl GridLayout { return; } - // Build a row×col grid of "has content?" + // Build a row×col grid of "has content?", one fold memo for the whole + // walk (improvise-metf). It is dropped before the axes are pruned. let mut has_value = vec![vec![false; cc]; rc]; - for (ri, row) in has_value.iter_mut().enumerate() { - for (ci, cell) in row.iter_mut().enumerate() { - *cell = self - .cell_key(ri, ci) - .and_then(|k| self.evaluate(model, &k)) - .is_some(); + { + let memo = self.fold_memo(); + for (ri, row) in has_value.iter_mut().enumerate() { + for (ci, cell) in row.iter_mut().enumerate() { + *cell = self + .cell_key(ri, ci) + .and_then(|k| self.evaluate_in(model, &k, &memo)) + .is_some(); + } } } @@ -1854,6 +1858,45 @@ mod tests { .and_then(|v| v.as_f64()) } + /// improvise-metf: pruning evaluates each formula a fold row reaches once, and + /// keeps what a fresh evaluation per cell keeps. context/test-evidence/core.md. + #[test] + fn pruning_evaluates_each_fold_formula_once_and_keeps_the_same_rows() { + let chain: Vec = std::iter::once("F0 = Revenue".to_string()) + .chain((1..=8).map(|i| format!("F{i} = F{p} + F{p}", p = i - 1))) + .collect(); + let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); + let mut wb = margin_book(&chain); + wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.model.recompute_formulas(&wb.active_view().none_cats()); + let unpruned = GridLayout::new(&wb.model, wb.active_view()); + let kept_by_fresh_evaluation: Vec = (0..unpruned.row_count()) + .filter(|&r| { + (0..unpruned.col_count()).any(|c| { + let k = unpruned.cell_key(r, c).unwrap(); + unpruned.evaluate(&wb.model, &k).is_some() + }) + }) + .map(|r| unpruned.row_label(r)) + .collect(); + wb.active_view_mut().prune_empty = true; + FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0)); + let pruned = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); + let evaluations = FOLD_FORMULA_EVALUATIONS.with(|n| n.get()); + let rows: Vec = (0..pruned.row_count()) + .map(|r| pruned.row_label(r)) + .collect(); + assert_eq!( + rows, kept_by_fresh_evaluation, + "sharing the memo changed what is pruned" + ); + assert!( + rows.iter().any(|r| r == "Q1"), + "setup: the fold row survives pruning" + ); + assert_eq!(evaluations, 9, "F0..F8 once each for the fold row, not 45"); + } + /// improvise-h6z1: one grid build evaluates each formula a fold row reaches /// once, not once per column. context/test-evidence/core.md. #[test] From da0eb3d7cb032cad90291ae295e632ef882e010e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:28:43 -0700 Subject: [PATCH 061/114] improvise-a0i3: red test, the walk under the user's pathspec and attribute settings Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- tests/shipped_documents_load.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/shipped_documents_load.rs b/tests/shipped_documents_load.rs index 01142d7a..d93dafd0 100644 --- a/tests/shipped_documents_load.rs +++ b/tests/shipped_documents_load.rs @@ -266,3 +266,29 @@ fn a_scratch_repo_ignores_the_users_default_ignore_and_attributes_files() { ); } } + +/// improvise-a0i3: pathspec magic and an attribute source are process-wide git +/// settings, and `--local-env-vars` lists none. Evidence: `context/test-evidence/tui.md`. +#[test] +fn the_walk_ignores_the_users_pathspec_and_attribute_settings() { + let settings = [ + ("GIT_LITERAL_PATHSPECS", "1"), + ("GIT_GLOB_PATHSPECS", "1"), + ("GIT_NOGLOB_PATHSPECS", "1"), + ("GIT_ICASE_PATHSPECS", "1"), + ("GIT_ATTR_SOURCE", "HEAD"), + ]; + let mut failed = Vec::new(); + for (name, value) in settings { + for test in [ + "a_gzipped_document_is_walked", + "every_shipped_document_loads", + ] { + let out = rerun_with_env(test, &[(name, Path::new(value))]); + if !out.status.success() { + failed.push(format!("{test} under {name}={value}: {}", told(&out))); + } + } + } + assert!(failed.is_empty(), "{}", failed.join("\n")); +} From 2c16f7f2ce21c59662b163074eb4116d9b4bf3f2 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:29:50 -0700 Subject: [PATCH 062/114] Guard App::restore's rebuild-after-view_state order improvise-3til: rebuilding before view_state is restored passed every test. A rolled-back frame that deleted the host's view now asserts the host is back on that view with that view's layout shape. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/command.md | 24 +++++++++++ crates/improvise-command/src/ui/host.rs | 57 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 3383baaf..713b3ceb 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6239,3 +6239,27 @@ behavioural one is the reason the rebuild exists. Placed in `ui/host.rs` rather than in `ui/app.rs`'s `snapshot_restore` module (which PR #254 adds), both to avoid that conflict and because the consequence is the host's. + +### a_rolled_back_delete_of_the_hosts_view_rebuilds_that_views_layout + +improvise-3til, found in the adversarial review of PR #261. `App::restore` +rebuilds the layout **last**, after `view_state` is restored. The order matters +because `rebuild_layout` reads `view_name()`, which comes from +`view_state.active_view`. Moving the rebuild up to just after the workbook +assignment passed every test in `improvise-command`, including +`a_frame_after_a_rolled_back_reshape_resolves_against_the_restored_grid`, +because that frame never moves the host's view. + +This test moves it. The host sits on a transposed view, `Other` (1 row by 2 +columns). A client's frame `[DeleteView(Other), RemoveItem(South), +SetCell(South)]` deletes the host's view, which moves the host to the default +view, and is then refused `ChainBroken` and rolled back. The test asserts that +the host is back on `Other` **and** that its layout is Other's shape. + +- **Rival** (the bead's: rebuild before `view_state` is restored), run across + the whole workspace: fails **only** this test. It fails at the layout shape, + (2, 1) against (1, 2), because the rival builds the default view's grid and + then puts the host back on `Other`. +- The bead suggests following up with a write that resolves only through + Other's layout. In this fixture both views show the same two cells, so no + write can tell the two layouts apart. The shape is the discriminating fact. diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index d287cffb..da6e2323 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -1033,6 +1033,63 @@ mod tests { ); } + /// A rolled-back frame that moved the HOST's view rebuilds the layout of the + /// view it is put back on (improvise-3til). Evidence: test-evidence/command.md. + #[test] + fn a_rolled_back_delete_of_the_hosts_view_rebuilds_that_views_layout() { + use improvise_protocol::ModelCommand; + + let mut host = host(); + let other = host.app.model_state.workbook.create_view("Other"); + other.set_axis("Region", Axis::Column); + other.set_axis("_Measure", Axis::Row); + assert!( + host.app.switch_to_view("Other"), + "setup: the host is on Other" + ); + host.app.rebuild_layout(); + let (asker, _) = host.connect(10, 10); + let shape = |host: &Host| (host.app().layout.row_count(), host.app().layout.col_count()); + assert_eq!(shape(&host), (1, 2), "setup: Other is transposed"); + + let basis = frame_basis(&host, &["South"]); + let refused = host.handle( + asker, + ClientFrame::Commands(vec![ + Command::Model { + basis: basis.clone(), + cmd: ModelCommand::DeleteView("Other".into()), + }, + Command::Model { + basis: basis.clone(), + cmd: ModelCommand::RemoveItem { + category: "Region".into(), + item: "South".into(), + }, + }, + Command::Model { + basis, + cmd: ModelCommand::SetCell { + cell: addressed("South"), + value: CellValue::Number(5.0), + }, + }, + ]), + ); + assert!( + refused + .iter() + .any(|a| matches!(a.msg, ServerFrame::Rejected(Rejection::ChainBroken(_)))), + "setup: the frame must be refused and rolled back: {refused:?}" + ); + assert_eq!(host.app().view_name(), "Other", "the host is back on Other"); + assert_eq!( + shape(&host), + (1, 2), + "and its layout must be Other's, not the default's" + ); + } + /// The rollback reaches the MIRRORS, not only the document — the cost the /// ticket called its least obvious. See `context/test-evidence/command.md`. #[test] From d7dceef54933f1f24e3542719e3ff4c2706d6680 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:30:41 -0700 Subject: [PATCH 063/114] a grid evaluates through its own evaluator, never another's memo (opbo) GridLayout::evaluate_in and column_total took any &FoldMemo, so a memo from a layout showing Feb, handed to one hiding it, gave 5.0 where 2.0 was right. GridLayout::evaluator(model) now returns a GridEval that owns a memo its layout built, with cell() and column_total(); the memo-taking methods and the public fold_memo are gone. grid_body and prune_empty use it, and the earlier rivals still fail when restated for the new API. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/core.md | 21 +++-- context/test-evidence/core.md | 35 ++++++++ crates/improvise-command/src/ui/viewport.rs | 22 ++--- crates/improvise-core/src/view/layout.rs | 95 +++++++++++++++------ crates/improvise-core/src/view/mod.rs | 4 +- 5 files changed, 129 insertions(+), 48 deletions(-) diff --git a/context/code/core.md b/context/code/core.md index 0628459e..758e20ad 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -931,9 +931,9 @@ every member. Five decisions: what gave the wrong ratios. The remaining ~6× is each column's cell re-deriving the chain below it (45 evaluations per row where 9 would do). **The memo is now shared across one grid build** (improvise-h6z1). - `viewport::grid_body` makes one `FoldMemo` and passes it through - `GridLayout::evaluate_in` and `column_total` for every cell and total, so a - fold row's chain is evaluated 9 times, not 45. A memo is valid only for the + `viewport::grid_body` takes one `GridLayout::evaluator` and asks it for + every cell and total, so a fold row's chain is evaluated 9 times, not 45. + `prune_empty` does the same (improvise-metf). A memo is valid only for the model state it read. That is why it is made per build and never stored on `GridLayout`, which outlives cell writes. The memo is keyed by the fold key **and** the span's categories. Within one @@ -948,10 +948,17 @@ every member. Five decisions: the key instead would have needed a closure's identity, and a closure has none worth hashing. So `FoldMemo::new(agg, covers)` owns both, and `evaluate_covering_in`/`evaluate_spanning_in` read them off the memo. A - mismatched pair can no longer be passed. `GridLayout::fold_memo` builds the - one a grid shares. What stays open is a memo built for one layout and - passed to another's `evaluate_in`. That hands over a consistent pair, only - for a different grid. + mismatched pair can no longer be passed. + **A grid's evaluations cannot be handed another grid's memo either** + (improvise-opbo). `GridLayout::evaluate_in` and `column_total` used to take + any `&FoldMemo`. A memo built from a layout showing Feb, passed to a layout + hiding it, gave 5.0 where 2.0 was right: a consistent pair, only for a + different grid. `GridLayout::evaluator(model)` now returns a `GridEval` that + owns a memo its own layout built, with `cell` and `column_total` on it, and + the memo-taking methods are gone. `Model::evaluate_covering_in` and + `evaluate_spanning_in` still take a `&FoldMemo`, but a memo there carries + its own agg and covers, so the only mismatch left is a memo over a + different *model state*. That is the per-build rule above, unchanged. - **A fold covers the members the grid draws** (improvise-f2oo). Hidden members are not in it. So a fold is exactly the rows it replaces, the total of a column's total does not move on `z`, and a group whose every member is hidden diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 858f8927..8115d3ba 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1593,3 +1593,38 @@ measured over an empty grid. **Rival:** the old line, `self.evaluate(model, &k)` per cell. This test fails: `left: 45, right: 9`. + +### a_grid_evaluates_under_its_own_covers_not_another_grids + +**What was open (improvise-opbo, P3).** It confirms ChatGPT Codex's P2 on #258. +`GridLayout::evaluate_in` and `column_total` took any `&FoldMemo`. With +`margin_book(["R = Revenue / Cost"])` and Q1 collapsed, there are two layouts +over one model: one draws Jan and Feb, the other hides Feb. The first layout's +memo, handed to the second layout's `evaluate_in`, gave **5.0** for +`{Month=Q1, _Measure=R}` where the second grid reads **2.0**. Nothing in +production reached it, because each build made its own memo. + +**Red first.** The test was first written against `evaluate_in` with a foreign +memo. It failed `left: Some(Number(5.0)), right: Some(Number(2.0))`. + +**Fix: the review's proposal.** `GridLayout::evaluator(model)` returns a +`GridEval` that borrows the layout and owns a memo the layout built itself. It +has `cell(key)` and `column_total(col)`. `evaluate_in`, the memo-taking +`column_total`, and `fold_memo` are gone. So the shape the test first used no +longer compiles, and the signature is the guard. The test now asserts two +things. Each layout's evaluator answers for its own covers: 5.0 and 2.0. And +asking one does not move the other. + +**Rival:** the evaluator's covers ignoring the layout's hidden items. This test +fails `left: Some(Number(5.0)), right: Some(Number(2.0))`. + +**The earlier rivals, restated for the new API, still fail:** +- `pruning_evaluates_each_fold_formula_once_and_keeps_the_same_rows` with a + fresh evaluator per cell: `left: 45, right: 9`. +- `one_grid_build_evaluates_each_fold_formula_once_for_cells_and_totals` with + a fresh evaluator per cell: `left: 54, right: 18`. +- The same test with a fresh evaluator per total: `left: 54, right: 18`. + +The entries above for h6z1, 0jw5 and metf name `evaluate_in` and `fold_memo` +because those were the API when they were written. This entry supersedes the +names, not the arguments. diff --git a/crates/improvise-command/src/ui/viewport.rs b/crates/improvise-command/src/ui/viewport.rs index edf28307..bd1a6906 100644 --- a/crates/improvise-command/src/ui/viewport.rs +++ b/crates/improvise-command/src/ui/viewport.rs @@ -9,8 +9,7 @@ use crate::format::{FormatPlan, NumberFormat}; use crate::model::Model; use crate::model::category::MEASURE_CATEGORY; use crate::model::cell::{CellKey, CellValue}; -use crate::model::types::FoldMemo; -use crate::view::{AxisEntry, GridLayout}; +use crate::view::{AxisEntry, GridEval, GridLayout}; /// Minimum column width — enough for short numbers/labels + 1 char gap. pub const MIN_COL_WIDTH: u16 = 5; @@ -47,16 +46,14 @@ pub fn grid_body( let n = layout.col_count(); let records = layout.is_records_mode(); let mut widths = header_level_seed(layout, n); - // One memo for the whole build: a fold every cell and total reaches is + // One evaluator for the whole build: a fold every cell and total reaches is // evaluated once (improvise-h6z1). The model does not change during it. - let memo = layout.fold_memo(); + let eval = layout.evaluator(model); // A column mixing measures has no total to show: `context/code/command.md`. let measures_on_rows = layout.row_cats.iter().any(|c| c == MEASURE_CATEGORY); let totalled = !records && !measures_on_rows && layout.row_count() > 0 && n > 0; let totals: Vec> = match totalled { - true => (0..n) - .map(|ci| layout.column_total(model, ci, &memo)) - .collect(), + true => (0..n).map(|ci| eval.column_total(ci)).collect(), false => Vec::new(), }; let mut window: Vec>> = Vec::with_capacity(rows.len()); @@ -73,7 +70,7 @@ pub fn grid_body( false => Vec::new(), }; for ci in 0..n { - let (key, value) = cell_value(model, layout, records, ri, ci, &memo); + let (key, value) = cell_value(layout, &eval, records, ri, ci); let fmt = plan.at(key.as_ref()); let text = match records { true => value.as_ref().map(|v| v.to_string()).unwrap_or_default(), @@ -106,20 +103,17 @@ pub fn grid_body( /// The one evaluation. Records mode reads its wide row; pivot mode aggregates. fn cell_value( - model: &Model, layout: &GridLayout, + eval: &GridEval, records: bool, row: usize, col: usize, - memo: &FoldMemo, ) -> (Option, Option) { match records { true => (None, layout.records_value(row, col)), false => { let key = layout.cell_key(row, col); - let value = key - .as_ref() - .and_then(|k| layout.evaluate_in(model, k, memo)); + let value = key.as_ref().and_then(|k| eval.cell(k)); (key, value) } } @@ -247,7 +241,7 @@ mod tests { layout.evaluate(&wb.model, &key); } for c in cols.clone() { - layout.column_total(&wb.model, c, &layout.fold_memo()); + layout.evaluator(&wb.model).column_total(c); } }); let together = evaluations(|| { diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index 91e39265..675a978e 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -615,13 +615,10 @@ impl GridLayout { // walk (improvise-metf). It is dropped before the axes are pruned. let mut has_value = vec![vec![false; cc]; rc]; { - let memo = self.fold_memo(); + let eval = self.evaluator(model); for (ri, row) in has_value.iter_mut().enumerate() { for (ci, cell) in row.iter_mut().enumerate() { - *cell = self - .cell_key(ri, ci) - .and_then(|k| self.evaluate_in(model, &k, &memo)) - .is_some(); + *cell = self.cell_key(ri, ci).and_then(|k| eval.cell(&k)).is_some(); } } } @@ -662,27 +659,20 @@ impl GridLayout { /// The value of a pivot key as THIS grid reads it: aggregated over its None /// axis, and a fold over the members it draws (core.md §14). One evaluation. pub fn evaluate(&self, model: &Model, key: &CellKey) -> Option { - self.evaluate_in(model, key, &self.fold_memo()) + self.evaluator(model).cell(key) } - /// A memo holding this grid's aggregation and the members it draws: the one - /// a build shares across its cells and totals (improvise-h6z1, pntu). - pub fn fold_memo(&self) -> FoldMemo<'_> { - FoldMemo::new(Aggregation::of(&self.none_cats), |cat, item| { + /// One grid build's evaluator, whose fold memo this layout built, so no other + /// grid's memo can reach its cells or totals (improvise-h6z1, pntu, opbo). + pub fn evaluator<'a>(&'a self, model: &'a Model) -> GridEval<'a> { + let memo = FoldMemo::new(Aggregation::of(&self.none_cats), |cat, item| { !self.hidden.get(cat).is_some_and(|h| h.contains(item)) - }) - } - - /// [`Self::evaluate`] sharing one grid build's `memo` (improvise-h6z1). - pub fn evaluate_in(&self, model: &Model, key: &CellKey, memo: &FoldMemo) -> Option { - model.evaluate_covering_in(key, memo) - } - - /// A column's total: its cell as if every drawn row were ONE fold, so a - /// formula is evaluated over the pooled rows (improvise-wm20, core.md §14). - pub fn column_total(&self, model: &Model, col: usize, memo: &FoldMemo) -> Option { - let key = self.column_key(col)?; - model.evaluate_spanning_in(&key, &self.row_cats, memo) + }); + GridEval { + layout: self, + model, + memo, + } } /// Display text for (row, col) in either mode. @@ -912,6 +902,29 @@ fn cross_product(model: &Model, view: &View, cats: &[String]) -> Vec result } +/// One grid build's evaluations under one fold memo its own layout built, valid +/// for one model state (improvise-h6z1, opbo). `context/code/core.md` §14. +pub struct GridEval<'a> { + layout: &'a GridLayout, + model: &'a Model, + memo: FoldMemo<'a>, +} + +impl GridEval<'_> { + /// A pivot key's value as this grid reads it: [`GridLayout::evaluate`], shared. + pub fn cell(&self, key: &CellKey) -> Option { + self.model.evaluate_covering_in(key, &self.memo) + } + + /// A column's total: its cell as if every drawn row were ONE fold, so a + /// formula is evaluated over the pooled rows (improvise-wm20, core.md §14). + pub fn column_total(&self, col: usize) -> Option { + let key = self.layout.column_key(col)?; + self.model + .evaluate_spanning_in(&key, &self.layout.row_cats, &self.memo) + } +} + #[cfg(test)] mod tests { use super::{ @@ -1916,12 +1929,12 @@ mod tests { .filter_map(|c| layout.cell_key(q1, c)) .collect(); let evaluations = |shared: bool| { - let memo = layout.fold_memo(); + let eval = layout.evaluator(&wb.model); FOLD_FORMULA_EVALUATIONS.with(|n| n.set(0)); let values: Vec<_> = row_keys .iter() .map(|k| match shared { - true => layout.evaluate_in(&wb.model, k, &memo), + true => eval.cell(k), false => layout.evaluate(&wb.model, k), }) .collect(); @@ -1981,6 +1994,38 @@ mod tests { ); } + /// improvise-opbo: a grid's evaluation cannot be handed another grid's memo. + #[test] + fn a_grid_evaluates_under_its_own_covers_not_another_grids() { + let mut wb = margin_book(&["R = Revenue / Cost"]); + wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + let all = GridLayout::new(&wb.model, wb.active_view()); + let mut no_feb_view = wb.active_view().clone(); + no_feb_view.hide_item("Month", "Feb"); + let no_feb = GridLayout::new(&wb.model, &no_feb_view); + let key = CellKey::new(vec![ + ("Month".into(), "Q1".into()), + ("_Measure".into(), "R".into()), + ]); + let on_all = all.evaluator(&wb.model); + let on_no_feb = no_feb.evaluator(&wb.model); + assert_eq!( + on_all.cell(&key), + Some(CellValue::Number(5.0)), + "Jan and Feb" + ); + assert_eq!( + on_no_feb.cell(&key), + Some(CellValue::Number(2.0)), + "the grid hiding Feb reads Jan alone" + ); + assert_eq!( + on_all.cell(&key), + Some(CellValue::Number(5.0)), + "and neither moved the other" + ); + } + /// improvise-eccl — see context/test-evidence/core.md. #[test] fn a_folds_ratio_formula_is_the_ratio_of_its_folded_inputs() { diff --git a/crates/improvise-core/src/view/mod.rs b/crates/improvise-core/src/view/mod.rs index e567d3c6..3bdea6ce 100644 --- a/crates/improvise-core/src/view/mod.rs +++ b/crates/improvise-core/src/view/mod.rs @@ -4,8 +4,8 @@ pub mod types; pub use axis::{Axis, AxisColor}; pub use layout::{ - AxisEntry, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow, ViewStep, - drill_constraint, drill_snapshot, drill_steps, records_spine, records_steps, + AxisEntry, GridEval, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow, + ViewStep, drill_constraint, drill_snapshot, drill_steps, records_spine, records_steps, synthetic_record_info, }; pub use types::{ From 4cef40c951602d65baf483d1239a9edbf0c6db8d Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:30:59 -0700 Subject: [PATCH 064/114] Strip pathspec and attribute-source settings from the walk's git (improvise-a0i3) GIT_LITERAL/GLOB/NOGLOB/ICASE_PATHSPECS and GIT_ATTR_SOURCE are process-wide, so --local-env-vars names none of them. Remove them in git() beside the local-vars loop. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tui.md | 41 +++++++++++++++++++++++++++++++++ tests/shipped_documents_load.rs | 13 +++++++++++ 2 files changed, 54 insertions(+) diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index 43292938..354184e4 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -610,6 +610,47 @@ the `-c` arguments in `run_git` beat relying on the absence of a config file. Rival: drop `--template=`. Red, as above. +### the_walk_ignores_the_users_pathspec_and_attribute_settings + +improvise-a0i3. `git(root)` strips what `git rev-parse --local-env-vars` names, +and that list holds only the variables that point at a repository (`GIT_DIR`, +`GIT_INDEX_FILE` and so on). Pathspec magic and the attribute source are +process-wide settings, so the list names none of them. They still change what +`git ls-files -- '*.improv' '*.improv.gz'` answers: + +- `GIT_LITERAL_PATHSPECS=1` reads `*.improv` as a file literally named + `*.improv`, so the walk finds nothing. +- `GIT_GLOB_PATHSPECS=1` stops `*` at `/`, so only a document at the root + matches. `GIT_NOGLOB_PATHSPECS=1` reads `*` literally, like the literal + setting. Every document these tests walk sits below the root, so both + settings made both tests fail. +- `GIT_ATTR_SOURCE=HEAD` makes git read attributes from `HEAD`. The scratch + repository in the gzip shape test has no commit, so `git add` fails with + `bad --attr-source`. + +The test reruns `a_gzipped_document_is_walked` and `every_shipped_document_loads` +in a child under each of five settings, which is ten runs, and collects every +failure before it asserts. Red at #260's head `322de370`: 7 of 10 runs failed. +`LITERAL`, `GLOB` and `NOGLOB` each broke both tests. `ATTR_SOURCE` broke only +the gzip shape test, because the real checkout has a `HEAD`. The ticket reported +`ATTR_SOURCE` breaking both tests. Here it broke one, so the difference is in +the checkout the ticket measured, not in the fix. + +The fix is to remove the five variables in `git()`, next to the +`--local-env-vars` loop. A `--literal-pathspecs`-style command-line flag would +have to be chosen per variable, and `GIT_ATTR_SOURCE` has no negating flag. + +Rivals, each dropping one name from `PATHSPEC_AND_ATTRIBUTE_SETTINGS`: + +- Without `GIT_LITERAL_PATHSPECS`, `GIT_GLOB_PATHSPECS` or + `GIT_NOGLOB_PATHSPECS`: red, both tests under that setting. +- Without `GIT_ATTR_SOURCE`: red, the gzip shape test. +- Without `GIT_ICASE_PATHSPECS`: **green.** A case-insensitive match of + `*.improv` still finds every document, so this row of the test cannot fail. + The name stays in the list because it is the fourth of git's pathspec + variables, and a future spelling of the walk that depends on case would be + exposed to it. The test covers it, but that coverage is vacuous today. + --- ## `tests/web_demo_model.rs` diff --git a/tests/shipped_documents_load.rs b/tests/shipped_documents_load.rs index d93dafd0..82378c3f 100644 --- a/tests/shipped_documents_load.rs +++ b/tests/shipped_documents_load.rs @@ -48,6 +48,15 @@ fn every_shipped_document_loads() { } } +/// Settings that change how git reads a pathspec or finds attributes. +const PATHSPEC_AND_ATTRIBUTE_SETTINGS: [&str; 5] = [ + "GIT_LITERAL_PATHSPECS", + "GIT_GLOB_PATHSPECS", + "GIT_NOGLOB_PATHSPECS", + "GIT_ICASE_PATHSPECS", + "GIT_ATTR_SOURCE", +]; + /// `git -C root`, with every repo-local variable a caller exported removed, /// since `GIT_DIR` and friends override `-C` (improvise-53zz). fn git(root: &Path) -> std::process::Command { @@ -59,6 +68,10 @@ fn git(root: &Path) -> std::process::Command { for name in String::from_utf8(names.stdout).expect("UTF-8").lines() { git.env_remove(name); } + // Process-wide, so `--local-env-vars` lists none of them (improvise-a0i3). + for name in PATHSPEC_AND_ATTRIBUTE_SETTINGS { + git.env_remove(name); + } git.arg("-C").arg(root); git } From 9872cc936f7f1ba19f5e9654a591f88f4a6f4eea Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:33:33 -0700 Subject: [PATCH 065/114] Every cell-editor opener awaits its seed while a write is unanswered (improvise-p6v6) mdfc put the "any unanswered write" check inside `advanced` alone, so an editor opened with i/a (`edit_or_drill`) still seeded from the cache the write had made stale: `i 9 Tab Esc i 5 Enter`, typed faster than the answer, stored 15 in the browser where the TUI stores 195. The in-flight half now lives in `opened_on`, so no opener can forget it. `advanced` keeps the "this frame writes" half, since the commit being resolved is not in flight yet; dropping it fails the 3tt4/3uzg tests. Stacked on #227 (fix/fy9o-reopened-editor-reseeded, de4a7d78). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/protocol.md | 14 ++++++ context/test-evidence/clients.md | 26 ++++++++++ crates/improvise-protocol/src/resolve.rs | 30 ++++++++---- .../improvise-wasm-client/tests/lockstep.rs | 49 +++++++++++++++++++ 4 files changed, 110 insertions(+), 9 deletions(-) diff --git a/context/code/protocol.md b/context/code/protocol.md index 0a9c6920..268422a9 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -2045,6 +2045,20 @@ stated once, over the write, never over a list of cases: in-flight write, not the newest frame: a frame that writes nothing stays in the queue behind an unanswered write, so the newest frame can be one of those. +- **Every opener asks the in-flight half, because it lives in `opened_on`** + (improvise-p6v6). mdfc first put the whole test inside `advanced`, which left + `i`/`a` (`edit_or_drill`) seeding from the cache with no in-flight check. + `i 9 Tab Esc i 5 Enter`, typed faster than the answer, stored 15 in the + browser where the TUI stores 195. The two halves now live in different + places, for a reason. "A write is still in flight" is true of the *client*, + so `opened_on` asks it, and no opener can forget. "This frame writes" is + true only of the commit being resolved, which is not in `in_flight` yet, so + `advanced` still asks it itself. Folding both into `opened_on` would lose the + second: the 3tt4 and 3uzg tests fail when `advanced` defers entirely. + The click-edit path (`socket.rs::edit_target_at`, improvise-ugj4) also seeds + from the cache, but it claims the record at the version it *read*. Behind an + unanswered write the host therefore refuses its commit as `RecordMoved`: a + loud failure, not a silent overwrite. - **The seed settles only into an open editor** (improvise-4cb5). An Esc typed before the answer arrives leaves the awaiting `opened_editor` in place. The late seed would then fill the edit buffer in `Normal` mode, where the TUI's is diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 1390ab0b..80cbc20b 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5278,3 +5278,29 @@ JSON. **Rival:** `#[serde(default)]` restored. The frame then parses, with `awaiting: false`, and the test fails at `expect_err`. Before this test existed, deleting the attribute left the whole workspace green, so nothing pinned it. + +### `an_editor_opened_with_i_before_the_last_write_is_answered_opens_on_that_write` + +**improvise-p6v6**, found by the #227 review at `de4a7d78`. It predates #227: +the reviewer reproduced it against gh/main `202e7eac`. Fixture: `precise_host`, +cursor (0,1) on North/Revenue = 2 (the rightmost column), window 2×2. + +1. `i 9` are answered one at a time. +2. `Tab` (writes 29), `Escape` and `i` go out through `sent`, and none is + answered until all three are out. +3. The browser's buffers must then equal the TUI host's. +4. `5 Enter` must store 295 on both ends. + +Red at `de4a7d78`: the browser buffer was `"2"` where the TUI's was `"29"`. `i` +reaches `edit_or_drill`, which called `opened_on(cell.edit_text)` with no +in-flight check. mdfc had put that check inside `advanced` alone. + +**Rivals:** +- **The fix reverted** (the code at `de4a7d78`): fails this test only. +- **`advanced` defers wholly to `opened_on`** (its own `writes` half dropped, + on the reasoning that `opened_on` now checks): fails + `a_commit_that_prunes_its_row_reopens_on_the_row_that_moved_up`, both + `tab_at_the_rightmost_column_*` tests, and + `resolve::tests::enter_commits_the_cell_advances_and_re_enters_editing`. The + commit being resolved is not in `in_flight` yet, so `opened_on` cannot see its + write. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index df46c7d8..eaf2fc7e 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -781,7 +781,11 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { match cell.id.as_ref()? { // `edit_text`, never `text`: the drawn text has been through // `format_f64`. `tests/lockstep.rs` found exactly that. - CellId::Record(_) => Some(opened_on(cell.edit_text.clone().unwrap_or_default(), mode)), + CellId::Record(_) => Some(opened_on( + state, + cell.edit_text.clone().unwrap_or_default(), + mode, + )), CellId::Aggregate(_) => Some(vec![Command::SetStatus(format!( "an aggregate is edited by drilling in — {}", not_yet("drill") @@ -796,9 +800,18 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { } } -/// Open the cell editor on `seed`: the buffer, what it captured, then the mode. -/// `EditOrDrill`'s and `EnterEditAtCursor`'s shared shape (improvise-ixxx). -fn opened_on(seed: String, mode: AppMode) -> Vec { +/// Open the cell editor on `seed`, or await it while any write is unanswered: the +/// cache predates that write (improvise-mdfc/p6v6, `protocol.md` §3). +fn opened_on(state: &ClientState, seed: String, mode: AppMode) -> Vec { + match state.in_flight.iter().any(|sent| sent.awaited) { + true => awaiting_seed(mode), + false => seeded(seed, mode), + } +} + +/// The buffer, what the editor captured, then the mode: `EditOrDrill`'s and +/// `EnterEditAtCursor`'s shared shape (improvise-ixxx). +fn seeded(seed: String, mode: AppMode) -> Vec { vec![ Command::SetBuffer { name: "edit".to_string(), @@ -1009,12 +1022,11 @@ fn advanced( return Some(Vec::new()); }; let mut commands = scrolled_to(state, nr, nc); - // After a write — this frame's or an unanswered earlier one's — the cache may - // be stale, so the seed waits for the host (improvise-3uzg/mdfc, `protocol.md` §3). - let stale = writes || state.in_flight.iter().any(|sent| sent.awaited); - commands.extend(match stale { + // This frame's own write makes the cache stale too; `opened_on` answers for + // earlier ones (improvise-3uzg/mdfc/p6v6, `protocol.md` §3). + commands.extend(match writes { true => awaiting_seed(edit_mode), - false => opened_on(seed_at(state, nr, nc), edit_mode), + false => opened_on(state, seed_at(state, nr, nc), edit_mode), }); Some(commands) } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 5ca971a8..c298ffc9 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2637,6 +2637,55 @@ fn a_tab_sent_before_the_last_write_is_answered_reopens_on_that_write() { } } +/// BUG (improvise-p6v6) — see `context/test-evidence/clients.md`. +#[test] +fn an_editor_opened_with_i_before_the_last_write_is_answered_opens_on_that_write() { + let window = (2, 2); + let mut client_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + client_host.cursor_mut().selected = (0, 1); + let mut key_host = precise_host(improvise_protocol::CellValue::Number(4.0)); + key_host.cursor_mut().selected = (0, 1); + let mut client = client_ready_to_edit(&client_host, window); + for key in ["i", "9"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + sent_and_answered(&mut client, &mut client_host, event, window); + reduce_key(&mut key_host, event); + } + // Tab writes 29; Esc and i reopen before its answer comes back. + let answers: Vec = ["Tab", "Escape", "i"] + .into_iter() + .map(|key| { + let event = BrowserKey::new(key).to_key_event().unwrap(); + reduce_key(&mut key_host, event); + sent(&mut client, &mut client_host, event, window) + }) + .collect(); + let awaited: Vec = answers.iter().map(|a| a.awaited).collect(); + assert_eq!(awaited, [true, false, false], "setup: only the Tab writes"); + answers + .into_iter() + .for_each(|answer| answered(&mut client, answer)); + assert_eq!( + client.view.buffers, key_host.view_state.buffers, + "i opened on the cache the unanswered write had made stale" + ); + for key in ["5", "Enter"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + sent_and_answered(&mut client, &mut client_host, event, window); + reduce_key(&mut key_host, event); + } + for (end, app) in [("browser", &client_host), ("host", &key_host)] { + assert_eq!( + app.model_state + .workbook + .model + .get_cell(®ion_measure("North", "Revenue")), + Some(&improvise_protocol::CellValue::Number(295.0)), + "{end}: the browser overwrote the value Tab had just written" + ); + } +} + const FORMULAS: [&str; 3] = [ "Profit = Revenue * 2", "Tax = Revenue * 0.1", From 462471871bb08270594f668d55bd62858e765816 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:31:43 -0700 Subject: [PATCH 066/114] Pin Tab's rightmost-column clamp and bound the tile-bar walks improvise-qnry: a lockstep case compares Tab at the rightmost column with the host's real CommitAndAdvance, and the tile lockstep case now presses past the last tile. improvise-ybum: browser_round_trip's tile and column walks are bounded and fail on a stuck press instead of hanging. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/clients.md | 56 +++++++++++++++++++ .../tests/browser_round_trip.rs | 53 +++++++++++++++--- .../improvise-wasm-client/tests/lockstep.rs | 53 +++++++++++++++++- 3 files changed, 151 insertions(+), 11 deletions(-) diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 9d77250e..ecbe54ea 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -3819,6 +3819,23 @@ ticket's transcript spells the route as `T r l c Esc`, which depends on `state().tiles` and walks to it, so the test pins *the route exists*, not a particular ordering the producer is free to change. +**The walks are bounded (improvise-ybum),** found in the adversarial review +of PR #256. `tile_to` and the column walk were `while` loops that pressed `l` +or `h` until an index was reached. A resolver that stopped the cursor short +never ended the loop: under the left-clamp fault a reviewer killed the binary +after 17 minutes, and in CI a hang costs the job timeout and names no test. +Both loops now go through `walk_to`. It presses at most `limit` times (the tile +count plus one, or the column count plus one), and it fails as soon as a press +leaves the index where it was, naming the stuck index and the target. + +- **Rival `left_clamp_one`** (`clamp(1, ..)` in `tile_cursor`): before the + change, the binary was still running at a 90-second `timeout` and was killed + (exit 124). After it, the test fails in 0.04s with "the tile cursor is stuck + at 1: `h` did not move it toward 0". +- **Rival `column_cap_one`** (`clamp(0, col_max.min(1))` in the grid move): + fails in 0.04s with "the column cursor is stuck at 1: `l` did not move it + toward 2", so the column walk is bounded too, not only the tile walk. + ### opening_an_editor_is_declined_off_the_grid_and_not_off_the_mode improvise-bd9o, and it exists because **the route test could not state the @@ -5150,3 +5167,42 @@ when the index does not move (gotcha 47). `SetTileCatIdx(3)` over three tiles. - The control matters: without the unclamped move first, a resolver that never moves at all would also pass the two clamped presses. + +### tab_at_the_rightmost_column_stays_there_on_both_ends + +improvise-qnry, found in the adversarial review of PR #256. Dropping the clamp +from `advanced`'s `Advance::Right` arm, so that it reads `Some((row, col + 1))`, +passed the whole workspace (1942 tests). `tab_in_an_editor_commits_and_lands_one_column_right` +sits on a centred cursor on purpose, so it cannot see the edge. Without the +clamp, Tab at the rightmost column of a pivot grid sends the selection one +column past the grid, and the editor opens on no cell. + +This is a lockstep case: the client is compared with the host's real +`CommitAndAdvance`, not with a hand-written expectation. The fixture is +`precise_host`, two rows by two columns, with the cursor at (1, 1). That is the +rightmost column at a nonzero row, so "the last column" is not "the first +cell". The test presses `i` then `Tab` and compares the cursor and the mode on +both ends after each key. Its preconditions assert that the grid is not in +records mode, where this corner declines instead, and that the editor is open +again at the end. + +- **Rival `right_unclamped`** (the ticket's fault): fails only this test across + the workspace (1942 passed, 1 failed). The client lands on `(1, 2)` with + `col_offset: 1`; the host stays on `(1, 1)`. +- improvise-3tt4's acceptance test (Tab at the rightmost column, comparing + buffers) would probably also see this fault, but it lives on PR #227's branch, + which is not in this base. This test compares the selection and does not + depend on #227. + +### a_locally_resolved_tile_cursor_agrees_with_the_host + +Extended for improvise-qnry. The test used to press `l` three times over +`tall_host`'s four tiles, starting at 0, so it never reached the upper clamp: +any rival that differed only at the last tile passed it. It now presses +`count + 1` times and asserts it ends on the last tile, so the final press is +checked against the host's real `MoveTileCursor` clamp. + +- **Rival `wraparound`** (`.rem_euclid(count)` in place of the clamp): fails + this test at press 3 (client 0, host 3), and also the unit test + `resolve::tests::the_tile_cursor_stops_on_the_last_tile`. Nothing else in + the workspace fails (1941 passed, 2 failed). diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 1ab86c87..1794d7fd 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -431,15 +431,44 @@ fn tile_to( .iter() .position(|t| t.category == category) .unwrap_or_else(|| panic!("the tile bar must carry {category}")); - while client.state().view.tile_cat_idx < at { - press(host, id, client, "l"); - } - while client.state().view.tile_cat_idx > at { - press(host, id, client, "h"); - } + let limit = client.state().tiles.tiles.len() + 1; + walk_to(host, id, client, "the tile cursor", at, limit, |c| { + c.state().view.tile_cat_idx + }); press(host, id, client, key); } +/// Press `l` or `h` until `read` gives `target`, failing on a stuck press or +/// after `limit` presses rather than hanging (improvise-ybum). Evidence file. +fn walk_to( + host: &mut Host, + id: improvise_command::ui::project::SessionId, + client: &mut Client, + what: &str, + target: usize, + limit: usize, + read: impl Fn(&Client) -> usize, +) { + for _ in 0..limit { + let now = read(client); + if now == target { + return; + } + let key = if now < target { "l" } else { "h" }; + press(host, id, client, key); + assert_ne!( + read(client), + now, + "{what} is stuck at {now}: `{key}` did not move it toward {target}" + ); + } + assert_eq!( + read(client), + target, + "{what} did not reach {target} in {limit} presses" + ); +} + /// `toggle-records-mode` is declined and the tile bar reaches records mode /// anyway (improvise-bd9o). Evidence file. #[test] @@ -472,9 +501,15 @@ fn the_tile_bar_route_into_records_mode_does_not_strand_the_user() { "the fixture must reach past the coordinate column" ); for col in 0..cols { - while client.state().view.cursor().selected.1 < col { - press(&mut host, id, &mut client, "l"); - } + walk_to( + &mut host, + id, + &mut client, + "the column cursor", + col, + cols + 1, + |c| c.state().view.cursor().selected.1, + ); let kind = format!( "{:?}", client.state().grid.cell(0, col).and_then(|c| c.id.clone()) diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index be6784aa..49f6a546 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -462,7 +462,8 @@ fn a_locally_resolved_tile_cursor_agrees_with_the_host() { "a one-tile fixture clamps immediately and proves nothing" ); - for press in 0..3 { + let count = client.tiles.tiles.len(); + for press in 0..=count { let event = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE); let KeyOutcome::Local(cmds) = resolve(&client, event) else { panic!("press {press}: the tile cursor must resolve locally"); @@ -476,7 +477,11 @@ fn a_locally_resolved_tile_cursor_agrees_with_the_host() { "press {press}: client and host disagree about the tile cursor" ); } - assert!(client.view.tile_cat_idx > 0, "the fixture moved nothing"); + assert_eq!( + client.view.tile_cat_idx, + count - 1, + "count + 1 presses must end clamped on the last tile (improvise-qnry)" + ); } // The editing path (improvise-bpq2's second half). @@ -671,6 +676,50 @@ fn a_locally_committed_edit_leaves_both_ends_in_the_same_place() { ); } +/// improvise-qnry: Tab at the rightmost column clamps there on both ends, as +/// `CommitAndAdvance` does. Evidence: `context/test-evidence/clients.md`. +#[test] +fn tab_at_the_rightmost_column_stays_there_on_both_ends() { + let window = (2, 2); + let mut app = precise_host(improvise_protocol::CellValue::Number(4.0)); + let mut client = client_ready_to_edit(&app, window); + let extent = client.grid.extent; + assert!( + !client.grid.records, + "records mode declines this corner instead" + ); + assert_eq!( + client.view.cursor().selected, + (1, extent.cols - 1), + "the cursor must start on the rightmost column at a nonzero row" + ); + + for key in ["i", "Tab"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: the client must answer this key"); + }; + for cmd in &cmds { + client.fold(cmd); + } + reduce_key(&mut app, event); + assert_eq!( + client.view.cursor(), + app.view_state.cursor(), + "{key}: client and host disagree about the cursor" + ); + assert_eq!( + format!("{:?}", client.view.mode), + format!("{:?}", app.view_state.mode), + "{key}: client and host disagree about the mode" + ); + } + assert!( + app.view_state.mode.is_editing(), + "Tab must have reopened the editor, or the walk stopped short" + ); +} + // The second tranche (improvise-e24v). /// A document with two categories, two views and a value, so every widget the From 5738e8e0c05695d8f642846b194bc0005005337a Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:34:06 -0700 Subject: [PATCH 067/114] check-ci-graph: a job's keys start at indent 4, or its lines are refused (rjaa) w0j4 read any line deeper than 4 outside steps:/needs: as content, so a needed job whose own keys sat at indent 5 or 6 (valid YAML) had its if: and needs: missed and the gate exited 0. A deeper line is now content only once its job has a 4-space key. tooling.md names the three valid styles the gate refuses loudly. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 15 +++++++++++++++ context/test-evidence/tooling.md | 32 ++++++++++++++++++++++++++++++++ scripts/check-ci-graph.py | 32 +++++++++++++++++++++++++++----- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index 543d36c9..d96c6b34 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -323,6 +323,21 @@ line and fails: Content under a job key other than `steps:` and `needs:`, such as `env:` or `strategy:`, is not structure the gate reads, so it is not checked. +**But a line is content only once its job has a 4-space key** (improvise-rjaa). +The first version accepted any line deeper than 4 outside `steps:` and +`needs:`. So a *needed* job whose own keys sat at indent 5 or 6, which is valid +YAML, had its `if:` and `needs:` read as content. The gate exited 0 with `kb` +skipped. Now a job's first key must be at indent 4. After that, a deeper line +can only be the content of an empty-valued key, because a key with an inline +value already owns every deeper line. + +Three valid styles are refused loudly rather than read: +- an indentless block list (`needs:` then `- gate` at indent 4); +- a block list at indent 8; +- a quoted top-level key such as `"on":`. + +Each exits 1 and names its line. Spell it the plain way. + **What this does not close, stated rather than guarded.** Once a counted step is reached, the gate trusts that it runs the script it names. An earlier step in the same job that rewrote `scripts/checkers.sh` or its table would defeat diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index cce90568..40ec45f9 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1089,3 +1089,35 @@ key. The review's rival fails it and nothing else. On the real `ci.yml` the **Stated, not guarded.** Content under job keys other than `steps:` and `needs:` is not checked. Neither is anything deeper than a step's own keys. The gate reads no structure there. + +### A job's keys start at indent 4, or its lines are refused — `scripts/check-ci-graph.py` (improvise-rjaa) + +**What broke (P2, fail-open).** Found in the delta review of #228 at +`3eafd4de`. w0j4's `_readable` accepted any line deeper than 4 outside `steps:` +and `needs:` as content. A *needed* job whose own keys sat at indent 5 or 6, +which is valid YAML, therefore had its `if:` and `needs:` read as content. The +review's three plants each exited 0 with `kb` skipped: +- a 6-space `gate` job, with `kb` given `needs: gate`; +- a 5-space `gate` job, with `kb` given `needs: [gate]`; +- a 6-space `mid` job holding `needs: gate`, with `kb` given `needs: mid`. + +**Fix, as the review suggested.** A job's first key must be at indent 4. After +that, a line at indent 5 to 8 outside `steps:` and `needs:` is content only +once its job has a 4-space key. Such a line can only be the content of an +empty-valued key, because a key with an inline value already owns every deeper +line. + +**Red first.** All three plants, as self-test cases, failed with `saw []`. A +control, `env:` then `FOO: bar` at indent 6, stays accepted as content. + +**Replayed on the real `ci.yml`.** Each plant was mutated in, run, and +restored. All three now exit 1, naming the first misplaced line. + +**Rivals, each run against the real `--self-test` (43 cases):** +- The pre-fix rule (any line past 4 is content): fails the three plants. +- `job_keyed` never reset at a new job: fails the same three. The previous + job's key would otherwise license the next job's misplaced lines. + +**Refused loudly, not read:** an indentless block list, a block list at indent +8, and a quoted top-level key such as `"on":`. Each is valid YAML, and each is +named by line. diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index bc7f0a9a..58ddcc60 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -82,9 +82,9 @@ _NEEDS_VALUE = re.compile(rf"^(|{_NAME}|\[\s*{_NAME}(\s*,\s*{_NAME})*\s*\])$") _NEEDS_ITEM = re.compile(rf"^ - {_NAME}$") -def _readable(line, indent, in_steps, in_needs): +def _readable(line, indent, in_steps, in_needs, job_keyed): """Whether a structural line inside `jobs:` is one this gate can read. Anything - else is refused, not parsed: context/code/tooling.md (improvise-w0j4).""" + else is refused, not parsed: context/code/tooling.md (improvise-w0j4, rjaa).""" if indent == 2: return bool(_JOB.match(line)) if indent == 4: @@ -95,7 +95,7 @@ def _readable(line, indent, in_steps, in_needs): return bool(_NEEDS_ITEM.match(line)) if in_steps: return indent in (6, 8) and bool(_STEP_KEY.match(line)) - return indent > 4 + return indent > 4 and job_keyed def _inline_value(rest): @@ -122,7 +122,7 @@ def ci_steps(text): continuation or a heredoc inside one is skipped whole (improvise-st8q).""" steps, stray, unread = [], [], [] job, in_steps, job_flags, step = None, False, set(), None - in_jobs = False + in_jobs, job_keyed = False, False workflow_flags = set() owner, owner_step, quote = None, None, None needs, if_jobs, in_needs = {}, set(), False @@ -153,7 +153,8 @@ def ci_steps(text): in_jobs = line.startswith("jobs:") if not _TOP_KEY.match(line): unread.append(n) - elif in_jobs and indent <= 8 and not _readable(line, indent, in_steps, in_needs): + elif in_jobs and indent <= 8 and not _readable(line, indent, in_steps, in_needs, + job_keyed): unread.append(n) if raw.startswith("defaults:"): workflow_flags.add("workflow defaults") @@ -162,12 +163,14 @@ def ci_steps(text): close_step() step = None job, in_steps, job_flags, in_needs = m.group(1), False, set(), False + job_keyed = False continue if in_needs and re.match(r"^ - ", raw): needs[job].add(_inline_value(raw.strip()[2:])) continue m = _JOB_KEY.match(raw) if m and job is not None: + job_keyed = True close_step() step = None key, value = m.group(1), _inline_value(m.group(2)) @@ -445,6 +448,25 @@ SUITE_SELF_TEST = [ '"defaults":\n run:\n shell: bash\n' + _wf(_BUILD, _job("kb", _KB_RUN)), '"defaults"'), + ("control: a job key's block content at indent 6 is content (rjaa)", _REQ, + _wf(_BUILD, _job("kb", _KB_RUN, head=" env:\n FOO: bar\n")), []), + + _unreadable("a needed job whose keys sit at indent 6 is refused (rjaa)", + _wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n", + _job("kb", _KB_RUN, head=" needs: gate\n")), + " if: false", " steps:", " - run: echo"), + + _unreadable("a needed job whose keys sit at indent 5 is refused (rjaa)", + _wf(_BUILD, " gate:\n if: false\n steps:\n - run: echo\n", + _job("kb", _KB_RUN, head=" needs: [gate]\n")), + " if: false", " steps:", " - run: echo"), + + _unreadable("a job between, with its needs: at indent 6, is refused (rjaa)", + _wf(_BUILD, _GATE, + " mid:\n needs: gate\n steps:\n - run: echo\n", + _job("kb", _KB_RUN, head=" needs: mid\n")), + " needs: gate", " steps:", " - run: echo"), + _unreadable("an explicit ? key is refused (w0j4)", _wf(_BUILD, _job("kb", _KB_RUN, head=" ? if\n : false\n")), "? if", ": false"), From 0947001b7073842dc7e6e31896d2c5e30e2a393e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:35:27 -0700 Subject: [PATCH 068/114] Each crate's tests compile alone; resolve.rs folds what it sends through the send door (improvise-a61f, improvise-aopa) a61f: `cargo test -p improvise-wasm-client --lib` failed with E0277, because `FormulaEntry: From<&str>` exists only under improvise-protocol's `test-support` feature, which that crate's dev-dependencies never asked for. `--workspace` hid it: feature unification pulled the feature in through improvise-command's dev-dep. A sweep of every member with `cargo test -p --no-run` found the same failure in the root `improvise` crate (tests/bug_hunt_reproductions.rs). Both now request the feature. The normal wasm tree still carries no `test-support`. aopa: eight helpers in resolve.rs's tests folded client-composed commands through `fold`, the host's door, so a composed Command::Model was never awaited in `in_flight`. All eight use `fold_outgoing`, one frame per key, as web/app.js does. `a_folded_browser_commit_waits_for_the_host` pins press_folded; the rival that folds through `fold` again fails it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- Cargo.toml | 4 +- context/test-evidence/clients.md | 26 +++++++++++++ crates/improvise-protocol/src/resolve.rs | 47 +++++++++++++++--------- crates/improvise-wasm-client/Cargo.toml | 3 ++ 4 files changed, 61 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 259d8478..8da5e69f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,9 @@ enum_dispatch = "0.3.13" # binary does not grow a wasm client; `improvise-wasm-client` builds # natively, which is how its own tests already run. improvise-wasm-client = { path = "crates/improvise-wasm-client" } -improvise-protocol = { path = "crates/improvise-protocol" } +# `test-support` gives `FormulaEntry: From<&str>`, which the tests use; asked for +# here, or `-p improvise` alone does not compile (improvise-a61f). +improvise-protocol = { path = "crates/improvise-protocol", features = ["test-support"] } # `tests/headless_cli.rs` detaches the binary from any terminal with `setsid`. libc = "0.2" pest = "2.9.2" diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 1b893acb..8c097931 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5117,3 +5117,29 @@ Rival injected: `claim_as_read` ignoring the captured generation and claiming the current one. That is the pre-fix behaviour, restated after the fix. Only this test failed in the whole workspace. The layout tier staying current is still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`. + +### a_folded_browser_commit_waits_for_the_host + +improvise-aopa. The helpers in `resolve.rs`'s tests fold what `resolve` +composed, which is what a browser **sends**. Eight of them folded it through +`ClientState::fold`, the door for what the **host** sends. That door does not +record a frame in `in_flight`, so a `Command::Model` folded there is never +awaited. Any assertion on rollback or settle state made through those helpers +would describe a client that cannot exist: the vacuity improvise-xoxh found in +its renaming test. All eight now use `fold_outgoing`, one frame per key, as +`web/app.js` and `lib.rs` do. + +The view moves the same way through either door, so no existing test could +tell them apart. This test is the cheap one that can. Through `press_folded` it +types `i` and `9`, neither of which composes a `Model`, so `in_flight` must be +empty. Then it presses `Enter`, which commits `9`, and requires exactly one +awaited frame whose commands are the commit's. + +Rival: `press_folded` folding through `fold` again. This test fails, and the +other 186 protocol tests pass. + +The other seven helpers are not pinned the same way: `heard`, `press`, the +sequence loop, the `enter-mode` and `append-char` folds, `arm` and the `yy` +fold. None of the tests that use them reads `in_flight` or `settled`, which is +why the bug was latent. A test that does read them now gets the true door +without having to know about it. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 981b5227..d9f4a0b0 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -2293,7 +2293,7 @@ mod tests { ..view.clone() }; let before = quiet(&state.view); - commands.iter().for_each(|c| state.fold(c)); + state.fold_outgoing(&commands); let sent = commands.iter().any(|c| matches!(c, Command::Model { .. })); let said = commands.iter().any(|c| matches!(c, Command::SetStatus(_))); match (sent || quiet(&state.view) != before, said) { @@ -2353,7 +2353,7 @@ mod tests { let (last, prefix) = sequence.split_last().expect("a sequence has a key"); for key in prefix { if let KeyOutcome::Local(commands) = resolve(&state, *key) { - commands.iter().for_each(|c| state.fold(c)); + state.fold_outgoing(&commands); } } let outcome = resolve(&state, *last); @@ -2520,7 +2520,7 @@ mod tests { let view = state.view.active_view.clone(); for key in &sequence { if let KeyOutcome::Local(commands) = resolve(&state, *key) { - commands.iter().for_each(|c| state.fold(c)); + state.fold_outgoing(&commands); } } let landed = state.view.cursor().selected; @@ -3448,9 +3448,7 @@ mod tests { let KeyOutcome::Local(commands) = resolve(client, ev(code)) else { panic!("{code:?} must resolve here"); }; - for command in &commands { - client.fold(command); - } + client.fold_outgoing(&commands); commands } @@ -3497,6 +3495,27 @@ mod tests { ); } + /// A frame this client composed goes through `fold_outgoing`, so a commit + /// waits for the host (improvise-aopa). See `context/test-evidence/clients.md`. + #[test] + fn a_folded_browser_commit_waits_for_the_host() { + let mut client = client_on_row_one("7"); + press_folded(&mut client, KeyCode::Char('i')); + press_folded(&mut client, KeyCode::Char('9')); + assert!( + client.in_flight.is_empty(), + "no Model yet, so nothing waits" + ); + let commit = press_folded(&mut client, KeyCode::Enter); + assert!( + commit.iter().any(|c| matches!(c, Command::Model { .. })), + "a typed commit writes: {commit:?}" + ); + assert_eq!(client.in_flight.len(), 1, "the commit is unanswered"); + assert!(client.in_flight[0].awaited); + assert_eq!(client.in_flight[0].cmds, commit); + } + /// improvise-465t: the editor an advance reopens starts untouched, even /// after a typed commit. See `context/test-evidence/clients.md`. #[test] @@ -3608,9 +3627,7 @@ mod tests { &["editing".into()], ) .expect("enter-mode editing composes here"); - for command in &entered { - client.fold(command); - } + client.fold_outgoing(&entered); assert!( client.view.mode.is_editing(), "enter-mode did not open an editor" @@ -3726,9 +3743,7 @@ mod tests { let KeyOutcome::Local(typed) = resolve(&client, ev(KeyCode::Char('5'))) else { panic!("a character in Editing is append-char"); }; - for command in &typed { - client.fold(command); - } + client.fold_outgoing(&typed); assert!( matches!( committed(&client), @@ -3824,9 +3839,7 @@ mod tests { let KeyOutcome::Local(commands) = resolve(state, prefix) else { panic!("{prefix:?} is a prefix; arming it must resolve locally"); }; - for command in &commands { - state.fold(command); - } + state.fold_outgoing(&commands); assert_eq!( state.view.transient_prefix, Some((prefix.code, prefix.modifiers)), @@ -3870,9 +3883,7 @@ mod tests { "the cell under the cursor reads 1200: {yank:?}" ); - for command in &yank { - state.fold(command); - } + state.fold_outgoing(&yank); assert_eq!( state.view.transient_prefix, None, "and the y prefix is spent" diff --git a/crates/improvise-wasm-client/Cargo.toml b/crates/improvise-wasm-client/Cargo.toml index 1abfbb14..34738d92 100644 --- a/crates/improvise-wasm-client/Cargo.toml +++ b/crates/improvise-wasm-client/Cargo.toml @@ -40,6 +40,9 @@ wasm-bindgen = "0.2" [dev-dependencies] improvise-command = { path = "../improvise-command" } improvise-core = { path = "../improvise-core" } +# The tests build `FormulaEntry` from `&str`, which only `test-support` provides. +# Asked for here, or `-p improvise-wasm-client` alone does not compile (a61f). +improvise-protocol = { path = "../improvise-protocol", features = ["test-support"] } improvise-ws-server = { path = "../improvise-ws-server" } # The `wasm-opt` wasm-pack downloads predates bulk-memory being enabled by From 0c3ec183d38a45bbe5200fbd36283f335ab87295 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:49:05 -0700 Subject: [PATCH 069/114] Pin the column toggle's cursor-follow; a group lookup stops at its row (improvise-pb30, improvise-0r01) pb30: gz puts the cursor on the collapsed group through landing and place_cursor, and their column arms were untested. Three rivals passed all 1948 tests: the arm swapped, landing returning None for columns, and landing reading col_tuple(0). A new test uses two column levels (Region over Month) with the cursor at (1, 7), South/May. It requires the cursor to land on South's Q2 at (1, 5). Each rival fails it and nothing else. The col_tuple(0) rival needs the two column levels, since with one level the prefix is empty everywhere. 0r01 (Codex's P2 on #263): innermost_group built open_runs for the whole axis, cloning a level stack per row, to answer for one row on every z. open_runs is now a lazy iterator, still the one reader, and the lookup takes .nth(idx). a_lookup_does_not_read_past_its_row gives it an unreadable header after row 0 (level usize::MAX). The eager rival panics with capacity overflow. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/core.md | 5 +- context/test-evidence/command.md | 21 +++++++ context/test-evidence/core.md | 15 +++++ .../improvise-command/src/command/cmd/grid.rs | 59 +++++++++++++++++++ crates/improvise-core/src/view/layout.rs | 57 ++++++++++++++---- 5 files changed, 143 insertions(+), 14 deletions(-) diff --git a/context/code/core.md b/context/code/core.md index 322d4dc2..3eddfef7 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -302,7 +302,10 @@ previous survivor, pruned against unpruned. It runs over five fixtures, including a non-contiguous group, a level with no groups, and two adjacent outer groups. Without the non-contiguous fixture, a pruner that keyed runs by name instead of header position passed every test. `open_runs` is the one -reading of the markers: `innermost_group` and the tests both use it. +reading of the markers: `innermost_group` and the tests both use it. It is a +lazy iterator, so a single lookup (`z`, which asks about one row) walks the axis +only up to that row, rather than building and cloning a stack for every row of +it (improvise-0r01). A header's level also names its category (`cats[level]`) on the wire, where `FrameGroup` carries `level` and not the category (improvise-7xyx removed that diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index d9c1cea6..4c3446ce 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6260,3 +6260,24 @@ ticket was written at `a4910eb4`, when every row test sat at `(0, 0)`. Since then, the grid tests seat the cursor on `Apr` (row 2). Injecting `row_group_for(0)` fails five of them, plus `z_on_a_row_past_an_inner_run_folds_the_outer_group` from improvise-e265. + +### a_column_collapse_lands_on_the_cursors_own_run_and_keeps_its_row + +improvise-pb30. `gz` (`ToggleGroupAtCursor { is_row: false }`) puts the cursor +on the collapsed group afterwards, through `landing` and `place_cursor`. The +column arm of both was untested: three rivals passed all 1948 tests. The +fixture puts two column levels (`Region` over `Month`) and a two-item row +category in place. The cursor sits at `(1, 7)`, which is South/May, the tail of +South's `Q2` run, and off the origin on both axes. The test asserts: +- the collapsed column frame; +- that the cursor lands on `(1, 5)`, South's `Q2`, on the same row. + +Rivals, each injected and run over the whole workspace. Each fails this test +and nothing else: +- **`place_cursor`'s column arm swapped** (`(at, ctx.selected.0)`): the cursor + lands transposed. +- **`landing` returning `None` for columns**: the cursor is left where the + toggle's reframe put it. +- **`landing` reading `col_tuple(0)`**: the prefix is North's, so the cursor + lands on North's `Q2`. This one needs the two column levels. With one level, + the prefix is empty and every column reads the same. diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index a291fd37..ef3fe231 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1604,3 +1604,18 @@ folded: Failed at base 202e7eac on the second assertion: the flat reading said `None`. The flat-membership rival fails it again. + +### a_lookup_does_not_read_past_its_row + +improvise-0r01, which is ChatGPT Codex's P2 on #263. `innermost_group` built +`open_runs` for the whole axis, cloning a level stack per row, to answer for one +row on every `z` keypress. `open_runs` is now lazy and the lookup takes +`.nth(idx)`. + +The walk's extent cannot be seen through a well-formed axis, so the test builds +an axis whose second header is unreadable on purpose: `level: usize::MAX`, which +`Vec::resize` cannot allocate. A lookup for row 0 must answer `Upper` without +reaching it. + +Rival: collecting `open_runs` before `.nth(idx)`, which is the eager shape. It +fails with `capacity overflow`, and the other 335 core tests pass. diff --git a/crates/improvise-command/src/command/cmd/grid.rs b/crates/improvise-command/src/command/cmd/grid.rs index 3b142c6b..265cfa8c 100644 --- a/crates/improvise-command/src/command/cmd/grid.rs +++ b/crates/improvise-command/src/command/cmd/grid.rs @@ -212,6 +212,65 @@ mod tests { ); } + /// The column twin of the row landing tests: two column levels, the cursor off + /// both origins (improvise-pb30). Evidence: `context/test-evidence/command.md`. + #[test] + fn a_column_collapse_lands_on_the_cursors_own_run_and_keeps_its_row() { + use crate::command::key::{KeyCode, KeyModifiers}; + use crate::ui::app::App; + use crate::view::Axis; + + let mut wb = crate::workbook::Workbook::new("T"); + wb.add_category("Region").unwrap(); + wb.add_category("Month").unwrap(); + wb.add_category("Type").unwrap(); + wb.add_item("Region", "North").unwrap(); + wb.add_item("Region", "South").unwrap(); + for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { + wb.add_item_in_group("Month", month, quarter).unwrap(); + } + wb.add_item("Type", "Food").unwrap(); + wb.add_item("Type", "Tools").unwrap(); + wb.active_view_mut().set_axis("Region", Axis::Column); + wb.active_view_mut().set_axis("Month", Axis::Column); + wb.active_view_mut().set_axis("Type", Axis::Row); + let mut app = App::new(wb, None); + app.rebuild_layout(); + assert_eq!(app.layout.col_cats, ["Region", "Month"], "Month is inner"); + app.cursor_mut().selected = (1, 7); + assert_eq!( + app.layout.col_tuple(7), + Some(&["South".into(), "May".into()][..]), + "the cursor sits on South/May, the tail of South's Q2 run" + ); + + let effects = { + let ctx = app.cmd_context(KeyCode::Char('z'), KeyModifiers::NONE); + ToggleGroupAtCursor { is_row: false }.execute(&ctx) + }; + app.apply_effects(effects); + + let cols: Vec = (0..app.layout.col_count()) + .map(|c| app.layout.col_label(c)) + .collect(); + assert_eq!( + cols, + [ + "North/Jan", + "North/Feb", + "North/Q2", + "South/Jan", + "South/Feb", + "South/Q2" + ] + ); + assert_eq!( + app.cursor().selected, + (1, 5), + "South's Q2 column, on the row the cursor was on" + ); + } + /// Evidence: `context/test-evidence/command.md`. #[test] fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() { diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index 92034329..6adc94f9 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -349,29 +349,35 @@ fn remark(input: &OpenRuns, output: &mut Vec>, kept: &mut Vec Vec>> { +/// opened each: the one reading of the markers, lazy so a lookup stops (0r01). +fn open_runs(items: &[AxisEntry]) -> impl Iterator>> + '_ { let mut open: Vec> = Vec::new(); - let mut out = Vec::new(); - for (at, entry) in items.iter().enumerate() { - match entry { + items + .iter() + .enumerate() + .filter_map(move |(at, entry)| match entry { AxisEntry::GroupHeader { level, .. } => { open.truncate(*level); open.resize(*level, None); open.push(Some(at)); + None } - AxisEntry::GroupEnd { level } => open.truncate(*level), - AxisEntry::DataItem { .. } => out.push(open.clone()), - } - } - out + AxisEntry::GroupEnd { level } => { + open.truncate(*level); + None + } + AxisEntry::DataItem { .. } => Some(open.clone()), + }) } /// The innermost run open at the data item `idx` — the group nearest it, which /// is what `z` toggles (improvise-e265's ruling). fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> { - let runs = open_runs(items); - let at = runs.get(idx)?.iter().rev().find_map(|run| *run)?; + let at = open_runs(items) + .nth(idx)? + .into_iter() + .rev() + .find_map(|run| run)?; match &items[at] { AxisEntry::GroupHeader { cat_name, @@ -2228,7 +2234,8 @@ mod group_membership { let kept: Vec = (0..data_items(full).count()) .filter(|i| mask & (1 << i) != 0) .collect(); - let (full_runs, pruned_runs) = (open_runs(full), open_runs(pruned)); + let full_runs: Vec<_> = open_runs(full).collect(); + let pruned_runs: Vec<_> = open_runs(pruned).collect(); assert_eq!(pruned_runs.len(), kept.len(), "{name}: the survivors"); if kept.len() == full_runs.len() { assert_eq!(pruned, full, "{name}: nothing pruned"); @@ -2301,6 +2308,30 @@ mod group_membership { assert_eq!(l.row_group_for(0), Some(("Region".into(), "Upper".into()))); } + /// A lookup reads only up to its own row (improvise-0r01). The header after + /// row 0 is unreadable on purpose: reaching it would try to allocate usize::MAX. + #[test] + fn a_lookup_does_not_read_past_its_row() { + let items = vec![ + AxisEntry::GroupHeader { + level: 0, + cat_name: "Region".into(), + group_name: "Upper".into(), + }, + AxisEntry::item(vec!["North".into()]), + AxisEntry::GroupHeader { + level: usize::MAX, + cat_name: "Nowhere".into(), + group_name: "Unreachable".into(), + }, + AxisEntry::item(vec!["South".into()]), + ]; + assert_eq!( + super::innermost_group(&items, 0), + Some(("Region".into(), "Upper".into())) + ); + } + /// The user's ruling on improvise-e265: membership is per level, and `z` /// names the innermost open group — the one nearest the cursor. #[test] From 7bf8debd12c7e3f346238bd0dce64b940e841873 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:49:43 -0700 Subject: [PATCH 070/114] The keyboard editor commits to the cell it opened on (improvise-8xq0, improvise-xz2z) resolve.rs::written read its operands at Enter: the record and measure from the cell under the cursor, and the basis from claim(), i.e. the version current at commit. So a HideItem from another client that renumbered the rows under an open editor sent the write to whichever cell the cursor then named (8xq0), and a write by another client to the record while the editor was open was silently overwritten instead of refused RecordMoved (xz2z, 0j0f's rule on the keyboard door). The fix reuses #221's capture rather than adding a second one. ClientState::opened becomes OpenedCell { record, target, version, document_generation }, where OpenedTarget is Value { measure } or Coordinate { category }. settle_opened captures a Record cell as a Value target, and written builds the RecordCell and the claim_as_read basis from it. With no capture while the host has yet to answer this client's own last write, the commit declines with the rename's "still waiting" status. Red first: both round-trip probes failed on gh/main 202e7eac and on #221's head 39821d5f. Unit-test fixtures that set Editing by assignment now open the editor through the fold, which is where the capture is taken. Stacked on #221 (fix/fdk1-coordinate-rename). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 35 +++- context/code/protocol.md | 10 +- context/test-evidence/clients.md | 71 ++++++++- crates/improvise-protocol/src/client.rs | 37 +++-- crates/improvise-protocol/src/resolve.rs | 149 ++++++++++++------ .../tests/browser_round_trip.rs | 148 +++++++++++++++++ .../improvise-wasm-client/tests/lockstep.rs | 8 +- 7 files changed, 391 insertions(+), 67 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 12bd469d..d1ff7085 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -2851,9 +2851,11 @@ they answer different questions: text it opened on, and whether the user has changed it**. It is portable state. It is composed optimistically at the keypress and folded the same way on the host, where `CommitAndAdvance` reads it. -- `ClientState::opened` (`OpenedCoordinate { record, category, version, document_generation }`) - answers **which record the rename names, and at which version the basis - claims it**. It is session-local and never serialized. +- `ClientState::opened` (`OpenedCell { record, target, version, document_generation }`) + answers **which cell the commit writes, and at which version the basis + claims it**. `target` is `Coordinate { category }` for a rename and + `Value { measure }` for a stored value. It is session-local and never + serialized. Neither answers "is an editor open": the mode does, for both. `opened_editor` is not cleared when the editor closes, only replaced when the next one opens. @@ -2875,3 +2877,30 @@ editor reopens with the typed text. `settle_opened` then captures the record at its **current** version, so a second `Enter` succeeds. That is the one-keystroke force `RecordMoved` promises (gotcha 43). The user has just been told that someone else changed the row. + +**A value editor commits to the cell it opened on, too (improvise-8xq0).** The +keyboard commit over a plain grid cell used to read the cursor at `Enter`: the +record from `record_at_cursor`, the measure from the `CellId` under it, and the +basis from `claim`, which reads the version as it is *now*. Another client's +`HideItem` renumbers the rows under an open editor, the cursor then names a +different cell, and the write landed there with the text typed for the first. +The inline `EditTarget` fixed this for the mouse editor (improvise-2wu3), but it +lives in the page, so the keyboard path never saw it. The fix reuses the rename +capture rather than adding a second one: `settle_opened` captures a `Record` +cell as `OpenedTarget::Value`, and `written` builds the `RecordCell` and the +`claim_as_read` basis from it. Nothing about the write is read at `Enter` except +the text. Whether the commit is a rename, a creating column or a value is still +decided by the cell under the cursor, as it was. + +Two things come with it. **The version is the one read at open**, so a write by +another client while the editor is open is refused `RecordMoved` on this path +as well: improvise-0j0f's rule, which the keyboard path had not had +(improvise-xz2z). And **a +value commit can now wait**: the capture waits for the host's answer to this +client's own last write, so `Enter` pressed again inside one round trip has no +target yet. It declines with the rename's "still waiting" status and writes +nothing. With no write in flight and no capture, the cursor is on no cell an +editor writes, and the commit answers `None` as before. The waiting decline is +still followed by the `Enter` binding's welded `clear-buffer`, so the typed +text is lost on that path, for a rename as for a value (improvise-joed). + diff --git a/context/code/protocol.md b/context/code/protocol.md index 10d6af24..f2975dd2 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -622,8 +622,8 @@ it, and the address it needs is the name. What was missing was therefore never an address but a **recognition**. `Create` carries the kind and nothing else, because the kind plus the typed name is the whole operand. It is not a `SetCell` target, so `render.rs::editable` stays -`Record`-only and `record_at_cursor` still answers `None` — both by matching, -which the compiler now forces. +`Record`-only and `opened_at_cursor` captures no editor target for it — both +by matching, which the compiler now forces. **No client composes it yet**, and that is one gap rather than this one: `keys.rs::commit` returns early for every records-mode commit, because a @@ -665,9 +665,9 @@ by surrogate and the new coordinate by value. `records_named` names the record, because a rename moves it (gotcha 43). Creating the item is internal to the effect. `render.rs::editable` still answers `false` for a coordinate: the page's inline editor writes stored cells only, and the rename's producer is the -keyboard path (`context/code/clients.md`). `typed_cell_command`, -`record_at_cursor` and `record_cell_at_cursor` still answer `None` for it, so -paste onto a coordinate is still improvise-kkcy. +keyboard path (`context/code/clients.md`). `typed_cell_command` and +`record_cell_at_cursor` still answer `None` for it, so paste onto a coordinate +is still improvise-kkcy. ### `CellDisplay::id` — `None` is not "aggregate" diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 96102811..7183c150 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5235,7 +5235,7 @@ still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumbe Added when #221 merged #219 (improvise-vndi). vndi changed `claim_as_read` to take the document generation captured when an editor opened, so an inline edit opened before a `LoadModel` is refused `DocumentReplaced` rather than written -into the new document. The rename capture (`OpenedCoordinate`) now records +into the new document. The rename capture (`OpenedCoordinate`, now `OpenedCell`) records `document_generation` at open for the same reason, and the rename claims it. **No rival of that choice is observable, and this test says why.** B loads a @@ -5261,3 +5261,72 @@ sits on row 2, which in the answered grid is Zed, the record the first rename produced. The test's principle is unchanged: the second rename moves the row the client shows, and that row differs from the stale capture (West). Only its value table assumed Zed could not come up, so Zed was added to it. + +### a_keyboard_edit_writes_the_cell_it_opened_on_after_the_rows_renumber + +improvise-8xq0, the ticket's probe, through a real `Host` with two clients. A +presses `l`, `i` on North/Cost at (0,1), and types `42`. B hides North, and A +folds the relayed frames, so the rows renumber under the open editor. A then +presses `Enter`. `resolve.rs::commit` composed `SetCell` from the cell under +the cursor **at Enter**. The cursor had been reset to the origin, which now +held South/Revenue, so South/Revenue became 42 and the host accepted it. That +failed at the `South was written` assertion, on `gh/main` at `202e7eac` and on +#221's head `39821d5f` alike, so #221's coordinate capture did not reach this +path. + +The editor opens on North/**Cost**, not the first column, so the cell at Enter +differs from the opened one in both record and measure. A fix that kept the +record and read the measure at Enter would still write the wrong column. + +- **Rival: `written` takes the whole cell from the cursor at Enter** (the bug). + Fails only this test, at `South was written`. +- **Rival: the record from the capture, the measure from the cursor.** Fails + only this test, at `the edit must land on the cell the editor opened on`: + North/Cost stays empty. +- **Rival: the basis claimed at commit (`claim`) instead of as read.** Fails + this test too: after the hide, A's basis no longer names North's record, so + `claim` leaves it out and the host answers `RecordUnstated`. + +### a_keyboard_edit_is_refused_when_its_record_was_written_while_open + +improvise-xz2z: improvise-0j0f's rule on the keyboard path, which it had not +had. xz2z was filed separately (by agent-227-fixes, scoping improvise-3al1) and +has the same cause as 8xq0: `written` read its operands at `Enter`. Same setup, +but B writes 999 into North/Revenue while A's editor is open on North/Cost. A +record is the coordinate tuple minus `_Measure` (gotcha 40), so that write +moves the version of the record A is editing. The keyboard commit claimed the +version current at Enter, which A had already folded, so the host accepted it. +It failed at the `RecordMoved` assertion, on `gh/main` and on #221's head. + +- **Rival: the basis claimed at commit instead of as read.** Fails this test + (and the renumber test, above). + +### a_second_keyboard_commit_writes_the_cell_the_advance_landed_on + +A regression guard for the capture's lifetime, which the fix made +load-bearing for value cells. `i`, `5`, `Enter` on North/Revenue writes 12005, +advances to South/Revenue and reopens the editor there; `7`, `Enter` must write +South/Revenue. The capture is dropped when the commit's `ChangeMode(Normal)` +folds and retaken for the reopened editor once the host answers. + +- **Rival: `settle_opened` keeps a capture after the editor closes.** Fails + this test, the second commit writing North again. Before this test only #221's + two coordinate tests saw that rival + (`a_second_rename_after_a_reorder_renames_the_row_the_host_shows` and + `a_document_swap_closes_an_open_rename_editor`); nothing on a value cell did. + +### a_value_commit_before_the_host_answers_the_last_one_waits_out_loud + +With the value target captured only after the host answers this client's own +last write, `Enter` pressed again before that answer has no target. The test +commits once, folds the frame as sent and not yet answered, and presses +`Enter` again in the reopened editor. The fixture's second row is given a +`Record` identity, or `commit` stops at the cursor's cell before it reaches the +capture and the test would pass for the wrong reason. + +- **Rival: no waiting decline (`None`, as for a cell no editor writes).** Fails + only this test: the key goes upstream, which in a browser does nothing. + +The test asserts the status and that nothing is written, not the exact +command list: the `Enter` binding's welded `clear-buffer` still runs after the +decline and empties the buffer. That loss is improvise-joed. diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index df670a92..8a4b6502 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -44,23 +44,32 @@ pub struct ClientState { /// replay the rest. Why a queue: `context/code/clients.md`. #[serde(skip)] pub in_flight: std::collections::VecDeque, - /// The coordinate an open editor renames, captured as the user saw it. + /// The cell an open editor commits to, captured as the user saw it. /// When it is taken, and when not yet: `context/code/clients.md`. #[serde(skip)] - pub opened: Option, + pub opened: Option, } -/// A coordinate editor's target: the record, the category, and the record's -/// write counter when the editor opened (improvise-fdk1, improvise-0j0f). +/// A cell editor's target: the record, which cell of it, and the record's write +/// counter when the editor opened (improvise-fdk1, improvise-0j0f, improvise-8xq0). #[derive(Debug, Clone, PartialEq, Eq)] -pub struct OpenedCoordinate { +pub struct OpenedCell { pub record: RecordHash, - pub category: String, + pub target: OpenedTarget, pub version: u64, /// The document the editor opened on, so a swap refuses it (improvise-vndi). pub document_generation: u64, } +/// Which cell of the record an editor opened on, as the projection named it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenedTarget { + /// A stored value: `SetCell`/`ClearCell` names it (improvise-8xq0). + Value { measure: Option }, + /// A records-mode coordinate: `RenameRecordCoordinate` names it. + Coordinate { category: String }, +} + /// One frame this client has sent. `awaited` is true exactly when the host /// will answer it, which is exactly when it can be refused — `clients.md`. #[derive(Debug, Clone, PartialEq)] @@ -181,19 +190,25 @@ impl ClientState { self.view.mode = settled_mode(&self.view.mode, self.grid.records); } - /// Capture the coordinate an open editor renames, but only from a cache that + /// Whether a frame the host will answer is still in flight, so this + /// client's cache does not yet show the host's answer to its own writes. + pub(crate) fn awaiting_answer(&self) -> bool { + self.in_flight.iter().any(|sent| sent.awaited) + } + + /// Capture the cell an open editor commits to, but only from a cache that /// shows the host's answer to this client's own writes (improvise-fdk1). fn settle_opened(&mut self) { - let answered = !self.in_flight.iter().any(|sent| sent.awaited); + let answered = !self.awaiting_answer(); self.opened = match (self.view.mode.is_editing(), self.opened.take()) { (false, _) => None, (true, Some(opened)) => Some(opened), (true, None) if answered => { - crate::resolve::coordinate_at_cursor(self).and_then(|(record, category)| { + crate::resolve::opened_at_cursor(self).and_then(|(record, target)| { let basis = self.basis.current()?; - Some(OpenedCoordinate { + Some(OpenedCell { record, - category, + target, version: basis.record(record)?, document_generation: basis.document_generation, }) diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index cdb2c78c..cd9e833c 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -4,7 +4,7 @@ use improvise_core::view::Axis; use serde::{Deserialize, Serialize}; -use crate::client::ClientState; +use crate::client::{ClientState, OpenedTarget}; use crate::frame::ClientFrame; use crate::keymap::{Binding, Keymaps, ModeKey}; use crate::viewmodel::{CatTreeEntry, axis_change_status, flatten_cat_tree}; @@ -800,7 +800,7 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { /// The record and category of the coordinate cell under the cursor, as the /// projection names them — never borrowed from a sibling cell (ADR 002). -pub(crate) fn coordinate_at_cursor(state: &ClientState) -> Option<(crate::RecordHash, String)> { +fn coordinate_at_cursor(state: &ClientState) -> Option<(crate::RecordHash, String)> { match cell_at_cursor(state)?.id.as_ref()? { CellId::Coordinate { record, category } => Some((*record, category.clone())), CellId::Record(_) | CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => { @@ -809,6 +809,26 @@ pub(crate) fn coordinate_at_cursor(state: &ClientState) -> Option<(crate::Record } } +/// The record and cell an editor opened on the cursor commits to, as the +/// projection names them (improvise-8xq0). `None` for a cell no editor writes. +pub(crate) fn opened_at_cursor(state: &ClientState) -> Option<(crate::RecordHash, OpenedTarget)> { + match cell_at_cursor(state)?.id.as_ref()? { + CellId::Record(cell) => Some(( + cell.record, + OpenedTarget::Value { + measure: cell.measure.clone(), + }, + )), + CellId::Coordinate { record, category } => Some(( + *record, + OpenedTarget::Coordinate { + category: category.clone(), + }, + )), + CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None, + } +} + /// Open the cell editor on `seed`: the buffer, what it captured, then the mode. /// `EditOrDrill`'s and `EnterEditAtCursor`'s shared shape (improvise-ixxx). fn opened_on(seed: String, mode: AppMode) -> Vec { @@ -928,7 +948,9 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option vec![Command::SetStatus( RECORD_COORDS_CANNOT_BE_EMPTY.to_string(), )], - (false, false, None) => { - return Some(vec![Command::SetStatus( - "still waiting for the host to answer your last edit".to_string(), - )]); + (false, false, None) => return Some(still_waiting()), + (false, false, Some(opened)) => { + let OpenedTarget::Coordinate { category } = &opened.target else { + return Some(still_waiting()); + }; + vec![Command::Model { + basis: state.basis.claim_as_read( + opened.document_generation, + [(opened.record, opened.version)], + )?, + cmd: ModelCommand::RenameRecordCoordinate { + record: opened.record, + category: category.clone(), + item: text.to_string(), + }, + }] } - (false, false, Some(opened)) => vec![Command::Model { - basis: state.basis.claim_as_read( - opened.document_generation, - [(opened.record, opened.version)], - )?, - cmd: ModelCommand::RenameRecordCoordinate { - record: opened.record, - category: opened.category.clone(), - item: text.to_string(), - }, - }], }; commands.push(Command::ChangeMode(AppMode::Normal)); commands.extend(advanced(state, edit_mode, advance)?); Some(commands) } -/// The write a commit makes: none when the buffer is the seed the editor -/// captured as it opened, and only the write is skipped (improvise-ixxx). -fn written(state: &ClientState, id: &CellId, text: &str) -> Option> { - match untouched(state.view.opened_editor.as_ref(), text) { - true => Some(Vec::new()), - false => Some(vec![Command::Model { - basis: state.basis.claim([record_at_cursor(state)?])?, - cmd: typed_cell_command(id, text)?, - }]), +/// The write a commit makes: none when untouched (improvise-ixxx), else to the +/// cell the editor OPENED on, at the version it read then (improvise-8xq0). +fn written(state: &ClientState, text: &str) -> Option> { + if untouched(state.view.opened_editor.as_ref(), text) { + return Some(Vec::new()); } + let opened = state.opened.as_ref()?; + let OpenedTarget::Value { measure } = &opened.target else { + return None; + }; + let cell = RecordCell { + record: opened.record, + measure: measure.clone(), + }; + Some(vec![Command::Model { + basis: state.basis.claim_as_read( + opened.document_generation, + [(opened.record, opened.version)], + )?, + cmd: typed_cell_command(&CellId::Record(cell), text)?, + }]) +} + +/// A commit whose target is not captured yet, because the host has not answered +/// this client's own last write. The editor stays open (improvise-fdk1). +fn still_waiting() -> Vec { + vec![Command::SetStatus( + "still waiting for the host to answer your last edit".to_string(), + )] } /// `CommitAndAdvance` over a creating column: a schema change over the typed @@ -1054,18 +1096,6 @@ fn advanced(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option Some(commands) } -/// The surrogate naming the record under the cursor, or `None` when the -/// cursor is not on a writable cell. -fn record_at_cursor(state: &ClientState) -> Option { - match cell_at_cursor(state)?.id.as_ref()? { - CellId::Record(cell) => Some(cell.record), - CellId::Aggregate(_) - | CellId::Derived { .. } - | CellId::Create(_) - | CellId::Coordinate { .. } => None, - } -} - /// What an editor opened on the cursor starts with, or `None` with no cell /// there to seed from. Read by the fold, never a renderer (improvise-ktlk). pub(crate) fn edit_seed(state: &ClientState) -> Option { @@ -2265,7 +2295,7 @@ mod tests { // A records MODE over a non-records GRID is a state no host can // produce, so the fixture projects both (improvise-e5pf). crate::WidgetCache::apply(&mut state.grid, &GridUpdate::Records(mode.is_records())); - state.view.mode = mode; + state.fold(&Command::ChangeMode(mode)); state.grid.page_coords = vec![crate::PageAxis { cat: "Quarter".into(), selected: "Q2".into(), @@ -2978,7 +3008,7 @@ mod tests { // Half two: the predicate is about *finishing*, so something has to // press Enter and observe. let mut client = client_on(record_cell()); - client.view.mode = AppMode::editing(); + client.fold(&Command::ChangeMode(AppMode::editing())); client.view.buffers.insert("edit".into(), "42".into()); let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else { panic!("Enter in Editing must resolve here — that is the arm"); @@ -3432,12 +3462,43 @@ mod tests { ); } + /// improvise-8xq0: a commit whose target is not captured yet, because the + /// last write is unanswered, declines out loud. Evidence file. + #[test] + fn a_value_commit_before_the_host_answers_the_last_one_waits_out_loud() { + let mut client = client_on(record_cell()); + // The row the advance lands on is a stored cell too, so the commit gets + // past the cursor's cell and reaches the capture. + let mut below = CellDisplay::number("900"); + below.id = Some(record_cell()); + client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells( + vec![((1, 0), below)], + )))); + client.fold(&Command::ChangeMode(AppMode::editing())); + client.view.buffers.insert("edit".into(), "42".into()); + let KeyOutcome::Local(first) = resolve(&client, ev(KeyCode::Enter)) else { + panic!("Enter in Editing must resolve here"); + }; + client.fold_outgoing(&first); + assert!(client.view.mode.is_editing(), "setup: the advance reopened"); + assert_eq!(client.opened, None, "setup: nothing captured yet"); + client.view.buffers.insert("edit".into(), "43".into()); + let KeyOutcome::Local(second) = resolve(&client, ev(KeyCode::Enter)) else { + panic!("a commit with no target yet must answer, not go upstream"); + }; + assert_eq!(second[..1], still_waiting()[..], "it says so: {second:?}"); + assert!( + !second.iter().any(|c| matches!(c, Command::Model { .. })), + "and it must write nothing: {second:?}" + ); + } + /// Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]` /// and every part shows up here — including the odd-looking trailing clear. #[test] fn enter_commits_the_cell_advances_and_re_enters_editing() { let mut client = client_on(record_cell()); - client.view.mode = AppMode::editing(); + client.fold(&Command::ChangeMode(AppMode::editing())); client.view.buffers.insert("edit".into(), "42".into()); let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else { panic!("Enter in Editing must resolve here"); @@ -3733,7 +3794,7 @@ mod tests { #[test] fn committing_an_empty_buffer_clears_the_cell() { let mut client = client_on(record_cell()); - client.view.mode = AppMode::editing(); + client.fold(&Command::ChangeMode(AppMode::editing())); client.view.buffers.insert("edit".into(), String::new()); let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else { panic!("Enter in Editing must resolve here"); @@ -3814,7 +3875,7 @@ mod tests { ], }); client.window = (1, 1); - client.view.mode = AppMode::editing(); + client.fold(&Command::ChangeMode(AppMode::editing())); client.view.buffers.insert("edit".into(), "7".into()); let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Enter)) else { diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 279cb473..eb6f5127 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1311,3 +1311,151 @@ fn a_second_rename_after_a_reorder_renames_the_row_the_host_shows() { "West untouched" ); } + +// ── the keyboard editor's target (improvise-8xq0) ──────────────────────── + +/// What client B does while A's keyboard editor is open on North/Cost. +#[derive(PartialEq)] +enum Meanwhile { + HideNorth, + WriteNorth, +} + +/// Client A presses `i` on North/Cost (0,1) and types `42`; B does `meanwhile` +/// before A presses Enter. Evidence file. +fn commit_north_cost(meanwhile: Meanwhile) -> (Host, Vec) { + use improvise_command::ui::reduce::caught_up; + use improvise_protocol::ModelCommand; + + let mut host = host(); + let (mut a, mut b) = (Client::new(), Client::new()); + let (a_id, a_snap) = host.connect(10, 10); + let (b_id, b_snap) = host.connect(10, 10); + a.set_viewport(10, 10); + b.set_viewport(10, 10); + deliver(&mut a, &[a_snap]); + deliver(&mut b, &[b_snap]); + for key in ["l", "i", "4", "2"] { + press(&mut host, a_id, &mut a, key); + } + assert_eq!( + a.state().view.cursor().selected, + (0, 1), + "setup: on North/Cost" + ); + assert!( + a.state().view.mode.is_editing(), + "setup: the editor is open" + ); + + let view = host.app().view_name().to_string(); + let cmd = match meanwhile { + Meanwhile::HideNorth => ModelCommand::HideItem { + view, + category: "Region".into(), + item: "North".into(), + }, + Meanwhile::WriteNorth => ModelCommand::SetCell { + cell: a.record_cell_at(0, 0).expect("North/Revenue is stored"), + value: CellValue::Number(999.0), + }, + }; + let relayed = host.handle(b_id, ClientFrame::command(caught_up(host.app(), cmd))); + route(&relayed, (a_id, &mut a), (b_id, &mut b)); + assert!(a.state().view.mode.is_editing(), "setup: still editing"); + if meanwhile == Meanwhile::HideNorth { + assert_eq!( + a.state().grid.row_frame.leaf_labels().first().cloned(), + Some("South".to_string()), + "setup: the rows renumbered under the open editor" + ); + } + + let frame = a + .key(&BrowserKey::new("Enter")) + .expect("a commit composes a frame"); + let json = serde_json::to_string(&frame).expect("a client frame serializes"); + let answers = send(&mut host, a_id, &json); + (host, answers) +} + +fn cell(host: &Host, region: &str, measure: &str) -> Option { + host.app() + .model_state + .workbook + .model + .get_cell(&CellKey::new(vec![ + ("Region".into(), region.into()), + ("_Measure".into(), measure.into()), + ])) + .cloned() +} + +/// improvise-8xq0: B hid the row under A's open editor, and Enter wrote the +/// cell under the cursor at Enter. Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_keyboard_edit_writes_the_cell_it_opened_on_after_the_rows_renumber() { + let (host, answers) = commit_north_cost(Meanwhile::HideNorth); + assert!( + answers + .iter() + .any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + "the write must be accepted: {answers:?}" + ); + assert_eq!(cell(&host, "South", "Revenue"), None, "South was written"); + assert_eq!(cell(&host, "South", "Cost"), None, "South was written"); + assert_eq!( + cell(&host, "North", "Cost"), + Some(CellValue::Number(42.0)), + "the edit must land on the cell the editor opened on" + ); +} + +/// improvise-8xq0: the editor an advance reopens commits to the cell it landed +/// on, not the one the last commit captured. Evidence file. +#[test] +fn a_second_keyboard_commit_writes_the_cell_the_advance_landed_on() { + let mut host = host(); + let mut a = Client::new(); + a.set_viewport(10, 10); + let (id, snapshot) = host.connect(10, 10); + deliver(&mut a, &[snapshot]); + for key in ["i", "5", "Enter"] { + press(&mut host, id, &mut a, key); + } + assert_eq!(a.state().view.cursor().selected, (1, 0), "setup: advanced"); + assert!(a.state().view.mode.is_editing(), "setup: reopened"); + for key in ["7", "Enter"] { + press(&mut host, id, &mut a, key); + } + assert_eq!( + cell(&host, "North", "Revenue"), + Some(CellValue::Number(12005.0)) + ); + assert_eq!( + cell(&host, "South", "Revenue"), + Some(CellValue::Number(7.0)), + "the second commit must write the cell the advance landed on" + ); +} + +/// improvise-xz2z, 0j0f's rule on the keyboard path: the basis is the one read +/// when the editor opened. Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_keyboard_edit_is_refused_when_its_record_was_written_while_open() { + let (host, answers) = commit_north_cost(Meanwhile::WriteNorth); + assert!( + answers.iter().any(|f| matches!( + f.msg, + improvise_protocol::ServerFrame::Rejected(improvise_protocol::Rejection::RecordMoved( + _ + )) + )), + "an edit composed against a record someone else wrote must be refused: {answers:?}" + ); + assert_eq!( + cell(&host, "North", "Revenue"), + Some(CellValue::Number(999.0)) + ); + assert_eq!(cell(&host, "North", "Cost"), None, "nothing was written"); +} diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index ee3bc6f2..d1a0e23e 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1610,13 +1610,15 @@ fn a_records_commit_is_declined_off_the_grid_and_not_off_the_mode() { // The control: the same mode over a REGULAR grid must still commit, or the // decline above is "this client never writes" and proves nothing. let plain = furnished_host(); - let mut ordinary = paged_client(&plain); - ordinary.view.mode = AppMode::editing(); + // Seeded as `records_client` seeds, basis included, over a regular grid; + // opened through the fold, which captures the cell the commit writes. + let mut ordinary = records_client(&plain); + ordinary.view.cursor_mut().selected = stored_cell_at(&ordinary); + ordinary.fold(&improvise_protocol::Command::ChangeMode(AppMode::editing())); ordinary .view .buffers .insert("edit".into(), "42".to_string()); - ordinary.view.cursor_mut().selected = stored_cell_at(&ordinary); assert!(!ordinary.grid.records, "the control must be a regular grid"); assert!( composes_a_write(&ordinary, enter), From 5036b4483a900c4a1cf300e0557e160f2bc36249 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 17:56:28 -0700 Subject: [PATCH 071/114] check-ci-graph: a comment on a line the gate reads is refused, and the stripper is gone (j49r) WIP; tooling.md and evidence follow. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- scripts/check-ci-graph.py | 59 ++++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index 58ddcc60..29a9bd5b 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -23,6 +23,7 @@ WORKFLOW = ".github/workflows/ci.yml" DEPLOY = "pages-deploy" _JOB = re.compile(r"^ ([A-Za-z0-9_-]+):$") +_JOB_OPEN = re.compile(r"^ ([A-Za-z0-9_-]+):(\s|$)") _NEEDS = re.compile(r"^ needs:\s*\[(.*)\]\s*$") @@ -31,7 +32,9 @@ def read_graph(text): Both come off indentation, which is what a workflow's own shape is. A job key is two spaces; `needs:` is four INSIDE the job it belongs to, so the - current job is tracked rather than the first `needs:` taken. + current job is tracked rather than the first `needs:` taken. A job opens on + `_JOB_OPEN`, wider than `_JOB`: this reader refuses nothing, so it reads wide + (improvise-j49r). """ jobs, needs, current = [], None, None in_jobs = False @@ -41,7 +44,7 @@ def read_graph(text): continue if not in_jobs: continue - m = _JOB.match(raw) + m = _JOB_OPEN.match(raw) if m: current = m.group(1) jobs.append(current) @@ -80,6 +83,7 @@ _NAME = r"[A-Za-z_][\w-]*" _TOP_KEY = re.compile(rf"^{_NAME}:(\s|$)") _NEEDS_VALUE = re.compile(rf"^(|{_NAME}|\[\s*{_NAME}(\s*,\s*{_NAME})*\s*\])$") _NEEDS_ITEM = re.compile(rf"^ - {_NAME}$") +_COMMENT = re.compile(r"\s#") def _readable(line, indent, in_steps, in_needs, job_keyed): @@ -99,8 +103,9 @@ def _readable(line, indent, in_steps, in_needs, job_keyed): def _inline_value(rest): - """A key's inline value, without a YAML comment (`#` after whitespace).""" - return re.sub(r"\s+#.*$", "", rest).strip() + """A key's inline value. No comment is stripped: a line carrying one is + refused, so every matcher reads the same string (improvise-j49r).""" + return rest.strip() def _opens_quote(value): @@ -148,13 +153,13 @@ def ci_steps(text): owner, owner_step = None, None if not raw.strip() or raw.lstrip().startswith("#"): continue - line = re.sub(r"\s+#.*$", "", raw) if indent == 0: - in_jobs = line.startswith("jobs:") - if not _TOP_KEY.match(line): - unread.append(n) - elif in_jobs and indent <= 8 and not _readable(line, indent, in_steps, in_needs, - job_keyed): + in_jobs = raw.startswith("jobs:") + readable = bool(_TOP_KEY.match(raw)) + else: + readable = not in_jobs or indent > 8 or _readable(raw, indent, in_steps, + in_needs, job_keyed) + if _COMMENT.search(raw) or not readable: unread.append(n) if raw.startswith("defaults:"): workflow_flags.add("workflow defaults") @@ -325,7 +330,11 @@ def _unreadable(name, text, *markers, rest=()): SUITE_SELF_TEST = [ ("two jobs each running their own row are accepted", _REQ, _wf(_BUILD, _job("kb", " - run: nix shell nixpkgs#trealla --command " - "./scripts/checkers.sh --ci kb # the kb row\n")), []), + "./scripts/checkers.sh --ci kb\n")), []), + + _unreadable("a trailing comment on the counted run: line is refused (j49r)", + _wf(_BUILD, _job("kb", _KB_RUN.rstrip("\n") + " # the kb row\n")), + "# the kb row", rest=[_stray(9)] + _NO_PW), ("a job that never calls its row leaves its suites unrun", _REQ, _wf(_BUILD, _job("kb", " - run: echo nothing\n")), _NO_PW), @@ -388,8 +397,8 @@ SUITE_SELF_TEST = [ ["job `kb`'s `checkers.sh --ci` step's run: continues onto another line, " "so it does not count"] + _NO_PW), - ("control: a padded flow list with a comment is still read (w0j4)", _REQ, - _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: [ gate ] # c\n")), + ("control: a padded flow list is still read (w0j4)", _REQ, + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: [ gate ]\n")), _NEEDS_GATE), ("control: a plain scalar needs: is still read (w0j4)", _REQ, @@ -527,6 +536,25 @@ SUITE_SELF_TEST = [ " run: ./scripts/checkers.sh --ci kb\n")), [_stray(9)]), + _unreadable("a job header with a trailing comment is refused (j49r)", + _wf(_BUILD, " test:\n runs-on: x\n", + " gate: # skipped on purpose\n if: false\n steps:\n - run: echo\n", + _job("kb", _KB_RUN, head=" needs: gate\n")), + "gate: # skipped"), + + _unreadable("a closed quote followed by a comment is refused (j49r)", + _wf(_BUILD, _job("kb", _KB_RUN, head=" env:\n A: \"x\" # note\n") + + " if: false\n", " after:\n steps:\n - run: echo \"z\"\n"), + '"x" # note', rest=[_stray(11)] + _NO_PW), + + _unreadable("a # inside a quoted value, below indent 8, is refused (j49r)", + _wf(_BUILD, _job("kb", _KB_RUN + " - uses: x\n with:\n" + " foo: \"x # y\"\n") + + " if: false\n", " after:\n steps:\n - run: echo \"z\"\n"), + '"x # y"', + rest=["job `kb`'s `checkers.sh --ci` step is conditional (job if), " + "so it does not count"] + _NO_PW), + ("a YAML comment mentioning checkers.sh is fine", _REQ, _wf(_BUILD, _job("kb", " # every suite goes through scripts/checkers.sh\n" " - run: ./scripts/checkers.sh --ci kb\n")), []), @@ -552,6 +580,11 @@ SELF_TEST = [ "jobs:\n test:\n x: 1\n pages-deploy:\n x: 1\n", [f"{DEPLOY} has no `needs:` list, so nothing gates the deploy"]), + ("a job header with a trailing comment is still a job (j49r)", + "jobs:\n test:\n x: 1\n gate: # skipped on purpose\n x: 1\n" + " pages-deploy:\n needs: [test]\n", + [f"{DEPLOY} does not need `gate`, so a failing gate cannot stop a deploy"]), + # `needs:` belongs to the job it is indented under. Taking the first one # seen would read ANOTHER job's list as the deploy's and pass. ("another job's needs is not mistaken for the deploy's", From cdefeeb9b85b8d7bd14beab928d9c2c6e9c311ff Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:01:08 -0700 Subject: [PATCH 072/114] Resolve Enter and H in the browser; decline / and n out loud improvise-fo0w. Three black-hole keys closed over vocabulary that already exists, each mirroring its Cmd line for line with a lockstep case: - enter-advance (Normal Enter) resolves as cursor arithmetic over GridExtent; the host's bottom-right AbortChain is Ignored here, which loses nothing because Enter is never bound inside a Sequence. - hide-selected-row-item (H) resolves to ModelCommand::HideItem over the outer row category and the selected tuple's first label, and refuses on a fold with the host's own FOLD_REFUSAL, now in improvise-protocol. - search (/) and search-navigate (n) decline out loud as a pair: n needs the whole model, and / alone would open a box that finds nothing. NEEDS_THE_HOST drops to exit-search-mode, force-quit and toggle-group-under-cursor. Tests that used Enter or "Start search" as their upstream example move to Ctrl-Q and "Exit search mode", including shell.spec.js's not-sent case. Also improvise-0629: the help render test now asserts every ex row is drawn dim, not just that its description is marked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 23 ++ context/test-evidence/clients.md | 109 +++++++++- .../improvise-command/src/command/cmd/core.rs | 2 +- crates/improvise-protocol/src/lib.rs | 4 + crates/improvise-protocol/src/resolve.rs | 75 ++++++- crates/improvise-wasm-client/src/lib.rs | 8 +- crates/improvise-wasm-client/src/render.rs | 3 +- .../improvise-wasm-client/tests/lockstep.rs | 203 +++++++++++++++++- harness/tests/shell.spec.js | 6 +- 9 files changed, 396 insertions(+), 37 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 146d0138..d2a7b1b1 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -584,6 +584,29 @@ key that reaches it. `Host::handle` answers a `Key` frame with an empty vec, so each one is a binding that **does nothing at all** in a browser (`doctrine(an_upstream_key_is_a_black_hole)`). +**improvise-fo0w took four names off it and left three** (`exit-search-mode`, +`force-quit`, `toggle-group-under-cursor`). Two of the four now resolve, and two +now decline. +- **`enter-advance` (Normal `Enter`) resolves.** It is cursor arithmetic over + `GridExtent`: down, then to the top of the next column. The host's + bottom-right `AbortChain` becomes `Ignored` on the client. Nothing is lost, + because `Enter` is bound alone and never inside a `Sequence`, so there is no + later step for the abort to cut. +- **`hide-selected-row-item` (`H`) resolves** to the one `ModelCommand::HideItem` + the host's effect is, over `row_frame.cats[0]` and the selected tuple's `[0]`. + That is the outer category and its item, exactly as the Cmd reads + `row_cats.first()` and `items[0]`. On a fold the client refuses in the host's + own words. That is why `FOLD_REFUSAL` moved from the command layer into + `improvise-protocol`: one string, not two copies. +- **`search` (`/`) and `search-navigate` (`n`) decline out loud.** `n` scans the + whole model, and a client holds one window. `/` alone would open a box that + can find nothing. Shipping `/` without `n` is the trap the ticket names, so + both decline until search can find. + +Declining is not silence, so the two search rows are no longer on the list, and +the help page stops marking them. They are in the same class as `ZZ`: pressed, +they say why. + **The help overlay marks them, and it marks rather than omits** (improvise-uln5). The overlay is shared content, and before this the browser painted every row plainly, including six that do nothing there. That is worse than a gap: diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 8092cd6e..8dac3b67 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -221,6 +221,19 @@ browser's help marks and the list this test drives are the same list. too. Rival: add `move-selection`, which the browser runs, to the list. This test fails, and so does `every_help_row_the_browser_cannot_run_is_one_it_marks`. +**improvise-fo0w took `enter-advance`, `hide-selected-row-item`, `search` and +`search-navigate` off it.** The first two resolve and the last two decline. Put +`enter-advance` back while its resolver stays in `composed`, and this test fails, +along with three others. + +**The list is held complete only where a help row reaches it.** Dropping +`force-quit` from it fails nothing, because no help row names Ctrl-Q, so no test +presses it and finds it silent. Dropping `exit-search-mode`, which a row does +name, fails three tests. So the two guards prove the list has no false entries, +and that no help row is an unmarked black hole. They do not prove that every +silent binding is listed. That is a residual, and it is reported for filing, +not fixed here. + ### every_help_row_the_browser_cannot_run_is_one_it_marks improvise-uln5. Every help row is pressed, in its own mode, on the fixture @@ -234,16 +247,21 @@ It also asserts that no status-bar hint names a listed command, since `hint_line` is never marked. Rivals, each run against the fixed tree: -- Drop `search` from the list: red. This test fails, and so does - `every_advertised_command_answers_in_its_own_mode`, which no longer skips `/`. +- Drop `exit-search-mode` from the list: red. This test fails, and so do + `every_advertised_command_answers_in_its_own_mode` and + `a_help_row_the_browser_cannot_run_is_drawn_marked`. The rival used to drop + `search`, which improvise-fo0w took off the list because `/` now declines out + loud. - Add `move-selection` to the list: red. This test fails, and so does the list's own test. ### a_help_row_the_browser_cannot_run_is_drawn_marked improvise-uln5, the display half, and the red test. On the Navigation page the -row "Start search — …" (key `/`) must render as `dim`, with "not in the browser" -in its description. On the Commands page every `:` row must say the same. A +row "Exit search mode" must render as `dim`, with "not in the browser" in its +description. Until improvise-fo0w this was the "Start search" row, key `/`, but +`/` now declines out loud, so its row is drawn plainly, as `ZZ`'s is. On the +Commands page every `:` row must be `dim` and must say the same thing. A row the browser runs ("Jump to last row") must stay a plain `keys` row with its description unchanged, so a renderer that marks everything cannot pass. @@ -252,6 +270,13 @@ Red at `202e7eac`: the search row rendered as `keys`. Rival: the renderer's is why the display has a test of its own and does not lean on the resolve-side guard. +**The `dim` assertion on the ex rows is improvise-0629.** Until then the loop +over `:` rows asserted only the description. The rival draws a `Literal` row +`dim` only when its own flag says so, ignoring `runs_the_command_line`, so +`:w`, `:q` and the rest keep "not in the browser" but render as `keys`. With the +assertion this test is red. Without it, this test passes, and the review that +filed 0629 found that all 21 render tests pass. + ### a_prompt_this_client_cannot_commit_is_never_entered The refusal list above is only half the property. `Upstream` says the key @@ -586,11 +611,73 @@ append that silently *replaced* the buffer would be visible. An unresolvable key must reach the host as a keypress, and what the host then does to *its* copy of this client's view slice is what the client will be told. Pinned as the shape the transport owes: the message carries the code and -modifiers, and they survive the round trip. `Enter` rather than an arrow key, -because since improvise-bpq2 the arrows resolve locally and `enter-advance` is -one of the movements deliberately left upstream — it aborts the rest of its batch -at the bottom-right corner, which is a fact about the host's effect chain and not -a cursor position. +modifiers, and they survive the round trip. The key is Ctrl-Q (`force-quit`), +and the round trip is asserted with its `CONTROL` modifier intact, so the case +covers modifiers as well as a code. It used to be `Enter`. That stopped working +when improvise-fo0w resolved `enter-advance` locally: its bottom-right +`AbortChain` turned out to be something the client can answer as `Ignored`, +because `Enter` is bound alone, and an abort with no later step to cut changes +nothing. Before that, the arrows had gone the same way (improvise-bpq2). Ctrl-Q +stays upstream because what it should mean in a browser tab is a product +ruling, not a gap. + +### enter_advances_and_wraps_where_the_host_does + +improvise-fo0w. `Enter` is pressed seven times over three rows and two columns, +in a 2×2 window, and the host's `reduce_key` is run alongside. The path is down, +down, then wrap to the top of the next column, down, down, then stop at the +bottom-right twice. The window is smaller than the grid, so the wrap and the +descent both scroll. At the stop the client may answer `Ignored`, which is the +host's `AbortChain` with nothing after it to abort. Red at `cf5c6fc7`: `Enter` +was `Upstream`. + +Rivals: +- The wrap lands at `(0, c)` instead of `(0, c + 1)`: red here and only here. +- `enter-advance` put back on `NEEDS_THE_HOST`, with its `composed` arm renamed + so nothing resolves it: red. This test fails, and so do + `every_grid_move_lands_where_its_binding_says`, + `every_advertised_command_answers_in_its_own_mode` and + `every_help_row_the_browser_cannot_run_is_one_it_marks`. + +### hiding_the_selected_row_item_agrees_with_the_host + +improvise-fo0w. After `j`, `H` resolves to one `HideItem`. That command is +applied to a twin host, and the real host runs `reduce_key`. The two documents +must then be equal, the status lines must match, and `Region/South` must be +hidden. The host's cursor is compared with the twin's rather than with the +client's, because a reframe resets the host's cursor (gotcha 24) and the client +learns that only when the relay arrives. `both_ends` would therefore compare a +cursor the client has not yet been told about. Red at `cf5c6fc7`: `H` was +`Upstream`. + +### hiding_under_two_row_categories_hides_the_outer_item + +improvise-fo0w. `Region` then `Product` are on rows, with the cursor on row 2, +which is `(South, Widget)`. The host's Cmd reads `row_cats.first()` and +`items[0]`, the outer category and its item. The previous test's fixture has one +row category, where first and last coincide, and there the rival +`cats.first()`→`cats.last()` passed the whole suite. Here it is red, and so is +the rival that takes `t.last()` for the tuple. + +### hiding_on_a_fold_refuses_as_the_host_does + +improvise-fo0w. With the cursor on a collapsed group's fold row, the client +composes exactly `[SetStatus(host's status_msg)]` and the document is unchanged. +That status is `FOLD_REFUSAL`, which moved into `improvise-protocol` so that +both ends share one string. Rival: the fold check disabled. The client then no +longer composes the refusal, and this test goes red. It is the only test that +does. + +### the_search_keys_decline_out_loud_in_a_browser + +improvise-fo0w. `/` and `n` each give exactly one `SetStatus` naming search +and the browser. Search is declined as a pair: `n` scans the whole model, which +a client holding one window cannot do, so a `/` that resolved would open a box +that finds nothing. Rival: the `search | search-navigate` arm removed from +`declined`. This test goes red, and so do +`every_advertised_command_answers_in_its_own_mode` and +`every_help_row_the_browser_cannot_run_is_one_it_marks`, since both keys become +unlisted black holes. ### a_locally_resolved_cursor_move_lands_where_the_host_would_put_it @@ -2147,7 +2234,9 @@ in `the_command_names_that_still_need_the_host_are_these`, and the help page advertised it anyway. Since the rest of improvise-uln5 they are no longer listed here: the browser marks those rows, and this test skips a silent row whose command is on `resolve::NEEDS_THE_HOST`, which -`every_help_row_the_browser_cannot_run_is_one_it_marks` owns. +`every_help_row_the_browser_cannot_run_is_one_it_marks` owns. Since +improvise-fo0w, `/` and `n` appear here again, as declines, and `H` and Normal's +`Enter` resolve. *Which cells.* `furnished` stood on a record cell only, so `i` on an aggregate (s8h7's whole subject) was outside its reach. Normal mode is now pressed on a diff --git a/crates/improvise-command/src/command/cmd/core.rs b/crates/improvise-command/src/command/cmd/core.rs index e3f9e876..a222d12a 100644 --- a/crates/improvise-command/src/command/cmd/core.rs +++ b/crates/improvise-command/src/command/cmd/core.rs @@ -14,7 +14,7 @@ use crate::workbook::Workbook; use improvise_protocol::CellId; /// What a command says when the cursor sits on a collapsed group's row (§14). -pub const FOLD_REFUSAL: &str = "Collapsed group — press z to expand it first"; +pub use improvise_protocol::FOLD_REFUSAL; const NO_CELL: &str = "no cell at cursor"; pub const AGGREGATE_REFUSAL: &str = "Cannot write to an aggregate — it combines several records, so there is no one cell to write"; diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index 84b55876..d66fcdee 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -56,6 +56,10 @@ pub use improvise_core::formula::FormulaId; /// it bounds, so help-page effects stay terminal-independent. pub const HELP_PAGE_COUNT: usize = 5; +/// What a command says when the cursor sits on a collapsed group's row. Here, +/// not in the command layer, because a browser refuses in the same words. +pub const FOLD_REFUSAL: &str = "Collapsed group — press z to expand it first"; + /// Where one client is looking within one view. Per-client (improvise-8uz); /// `View` no longer carries it — `context/code/protocol.md` §1. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 4e115009..b095ea93 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -345,6 +345,9 @@ fn declined(name: &str, args: &[String]) -> Option { // strands its own disarm — improvise-hs0u, `context/code/clients.md`. "toggle-col-group-under-cursor" => "collapsing a column group", "wq" => "saving and quitting", + // `n` scans the whole model; `/` alone is a box that finds nothing + // (improvise-fo0w). Both decline until search can find. + "search" | "search-navigate" => "search", // A door this client will not open still has to say so: entering is // resolvable and the commit behind it is not. "enter-mode" => stranding(&AppMode::from_name(args.first()?)?)?, @@ -362,12 +365,8 @@ fn not_yet(what: &str) -> String { /// The command names this client has no arm for, so their keys go upstream to /// a host that answers nothing (gotcha 47). The one list: see clients.md. pub const NEEDS_THE_HOST: &[&str] = &[ - "enter-advance", "exit-search-mode", "force-quit", - "hide-selected-row-item", - "search", - "search-navigate", "toggle-group-under-cursor", ]; @@ -475,6 +474,9 @@ fn composed( }, }]), + "enter-advance" => entered_advance(state), + "hide-selected-row-item" => hidden_row_item(state), + // ── editing ────────────────────────────────────────────────── "edit-or-drill" => edit_or_drill(state, AppMode::from_name(args.first()?)?), "commit-cell-edit" => commit(state, AppMode::from_name(args.first()?)?, Advance::Down), @@ -747,6 +749,51 @@ fn scrolled_to(state: &ClientState, nr: usize, nc: usize) -> Vec { commands } +/// `EnterAdvance::execute`, line for line over `GridExtent`. Its bottom-right +/// `AbortChain` composes nothing here, so the key reads as `Ignored`. +fn entered_advance(state: &ClientState) -> Option> { + let GridExtent { rows, cols } = state.grid.extent; + let (window_rows, window_cols) = state.window; + // `grid_move`'s guard: no window is nothing to scroll within — clients.md. + if window_rows == 0 || window_cols == 0 { + return Some(Vec::new()); + } + let row_max = rows.saturating_sub(1); + let col_max = cols.saturating_sub(1); + let (r, c) = state.view.cursor().selected; + let (nr, nc) = if r < row_max { + (r + 1, c) + } else if c < col_max { + (0, c + 1) + } else { + return Some(Vec::new()); + }; + Some(scrolled_to(state, nr, nc)) +} + +/// `HideSelectedRowItem::execute`, line for line over the row frame, whose +/// `first` is where the window sits in the grid (gotcha 44). +fn hidden_row_item(state: &ClientState) -> Option> { + let frame = &state.grid.row_frame; + let Some(category) = frame.cats.first().cloned() else { + return Some(Vec::new()); + }; + let idx = state.view.cursor().selected.0.checked_sub(frame.first); + // A fold's row names a group, not an item, so there is nothing to hide. + if idx.and_then(|i| frame.group_of(i)).is_some_and(|g| g.collapsed) { + return Some(vec![Command::SetStatus(crate::FOLD_REFUSAL.to_string())]); + } + let Some(item) = idx.and_then(|i| frame.item(i)).and_then(|t| t.first()) else { + return Some(Vec::new()); + }; + let hide = ModelCommand::HideItem { + view: state.view.active_view.clone(), + category, + item: item.clone(), + }; + Some(vec![model(state, hide)?]) +} + // ── the panel cursors ─────────────────────────────────────────────────── /// `MovePanelCursor::execute`, deliberately line for line — the three details @@ -2200,12 +2247,15 @@ mod tests { client.bootstrap(Snapshot { view: ViewState::default(), seed: vec![ - Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([ - "North", "South", "East", - ]))), - Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([ - "Revenue", "Cost", "Profit", - ]))), + // Named axes, as a host's always are: `H` hides from `cats[0]`. + Projection::Grid(GridUpdate::RowFrame(AxisFrame { + cats: vec!["Region".into()], + ..AxisFrame::flat(["North", "South", "East"]) + })), + Projection::Grid(GridUpdate::ColFrame(AxisFrame { + cats: vec!["_Measure".into()], + ..AxisFrame::flat(["Revenue", "Cost", "Profit"]) + })), Projection::Grid(GridUpdate::Cells(cells)), Projection::Grid(GridUpdate::Extent(GridExtent { rows: 3, cols: 3 })), Projection::Basis(published()), @@ -2466,6 +2516,9 @@ mod tests { "NORMAL: P toggle-prune-empty Declines", "NORMAL: R toggle-records-mode Declines", "NORMAL: ZZ wq Declines", + // `n` needs the whole model, and `/` alone finds nothing (fo0w). + "NORMAL: / search Declines", + "NORMAL: n search-navigate Declines", "RECORDS: o add-record-row Declines", "RECORDS: a/i edit-or-drill Declines", "RECORDS: : enter-mode Declines", @@ -2522,6 +2575,7 @@ mod tests { "jump-last-row" => Some((20, START.1)), "jump-first-col" => Some((START.0, 0)), "jump-last-col" => Some((START.0, 20)), + "enter-advance" => Some((START.0 + 1, START.1)), _ => None, }; let mut unstated = Vec::new(); @@ -2575,6 +2629,7 @@ mod tests { "these move the cursor and nothing states where to: {unstated:?}" ); let every = [ + "enter-advance", "jump-first-col", "jump-first-row", "jump-last-col", diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs index 8d214b40..c6f5daf3 100644 --- a/crates/improvise-wasm-client/src/lib.rs +++ b/crates/improvise-wasm-client/src/lib.rs @@ -384,19 +384,19 @@ mod tests { assert_eq!(rendered["revision"], client.revision()); } - /// `Enter` rather than an arrow: since improvise-bpq2 the arrows resolve - /// here, and `enter-advance` is one of the movements left to the host. + /// Ctrl-Q, `force-quit`: the arrows (bpq2) and `Enter` (fo0w) resolve here + /// now, and what Ctrl-Q means in a browser tab is still a ruling. #[test] fn an_unresolvable_key_is_forwarded_and_changes_nothing_locally() { let mut client = init(); let before = client.revision(); let outbound = client - .on_key_event("Enter", false, false, false, false) + .on_key_event("q", true, false, false, false) .unwrap() .unwrap(); assert!( outbound.contains("Key"), - "a Normal-mode Enter is the host's to resolve: {outbound}" + "a Normal-mode Ctrl-Q is the host's to resolve: {outbound}" ); assert_eq!( client.revision(), diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 6e916ec4..721cbca1 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -612,7 +612,7 @@ mod tests { let found = rows.iter().find(|r| r.description.starts_with(prefix)); found.unwrap_or_else(|| panic!("no Navigation row reads {prefix:?}")) }; - let search = row("Start search"); + let search = row("Exit search mode"); assert_eq!(search.kind, "dim", "{search:?}"); assert!( search.description.contains("not in the browser"), @@ -634,6 +634,7 @@ mod tests { "precondition: the Commands page lists ex-commands" ); for row in ex { + assert_eq!(row.kind, "dim", "{row:?}"); assert!(row.description.contains("not in the browser"), "{row:?}"); } } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index be6784aa..f32b192f 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -138,11 +138,14 @@ fn command_mode_backspace_on_an_empty_buffer_changes_the_mode_on_the_host() { } /// An unresolvable key must reach the host as a keypress, carrying its code -/// and modifiers intact. Why `Enter` and not an arrow: the evidence file. +/// and modifiers intact. Why Ctrl-Q: the evidence file. #[test] fn a_forwarded_key_reaches_the_host_unchanged() { let mut client = ClientState::default(); - let browser = BrowserKey::new("Enter"); + let browser = BrowserKey { + ctrl: true, + ..BrowserKey::new("q") + }; let event = browser.to_key_event().unwrap(); assert_eq!(resolve(&client, event), KeyOutcome::Upstream(event)); @@ -150,19 +153,20 @@ fn a_forwarded_key_reaches_the_host_unchanged() { let ClientFrame::Key { code, modifiers } = serde_json::from_str(&wire).unwrap() else { panic!("a forwarded key must stay a key"); }; + assert_eq!(modifiers, KeyModifiers::CONTROL, "the modifier survives the wire"); let mut app = host(); reduce_key(&mut app, KeyEvent::new(code, modifiers)); assert_eq!( - app.view_state.cursor().selected, - (1, 0), - "the host resolved the arrow against the document the client does not have" + app.view_state.mode, + AppMode::Quit, + "the host resolved Ctrl-Q, which the client does not" ); // And the client catches up only by folding what it is told. - assert_eq!(client.view.cursor().selected, (0, 0)); - client.fold(&improvise_protocol::Command::SetSelected { row: 1, col: 0 }); - assert_eq!(client.view.cursor().selected, (1, 0)); + assert_eq!(client.view.mode, AppMode::Normal); + client.fold(&improvise_protocol::Command::ChangeMode(AppMode::Quit)); + assert_eq!(client.view.mode, AppMode::Quit); } /// `reduce_view` is the shared fold, not a second implementation — so a command @@ -2360,3 +2364,186 @@ fn renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host() { assert_eq!(client.view.status_msg, "Formula updated"); assert_eq!(app.view_state.status_msg, "Renamed Tax to Levy"); } + +// improvise-fo0w: three keys the browser sent to a host that answers nothing. + +/// Three rows by two columns, a two-row window: `Enter` must scroll, wrap to +/// the next column and stop at the bottom-right. +fn advancing_host() -> App { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + for item in ["North", "South", "East"] { + wb.add_item("Region", item).unwrap(); + } + for m in ["A", "B"] { + wb.add_item("_Measure", m).unwrap(); + } + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + let mut app = App::new(wb, None); + app.term_height = 2 + 8; + app.rebuild_layout(); + app +} + +/// improvise-fo0w: `Enter` mirrors `EnterAdvance` press for press, including +/// the stop. Evidence: `context/test-evidence/clients.md`. +#[test] +fn enter_advances_and_wraps_where_the_host_does() { + let window = (2, 2); + let mut app = advancing_host(); + let mut client = client_of(&app, window); + let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + let path = [(1, 0), (2, 0), (0, 1), (1, 1), (2, 1), (2, 1), (2, 1)]; + for (press, want) in path.into_iter().enumerate() { + match resolve(&client, event) { + KeyOutcome::Local(cmds) => cmds.iter().for_each(|c| client.fold(c)), + KeyOutcome::Ignored if want == (2, 1) => {} + other => panic!("press {press}: the client must answer Enter, got {other:?}"), + } + reduce_key(&mut app, event); + assert_eq!( + client.view.cursor(), + app.view_state.cursor(), + "press {press}: client and host disagree" + ); + assert_eq!(client.view.cursor().selected, want, "press {press}"); + } +} + +/// improvise-fo0w: `H` hides the selected row's item, composed as the one +/// `HideItem` the host's effect is. Evidence: `context/test-evidence/clients.md`. +#[test] +fn hiding_the_selected_row_item_agrees_with_the_host() { + let mut app = furnished_host(); + let mut twin = twin_of(&app); + let mut client = furnished_client(&app, (2, 1)); + both_ends(&mut app, &mut twin, &mut client, "j"); + let event = BrowserKey::new("H").to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("H: the client must answer this key locally"); + }; + cmds.iter().for_each(|c| client.fold(c)); + apply_on(&mut twin, &cmds); + reduce_key(&mut app, event); + assert_eq!(document_text(&twin), document_text(&app)); + assert_eq!(client.view.status_msg, app.view_state.status_msg); + // The re-framed grid resets the cursor on the host, and on the twin that + // applied the client's `HideItem`; the client hears it relayed (gotcha 24). + assert_eq!(twin.view_state.cursor(), app.view_state.cursor()); + let view = app.model_state.workbook.active_view(); + assert!( + view.is_hidden("Region", "South"), + "the row under the cursor, South, must be the item hidden" + ); +} + +/// improvise-fo0w: under two row categories `H` hides the OUTER one's item, +/// as the host does. Evidence: `context/test-evidence/clients.md`. +#[test] +fn hiding_under_two_row_categories_hides_the_outer_item() { + let mut app = two_row_categories_host(); + let mut twin = two_row_categories_host(); + let client = furnished_client(&app, (4, 1)); + let event = BrowserKey::new("H").to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("H: the client must answer this key locally"); + }; + apply_on(&mut twin, &cmds); + reduce_key(&mut app, event); + assert_eq!(document_text(&twin), document_text(&app)); + assert!( + app.model_state.workbook.active_view().is_hidden("Region", "South"), + "row 2 is (South, Widget): the outer category's item is the one hidden" + ); +} + +/// Region then Product on rows, with the cursor on row 2, (South, Widget). +fn two_row_categories_host() -> App { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + wb.add_category("Product").unwrap(); + for (cat, item) in [ + ("Region", "North"), + ("Region", "South"), + ("Product", "Widget"), + ("Product", "Gadget"), + ] { + wb.add_item(cat, item).unwrap(); + } + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("Product", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + let mut app = App::new(wb, None); + app.term_height = 4 + 8; + app.rebuild_layout(); + app.view_state.cursor_mut().selected = (2, 0); + app +} + +/// A collapsed group of Region items: its fold is the second row. +fn folded_host() -> App { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + wb.add_item("Region", "North").unwrap(); + for item in ["East", "West"] { + wb.add_item_in_group("Region", item, "Coast").unwrap(); + } + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + wb.active_view_mut() + .toggle_group_collapse("Region", "Coast"); + let mut app = App::new(wb, None); + app.term_height = 4 + 8; + app.rebuild_layout(); + app +} + +/// improvise-fo0w: on a fold `H` refuses in the host's own words and hides +/// nothing. Evidence: `context/test-evidence/clients.md`. +#[test] +fn hiding_on_a_fold_refuses_as_the_host_does() { + let mut app = folded_host(); + let before = document_text(&app); + let fold = (0..app.layout.row_count()) + .find(|&r| app.layout.row_folds_a_group(r)) + .expect("setup: the fixture has a fold row"); + app.view_state.cursor_mut().selected = (fold, 0); + let client = client_of(&app, (4, 1)); + let event = KeyEvent::new(KeyCode::Char('H'), KeyModifiers::NONE); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("H on a fold must be answered here, out loud"); + }; + reduce_key(&mut app, event); + assert_eq!(document_text(&app), before, "setup: the host hid nothing"); + assert_eq!( + cmds, + vec![improvise_protocol::Command::SetStatus( + app.view_state.status_msg.clone() + )], + "the client must refuse in the host's words and compose nothing else" + ); +} + +/// improvise-fo0w: `/` and `n` decline out loud. A search box that can find +/// nothing is worse than silence. Evidence: `context/test-evidence/clients.md`. +#[test] +fn the_search_keys_decline_out_loud_in_a_browser() { + let app = furnished_host(); + let client = furnished_client(&app, (2, 1)); + for key in ["/", "n"] { + let event = BrowserKey::new(key).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{key}: must be answered here, out loud"); + }; + let [improvise_protocol::Command::SetStatus(said)] = cmds.as_slice() else { + panic!("{key}: must say so and do nothing else, got {cmds:?}"); + }; + assert!( + said.contains("search") && said.contains("browser"), + "{key}: {said}" + ); + } +} diff --git a/harness/tests/shell.spec.js b/harness/tests/shell.spec.js index 73feb294..714437b4 100644 --- a/harness/tests/shell.spec.js +++ b/harness/tests/shell.spec.js @@ -132,9 +132,9 @@ test.describe("the shell reports what its peer is doing", { tag: "@mocked-peer" await expect(status(page)).toContainText("connected", { timeout: 15_000 }); await expect(status(page)).toContainText("disconnected", { timeout: 15_000 }); - // Enter is forwarded UPSTREAM, so it composes a frame even on a client - // that never got a snapshot — a cursor key here composes nothing at all. - await page.keyboard.press("Enter"); + // Ctrl-Q is forwarded UPSTREAM, so it composes a frame with no snapshot; + // Enter resolves locally since improvise-fo0w, and composes nothing here. + await page.keyboard.press("Control+q"); await expect(status(page)).toContainText("not sent", { timeout: 15_000 }); const log = await page.evaluate(() => window.__statusLog); expect( From ddcdf93a6b6e74dfedd8ad4723d63b019ef9a583 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:01:54 -0700 Subject: [PATCH 073/114] The shipped-document walk strips every GIT_TRACE* variable (improvise-2iws) GIT_TRACE, GIT_TRACE2_EVENT and GIT_TRACE_PERFORMANCE are process-wide, so --local-env-vars lists none of them. Pointed at /dev/stdout, git wrote trace lines into the ls-files -z output, and the walk read them as paths. git() now builds on untraced_git(), which removes every variable starting with GIT_TRACE: a prefix, because the family is open-ended. The rev-parse --local-env-vars call goes through it too, since its stdout is parsed as well. the_walk_ignores_a_git_trace_sent_to_stdout reruns the gzip shape test and every_shipped_document_loads in children with all three set to /dev/stdout. It failed at #269's head. Rivals, each of which fails it: - no strip; - the exact name only; - keep GIT_TRACE_PERFORMANCE; - keep GIT_TRACE2_EVENT. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tui.md | 28 ++++++++++++++++++++++ tests/shipped_documents_load.rs | 41 +++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index 354184e4..8064b0eb 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -655,6 +655,34 @@ Rivals, each dropping one name from `PATHSPEC_AND_ATTRIBUTE_SETTINGS`: ## `tests/web_demo_model.rs` +### the_walk_ignores_a_git_trace_sent_to_stdout + +improvise-2iws. The `GIT_TRACE*` family is process-wide, like improvise-a0i3's +settings, so `--local-env-vars` lists none of them either. Pointed at +`/dev/stdout`, git writes trace lines into the `ls-files -z` output the walk +parses, and they are read as paths. `every_shipped_document_loads` panicked on +"Cannot open /17:39:40… git.c:502 trace: built-in: git ls-files …". +`git()` now builds on `untraced_git()`, which removes every variable whose name +starts with `GIT_TRACE`. It is a prefix match because the family is open-ended. +The `rev-parse --local-env-vars` call goes through it too, since its stdout is +parsed as well. + +The test re-runs `a_gzipped_document_is_walked` and +`every_shipped_document_loads` in child processes, with `GIT_TRACE`, +`GIT_TRACE2_EVENT` and `GIT_TRACE_PERFORMANCE` all set to `/dev/stdout`: one +child per test, not one per variable, to keep this binary's child count down. +It failed at #269's head 4cef40c9 for both tests. + +Rivals, each injected into `untraced_git` and restored: +- **no strip**: fails; +- **strip the exact name `GIT_TRACE` only**: fails. The other two remain, so the + prefix is what the test pins; +- **strip all but `GIT_TRACE_PERFORMANCE`**: fails; +- **strip all but `GIT_TRACE2_EVENT`**: fails. + +The last two show that each variable alone breaks the walk, which is why one +child per test is enough. + ### every_web_demo_cell_is_settable_except_the_column_a_formula_owns improvise-bck. The demo document the standalone page opens must be one a visitor diff --git a/tests/shipped_documents_load.rs b/tests/shipped_documents_load.rs index 82378c3f..1b49ab72 100644 --- a/tests/shipped_documents_load.rs +++ b/tests/shipped_documents_load.rs @@ -60,11 +60,11 @@ const PATHSPEC_AND_ATTRIBUTE_SETTINGS: [&str; 5] = [ /// `git -C root`, with every repo-local variable a caller exported removed, /// since `GIT_DIR` and friends override `-C` (improvise-53zz). fn git(root: &Path) -> std::process::Command { - let names = std::process::Command::new("git") + let names = untraced_git() .args(["rev-parse", "--local-env-vars"]) .output() .expect("git runs"); - let mut git = std::process::Command::new("git"); + let mut git = untraced_git(); for name in String::from_utf8(names.stdout).expect("UTF-8").lines() { git.env_remove(name); } @@ -76,6 +76,18 @@ fn git(root: &Path) -> std::process::Command { git } +/// `git` with every `GIT_TRACE*` variable removed: one pointed at stdout writes +/// into what we parse. A prefix, since the family is open-ended (improvise-2iws). +fn untraced_git() -> std::process::Command { + let mut git = std::process::Command::new("git"); + for (name, _) in std::env::vars_os() { + if name.to_string_lossy().starts_with("GIT_TRACE") { + git.env_remove(name); + } + } + git +} + /// A scratch repository, so each way of shipping a document is set up for real. fn scratch_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().expect("a temp dir"); @@ -305,3 +317,28 @@ fn the_walk_ignores_the_users_pathspec_and_attribute_settings() { } assert!(failed.is_empty(), "{}", failed.join("\n")); } + +/// improvise-2iws: a git trace sent to stdout lands in `ls-files -z`'s output +/// and is read as paths. Evidence: `context/test-evidence/tui.md`. +#[test] +fn the_walk_ignores_a_git_trace_sent_to_stdout() { + // All three at once, one child per test: each alone breaks the walk, and a + // strip of one exact name leaves the other two (the prefix is the point). + let stdout = Path::new("/dev/stdout"); + let traces = [ + ("GIT_TRACE", stdout), + ("GIT_TRACE2_EVENT", stdout), + ("GIT_TRACE_PERFORMANCE", stdout), + ]; + for test in [ + "a_gzipped_document_is_walked", + "every_shipped_document_loads", + ] { + let out = rerun_with_env(test, &traces); + assert!( + out.status.success(), + "{test} under a trace to stdout: {}", + told(&out) + ); + } +} From 588094b7bb54b25ff4d2e8d5e1cad803a9e72dfb Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:02:08 -0700 Subject: [PATCH 074/114] check-ci-graph: a ' #' on a read line is refused, one string per line, read_graph opens wide (j49r) A job header with a trailing comment was accepted by the readability check (comment-stripped line) and never opened (raw line), so its if: went to the previous job and read_graph never listed it. The audit found the stripper also cut a quoted "x # y" open, hiding a later job-level if:. Both exited 0 on the real ci.yml. Refusing the comment makes stripped == raw on every accepted line, so the stripper is deleted. Argument: context/code/tooling.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 33 +++++++++++++++++++ context/test-evidence/tooling.md | 56 ++++++++++++++++++++++++++++++++ scripts/check-ci-graph.py | 21 +++++++----- 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index d96c6b34..d1c09a7a 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -338,6 +338,39 @@ Three valid styles are refused loudly rather than read: Each exits 1 and names its line. Spell it the plain way. +**A ` #` on any line the gate reads is refused, and every matcher reads the +same string** (improvise-j49r). The gate used to strip a trailing comment for +the readability check and match the raw line everywhere else. So +` gate: # skipped on purpose` was accepted as a plain job header by one and +never opened as a job by the other. Its `if: false` was credited to the job +before it, and `read_graph` did not list `gate` at all. Both rules passed with +`kb` skipped and the deploy ungated by `gate`. An audit of every match found +the stripper wrong in a second way: it cut `A: "x # y"` to `A: "x`, the quote +tracker then held the next lines open, and a job-level `if:` after it vanished. +That also exited 0 on the real `ci.yml`. + +There were two ways to make the matchers agree: +- **strip everywhere.** A correct stripper has to know where a quoted value + starts and ends, because `"x # y"` has no comment and `"x" # y` has one. That + is parsing again, the thing st8q and w0j4 gave up. +- **Refuse the comment.** This was chosen. Once every line containing ` #` is + refused, the stripped and raw forms of every accepted line are the same + string, so no two matchers can read different versions of it. So the + stripper is deleted, not kept consistent. + +It costs one valid style: a trailing comment, or a ` #` inside a quoted value, +on any line that is not block-scalar or quoted content. The real `ci.yml` has +neither. A full-line comment is still skipped, and a `#` with no space before +it, as in `nixpkgs#trealla`, is not a comment. After a refused line only the +exit is guaranteed. What the gate reads next may be garbled: a refused +`"x" # note` still opens the quote tracker. The exit is 1 either way. + +`read_graph` refuses nothing, so it cannot rely on refusal. It opens a job on +any ` name:` line followed by whitespace or the end of the line, which is +wider than the exact header `ci_steps` accepts. Reading too wide only adds jobs +`pages-deploy` must need, which fails closed. Reading too narrow is what lost +`gate`. + **What this does not close, stated rather than guarded.** Once a counted step is reached, the gate trusts that it runs the script it names. An earlier step in the same job that rewrote `scripts/checkers.sh` or its table would defeat diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 40ec45f9..e489adda 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1121,3 +1121,59 @@ restored. All three now exit 1, naming the first misplaced line. **Refused loudly, not read:** an indentless block list, a block list at indent 8, and a quoted top-level key such as `"on":`. Each is valid YAML, and each is named by line. + +### A comment on a read line is refused, and the matchers share one string — `scripts/check-ci-graph.py` (improvise-j49r) + +**What broke (P2, fail-open).** Found in the delta review of #228 at +`5738e8e0`. `_readable` checked the comment-stripped line, while `_JOB.match` +in both `ci_steps` and `read_graph` ran on the raw line. A header +` gate: # skipped on purpose` was therefore accepted and never opened. Its +`if: false` went to the previous job, and `read_graph` did not list `gate`. +Planted before ` wasm:` in the real `ci.yml`, with `kb` given `needs: gate`, +the gate exited 0: "pages-deploy needs all 7 other jobs; all 9 suites run". + +**The audit found a second instance.** The stripper cut `A: "x # y"` to +`A: "x`, so `_opens_quote` held the following lines open, and a job-level +`if: false` after them was never read. Planted under `kb` in the real +`ci.yml`, this also exited 0 at `5738e8e0`. + +**Fix.** A line containing ` #` is refused, with its own message, and the +stripper is deleted, so every matcher reads the raw line. `read_graph` opens a +job on `_JOB_OPEN` (` name:` followed by whitespace or the end of the line). +That is wider than `_JOB`, because it refuses nothing. The argument is in +`context/code/tooling.md`. + +**Red first** against `5738e8e0`: +- the suite case for the header: `saw []`; +- the `read_graph` case for the header: `saw []`; +- the `"x # y"` case at indent 10: `saw []`. + +The `"x" # note` case read correctly at `5738e8e0`, because the stripper cut it +cleanly. It guards the alternative of reading raw lines without refusing: under +that alternative the raw value `"x" # note` opens the quote tracker. Two old +cases were changed. The positive "own row" case lost its trailing comment, and +that form is now its own refusal case. The w0j4 padded-list control lost its +`# c` and is still a control. + +**Replayed on the real `ci.yml`.** Each plant was mutated in, run and +restored, and the file was compared byte for byte (sha1 `a00288a8…`). Four +plants were run: j49r as reviewed, j49r with `gate` also in pages-deploy's +`needs`, `"x # y"` under `kb`, and `"x" # note` under `kb`. At `5738e8e0` the +first and third exited 0. Now all four exit 1 and name the line. + +**Rivals, each a source edit run against the real `--self-test` (47 cases).** +The review's eleven still fail where the review thread said they would. The +continuation rival fails two cases, the needs flag five, block-list one, +transitive one, flow-only two, `owner + 1` one, `if_jobs` kb-only five, +`_readable` always True fifteen, quoted `_NEEDS_VALUE` one, and each rjaa rival +three. Four rivals are new: +- Raw lines, no refusal: fails the run-line, `"x" # note` and `"x # y"` cases. +- The stripped line everywhere, no refusal (the other option): fails all four + suite cases. +- Refusal only inside `jobs:` at indent 8 or less: fails the indent-10 + `"x # y"` case alone. +- `read_graph` opening on the exact `_JOB`: fails the `read_graph` case alone. + +The header suite case does not separate the raw-without-refusal rival. There +the exact `_JOB` on the raw line refuses the header anyway, and the complaint +has the same text. diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index 29a9bd5b..eb6ee127 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -160,7 +160,7 @@ def ci_steps(text): readable = not in_jobs or indent > 8 or _readable(raw, indent, in_steps, in_needs, job_keyed) if _COMMENT.search(raw) or not readable: - unread.append(n) + unread.append((n, bool(_COMMENT.search(raw)))) if raw.startswith("defaults:"): workflow_flags.add("workflow defaults") m = _JOB.match(raw) @@ -252,7 +252,10 @@ def _unrun(s): "ci_suites in scripts/checkers.sh") -def _unread(n): +def _unread(n, comment=False): + if comment: + return (f"ci.yml line {n} has a ` #`, which YAML may read as a comment; this gate " + "reads no comments, so put it on a line of its own") return (f"ci.yml line {n} is a key or `needs:` value this gate cannot read as a " "plain name, so it cannot trust the graph; spell it plainly") @@ -261,7 +264,7 @@ def suite_verdict(suites, text): """The complaints: suites no counted step runs, and every step or line that does not count, each named so the fix is obvious rather than silent.""" steps, stray, unread = ci_steps(text) - out, ran, missing = [_unread(n) for n in unread], set(), set() + out, ran, missing = [_unread(n, c) for n, c in unread], set(), set() for s in steps: m = _CI_CALL.match(s["run"] or "") if not m: @@ -318,11 +321,11 @@ _NEEDS_GATE = ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, w "so it does not count"] + _NO_PW -def _unreadable(name, text, *markers, rest=()): +def _unreadable(name, text, *markers, rest=(), comment=False): """A case whose workflow spells a key or a `needs:` value the gate must refuse.""" lines = text.split("\n") at = [next(i for i, l in enumerate(lines, 1) if m in l) for m in markers] - return (name, _REQ, text, [_unread(n) for n in at] + list(rest)) + return (name, _REQ, text, [_unread(n, comment) for n in at] + list(rest)) # (name, suites to require, workflow text, the complaints it must draw). Every @@ -334,7 +337,7 @@ SUITE_SELF_TEST = [ _unreadable("a trailing comment on the counted run: line is refused (j49r)", _wf(_BUILD, _job("kb", _KB_RUN.rstrip("\n") + " # the kb row\n")), - "# the kb row", rest=[_stray(9)] + _NO_PW), + "# the kb row", rest=[_stray(9)] + _NO_PW, comment=True), ("a job that never calls its row leaves its suites unrun", _REQ, _wf(_BUILD, _job("kb", " - run: echo nothing\n")), _NO_PW), @@ -540,18 +543,18 @@ SUITE_SELF_TEST = [ _wf(_BUILD, " test:\n runs-on: x\n", " gate: # skipped on purpose\n if: false\n steps:\n - run: echo\n", _job("kb", _KB_RUN, head=" needs: gate\n")), - "gate: # skipped"), + "gate: # skipped", comment=True), _unreadable("a closed quote followed by a comment is refused (j49r)", _wf(_BUILD, _job("kb", _KB_RUN, head=" env:\n A: \"x\" # note\n") + " if: false\n", " after:\n steps:\n - run: echo \"z\"\n"), - '"x" # note', rest=[_stray(11)] + _NO_PW), + '"x" # note', rest=[_stray(11)] + _NO_PW, comment=True), _unreadable("a # inside a quoted value, below indent 8, is refused (j49r)", _wf(_BUILD, _job("kb", _KB_RUN + " - uses: x\n with:\n" " foo: \"x # y\"\n") + " if: false\n", " after:\n steps:\n - run: echo \"z\"\n"), - '"x # y"', + '"x # y"', comment=True, rest=["job `kb`'s `checkers.sh --ci` step is conditional (job if), " "so it does not count"] + _NO_PW), From c704ea1263b0c775db5e6ab690d9e5f3cfa60d4e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:03:39 -0700 Subject: [PATCH 075/114] A commit that waits for the host keeps the typed text (improvise-joed) Enter in Editing/RecordsEditing is [commit-cell-edit, clear-buffer edit]. The "still waiting" answer came back from composed() as an ordinary Local step, so the sequence ran the welded clear after it and emptied the editor it had left open, on the value path and on #221's rename path alike. The two cell commits are now answered by committed(), ahead of composed(), and still_waiting() is a Step::Declined: the existing channel by which a step stops a sequence. Under #227 (no weld; the commit clears at its head) the binding is one command and the waiting return precedes that clear, so the fix fits both. Stacked on #274. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 18 +++- context/test-evidence/clients.md | 27 ++++++ crates/improvise-protocol/src/resolve.rs | 91 ++++++++++++++----- .../tests/browser_round_trip.rs | 66 ++++++++++++++ 4 files changed, 177 insertions(+), 25 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index d1ff7085..bc1bff26 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -2900,7 +2900,19 @@ value commit can now wait**: the capture waits for the host's answer to this client's own last write, so `Enter` pressed again inside one round trip has no target yet. It declines with the rename's "still waiting" status and writes nothing. With no write in flight and no capture, the cursor is on no cell an -editor writes, and the commit answers `None` as before. The waiting decline is -still followed by the `Enter` binding's welded `clear-buffer`, so the typed -text is lost on that path, for a rename as for a value (improvise-joed). +editor writes, and the commit answers `None` as before. **A waiting commit is a decline, so it keeps the typed text (improvise-joed).** +`Enter` in `Editing` is the sequence `[commit-cell-edit, clear-buffer edit]`, +and a sequence runs every step unless one is `Step::Declined`. The waiting +answer used to come back from `composed` as an ordinary `Local` step, so the +welded clear ran after it and emptied the editor it had just left open. The two +cell commits are therefore answered by `committed`, ahead of `composed`, and +`still_waiting` is a `Step::Declined`. That is the same channel `declined` uses +for commands the browser cannot compose, and the same rule gotcha 21 gives an +abort: what follows a refusal in one binding does not run. + +This also fits improvise-fy9o (PR #227), which drops the weld and has the +commit clear its own buffer at its head. There the binding is one command, so +`Declined` and `Local` answer the same, and the text survives only if the +waiting return comes **before** that clear. A merge of the two must keep that +order. The joed tests fail if it does not. diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 7183c150..7b5f2df3 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5330,3 +5330,30 @@ capture and the test would pass for the wrong reason. The test asserts the status and that nothing is written, not the exact command list: the `Enter` binding's welded `clear-buffer` still runs after the decline and empties the buffer. That loss is improvise-joed. + +### a_commit_that_waits_for_the_host_keeps_the_typed_text + +improvise-joed, on the value path. A commit is sent and not yet answered, its +advance reopens the editor, and the user types `43` and presses `Enter` again. +There is no capture yet, so the commit declines with "still waiting". But the +key is `[commit-cell-edit, clear-buffer edit]`, and the decline came back from +`composed` as an ordinary `Local` step, so the sequence ran the welded clear +after it. The editor stayed open with an empty buffer. It failed at the buffer +assertion with `Some("")`. The test folds the second `Enter` as sent, so it +checks what the user sees, not what the resolver returns. + +- **Rival: the waiting decline is `Step::Local` again.** Fails this test and + `a_rename_that_waits_for_the_host_keeps_the_typed_text`. +- **Rival: a `Declined` step no longer stops a sequence.** Fails both joed + tests and `every_advertised_command_answers_in_its_own_mode`. + +### a_rename_that_waits_for_the_host_keeps_the_typed_text + +improvise-joed, on #221's rename path, through a real `Host`. A records-mode +rename of South to Zed is composed and folded as sent, and never answered. The +commit reopens the editor, the user edits it, and presses `Enter` again. It +failed at the buffer assertion: `Some("")` where the typed text was expected. + +- **Rival: only the value path declines (`rename_coordinate`'s waiting arm + stays `Local`).** Fails only this test, so the value test alone could not + see the rename path. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index cd9e833c..14126473 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -316,14 +316,33 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> St Some(request) => Step::Request(request), None => match declined(name, args) { Some(said) => Step::Declined(vec![Command::SetStatus(said)]), - None => match composed(state, event, name, args) { - Some(commands) => Step::Local(commands), - None => Step::Upstream, + None => match committed(state, name, args) { + Some(answer) => answer, + None => match composed(state, event, name, args) { + Some(commands) => Step::Local(commands), + None => Step::Upstream, + }, }, }, } } +/// The two cell commits, answered here rather than in [`composed`] because one +/// of their answers is a decline, which stops a sequence (improvise-joed). +fn committed(state: &ClientState, name: &str, args: &[String]) -> Option { + let advance = match name { + "commit-cell-edit" => Advance::Down, + "commit-and-advance-right" => Advance::Right, + _ => return None, + }; + let edit_mode = args.first().and_then(|mode| AppMode::from_name(mode)); + Some( + edit_mode + .and_then(|mode| commit(state, mode, advance)) + .unwrap_or(Step::Upstream), + ) +} + /// The command names this client answers by **asking its host**, rather than /// by composing anything. See `context/code/clients.md` (improvise-jz6w). fn requested(name: &str) -> Option { @@ -450,10 +469,6 @@ fn composed( // ── editing ────────────────────────────────────────────────── "edit-or-drill" => edit_or_drill(state, AppMode::from_name(args.first()?)?), - "commit-cell-edit" => commit(state, AppMode::from_name(args.first()?)?, Advance::Down), - "commit-and-advance-right" => { - commit(state, AppMode::from_name(args.first()?)?, Advance::Right) - } // ── the category panel ─────────────────────────────────────── "cycle-axis-at-cursor" => Some(match cat_at_cursor(state) { @@ -933,7 +948,7 @@ enum Advance { /// `CommitAndAdvance::execute`, for the one case a thin client can compose: a /// plain grid cell. The three-way fork and the mirror order: clients.md. -fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option> { +fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option { let (window_rows, window_cols) = state.window; if window_rows == 0 || window_cols == 0 { return None; @@ -943,7 +958,7 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option Option Option> { +) -> Option { let CellId::Coordinate { .. } = id else { return None; }; @@ -996,7 +1011,7 @@ fn rename_coordinate( }; commands.push(Command::ChangeMode(AppMode::Normal)); commands.extend(advanced(state, edit_mode, advance)?); - Some(commands) + Some(Step::Local(commands)) } /// The write a commit makes: none when untouched (improvise-ixxx), else to the @@ -1023,13 +1038,13 @@ fn written(state: &ClientState, text: &str) -> Option> { } /// A commit whose target is not captured yet, because the host has not answered -/// this client's own last write. The editor stays open (improvise-fdk1). -fn still_waiting() -> Vec { - vec![Command::SetStatus( - "still waiting for the host to answer your last edit".to_string(), - )] +/// this client's own last write. Declined, so no welded clear runs (improvise-joed). +fn still_waiting() -> Step { + Step::Declined(vec![Command::SetStatus(STILL_WAITING.to_string())]) } +const STILL_WAITING: &str = "still waiting for the host to answer your last edit"; + /// `CommitAndAdvance` over a creating column: a schema change over the typed /// NAME. Why +cat does not advance: clients.md (improvise-gcvb). fn create_column( @@ -3466,6 +3481,25 @@ mod tests { /// last write is unanswered, declines out loud. Evidence file. #[test] fn a_value_commit_before_the_host_answers_the_last_one_waits_out_loud() { + let client = reopened_before_the_host_answers(); + assert_eq!(client.opened, None, "setup: nothing captured yet"); + let KeyOutcome::Local(second) = resolve(&client, ev(KeyCode::Enter)) else { + panic!("a commit with no target yet must answer, not go upstream"); + }; + assert_eq!( + second[..1], + [Command::SetStatus(STILL_WAITING.into())], + "it says so: {second:?}" + ); + assert!( + !second.iter().any(|c| matches!(c, Command::Model { .. })), + "and it must write nothing: {second:?}" + ); + } + + /// A commit, sent and unanswered, whose advance reopened the editor on a + /// stored cell, where the user has since typed `43`. + fn reopened_before_the_host_answers() -> ClientState { let mut client = client_on(record_cell()); // The row the advance lands on is a stored cell too, so the commit gets // past the cursor's cell and reaches the capture. @@ -3481,15 +3515,28 @@ mod tests { }; client.fold_outgoing(&first); assert!(client.view.mode.is_editing(), "setup: the advance reopened"); - assert_eq!(client.opened, None, "setup: nothing captured yet"); client.view.buffers.insert("edit".into(), "43".into()); + client + } + + /// improvise-joed: the waiting decline kept the editor open, but the key's + /// welded `clear-buffer` still ran and lost the text. Evidence file. + #[test] + fn a_commit_that_waits_for_the_host_keeps_the_typed_text() { + let mut client = reopened_before_the_host_answers(); let KeyOutcome::Local(second) = resolve(&client, ev(KeyCode::Enter)) else { panic!("a commit with no target yet must answer, not go upstream"); }; - assert_eq!(second[..1], still_waiting()[..], "it says so: {second:?}"); - assert!( - !second.iter().any(|c| matches!(c, Command::Model { .. })), - "and it must write nothing: {second:?}" + client.fold_outgoing(&second); + assert_eq!( + client.view.status_msg, STILL_WAITING, + "setup: this is the waiting decline" + ); + assert!(client.view.mode.is_editing(), "the editor stays open"); + assert_eq!( + client.view.buffers.get("edit").map(String::as_str), + Some("43"), + "the text the user typed must survive the decline: {second:?}" ); } diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index eb6f5127..4483910e 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1459,3 +1459,69 @@ fn a_keyboard_edit_is_refused_when_its_record_was_written_while_open() { ); assert_eq!(cell(&host, "North", "Cost"), None, "nothing was written"); } + +/// improvise-joed on the rename path: a second rename typed before the host +/// answers the first waits, and keeps its text. Evidence file. +#[test] +fn a_rename_that_waits_for_the_host_keeps_the_typed_text() { + let mut host = three_records_host(); + let mut a = Client::new(); + a.set_viewport(10, 10); + let (id, snapshot) = host.connect(10, 10); + deliver(&mut a, &[snapshot]); + press(&mut host, id, &mut a, "T"); + tile_to(&mut host, id, &mut a, "_Index", "r"); + tile_to(&mut host, id, &mut a, "_Dim", "c"); + press(&mut host, id, &mut a, "Escape"); + assert!(a.state().grid.records, "setup: records mode"); + let coordinate = (0..a.state().grid.extent.cols) + .find(|&c| { + format!("{:?}", a.state().grid.cell(0, c).and_then(|x| x.id.clone())) + .starts_with("Some(Coordinate") + }) + .expect("a coordinate column"); + while a.state().view.cursor().selected.1 < coordinate { + press(&mut host, id, &mut a, "l"); + } + press(&mut host, id, &mut a, "j"); + press(&mut host, id, &mut a, "i"); + for key in ["Backspace"; 5] { + press(&mut host, id, &mut a, key); + } + for key in ["Z", "e", "d"] { + press(&mut host, id, &mut a, key); + } + // The first rename is composed and folded as sent, and never answered. + let first = a + .key(&BrowserKey::new("Enter")) + .expect("the rename composes"); + assert!( + matches!(&first, ClientFrame::Commands(c) if c.iter().any(|c| matches!(c, improvise_protocol::Command::Model { .. }))), + "setup: the first Enter renames" + ); + assert!( + a.state().view.mode.is_editing(), + "setup: the commit reopened" + ); + for key in ["Backspace", "Backspace", "Q"] { + a.key(&BrowserKey::new(key)); + } + let typed = a.state().view.buffers.get("edit").cloned(); + assert!( + typed.as_deref().is_some_and(|t| !t.is_empty()), + "setup: there is typed text to lose: {typed:?}" + ); + + let second = a.key(&BrowserKey::new("Enter")); + assert!( + a.state().view.status_msg.starts_with("still waiting"), + "setup: this is the waiting decline: {:?}", + a.state().view.status_msg + ); + assert!(a.state().view.mode.is_editing(), "the editor stays open"); + assert_eq!( + a.state().view.buffers.get("edit").cloned(), + typed, + "the text the user typed must survive the decline: {second:?}" + ); +} From 7817d74da1f05bf91a74738203e4c2963df77dc0 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:06:41 -0700 Subject: [PATCH 076/114] improvise-5m9z: red lockstep test, an advance past the window opens the editor Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../improvise-wasm-client/tests/lockstep.rs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 462e9ab1..fef7fc1b 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2124,6 +2124,72 @@ fn fold_host() -> App { app } +/// improvise-5m9z: an advance past the client's window lands on a cell it holds +/// no id for, and must settle as the TUI does. See `context/test-evidence/clients.md`. +#[test] +fn advancing_the_editor_past_the_window_settles_where_the_tui_does() { + let expanded = || { + let mut app = fold_host(); + let view = app.model_state.workbook.active_view_mut(); + view.toggle_group_collapse("Month", "Q2"); + app.rebuild_layout(); + app + }; + let cases: [(fn() -> App, (usize, usize), (usize, usize), &str, bool); 3] = [ + (fold_host, (2, 6), (1, 1), "Enter", false), + (yank_host, (6, 3), (1, 2), "Tab", false), + (expanded, (2, 6), (1, 1), "Enter", true), + ]; + for (make, window, at, key, editing) in cases { + let mut tui = make(); + tui.cursor_mut().selected = at; + for k in ["i", key] { + reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap()); + } + assert_eq!(tui.view_state.mode.is_editing(), editing, "setup: the TUI"); + + let mut host = improvise_command::ui::host::Host::new(make()); + let (session, welcome) = host.connect(window.0, window.1); + let mut client = ClientState { + window, + ..ClientState::default() + }; + deliver(vec![welcome], session, &mut client); + let placed = vec![improvise_protocol::Command::SetSelected { + row: at.0, + col: at.1, + }]; + client.fold_outgoing(&placed); + deliver( + host.handle(session, ClientFrame::Commands(placed)), + session, + &mut client, + ); + for k in ["i", key] { + let event = BrowserKey::new(k).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{k}: the client must answer this key locally"); + }; + client.fold_outgoing(&cmds); + deliver( + host.handle(session, ClientFrame::Commands(cmds)), + session, + &mut client, + ); + } + assert_eq!( + client.view.cursor().selected, + tui.view_state.cursor().selected, + "{key}: the advance must land past the window on both ends" + ); + assert_eq!( + format!("{:?}", client.view.mode), + format!("{:?}", tui.view_state.mode), + "{key} from {at:?} in a {window:?} window: the client and the TUI disagree" + ); + } +} + /// improvise-enz6: a commit that advances onto a cell `i` declines re-opens no /// editor there, on either end. See `context/test-evidence/clients.md`. #[test] From 9eb83538fe2e1091ef4bae52dcd4345aac543759 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:08:26 -0700 Subject: [PATCH 077/114] A message beside its open editor stands in the browser too (improvise-1x9h) The toast hid every status after six seconds, so a refusal the user was still fixing vanished with its prompt open: su4u's bug on a timer. The browser now asks the TUI's question. EditorSession moves into improvise-protocol, ClientState records which editor a status was written in, and RenderModel::status_stands tells the toast to hold it until that editor closes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 55 +++++++++++ context/code/tui.md | 11 ++- context/test-evidence/clients.md | 57 +++++++++++ crates/improvise-protocol/src/client.rs | 110 ++++++++++++++++++++- crates/improvise-protocol/src/lib.rs | 21 ++++ crates/improvise-wasm-client/src/render.rs | 4 + crates/improvise-wasm-client/src/socket.rs | 22 ++++- harness/tests/header.spec.js | 34 +++++++ src/draw.rs | 38 ++----- web/app.js | 4 +- web/shell.js | 23 ++++- web/tests/shell.test.js | 55 +++++++++++ 12 files changed, 389 insertions(+), 45 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 73c7cbde..b7968798 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -2267,6 +2267,61 @@ reads that text. An emptied node cannot tell "nothing was said" from "something else was said", and the assertion would pass vacuously the moment the timer fired. +#### A message beside its open editor stands until that editor closes (improvise-1x9h) + +The timer is wrong for one kind of message: a refusal the user is still +fixing. Six seconds after it appears, the toast hid it while the minibuffer it +was about stayed open. That is the TUI's su4u bug with a timer instead of a +keypress, and the rule is the TUI's, so both front ends ask one question. + +**The rule: a message stands while the editor it was written in is still the +open one.** "The editor" is `improvise_protocol::EditorSession`, the value the +TUI's `press` compares before and after a key (`context/code/tui.md`, "What +'the same editor' means"). It moved from `src/draw.rs` into the protocol crate +so that the browser asks the same question rather than a second derivation of +it (design principles §5). + +**The decision is Rust's, and the page only obeys it.** `ClientState` observes +the status after every fold (`settle_status`, beside `settle_mode` and +`settle_editor`): + +- a status that has changed since it was last observed is owned by the editor + open at that moment, or by nothing outside a text-entry mode; +- an unchanged status loses its owner, for good, as soon as a different editor + (or none) is open. + +`RenderModel::status_stands` is `ClientState::status_stands()`. The page passes +it to `createToasts.offer(text, stands)`. A standing message arms no timer. When +it stops standing it hides at once, not six seconds later, because the TUI drops +a refusal on the key that leaves its editor. + +**"For good" is the part that needs memory.** A refusal in `FormulaEdit`, then +`Esc`, then `n` again opens an editor *equal* to the first: the same mode, the +same cursor, no seed. Comparing the current editor with nothing would make the +old message stand again beside the new opening. The owner is therefore +recorded when the message is written and cleared when that editor closes, not +recomputed from what is open now. + +**A status written outside a fold must be observed where it is written.** +`socket.rs` writes a rejection's sentence *after* `refused()`, so the sentence +survives the rollback. It now writes it through `ClientState::notify`, which +observes it on the spot. Assigning `status_msg` directly leaves it unobserved +until the next fold. By then the editor may have changed, and the message +would be owned by the wrong one. + +**What it inherits from the identity rule above.** An identical message written +again in a *new* editor is not a new write, so it neither stands nor re-shows. +That is the "two identical messages show once" cost, and it has the same cause: +nothing beside `status_msg` says that it was written again. + +**Where the rule is live.** A formula refusal still never reaches a browser. +The client folds `CommitBuffer`'s optimistic "Formula added", and the host's +error is a write to the host's own slice +(`a_formula_the_client_cannot_parse_diverges_on_the_status_line`). What +reaches a browser today is a host rejection that leaves the editor open, such +as a refused save. The rule becomes fully live when a host-addressed status +reaches its composer. + ### What the styling is, and what no test here can see `web/style.css` stopped being "legibility only" and is now a small design diff --git a/context/code/tui.md b/context/code/tui.md index a3589e3e..964b9901 100644 --- a/context/code/tui.md +++ b/context/code/tui.md @@ -505,7 +505,10 @@ and stay in the minibuffer: a `:` line that switches view settles back to Normal. So nothing could test the field, and an untestable field is a claim no test can check. -**The browser is not covered by this.** There, `status_msg` is a toast that -hides after `TOAST_MS` (six seconds), and the minibuffer element draws only the -prompt and the text. A refusal therefore disappears on a timer, whatever the -user is doing. That is a different mechanism and has not been changed here. +**The browser asks the same question (improvise-1x9h).** `EditorSession` now +lives in `improvise-protocol` and is built from a `ViewState`, so the TUI's +`press` and the browser's `ClientState::status_stands` compare the same value. +The browser's mechanism differs: its message is a toast on a timer, and there +is no per-key clear. So it keeps the message's *owner* and suspends the timer +while that owner is open. See `context/code/clients.md`, "A message beside its +open editor stands until that editor closes". diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 1b893acb..374fa5a2 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5117,3 +5117,60 @@ Rival injected: `claim_as_read` ignoring the captured generation and claiming the current one. That is the pre-fix behaviour, restated after the fix. Only this test failed in the whole workspace. The layout tier staying current is still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`. + +### a_message_stands_while_the_editor_it_was_written_in_is_open + +improvise-1x9h, the browser half of improvise-su4u. In the browser, +`status_msg` was only ever a toast, and `createToasts` hid it after `TOAST_MS` +(six seconds) whatever the mode. A refusal written beside an open minibuffer +therefore disappeared while the user was still fixing the thing it described. + +The bug was shown red in JavaScript first, where the timer lives. `shell.test.js`, +"a standing message outlives the timer and goes with its editor", failed on the +unchanged `createToasts` with "a standing message arms no timer". The decision +of *whether* a message stands is Rust's, and this test pins it. It opens +`FormulaEdit`, writes a status, and asserts that the message stands, and still +stands after a buffer edit. Then it leaves to `FormulaPanel` and asserts that +it does not. Then it re-opens an equal `FormulaEdit` and asserts that the +message *still* does not stand, although `status_msg` is unchanged. + +- **Rival `any_editor`** (`status_stands` = non-empty status and any editor + open): fails this test and its sibling below, and nothing else in the + workspace (1943 passed, 2 failed). +- **Rival `owner_never_lost`** (drop the `else if` that clears the owner once + its editor closes): fails only this test, on the re-opened equal editor. + That clause is what the last assertion exists for. Without it an equal + opening is indistinguishable from the original one. + +### a_message_does_not_stand_beside_an_editor_it_was_not_written_in + +improvise-1x9h. There are three ways to be a different editor, and one case +each: a status written in Normal before an editor opens; a cell editor whose +cursor then moves from (1,1) to (2,1), which is a commit that advanced; and a +cell editor whose seed changes from `4` to `47` on the same cell, which is a +re-opening after a commit (improvise-sx7h's `Tab` in the last column). The +cursor sits at a nonzero row so that "the cell" is not "the first cell". +Rival `any_editor` fails it on the first case. + +### a_rejection_that_arrives_in_an_open_editor_stands_beside_it + +improvise-1x9h. `socket.rs` writes a rejection's sentence after `refused()`, +outside any fold, so nothing observed it. The test folds `Editing`, then a +`Rejected(NoDocumentPath)`. A refused save rolls nothing back and closes +nothing, so the editor is still open. It asserts that the sentence stands. + +- **Rival `direct_write`** (the old `state.view.status_msg = message`): fails + this test. The status is never observed, so it has no owner. + +**The DOM half.** `harness/tests/header.spec.js`, "a message written beside an +open minibuffer outlives the timer", is the check on the wire from +`model.status_stands` through `shell.setModel` to `toasts.offer`. +`render_surface.test.js` cannot see that wire: it is a substring search, so it +passes as long as `model.status_stands` appears anywhere in `app.js`. The spec +opens the formula editor in the real demo and delivers +`{"Commands":[{"SetStatus":"Formula error"}]}` as a worker message. It runs +the page clock past ten seconds and asserts that the toast is still visible. +Then it presses `Escape` and asserts that the toast hides. The rival +`onModel: (text, stands) => toasts.offer(text)` fails it at the +still-visible-after-ten-seconds assertion, and the node suite stays green +under that rival. diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index a2615b56..62ca8f93 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -2,9 +2,9 @@ //! The two properties this dispatch owes: `context/code/clients.md`. use crate::{ - BasisCache, CategoryTreeCache, Command, DocumentCache, FormulaCache, GridCache, Projection, - Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, reduce_view, - settled_mode, + BasisCache, CategoryTreeCache, Command, DocumentCache, EditorSession, FormulaCache, GridCache, + Projection, Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, + reduce_view, settled_mode, }; use serde::Serialize; @@ -43,6 +43,12 @@ pub struct ClientState { /// replay the rest. Why a queue: `context/code/clients.md`. #[serde(skip)] pub in_flight: std::collections::VecDeque, + /// The status text last observed, and the editor it was written in while + /// that editor is still open (improvise-1x9h, `context/code/clients.md`). + #[serde(skip)] + pub status_seen: String, + #[serde(skip)] + pub status_owner: Option, } /// One frame this client has sent. `awaited` is true exactly when the host @@ -75,6 +81,7 @@ impl ClientState { } self.settle_mode(); self.settle_editor(); + self.settle_status(); self.revision += 1; } @@ -87,6 +94,7 @@ impl ClientState { } self.settle_mode(); self.settle_editor(); + self.settle_status(); self.revision += 1; } @@ -127,9 +135,26 @@ impl ClientState { self.last_rejection = Some(rejection); self.settle_mode(); self.settle_editor(); + self.settle_status(); self.revision += 1; } + /// Say something to this client's user: the one door for a status written + /// outside a fold, so it is observed where it was written. + pub fn notify(&mut self, message: String) { + self.view.status_msg = message; + self.settle_status(); + self.revision += 1; + } + + /// Whether the status belongs to the editor still open, so a renderer must + /// not expire it (improvise-1x9h). The TUI asks the same `EditorSession`. + pub fn status_stands(&self) -> bool { + !self.view.status_msg.is_empty() + && self.status_owner.is_some() + && self.status_owner == EditorSession::of(&self.view) + } + /// The settled slice plus every frame still in flight, in order. What a /// dropped frame leaves behind is the *rest* of them, not nothing. fn replay(&mut self) { @@ -177,6 +202,18 @@ impl ClientState { } } + /// A new message is owned by the editor open as it arrives; an old one loses + /// its owner for good once that editor is not the open one. + fn settle_status(&mut self) { + let now = EditorSession::of(&self.view); + if self.view.status_msg != self.status_seen { + self.status_seen = self.view.status_msg.clone(); + self.status_owner = now; + } else if self.status_owner != now { + self.status_owner = None; + } + } + /// The widget dispatch: a projection names the widget it is addressed to. /// The `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32). fn apply_projection(&mut self, projection: &Projection) { @@ -435,6 +472,73 @@ mod tests { ); } + /// improvise-1x9h: a message written while an editor is open stands until + /// that editor closes, and never again after. Evidence file. + #[test] + fn a_message_stands_while_the_editor_it_was_written_in_is_open() { + let mut state = ClientState::default(); + state.fold(&Command::ChangeMode(crate::AppMode::formula_edit())); + state.fold(&Command::SetStatus("Formula error".into())); + assert!( + state.status_stands(), + "a refusal beside its editor must stand" + ); + state.fold(&Command::SetBuffer { + name: "formula".into(), + value: "Total = ".into(), + }); + assert!(state.status_stands(), "typing is the same editor"); + + state.fold(&Command::ChangeMode(crate::AppMode::FormulaPanel)); + assert!(!state.status_stands(), "the refusal outlived its editor"); + state.fold(&Command::ChangeMode(crate::AppMode::formula_edit())); + assert_eq!(state.view.status_msg, "Formula error", "nothing cleared it"); + assert!( + !state.status_stands(), + "an equal editor opened later is another opening, and the message is not its" + ); + } + + /// The editor is the one it was WRITTEN in: not any editor, and not the next + /// cell's after a commit advances (improvise-1x9h, sx7h). Evidence file. + #[test] + fn a_message_does_not_stand_beside_an_editor_it_was_not_written_in() { + let mut state = ClientState::default(); + state.fold(&Command::SetStatus("saved".into())); + state.fold(&Command::ChangeMode(crate::AppMode::editing())); + assert!( + !state.status_stands(), + "written in Normal, it is the timer's" + ); + + let mut state = ClientState::default(); + state.fold(&Command::SetSelected { row: 1, col: 1 }); + state.fold(&Command::ChangeMode(crate::AppMode::editing())); + state.fold(&Command::SetStatus("a note about this cell".into())); + assert!(state.status_stands()); + state.fold(&Command::SetSelected { row: 2, col: 1 }); + assert!( + !state.status_stands(), + "the note followed the editor down a row" + ); + + let mut state = ClientState::default(); + state.fold(&Command::SetSelected { row: 1, col: 1 }); + state.fold(&Command::ChangeMode(crate::AppMode::editing())); + state.fold(&Command::SetOpenedEditor(Some(crate::OpenedEditor::on( + "4".into(), + )))); + state.fold(&Command::SetStatus("a note about this cell".into())); + assert!(state.status_stands()); + state.fold(&Command::SetOpenedEditor(Some(crate::OpenedEditor::on( + "47".into(), + )))); + assert!( + !state.status_stands(), + "a re-opening on the same cell is another editor" + ); + } + #[test] fn every_fold_bumps_the_revision_so_a_renderer_can_poll_it() { let mut state = ClientState::default(); diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index 84b55876..9e7e288e 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -554,6 +554,27 @@ impl ViewState { } } +/// What one opening of a minibuffer sets. Equal at two moments means the same +/// editor is still open; both front ends ask this. `context/code/tui.md`. +#[derive(Debug, Clone, PartialEq)] +pub struct EditorSession { + mode: AppMode, + cell: (usize, usize), + seed: Option, +} + +impl EditorSession { + /// The editor open in `view`, or `None` outside a text-entry mode. + pub fn of(view: &ViewState) -> Option { + view.mode.minibuffer()?; + Some(Self { + mode: view.mode.clone(), + cell: view.cursor().selected, + seed: view.opened_editor.as_ref().map(|o| o.seed.clone()), + }) + } +} + #[cfg(test)] mod boundary_tests { /// improvise-cqi AC 2: the part of the boundary the compiler can hold. diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 8535f78b..02fbfa9d 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -16,6 +16,9 @@ pub struct RenderModel { /// The mode's own label, off `AppMode` — the client does not spell it. pub mode: String, pub status: String, + /// Whether `status` belongs to the editor still open, so the page must not + /// expire it on a timer (improvise-1x9h). + pub status_stands: bool, /// The document's active view, as this client knows it (gotcha 29). pub view_name: String, /// The title bar: the document's name, whether it has unsaved changes, and @@ -238,6 +241,7 @@ pub fn render_model(state: &ClientState) -> RenderModel { revision: state.revision, mode: improvise_protocol::mode_label(&state.view.mode).to_string(), status: state.view.status_msg.clone(), + status_stands: state.status_stands(), view_name: state.view.active_view.clone(), doc_name: state.document.name.clone(), doc_dirty: state.document.dirty, diff --git a/crates/improvise-wasm-client/src/socket.rs b/crates/improvise-wasm-client/src/socket.rs index 1f1c0d21..e080f18c 100644 --- a/crates/improvise-wasm-client/src/socket.rs +++ b/crates/improvise-wasm-client/src/socket.rs @@ -95,7 +95,7 @@ impl Client { // The sentence goes on *after* the rollback: it is this // client's own notification, and the slice it restores has none. state.refused(rejection); - state.view.status_msg = message; + state.notify(message); } ServerFrame::Accepted => self.state_mut().accepted(), } @@ -195,6 +195,26 @@ mod tests { ); } + /// A rejection's sentence is written outside a fold, so it must still be owned + /// by the editor it arrived in (improvise-1x9h). Evidence file. + #[test] + fn a_rejection_that_arrives_in_an_open_editor_stands_beside_it() { + let mut client = Client::default(); + client + .state_mut() + .fold(&Command::ChangeMode(improvise_protocol::AppMode::editing())); + client.fold_frame(ServerFrame::Rejected(Rejection::NoDocumentPath)); + assert!( + client.state().view.mode.is_editing(), + "a refused save closes nothing" + ); + assert!(!client.state().view.status_msg.is_empty()); + assert!( + client.state().status_stands(), + "the refusal was not observed where it arrived, so it would expire" + ); + } + /// A rejection that refused an **edit** must say nothing was applied; the /// two `Save` answers refused none. Evidence: clients.md (improvise-8uj8). #[test] diff --git a/harness/tests/header.spec.js b/harness/tests/header.spec.js index 0fd525c5..7c27dc1e 100644 --- a/harness/tests/header.spec.js +++ b/harness/tests/header.spec.js @@ -182,4 +182,38 @@ test.describe("the app bar is reserved, not fitted", () => { const toast = await page.locator("#toast").boundingBox(); expect(toast.y + toast.height).toBeLessThanOrEqual(alone.y); }); + + // improvise-1x9h. Whether a message stands is Rust's answer; this is the + // wire from the render model to the toast's timer, which node cannot see. + test("a message written beside an open minibuffer outlives the timer", async ({ page }) => { + await page.addInitScript(() => { + const Real = window.Worker; + window.Worker = class extends Real { + constructor(...args) { + super(...args); + window.__worker = this; + } + }; + }); + await page.clock.install(); + await theDemo(page); + + await page.keyboard.press("F"); + await page.keyboard.press("n"); + await expect(page.locator("#mode")).toHaveText("FORMULA"); + // A host message arriving during text entry has no keyboard route in the + // demo, so the frame is delivered as the worker would deliver it. + await page.evaluate(() => { + const frame = JSON.stringify({ Commands: [{ SetStatus: "Formula error" }] }); + window.__worker.dispatchEvent(new MessageEvent("message", { data: frame })); + }); + await expect(page.locator("#toast")).toContainText("Formula error"); + + await page.clock.runFor(10_000); + await expect(page.locator("#toast")).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(page.locator("#mode")).toHaveText("FORMULAS"); + await expect(page.locator("#toast")).toBeHidden(); + }); }); diff --git a/src/draw.rs b/src/draw.rs index 05708b4a..78110a7f 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -17,6 +17,7 @@ use ratatui::{ }; use crate::command::key; +use crate::protocol::EditorSession; use crate::ui::app::{App, AppMode, MinibufColor}; use crate::ui::category_panel::CategoryContent; use crate::ui::formula_panel::FormulaContent; @@ -102,41 +103,17 @@ pub fn run_tui( /// One keypress in the TUI. A message lasts until the next key (improvise-cq3d), /// except while one editor stays open under it (su4u, sx7h). `context/code/tui.md`. fn press(app: &mut App, key: key::KeyEvent) -> Result<()> { - let before = EditorSession::of(app); + let before = EditorSession::of(&app.view_state); let stale = app.view_state.status_msg.clone(); app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(String::new()))]); app.handle_key(key)?; - let still_editing = before.is_some() && before == EditorSession::of(app); + let still_editing = before.is_some() && before == EditorSession::of(&app.view_state); if still_editing && app.view_state.status_msg.is_empty() && !stale.is_empty() { app.apply_effects(vec![Box::new(crate::ui::effect::SetStatus(stale))]); } Ok(()) } -/// What one opening of a minibuffer sets, which is all the TUI loop can see of -/// it: equal before and after a key means the same editor (improvise-sx7h). -#[derive(Debug, PartialEq)] -struct EditorSession { - mode: AppMode, - cell: (usize, usize), - seed: Option, -} - -impl EditorSession { - fn of(app: &App) -> Option { - app.view_state.mode.minibuffer()?; - Some(Self { - mode: app.view_state.mode.clone(), - cell: app.cursor().selected, - seed: app - .view_state - .opened_editor - .as_ref() - .map(|o| o.seed.clone()), - }) - } -} - // ── Key event conversion (TUI boundary) ────────────────────────────────────── // The only place crossterm's key types and `command::key` may both appear. @@ -604,8 +581,9 @@ fn draw_welcome(f: &mut Frame, area: Rect) { #[cfg(test)] mod bottom_bar_tests { - use super::{EditorSession, draw_bottom_bar, minibuffer_line, press}; + use super::{draw_bottom_bar, minibuffer_line, press}; use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; + use crate::protocol::EditorSession; use crate::ui::app::{App, AppMode}; use crate::workbook::Workbook; use ratatui::{Terminal, backend::TestBackend, layout::Rect}; @@ -837,14 +815,14 @@ mod bottom_bar_tests { let mut app = App::new(Workbook::new("T"), None); let item_add = |category: &str| AppMode::item_add(category.into()); app.view_state.mode = item_add("Region"); - let region = EditorSession::of(&app); + let region = EditorSession::of(&app.view_state); assert!(region.is_some(), "ItemAdd is a text-entry mode"); app.view_state.mode = item_add("Month"); - assert_ne!(region, EditorSession::of(&app)); + assert_ne!(region, EditorSession::of(&app.view_state)); app.view_state.mode = item_add("Region"); assert_eq!( region, - EditorSession::of(&app), + EditorSession::of(&app.view_state), "and the same one is the same" ); } diff --git a/web/app.js b/web/app.js index 3277eb55..5de93091 100644 --- a/web/app.js +++ b/web/app.js @@ -112,7 +112,7 @@ const shell = createShell({ // it stays reachable rather than being lost to the cap. el.status.title = text; }, - onModel: (text) => toasts.offer(text), + onModel: (text, stands) => toasts.offer(text, stands), timeoutMs: HANDSHAKE_TIMEOUT_MS, }); // The harness asks whether the watchdog is ARMED, because a negative assertion @@ -268,7 +268,7 @@ function repaint() { el.hint.textContent = model.hint; paintHelp(model); paintMinibuffer(model); - shell.setModel(model.status); + shell.setModel(model.status, model.status_stands); el.viewName.textContent = model.view_name; // The title bar, off the cache `src/draw.rs::draw_title` folds — one // derivation, both front ends (improvise-z754, improvise-5jna). diff --git a/web/shell.js b/web/shell.js index 296fa1de..bee7a693 100644 --- a/web/shell.js +++ b/web/shell.js @@ -16,7 +16,10 @@ export const TOAST_MS = 6000; /** `ViewState::status_msg` is STATE, not an event: a repaint re-offers the * string that is already on screen, so dismissal keys off message IDENTITY - * and never off a timer alone. What that costs: context/code/clients.md. */ + * and never off a timer alone. What that costs: context/code/clients.md. + * + * `stands` is Rust's answer (`RenderModel::status_stands`): while it holds the + * message is on no timer, and when it stops the message goes (improvise-1x9h). */ export function createToasts({ onShow, onHide, @@ -28,12 +31,21 @@ export function createToasts({ // mechanism: a repaint offering it again is recognised and dropped. let offered = ""; let timer = null; + let standing = false; return { - offer(text) { + offer(text, stands = false) { const next = text ?? ""; - if (next === offered) return; + if (next === offered) { + // Standing ends once: the editor it belonged to has closed. + if (standing && !stands) { + standing = false; + onHide(); + } + return; + } offered = next; + standing = next !== "" && stands; clearTimer(timer); timer = null; if (next === "") { @@ -41,6 +53,7 @@ export function createToasts({ return; } onShow(next); + if (standing) return; timer = setTimer(() => { timer = null; onHide(); @@ -349,8 +362,8 @@ export function createShell({ setShell(text) { onShell(text); }, - setModel(text) { - onModel(text); + setModel(text, stands = false) { + onModel(text, stands); }, /** A peer that never answers the handshake. Deliberately about the *first * frame*, not either peer's own failure modes — clients.md. */ diff --git a/web/tests/shell.test.js b/web/tests/shell.test.js index e8a9570d..d5fee7e5 100644 --- a/web/tests/shell.test.js +++ b/web/tests/shell.test.js @@ -125,6 +125,61 @@ test("a host that clears the status hides the toast without waiting", () => { assert.equal(clock.count(), 0, "a cleared message leaves no timer behind"); }); +/// improvise-1x9h: a message that stands beside an open editor is the user's +/// until the editor closes, not the timer's. Evidence: clients.md. +test("a standing message outlives the timer and goes with its editor", () => { + const clock = fakeClock(); + const shown = []; + let visible = false; + const toasts = createToasts({ + onShow: (t) => { + shown.push(t); + visible = true; + }, + onHide: () => { + visible = false; + }, + ttlMs: 6000, + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, + }); + + toasts.offer("Formula error", true); + assert.equal(clock.count(), 0, "a standing message arms no timer"); + clock.fire(); + assert.equal(visible, true, "the refusal hid while its editor was open"); + toasts.offer("Formula error", true); + assert.deepEqual(shown, ["Formula error"], "a repaint is not a second event"); + + toasts.offer("Formula error", false); + assert.equal(visible, false, "the refusal outlived its editor"); + assert.equal(clock.count(), 0, "leaving hides now, not on a timer"); + toasts.offer("Formula error", true); + assert.equal(visible, false, "a message that stopped standing came back"); +}); + +/// The other direction: a message that does not stand is on the timer exactly +/// as before (improvise-1x9h). +test("a message that does not stand keeps its timer", () => { + const clock = fakeClock(); + let visible = false; + const toasts = createToasts({ + onShow: () => { + visible = true; + }, + onHide: () => { + visible = false; + }, + ttlMs: 6000, + setTimer: clock.setTimer, + clearTimer: clock.clearTimer, + }); + toasts.offer("Formula added", false); + assert.equal(clock.count(), 1); + clock.fire(); + assert.equal(visible, false); +}); + test("an empty status on the first frame shows nothing", () => { // The common case: every repaint before any command offers "". A feed that // treated the first offer as new would flash an empty toast on load. From 88e53aed73a239075f5f0e1c30bcd43d857803d3 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:09:54 -0700 Subject: [PATCH 078/114] Hold NEEDS_THE_HOST complete from the keymaps, not the help rows improvise-oyaa. every_silent_binding_is_on_the_list presses every binding the Normal and search-mode keymaps reach (parents unshadowed, prefixes expanded) on the furnished fixture, and requires each silent one to be on NEEDS_THE_HOST. The two earlier guards could not see an unlisted binding that no help row names: dropping force-quit (Ctrl-Q) from the list failed nothing. Now it fails this test. every_binding_reaches_parents_and_prefixes checks the walk itself against a hand-built keymap, because no silent binding in today's maps sits in a parent or behind a prefix, so a walk that skipped either passed the first test unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 9 ++- context/test-evidence/clients.md | 55 ++++++++++++-- crates/improvise-protocol/src/resolve.rs | 94 +++++++++++++++++++++++- 3 files changed, 148 insertions(+), 10 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index d2a7b1b1..50e7888a 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -625,14 +625,19 @@ Marking was chosen over omitting for three reasons: The TUI is unaffected, because the marking lives in the browser's renderer. -**One list, held from both sides.** `NEEDS_THE_HOST` is production data because -the renderer needs it. Two tests hold it to what `resolve` actually does: +**One list, held from three sides.** `NEEDS_THE_HOST` is production data because +the renderer needs it. Three tests hold it to what `resolve` actually does: - `the_command_names_that_still_need_the_host_are_these` fails if a name on it resolves locally. - `every_help_row_the_browser_cannot_run_is_one_it_marks` fails if a help row is silent in the browser without being on the list, or on the list without being silent. It also fails if a *hint line* ever names a listed command, because the status bar is never marked. +- `every_silent_binding_is_on_the_list` fails if any binding the Normal or + search-mode keymap reaches is silent and unlisted (improvise-oyaa). It walks + the keymaps, parents and prefixes included, because the help rows are only a + subset: no help row names Ctrl-Q, so before it `force-quit` could leave the + list with every test still passing. `every_advertised_command_answers_in_its_own_mode` therefore no longer restates the black holes. It skips a silent row whose command is on the list, because diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 8dac3b67..a78a5371 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -226,13 +226,10 @@ fails, and so does `every_help_row_the_browser_cannot_run_is_one_it_marks`. `enter-advance` back while its resolver stays in `composed`, and this test fails, along with three others. -**The list is held complete only where a help row reaches it.** Dropping -`force-quit` from it fails nothing, because no help row names Ctrl-Q, so no test -presses it and finds it silent. Dropping `exit-search-mode`, which a row does -name, fails three tests. So the two guards prove the list has no false entries, -and that no help row is an unmarked black hole. They do not prove that every -silent binding is listed. That is a residual, and it is reported for filing, -not fixed here. +**Until improvise-oyaa the list was held complete only where a help row reached +it.** Dropping `force-quit` failed nothing, because no help row names Ctrl-Q, so +no test pressed it and found it silent. `every_silent_binding_is_on_the_list` +closes that by walking the keymaps instead of the help rows. ### every_help_row_the_browser_cannot_run_is_one_it_marks @@ -255,6 +252,50 @@ Rivals, each run against the fixed tree: - Add `move-selection` to the list: red. This test fails, and so does the list's own test. +### every_silent_binding_is_on_the_list + +improvise-oyaa. The third guard on `NEEDS_THE_HOST`, and the one that reads the +keymaps rather than the help rows. Every binding the Normal and search-mode +keymaps reach is pressed on the `furnished` fixture: each key, every parent's +key a child does not shadow, and each prefix followed by each key of its +sub-keymap. A binding that is silent must name a command on the list. + +The other two guards could not see this. +`the_command_names_that_still_need_the_host_are_these` iterates the list, so it +cannot notice a name missing from it. +`every_help_row_the_browser_cannot_run_is_one_it_marks` presses only what the +help overlay names, and no help row names Ctrl-Q. So dropping `force-quit` from +the list failed nothing until this test. + +Red at `cdefeeb9` with `force-quit` dropped: the test names +`Normal [Ctrl-q] force-quit`. That rival is the one the ticket asks for. Two +more, each red here: +- Drop `exit-search-mode`: the test names search mode's `Enter` and `Esc`. + Both older guards fail too. +- Drop `toggle-group-under-cursor`: the test names `z`. Both older guards fail + too. + +**The fixture carries a search query.** With an empty one, search mode's +`Backspace` (`pop-char`) has nothing to pop, so it is silent for a reason of +state, not of capability. That was the test's first and only false report. +`furnished` already seats a yank and a history for the same reason, so a +command is never refused by an empty stack. + +Silent means what `heard` says it means: `Upstream` or `Ignored`, or a `Local` +whose fold moved nothing and said nothing. A decline that sets a status is not +silent, so `/`, `n` and `ZZ` pass without being listed. + +### every_binding_reaches_parents_and_prefixes + +improvise-oyaa. This checks the walk that the test above relies on. It builds a +keymap with a parent and a prefix, and asserts the walk returns three things: +the parent's unshadowed key, the child's binding where both bind the same key, +and the prefix's two-key sequence. The walk needs its own check because today +no silent binding sits in a parent or behind a prefix. So a walk that skipped +parents, or skipped prefixes, passed `every_silent_binding_is_on_the_list` +unchanged. Each of those two rivals is red here. So is a third, a walk that +lets a parent's binding shadow the child's. + ### a_help_row_the_browser_cannot_run_is_drawn_marked improvise-uln5, the display half, and the red test. On the Navigation page the diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index b095ea93..43aae663 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1531,7 +1531,7 @@ fn commit_buffer(state: &ClientState, kind: CommitKind) -> Option> #[cfg(test)] mod tests { use super::*; - use crate::keymap::Keymap; + use crate::keymap::{Keymap, KeyPattern}; use crate::{AxisFrame, CellDisplay, GridUpdate, KeyModifiers, Projection, Snapshot}; fn ev(code: KeyCode) -> KeyEvent { @@ -2834,6 +2834,98 @@ mod tests { ); } + /// Every binding a mode's keymap reaches, as the key sequence that presses + /// it and the command it names; prefixes are expanded, parents shadowed. + fn every_binding(keymap: &Keymap) -> Vec<(Vec, &'static str)> { + fn pattern_key(pattern: &KeyPattern) -> KeyEvent { + match pattern { + KeyPattern::Key(code, mods) => KeyEvent::new(*code, *mods), + KeyPattern::AnyChar => ev(KeyCode::Char('x')), + KeyPattern::Any => ev(KeyCode::F(12)), + } + } + fn named(binding: &Binding) -> Option<&'static str> { + match binding { + Binding::Cmd { name, .. } => Some(name), + Binding::Sequence(steps) => steps.first().map(|(name, _)| *name), + Binding::Prefix(_) => None, + } + } + let mut seen = std::collections::HashSet::new(); + let mut found = Vec::new(); + let mut map = Some(keymap); + while let Some(current) = map { + for (pattern, binding) in current.bindings() { + if !seen.insert(pattern.clone()) { + continue; + } + let first = pattern_key(pattern); + match binding { + Binding::Prefix(sub) => { + for (second, name) in every_binding(sub) { + found.push((std::iter::once(first).chain(second).collect(), name)); + } + } + other => found.extend(named(other).map(|name| (vec![first], name))), + } + } + map = current.parent().map(|p| p.as_ref()); + } + found + } + + /// The walk reaches a parent's keys and a prefix's second key, and a child + /// shadows its parent; today's keymaps put no silent binding in either. + #[test] + fn every_binding_reaches_parents_and_prefixes() { + let mut parent = Keymap::new(); + parent.bind(KeyCode::Char('a'), KeyModifiers::NONE, "parent-only"); + parent.bind(KeyCode::Char('b'), KeyModifiers::NONE, "shadowed"); + let mut sub = Keymap::new(); + sub.bind(KeyCode::Char('g'), KeyModifiers::NONE, "behind-prefix"); + let mut child = Keymap::with_parent(std::sync::Arc::new(parent)); + child.bind(KeyCode::Char('b'), KeyModifiers::NONE, "child"); + child.bind_prefix(KeyCode::Char('g'), KeyModifiers::NONE, std::sync::Arc::new(sub)); + let mut found: Vec<(String, &str)> = every_binding(&child) + .into_iter() + .map(|(seq, name)| (seq.iter().map(|k| format!("{:?}", k.code)).collect(), name)) + .collect(); + found.sort(); + assert_eq!( + found, + vec![ + ("Char('a')".to_string(), "parent-only"), + ("Char('b')".to_string(), "child"), + ("Char('g')Char('g')".to_string(), "behind-prefix"), + ] + ); + } + + /// improvise-oyaa: every Normal and search-mode binding acts, declines out + /// loud, or is on `NEEDS_THE_HOST`. See `context/test-evidence/clients.md`. + #[test] + fn every_silent_binding_is_on_the_list() { + let mut unlisted = Vec::new(); + for (key, search) in [(ModeKey::Normal, false), (ModeKey::SearchMode, true)] { + let keymap = Keymaps::shared().get(&key).expect("a keymap"); + for (sequence, command) in every_binding(keymap) { + let mut state = furnished(AppMode::Normal); + state.view.search_mode = search; + // A query to pop, as `furnished` seats a yank to paste. + state.view.search_query = "No".into(); + if press(state, &sequence) == Heard::Silent && !needs_the_host(command) { + unlisted.push(format!("{key:?} {sequence:?} {command}")); + } + } + } + unlisted.sort(); + assert!( + unlisted.is_empty(), + "these bindings do nothing in a browser and are not on NEEDS_THE_HOST, \ + so the help overlay cannot mark them: {unlisted:#?}" + ); + } + /// improvise-jz6w: the outcome a resolver had no way to name. See /// `context/test-evidence/clients.md`. #[test] From 519c8ddefdf3ab07bc77fd5f33e40f0e1c4b17e0 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:11:18 -0700 Subject: [PATCH 079/114] Model::is_item: membership without building the item list (improvise-4daa) `first_non_item` asked `effective_item_names(cat).contains(name)` per coordinate per projected cell, cloning every item name each time, and the TUI projects the whole grid every frame. `is_item` asks `contains_key`, plus the formula targets for a measure category, and is pinned equal to the list it replaces. #252's write path already goes through `first_non_item`, so both callers get it. Measured on an Apple M4 Max, interleaved 5x, minimum per build: pivot 18k cells 193 ms -> 136 ms (base 131 ms); records with a 2000-item label 145 ms -> 2.8 ms (base 1.9 ms). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/core.md | 34 +++++++++++++++ crates/improvise-core/src/model/types.rs | 54 +++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 26c16963..f03c3d8c 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1511,3 +1511,37 @@ are each compared with the same document with Month on `Axis::None`. At `Flag` at `Some(0.0)`, false rather than absent, so a fix that dropped the cell cannot pass. Rival: `read_coordinate` ignoring the reach (`if false && …`). This test fails. + +### `is_item_agrees_with_effective_item_names_everywhere` + +**improvise-4daa**, found by the #265 review at `adb6b814`. `Model::first_non_item` +used to ask `effective_item_names(cat).contains(name)` for every coordinate. That +built and cloned the whole item list per coordinate per projected cell. +`Model::is_item` asks the same question with no allocation. This test holds the +two answers equal across every pairing of three categories (`Region`, `_Measure`, +and one that does not exist) and six names: +- an item; +- a measure item; +- a measure formula target; +- a formula target on a regular category, which `add_formula` makes a real item; +- a name that is nothing. + +A setup assertion checks that `Profit` is not in `_Measure`'s explicit items, so +only the formula branch can answer it. + +**Rival:** `is_item` without the formula-target branch fails this test, and +`ui::host::tests::a_write_under_a_formula_is_refused_for_the_formula_not_as_a_non_item` +in `improvise-command`. + +**Bench**, not committed, on an Apple M4 Max, 16 cores. It is the reviewer's +`bench265`, and each build ran interleaved 5 times; figures are the minimum per +build. + +| build | pivot 150×120 (18k cells) | records 2000×5 (2000-item label) | +|---|---|---| +| `47b166ee` (#252, before) | 131 ms | 1.93 ms | +| `adb6b814` (#265 head) | 193 ms | 145 ms | +| with `is_item` | 136 ms | 2.82 ms | + +Single, non-interleaved runs on this machine varied by more than 2×. That is why +each build was run 5 times, interleaved, and the minimum taken. diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index 4f476d66..4d43adbb 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -594,10 +594,28 @@ impl Model { pub fn first_non_item<'k>(&self, key: &'k CellKey) -> Option<(&'k str, &'k str)> { key.0 .iter() - .find(|(cat, name)| !self.effective_item_names(cat).contains(name)) + .find(|(cat, name)| !self.is_item(cat, name)) .map(|(cat, name)| (cat.as_str(), name.as_str())) } + /// `effective_item_names(cat).contains(name)`, asked without building the list: + /// it runs per coordinate per projected cell (improvise-4daa). + pub fn is_item(&self, cat_name: &str, name: &str) -> bool { + let listed = |cat: &str| { + self.category(cat) + .is_some_and(|c| c.items.contains_key(name)) + }; + match CategoryKind::of_name(cat_name).is_measure() { + true => { + listed(MEASURE_CATEGORY) + || self.formulas.iter().any(|f| { + f.target == name && CategoryKind::of_name(&f.target_category).is_measure() + }) + } + false => listed(cat_name), + } + } + /// Category names excluding virtual categories (_Index, _Dim). pub fn regular_category_names(&self) -> Vec<&str> { self.categories @@ -1187,6 +1205,40 @@ mod model_tests { use super::{Aggregation, Model}; use crate::model::cell::{CellKey, CellValue}; + /// improvise-4daa: `is_item` answers exactly what the list it replaces answers. + /// Evidence: `context/test-evidence/core.md`. + #[test] + fn is_item_agrees_with_effective_item_names_everywhere() { + let mut m = Model::new("Test"); + m.add_category("Region").unwrap(); + for item in ["East", "North"] { + m.category_mut("Region").unwrap().add_item(item); + } + m.category_mut("_Measure").unwrap().add_item("Revenue"); + for (raw, category) in [ + ("Profit = Revenue * 2", "_Measure"), + ("Total = 1", "Region"), + ] { + m.add_formula(crate::formula::parse_formula(raw, category).unwrap()); + } + let names = ["East", "North", "Revenue", "Profit", "Total", "Mars"]; + for cat in ["Region", "_Measure", "Missing"] { + for name in names { + assert_eq!( + m.is_item(cat, name), + m.effective_item_names(cat).iter().any(|n| n == name), + "{cat}/{name}" + ); + } + } + let listed = m.category("_Measure").unwrap().items.contains_key("Profit"); + assert!(!listed, "setup: only the formula branch can answer Profit"); + assert!( + m.is_item("_Measure", "Profit"), + "a measure formula target is an item" + ); + } + fn coord(pairs: &[(&str, &str)]) -> CellKey { CellKey::new( pairs From d09a27c7e77f9cd28701f4d2de54cceeecd68206 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:11:26 -0700 Subject: [PATCH 080/114] EditOrDrill refuses a NonItem cell at open, and the four declines are pinned (improvise-yc9r) `i`/Enter opened an editor on a NonItem cell and the refusal came only at commit, under a minibuffer that hides the status. `EditOrDrill` now asks `cursor_cell_id` after the formula check, as the browser already does. Three rivals each passed the whole workspace before this: NonItem writable in `value_cell_key`, a Record in `committable_cell_key`, and `None` from the browser's open-editor arm. Each now fails its own test. The commit guard is reached through `:enter-mode editing`, which opens an editor past `EditOrDrill`. command.md also records improvise-4daa's argument beside gk51's. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/command.md | 21 ++++ context/test-evidence/command.md | 49 +++++++++ .../improvise-command/src/command/cmd/core.rs | 2 +- .../improvise-command/src/command/cmd/mode.rs | 7 ++ crates/improvise-command/src/ui/app.rs | 101 ++++++++++++++++++ crates/improvise-protocol/src/resolve.rs | 19 ++++ 6 files changed, 198 insertions(+), 1 deletion(-) diff --git a/context/code/command.md b/context/code/command.md index 1ef55b3b..8948c8b6 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -3686,6 +3686,27 @@ such a cell are refused with that sentence in the terminal as well. A `NonItem` publishes no record version, since no basis can claim a write it could never make. +**`i` refuses at open, as the browser does (improvise-yc9r).** `EditOrDrill` +first asked only about folds, aggregates and formulas. So `i` opened an editor +on a `NonItem` cell, and the refusal came at commit. There, the minibuffer +draws in place of the status line (gotcha 21), so the user saw nothing happen. +`EditOrDrill` now asks `cursor_cell_id` after the formula check, which is the +order `cell_id` asks in. The commit guard stays, and it is still reachable: +`:enter-mode editing` opens an editor without passing through `EditOrDrill`. +Each of the four declines has its own test. Before these, three rivals passed +the whole workspace: writable in `value_cell_key`, a `Record` in +`committable_cell_key`, and `None` in the browser's open-editor arm. + +**Membership is a lookup, not a list (improvise-4daa).** `first_non_item` runs +for every coordinate of every projected cell, and the TUI projects the whole +grid every frame (gotcha 44). It used to build `effective_item_names` (a fresh +`Vec` of every item) for each coordinate. That made `grid_of` quadratic +in a label category: 145 ms against 1.9 ms for 2000 records. `Model::is_item` +answers the same question with `contains_key`, plus the formula targets for a +measure category, which is where `effective_item_names` differs from the +category's own items. `is_item_agrees_with_effective_item_names_everywhere` +holds the two answers equal. + Option (b) on the bead, closing the sources, is not done here. It is improvise-98pd. With (a) in place, a source that still writes a non-item produces a cell that is declined everywhere, not one that is offered and then diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 7e622450..3fd8b689 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6325,3 +6325,52 @@ asserts that every projected `Record` is writable. It passes at the base too: the reviewer counted 518 cells across the shipped documents and found no non-item record among them. So it is a **non-regression guard**, not a reproduction; the two fixtures above are the reproductions. + +### `ui::app::non_item_key_tests::i_on_a_non_item_cell_refuses_at_open_and_says_why` + +**improvise-yc9r**, found by the #265 review at `adb6b814`. Fixture: Region on +page with the selection set to `Mars`, which names no item. So every cell is a +`NonItem`. Pressing `i` must leave the mode `Normal` and set the status to +`non_item_refusal("Region", "Mars")`. + +Red at `adb6b814`: `i` entered `Editing`. The refusal came only at commit, under +the minibuffer, which draws in place of the status line (gotcha 21). The browser +already refused at open. + +**Rival:** removing the `NonItem` check from `EditOrDrill` fails this test, and +the rest of `improvise-command`'s lib passes. + +### `ui::app::non_item_key_tests::p_on_a_non_item_cell_writes_nothing` + +improvise-yc9r's pin on `value_cell_key`. Same stale-page fixture, with 7 yanked. +`p` must store nothing under `Region=Mars`, and the status must end with the +non-item sentence (it is prefixed `paste: `). + +**Rival:** `value_cell_key`'s `NonItem` arm returning `stored_cell_key()` fails +this test, and the rest of its crate's tests pass. Before this test existed, +that rival passed the whole workspace. + +### `ui::app::non_item_key_tests::a_commit_on_a_non_item_cell_writes_no_orphan` + +improvise-yc9r's pin on `committable_cell_key`. With `i` refusing at open, the +commit guard is reached through `:enter-mode editing`, which opens the editor +without passing through `EditOrDrill`. Then `5 Enter`. Nothing may be stored +under `Region=Mars`, and the status ends with the non-item sentence. + +**Rival:** `committable_cell_key` treating `NonItem` as `Record` fails this +test, and the rest of its crate's tests pass. Before this test existed, that +rival passed the whole workspace. + +My first attempt removed the page's item under an open editor. It could not reach +the guard, because `RemoveItem` clears the page selection instead of leaving it +stale. + +### `resolve::tests::i_on_a_non_item_cell_declines_with_the_non_item_sentence` + +improvise-yc9r's pin on the browser side. `i` on a `CellId::NonItem` must resolve +`Local` to exactly `[SetStatus(non_item_refusal(...))]`, the sentence the TUI now +says too. + +**Rival:** the resolver's open-editor `NonItem` arm returning `None` fails this +test, and the rest of `improvise-protocol`'s lib passes. Before this test existed, +that rival passed the whole workspace. diff --git a/crates/improvise-command/src/command/cmd/core.rs b/crates/improvise-command/src/command/cmd/core.rs index c42a2453..dfc4b2ec 100644 --- a/crates/improvise-command/src/command/cmd/core.rs +++ b/crates/improvise-command/src/command/cmd/core.rs @@ -127,7 +127,7 @@ impl<'a> CmdContext<'a> { } /// What the cursor's cell is, asked exactly as the projection asks it. - fn cursor_cell_id(&self) -> Option { + pub(crate) fn cursor_cell_id(&self) -> Option { crate::ui::project::cell_id(self.model, self.layout, self.selected.0, self.selected.1) } diff --git a/crates/improvise-command/src/command/cmd/mode.rs b/crates/improvise-command/src/command/cmd/mode.rs index d11f24a2..b72d49eb 100644 --- a/crates/improvise-command/src/command/cmd/mode.rs +++ b/crates/improvise-command/src/command/cmd/mode.rs @@ -388,6 +388,13 @@ impl Cmd for EditOrDrill { &formula.target, ))]; } + // Refused at open, as the browser refuses it: a commit would only be + // refused later, under a minibuffer that hides why (improvise-yc9r). + if let Some(improvise_protocol::CellId::NonItem { category, item }) = ctx.cursor_cell_id() { + return vec![effect::refuse(improvise_protocol::non_item_refusal( + &category, &item, + ))]; + } vec![ Box::new(effect::SetBuffer { name: "edit".to_string(), diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index 576187e3..0d221e54 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -5132,3 +5132,104 @@ mod formula_edit_tests { assert_eq!(buffer(&app), Some("Sum = East + West")); } } + +/// A `NonItem` cell reached through the TUI's own keys (improvise-yc9r). +#[cfg(test)] +mod non_item_key_tests { + use super::*; + use crate::model::cell::{CellKey, CellValue}; + use crate::view::Axis; + + /// Region on page at `page`, Month (Jan) on rows, one measure: East/Jan = 1. + fn paged_app(page: &str) -> App { + let mut wb = Workbook::new("Paged"); + wb.add_category("Region").unwrap(); + wb.add_category("Month").unwrap(); + wb.add_item("Region", "East").unwrap(); + wb.add_item("Month", "Jan").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + let view = wb.active_view_mut(); + view.set_axis("Region", Axis::Page); + view.set_axis("Month", Axis::Row); + view.set_axis("_Measure", Axis::Column); + view.set_page_selection("Region", page); + wb.model.set_cell( + CellKey::new(vec![ + ("Month".into(), "Jan".into()), + ("Region".into(), "East".into()), + ("_Measure".into(), "Revenue".into()), + ]), + CellValue::Number(1.0), + ); + App::new(wb, None) + } + + fn press(app: &mut App, keys: &str) { + for c in keys.chars() { + let code = match c { + '\n' => KeyCode::Enter, + c => KeyCode::Char(c), + }; + app.handle_key(KeyEvent::new(code, KeyModifiers::NONE)) + .unwrap(); + } + } + + /// Every stored key naming `Region=region`, however it got there. + fn stored_under(app: &App, region: &str) -> usize { + let model = &app.model_state.workbook.model; + model + .data + .iter_cells() + .filter(|(key, _)| key.get("Region") == Some(region)) + .count() + } + + fn refusal(item: &str) -> String { + improvise_protocol::non_item_refusal("Region", item) + } + + /// BUG (improvise-yc9r) — see `context/test-evidence/command.md`. + #[test] + fn i_on_a_non_item_cell_refuses_at_open_and_says_why() { + let mut app = paged_app("Mars"); + press(&mut app, "i"); + assert_eq!(app.view_state.mode, AppMode::Normal, "no editor was opened"); + assert_eq!(app.view_state.status_msg, refusal("Mars")); + } + + /// improvise-yc9r: `p` on a non-item cell writes no orphan. Evidence file. + #[test] + fn p_on_a_non_item_cell_writes_nothing() { + let mut app = paged_app("Mars"); + app.view_state.yanked = Some(CellValue::Number(7.0)); + press(&mut app, "p"); + assert_eq!( + stored_under(&app, "Mars"), + 0, + "an orphan Mars cell was written" + ); + let status = &app.view_state.status_msg; + assert!(status.ends_with(&refusal("Mars")), "{status}"); + } + + /// improvise-yc9r: an editor opened past `i` (`:enter-mode editing`) still + /// refuses its commit rather than writing an orphan. Evidence file. + #[test] + fn a_commit_on_a_non_item_cell_writes_no_orphan() { + let mut app = paged_app("Mars"); + press(&mut app, ":enter-mode editing\n"); + assert!( + app.view_state.mode.is_editing(), + "setup: the editor is open" + ); + press(&mut app, "5\n"); + assert_eq!( + stored_under(&app, "Mars"), + 0, + "the commit wrote an orphan Mars cell" + ); + let status = &app.view_state.status_msg; + assert!(status.ends_with(&refusal("Mars")), "{status}"); + } +} diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 53083517..4bc8de1a 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -3259,6 +3259,25 @@ mod tests { ); } + /// improvise-yc9r: `i` on a non-item cell says which coordinate names no item, + /// the sentence the TUI now also says at open. Evidence: `test-evidence/command.md`. + #[test] + fn i_on_a_non_item_cell_declines_with_the_non_item_sentence() { + let client = client_on(CellId::NonItem { + category: "Region".into(), + item: "Mars".into(), + }); + let KeyOutcome::Local(commands) = resolve(&client, ev(KeyCode::Char('i'))) else { + panic!("a non-item cell must not be a silent no-op"); + }; + assert_eq!( + commands, + vec![Command::SetStatus(crate::non_item_refusal( + "Region", "Mars" + ))] + ); + } + /// Two page axes, so the odometer's carry is visible: `Quarter` is least /// significant and `Year` only moves when it wraps. fn paged() -> ClientState { From 689c8c6eb558734f4acaf50df2b54d8217ce4596 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:11:33 -0700 Subject: [PATCH 081/114] improvise-5m9z: an editor opened past the window settles on the host's answer reopened_at opens provisionally where the landing cell is not projected yet; ClientState::settle_declined_editor closes an untouched editor once the host projects that cell as one i declines. Both ask one resolve::declined_at. A touched editor is kept. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 31 +++++++++--- context/test-evidence/clients.md | 48 +++++++++++++++++-- crates/improvise-protocol/src/client.rs | 17 +++++++ crates/improvise-protocol/src/resolve.rs | 19 +++++--- .../improvise-wasm-client/tests/lockstep.rs | 42 ++++++++++++++++ 5 files changed, 141 insertions(+), 16 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 9cf5a5ba..f3e5a243 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -519,17 +519,34 @@ looks wrong and is exactly what the TUI does; mirroring it is the point. The re-entry is `reopened_at`, and it declines where the host's `EnterEditAtCursor` declines (improvise-enz6). The host asks `core::uneditable` of its layout. The browser asks the landing cell's projected -`CellId`, which is the same answer, because `cell_id` is what fills it. Two -limits follow from asking the cache: +`CellId`, which is the same answer, because `cell_id` is what fills it. Asking +the cache has two consequences: - **The wording differs on a fold.** The host can tell a fold from any other aggregate and names the fold. The browser sees only `CellId::Aggregate`, so it gives the host's generic aggregate text. `lockstep.rs` compares the mode, the cursor and the buffers here, never the status text. -- **A landing past the window opens anyway.** The cache holds only the - announced window, so a `Down` from the window's last row lands on a cell with - no projected id. The browser then opens the editor, where the host does not. - No write can follow it: once the host has projected that cell, - `record_at_cursor` finds no record there and the commit composes nothing. +- **A landing past the window is decided when the host answers** + (improvise-5m9z). The cache holds only the announced window, so a `Down` from + the window's last row lands on a cell with no projected id yet. `reopened_at` + opens the editor there provisionally, and the fold settles it: + `ClientState::settle_declined_editor` runs after every fold. It closes an + **untouched** editor whose cursor cell the host has now projected as one `i` + declines, and says why. The client then ends where the TUI does. Both ends + ask the same `resolve::declined_at`, so the immediate decline and the late + one cannot disagree. + + The decline waits for the host's answer, as #227's awaiting seed does. It does + not choose a seed, which stays `seed_at`'s and #227/#272's. + + **A touched editor is kept.** If the user typed before the answer came, the + editor stays open with their text. Closing it would throw input away, and no + write can follow on that cell: `record_at_cursor` finds no record there. This + is the one state the TUI cannot reach, because it has the layout and never + waits. + + The settle is not specific to advancing. An untouched editor also closes + when the host re-projects its cell as declined for any other reason, such as + another client adding a formula over it. `GridMove` is a hand-spelled mirror of `navigation.rs`'s `MoveKind`, because the command crate is not on a browser's dependency list and never will be. The diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 94abf6a8..28d1bad5 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -4605,9 +4605,51 @@ Rivals: - The host opening everywhere: fails, the client now declining where the host opens. -One limit is not covered, because the window is 6×6 and every landing is -inside it. A landing past the announced window has no projected id, so the -browser still opens the editor there (`clients.md`). +The window is 6×6, so every landing here is inside it. A landing past the +announced window is improvise-5m9z, covered by +`advancing_the_editor_past_the_window_settles_where_the_tui_does`. + +### advancing_the_editor_past_the_window_settles_where_the_tui_does + +improvise-5m9z, filed from PR #277. `reopened_at` can decline only a cell +whose id the client holds. An advance past the announced window lands on one it +does not hold yet, so the browser opened the editor where the TUI declines. + +This test runs a real `Host` and a real `ClientState`. Each key is resolved on +the client, folded with `fold_outgoing`, sent through `Host::handle`, and the +whole reply is delivered. A TUI `App` presses the same keys through +`reduce_key`. Three cases, each landing one step past the window: +- `fold_host`, 2×6 window: `i` on Feb (1,1), then `Enter` onto the Q2 fold at + row 2. +- `yank_host`, 6×3 window: `i` on North/Cost (1,2), then `Tab` onto Profit at + column 3. +- The control, `fold_host` with Q2 expanded: `Enter` lands on Apr, a record, + and must stay editing on both ends. A settle that closed every editor landing + past the window cannot pass it. + +The test asserts both ends land on the same cell and end in the same mode. + +It was red at #277's head `aab62a36`, on the fold case: the client was +`Editing` and the TUI `Normal`. + +Rivals: +- No `settle_declined_editor`: fails on the fold case. +- `declined_at` letting a formula's result through: fails, as does the enz6 + lockstep test. + +### typing_before_the_host_answers_keeps_the_editor_past_the_window + +The half of improvise-5m9z that the TUI cannot see. The TUI never waits, so +nothing in it can be compared with this. On the same 2×6 `fold_host` as +above, `i`, `Enter` and `7` are all folded and sent **before** any of the +host's replies is delivered. The `7` is typed into the provisional editor while +the landing cell is still unprojected. Then the replies arrive in order. The +editor must still be open with `7` in its buffer. The fold under it is declined, +but the user's input is not thrown away. + +Rival: the settle ignoring `touched`, closing any editor on a declined cell. +This test fails and the one above passes, so the two tests pin the two +directions of the `touched` guard. ### the worker is not called up until its first frame has arrived diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index a2615b56..4dd52a72 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -175,6 +175,23 @@ impl ClientState { self.view.buffers.insert(key, seed.clone()); self.view.opened_editor = Some(crate::OpenedEditor::on(seed)); } + self.settle_declined_editor(); + } + + /// An untouched editor on a cell `i` declines closes once the host projects + /// that cell: an advance past the window opens before it can know (5m9z). + fn settle_declined_editor(&mut self) { + let (row, col) = self.view.cursor().selected; + let untouched = self.view.mode.is_editing() + && self + .view + .opened_editor + .as_ref() + .is_some_and(|opened| !opened.touched); + if let (true, Some(refusal)) = (untouched, crate::resolve::declined_at(self, row, col)) { + self.view.mode = settled_mode(&crate::AppMode::Normal, self.grid.records); + self.view.status_msg = refusal; + } } /// The widget dispatch: a projection names the widget it is addressed to. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index c34200f2..e135db75 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1031,12 +1031,19 @@ fn cell_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::CellDi /// `EnterEditAtCursor`: re-open the editor where the advance landed, unless `i` /// would decline that cell. The host asks `uneditable`; this asks the same id. fn reopened_at(state: &ClientState, row: usize, col: usize, mode: AppMode) -> Vec { - match cell_at(state, row, col).and_then(|cell| cell.id.as_ref()) { - Some(CellId::Aggregate(_)) => vec![Command::SetStatus(AGGREGATE_DECLINE.to_string())], - Some(CellId::Derived { target }) => vec![Command::SetStatus(derived_decline(target))], - Some(CellId::Record(_) | CellId::Create(_) | CellId::Coordinate) | None => { - opened_on(seed_at(state, row, col), mode) - } + match declined_at(state, row, col) { + Some(refusal) => vec![Command::SetStatus(refusal)], + None => opened_on(seed_at(state, row, col), mode), + } +} + +/// Why `i` declines the cell the host projected at `(row, col)`. `None` for an +/// editable cell, and for one not projected yet, which the fold settles later. +pub(crate) fn declined_at(state: &ClientState, row: usize, col: usize) -> Option { + match cell_at(state, row, col)?.id.as_ref()? { + CellId::Aggregate(_) => Some(AGGREGATE_DECLINE.to_string()), + CellId::Derived { target } => Some(derived_decline(target)), + CellId::Record(_) | CellId::Create(_) | CellId::Coordinate => None, } } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index fef7fc1b..8520508c 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2124,6 +2124,48 @@ fn fold_host() -> App { app } +/// improvise-5m9z's other half: what the user typed before the host answered +/// is not thrown away. See `context/test-evidence/clients.md`. +#[test] +fn typing_before_the_host_answers_keeps_the_editor_past_the_window() { + let mut host = improvise_command::ui::host::Host::new(fold_host()); + let (session, welcome) = host.connect(2, 6); + let mut client = ClientState { + window: (2, 6), + ..ClientState::default() + }; + deliver(vec![welcome], session, &mut client); + let placed = vec![improvise_protocol::Command::SetSelected { row: 1, col: 1 }]; + client.fold_outgoing(&placed); + deliver( + host.handle(session, ClientFrame::Commands(placed)), + session, + &mut client, + ); + let mut held = Vec::new(); + for k in ["i", "Enter", "7"] { + let event = BrowserKey::new(k).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{k}: the client must answer this key locally"); + }; + client.fold_outgoing(&cmds); + held.push(host.handle(session, ClientFrame::Commands(cmds))); + } + assert_eq!(client.view.cursor().selected, (2, 1), "setup: on the fold"); + for reply in held { + deliver(reply, session, &mut client); + } + assert!( + client.view.mode.is_editing(), + "the typed 7 was thrown away: {:?}", + client.view.mode + ); + assert_eq!( + client.view.buffers.get("edit").map(String::as_str), + Some("7") + ); +} + /// improvise-5m9z: an advance past the client's window lands on a cell it holds /// no id for, and must settle as the TUI does. See `context/test-evidence/clients.md`. #[test] From 942f57e5276d80b9faeba72d85830f9f1551c952 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:14:35 -0700 Subject: [PATCH 082/114] improvise-5m9z: name the lockstep case tuple for clippy Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-wasm-client/tests/lockstep.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 8520508c..785104e1 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2177,7 +2177,14 @@ fn advancing_the_editor_past_the_window_settles_where_the_tui_does() { app.rebuild_layout(); app }; - let cases: [(fn() -> App, (usize, usize), (usize, usize), &str, bool); 3] = [ + type Case = ( + fn() -> App, + (usize, usize), + (usize, usize), + &'static str, + bool, + ); + let cases: [Case; 3] = [ (fold_host, (2, 6), (1, 1), "Enter", false), (yank_host, (6, 3), (1, 2), "Tab", false), (expanded, (2, 6), (1, 1), "Enter", true), From fbfe37802f208823c79b9fdfa3a72c9230661ff4 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:16:22 -0700 Subject: [PATCH 083/114] Pin the GIT_TRACE prefix with all six variables; override config trace2 targets (improvise-2iws review, improvise-vav0) The review of #276 found that an exact list of the three names the test set passed it. The child now sets all six variables that write to their target on git 2.55: GIT_TRACE, _SETUP, _PERFORMANCE, 2, 2_EVENT and 2_PERF. An exact three-name list leaves GIT_TRACE_SETUP behind and fails. vav0: a trace2.*Target in user config is not an environment variable, so removal cannot reach it, and the walk's git does not null the user's config. untraced_git now also sets GIT_TRACE2, GIT_TRACE2_EVENT and GIT_TRACE2_PERF to 0, which outranks the config. The child adds a GIT_CONFIG_GLOBAL whose [trace2] section sends all three targets to /dev/stdout. Rivals, each of which fails: - no strip; - the exact three names; - the prefix minus GIT_TRACE, minus _SETUP, or minus _PERFORMANCE; - no 0 override. The evidence entry moves from the web_demo_model section to the shipped_documents_load one. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tui.md | 72 ++++++++++++++++++++------------- tests/shipped_documents_load.rs | 33 +++++++++++---- 2 files changed, 70 insertions(+), 35 deletions(-) diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index 8064b0eb..3565bcdd 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -651,38 +651,54 @@ Rivals, each dropping one name from `PATHSPEC_AND_ATTRIBUTE_SETTINGS`: variables, and a future spelling of the walk that depends on case would be exposed to it. The test covers it, but that coverage is vacuous today. +### the_walk_ignores_a_git_trace_sent_to_stdout + +improvise-2iws, with improvise-vav0 folded in. The `GIT_TRACE*` family is +process-wide, like improvise-a0i3's settings, so `--local-env-vars` lists none +of them either. Pointed at `/dev/stdout`, git writes trace lines into the +`ls-files -z` output the walk parses, and they are read as paths. +`every_shipped_document_loads` panicked on "Cannot open /17:39:40… git.c:502 +trace: built-in: git ls-files …". + +**The fix has two parts.** +- `untraced_git()` removes every variable whose name starts with `GIT_TRACE`. A + prefix match, because the family is open-ended. `git()` builds on it, and so + does the `rev-parse --local-env-vars` call, whose stdout is parsed as well. +- It then sets `GIT_TRACE2`, `GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF` to `0`. + A user config's `trace2.normalTarget`, `eventTarget` or `perfTarget` is a + **config** setting, which no environment removal reaches, and the walk's git + does not null the user's config (only `run_git` does). An environment value of + `0` outranks it (improvise-vav0, verified by the #276 reviewer on git 2.55). + +**The test** re-runs `a_gzipped_document_is_walked` and +`every_shipped_document_loads` in child processes. Each child gets all six +family variables that write to their target on git 2.55, set to `/dev/stdout`: +`GIT_TRACE`, `GIT_TRACE_SETUP`, `GIT_TRACE_PERFORMANCE`, `GIT_TRACE2`, +`GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF`. It also gets a `GIT_CONFIG_GLOBAL` +whose `[trace2]` section sends all three targets to `/dev/stdout`. There is one +child per test, not one per setting, to keep this binary's child count down +(improvise-eahe). The test failed at #269's head 4cef40c9 for both tests. + +The first version set only three variables, so an exact list of those three +names passed it: the review of #276 found that. With all six set, an exact +three-name list leaves `GIT_TRACE_SETUP` behind and fails. + +Rivals, each injected into `untraced_git` and restored. Every one fails: +- **no strip**; +- **the exact names `GIT_TRACE`, `GIT_TRACE2_EVENT`, `GIT_TRACE_PERFORMANCE`**: + this is the rival the review found passing; +- **the prefix minus `GIT_TRACE`**, **minus `GIT_TRACE_SETUP`**, and **minus + `GIT_TRACE_PERFORMANCE`**: each of the three variables that the `0` override + does not cover breaks the walk on its own; +- **no `0` override** (removal only): the config's targets break the walk. + +The `GIT_TRACE2*` variables have no removal rival of their own. The `0` override +covers them whether or not they are removed. + --- ## `tests/web_demo_model.rs` -### the_walk_ignores_a_git_trace_sent_to_stdout - -improvise-2iws. The `GIT_TRACE*` family is process-wide, like improvise-a0i3's -settings, so `--local-env-vars` lists none of them either. Pointed at -`/dev/stdout`, git writes trace lines into the `ls-files -z` output the walk -parses, and they are read as paths. `every_shipped_document_loads` panicked on -"Cannot open /17:39:40… git.c:502 trace: built-in: git ls-files …". -`git()` now builds on `untraced_git()`, which removes every variable whose name -starts with `GIT_TRACE`. It is a prefix match because the family is open-ended. -The `rev-parse --local-env-vars` call goes through it too, since its stdout is -parsed as well. - -The test re-runs `a_gzipped_document_is_walked` and -`every_shipped_document_loads` in child processes, with `GIT_TRACE`, -`GIT_TRACE2_EVENT` and `GIT_TRACE_PERFORMANCE` all set to `/dev/stdout`: one -child per test, not one per variable, to keep this binary's child count down. -It failed at #269's head 4cef40c9 for both tests. - -Rivals, each injected into `untraced_git` and restored: -- **no strip**: fails; -- **strip the exact name `GIT_TRACE` only**: fails. The other two remain, so the - prefix is what the test pins; -- **strip all but `GIT_TRACE_PERFORMANCE`**: fails; -- **strip all but `GIT_TRACE2_EVENT`**: fails. - -The last two show that each variable alone breaks the walk, which is why one -child per test is enough. - ### every_web_demo_cell_is_settable_except_the_column_a_formula_owns improvise-bck. The demo document the standalone page opens must be one a visitor diff --git a/tests/shipped_documents_load.rs b/tests/shipped_documents_load.rs index 1b49ab72..3f4dc62e 100644 --- a/tests/shipped_documents_load.rs +++ b/tests/shipped_documents_load.rs @@ -85,9 +85,17 @@ fn untraced_git() -> std::process::Command { git.env_remove(name); } } + // A config `trace2.*Target` survives a removal; `0` here outranks it (vav0). + for name in TRACE2_TARGETS { + git.env(name, "0"); + } git } +/// The trace2 targets a config file can also set, as `trace2.normalTarget`, +/// `trace2.eventTarget` and `trace2.perfTarget`. +const TRACE2_TARGETS: [&str; 3] = ["GIT_TRACE2", "GIT_TRACE2_EVENT", "GIT_TRACE2_PERF"]; + /// A scratch repository, so each way of shipping a document is set up for real. fn scratch_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().expect("a temp dir"); @@ -322,14 +330,25 @@ fn the_walk_ignores_the_users_pathspec_and_attribute_settings() { /// and is read as paths. Evidence: `context/test-evidence/tui.md`. #[test] fn the_walk_ignores_a_git_trace_sent_to_stdout() { - // All three at once, one child per test: each alone breaks the walk, and a - // strip of one exact name leaves the other two (the prefix is the point). + // Every variable of the family that writes to its target on git 2.55, so a + // fixed list of names shorter than the family leaves one behind. let stdout = Path::new("/dev/stdout"); - let traces = [ - ("GIT_TRACE", stdout), - ("GIT_TRACE2_EVENT", stdout), - ("GIT_TRACE_PERFORMANCE", stdout), - ]; + let mut traces: Vec<(&str, &Path)> = [ + "GIT_TRACE", + "GIT_TRACE_SETUP", + "GIT_TRACE_PERFORMANCE", + "GIT_TRACE2", + "GIT_TRACE2_EVENT", + "GIT_TRACE2_PERF", + ] + .map(|name| (name, stdout)) + .to_vec(); + // improvise-vav0: the same targets set in config, where no removal reaches. + let home = tempfile::tempdir().expect("a temp dir"); + let config = home.path().join("gitconfig"); + let text = "[trace2]\n\tnormalTarget = /dev/stdout\n\teventTarget = /dev/stdout\n\tperfTarget = /dev/stdout\n"; + std::fs::write(&config, text).expect("writes"); + traces.push(("GIT_CONFIG_GLOBAL", &config)); for test in [ "a_gzipped_document_is_walked", "every_shipped_document_loads", From 57b713cf1551b8433cfe8ba6ffd5895b2cc6f779 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:20:12 -0700 Subject: [PATCH 084/114] A refusal's sentence queues at the tail, so a rebase cannot erase it (improvise-n0pb) `fold_frame` wrote the rejection sentence straight into `view.status_msg`, so it was in neither `settled` nor `in_flight`, and the xd3q rebase erased it whenever a relay queued behind a frame still in flight: an in-flight frame's own `SetStatus` was replayed over it. `ClientState::notify` queues the sentence as a view-only entry at the tail of `in_flight`, where `fold_outgoing` puts a frame the host never answers. Frames sent before it replay first, relays queue ahead of it, frames sent after it may replace it, and it settles when everything ahead is answered. Writing it into `settled` too was tried as a rival and fails the same way. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 25 ++++++++++++ context/test-evidence/clients.md | 27 +++++++++++++ crates/improvise-protocol/src/client.rs | 6 +++ crates/improvise-wasm-client/src/socket.rs | 46 +++++++++++++++++++++- 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/context/code/clients.md b/context/code/clients.md index e7ebf6b5..1df1995f 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -1404,6 +1404,31 @@ while its mirror stepped back one. The host could not have fixed this. It cannot know whether a frame was composed before or after the client heard the reset, and only the client knows what it sent. +**What the client says to itself queues too (improvise-n0pb).** A refusal's +sentence ("…your edit was not applied") is written by the client, not sent by +the host. It used to go straight into `view.status_msg`, after `refused` had +rolled back. That put it in neither `settled` nor `in_flight`, so the xd3q +rebase erased it the next time a relay queued behind a frame still in flight. +A frame in flight that carried its own `SetStatus` was replayed over it. The +sentence is a local event that happened at a moment, so it takes its place in +the queue at that moment. `ClientState::notify` queues it as a view-only entry +at the **tail**, which is what `fold_outgoing` does with a frame the host never +answers. This gives it the right order against everything else: +- Every frame sent before it replays first, so none of them overwrites it. +- A relay queues behind the head, so it lands before the sentence and cannot + erase it either. +- A frame sent after it lands after it, and may replace it. That is the same + order in which the user saw them. +- It settles once everything ahead of it is answered. + +Two simpler fixes were rejected: +- **Writing the sentence into `settled` as well:** the next rebase replays the + in-flight frames over it, which puts the sentence *before* frames it + followed. +- **A status channel outside `ViewState`:** this would be a second answer to + "what does the status line say", which every renderer would then have to + merge. + Two simpler shapes were rejected, and each fails the Host-level test: - **Advancing `settled` directly** gets the order wrong. The head frame's own diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 31c05364..80df5c13 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5173,3 +5173,30 @@ described: the browser wrote **West = 35** where the TUI wrote **East = 45**. This file folds host frames through its own `heard` helper and not through `deliver`. On #246, `deliver` folds the host's commands with `fold_outgoing`, which queues them as frames this client sent (reported on #246). + +### `socket::tests::a_relay_heard_after_a_refusal_keeps_the_refusals_sentence` + +**improvise-n0pb**, found by the #266 review at `e4b5ce90`. The test drives +`Client::fold_frame`, the page's own entry point: +1. Two awaited frames, each carrying its own `SetStatus` (`first`, `second`), go + out through `fold_outgoing`. +2. The first comes back `Rejected(LayoutMoved)`, and the status must be the + refusal's sentence. +3. `Commands([ResetCursor])` arrives while the second frame is still in flight. + The sentence must survive it. +4. The second frame's `Accepted` arrives. The sentence must still be in `view`, + and it must now be in `settled`. + +Red at `e4b5ce90` at step 3: the status was `"second"`. The xd3q rebase rebuilt +`view` from `settled` plus the in-flight frames, and the sentence was in +neither, so the second frame's own status was replayed over it. The reviewer's +probe, whose frames carried no status, saw `""` instead. It passed at the base +`790a33ba`, which applied a relay directly. + +**Rivals:** +- **The code at `e4b5ce90`** (`view.status_msg = message`): fails at step 3 with + `"second"`. +- **The reviewer's option, writing the sentence into `settled` as well as + `view`:** fails at step 3 the same way, with `"second"`. The rebase replays the + in-flight frame over the settled sentence. That is why the sentence goes to + the tail of the queue rather than into `settled`. diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index e5a432bf..e4111c44 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -149,6 +149,12 @@ impl ClientState { } } + /// A status this client says to itself now. It queues after every frame + /// already sent, so no rebase replays one over it (improvise-n0pb, `clients.md`). + pub fn notify(&mut self, message: String) { + self.fold_outgoing(&[Command::SetStatus(message)]); + } + /// The host applied the oldest awaited frame whole, so it is the settled /// slice's now. pub fn accepted(&mut self) { diff --git a/crates/improvise-wasm-client/src/socket.rs b/crates/improvise-wasm-client/src/socket.rs index 1f1c0d21..e80d20cf 100644 --- a/crates/improvise-wasm-client/src/socket.rs +++ b/crates/improvise-wasm-client/src/socket.rs @@ -95,7 +95,7 @@ impl Client { // The sentence goes on *after* the rollback: it is this // client's own notification, and the slice it restores has none. state.refused(rejection); - state.view.status_msg = message; + state.notify(message); } ServerFrame::Accepted => self.state_mut().accepted(), } @@ -219,6 +219,50 @@ mod tests { } } + /// An awaited frame that also says `status`, as a commit's frame does. + fn awaited_saying(status: &str) -> Vec { + let mut cache = improvise_protocol::BasisCache::default(); + let basis = improvise_protocol::basis::published(0, 0, Default::default()); + improvise_protocol::WidgetCache::apply(&mut cache, &basis); + vec![ + Command::Model { + basis: cache.claim([]).expect("seeded"), + cmd: improvise_protocol::ModelCommand::CleanEmptyRecords, + }, + Command::SetStatus(status.into()), + ] + } + + /// BUG (improvise-n0pb) — see `context/test-evidence/clients.md`. + #[test] + fn a_relay_heard_after_a_refusal_keeps_the_refusals_sentence() { + let mut client = Client::default(); + for status in ["first", "second"] { + client.state_mut().fold_outgoing(&awaited_saying(status)); + } + client.fold_frame(ServerFrame::Rejected(Rejection::LayoutMoved)); + let sentence = rejection_message(&Rejection::LayoutMoved); + assert_eq!( + client.state().view.status_msg, + sentence, + "setup: the refusal says so" + ); + + client.fold_frame(ServerFrame::Commands(vec![Command::ResetCursor])); + assert_eq!( + client.state().view.status_msg, + sentence, + "a relay behind the second frame erased the refusal's sentence" + ); + client.fold_frame(ServerFrame::Accepted); + assert_eq!( + client.state().view.status_msg, + sentence, + "and settling keeps it" + ); + assert_eq!(client.state().settled.status_msg, sentence); + } + /// The rejection belongs to whoever composed the refused batch, so it does /// not survive a document swap: it names a record that is gone. #[test] From 9dc8a643b2ea08957acb1fbea6cea53762f123af Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:20:36 -0700 Subject: [PATCH 085/114] check-ci-graph: a multi-line quoted value is refused, and the quote tracker is deleted (6zf1) The tracker closed a multi-line quote on an escaped \" or a doubled '', so a later quote reopened it over a job's real if:. Both styles exited 0 on the real ci.yml. The opening line is refused instead; _opens_quote's escape handling is now load-bearing and has a case per style. Argument: context/code/tooling.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 43 +++++++++++++---- context/test-evidence/tooling.md | 66 +++++++++++++++++++++++++ scripts/check-ci-graph.py | 83 +++++++++++++++++++++----------- 3 files changed, 154 insertions(+), 38 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index d1c09a7a..d23ffa4e 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -295,10 +295,10 @@ The gate reads no shell. It counts a step only when all of these hold: away skips the call as surely as one on its own job (improvise-bbwa). The only YAML it models is what makes a line structure rather than content. A -key with an inline value owns every following line indented deeper, and an -open quote owns every line until it closes. So a heredoc, a folded or plain -continuation, a quoted value or a whole fake job inside a top-level block -scalar is skipped as content. Any line outside a YAML comment that mentions +key with an inline value owns every following line indented deeper. So a +heredoc, a folded or plain continuation, or a whole fake job inside a +top-level block scalar is skipped as content. A quoted value must close on its +own line, or the line is refused (improvise-6zf1, below). Any line outside a YAML comment that mentions `scripts/checkers.sh` and is not a counted step is **reported**, not ignored. The union of the counted jobs' rows must cover `checkers.sh --list`. @@ -358,12 +358,35 @@ There were two ways to make the matchers agree: string, so no two matchers can read different versions of it. So the stripper is deleted, not kept consistent. -It costs one valid style: a trailing comment, or a ` #` inside a quoted value, -on any line that is not block-scalar or quoted content. The real `ci.yml` has -neither. A full-line comment is still skipped, and a `#` with no space before -it, as in `nixpkgs#trealla`, is not a comment. After a refused line only the -exit is guaranteed. What the gate reads next may be garbled: a refused -`"x" # note` still opens the quote tracker. The exit is 1 either way. +This refuses some valid styles, on any line that is not block-scalar content: +- a trailing comment on a step, such as `- uses: actions/checkout@v7 # pin`; +- a one-line `run: echo "a # b"`; +- a quoted `with:` value containing ` #`. + +This costs future readability, not correctness. The real `ci.yml` has none of +them. A full-line comment is still skipped, and a `#` with no space before it, +as in `nixpkgs#trealla`, is not a comment. After a refused line only the exit +is guaranteed: what the gate reads next may be garbled, and the exit is 1 +either way. + +**A quoted value that does not close on its own line is refused, and the quote +tracker is deleted** (improvise-6zf1). The tracker held a multi-line quoted +value open until the next line containing its quote character. YAML does not +close on an escaped `\"` inside double quotes, or on a doubled `''` inside +single quotes. So the tracker closed early, a later content line such as +`b: "` opened a phantom string, and that string swallowed the job's real +`if:`. Both quote styles exited 0 on the real `ci.yml`. This was the fourth +review round whose fail-open lived in quote or comment handling. Closing the +tracker correctly would have meant modelling YAML's quote rules one more time, +so the gate stops reading multi-line quotes at all. The real `ci.yml` has +none. + +The opening line is still judged by `_opens_quote`, and that check is now +load-bearing: a value it wrongly calls closed is not refused, and its +continuation lines are read as structure. A `name: "a\"` followed by +`needs: ok` would then overwrite a job's real `needs: gate`. So `_opens_quote` +drops `\\.` before looking for the closing `"`, and `''` before looking for +the closing `'`, and a case covers each. `read_graph` refuses nothing, so it cannot rely on refusal. It opens a job on any ` name:` line followed by whitespace or the end of the line, which is diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index e489adda..ef5ffeb7 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1177,3 +1177,69 @@ three. Four rivals are new: The header suite case does not separate the raw-without-refusal rival. There the exact `_JOB` on the raw line refuses the header anyway, and the complaint has the same text. + +### A multi-line quoted value is refused, and the quote tracker is gone — `scripts/check-ci-graph.py` (improvise-6zf1) + +**What broke (P2, fail-open, predates j49r).** Found in the delta review of #228 +at `588094b7`. The tracker closed a multi-line quoted value at the first line +containing its quote character, including an escaped `\"` or a doubled `''`, +which YAML does not treat as a close. A later content line such as `b: "` then +opened a phantom string that swallowed the job's real `if: false`. The review +planted a `gate` job whose `name:` spans lines, with `kb` given `needs: gate` +and `gate` added to pages-deploy's `needs`. yq-go read `gate.if == false`, and +the gate exited 0 in both quote styles. + +**Fix.** A line whose quoted value does not close on its own line is refused, +with its own message. The tracker is deleted. The real `ci.yml` has no +multi-line quoted value (scanned with `_ANY_KEY` and `_opens_quote`: 0). + +**Red first** against `588094b7`: both plant cases (double and single) gave +`saw []`. + +**New cases, and why each is there.** +- The two plants. Each now draws the refusal, and also "conditional (needs + gate, which has if)", because with the tracker gone the job's `if:` is read. +- An opening line ending in an escaped `\"`, and one ending in a doubled `''`, + each followed by `needs: ok` and a readable closing line (`x: y"`). yq-go + reads each as one string, with `kb.needs == "gate"`. If `_opens_quote` + called either value closed, nothing would be refused, and the gate would + read `needs: ok` in place of kb's real need. These cases are what make + `_opens_quote`'s escape handling load-bearing and tested. +- Two refused styles the review listed: a trailing comment on a step, and + `run: echo "a # b"`. Both were already refused by the j49r rule, so these + cases document behaviour rather than fix a bug. The third style, a quoted + `with:` value containing ` #`, was already the j49r indent-10 case. + +**Changed cases.** +- "an exact run: line inside a multi-line quoted value does not count" is now + "a run: line inside a multi-line quoted value: the quote is refused". It + draws the refusal of the opening line and a plain refusal of the closing + `b"`. Only the exit is guaranteed after a refused line. +- "a closed quote followed by a comment is refused (j49r)" now reads the + job-level `if:` correctly. Its expectation is the refusal plus "conditional + (job if)", which is what the j49r evidence wanted but could not have while + the tracker existed. + +**Replayed on the real `ci.yml`.** I planted the review's three shapes: double +quote, single quote, and double quote with the reopening line at indent 4. At +`588094b7` each exited 0. Now each exits 1, naming the opening line. All seven +plants from both rounds were restored byte for byte (sha1 `a00288a8…`). + +**Rivals, each a source edit run against the real `--self-test` (53 cases).** +All 18 fail: +- the review's eleven; +- the four from j49r; +- three new ones: + - a multi-line quote is not refused: fails the five 6zf1 cases; + - `_opens_quote` ignores `\"`: fails the escaped-`\"` case alone; + - `_opens_quote` ignores `''`: fails the doubled-`''` case alone. + +The j49r rival "no comment refusal" now fails four cases, not three: the +`"x" # note` case is also caught by the quote refusal, because the raw value +`"x" # note` does not end in its quote. + +**Checked, not a finding.** A flow value (`{…}` or `[…]`) that continues onto +another line is not refused. yq-go rejects a continuation at a lesser +indentation (`did not find expected ',' or '}'`), which is loud. At the key's +own indentation the continuation is read as a key, which fails closed: a +spurious `if:`, or a `needs:` value the exact `_NEEDS_VALUE` refuses. diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index eb6ee127..524a8944 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -129,7 +129,7 @@ def ci_steps(text): job, in_steps, job_flags, step = None, False, set(), None in_jobs, job_keyed = False, False workflow_flags = set() - owner, owner_step, quote = None, None, None + owner, owner_step = None, None needs, if_jobs, in_needs = {}, set(), False def close_step(): @@ -138,12 +138,6 @@ def ci_steps(text): for n, raw in enumerate(text.split("\n"), 1): indent = len(raw) - len(raw.lstrip()) - if quote is not None: - if quote in raw: - quote = None - if "scripts/checkers.sh" in raw: - stray.append(n) - continue if owner is not None and (not raw.strip() or indent > owner): if "scripts/checkers.sh" in raw: stray.append(n) @@ -159,8 +153,11 @@ def ci_steps(text): else: readable = not in_jobs or indent > 8 or _readable(raw, indent, in_steps, in_needs, job_keyed) - if _COMMENT.search(raw) or not readable: - unread.append((n, bool(_COMMENT.search(raw)))) + k = _ANY_KEY.match(raw) + opens = bool(k) and _opens_quote(_inline_value(k.group(3))) + if _COMMENT.search(raw) or opens or not readable: + unread.append((n, "comment" if _COMMENT.search(raw) else + "quote" if opens else "plain")) if raw.startswith("defaults:"): workflow_flags.add("workflow defaults") m = _JOB.match(raw) @@ -207,14 +204,9 @@ def ci_steps(text): stray.append(n) elif "scripts/checkers.sh" in raw: stray.append(n) - k = _ANY_KEY.match(raw) if k and _inline_value(k.group(3)): - value = _inline_value(k.group(3)) - if _opens_quote(value): - quote = value[0] - else: - owner = len(k.group(1)) + (2 if raw.lstrip().startswith("- ") else 0) - owner_step = step if m and m.group(1) == "run" else None + owner = len(k.group(1)) + (2 if raw.lstrip().startswith("- ") else 0) + owner_step = step if m and m.group(1) == "run" else None close_step() for s in steps: s["flags"] |= workflow_flags @@ -252,8 +244,11 @@ def _unrun(s): "ci_suites in scripts/checkers.sh") -def _unread(n, comment=False): - if comment: +def _unread(n, kind="plain"): + if kind == "quote": + return (f"ci.yml line {n} opens a quoted value that continues onto another line; " + "this gate reads no multi-line quotes, so keep it on one line") + if kind == "comment": return (f"ci.yml line {n} has a ` #`, which YAML may read as a comment; this gate " "reads no comments, so put it on a line of its own") return (f"ci.yml line {n} is a key or `needs:` value this gate cannot read as a " @@ -321,11 +316,11 @@ _NEEDS_GATE = ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, w "so it does not count"] + _NO_PW -def _unreadable(name, text, *markers, rest=(), comment=False): +def _unreadable(name, text, *markers, rest=(), kind="plain"): """A case whose workflow spells a key or a `needs:` value the gate must refuse.""" lines = text.split("\n") at = [next(i for i, l in enumerate(lines, 1) if m in l) for m in markers] - return (name, _REQ, text, [_unread(n, comment) for n in at] + list(rest)) + return (name, _REQ, text, [_unread(n, kind) for n in at] + list(rest)) # (name, suites to require, workflow text, the complaints it must draw). Every @@ -337,7 +332,7 @@ SUITE_SELF_TEST = [ _unreadable("a trailing comment on the counted run: line is refused (j49r)", _wf(_BUILD, _job("kb", _KB_RUN.rstrip("\n") + " # the kb row\n")), - "# the kb row", rest=[_stray(9)] + _NO_PW, comment=True), + "# the kb row", rest=[_stray(9)] + _NO_PW, kind="comment"), ("a job that never calls its row leaves its suites unrun", _REQ, _wf(_BUILD, _job("kb", " - run: echo nothing\n")), _NO_PW), @@ -355,10 +350,10 @@ SUITE_SELF_TEST = [ _wf(_BUILD, _job("kb", " - run: echo\n ./scripts/checkers.sh --ci kb\n")), [_stray(10)] + _NO_PW), - ("an exact run: line inside a multi-line quoted value does not count", _REQ, - _wf(_BUILD, _job("kb", " - name: \"a\n" - " run: ./scripts/checkers.sh --ci kb\n b\"\n")), - [_stray(10)] + _NO_PW), + _unreadable("a run: line inside a multi-line quoted value: the quote is refused (6zf1)", + _wf(_BUILD, _job("kb", " - name: \"a\n" + " run: ./scripts/checkers.sh --ci kb\n b\"\n")), + '- name: "a', kind="quote", rest=[_unread(11)]), ("a whole job inside a top-level block scalar does not count", _REQ, _wf(_BUILD) + "notes: |\n kb:\n steps:\n" @@ -543,21 +538,53 @@ SUITE_SELF_TEST = [ _wf(_BUILD, " test:\n runs-on: x\n", " gate: # skipped on purpose\n if: false\n steps:\n - run: echo\n", _job("kb", _KB_RUN, head=" needs: gate\n")), - "gate: # skipped", comment=True), + "gate: # skipped", kind="comment"), _unreadable("a closed quote followed by a comment is refused (j49r)", _wf(_BUILD, _job("kb", _KB_RUN, head=" env:\n A: \"x\" # note\n") + " if: false\n", " after:\n steps:\n - run: echo \"z\"\n"), - '"x" # note', rest=[_stray(11)] + _NO_PW, comment=True), + '"x" # note', kind="comment", + rest=["job `kb`'s `checkers.sh --ci` step is conditional (job if), " + "so it does not count"] + _NO_PW), _unreadable("a # inside a quoted value, below indent 8, is refused (j49r)", _wf(_BUILD, _job("kb", _KB_RUN + " - uses: x\n with:\n" " foo: \"x # y\"\n") + " if: false\n", " after:\n steps:\n - run: echo \"z\"\n"), - '"x # y"', comment=True, + '"x # y"', kind="comment", rest=["job `kb`'s `checkers.sh --ci` step is conditional (job if), " "so it does not count"] + _NO_PW), + _unreadable("a trailing comment on a step is refused (j49r)", + _wf(_BUILD, _job("kb", " - uses: actions/checkout@v7 # pin\n" + _KB_RUN)), + "# pin", kind="comment"), + + _unreadable("a one-line run: with a ` #` inside quotes is refused (j49r)", + _wf(_BUILD, _job("kb", " - run: echo \"a # b\"\n" + _KB_RUN)), + '"a # b"', kind="comment"), + + _unreadable("a multi-line double-quoted value is refused, escaped quote and all (6zf1)", + _wf(_BUILD, " gate:\n runs-on: x\n name: \"a\n it\\\" is\n b: \"\n" + " if: false\n steps:\n - run: echo \"z\"\n", + _job("kb", _KB_RUN, head=" needs: gate\n")), + 'name: "a', kind="quote", rest=_NEEDS_GATE), + + _unreadable("a multi-line single-quoted value is refused, doubled quote and all (6zf1)", + _wf(_BUILD, " gate:\n runs-on: x\n name: 'a\n it''s\n b: '\n" + " if: false\n steps:\n - run: echo 'z'\n", + _job("kb", _KB_RUN, head=" needs: gate\n")), + "name: 'a", kind="quote", rest=_NEEDS_GATE), + + _unreadable("an opening line ending in an escaped \\\" still opens a quote (6zf1)", + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n name: \"a\\\"\n" + " needs: ok\n x: y\"\n")), + 'name: "a', kind="quote"), + + _unreadable("an opening line ending in a doubled '' still opens a quote (6zf1)", + _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n name: 'it''\n" + " needs: ok\n x: y'\n")), + "name: 'it", kind="quote"), + ("a YAML comment mentioning checkers.sh is fine", _REQ, _wf(_BUILD, _job("kb", " # every suite goes through scripts/checkers.sh\n" " - run: ./scripts/checkers.sh --ci kb\n")), []), From 5b8bdc19b17a68f7bcad181c07d7e62d5cacf6d8 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:22:54 -0700 Subject: [PATCH 086/114] One child per test, carrying every hostile git setting at once (improvise-eahe) tests/shipped_documents_load.rs re-ran itself in 18 child processes, one per hostile setting, and each child spawns several gits. So the binary took 2.1-3.3s against design-principles 7's 2s-per-binary tripwire. The six per-setting tests (53zz x2, vwjj, pr3i, a0i3 and 2iws/vav0) are now one test, a_hostile_git_environment_changes_nothing. It has two children: the gzip shape test and every_shipped_document_loads. Each carries every setting at once: - the template dir; - the global and system config (with trace2 targets); - XDG_CONFIG_HOME; - GIT_DIR and GIT_INDEX_FILE; - the five pathspec and attribute settings; - the six trace variables. Afterwards the test asserts the outer index is still empty. Each setting breaks the walk on its own, so a rival that drops one defence leaves that setting live, and the child fails. Every rival that was red against the per-setting tests is red against the combined one. The rivals that were green before are green now: - ICASE; - the trace2 names under the 0 override; - the GIT_CONFIG_GLOBAL and NOSYSTEM nulls, whose excludes witness vwjj's -c override masks. That last pair is filed as improvise-3j70. Measured on an Apple M4 Max, three workspace runs each: this binary went from 2.10-3.31s (load 37-39) to 0.57-0.91s (load 30-33). It is now below the next-largest binary in every run. The evidence sections keep their reasoning, under prose headings. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tui.md | 43 +++++++- tests/shipped_documents_load.rs | 190 ++++++++++---------------------- 2 files changed, 94 insertions(+), 139 deletions(-) diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index 3565bcdd..a6189f38 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -490,7 +490,38 @@ in a scratch repository and asserts the walk lists it. All four were red against the `.gitignore` walk. The account, including the rivals, is under `every_shipped_document_loads` above. -### a_scratch_repo_ignores_an_inherited_git_dir +### a_hostile_git_environment_changes_nothing + +improvise-eahe. Each section below it once had a test of its own, and each of those tests re-ran this binary in child processes, one child per setting (a0i3 alone ran ten). By #276 that was 18 children, and each one spawns several `git` processes, so this binary took 2.1–3.3s against the 2s-per-binary tripwire (`context/design-principles.md` §7). + +**What changed.** The six tests are now one test, and its two children carry every hostile setting at once: +- **Children:** one re-runs `a_gzipped_document_is_walked` and one re-runs `every_shipped_document_loads`. +- **Settings:** each child gets the template dir, the global and system config, `XDG_CONFIG_HOME`, `GIT_DIR`/`GIT_INDEX_FILE`, the five pathspec and attribute settings, the six trace variables, and the config's trace2 targets. +- **Outer index:** the test then asserts the outer repository's index is still empty. That is the check that makes the inherited `GIT_DIR` visible, as in its section below. + +**Why one child still separates the settings.** The sections below show that each setting breaks the walk on its own. So a rival that disables the defence against one setting leaves that setting live in the child, and the child fails. Every rival was re-run against the combined test. The ones that were red against the per-setting tests are still red: +- dropping `--template=`; +- the `--local-env-vars` loop; +- the `core.excludesFile` or `core.attributesFile` null; +- each of `GIT_LITERAL_PATHSPECS`, `GIT_GLOB_PATHSPECS`, `GIT_NOGLOB_PATHSPECS` and `GIT_ATTR_SOURCE`; +- the trace prefix: no strip, the exact name only, the exact three names, and the prefix minus `GIT_TRACE`, `_SETUP` or `_PERFORMANCE`; +- the trace2 `0` override. + +**The ones green before are green now.** +- `GIT_ICASE_PATHSPECS` is green, as its section explains. +- The prefix minus `GIT_TRACE2_EVENT` is green, because the `0` override covers it. It was red against the per-setting test at ddcdf93a, before #276 added the override. +- **Dropping `GIT_CONFIG_GLOBAL=/dev/null` or `GIT_CONFIG_NOSYSTEM=1` is green, both at #276's head and here.** Their witness is an excludes line, and vwjj's `-c core.excludesFile=/dev/null` outranks every config file, so the witness cannot see them. Consolidation did not cause this. It is filed as improvise-3j70. + +**Measured** on an Apple M4 Max (16 cores), three `cargo test --workspace` runs each, before and after. The median counts only binaries that ran a test: + +| | this binary | load average | run median | ratio | next largest | +|---|---|---|---|---|---| +| before (#276, 18 children) | 2.10, 3.04, 3.31s | 37–39 (1 min) | 0.00–0.01s | ≈300× | 0.90s, one 2.66s spike | +| after (2 children) | 0.57, 0.59, 0.91s | 30–33 (1 min) | 0.00–0.02s | ≈30–90× | 1.02s, one 2.49s spike | + +The median is at the 0.01s resolution cargo prints, so the ratio is coarse. Before, this binary was the outlier by more than two orders of magnitude and over the tripwire in every run. After, it is below the next-largest binary in every run and well under 2s. The occasional 2.5s spike in "next largest" is some other binary, in both runs; that is load, not this file. + +### An inherited `GIT_DIR` (improvise-53zz) improvise-53zz. The scratch repositories inherited the caller's git environment, and two parts of it change what `git -C ` does. @@ -526,7 +557,7 @@ Rivals, each run against the fixed file: libtest exits 0 when a filter matches nothing, and both of these tests would then pass without running anything. -### a_scratch_repo_ignores_the_users_global_and_system_excludes +### The user's global and system excludes (improvise-53zz) improvise-53zz. The scratch repositories also read the user's own git config. A `*.gz` line in a global `core.excludesFile` made `git add` refuse @@ -545,7 +576,7 @@ Rivals: - Drop `GIT_CONFIG_NOSYSTEM=1`: red, in the system run. The system scope is there so that this half of the fix has a witness at all. -### a_scratch_repo_ignores_the_users_default_ignore_and_attributes_files +### The user's default ignore and attributes files (improvise-vwjj) improvise-vwjj, found reviewing PR #248 at `f5cfc7ed`. The test above sets `core.excludesFile` *inside* a config file, so emptying the config files looks @@ -582,7 +613,7 @@ Rivals, each run against the fixed file: - Drop `core.attributesFile=/dev/null`: red, `fails under a default git/attributes`. -### a_scratch_repo_ignores_the_users_template_dir +### The user's template directory (improvise-pr3i) improvise-pr3i, found reviewing PR #255 at `56ac72a2`. This is a third path to the 53zz symptom. `git init` copies a template directory into the new `.git`, and @@ -610,7 +641,7 @@ the `-c` arguments in `run_git` beat relying on the absence of a config file. Rival: drop `--template=`. Red, as above. -### the_walk_ignores_the_users_pathspec_and_attribute_settings +### Pathspec and attribute settings (improvise-a0i3) improvise-a0i3. `git(root)` strips what `git rev-parse --local-env-vars` names, and that list holds only the variables that point at a repository (`GIT_DIR`, @@ -651,7 +682,7 @@ Rivals, each dropping one name from `PATHSPEC_AND_ATTRIBUTE_SETTINGS`: variables, and a future spelling of the walk that depends on case would be exposed to it. The test covers it, but that coverage is vacuous today. -### the_walk_ignores_a_git_trace_sent_to_stdout +### A git trace sent to stdout (improvise-2iws, improvise-vav0) improvise-2iws, with improvise-vav0 folded in. The `GIT_TRACE*` family is process-wide, like improvise-a0i3's settings, so `--local-env-vars` lists none diff --git a/tests/shipped_documents_load.rs b/tests/shipped_documents_load.rs index 3f4dc62e..63d0a137 100644 --- a/tests/shipped_documents_load.rs +++ b/tests/shipped_documents_load.rs @@ -211,41 +211,70 @@ fn told(out: &std::process::Output) -> String { format!("\n--- stdout\n{stdout}\n--- stderr\n{stderr}") } -/// improvise-pr3i: `git init` copies `GIT_TEMPLATE_DIR`, which no config null -/// reaches. Evidence: `context/test-evidence/tui.md`. +/// Every hostile git setting these tests have met, at once, in one child per +/// test (improvise-eahe). Each setting's own witness is a rival: `context/test-evidence/tui.md`. #[test] -fn a_scratch_repo_ignores_the_users_template_dir() { +fn a_hostile_git_environment_changes_nothing() { + // improvise-pr3i: a template whose `info/exclude` refuses `*.gz`. let templates = tempfile::tempdir().expect("a temp dir"); write(templates.path(), "info/exclude", "*.gz\n"); - let out = rerun_with_env( - "a_gzipped_document_is_walked", - &[("GIT_TEMPLATE_DIR", templates.path())], + // improvise-53zz: a global and a system config whose excludes refuse `*.gz`. + let home = tempfile::tempdir().expect("a temp dir"); + let excludes = home.path().join("ignore"); + std::fs::write(&excludes, "*.gz\n").expect("writes"); + let config = home.path().join("gitconfig"); + // improvise-vav0: the same config also sends every trace2 target to stdout. + let text = format!( + "[core]\n\texcludesFile = {}\n[trace2]\n\tnormalTarget = /dev/stdout\n\teventTarget = /dev/stdout\n\tperfTarget = /dev/stdout\n", + excludes.display() ); - assert!( - out.status.success(), - "the gzip shape test fails under a template info/exclude: {}", - told(&out) + std::fs::write(&config, text).expect("writes"); + // improvise-vwjj: git's default ignore and attributes files, no config line. + let xdg = tempfile::tempdir().expect("a temp dir"); + write(xdg.path(), "git/ignore", "*.gz\n"); + write( + xdg.path(), + "git/attributes", + "*.gz working-tree-encoding=UTF-16\n", ); -} - -/// improvise-53zz: a hook in a linked worktree exports `GIT_DIR`, which beats -/// `-C`. Evidence: `context/test-evidence/tui.md`. -#[test] -fn a_scratch_repo_ignores_an_inherited_git_dir() { + // improvise-53zz: an inherited `GIT_DIR`, which beats `-C`. let outer = scratch_repo(); let dot_git = outer.path().join(".git"); - let out = rerun_with_env( - "a_single_file_un_ignore_is_walked", - &[ - ("GIT_DIR", &dot_git), - ("GIT_INDEX_FILE", &dot_git.join("index")), - ], - ); - assert!( - out.status.success(), - "the shape test fails under an inherited GIT_DIR: {}", - told(&out) - ); + let index = dot_git.join("index"); + let stdout = Path::new("/dev/stdout"); + let one = Path::new("1"); + let hostile: Vec<(&str, &Path)> = vec![ + ("GIT_TEMPLATE_DIR", templates.path()), + ("GIT_CONFIG_GLOBAL", &config), + ("GIT_CONFIG_SYSTEM", &config), + ("XDG_CONFIG_HOME", xdg.path()), + ("GIT_DIR", &dot_git), + ("GIT_INDEX_FILE", &index), + // improvise-a0i3: process-wide pathspec and attribute settings. + ("GIT_LITERAL_PATHSPECS", one), + ("GIT_GLOB_PATHSPECS", one), + ("GIT_NOGLOB_PATHSPECS", one), + ("GIT_ICASE_PATHSPECS", one), + ("GIT_ATTR_SOURCE", Path::new("HEAD")), + // improvise-2iws: every trace that writes to its target on git 2.55. + ("GIT_TRACE", stdout), + ("GIT_TRACE_SETUP", stdout), + ("GIT_TRACE_PERFORMANCE", stdout), + ("GIT_TRACE2", stdout), + ("GIT_TRACE2_EVENT", stdout), + ("GIT_TRACE2_PERF", stdout), + ]; + for test in [ + "a_gzipped_document_is_walked", + "every_shipped_document_loads", + ] { + let out = rerun_with_env(test, &hostile); + assert!( + out.status.success(), + "{test} fails in a hostile git environment: {}", + told(&out) + ); + } let staged = git(outer.path()) .args(["ls-files"]) .output() @@ -256,108 +285,3 @@ fn a_scratch_repo_ignores_an_inherited_git_dir() { String::from_utf8_lossy(&staged.stdout) ); } - -/// improvise-53zz: a user's `*.gz` exclude refused the gzip shape test's `add`. -/// Evidence: `context/test-evidence/tui.md`. -#[test] -fn a_scratch_repo_ignores_the_users_global_and_system_excludes() { - let home = tempfile::tempdir().expect("a temp dir"); - let excludes = home.path().join("ignore"); - std::fs::write(&excludes, "*.gz\n").expect("writes"); - let config = home.path().join("gitconfig"); - let text = format!("[core]\n\texcludesFile = {}\n", excludes.display()); - std::fs::write(&config, text).expect("writes"); - for scope in ["GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"] { - let out = rerun_with_env("a_gzipped_document_is_walked", &[(scope, &config)]); - assert!( - out.status.success(), - "the gzip shape test fails under a `*.gz` exclude in {scope}: {}", - told(&out) - ); - } -} - -/// improvise-vwjj: git's DEFAULT ignore and attributes files need no config line. -/// Evidence: `context/test-evidence/tui.md`. -#[test] -fn a_scratch_repo_ignores_the_users_default_ignore_and_attributes_files() { - let files = [ - ("ignore", "*.gz\n"), - ("attributes", "*.gz working-tree-encoding=UTF-16\n"), - ]; - for (name, text) in files { - let xdg = tempfile::tempdir().expect("a temp dir"); - write(xdg.path(), &format!("git/{name}"), text); - let out = rerun_with_env( - "a_gzipped_document_is_walked", - &[("XDG_CONFIG_HOME", xdg.path())], - ); - assert!( - out.status.success(), - "the gzip shape test fails under a default git/{name}: {}", - told(&out) - ); - } -} - -/// improvise-a0i3: pathspec magic and an attribute source are process-wide git -/// settings, and `--local-env-vars` lists none. Evidence: `context/test-evidence/tui.md`. -#[test] -fn the_walk_ignores_the_users_pathspec_and_attribute_settings() { - let settings = [ - ("GIT_LITERAL_PATHSPECS", "1"), - ("GIT_GLOB_PATHSPECS", "1"), - ("GIT_NOGLOB_PATHSPECS", "1"), - ("GIT_ICASE_PATHSPECS", "1"), - ("GIT_ATTR_SOURCE", "HEAD"), - ]; - let mut failed = Vec::new(); - for (name, value) in settings { - for test in [ - "a_gzipped_document_is_walked", - "every_shipped_document_loads", - ] { - let out = rerun_with_env(test, &[(name, Path::new(value))]); - if !out.status.success() { - failed.push(format!("{test} under {name}={value}: {}", told(&out))); - } - } - } - assert!(failed.is_empty(), "{}", failed.join("\n")); -} - -/// improvise-2iws: a git trace sent to stdout lands in `ls-files -z`'s output -/// and is read as paths. Evidence: `context/test-evidence/tui.md`. -#[test] -fn the_walk_ignores_a_git_trace_sent_to_stdout() { - // Every variable of the family that writes to its target on git 2.55, so a - // fixed list of names shorter than the family leaves one behind. - let stdout = Path::new("/dev/stdout"); - let mut traces: Vec<(&str, &Path)> = [ - "GIT_TRACE", - "GIT_TRACE_SETUP", - "GIT_TRACE_PERFORMANCE", - "GIT_TRACE2", - "GIT_TRACE2_EVENT", - "GIT_TRACE2_PERF", - ] - .map(|name| (name, stdout)) - .to_vec(); - // improvise-vav0: the same targets set in config, where no removal reaches. - let home = tempfile::tempdir().expect("a temp dir"); - let config = home.path().join("gitconfig"); - let text = "[trace2]\n\tnormalTarget = /dev/stdout\n\teventTarget = /dev/stdout\n\tperfTarget = /dev/stdout\n"; - std::fs::write(&config, text).expect("writes"); - traces.push(("GIT_CONFIG_GLOBAL", &config)); - for test in [ - "a_gzipped_document_is_walked", - "every_shipped_document_loads", - ] { - let out = rerun_with_env(test, &traces); - assert!( - out.status.success(), - "{test} under a trace to stdout: {}", - told(&out) - ); - } -} From 8d9de94e0823ef061b3623bc0343961da678a970 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:29:33 -0700 Subject: [PATCH 087/114] A value retry writes only the cell it named; Enter follows the capture (improvise-8phw) 8phw: when A's Enter crossed B's HideItem, the LayoutMoved refusal replayed the editor over South and settle_opened recaptured South/Cost, so the retry wrote there. #251's Opened::Retry/Gone now covers values: written_to() recognises SetCell/ClearCell as well as a rename, and Retry and Gone carry the whole OpenedTarget, which the recapture must match. A Gone value editor declines with RECORD_RENAMED_AWAY. seen stays rename-only. Codex P2 on #274: commit picked value/rename/create from the cell under the cursor before it consulted the capture, so a reframe that left no cell there sent a captured editor's Enter upstream. commit now dispatches on opened.target(); only an uncaptured editor is dispatched by the cursor. A records grid's measure cell is not captured as a value, since the browser still declines it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 46 +++-- context/test-evidence/clients.md | 53 +++++ crates/improvise-protocol/src/client.rs | 58 ++++-- crates/improvise-protocol/src/resolve.rs | 82 +++++--- .../tests/browser_round_trip.rs | 187 ++++++++++++++++-- 5 files changed, 353 insertions(+), 73 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index bc679f46..ad45b5c7 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -2875,21 +2875,29 @@ the host's value would have no record in it. Gating the capture on `enter-mode` records no opening, yet the host still commits a rename from it, so the gate would strand that path on "still waiting". -**A retry after a refusal may rename only the record it named (improvise-20ek, -improvise-9ww8).** A refusal replays the view slice, so the editor reopens with -the typed text over whatever row the cursor now shows. The rule is about that -row: +**A retry after a refusal may write only the cell it named (improvise-20ek, +improvise-9ww8, improvise-8phw).** A refusal replays the view slice, so the +editor reopens with the typed text over whatever cell the cursor now shows. The +rule is about that cell, and it holds for a rename and for a value alike: -- `refused()` pins `ClientState::opened` to `Opened::Retry(record)`, where - `record` is the one the refused rename named. The refusal's kind plays no part. +- `refused()` pins `ClientState::opened` to `Opened::Retry(record, target)`, + naming the cell the refused frame wrote: a rename's record and category, or a + `SetCell`/`ClearCell`'s record and measure (`written_to`). The refusal's kind + plays no part. - Once the host has answered, `settle_opened` captures what is under the cursor. - If that is the same record, it becomes the target at its **current** version, - so a second `Enter` goes through. That is the one-keystroke force - `RecordMoved` promises (gotcha 43), and the user has just been told someone - else changed the row. -- If it is any other record, or none, `opened` becomes `Opened::Gone(record)`, - and `rename_coordinate` answers `Enter` with a status and no write. Esc - leaves the editor, and opening it again captures afresh. + If that is the same record **and** the same `OpenedTarget`, it becomes the + target at its **current** version, so a second `Enter` goes through. That is + the one-keystroke force `RecordMoved` promises (gotcha 43), and the user has + just been told someone else changed the row. +- If it is anything else, or nothing, `opened` becomes `Opened::Gone`, and + `Enter` answers with a status and no write. Esc leaves the editor, and opening + it again captures afresh. + +The whole target is compared, not only the record, because a record is a row +and a value editor names a cell in it: a crossing `HideItem` on a measure leaves +the **same** record under the cursor in another column, and a record-only +comparison forces the retry onto a measure the user never typed for. `seen` +stays rename-only, because only a rename has a destination to claim. **Why the rule names the record rather than a rejection.** The first version (20ek) pinned only on `RecordGone` of the renamed record. But `stale_basis` @@ -2935,8 +2943,16 @@ lives in the page, so the keyboard path never saw it. The fix reuses the rename capture rather than adding a second one: `settle_opened` captures a `Record` cell as `OpenedTarget::Value`, and `written` builds the `RecordCell` and the `claim_as_read` basis from it. Nothing about the write is read at `Enter` except -the text. Whether the commit is a rename, a creating column or a value is still -decided by the cell under the cursor, as it was. +the text. **What the commit is follows the capture too.** Whether `Enter` writes a value +or renames a coordinate is read off `opened.target()`, not off the cell under +the cursor (ChatGPT Codex on PR #274): a relayed reframe can leave the cursor on +no cell, on a creating column, or on a records grid, and dispatching on that +sent a captured value editor's `Enter` upstream or down the wrong path. Only an +editor that captured nothing (`uncaptured`) is dispatched by the cursor, and +that is the creating columns plus the waiting cases. A records grid's measure +cell is deliberately not captured as a value, since the browser still declines +editing it (gotcha 1); `every_advertised_command_answers_in_its_own_mode` +fails if it is. Two things come with it. **The version is the one read at open**, so a write by another client while the editor is open is refused `RecordMoved` on this path diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 8a826838..f4bb6bad 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5461,3 +5461,56 @@ subsumes it: after `RecordGone(r)` the capture can never be `r`, so it becomes `Gone(r)` anyway. A rival that dropped the explicit pin (`gone_as_retry`) failed no test, which is how the redundancy was found. The test's live rivals are now `no_pin_at_all` and `retry_any_capture`. + +### a_keyboard_edit_retried_after_a_crossing_hide_writes_no_other_cell + +improvise-8phw, the value twin of improvise-9ww8, found reviewing PR #274 at +`7bf8debd`. A presses `l i 4 2` on North/Cost. B hides North, and **B's relays +reach A only after A has sent `Enter`**, which is ordinary latency. The host +refuses A's frame `LayoutMoved`. The refusal replays the view, the editor +reopens at (0,1), and `settle_opened` recaptured what was now there, South/Cost. +The retry `Enter` was accepted and wrote 42 into South/Cost. It failed at +`South/Cost was written`. The 8xq0 test cannot see this: it delivers the hide +before `Enter`, so the capture from open time is still in use. + +The test also asserts the retry's status. Without that, a decline that goes +silently upstream would pass. + +- **Rival: `written_to` recognises renames only** (#251 as it was). Fails this + test and the column-hide test below. +- **Rival: a `Gone` value editor answers `None`**, which goes upstream. Fails + only this test, at the status assertion. + +### a_keyboard_edit_retried_after_a_crossing_column_hide_writes_no_other_measure + +improvise-8phw, the measure half. A opens on North/Revenue at (0,0) and types. +B's crossing `HideItem` hides the Revenue *measure*, so after the refusal the +cursor sits on North/Cost: the same **record**, another cell. This is the input +that separates "compare the whole `OpenedTarget`" from "compare the record", +which the row-hide test above cannot do, since there the record differs too. + +- **Rival: `Retry` compares the record only.** Fails only this test: the retry + writes the typed text into North/Cost. + +### a_keyboard_edit_retried_after_a_crossing_write_forces_the_same_cell + +improvise-8phw's control. B's crossing write to North/Revenue moves the +version of A's record, and the refusal is `RecordMoved`. The recapture is +North/Cost again, so the retry must go through: the one-keystroke force of +gotcha 43. A fix that declined every retry would fail here. It passed before +the fix too; it is a guard, not a reproduction. + +### a_keyboard_edit_whose_rows_all_vanished_writes_the_cell_it_opened_on + +ChatGPT Codex's P2 on PR #274 at `7bf8debd`. `commit` picked the operation from +the cell under the cursor before it consulted the capture. B hides both regions +while A's editor is open on North/Cost and A folds the relays, so there is no +cell under the cursor at all. `Enter` then answered `None`, which goes upstream +and does nothing in a browser. The capture was intact the whole time. It failed +at the `Accepted` assertion. + +- **Rival: dispatch on the cursor's cell** (the code before the fix). Fails only + this test. +- **Rival: capture a records grid's measure cell as a value.** Fails + `every_advertised_command_answers_in_its_own_mode`: `RECORDS INSERT` `Enter` + would start writing a cell the browser deliberately declines. diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 0d889e26..2533a0f5 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -50,13 +50,23 @@ pub struct ClientState { pub opened: Option, } -/// What an open cell editor commits to. After a refusal the retry may name -/// only the record it named (`Retry`) or none (`Gone`): improvise-20ek, -9ww8. +/// What an open cell editor commits to. After a refusal the retry may name only +/// the cell it named (`Retry`) or none (`Gone`): improvise-20ek, -9ww8, -8phw. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Opened { Target(OpenedCell), - Retry(RecordHash), - Gone(RecordHash), + Retry(RecordHash, OpenedTarget), + Gone(RecordHash, OpenedTarget), +} + +impl Opened { + /// Which kind of cell the editor commits to, whether or not it is captured. + pub fn target(&self) -> &OpenedTarget { + match self { + Opened::Target(cell) => &cell.target, + Opened::Retry(_, target) | Opened::Gone(_, target) => target, + } + } } /// A cell editor's target: the record, which cell of it, the record's write @@ -161,7 +171,7 @@ impl ClientState { if rejection.refused_an_edit() { let dropped = self.in_flight.pop_front(); self.replay(); - self.opened = renamed(dropped.as_ref()).map(Opened::Retry); + self.opened = written_to(dropped.as_ref()).map(|(r, t)| Opened::Retry(r, t)); } self.last_rejection = Some(rejection); self.settle_mode(); @@ -213,10 +223,14 @@ impl ClientState { let answered = !self.awaiting_answer(); self.opened = match (self.view.mode.is_editing(), self.opened.take()) { (false, _) => None, - (true, Some(Opened::Retry(record))) if answered => Some(match self.captured() { - Some(target) if target.record == record => Opened::Target(target), - _ => Opened::Gone(record), - }), + (true, Some(Opened::Retry(record, target))) if answered => { + Some(match self.captured() { + Some(cell) if cell.record == record && cell.target == target => { + Opened::Target(cell) + } + _ => Opened::Gone(record, target), + }) + } (true, Some(opened)) => Some(opened), (true, None) if answered => self.captured().map(Opened::Target), (true, None) => None, @@ -272,13 +286,27 @@ impl ClientState { } } -/// The record a refused frame renamed, if it renamed one (improvise-9ww8). -fn renamed(sent: Option<&Sent>) -> Option { +/// The cell a refused frame wrote, if it wrote one: a rename's coordinate or a +/// value's record and measure (improvise-9ww8, improvise-8phw). +fn written_to(sent: Option<&Sent>) -> Option<(RecordHash, OpenedTarget)> { sent?.cmds.iter().find_map(|cmd| match cmd { - Command::Model { - cmd: ModelCommand::RenameRecordCoordinate { record, .. }, - .. - } => Some(*record), + Command::Model { cmd, .. } => match cmd { + ModelCommand::RenameRecordCoordinate { + record, category, .. + } => Some(( + *record, + OpenedTarget::Coordinate { + category: category.clone(), + }, + )), + ModelCommand::SetCell { cell, .. } | ModelCommand::ClearCell(cell) => Some(( + cell.record, + OpenedTarget::Value { + measure: cell.measure.clone(), + }, + )), + _ => None, + }, _ => None, }) } diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 5702a31f..7d65dec9 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -813,7 +813,7 @@ fn coordinate_at_cursor(state: &ClientState) -> Option<(crate::RecordHash, Strin /// projection names them (improvise-8xq0). `None` for a cell no editor writes. pub(crate) fn opened_at_cursor(state: &ClientState) -> Option<(crate::RecordHash, OpenedTarget)> { match cell_at_cursor(state)?.id.as_ref()? { - CellId::Record(cell) => Some(( + CellId::Record(cell) if !state.grid.records => Some(( cell.record, OpenedTarget::Value { measure: cell.measure.clone(), @@ -825,7 +825,9 @@ pub(crate) fn opened_at_cursor(state: &ClientState) -> Option<(crate::RecordHash category: category.clone(), }, )), - CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => None, + CellId::Record(_) | CellId::Aggregate(_) | CellId::Derived { .. } | CellId::Create(_) => { + None + } } } @@ -939,25 +941,59 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option commit_value(state, edit_mode, advance, &text), + Some(OpenedTarget::Coordinate { .. }) => { + rename_coordinate(state, edit_mode, advance, &text) + } + None => uncaptured(state, edit_mode, advance, &text), } - if state.grid.records { - return rename_coordinate(state, edit_mode, advance, id, &text); +} + +/// An editor that captured nothing: the cell under the cursor says what it is. +/// Records mode needs `record_col` for a measure cell (gotcha 1, improvise-gcvb). +fn uncaptured( + state: &ClientState, + edit_mode: AppMode, + advance: Advance, + text: &str, +) -> Option> { + match cell_at_cursor(state)?.id.as_ref()? { + CellId::Create(kind) => create_column(state, edit_mode, advance, kind, text), + CellId::Coordinate { .. } => rename_coordinate(state, edit_mode, advance, text), + CellId::Record(_) if !state.grid.records => commit_value(state, edit_mode, advance, text), + CellId::Record(_) | CellId::Aggregate(_) | CellId::Derived { .. } => None, } - let Some(mut commands) = written(state, &text) else { - return state.awaiting_answer().then(still_waiting); +} + +/// `CommitAndAdvance` over a stored value: write the cell the editor OPENED on, +/// or decline when a refusal left it no longer there (improvise-8phw). +fn commit_value( + state: &ClientState, + edit_mode: AppMode, + advance: Advance, + text: &str, +) -> Option> { + let unchanged = untouched(state.view.opened_editor.as_ref(), text); + let mut commands = match (unchanged, state.opened.as_ref()) { + (true, _) => Vec::new(), + (false, Some(Opened::Target(opened))) => written(state, opened, text)?, + (false, Some(Opened::Gone(..))) => { + return Some(vec![Command::SetStatus(RECORD_RENAMED_AWAY.to_string())]); + } + (false, None | Some(Opened::Retry(..))) => { + return state.awaiting_answer().then(still_waiting); + } }; commands.push(Command::ChangeMode(AppMode::Normal)); commands.extend(advanced(state, edit_mode, advance)?); Some(commands) } -/// Why a retry after a refusal sends nothing: the record it opened on is no longer -/// the one under the cursor, or is gone (improvise-20ek, improvise-9ww8). +/// Why a retry after a refusal sends nothing: the cell it opened on is no longer +/// the one under the cursor, or is gone (improvise-20ek, -9ww8, -8phw). const RECORD_RENAMED_AWAY: &str = "the row you were editing has changed; Esc to leave the editor"; /// `CommitAndAdvance` over a coordinate: rename the record the editor OPENED on, @@ -966,12 +1002,8 @@ fn rename_coordinate( state: &ClientState, edit_mode: AppMode, advance: Advance, - id: &CellId, text: &str, ) -> Option> { - let CellId::Coordinate { .. } = id else { - return None; - }; // Untouched writes nothing (improvise-ixxx); empty is refused as the host // refuses it, where composing the write would undo the whole frame. let unchanged = untouched(state.view.opened_editor.as_ref(), text); @@ -980,8 +1012,8 @@ fn rename_coordinate( (false, true, _) => vec![Command::SetStatus( RECORD_COORDS_CANNOT_BE_EMPTY.to_string(), )], - (false, false, None | Some(Opened::Retry(_))) => return Some(still_waiting()), - (false, false, Some(Opened::Gone(_))) => { + (false, false, None | Some(Opened::Retry(..))) => return Some(still_waiting()), + (false, false, Some(Opened::Gone(..))) => { return Some(vec![Command::SetStatus(RECORD_RENAMED_AWAY.to_string())]); } (false, false, Some(Opened::Target(opened))) => { @@ -1013,15 +1045,9 @@ fn claimed(opened: &OpenedCell) -> impl Iterator Option> { - if untouched(state.view.opened_editor.as_ref(), text) { - return Some(Vec::new()); - } - let Some(Opened::Target(opened)) = state.opened.as_ref() else { - return None; - }; +/// The write to the value cell the editor OPENED on, at the version it read +/// then (improvise-8xq0, improvise-xz2z). +fn written(state: &ClientState, opened: &OpenedCell, text: &str) -> Option> { let OpenedTarget::Value { measure } = &opened.target else { return None; }; diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 73a6fe42..40a080ab 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1319,13 +1319,41 @@ fn a_second_rename_after_a_reorder_renames_the_row_the_host_shows() { enum Meanwhile { HideNorth, WriteNorth, + HideBoth, + HideRevenue, +} + +/// The commands B sends for `meanwhile`, in order. +fn meanwhile_commands( + host: &Host, + a: &Client, + meanwhile: &Meanwhile, +) -> Vec { + use improvise_protocol::ModelCommand; + let hide = |item: &str| ModelCommand::HideItem { + view: host.app().view_name().to_string(), + category: "Region".into(), + item: item.into(), + }; + match meanwhile { + Meanwhile::HideNorth => vec![hide("North")], + Meanwhile::HideBoth => vec![hide("North"), hide("South")], + Meanwhile::HideRevenue => vec![ModelCommand::HideItem { + view: host.app().view_name().to_string(), + category: "_Measure".into(), + item: "Revenue".into(), + }], + Meanwhile::WriteNorth => vec![ModelCommand::SetCell { + cell: a.record_cell_at(0, 0).expect("North/Revenue is stored"), + value: CellValue::Number(999.0), + }], + } } /// Client A presses `i` on North/Cost (0,1) and types `42`; B does `meanwhile` /// before A presses Enter. Evidence file. fn commit_north_cost(meanwhile: Meanwhile) -> (Host, Vec) { use improvise_command::ui::reduce::caught_up; - use improvise_protocol::ModelCommand; let mut host = host(); let (mut a, mut b) = (Client::new(), Client::new()); @@ -1348,21 +1376,17 @@ fn commit_north_cost(meanwhile: Meanwhile) -> (Host, Vec) { "setup: the editor is open" ); - let view = host.app().view_name().to_string(); - let cmd = match meanwhile { - Meanwhile::HideNorth => ModelCommand::HideItem { - view, - category: "Region".into(), - item: "North".into(), - }, - Meanwhile::WriteNorth => ModelCommand::SetCell { - cell: a.record_cell_at(0, 0).expect("North/Revenue is stored"), - value: CellValue::Number(999.0), - }, - }; - let relayed = host.handle(b_id, ClientFrame::command(caught_up(host.app(), cmd))); - route(&relayed, (a_id, &mut a), (b_id, &mut b)); + for cmd in meanwhile_commands(&host, &a, &meanwhile) { + let relayed = host.handle(b_id, ClientFrame::command(caught_up(host.app(), cmd))); + route(&relayed, (a_id, &mut a), (b_id, &mut b)); + } assert!(a.state().view.mode.is_editing(), "setup: still editing"); + if meanwhile == Meanwhile::HideBoth { + assert!( + a.state().grid.cell(0, 0).is_none(), + "setup: no cell is left under the cursor" + ); + } if meanwhile == Meanwhile::HideNorth { assert_eq!( a.state().grid.row_frame.leaf_labels().first().cloned(), @@ -1460,6 +1484,139 @@ fn a_keyboard_edit_is_refused_when_its_record_was_written_while_open() { assert_eq!(cell(&host, "North", "Cost"), None, "nothing was written"); } +/// A's Enter on North/Cost CROSSES B's `meanwhile`: B's relays reach A only +/// after A has sent. Then A presses Enter again. Evidence file. +fn cross_north_cost(meanwhile: Meanwhile) -> (Host, Vec, Client) { + cross_north(&["l", "i", "4", "2"], meanwhile) +} + +/// `cross_north_cost`, with the keys that open the editor and type into it. +fn cross_north(keys: &[&str], meanwhile: Meanwhile) -> (Host, Vec, Client) { + use improvise_command::ui::reduce::caught_up; + + let mut host = host(); + let (mut a, mut b) = (Client::new(), Client::new()); + let (a_id, a_snap) = host.connect(10, 10); + let (b_id, b_snap) = host.connect(10, 10); + a.set_viewport(10, 10); + b.set_viewport(10, 10); + deliver(&mut a, &[a_snap]); + deliver(&mut b, &[b_snap]); + for key in keys { + press(&mut host, a_id, &mut a, key); + } + let mut held = Vec::new(); + for cmd in meanwhile_commands(&host, &a, &meanwhile) { + held.extend(host.handle(b_id, ClientFrame::command(caught_up(host.app(), cmd)))); + } + let first = a + .key(&BrowserKey::new("Enter")) + .expect("a commit composes a frame"); + let json = serde_json::to_string(&first).expect("a client frame serializes"); + let answered = send(&mut host, a_id, &json); + assert!( + answered + .iter() + .any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Rejected(_))), + "setup: the crossing Enter is refused: {answered:?}" + ); + route(&held, (a_id, &mut a), (b_id, &mut b)); + route(&answered, (a_id, &mut a), (b_id, &mut b)); + assert!( + a.state().view.mode.is_editing(), + "setup: the refusal reopens the editor" + ); + + let retry = a.key(&BrowserKey::new("Enter")); + let answers = match retry { + Some(frame) => { + let json = serde_json::to_string(&frame).expect("a client frame serializes"); + send(&mut host, a_id, &json) + } + None => Vec::new(), + }; + (host, answers, a) +} + +/// improvise-8phw: the crossing refusal reopened the editor over South, and the +/// retry wrote 42 into South/Cost. Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_keyboard_edit_retried_after_a_crossing_hide_writes_no_other_cell() { + let (host, answers, a) = cross_north_cost(Meanwhile::HideNorth); + assert_eq!(cell(&host, "South", "Cost"), None, "South/Cost was written"); + assert_eq!( + cell(&host, "South", "Revenue"), + None, + "South/Revenue was written" + ); + assert!( + !answers + .iter() + .any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + "the retry must send no write: {answers:?}" + ); + assert!( + a.state().view.mode.is_editing(), + "the retry declines and leaves the editor open" + ); + assert!( + a.state().view.status_msg.contains("has changed"), + "and says so, rather than going silently upstream: {:?}", + a.state().view.status_msg + ); +} + +/// improvise-8phw, the measure half: the crossing hide leaves the SAME record +/// under the cursor in another column. Evidence: `context/test-evidence/clients.md`. +#[test] +fn a_keyboard_edit_retried_after_a_crossing_column_hide_writes_no_other_measure() { + let (host, answers, _) = cross_north(&["i", "4", "2"], Meanwhile::HideRevenue); + assert_eq!(cell(&host, "North", "Cost"), None, "North/Cost was written"); + assert_eq!( + cell(&host, "North", "Revenue"), + Some(CellValue::Number(1200.0)), + "the opened cell is hidden, so nothing may land on it either" + ); + assert!( + !answers + .iter() + .any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + "the retry must send no write: {answers:?}" + ); +} + +/// improvise-8phw's control: a crossing WRITE to the record is refused, and the +/// retry is the one-keystroke force onto the same cell (gotcha 43). +#[test] +fn a_keyboard_edit_retried_after_a_crossing_write_forces_the_same_cell() { + let (host, answers, _) = cross_north_cost(Meanwhile::WriteNorth); + assert!( + answers + .iter() + .any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + "the retry is a force: {answers:?}" + ); + assert_eq!(cell(&host, "North", "Cost"), Some(CellValue::Number(42.0))); + assert_eq!( + cell(&host, "North", "Revenue"), + Some(CellValue::Number(999.0)) + ); +} + +/// Codex on PR #274: with no cell left under the cursor, Enter went upstream +/// instead of writing the cell the editor opened on. Evidence file. +#[test] +fn a_keyboard_edit_whose_rows_all_vanished_writes_the_cell_it_opened_on() { + let (host, answers) = commit_north_cost(Meanwhile::HideBoth); + assert!( + answers + .iter() + .any(|f| matches!(f.msg, improvise_protocol::ServerFrame::Accepted)), + "the write must be sent and accepted: {answers:?}" + ); + assert_eq!(cell(&host, "North", "Cost"), Some(CellValue::Number(42.0))); +} + // ── races on a coordinate rename (improvise-20ek, improvise-milr) ─────── type Sid = improvise_command::ui::project::SessionId; From 12a92a86692d9cacfdce52159c84901281e8204b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:32:41 -0700 Subject: [PATCH 088/114] Give run_git's config nulls a witness no -c reaches (improvise-3j70) GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1 in run_git were witnessed by a config excludesFile, which vwjj's -c core.excludesFile=/dev/null outranks. So dropping either line passed every test from vwjj onwards. The hostile child's config, read as both GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM, now also sets core.sharedRepository = nonsense. Measured on git 2.55: git init and git add die on it, git ls-files does not read it (so the walk, which does not null the user's config, is unaffected), and run_git passes no -c for it. init.defaultObjectFormat and init.defaultRefFormat were tried first; they only warn. Dropping either env line is now red. Every other rival in the combined test is unchanged, and there is still one child per test. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tui.md | 24 +++++++++++++++++++++++- tests/shipped_documents_load.rs | 5 +++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index a6189f38..6ad7c0ab 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -510,7 +510,7 @@ improvise-eahe. Each section below it once had a test of its own, and each of th **The ones green before are green now.** - `GIT_ICASE_PATHSPECS` is green, as its section explains. - The prefix minus `GIT_TRACE2_EVENT` is green, because the `0` override covers it. It was red against the per-setting test at ddcdf93a, before #276 added the override. -- **Dropping `GIT_CONFIG_GLOBAL=/dev/null` or `GIT_CONFIG_NOSYSTEM=1` is green, both at #276's head and here.** Their witness is an excludes line, and vwjj's `-c core.excludesFile=/dev/null` outranks every config file, so the witness cannot see them. Consolidation did not cause this. It is filed as improvise-3j70. +- **Dropping `GIT_CONFIG_GLOBAL=/dev/null` or `GIT_CONFIG_NOSYSTEM=1` was green, both at #276's head and in eahe's first version.** Their witness was an excludes line, and vwjj's `-c core.excludesFile=/dev/null` outranks every config file, so the witness could not see them. Consolidation did not cause this. improvise-3j70 has since given them a witness that no `-c` reaches (see “The user's global and system excludes” below), and both rivals are red. **Measured** on an Apple M4 Max (16 cores), three `cargo test --workspace` runs each, before and after. The median counts only binaries that ran a test: @@ -576,6 +576,28 @@ Rivals: - Drop `GIT_CONFIG_NOSYSTEM=1`: red, in the system run. The system scope is there so that this half of the fix has a witness at all. +**Both rivals later went green, and nothing noticed (improvise-3j70).** +improvise-vwjj added `-c core.excludesFile=/dev/null` to `run_git`, and a `-c` +argument outranks every config file. So a global or system `excludesFile` could +no longer reach `git add` whether or not the config was nulled, and the two +rivals above passed from vwjj onwards. The config lines were still worth having, +since they null every *other* key, but nothing pinned them. + +The witness is now a second key in the same config, `core.sharedRepository = +nonsense`. Measured on git 2.55: +- `git init` and `git add` die on it (`fatal: bad boolean config value + 'nonsense' for 'core.sharedrepository'`); +- `git ls-files` does not read it, so the walk itself, which does not null the + user's config, is unaffected; +- `run_git` passes no `-c` for it. + +Two keys were tried first and rejected: `init.defaultObjectFormat` and +`init.defaultRefFormat` only **warn** on an unknown value, so `init` carries on. +The key sits in `a_hostile_git_environment_changes_nothing`'s config, which the +child reads as both `GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM`, so there is +still one child. Dropping either env line is red. Every other rival in the +combined test is unchanged. + ### The user's default ignore and attributes files (improvise-vwjj) improvise-vwjj, found reviewing PR #248 at `f5cfc7ed`. The test above sets diff --git a/tests/shipped_documents_load.rs b/tests/shipped_documents_load.rs index 63d0a137..4daadb7e 100644 --- a/tests/shipped_documents_load.rs +++ b/tests/shipped_documents_load.rs @@ -223,9 +223,10 @@ fn a_hostile_git_environment_changes_nothing() { let excludes = home.path().join("ignore"); std::fs::write(&excludes, "*.gz\n").expect("writes"); let config = home.path().join("gitconfig"); - // improvise-vav0: the same config also sends every trace2 target to stdout. + // vav0: trace2 targets go to stdout. 3j70: a bad sharedRepository kills `init` + // and `add` but not `ls-files`, and no `-c` in run_git overrides it. let text = format!( - "[core]\n\texcludesFile = {}\n[trace2]\n\tnormalTarget = /dev/stdout\n\teventTarget = /dev/stdout\n\tperfTarget = /dev/stdout\n", + "[core]\n\texcludesFile = {}\n[trace2]\n\tnormalTarget = /dev/stdout\n\teventTarget = /dev/stdout\n\tperfTarget = /dev/stdout\n[core]\n\tsharedRepository = nonsense\n", excludes.display() ); std::fs::write(&config, text).expect("writes"); From 347b2154a5fb6dc849be5912cfb32e2716c9936f Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:35:35 -0700 Subject: [PATCH 089/114] improvise-y5eb: red test, the settle reads a stale cell after a wrapping advance Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../improvise-wasm-client/tests/lockstep.rs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 785104e1..c3ba2068 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2124,6 +2124,95 @@ fn fold_host() -> App { app } +/// Measures on Row (`Profit` both stored and a formula's), months on Column, +/// so a `Down` from the bottom row wraps to the top of the next column. +fn wrapping_host() -> App { + let mut wb = Workbook::new("Test"); + wb.add_category("Month").unwrap(); + for month in ["Jan", "Feb"] { + wb.add_item("Month", month).unwrap(); + } + for measure in ["Revenue", "Profit", "Cost"] { + wb.add_item("_Measure", measure).unwrap(); + } + wb.model.add_formula( + improvise_command::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(), + ); + wb.active_view_mut().set_axis("_Measure", Axis::Row); + wb.active_view_mut().set_axis("Month", Axis::Column); + for month in ["Jan", "Feb"] { + for (measure, n) in [("Revenue", 10.0), ("Cost", 4.0)] { + let key = improvise_command::model::cell::CellKey::new(vec![ + ("Month".to_string(), month.to_string()), + ("_Measure".to_string(), measure.to_string()), + ]); + wb.model + .set_cell(key, improvise_protocol::CellValue::Number(n)); + } + } + let mut app = App::new(wb, None); + app.rebuild_layout(); + app +} + +/// improvise-y5eb: the settle must not read the cache through offsets it has not +/// caught up with. See `context/test-evidence/clients.md`. +#[test] +fn a_wrapping_advance_settles_on_the_cell_it_lands_on_not_a_stale_one() { + let mut tui = wrapping_host(); + tui.cursor_mut().selected = (2, 0); + tui.cursor_mut().row_offset = 1; + for k in ["i", "Enter"] { + reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap()); + } + assert_eq!( + tui.view_state.cursor().selected, + (0, 1), + "setup: Enter wrapped" + ); + assert!( + tui.view_state.mode.is_editing(), + "setup: Revenue/Feb is editable" + ); + + let mut host = improvise_command::ui::host::Host::new(wrapping_host()); + let (session, welcome) = host.connect(2, 6); + let mut client = ClientState { + window: (2, 6), + ..ClientState::default() + }; + deliver(vec![welcome], session, &mut client); + let placed = vec![ + improvise_protocol::Command::SetSelected { row: 2, col: 0 }, + improvise_protocol::Command::SetRowOffset(1), + ]; + client.fold_outgoing(&placed); + deliver( + host.handle(session, ClientFrame::Commands(placed)), + session, + &mut client, + ); + for k in ["i", "Enter"] { + let event = BrowserKey::new(k).to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{k}: the client must answer this key locally"); + }; + client.fold_outgoing(&cmds); + deliver( + host.handle(session, ClientFrame::Commands(cmds)), + session, + &mut client, + ); + } + assert_eq!(client.view.cursor().selected, (0, 1)); + assert_eq!( + format!("{:?}", client.view.mode), + format!("{:?}", tui.view_state.mode), + "the client settled on a cell not under its cursor: {:?}", + client.view.status_msg + ); +} + /// improvise-5m9z's other half: what the user typed before the host answered /// is not thrown away. See `context/test-evidence/clients.md`. #[test] From 1e49bc0df7045e1311f75334313d39c118298b38 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:35:38 -0700 Subject: [PATCH 090/114] H refuses on every fold the host refuses on; carry the fold flag improvise-4k1j, from PR #275's review. hidden_row_item refused on a fold only when the NEAREST group header was collapsed. Under two row categories that header can be the inner one's: with Region's Coast collapsed and Product's Kit expanded, the client composed HideItem{Region, "Coast"} and a twin host wrote a group name into the document, where the host refuses. The frame cannot answer this on gh/main: FrameGroup names no level and an inner GroupEnd reads as an outer one. So FrameEntry::Item now carries the layout's own DataItem::collapsed flag (a fold, or under one, at any level) and AxisFrame::folds reads it, the same bit GridLayout::row_folds_a_group reads. FrameEntry::item builds an unfolded one, as AxisEntry::item does. hiding_agrees_with_the_host_on_every_row_of_nested_groups presses H on all 25 rows of four nested grouped grids, in two windows each. It was red on the nearest-header check at rows 4 and 5, as the review found. Also fixes the fmt gate the review found red: fmt-workspaces.sh is a check, not a formatter, and its diff output was misread as success. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 5 +- context/code/protocol.md | 19 ++++- context/test-evidence/clients.md | 25 +++++- crates/improvise-command/src/ui/project.rs | 13 +-- crates/improvise-protocol/src/render.rs | 69 ++++++++++----- crates/improvise-protocol/src/resolve.rs | 2 +- crates/improvise-wasm-client/src/lib.rs | 2 +- crates/improvise-wasm-client/src/render.rs | 4 +- .../improvise-wasm-client/tests/lockstep.rs | 83 ++++++++++++++++++- src/ui/grid.rs | 2 +- 10 files changed, 186 insertions(+), 38 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index d2a7b1b1..0a1798c3 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -596,7 +596,10 @@ now decline. the host's effect is, over `row_frame.cats[0]` and the selected tuple's `[0]`. That is the outer category and its item, exactly as the Cmd reads `row_cats.first()` and `items[0]`. On a fold the client refuses in the host's - own words. That is why `FOLD_REFUSAL` moved from the command layer into + own words. It knows a fold from `AxisFrame::folds`, the layout's own + per-item flag carried on the frame, never from the nearest group header: under + two row categories that header can be the inner one's (improvise-4k1j, + `context/code/protocol.md`). That is why `FOLD_REFUSAL` moved from the command layer into `improvise-protocol`: one string, not two copies. - **`search` (`/`) and `search-navigate` (`n`) decline out loud.** `n` scans the whole model, and a client holds one window. `/` alone would open a box that diff --git a/context/code/protocol.md b/context/code/protocol.md index 2056b567..4c44d32d 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -923,8 +923,8 @@ type and there is nothing else to ask. Note what this cure cost that shared derivation could not be added beside the old one. `cats` is the categories outermost-first; `entries` interleaves -`Group(FrameGroup)` headers with `Item(Vec)` label tuples exactly as -`GridLayout::row_items` interleaves them. On the row axis the TUI paints a +`Group(FrameGroup)` headers with `Item { tuple, collapsed }` label tuples exactly +as `GridLayout::row_items` interleaves them. On the row axis the TUI paints a `Group` as its own row; on the column axis it paints the runs as spans above the headers. **That difference is painting, and it is the only thing that differs.** @@ -935,7 +935,20 @@ one**. `AxisFrame::flat` leaves `cats` empty on purpose, so it is not a way to smuggle a multi-level axis in flattened. `FrameGroup::collapsed` is on the wire rather than left to the client because -it is a fact about the *view*, which only the server holds. There is no +it is a fact about the *view*, which only the server holds. + +**`Item::collapsed` is on the wire too, and for a sharper reason** (improvise-4k1j). +It is the layout's own `AxisEntry::DataItem::collapsed`, which `GridLayout` +sets on a fold and on every item expanded beneath one, at any level. A client +cannot derive it from the headers. `FrameGroup` names no level, and an inner +`GroupEnd` looks the same as an outer one, so under two row categories the +nearest header can be the inner category's. That is how `H` came to hide a +group name as an item. Tuples cannot answer it either, because a group and an +item may share a name. `AxisFrame::folds(idx)` reads the flag, and it answers +exactly what `GridLayout::row_folds_a_group` answers, because it is the same +bit. improvise-e265 puts a level on headers and ends, which makes per-level +membership derivable. The flag still stays: a client reading the host's fact is +stronger than one re-deriving it, however correct the derivation. There is no `FrameGroup::cat` any more (improvise-7xyx). Its doc comment promised that a client echoes it back in `ToggleGroup`, but no client ever did. `resolve.rs` has no `toggle-group-under-cursor` arm (`z` is on the still-needs-the-host diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 8dac3b67..1dc5d2ea 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -659,12 +659,35 @@ row category, where first and last coincide, and there the rival `cats.first()`→`cats.last()` passed the whole suite. Here it is red, and so is the rival that takes `t.last()` for the tuple. +### hiding_agrees_with_the_host_on_every_row_of_nested_groups + +improvise-4k1j, found reviewing PR #275. There are four fixtures, each with +Region over Product on rows: `Coast` collapsed, `Kit` collapsed, both, and +neither. Every row of each is pressed twice, once in a whole-grid window and +once in a two-row window at an offset. On each press the client's commands go to +a twin host, the real host runs `reduce_key`, and the documents and status lines +must agree. That is 25 rows and 50 presses. + +Red with the first fix to `hidden_row_item`, which asked +`frame.group_of(idx)` for a collapsed header. Four presses diverged: rows 4 and 5 +with `Coast` collapsed under an expanded `Kit`, in both windows. The nearest +header there is `Kit`, so the client composed +`HideItem { category: "Region", item: "Coast" }`. That writes a group name into +the document as a hidden item, where the host refuses. + +Two rivals, each red here: +- `hidden_row_item` asks the nearest header again. This test alone goes red. +- The producer in `project.rs` sends `collapsed: false` for every item. This + test goes red, and so does `hiding_on_a_fold_refuses_as_the_host_does`, since + its single-category fold is also invisible without the flag. + ### hiding_on_a_fold_refuses_as_the_host_does improvise-fo0w. With the cursor on a collapsed group's fold row, the client composes exactly `[SetStatus(host's status_msg)]` and the document is unchanged. That status is `FOLD_REFUSAL`, which moved into `improvise-protocol` so that -both ends share one string. Rival: the fold check disabled. The client then no +both ends share one string. The fixture has one row category, so it cannot see +improvise-4k1j, where the nearest header is the wrong level. The test above can. Rival: the fold check disabled. The client then no longer composes the refusal, and this test goes red. It is the only test that does. diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index c40392e8..1e78da16 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -229,12 +229,15 @@ fn axis_frame( // Queued like a header, never clearing one: a COLLAPSED group has // no item to carry it and reaches the frame only through `pending`. AxisEntry::GroupEnd => pending.push(FrameEntry::GroupEnd), - AxisEntry::DataItem { tuple, .. } => { + AxisEntry::DataItem { tuple, collapsed } => { let idx = data_idx; data_idx += 1; if window.contains(&idx) { entries.append(&mut pending); - entries.push(FrameEntry::Item(tuple.clone())); + entries.push(FrameEntry::Item { + tuple: tuple.clone(), + collapsed: *collapsed, + }); } } } @@ -1392,7 +1395,7 @@ mod tests { .map(|e| match e { FrameEntry::Group(g) => format!("[{}]", g.name), FrameEntry::GroupEnd => "[/]".to_string(), - FrameEntry::Item(v) => v.join("+"), + FrameEntry::Item { tuple: v, .. } => v.join("+"), }) .collect() } @@ -1469,7 +1472,7 @@ mod tests { assert_eq!(group.name, "Late"); assert!(!group.collapsed); assert!( - matches!(grid.row_frame.body_from(0), [FrameEntry::Item(_)]), + matches!(grid.row_frame.body_from(0), [FrameEntry::Item { .. }]), "but a renderer starts at the item, not at the header above it" ); } @@ -2161,7 +2164,7 @@ mod tests { row_frame: AxisFrame { first: 0, cats: vec!["Region".into()], - entries: vec![FrameEntry::Item(vec!["North".into()])], + entries: vec![FrameEntry::item(vec!["North".into()])], }, col_frame: AxisFrame::flat(["A", "B"]), col_widths: vec![8, 8], diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 88e043db..1a75933c 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -212,7 +212,21 @@ pub enum FrameEntry { GroupEnd, /// One label per header level, outermost first. Data indices — the /// `row`/`col` of every other field on [`GridCache`] — count only these. - Item(Vec), + Item { + tuple: Vec, + /// The layout's own fold flag: `context/code/protocol.md` (improvise-4k1j). + collapsed: bool, + }, +} + +impl FrameEntry { + /// An ordinary, unfolded item, as `AxisEntry::item` is on the layout side. + pub fn item(tuple: Vec) -> Self { + FrameEntry::Item { + tuple, + collapsed: false, + } + } } /// One axis of the grid's *frame*: header levels, label tuples and groups @@ -237,7 +251,7 @@ impl AxisFrame { first: 0, entries: labels .into_iter() - .map(|l| FrameEntry::Item(vec![l.into()])) + .map(|l| FrameEntry::item(vec![l.into()])) .collect(), } } @@ -246,7 +260,7 @@ impl AxisFrame { /// index counts items only, exactly as `GridLayout` does. pub fn items(&self) -> impl Iterator> { self.entries.iter().filter_map(|e| match e { - FrameEntry::Item(v) => Some(v), + FrameEntry::Item { tuple: v, .. } => Some(v), FrameEntry::Group(_) | FrameEntry::GroupEnd => None, }) } @@ -275,6 +289,19 @@ impl AxisFrame { self.items().nth(idx) } + /// Whether the item at `idx` is a fold or lies under one, at any level: the + /// layout's own flag, as `GridLayout::row_folds_a_group` reads it (4k1j). + pub fn folds(&self, idx: usize) -> bool { + self.entries + .iter() + .filter_map(|e| match e { + FrameEntry::Item { collapsed, .. } => Some(*collapsed), + FrameEntry::Group(_) | FrameEntry::GroupEnd => None, + }) + .nth(idx) + .unwrap_or(false) + } + /// How many header levels this axis draws. **At least one**: an axis /// with no categories still draws a single, empty header line. pub fn levels(&self) -> usize { @@ -345,7 +372,7 @@ impl AxisFrame { match entry { FrameEntry::Group(g) => current = Some(g), FrameEntry::GroupEnd => current = None, - FrameEntry::Item(_) => out.push(current), + FrameEntry::Item { .. } => out.push(current), } } out @@ -361,7 +388,7 @@ impl AxisFrame { .find_map(|e| match e { FrameEntry::Group(g) => Some(Some(g)), FrameEntry::GroupEnd => Some(None), - FrameEntry::Item(_) => None, + FrameEntry::Item { .. } => None, }) .flatten() } @@ -396,7 +423,7 @@ impl AxisFrame { self.entries .iter() .enumerate() - .filter(|(_, e)| matches!(e, FrameEntry::Item(_))) + .filter(|(_, e)| matches!(e, FrameEntry::Item { .. })) .map(|(vi, _)| vi) .nth(idx) } @@ -1168,13 +1195,13 @@ mod tests { first: 0, cats: vec!["Type".into(), "Who".into()], entries: vec![ - FrameEntry::Item(vec!["A".into(), "x".into()]), - FrameEntry::Item(vec!["A".into(), "y".into()]), + FrameEntry::item(vec!["A".into(), "x".into()]), + FrameEntry::item(vec!["A".into(), "y".into()]), FrameEntry::Group(FrameGroup { name: "Late".into(), collapsed: true, }), - FrameEntry::Item(vec!["B".into(), "z".into()]), + FrameEntry::item(vec!["B".into(), "z".into()]), ], } } @@ -1191,11 +1218,11 @@ mod tests { cats: vec!["Region".into(), "Month".into()], entries: vec![ FrameEntry::Group(q1()), - FrameEntry::Item(vec!["North".into(), "Jul".into()]), - FrameEntry::Item(vec!["North".into(), "Aug".into()]), + FrameEntry::item(vec!["North".into(), "Jul".into()]), + FrameEntry::item(vec!["North".into(), "Aug".into()]), FrameEntry::Group(q1()), - FrameEntry::Item(vec!["South".into(), "Jul".into()]), - FrameEntry::Item(vec!["South".into(), "Aug".into()]), + FrameEntry::item(vec!["South".into(), "Jul".into()]), + FrameEntry::item(vec!["South".into(), "Aug".into()]), ], } } @@ -1211,10 +1238,10 @@ mod tests { name: "Q1".into(), collapsed: false, }), - FrameEntry::Item(vec!["Jan".into()]), - FrameEntry::Item(vec!["Feb".into()]), + FrameEntry::item(vec!["Jan".into()]), + FrameEntry::item(vec!["Feb".into()]), FrameEntry::GroupEnd, - FrameEntry::Item(vec!["Sep".into()]), + FrameEntry::item(vec!["Sep".into()]), ], } } @@ -1249,7 +1276,7 @@ mod tests { collapsed: true, }), FrameEntry::GroupEnd, - FrameEntry::Item(vec!["Sep".into()]), + FrameEntry::item(vec!["Sep".into()]), ], }; assert_eq!(frame.len(), 1, "the collapsed group contributes no item"); @@ -1399,7 +1426,7 @@ mod tests { fn body_from_starts_at_the_item_not_at_the_header_above_it() { let frame = two_level_frame(); assert!( - matches!(frame.body_from(2), [FrameEntry::Item(_)]), + matches!(frame.body_from(2), [FrameEntry::Item { .. }]), "the trailing group header is above item 2, not inside the body" ); assert_eq!(frame.body_from(0).len(), 4, "everything, headers included"); @@ -1412,7 +1439,7 @@ mod tests { let frame = AxisFrame { first: 0, cats: vec!["A".into(), "B".into()], - entries: vec![FrameEntry::Item(vec!["Clothing".into(), "日本".into()])], + entries: vec![FrameEntry::item(vec!["Clothing".into(), "日本".into()])], }; assert_eq!( frame.header_widths(), @@ -1450,7 +1477,7 @@ mod tests { let frame = AxisFrame { first: 0, cats: Vec::new(), - entries: vec![FrameEntry::Item(Vec::new())], + entries: vec![FrameEntry::item(Vec::new())], }; assert_eq!(frame.levels(), 1); assert_eq!(frame.label(0, 0), ""); @@ -1583,7 +1610,7 @@ mod tests { cats: vec![cat.into()], entries: labels .iter() - .map(|l| FrameEntry::Item(vec![(*l).into()])) + .map(|l| FrameEntry::item(vec![(*l).into()])) .collect(), }; GridCache::folded(&[ diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index b095ea93..64fc2380 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -780,7 +780,7 @@ fn hidden_row_item(state: &ClientState) -> Option> { }; let idx = state.view.cursor().selected.0.checked_sub(frame.first); // A fold's row names a group, not an item, so there is nothing to hide. - if idx.and_then(|i| frame.group_of(i)).is_some_and(|g| g.collapsed) { + if idx.is_some_and(|i| frame.folds(i)) { return Some(vec![Command::SetStatus(crate::FOLD_REFUSAL.to_string())]); } let Some(item) = idx.and_then(|i| frame.item(i)).and_then(|t| t.first()) else { diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs index c6f5daf3..88579c29 100644 --- a/crates/improvise-wasm-client/src/lib.rs +++ b/crates/improvise-wasm-client/src/lib.rs @@ -377,7 +377,7 @@ mod tests { let rendered: serde_json::Value = serde_json::from_str(&client.state_json().unwrap()).unwrap(); assert_eq!( - rendered["grid"]["row_frame"]["entries"][0]["Item"][0], + rendered["grid"]["row_frame"]["entries"][0]["Item"]["tuple"][0], "North" ); assert_eq!(rendered["view"]["buffers"]["category"], "R"); diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 721cbca1..c07cbd7c 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -257,7 +257,7 @@ pub fn render_model(state: &ClientState) -> RenderModel { label: group.name.clone(), collapsed: group.collapsed, }, - FrameEntry::Item(_) => { + FrameEntry::Item { .. } => { let r = *data_idx; *data_idx += 1; RenderRow::Data { @@ -591,7 +591,7 @@ mod tests { } fn item(label: &str) -> FrameEntry { - FrameEntry::Item(vec![label.into()]) + FrameEntry::item(vec![label.into()]) } fn help_page(page: usize) -> Vec { diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index f32b192f..7dbbda52 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -153,7 +153,11 @@ fn a_forwarded_key_reaches_the_host_unchanged() { let ClientFrame::Key { code, modifiers } = serde_json::from_str(&wire).unwrap() else { panic!("a forwarded key must stay a key"); }; - assert_eq!(modifiers, KeyModifiers::CONTROL, "the modifier survives the wire"); + assert_eq!( + modifiers, + KeyModifiers::CONTROL, + "the modifier survives the wire" + ); let mut app = host(); reduce_key(&mut app, KeyEvent::new(code, modifiers)); @@ -2453,7 +2457,10 @@ fn hiding_under_two_row_categories_hides_the_outer_item() { reduce_key(&mut app, event); assert_eq!(document_text(&twin), document_text(&app)); assert!( - app.model_state.workbook.active_view().is_hidden("Region", "South"), + app.model_state + .workbook + .active_view() + .is_hidden("Region", "South"), "row 2 is (South, Widget): the outer category's item is the one hidden" ); } @@ -2482,6 +2489,78 @@ fn two_row_categories_host() -> App { app } +/// Region over Product, each with one group (`Coast`, `Kit`), either collapsed. +fn nested_groups_host(coast_collapsed: bool, kit_collapsed: bool) -> App { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + wb.add_category("Product").unwrap(); + wb.add_item("Region", "North").unwrap(); + for item in ["East", "West"] { + wb.add_item_in_group("Region", item, "Coast").unwrap(); + } + wb.add_item("Product", "Widget").unwrap(); + for item in ["Gadget", "Gizmo"] { + wb.add_item_in_group("Product", item, "Kit").unwrap(); + } + wb.add_item("_Measure", "Revenue").unwrap(); + let view = wb.active_view_mut(); + view.set_axis("Region", Axis::Row); + view.set_axis("Product", Axis::Row); + view.set_axis("_Measure", Axis::Column); + if coast_collapsed { + view.toggle_group_collapse("Region", "Coast"); + } + if kit_collapsed { + view.toggle_group_collapse("Product", "Kit"); + } + let mut app = App::new(wb, None); + app.term_height = 12 + 8; + app.rebuild_layout(); + app +} + +/// improvise-4k1j: `H` on every row of two-category grouped grids, whole and +/// windowed, agrees with the host. Evidence: `context/test-evidence/clients.md`. +#[test] +fn hiding_agrees_with_the_host_on_every_row_of_nested_groups() { + let mut disagreements = Vec::new(); + let mut rows_pressed = 0; + for (coast, kit) in [(true, false), (false, true), (true, true), (false, false)] { + let rows = nested_groups_host(coast, kit).layout.row_count(); + for row in 0..rows { + for window in [rows, 2] { + let mut app = nested_groups_host(coast, kit); + let mut twin = nested_groups_host(coast, kit); + let offset = row.saturating_sub(window - 1); + app.view_state.cursor_mut().selected = (row, 0); + app.view_state.cursor_mut().row_offset = offset; + twin.view_state = app.view_state.clone(); + twin.rebuild_layout(); + let mut client = furnished_client(&app, (window, 1)); + let event = BrowserKey::new("H").to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("H must answer locally at coast={coast} kit={kit} row={row}"); + }; + cmds.iter().for_each(|c| client.fold(c)); + apply_on(&mut twin, &cmds); + reduce_key(&mut app, event); + rows_pressed += 1; + if document_text(&twin) != document_text(&app) + || client.view.status_msg != app.view_state.status_msg + { + disagreements.push(format!( + "coast={coast} kit={kit} row={row} window={window}: client {cmds:?}, \ + host status {:?}", + app.view_state.status_msg + )); + } + } + } + } + assert_eq!(rows_pressed, 50, "setup: the four grids have 25 rows"); + assert!(disagreements.is_empty(), "{disagreements:#?}"); +} + /// A collapsed group of Region items: its fold is the second row. fn folded_host() -> App { let mut wb = Workbook::new("Test"); diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 2f61de43..5d96167c 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -263,7 +263,7 @@ impl<'a> GridWidget<'a> { ); } } - FrameEntry::Item(_) => { + FrameEntry::Item { .. } => { let ri = data_row_idx; data_row_idx += 1; From bb2f8897326e525305cadbdd9401bbe4940b7eb4 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:36:24 -0700 Subject: [PATCH 091/114] A retry whose cell is Gone keeps the typed text (improvise-joed on #251's decline) renamed_away() is a Step::Declined, like still_waiting(), so the Enter binding's welded clear-buffer no longer empties the editor the user must Esc away from. Shared by the value and rename paths. Red first: a_retry_whose_cell_is_gone_keeps_the_typed_text failed with Some(""). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 9 +++++++-- context/test-evidence/clients.md | 15 +++++++++++++++ crates/improvise-protocol/src/resolve.rs | 5 +++-- .../tests/browser_round_trip.rs | 17 +++++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 01165341..0dde4d10 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -2962,7 +2962,9 @@ value commit can now wait**: the capture waits for the host's answer to this client's own last write, so `Enter` pressed again inside one round trip has no target yet. It declines with the rename's "still waiting" status and writes nothing. With no write in flight and no capture, the cursor is on no cell an -editor writes, and the commit answers `None` as before. **A waiting commit is a decline, so it keeps the typed text (improvise-joed).** +editor writes, and the commit answers `None` as before. + +**A waiting commit is a decline, so it keeps the typed text (improvise-joed).** `Enter` in `Editing` is the sequence `[commit-cell-edit, clear-buffer edit]`, and a sequence runs every step unless one is `Step::Declined`. The waiting answer used to come back from `composed` as an ordinary `Local` step, so the @@ -2970,7 +2972,10 @@ welded clear ran after it and emptied the editor it had just left open. The two cell commits are therefore answered by `committed`, ahead of `composed`, and `still_waiting` is a `Step::Declined`. That is the same channel `declined` uses for commands the browser cannot compose, and the same rule gotcha 21 gives an -abort: what follows a refusal in one binding does not run. +abort: what follows a refusal in one binding does not run. #251's other +decline, a retry whose cell is `Opened::Gone`, keeps its text the same way: +`renamed_away()` is a `Step::Declined` too, and it serves both the value and the +rename path. This also fits improvise-fy9o (PR #227), which drops the weld and has the commit clear its own buffer at its head. There the binding is one command, so diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index f34ad2a3..eab79f61 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5541,3 +5541,18 @@ failed at the buffer assertion: `Some("")` where the typed text was expected. - **Rival: only the value path declines (`rename_coordinate`'s waiting arm stays `Local`).** Fails only this test, so the value test alone could not see the rename path. + +### a_retry_whose_cell_is_gone_keeps_the_typed_text + +improvise-joed on #251's other decline, as the review of #278 asked. This is +8phw's crossing: A's Enter on North/Cost crosses B's hide of North, the refusal +leaves `Opened::Gone`, and the retry `Enter` declines with "the row you were +editing has changed". That decline came back as a `Local` step too, so the +welded `clear-buffer` ran after it and emptied the editor the user must now Esc +away from. It failed at the buffer assertion with `Some("")` where `Some("42")` +was expected. The fix is the same as for the waiting decline: `renamed_away()` +is a `Step::Declined`, shared by the value and rename paths. + +- **Rival: `renamed_away()` is `Step::Local`** (the state before the fix). Fails + only this test. It drives the value path; the rename path reaches the same + function, so no rival can split them without also changing the code shape. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index b13a983a..6edb4123 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1011,9 +1011,10 @@ fn commit_value( /// the one under the cursor, or is gone (improvise-20ek, -9ww8, -8phw). const RECORD_RENAMED_AWAY: &str = "the row you were editing has changed; Esc to leave the editor"; -/// A retry whose cell is gone. +/// A retry whose cell is gone. Declined, so no welded clear takes the text the +/// user must still Esc away from (improvise-joed, until #227 drops the weld). fn renamed_away() -> Step { - Step::Local(vec![Command::SetStatus(RECORD_RENAMED_AWAY.to_string())]) + Step::Declined(vec![Command::SetStatus(RECORD_RENAMED_AWAY.to_string())]) } /// `CommitAndAdvance` over a coordinate: rename the record the editor OPENED on, diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 24cbb369..638bb3c1 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1991,3 +1991,20 @@ fn a_rename_that_waits_for_the_host_keeps_the_typed_text() { "the text the user typed must survive the decline: {second:?}" ); } + +/// improvise-joed on #251's `Gone` decline: the retry declined, and the welded +/// `clear-buffer` still emptied the editor. Evidence file. +#[test] +fn a_retry_whose_cell_is_gone_keeps_the_typed_text() { + let (_, _, a) = cross_north_cost(Meanwhile::HideNorth); + assert!( + a.state().view.status_msg.contains("has changed"), + "setup: this is the Gone decline: {:?}", + a.state().view.status_msg + ); + assert_eq!( + a.state().view.buffers.get("edit").map(String::as_str), + Some("42"), + "the text the user typed must survive the decline" + ); +} From 100627fd57e954a65f00849b4638062d8a9f91a9 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:36:34 -0700 Subject: [PATCH 092/114] improvise-y5eb: read the declined cell through the frame's own origin declined_at indexed the cache with the cursor's offsets, which the client's own outgoing frame moves before the host's projection arrives. A wrapping Down read the old window's top row and closed an editor on Revenue/Feb with Profit's refusal. projected_at reads against AxisFrame::first, so a stale cache answers None instead. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-protocol/src/resolve.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index e135db75..260bcb34 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1040,13 +1040,21 @@ fn reopened_at(state: &ClientState, row: usize, col: usize, mode: AppMode) -> Ve /// Why `i` declines the cell the host projected at `(row, col)`. `None` for an /// editable cell, and for one not projected yet, which the fold settles later. pub(crate) fn declined_at(state: &ClientState, row: usize, col: usize) -> Option { - match cell_at(state, row, col)?.id.as_ref()? { + match projected_at(state, row, col)?.id.as_ref()? { CellId::Aggregate(_) => Some(AGGREGATE_DECLINE.to_string()), CellId::Derived { target } => Some(derived_decline(target)), CellId::Record(_) | CellId::Create(_) | CellId::Coordinate => None, } } +/// The cell the host projected at an absolute position, read against the frame's +/// own origin: offsets the client has scrolled ahead of the cache cannot alias it. +fn projected_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::CellDisplay> { + let r = row.checked_sub(state.grid.row_frame.first)?; + let c = col.checked_sub(state.grid.col_frame.first)?; + state.grid.cell(r, c) +} + /// What the host says of an aggregate, the fold of a collapsed group included. const AGGREGATE_DECLINE: &str = "Cannot write to an aggregate — it combines several records, so there is no one cell to write"; From 1a58fa37fceb796ae01fb664265f9973d2eb0def Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:37:25 -0700 Subject: [PATCH 093/114] improvise-ik6l: red test, Enter over a declined cell goes silently upstream Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../improvise-wasm-client/tests/lockstep.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index c3ba2068..efd57034 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2213,6 +2213,51 @@ fn a_wrapping_advance_settles_on_the_cell_it_lands_on_not_a_stale_one() { ); } +/// improvise-ik6l: Enter on a typed-into editor over a cell `i` declines says +/// so on both ends. See `context/test-evidence/clients.md`. +#[test] +fn enter_on_an_editor_over_a_declined_cell_declines_out_loud_on_both_ends() { + let cases: [(fn() -> App, (usize, usize)); 2] = [(fold_host, (2, 1)), (yank_host, (1, 3))]; + for (make, at) in cases { + let mut app = make(); + app.cursor_mut().selected = at; + app.view_state.mode = AppMode::editing(); + app.view_state.buffers.insert("edit".into(), "7".into()); + app.view_state.opened_editor = Some(improvise_protocol::OpenedEditor { + seed: String::new(), + touched: true, + }); + let before = document_text(&app); + let mut client = client_ready_to_edit(&app, (6, 6)); + let event = BrowserKey::new("Enter").to_key_event().unwrap(); + let KeyOutcome::Local(cmds) = resolve(&client, event) else { + panic!("{at:?}: Enter went upstream, where nothing answers it"); + }; + for cmd in &cmds { + client.fold(cmd); + } + reduce_key(&mut app, event); + assert_eq!(document_text(&app), before, "setup: the TUI wrote nothing"); + assert!( + cmds.iter() + .all(|c| !matches!(c, improvise_protocol::Command::Model { .. })), + "{at:?}: the browser composed a write" + ); + assert_eq!( + format!("{:?}", client.view.mode), + format!("{:?}", app.view_state.mode), + "{at:?}: the two ends disagree about the mode" + ); + assert_eq!(client.view.buffers, app.view_state.buffers, "{at:?}"); + assert!( + !app.view_state.status_msg.is_empty() && !client.view.status_msg.is_empty(), + "{at:?}: a silent decline: TUI {:?}, browser {:?}", + app.view_state.status_msg, + client.view.status_msg + ); + } +} + /// improvise-5m9z's other half: what the user typed before the host answered /// is not thrown away. See `context/test-evidence/clients.md`. #[test] From 7ae871cb1e2f7a9ca5e3ccf5e8e1b970ee85ae57 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:38:35 -0700 Subject: [PATCH 094/114] check-ci-graph: every value it reads is one of a few one-line shapes, or refused (0wn3) A multi-line flow value (- name: [a, "b ... run: ... c: x"]) is one string to go-yaml, PyYAML and Psych, but the gate counted the inner run:. Rather than refuse one more construct, values are now an allowlist: empty, a block header, a closed quote, a one-line flow with no quotes or nesting, or a plain scalar not starting with an indicator. The real ci.yml needs nothing else. Argument: context/code/tooling.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 51 ++++++++++++++++++++- context/test-evidence/tooling.md | 61 +++++++++++++++++++++++++ scripts/check-ci-graph.py | 77 +++++++++++++++++++++++++++----- 3 files changed, 176 insertions(+), 13 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index d23ffa4e..b5bc44e7 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -297,8 +297,9 @@ The gate reads no shell. It counts a step only when all of these hold: The only YAML it models is what makes a line structure rather than content. A key with an inline value owns every following line indented deeper. So a heredoc, a folded or plain continuation, or a whole fake job inside a -top-level block scalar is skipped as content. A quoted value must close on its -own line, or the line is refused (improvise-6zf1, below). Any line outside a YAML comment that mentions +top-level block scalar is skipped as content. Every other line's value must be +one of a few shapes that end on their own line, or the line is refused +(improvise-0wn3, below). Any line outside a YAML comment that mentions `scripts/checkers.sh` and is not a counted step is **reported**, not ignored. The union of the counted jobs' rows must cover `checkers.sh --list`. @@ -388,6 +389,52 @@ continuation lines are read as structure. A `name: "a\"` followed by drops `\\.` before looking for the closing `"`, and `''` before looking for the closing `'`, and a case covers each. +**Every value the gate reads must be one of a few shapes, each ending on its +own line** (improvise-0wn3). The 6zf1 fix refused a multi-line *quote* and +left a multi-line *flow* value (`[`, `{`) alone. The argument for leaving it +was that a flow continuation line is read as a key, so it fails closed. That +argument was wrong. It covered the closing line it imagined, not one written +to look like a step key: + +``` + - name: [a, "b + run: ./scripts/checkers.sh --ci kb + c: x"] +``` + +go-yaml, PyYAML and Psych all read this step as one `name` string with no +`run:`. The gate read the middle line as the step's `run:` and counted it. So +did the single-quoted form, a flow map, a flow opened on a line of its own, and +an anchored or tagged flow (`&a [x, "b`, `!!seq [x, "b`). All exited 0. + +This was the fifth review round in a row, and each fail-open was a construct +the gate did not *recognise* but also did not *refuse*. So the check is now an +allowlist over values, not a list of refused constructs. `_line_value` takes the +value a line carries: a key's, a list item's, or a whole line of its own. That +value must be one of: +- empty (a block follows); +- a block-scalar header (`|`, `>`, with chomping and indentation indicators), + whose body the owner rule skips; +- a quoted scalar that closes on the same line (`_opens_quote`); +- a one-line flow with no quotes, no nesting and no `#` inside it, such as + `needs: [test, wasm]`; +- a plain scalar that does not start with a YAML indicator (`[ ] { } & * ! | > + % @ \` ? ,`). + +Anything else is refused, whatever it is. The real `ci.yml` needs nothing +more: 153 plain values, 41 empty, 2 one-line flows and 5 block headers, with 0 +refused. + +The obvious narrower fix, "a flow value must end in its closer", is not enough. +`[a, "b]` ends in `]` while its quote is still open, and a case pins that. + +This is an allowlist over *values*, and it applies on every line the gate +reads, at any depth. The *keys* at indent 8 or less inside `jobs:` were already +allowlisted by `_readable` (w0j4, rjaa). A deeper key is not shape-checked, +because its value is. So a quoted key deep in a job, such as `"FOO": bar` under +`env:`, is now refused, because the gate cannot tell it from an unclosed quote. +That is a valid style refused loudly, beside the others in this section. + `read_graph` refuses nothing, so it cannot rely on refusal. It opens a job on any ` name:` line followed by whitespace or the end of the line, which is wider than the exact header `ci_steps` accepts. Reading too wide only adds jobs diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index ef5ffeb7..969f4450 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1243,3 +1243,64 @@ another line is not refused. yq-go rejects a continuation at a lesser indentation (`did not find expected ',' or '}'`), which is loud. At the key's own indentation the continuation is read as a key, which fails closed: a spurious `if:`, or a `needs:` value the exact `_NEEDS_VALUE` refuses. + +### Every value is one of a few one-line shapes — `scripts/check-ci-graph.py` (improvise-0wn3) + +**What broke (P2, fail-open).** Found in the delta review of #228 at +`9dc8a643`. A multi-line flow value was not refused: + +``` + - name: [a, "b + run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh --ci kb + c: x"] +``` + +go-yaml, PyYAML 6.0.3 and Psych 5.2.2 read this step as a single `name` string +with no `run:`. The gate read the middle line as the step's `run:` and counted +it. The single-quoted form and a flow map did the same. All three exited 0 on +the real `ci.yml`. + +**This retracts the 6zf1 entry's "Checked, not a finding" note above.** That +note argued a flow continuation "fails closed", but it only considered closing +lines that are not spelled like a step key. + +**Fix: an allowlist over values, not one more refused construct.** Every value +the gate reads must be one of: empty; a block-scalar header; a quoted scalar +closed on its line; a one-line flow with no quotes, nesting or `#`; or a plain +scalar not starting with a YAML indicator. That covers a key's value, a list +item's, or a line of its own. Anything else is refused. The argument is in +`context/code/tooling.md`. + +The real `ci.yml` has 153 plain values, 41 empty, 2 one-line flows and 5 block +headers, and none is refused. When a line is both an unreadable key and an +unreadable value, the key refusal's message wins, so w0j4's cases keep their +diagnosis. + +**Red first** against `9dc8a643`. All seven new cases gave `saw []`: +- the review's three: `[` double-quoted, `[` single-quoted, `{`; +- `[a, "b]`, which ends in its closer with its quote still open. This is what + the review's proposed fix ("does not close on the same line") would pass if + it were read as "ends in `]`"; +- a flow opened on a line of its own, under an empty `name:`. yq-go reads it + as one string too; +- an anchored flow (`&a [x, "b`) and a tagged one (`!!seq [x, "b`), each + confirmed with yq-go to swallow the `run:`. + +**Replayed on the real `ci.yml`.** I replaced kb's checkers step with each of +the review's three plants. At `9dc8a643` all three exit 0. Now all three exit +1, naming the opening line. All ten plants from the four rounds exit 1, and +the file was restored byte for byte (sha1 `a00288a8…`). + +**Rivals, each a source edit run against the real `--self-test` (60 cases).** +All 22 fail. The review's sixteen are my 01–11, the comment rival, the +`read_graph` rival, the old quote-refusal rival (now "value allowlist removed", +which fails 12 cases), and both `_opens_quote` rivals. The four new ones: +- a flow need only end in its closer: fails `[a, "b]` alone; +- any flow start allowed: fails the five flow cases; +- only a key's value is checked: fails the own-line flow case alone; +- anchors and tags read as plain: fails the anchored and tagged cases. + +**A valid style newly refused.** A quoted key below the `_readable` depth, such +as `"FOO": bar` under `env:`, is refused, because the gate cannot tell it from +an unclosed quote. Checked directly: refused, and the plain `FOO: bar` control +is accepted. diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index 524a8944..d6abc87d 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -108,6 +108,29 @@ def _inline_value(rest): return rest.strip() +_FLOW_LINE = re.compile(r"""^(\[[^\[\]{}"'#]*\]|\{[^\[\]{}"'#]*\})$""") +_BLOCK_HEADER = re.compile(r"^[|>][-+0-9]*$") + + +def _plain_value(value): + """Whether a value is one of the few shapes the gate reads, all ending on their own + line; anything else is refused: context/code/tooling.md (improvise-0wn3).""" + if value == "" or _BLOCK_HEADER.match(value) or _FLOW_LINE.match(value): + return True + if value[0] in "\"'": + return not _opens_quote(value) + return value[0] not in "[]{}&*!|>%@`?," + + +def _line_value(raw): + """The value a line carries: a key's, a list item's, or the whole line's.""" + k = _ANY_KEY.match(raw) + if k: + return _inline_value(k.group(3)) + body = raw.strip() + return body[2:].strip() if body.startswith("- ") else body + + def _opens_quote(value): """True when a quoted inline value is not closed on its own line.""" if value[:1] not in ("'", '"') or len(value) < 2: @@ -154,10 +177,10 @@ def ci_steps(text): readable = not in_jobs or indent > 8 or _readable(raw, indent, in_steps, in_needs, job_keyed) k = _ANY_KEY.match(raw) - opens = bool(k) and _opens_quote(_inline_value(k.group(3))) - if _COMMENT.search(raw) or opens or not readable: + shaped = _plain_value(_line_value(raw)) + if _COMMENT.search(raw) or not shaped or not readable: unread.append((n, "comment" if _COMMENT.search(raw) else - "quote" if opens else "plain")) + "plain" if not readable else "value")) if raw.startswith("defaults:"): workflow_flags.add("workflow defaults") m = _JOB.match(raw) @@ -245,9 +268,10 @@ def _unrun(s): def _unread(n, kind="plain"): - if kind == "quote": - return (f"ci.yml line {n} opens a quoted value that continues onto another line; " - "this gate reads no multi-line quotes, so keep it on one line") + if kind == "value": + return (f"ci.yml line {n} has a value that is not one plain line (a multi-line " + "quote or flow, a nested or quoted flow, an anchor, alias or tag); spell " + "it plainly") if kind == "comment": return (f"ci.yml line {n} has a ` #`, which YAML may read as a comment; this gate " "reads no comments, so put it on a line of its own") @@ -312,6 +336,7 @@ def _stray(n): _GATE = " gate:\n if: false\n steps:\n - run: echo\n" _KB_RUN = " - run: ./scripts/checkers.sh --ci kb\n" +_INNER_RUN = " run: ./scripts/checkers.sh --ci kb\n" _NEEDS_GATE = ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), " "so it does not count"] + _NO_PW @@ -353,7 +378,7 @@ SUITE_SELF_TEST = [ _unreadable("a run: line inside a multi-line quoted value: the quote is refused (6zf1)", _wf(_BUILD, _job("kb", " - name: \"a\n" " run: ./scripts/checkers.sh --ci kb\n b\"\n")), - '- name: "a', kind="quote", rest=[_unread(11)]), + '- name: "a', kind="value", rest=[_unread(11)]), ("a whole job inside a top-level block scalar does not count", _REQ, _wf(_BUILD) + "notes: |\n kb:\n steps:\n" @@ -567,23 +592,53 @@ SUITE_SELF_TEST = [ _wf(_BUILD, " gate:\n runs-on: x\n name: \"a\n it\\\" is\n b: \"\n" " if: false\n steps:\n - run: echo \"z\"\n", _job("kb", _KB_RUN, head=" needs: gate\n")), - 'name: "a', kind="quote", rest=_NEEDS_GATE), + 'name: "a', kind="value", rest=_NEEDS_GATE), _unreadable("a multi-line single-quoted value is refused, doubled quote and all (6zf1)", _wf(_BUILD, " gate:\n runs-on: x\n name: 'a\n it''s\n b: '\n" " if: false\n steps:\n - run: echo 'z'\n", _job("kb", _KB_RUN, head=" needs: gate\n")), - "name: 'a", kind="quote", rest=_NEEDS_GATE), + "name: 'a", kind="value", rest=_NEEDS_GATE), _unreadable("an opening line ending in an escaped \\\" still opens a quote (6zf1)", _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n name: \"a\\\"\n" " needs: ok\n x: y\"\n")), - 'name: "a', kind="quote"), + 'name: "a', kind="value"), _unreadable("an opening line ending in a doubled '' still opens a quote (6zf1)", _wf(_BUILD, _GATE, _job("kb", _KB_RUN, head=" needs: gate\n name: 'it''\n" " needs: ok\n x: y'\n")), - "name: 'it", kind="quote"), + "name: 'it", kind="value"), + + _unreadable("a flow list whose quote runs onto the next lines is refused (0wn3)", + _wf(_BUILD, _job("kb", " - name: [a, \"b\n" + _INNER_RUN + " c: x\"]\n")), + 'name: [a, "b', kind="value"), + + _unreadable("the same with a single-quoted string (0wn3)", + _wf(_BUILD, _job("kb", " - name: [a, 'b\n" + _INNER_RUN + " c: x']\n")), + "name: [a, 'b", kind="value"), + + _unreadable("the same as a flow map (0wn3)", + _wf(_BUILD, _job("kb", " - name: {a: \"b\n" + _INNER_RUN + " c: x\"}\n")), + 'name: {a: "b', kind="value"), + + _unreadable("a flow list that ends in its closer with a quote still open is refused (0wn3)", + _wf(_BUILD, _job("kb", " - name: [a, \"b]\n" + _INNER_RUN + " c: x\"]\n")), + 'name: [a, "b]', kind="value"), + + _unreadable("a flow value opened on a line of its own is refused too (0wn3)", + _wf(_BUILD, _job("kb", " - name:\n [a, \"b\n" + _INNER_RUN + + " c: x\"]\n")), + '[a, "b', kind="value"), + + _unreadable("an anchored multi-line flow is refused (0wn3)", + _wf(_BUILD, _job("kb", " - name: &a [x, \"b\n" + _INNER_RUN + " c: x\"]\n")), + 'name: &a', kind="value"), + + _unreadable("a tagged multi-line flow is refused (0wn3)", + _wf(_BUILD, _job("kb", " - name: !!seq [x, \"b\n" + _INNER_RUN + + " c: x\"]\n")), + 'name: !!seq', kind="value"), ("a YAML comment mentioning checkers.sh is fine", _REQ, _wf(_BUILD, _job("kb", " # every suite goes through scripts/checkers.sh\n" From d0a0784febb7a2663e327390219e823134269886 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:39:40 -0700 Subject: [PATCH 095/114] improvise-ik6l: a commit over a declined cell is refused out loud Enter or Tab on a typed-into editor over a fold or a formula's cell went Upstream, which nothing answers. refused_here declines the step, which takes the whole binding with it, exactly as the TUI's failed committable_cell_key does: status said, buffer and mode kept. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-protocol/src/resolve.rs | 12 +++++++++++- crates/improvise-wasm-client/tests/lockstep.rs | 13 +++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 260bcb34..86c2bbf2 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -313,7 +313,7 @@ enum Step { fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step { match requested(name) { Some(request) => Step::Request(request), - None => match declined(name, args) { + None => match declined(name, args).or_else(|| refused_here(state, name)) { Some(said) => Step::Declined(vec![Command::SetStatus(said)]), None => match composed(state, event, name, args) { Some(commands) => Step::Local(commands), @@ -323,6 +323,16 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> St } } +/// A cell commit onto a cell `i` declines, refused as the TUI's +/// `committable_cell_key` refuses it: out loud, taking the binding with it (ik6l). +fn refused_here(state: &ClientState, name: &str) -> Option { + let (row, col) = state.view.cursor().selected; + match name { + "commit-cell-edit" | "commit-and-advance-right" => declined_at(state, row, col), + _ => None, + } +} + /// The command names this client answers by **asking its host**, rather than /// by composing anything. See `context/code/clients.md` (improvise-jz6w). fn requested(name: &str) -> Option { diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index efd57034..1a35e48d 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2217,8 +2217,13 @@ fn a_wrapping_advance_settles_on_the_cell_it_lands_on_not_a_stale_one() { /// so on both ends. See `context/test-evidence/clients.md`. #[test] fn enter_on_an_editor_over_a_declined_cell_declines_out_loud_on_both_ends() { - let cases: [(fn() -> App, (usize, usize)); 2] = [(fold_host, (2, 1)), (yank_host, (1, 3))]; - for (make, at) in cases { + let cases: [(fn() -> App, (usize, usize), &str); 4] = [ + (fold_host, (2, 1), "Enter"), + (yank_host, (1, 3), "Enter"), + (fold_host, (2, 1), "Tab"), + (yank_host, (1, 3), "Tab"), + ]; + for (make, at, key) in cases { let mut app = make(); app.cursor_mut().selected = at; app.view_state.mode = AppMode::editing(); @@ -2229,9 +2234,9 @@ fn enter_on_an_editor_over_a_declined_cell_declines_out_loud_on_both_ends() { }); let before = document_text(&app); let mut client = client_ready_to_edit(&app, (6, 6)); - let event = BrowserKey::new("Enter").to_key_event().unwrap(); + let event = BrowserKey::new(key).to_key_event().unwrap(); let KeyOutcome::Local(cmds) = resolve(&client, event) else { - panic!("{at:?}: Enter went upstream, where nothing answers it"); + panic!("{at:?}: {key} went upstream, where nothing answers it"); }; for cmd in &cmds { client.fold(cmd); From dffbf99ff7abfc0ce27a1446424ee9345a964aed Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:40:09 -0700 Subject: [PATCH 096/114] improvise-y5eb/ik6l: evidence and the clients.md account Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 27 +++++++++++++--- context/test-evidence/clients.md | 53 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index f3e5a243..83fc462f 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -539,15 +539,34 @@ the cache has two consequences: not choose a seed, which stays `seed_at`'s and #227/#272's. **A touched editor is kept.** If the user typed before the answer came, the - editor stays open with their text. Closing it would throw input away, and no - write can follow on that cell: `record_at_cursor` finds no record there. This - is the one state the TUI cannot reach, because it has the layout and never - waits. + editor stays open with their text. Closing it would throw input away. Its + commit is then refused out loud (improvise-ik6l): `refused_here` declines the + `commit-cell-edit` or `commit-and-advance-right` **step**, which takes the + whole binding with it. That includes the welded `clear-buffer`, as the TUI's + failed `committable_cell_key` does, so the status is said and the mode and + the typed text stay. Before this fix the commit composed nothing, and Enter + went `Upstream`, which no host answers: a silent dead key. + + **`declined_at` reads the cache through the frame's own origin** + (`AxisFrame::first`), never the cursor's offsets (improvise-y5eb). The settle + also runs when the client folds its **own** outgoing frame. By then the + offsets have moved but the cache still holds the old window. Indexing it with + the new offsets read the wrong cell. A `Down` that wraps from the bottom row + to the next column's top read the old window's top row, and closed an editor + on Revenue/Feb with Profit's formula refusal. Read against the frame's + origin, a stale cache answers `None` ("not projected yet"), and the host's + own reply decides. The earlier version of this section called that aliasing + unreachable. It is reachable, and the wrap is the case that reaches it. The settle is not specific to advancing. An untouched editor also closes when the host re-projects its cell as declined for any other reason, such as another client adding a formula over it. + **When `CellId::NonItem` lands (#265), both `declined_at` and + `core::uneditable` need an arm for it.** Both matches are exhaustive, so the + compiler will ask. The trap is answering in the `None` arm: a non-item cell + would then open an editor whose commit writes a ghost record. + `GridMove` is a hand-spelled mirror of `navigation.rs`'s `MoveKind`, because the command crate is not on a browser's dependency list and never will be. The *keys* that reach it are no longer spelled twice. `scrolled_to` mirrors diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 28d1bad5..cc009193 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -4637,6 +4637,59 @@ Rivals: - `declined_at` letting a formula's result through: fails, as does the enz6 lockstep test. +### a_wrapping_advance_settles_on_the_cell_it_lands_on_not_a_stale_one + +improvise-y5eb, found by the adversarial review of #282 at `942f57e5`. The +reviewer's repro, made permanent. + +Setup (`wrapping_host`): +- `_Measure` is on rows: [Revenue, Profit, Cost]. Profit is an explicit item + and also `= Revenue - Cost`. +- Months [Jan, Feb] are on columns. +- The window is 2×6, scrolled to rows 1–2, with the cursor on Cost/Jan (2,0). + +`i` then `Enter` wraps to (0,1), Revenue/Feb, which is editable, and the TUI +edits it. The client first folds its own outgoing frame: the row offset drops +to 0, but the cache still holds rows 1–2. `settle_declined_editor` then read +the cache at `(0 - 0, 1 - 0)`, which is Profit/Feb, and closed the editor with +Profit's formula refusal, naming a cell that was not under the cursor. The +host's reply did not repair it, because the settle only ever closes. + +- **Red at `942f57e5`:** the client was `Normal`, the TUI `Editing`. +- **Fix:** `declined_at` reads through `AxisFrame::first`. +- **Rival:** reading through `cell_at` (the cursor's offsets) again fails this + test and nothing else. + +The same test passes at #277's head, so #282 introduced the bug. My hand-back +for #282 said the aliasing was unobservable through a one-step advance. That +was true for one step and false for the wrap. + +### enter_on_an_editor_over_a_declined_cell_declines_out_loud_on_both_ends + +improvise-ik6l. The editor is seated with `7` typed in and `touched: true`, on +two declined cells: the Q2 fold (`fold_host`, (2,1)) and Profit (`yank_host`, +(1,3)). Each is tried with `Enter` and with `Tab`, so four cases in all. + +The TUI refuses at `committable_cell_key` and names the reason. The whole +sequence goes with it: the welded `clear-buffer` does not run, so the text stays +and the mode stays `Editing`. The browser composed nothing, and the key went +`Upstream`, which no host answers: nothing happened and nothing was said. + +The test asserts four things: +- the key resolves locally; +- no write is composed; +- the mode and buffers equal the TUI's; +- both status lines are non-empty. + +Red at `100627fd`: the `(2,1)` fold case went upstream. + +Rivals: +- No `refused_here`: this test fails, going upstream. +- Refusing inside `commit` instead of at the step: this test fails on the + buffers (`""` against `"7"`). A step-level decline is what takes the welded + `clear-buffer` with it. That was my first attempt. +- Refusing `commit-cell-edit` only: this test fails on the `Tab` cases. + ### typing_before_the_host_answers_keeps_the_editor_past_the_window The half of improvise-5m9z that the TUI cannot see. The TUI never waits, so From 6942e53e5e1c14d3edc5da0edf761483e4df1cfc Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:51:53 -0700 Subject: [PATCH 097/114] improvise-ik6l: name the lockstep case tuple for clippy Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-wasm-client/tests/lockstep.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 1a35e48d..4afacbc3 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2217,7 +2217,8 @@ fn a_wrapping_advance_settles_on_the_cell_it_lands_on_not_a_stale_one() { /// so on both ends. See `context/test-evidence/clients.md`. #[test] fn enter_on_an_editor_over_a_declined_cell_declines_out_loud_on_both_ends() { - let cases: [(fn() -> App, (usize, usize), &str); 4] = [ + type Case = (fn() -> App, (usize, usize), &'static str); + let cases: [Case; 4] = [ (fold_host, (2, 1), "Enter"), (yank_host, (1, 3), "Enter"), (fold_host, (2, 1), "Tab"), From 5c69381b69de177a6eadd3981e8a3c2961434353 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 18:52:49 -0700 Subject: [PATCH 098/114] Walk every mode a browser can reach; an empty status is not a decline From PR #280's review. improvise-s2gb: every_silent_binding_is_on_the_list walked Normal and search mode only, so a black hole bound in FormulaPanel (the reviewer's Z -> commit-export) passed the whole suite. The modes are now derived: reachable_modes starts from both halves of Normal (the grid, not a key, picks RecordsNormal, e5pf) and closes over the modes a pressed binding lands in. Doors `declined` shuts (the three stranding prompts, the import wizard, records editing, search now that `/` declines) fall out with no list to maintain. A floor assertion names seven modes the walk must reach, so it cannot pass by shrinking. furnished_to_type seeds the mode's own minibuffer as well as the search query. `heard` counts a SetStatus as a decline only when it says something: a resolver answering SetStatus("") left the user seeing nothing and passed this guard, caught only incidentally by the Ctrl-Q tests. Merges #275's new head (improvise-4k1j) and fixes the fmt gate. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 10 +-- context/test-evidence/clients.md | 81 +++++++++++++++------- crates/improvise-protocol/src/resolve.rs | 88 +++++++++++++++++++++--- 3 files changed, 142 insertions(+), 37 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 87892b37..1bfceac4 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -636,11 +636,13 @@ the renderer needs it. Three tests hold it to what `resolve` actually does: is silent in the browser without being on the list, or on the list without being silent. It also fails if a *hint line* ever names a listed command, because the status bar is never marked. -- `every_silent_binding_is_on_the_list` fails if any binding the Normal or - search-mode keymap reaches is silent and unlisted (improvise-oyaa). It walks - the keymaps, parents and prefixes included, because the help rows are only a +- `every_silent_binding_is_on_the_list` fails if any binding in any mode a + browser can reach is silent and unlisted (improvise-oyaa, s2gb). It walks the + keymaps, parents and prefixes included, because the help rows are only a subset: no help row names Ctrl-Q, so before it `force-quit` could leave the - list with every test still passing. + list with every test still passing. Which modes are reachable is derived by + pressing, starting from both halves of Normal. A door that `declined` shuts + therefore drops out with no list to keep. `every_advertised_command_answers_in_its_own_mode` therefore no longer restates the black holes. It skips a silent row whose command is on the list, because diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index 954802fa..fe7dc933 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -254,11 +254,30 @@ Rivals, each run against the fixed tree: ### every_silent_binding_is_on_the_list -improvise-oyaa. The third guard on `NEEDS_THE_HOST`, and the one that reads the -keymaps rather than the help rows. Every binding the Normal and search-mode -keymaps reach is pressed on the `furnished` fixture: each key, every parent's -key a child does not shadow, and each prefix followed by each key of its -sub-keymap. A binding that is silent must name a command on the list. +improvise-oyaa, widened by improvise-s2gb. The third guard on +`NEEDS_THE_HOST`, and the one that reads the keymaps rather than the help rows. + +**Which modes it walks is derived, not listed.** `reachable_modes` starts from +both halves of Normal, because the grid picks `Normal` or `RecordsNormal`, not a +key (e5pf). It then presses every binding of every mode found so far, and adds +every mode a press lands in, until nothing new appears. At `88e53aed` the guard +walked Normal and search mode only. Now it reaches Normal, RecordsNormal, Help, +Editing, TileSelect, the three panels, and CategoryAdd, ItemAdd and FormulaEdit. + +It leaves out the modes whose door `declined` shuts. It does not list them; they +fall out because no press lands there: +- CommandMode, ExportPrompt and SavePrompt, which `stranding` refuses; +- ImportWizard, reached only through the command line; +- RecordsEditing, whose `a`/`i` decline; +- search mode, since `/` declines (improvise-fo0w). + +A floor assertion names seven modes the walk must reach, so a walk that shrank +cannot pass by walking less. + +Every binding of every reached mode is pressed on the `furnished` fixture: +each key, every parent's key a child does not shadow, and each prefix followed +by each key of its sub-keymap. A binding that is silent must name a command on +the list. The other two guards could not see this. `the_command_names_that_still_need_the_host_are_these` iterates the list, so it @@ -267,34 +286,50 @@ cannot notice a name missing from it. help overlay names, and no help row names Ctrl-Q. So dropping `force-quit` from the list failed nothing until this test. -Red at `cdefeeb9` with `force-quit` dropped: the test names -`Normal [Ctrl-q] force-quit`. That rival is the one the ticket asks for. Two -more, each red here: -- Drop `exit-search-mode`: the test names search mode's `Enter` and `Esc`. - Both older guards fail too. -- Drop `toggle-group-under-cursor`: the test names `z`. Both older guards fail - too. +Rivals, each run on the tree: +- Drop `force-quit`, the rival the ticket asks for: red, naming + `Normal [Ctrl-q] force-quit`. +- Drop `toggle-group-under-cursor`: red, naming `z`. Both older guards fail too. +- Bind `Z` to `commit-export` on the FormulaPanel keymap: red here and only + here. This is s2gb's own rival. At `88e53aed` the whole suite passed with it, + because the guard never walked FormulaPanel. +- Seed the walk with Normal alone: red at the floor, "the walk no longer + reaches RecordsNormal". +- Drop `exit-search-mode`: not this test, since search mode is unreachable. It + is red in `every_help_row_the_browser_cannot_run_is_one_it_marks`, + `every_advertised_command_answers_in_its_own_mode` and + `a_help_row_the_browser_cannot_run_is_drawn_marked`. -**The fixture carries a search query.** With an empty one, search mode's +**The fixture types into every buffer it can.** `furnished_to_type` puts text in +the search query and in the mode's own minibuffer. With an empty buffer, `Backspace` (`pop-char`) has nothing to pop, so it is silent for a reason of state, not of capability. That was the test's first and only false report. -`furnished` already seats a yank and a history for the same reason, so a -command is never refused by an empty stack. +`furnished` already seats a yank and a history for the same reason. -Silent means what `heard` says it means: `Upstream` or `Ignored`, or a `Local` -whose fold moved nothing and said nothing. A decline that sets a status is not -silent, so `/`, `n` and `ZZ` pass without being listed. +**Silent means what `heard` says it means**: `Upstream` or `Ignored`, or a +`Local` whose fold moved nothing and said nothing. A decline must *say* +something. Since #280's review, a `SetStatus` of the empty string does not +count, because the user sees nothing. The rival resolves `force-quit` to +`SetStatus("")` and takes it off the list: +- with that rule, this test is red; +- without it, this test passes, and only the two tests that pin Ctrl-Q as + upstream catch it, incidentally. + +`/`, `n` and `ZZ` pass without being listed, because each sets a real status. ### every_binding_reaches_parents_and_prefixes improvise-oyaa. This checks the walk that the test above relies on. It builds a keymap with a parent and a prefix, and asserts the walk returns three things: the parent's unshadowed key, the child's binding where both bind the same key, -and the prefix's two-key sequence. The walk needs its own check because today -no silent binding sits in a parent or behind a prefix. So a walk that skipped -parents, or skipped prefixes, passed `every_silent_binding_is_on_the_list` -unchanged. Each of those two rivals is red here. So is a third, a walk that -lets a parent's binding shadow the child's. +and the prefix's two-key sequence. The walk needs its own check because no +silent binding sits behind a prefix today. A walk that skipped prefixes +therefore passes `every_silent_binding_is_on_the_list` unchanged, and only this +test goes red. A walk that skipped parents also passed it at `88e53aed`. Since +s2gb widened the guard to modes whose keymaps have parents, it is red there too, +but that is a property of today's keymaps, so this test still owns the walk. +A third rival, a walk that lets a parent's binding shadow the child's, is red +here. ### a_help_row_the_browser_cannot_run_is_drawn_marked diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 71deb5b3..4b1dd886 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1531,7 +1531,7 @@ fn commit_buffer(state: &ClientState, kind: CommitKind) -> Option> #[cfg(test)] mod tests { use super::*; - use crate::keymap::{Keymap, KeyPattern}; + use crate::keymap::{KeyPattern, Keymap}; use crate::{AxisFrame, CellDisplay, GridUpdate, KeyModifiers, Projection, Snapshot}; fn ev(code: KeyCode) -> KeyEvent { @@ -2373,7 +2373,10 @@ mod tests { let before = quiet(&state.view); commands.iter().for_each(|c| state.fold(c)); let sent = commands.iter().any(|c| matches!(c, Command::Model { .. })); - let said = commands.iter().any(|c| matches!(c, Command::SetStatus(_))); + // An empty status is a line the user never sees, so it is not a decline. + let said = commands + .iter() + .any(|c| matches!(c, Command::SetStatus(s) if !s.is_empty())); match (sent || quiet(&state.view) != before, said) { (true, _) => Heard::Acts, (false, true) => Heard::Declines, @@ -2885,7 +2888,11 @@ mod tests { sub.bind(KeyCode::Char('g'), KeyModifiers::NONE, "behind-prefix"); let mut child = Keymap::with_parent(std::sync::Arc::new(parent)); child.bind(KeyCode::Char('b'), KeyModifiers::NONE, "child"); - child.bind_prefix(KeyCode::Char('g'), KeyModifiers::NONE, std::sync::Arc::new(sub)); + child.bind_prefix( + KeyCode::Char('g'), + KeyModifiers::NONE, + std::sync::Arc::new(sub), + ); let mut found: Vec<(String, &str)> = every_binding(&child) .into_iter() .map(|(seq, name)| (seq.iter().map(|k| format!("{:?}", k.code)).collect(), name)) @@ -2901,18 +2908,79 @@ mod tests { ); } - /// improvise-oyaa: every Normal and search-mode binding acts, declines out - /// loud, or is on `NEEDS_THE_HOST`. See `context/test-evidence/clients.md`. + /// `furnished` in `mode`, every buffer it could edit holding text, so a + /// Backspace is never silent for want of a character (improvise-oyaa). + fn furnished_to_type(mode: AppMode, search: bool) -> ClientState { + let mut state = furnished(mode.clone()); + state.view.search_mode = search; + state.view.search_query = "No".into(); + if let Some(mb) = mode.minibuffer() { + state + .view + .buffers + .insert(mb.buffer_key.clone(), "No".into()); + } + state + } + + /// Every mode a browser can stand in: both halves of Normal (the grid picks + /// one, not a key — e5pf), closed over what a press lands in. + fn reachable_modes() -> Vec<(AppMode, bool)> { + let mut seen = vec![ModeKey::Normal, ModeKey::RecordsNormal]; + let mut found = vec![(AppMode::Normal, false), (AppMode::RecordsNormal, false)]; + let mut next = 0; + while let Some((mode, search)) = found.get(next).cloned() { + next += 1; + let key = ModeKey::from_app_mode(&mode, search).expect("a keyed mode"); + let keymap = Keymaps::shared().get(&key).expect("a keymap"); + for (sequence, _) in every_binding(keymap) { + let mut state = furnished_to_type(mode.clone(), search); + for key in &sequence { + if let KeyOutcome::Local(commands) = resolve(&state, *key) { + commands.iter().for_each(|c| state.fold(c)); + } + } + let landed = (state.view.mode.clone(), state.view.search_mode); + if let Some(key) = ModeKey::from_app_mode(&landed.0, landed.1) + && !seen.contains(&key) + { + seen.push(key); + found.push(landed); + } + } + } + found + } + + /// improvise-oyaa, s2gb: every binding of every mode a browser can reach acts, + /// declines out loud, or is on `NEEDS_THE_HOST`. See `context/test-evidence/clients.md`. #[test] fn every_silent_binding_is_on_the_list() { + let modes = reachable_modes(); + let reached: Vec = modes + .iter() + .filter_map(|(mode, search)| ModeKey::from_app_mode(mode, *search)) + .collect(); + for floor in [ + ModeKey::Help, + ModeKey::Editing, + ModeKey::TileSelect, + ModeKey::CategoryPanel, + ModeKey::ViewPanel, + ModeKey::FormulaPanel, + ModeKey::RecordsNormal, + ] { + assert!( + reached.contains(&floor), + "the walk no longer reaches {floor:?}" + ); + } let mut unlisted = Vec::new(); - for (key, search) in [(ModeKey::Normal, false), (ModeKey::SearchMode, true)] { + for (mode, search) in modes { + let key = ModeKey::from_app_mode(&mode, search).expect("a keyed mode"); let keymap = Keymaps::shared().get(&key).expect("a keymap"); for (sequence, command) in every_binding(keymap) { - let mut state = furnished(AppMode::Normal); - state.view.search_mode = search; - // A query to pop, as `furnished` seats a yank to paste. - state.view.search_query = "No".into(); + let state = furnished_to_type(mode.clone(), search); if press(state, &sequence) == Heard::Silent && !needs_the_host(command) { unlisted.push(format!("{key:?} {sequence:?} {command}")); } From 1bec7c0fb9f5b3c68d3c5088f815536929d5a5bd Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 19:00:08 -0700 Subject: [PATCH 099/114] check-ci-graph: a line holding a YAML-only line break or a control character is refused (xpzh) The gate split on \n alone while YAML also breaks at U+0085, U+2028 and U+2029, so an if: after one was invisible and the gate exited 0. Every line, comments and block-scalar bodies included, is now checked for C0 (but tab), DEL, C1 and U+2028/2029. CR is folded by the one read path, read_workflow, and file-based controls pin that. Argument: context/code/tooling.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 31 ++++++++++++++++++ context/test-evidence/tooling.md | 48 +++++++++++++++++++++++++++ scripts/check-ci-graph.py | 56 ++++++++++++++++++++++++++++++-- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index b5bc44e7..943409f5 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -435,6 +435,37 @@ because its value is. So a quoted key deep in a job, such as `"FOO": bar` under `env:`, is now refused, because the gate cannot tell it from an unclosed quote. That is a valid style refused loudly, beside the others in this section. +**A line holding a line break YAML knows and the gate does not, or a control +character, is refused** (improvise-xpzh). The gate splits the text on `\n` +only. YAML also breaks lines at U+0085 (NEL), U+2028 and U+2029, so +`runs-on: x if: false` was one line to the gate and two to go-yaml. +The job's `if:` was invisible, and the gate exited 0. The same break inside a +full-line comment, or inside a `run: |` body, ends that line for YAML too. A +body line `echo a if: false` makes the `if:` a real job key. + +So the check runs on **every** line, before the comment and owner skips, and +refuses: +- C0 controls other than tab (CR included); +- DEL; +- the whole C1 block, which contains U+0085; +- U+2028 and U+2029. + +CR is the one break the read folds. `read_workflow` opens with Python's +universal newlines, which turn CR and CRLF into `\n`, and a lone CR is a line +break to YAML too, so the two agree. A CR that survives to the split can only +come from a read that did not fold it, and it is refused. Two self-test +controls write real files, one CRLF and one lone CR, and read them back +through `read_workflow`. The code path is the point: a string handed straight +to `suite_verdict` never meets the fold. + +**Why not refuse all non-ASCII,** which would be the simpler allowlist. The +real `ci.yml` has 27 non-ASCII characters (em dash, `§`, arrows) in its +comments, so it would have to be rewritten to pass. The set of characters that +changes line structure is not open-ended, though. It is YAML's own `b-char`: +LF and CR, plus NEL, LS and PS in YAML 1.1. So refusing that set, and the +controls around it, is complete by the spec's definition, not a list that +grows with each review. + `read_graph` refuses nothing, so it cannot rely on refusal. It opens a job on any ` name:` line followed by whitespace or the end of the line, which is wider than the exact header `ci_steps` accepts. Reading too wide only adds jobs diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 969f4450..c3b04189 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1304,3 +1304,51 @@ which fails 12 cases), and both `_opens_quote` rivals. The four new ones: as `"FOO": bar` under `env:`, is refused, because the gate cannot tell it from an unclosed quote. Checked directly: refused, and the plain `FOO: bar` control is accepted. + +### A line break YAML knows and the gate does not is refused — `scripts/check-ci-graph.py` (improvise-xpzh) + +**What broke (P2, fail-open).** Found in the delta review of #228 at +`7ae871cb`. The gate splits on `\n` only, but YAML also breaks lines at U+0085, +U+2028 and U+2029. `runs-on: ubuntu-latest if: false` on the real +`kb` job is one line to the gate and two to go-yaml. yq-go reads +`kb.if == false` with each of the three characters, and the gate exited 0. + +**Fix.** `_UNSAFE_CHAR` is checked on every line, before the comment and owner +skips. It covers C0 except tab, DEL, all of C1 (which holds U+0085), U+2028 and +U+2029. The workflow is read through `read_workflow`, whose universal newlines +fold CR and CRLF. The argument, including why all non-ASCII is not refused, is +in `context/code/tooling.md`. The real `ci.yml` has 0 CR bytes, no BOM and 27 +printable non-ASCII characters, none of them in the refused set. + +**Red first** against `7ae871cb`. All five new suite cases gave `saw []`: +- one per break character, each on a `runs-on:` line; +- a U+2028 inside a full-line comment; +- a U+2028 inside a `run: |` body, on the job's last step. yq-go ends the + block there and reads the `if:` as a job key. + +**Controls.** Two cases write a real file and read it back through +`read_workflow`: +- CRLF line ends are accepted with no complaint; +- a lone CR is a line break, so the following `if:` makes kb conditional, as + YAML reads it. + +**Replayed on the real `ci.yml`.** The three plants, one per character, on +kb's `runs-on:`, exit 0 at `7ae871cb`. Now all three exit 1, naming line 255. +All 13 plants from the five rounds exit 1. The file was written with +`newline=""` so the characters landed literally, and it was restored byte for +byte (sha1 `a00288a8…`). + +**Rivals, each a source edit run against the real `--self-test` (65 suite +lists, 2 files).** All 27 fail. The 22 from earlier rounds fail as before. +Five are new: + +| Rival | Fails | +|---|---| +| The character check removed | the five new cases | +| C1 allowed | the U+0085 case alone | +| Comments not checked | the comment case alone | +| Owned content not checked | the block-scalar case, and the comment case | +| The read does not fold CR (`newline=""`) | both file controls | + +The owned-content rival also fails the comment case because `owner` still +holds the preceding `runs-on:` key's column when that line is checked. diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index d6abc87d..ae60bcc3 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -15,9 +15,11 @@ here, and what a row runs is asked of `--would-run` (improvise-maax, st8q). `--self-test` checks the checker: each case is a graph this must refuse. """ +import os import re import subprocess import sys +import tempfile WORKFLOW = ".github/workflows/ci.yml" DEPLOY = "pages-deploy" @@ -84,6 +86,7 @@ _TOP_KEY = re.compile(rf"^{_NAME}:(\s|$)") _NEEDS_VALUE = re.compile(rf"^(|{_NAME}|\[\s*{_NAME}(\s*,\s*{_NAME})*\s*\])$") _NEEDS_ITEM = re.compile(rf"^ - {_NAME}$") _COMMENT = re.compile(r"\s#") +_UNSAFE_CHAR = re.compile("[\x00-\x08\x0b-\x1f\x7f-\x9f\u2028\u2029]") def _readable(line, indent, in_steps, in_needs, job_keyed): @@ -160,6 +163,9 @@ def ci_steps(text): steps.append(step) for n, raw in enumerate(text.split("\n"), 1): + if _UNSAFE_CHAR.search(raw): + unread.append((n, "char")) + continue indent = len(raw) - len(raw.lstrip()) if owner is not None and (not raw.strip() or indent > owner): if "scripts/checkers.sh" in raw: @@ -268,6 +274,9 @@ def _unrun(s): def _unread(n, kind="plain"): + if kind == "char": + return (f"ci.yml line {n} holds a control character or a line break YAML sees and " + "this gate does not (U+0085, U+2028, U+2029); remove it") if kind == "value": return (f"ci.yml line {n} has a value that is not one plain line (a multi-line " "quote or flow, a nested or quoted flow, an anchor, alias or tag); spell " @@ -309,6 +318,12 @@ def suite_verdict(suites, text): return out +def read_workflow(path): + """The workflow's text. Universal newlines fold CR and CRLF into the one break the + gate splits on; every other break YAML knows is refused (improvise-xpzh).""" + return open(path, encoding="utf8").read() + + def list_suites(): """The suites, from checkers.sh itself, so its naming rule has one home.""" out = subprocess.run(["scripts/checkers.sh", "--list"], capture_output=True, @@ -336,6 +351,7 @@ def _stray(n): _GATE = " gate:\n if: false\n steps:\n - run: echo\n" _KB_RUN = " - run: ./scripts/checkers.sh --ci kb\n" +_YAML_ONLY_BREAKS = ("\x85", "\u2028", "\u2029") _INNER_RUN = " run: ./scripts/checkers.sh --ci kb\n" _NEEDS_GATE = ["job `kb`'s `checkers.sh --ci` step is conditional (needs gate, which has if), " "so it does not count"] + _NO_PW @@ -640,12 +656,38 @@ SUITE_SELF_TEST = [ + " c: x\"]\n")), 'name: !!seq', kind="value"), + *[_unreadable(f"a U+{ord(sep):04X} inside a line is refused (xpzh)", + _wf(_BUILD, " kb:\n runs-on: x" + sep + " if: false\n steps:\n" + _KB_RUN), + sep, kind="char") + for sep in _YAML_ONLY_BREAKS], + + _unreadable("a line break inside a block scalar's body is refused too (xpzh)", + _wf(_BUILD, _job("kb", _KB_RUN + " - run: |\n echo a\u2028 if: false\n")), + "echo a", kind="char"), + + _unreadable("a line break inside a full-line comment is refused too (xpzh)", + _wf(_BUILD, " kb:\n runs-on: x\n # note\u2028 if: false\n steps:\n" + + _KB_RUN), "# note", kind="char"), + ("a YAML comment mentioning checkers.sh is fine", _REQ, _wf(_BUILD, _job("kb", " # every suite goes through scripts/checkers.sh\n" " - run: ./scripts/checkers.sh --ci kb\n")), []), ] +# (name, file bytes as text, the complaints it must draw), read back through +# read_workflow, because what a CR means depends on that read (improvise-xpzh). +FILE_SELF_TEST = [ + ("control: CRLF line ends are folded by the read and accepted (xpzh)", + _wf(_BUILD, _job("kb", _KB_RUN)).replace("\n", "\r\n"), []), + + ("control: a lone CR is a line break, as YAML reads it (xpzh)", + _wf(_BUILD, " kb:\n runs-on: x\r if: false\n steps:\n" + _KB_RUN), + ["job `kb`'s `checkers.sh --ci` step is conditional (job if), so it does not count"] + + _NO_PW), +] + + # (name, workflow text, the complaints it must draw). SELF_TEST = [ ("a job nobody gates is named", @@ -690,19 +732,27 @@ def self_test(): got = suite_verdict(suites, text) if got != want: failures.append(f" {name}: expected {want}, saw {got}") + for name, content, want in FILE_SELF_TEST: + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "ci.yml") + with open(path, "w", encoding="utf8", newline="") as f: + f.write(content) + got = suite_verdict(_REQ, read_workflow(path)) + if got != want: + failures.append(f" {name}: expected {want}, saw {got}") if failures: print("ci-graph self-test FAILED:", file=sys.stderr) print("\n".join(failures), file=sys.stderr) return 1 - print(f"ci-graph self-test: {len(SELF_TEST)} graphs and " - f"{len(SUITE_SELF_TEST)} suite lists judged") + print(f"ci-graph self-test: {len(SELF_TEST)} graphs, " + f"{len(SUITE_SELF_TEST)} suite lists and {len(FILE_SELF_TEST)} files judged") return 0 def main(): if "--self-test" in sys.argv[1:]: return self_test() - text = open(WORKFLOW, encoding="utf8").read() + text = read_workflow(WORKFLOW) jobs, needs = read_graph(text) if len(jobs) < 2: print(f"error: {WORKFLOW}: found {len(jobs)} jobs; the parse is wrong", From 32f6067423480d68d98dc67a8c0c383f415a6a9d Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 19:11:18 -0700 Subject: [PATCH 100/114] improvise-7mro: red lockstep matrix, keys sent before a scroll is answered Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../improvise-wasm-client/tests/lockstep.rs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 4afacbc3..5eefe70c 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2662,3 +2662,140 @@ fn renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host() { assert_eq!(client.view.status_msg, "Formula updated"); assert_eq!(app.view_state.status_msg, "Renamed Tax to Levy"); } + +/// One scroll-then-edit case: the window, where the cursor starts, and the keys. +type Pipelined = ( + &'static str, + (usize, usize), + ((usize, usize), usize, usize), + &'static [&'static str], +); + +/// `wrapping_host`'s cells that differ from how it was built, as `Month/Measure`. +fn written_cells(app: &App) -> Vec { + let model = &app.model_state.workbook.model; + let mut written = Vec::new(); + for month in ["Jan", "Feb"] { + for (measure, built) in [("Revenue", Some(10.0)), ("Profit", None), ("Cost", Some(4.0))] { + let key = improvise_command::model::cell::CellKey::new(vec![ + ("Month".to_string(), month.to_string()), + ("_Measure".to_string(), measure.to_string()), + ]); + let now = model.get_cell(&key).cloned(); + if now != built.map(improvise_protocol::CellValue::Number) { + written.push(format!("{month}/{measure}")); + } + } + } + written +} + +/// One key on the client, its frame sent to the host; the reply comes back +/// for the caller to deliver or hold. An upstream key reaches a host that answers nothing. +fn sent( + host: &mut improvise_command::ui::host::Host, + session: improvise_command::ui::project::SessionId, + client: &mut ClientState, + key: &str, +) -> Vec { + match resolve(client, BrowserKey::new(key).to_key_event().unwrap()) { + KeyOutcome::Local(cmds) => { + client.fold_outgoing(&cmds); + host.handle(session, ClientFrame::Commands(cmds)) + } + _ => Vec::new(), + } +} + +/// improvise-7mro: the offset-aliasing matrix, replies delivered and replies +/// held. See `context/test-evidence/clients.md`. +#[test] +fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { + let cases: [Pipelined; 5] = [ + ("wrap", (2, 6), ((2, 0), 1, 0), &["i", "Enter", "8", "Enter"]), + ("horizontal wrap", (2, 1), ((2, 0), 1, 0), &["i", "Enter", "8", "Enter"]), + ("G", (2, 6), ((0, 0), 0, 0), &["G", "i", "7", "Enter"]), + ("$", (2, 1), ((0, 0), 0, 0), &["$", "i", "7", "Enter"]), + ("gg", (2, 6), ((2, 0), 1, 0), &["g", "g", "i", "7", "Enter"]), + ]; + for (name, window, (at, row_offset, col_offset), keys) in cases { + let mut tui = wrapping_host(); + tui.cursor_mut().selected = at; + for &k in keys { + reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap()); + } + assert_eq!(written_cells(&tui).len(), 1, "setup: {name}: the TUI wrote one cell"); + for held in [false, true] { + let what = format!("{name} ({keys:?}, replies held: {held})"); + let mut host = improvise_command::ui::host::Host::new(wrapping_host()); + let (session, welcome) = host.connect(window.0, window.1); + let mut client = ClientState { + window, + ..ClientState::default() + }; + deliver(vec![welcome], session, &mut client); + let placed = vec![ + improvise_protocol::Command::SetSelected { + row: at.0, + col: at.1, + }, + improvise_protocol::Command::SetRowOffset(row_offset), + improvise_protocol::Command::SetColOffset(col_offset), + ]; + client.fold_outgoing(&placed); + deliver( + host.handle(session, ClientFrame::Commands(placed)), + session, + &mut client, + ); + let mut replies = Vec::new(); + for &k in keys { + let reply = sent(&mut host, session, &mut client, k); + match held { + true => replies.extend(reply), + false => deliver(reply, session, &mut client), + } + } + if held { + assert_eq!( + written_cells(host.app()), + Vec::::new(), + "{what}: a write was composed before its target was projected" + ); + assert!( + client.view.mode.is_editing() && !client.view.status_msg.is_empty(), + "{what}: Enter must wait out loud with the editor open: {:?}, {:?}", + client.view.mode, + client.view.status_msg + ); + deliver(replies, session, &mut client); + let reply = sent(&mut host, session, &mut client, "Enter"); + deliver(reply, session, &mut client); + } + assert_eq!( + written_cells(host.app()), + written_cells(&tui), + "{what}: the browser wrote a different cell from the TUI" + ); + if !held { + assert_eq!(document_text(host.app()), document_text(&tui), "{what}"); + } + assert_eq!( + client.view.cursor().selected, + tui.view_state.cursor().selected, + "{what}: the cursor" + ); + assert_eq!( + format!("{:?}", client.view.mode), + format!("{:?}", tui.view_state.mode), + "{what}: the mode; browser status {:?}", + client.view.status_msg + ); + assert_eq!( + client.view.buffers.get("edit"), + tui.view_state.buffers.get("edit"), + "{what}: the editor's buffer" + ); + } + } +} From c1e5a4aaaef56b21d82fc3e0c4886c690134eb2e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 19:15:07 -0700 Subject: [PATCH 101/114] improvise-7mro: every cache read goes through the frame's own origin cell_at_cursor and seed_at read through projected_at, and cell_at (the cursor-offset read) is gone. A cell of the grid outside the window the cache holds is unprojected: i and an advance open an editor awaiting its seed, which the fold settles once the host projects the cell, and a commit, x, yy or p on it waits out loud rather than composing anything. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-protocol/src/client.rs | 18 +++++ crates/improvise-protocol/src/resolve.rs | 79 ++++++++++++++----- .../improvise-wasm-client/tests/lockstep.rs | 1 + 3 files changed, 78 insertions(+), 20 deletions(-) diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 4dd52a72..a34b1d0f 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -176,6 +176,24 @@ impl ClientState { self.view.opened_editor = Some(crate::OpenedEditor::on(seed)); } self.settle_declined_editor(); + self.settle_awaited_seed(); + } + + /// An untouched editor opened on a cell not projected yet takes its seed + /// once the host projects that cell, and never over typing (improvise-7mro). + fn settle_awaited_seed(&mut self) { + let (row, col) = self.view.cursor().selected; + let waiting = self.view.mode.is_editing() + && self + .view + .opened_editor + .as_ref() + .is_some_and(|opened| opened.awaiting && !opened.touched); + if waiting && !crate::resolve::unprojected(self, row, col) { + let seed = crate::resolve::edit_seed(self).unwrap_or_default(); + self.view.buffers.insert("edit".to_string(), seed.clone()); + self.view.opened_editor = Some(crate::OpenedEditor::on(seed)); + } } /// An untouched editor on a cell `i` declines closes once the host projects diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 86c2bbf2..b6c6eab9 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -325,10 +325,13 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> St /// A cell commit onto a cell `i` declines, refused as the TUI's /// `committable_cell_key` refuses it: out loud, taking the binding with it (ik6l). +/// A cell op on a cell the host has not shown yet waits, out loud too (7mro). fn refused_here(state: &ClientState, name: &str) -> Option { let (row, col) = state.view.cursor().selected; + let waiting = unprojected(state, row, col).then(|| NOT_PROJECTED.to_string()); match name { - "commit-cell-edit" | "commit-and-advance-right" => declined_at(state, row, col), + "commit-cell-edit" | "commit-and-advance-right" => declined_at(state, row, col).or(waiting), + "clear-cell" | "yank" | "paste" => waiting, _ => None, } } @@ -770,13 +773,11 @@ fn tile_cursor(state: &ClientState, delta: i32) -> Option> { // ── editing a cell ────────────────────────────────────────────────────── -/// The cell under the cursor in this viewport-indexed cache. `None` when the -/// selection is outside the window — reachable only by a projection race. +/// The cell under the cursor, as the host projected it. `None` outside the +/// window the cache holds, which the cursor's own offsets may have left (7mro). fn cell_at_cursor(state: &ClientState) -> Option<&crate::CellDisplay> { - let cursor = state.view.cursor(); - let row = cursor.selected.0.checked_sub(cursor.row_offset)?; - let col = cursor.selected.1.checked_sub(cursor.col_offset)?; - state.grid.cell(row, col) + let (row, col) = state.view.cursor().selected; + projected_at(state, row, col) } /// `EditOrDrill::execute`, with ADR 002 answering the branch, and drill @@ -787,6 +788,10 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { if state.grid.records { return Some(vec![Command::SetStatus(not_yet("editing in records mode"))]); } + let (row, col) = state.view.cursor().selected; + if unprojected(state, row, col) { + return Some(awaiting_seed(mode)); + } let cell = cell_at_cursor(state)?; match cell.id.as_ref()? { // `edit_text`, never `text`: the drawn text has been through @@ -817,6 +822,19 @@ fn opened_on(seed: String, mode: AppMode) -> Vec { ] } +/// Open the cell editor on a cell the host has not shown yet: empty, its seed +/// settled by the fold once the cell is projected (improvise-7mro). +fn awaiting_seed(mode: AppMode) -> Vec { + vec![ + Command::SetBuffer { + name: EDIT_BUFFER.to_string(), + value: String::new(), + }, + Command::SetOpenedEditor(Some(OpenedEditor::awaiting())), + Command::ChangeMode(mode), + ] +} + /// One direction of the view history. An empty stack is answered out loud: /// `reduce_view`'s own arm is silent there — `context/code/clients.md`. fn step_history(stack: &[crate::ViewFrame], step: Command, empty: &str) -> Vec { @@ -1023,27 +1041,20 @@ pub(crate) fn edit_seed(state: &ClientState) -> Option { cell_at_cursor(state)?.edit_text.clone() } -/// The display text at an absolute grid position, as the cache holds it. +/// The edit text the host projected at an absolute grid position. fn seed_at(state: &ClientState, row: usize, col: usize) -> String { - cell_at(state, row, col) + projected_at(state, row, col) .and_then(|cell| cell.edit_text.clone()) .unwrap_or_default() } -/// The projected cell at an absolute grid position, `None` outside the window. -fn cell_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::CellDisplay> { - let cursor = state.view.cursor(); - let r = row.checked_sub(cursor.row_offset)?; - let c = col.checked_sub(cursor.col_offset)?; - state.grid.cell(r, c) -} - /// `EnterEditAtCursor`: re-open the editor where the advance landed, unless `i` /// would decline that cell. The host asks `uneditable`; this asks the same id. fn reopened_at(state: &ClientState, row: usize, col: usize, mode: AppMode) -> Vec { - match declined_at(state, row, col) { - Some(refusal) => vec![Command::SetStatus(refusal)], - None => opened_on(seed_at(state, row, col), mode), + match (declined_at(state, row, col), unprojected(state, row, col)) { + (Some(refusal), _) => vec![Command::SetStatus(refusal)], + (None, true) => awaiting_seed(mode), + (None, false) => opened_on(seed_at(state, row, col), mode), } } @@ -1065,6 +1076,21 @@ fn projected_at(state: &ClientState, row: usize, col: usize) -> Option<&crate::C state.grid.cell(r, c) } +/// Whether `(row, col)` is a cell of the grid that the window the cache holds +/// does not cover: unknown, never the cell at the same cache index (7mro). +pub(crate) fn unprojected(state: &ClientState, row: usize, col: usize) -> bool { + let GridExtent { rows, cols } = state.grid.extent; + let outside = |frame: &crate::AxisFrame, at: usize| { + !(frame.first..frame.first + frame.len()).contains(&at) + }; + row < rows + && col < cols + && (outside(&state.grid.row_frame, row) || outside(&state.grid.col_frame, col)) +} + +/// What a cell op says while its cell is still unknown to this client. +const NOT_PROJECTED: &str = "waiting for the host to show this cell"; + /// What the host says of an aggregate, the fold of a collapsed group included. const AGGREGATE_DECLINE: &str = "Cannot write to an aggregate — it combines several records, so there is no one cell to write"; @@ -1081,6 +1107,9 @@ pub struct OpenedEditor { pub seed: String, /// Whether the user has edited the buffer since it opened (improvise-2054). pub touched: bool, + /// The seed is still to come, from a cell the host has not projected yet + /// (improvise-7mro). Not `serde(default)`: `false` is a claim. + pub awaiting: bool, } impl OpenedEditor { @@ -1089,6 +1118,16 @@ impl OpenedEditor { Self { seed, touched: false, + awaiting: false, + } + } + + /// An editor opened before its seed is known: empty until it settles. + pub fn awaiting() -> Self { + Self { + seed: String::new(), + touched: false, + awaiting: true, } } } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 5eefe70c..d7106c90 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2232,6 +2232,7 @@ fn enter_on_an_editor_over_a_declined_cell_declines_out_loud_on_both_ends() { app.view_state.opened_editor = Some(improvise_protocol::OpenedEditor { seed: String::new(), touched: true, + awaiting: false, }); let before = document_text(&app); let mut client = client_ready_to_edit(&app, (6, 6)); From 2007a215577efb560dc61f79f7fe59a44002e743 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 19:16:22 -0700 Subject: [PATCH 102/114] improvise-7mro: the matrix covers x and p, and a cell inside the held window Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../improvise-wasm-client/tests/lockstep.rs | 59 +++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index d7106c90..08badb7f 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2712,12 +2712,14 @@ fn sent( /// held. See `context/test-evidence/clients.md`. #[test] fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { - let cases: [Pipelined; 5] = [ + let cases: [Pipelined; 7] = [ ("wrap", (2, 6), ((2, 0), 1, 0), &["i", "Enter", "8", "Enter"]), ("horizontal wrap", (2, 1), ((2, 0), 1, 0), &["i", "Enter", "8", "Enter"]), ("G", (2, 6), ((0, 0), 0, 0), &["G", "i", "7", "Enter"]), ("$", (2, 1), ((0, 0), 0, 0), &["$", "i", "7", "Enter"]), ("gg", (2, 6), ((2, 0), 1, 0), &["g", "g", "i", "7", "Enter"]), + ("$ x", (2, 1), ((0, 0), 0, 0), &["$", "x"]), + ("gg p", (2, 6), ((2, 0), 1, 0), &["y", "y", "g", "g", "p"]), ]; for (name, window, (at, row_offset, col_offset), keys) in cases { let mut tui = wrapping_host(); @@ -2764,13 +2766,15 @@ fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { "{what}: a write was composed before its target was projected" ); assert!( - client.view.mode.is_editing() && !client.view.status_msg.is_empty(), - "{what}: Enter must wait out loud with the editor open: {:?}, {:?}", + client.view.mode.is_editing() == keys.contains(&"i") + && !client.view.status_msg.is_empty(), + "{what}: the last key must wait out loud, any editor open: {:?}, {:?}", client.view.mode, client.view.status_msg ); deliver(replies, session, &mut client); - let reply = sent(&mut host, session, &mut client, "Enter"); + let retry = keys.last().expect("a key"); + let reply = sent(&mut host, session, &mut client, retry); deliver(reply, session, &mut client); } assert_eq!( @@ -2800,3 +2804,50 @@ fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { } } } + +/// improvise-7mro: a cell inside the held window whose offsets the client has +/// moved is read at its own position. See `context/test-evidence/clients.md`. +#[test] +fn a_held_cell_is_read_at_its_position_and_not_its_cache_index() { + let keys = ["k", "k", "j", "i"]; + let mut tui = wrapping_host(); + tui.cursor_mut().selected = (2, 0); + for k in keys { + reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap()); + } + assert_eq!(tui.view_state.cursor().selected, (1, 0), "setup: on Profit/Jan"); + + let mut host = improvise_command::ui::host::Host::new(wrapping_host()); + let (session, welcome) = host.connect(2, 6); + let mut client = ClientState { + window: (2, 6), + ..ClientState::default() + }; + deliver(vec![welcome], session, &mut client); + let placed = vec![ + improvise_protocol::Command::SetSelected { row: 2, col: 0 }, + improvise_protocol::Command::SetRowOffset(1), + ]; + client.fold_outgoing(&placed); + deliver( + host.handle(session, ClientFrame::Commands(placed)), + session, + &mut client, + ); + for k in keys { + let _held = sent(&mut host, session, &mut client, k); + } + let cursor = client.view.cursor(); + assert_eq!( + (cursor.selected, cursor.row_offset, client.grid.row_frame.first), + ((1, 0), 0, 1), + "setup: inside the held window, offsets ahead of it" + ); + assert_eq!( + format!("{:?}", client.view.mode), + format!("{:?}", tui.view_state.mode), + "the client read another row's cell: {:?}", + client.view.buffers.get("edit") + ); + assert_eq!(client.view.status_msg, tui.view_state.status_msg); +} From ff418d33bd18f8a56f24609b5b8931728f50e0ae Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 19:18:12 -0700 Subject: [PATCH 103/114] Restore the harness-lock network step the recompose dropped improvise-dndu, from #245's review and Codex. Resolving the ci.yml conflict with `git checkout --theirs` took #228's whole file, not the conflicting hunk, so #245's `test`-job step running `check-harness-lock.sh --allow-network` against the real harness lock went with it. `git diff 1bec7c0f f41a946b -- ci.yml` was empty: CI kept only the kb row's self-test, which never reads the real lock. The merge message's "the --allow-network step is unchanged" was wrong; this supersedes it. The four lines are restored after the fmt step, where #245 had them. The evidence note records that the self-test and the network step are two different checks, and how to verify a conflict resolution against each parent. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .github/workflows/ci.yml | 4 ++++ context/test-evidence/tooling.md | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e1b9526..d8353fac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,10 @@ jobs: # roots. Exit 2 here is a usage error, never a verdict (improvise-b4ks). - name: Every workspace root is formatted run: ./scripts/fmt-workspaces.sh + # A manifest-only change to a crate the harness reaches by path stales + # its own lock, which only the slow `build` job saw (improvise-zs2r). + - name: The harness lock matches the manifests it resolves against + run: ./scripts/check-harness-lock.sh --allow-network - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace # The comment cap and the test-evidence keys (improvise-9c8c); without diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index c3b04189..2e8e0a0e 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1352,3 +1352,36 @@ Five are new: The owned-content rival also fails the comment case because `owner` still holds the preceding `runs-on:` key's column when that line is checked. + +### Two checks of the harness lock, not one — `ci.yml` (improvise-dndu) + +The CI graph runs two different harness-lock checks, and neither stands in for +the other: +- **the kb row of `ci_suites`** runs `check-harness-lock-test.sh`, the + *self-test*. It pins the script's three answers against path-only fixture + workspaces. It says the script *can* tell stale from current from "did not + run". It never looks at the real harness lock. +- **the `test` job's step** "The harness lock matches the manifests it + resolves against" runs `check-harness-lock.sh --allow-network` against the + real `harness/opfs-contract` manifest. This is the check that actually fails + a stale lock. + +The recompose of #245 onto #228 dropped the second while keeping the first. +The merge resolved the `ci.yml` conflict with `git checkout --theirs`, which +takes the other side's **whole file**, not just the conflicting hunk. So the +network step, which was not part of the conflict, went with it. Nothing +noticed: +- `check-ci-graph` only asks that every `checkers.sh` suite has a CI row, and + the self-test still had one; +- every other gate is local, and the lock was current. + +The merge commit's message even said the step was unchanged. Review and Codex +both caught it: `git diff 1bec7c0f f41a946b -- .github/workflows/ci.yml` was +empty, so #245 no longer changed `ci.yml` at all. + +The repair restores the four lines at their original place, after the +formatting step. The check that would have caught the loss is the one the +repair used: diff the resolved file against **each** parent, and against the +merge base, then confirm every change one side made to the base survives. +Against #245's head, the restored `ci.yml` differs only where #228 moved the +kb step's suite list into its table. From fb246a83310a57fa5ba14f59393676415a95bc5b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 19:24:09 -0700 Subject: [PATCH 104/114] improvise-7mro: the seed settles on a projected cell, and the matrix holds replies three ways An awaited seed waits for a cell the host projected (id present), never the blank a relabel leaves between a frame and its cells. The advance's reopen keeps #282's shape: on this base the welded clear-buffer empties it anyway. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-protocol/src/client.rs | 4 +- crates/improvise-protocol/src/resolve.rs | 14 +- .../improvise-wasm-client/tests/lockstep.rs | 254 ++++++++++++------ 3 files changed, 177 insertions(+), 95 deletions(-) diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index a34b1d0f..f07aeed3 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -182,15 +182,13 @@ impl ClientState { /// An untouched editor opened on a cell not projected yet takes its seed /// once the host projects that cell, and never over typing (improvise-7mro). fn settle_awaited_seed(&mut self) { - let (row, col) = self.view.cursor().selected; let waiting = self.view.mode.is_editing() && self .view .opened_editor .as_ref() .is_some_and(|opened| opened.awaiting && !opened.touched); - if waiting && !crate::resolve::unprojected(self, row, col) { - let seed = crate::resolve::edit_seed(self).unwrap_or_default(); + if let (true, Some(seed)) = (waiting, crate::resolve::projected_seed(self)) { self.view.buffers.insert("edit".to_string(), seed.clone()); self.view.opened_editor = Some(crate::OpenedEditor::on(seed)); } diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index b6c6eab9..b6719eca 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -1041,6 +1041,13 @@ pub(crate) fn edit_seed(state: &ClientState) -> Option { cell_at_cursor(state)?.edit_text.clone() } +/// What an editor awaiting its seed takes once the host projects the cell under +/// the cursor: `None` until then, a relabel's blank included (improvise-7mro). +pub(crate) fn projected_seed(state: &ClientState) -> Option { + let cell = cell_at_cursor(state).filter(|cell| cell.id.is_some())?; + Some(cell.edit_text.clone().unwrap_or_default()) +} + /// The edit text the host projected at an absolute grid position. fn seed_at(state: &ClientState, row: usize, col: usize) -> String { projected_at(state, row, col) @@ -1051,10 +1058,9 @@ fn seed_at(state: &ClientState, row: usize, col: usize) -> String { /// `EnterEditAtCursor`: re-open the editor where the advance landed, unless `i` /// would decline that cell. The host asks `uneditable`; this asks the same id. fn reopened_at(state: &ClientState, row: usize, col: usize, mode: AppMode) -> Vec { - match (declined_at(state, row, col), unprojected(state, row, col)) { - (Some(refusal), _) => vec![Command::SetStatus(refusal)], - (None, true) => awaiting_seed(mode), - (None, false) => opened_on(seed_at(state, row, col), mode), + match declined_at(state, row, col) { + Some(refusal) => vec![Command::SetStatus(refusal)], + None => opened_on(seed_at(state, row, col), mode), } } diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index 08badb7f..9e410cb3 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -2672,12 +2672,27 @@ type Pipelined = ( &'static [&'static str], ); +/// When the host's replies reach the client. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Replies { + /// Each before the next key. + Delivered, + /// All held to the end, so every key reads the first window. + Held, + /// Held until the last two keys, which then run delivered. + HeldThenTyped, +} + /// `wrapping_host`'s cells that differ from how it was built, as `Month/Measure`. fn written_cells(app: &App) -> Vec { let model = &app.model_state.workbook.model; let mut written = Vec::new(); for month in ["Jan", "Feb"] { - for (measure, built) in [("Revenue", Some(10.0)), ("Profit", None), ("Cost", Some(4.0))] { + for (measure, built) in [ + ("Revenue", Some(10.0)), + ("Profit", None), + ("Cost", Some(4.0)), + ] { let key = improvise_command::model::cell::CellKey::new(vec![ ("Month".to_string(), month.to_string()), ("_Measure".to_string(), measure.to_string()), @@ -2691,8 +2706,8 @@ fn written_cells(app: &App) -> Vec { written } -/// One key on the client, its frame sent to the host; the reply comes back -/// for the caller to deliver or hold. An upstream key reaches a host that answers nothing. +/// One key on the client, its frame sent to the host; the reply comes back for +/// the caller to deliver or hold. An upstream key reaches a host that answers nothing. fn sent( host: &mut improvise_command::ui::host::Host, session: improvise_command::ui::project::SessionId, @@ -2708,58 +2723,93 @@ fn sent( } } -/// improvise-7mro: the offset-aliasing matrix, replies delivered and replies -/// held. See `context/test-evidence/clients.md`. +/// A host over `make()` and a client on it, the cursor placed and answered. +fn placed_client( + make: fn() -> App, + window: (usize, usize), + (at, row_offset, col_offset): ((usize, usize), usize, usize), +) -> ( + improvise_command::ui::host::Host, + improvise_command::ui::project::SessionId, + ClientState, +) { + let mut host = improvise_command::ui::host::Host::new(make()); + let (session, welcome) = host.connect(window.0, window.1); + let mut client = ClientState { + window, + ..ClientState::default() + }; + deliver(vec![welcome], session, &mut client); + let placed = vec![ + improvise_protocol::Command::SetSelected { + row: at.0, + col: at.1, + }, + improvise_protocol::Command::SetRowOffset(row_offset), + improvise_protocol::Command::SetColOffset(col_offset), + ]; + client.fold_outgoing(&placed); + deliver( + host.handle(session, ClientFrame::Commands(placed)), + session, + &mut client, + ); + (host, session, client) +} + +/// improvise-7mro: the offset-aliasing matrix, with the host's replies delivered, +/// held, and held until the typing. See `context/test-evidence/clients.md`. #[test] fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { let cases: [Pipelined; 7] = [ - ("wrap", (2, 6), ((2, 0), 1, 0), &["i", "Enter", "8", "Enter"]), - ("horizontal wrap", (2, 1), ((2, 0), 1, 0), &["i", "Enter", "8", "Enter"]), + ( + "wrap", + (2, 6), + ((2, 0), 1, 0), + &["i", "Enter", "8", "Enter"], + ), + ( + "horizontal wrap", + (2, 1), + ((2, 0), 1, 0), + &["i", "Enter", "8", "Enter"], + ), ("G", (2, 6), ((0, 0), 0, 0), &["G", "i", "7", "Enter"]), ("$", (2, 1), ((0, 0), 0, 0), &["$", "i", "7", "Enter"]), ("gg", (2, 6), ((2, 0), 1, 0), &["g", "g", "i", "7", "Enter"]), ("$ x", (2, 1), ((0, 0), 0, 0), &["$", "x"]), ("gg p", (2, 6), ((2, 0), 1, 0), &["y", "y", "g", "g", "p"]), ]; - for (name, window, (at, row_offset, col_offset), keys) in cases { + for (name, window, start, keys) in cases { let mut tui = wrapping_host(); - tui.cursor_mut().selected = at; + tui.cursor_mut().selected = start.0; for &k in keys { reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap()); } - assert_eq!(written_cells(&tui).len(), 1, "setup: {name}: the TUI wrote one cell"); - for held in [false, true] { - let what = format!("{name} ({keys:?}, replies held: {held})"); - let mut host = improvise_command::ui::host::Host::new(wrapping_host()); - let (session, welcome) = host.connect(window.0, window.1); - let mut client = ClientState { - window, - ..ClientState::default() + assert_eq!( + written_cells(&tui).len(), + 1, + "setup: {name}: the TUI wrote one cell" + ); + for replies in [Replies::Delivered, Replies::Held, Replies::HeldThenTyped] { + let what = format!("{name} ({keys:?}, replies {replies:?})"); + let (mut host, session, mut client) = placed_client(wrapping_host, window, start); + let typed_from = match replies { + Replies::Delivered => 0, + Replies::Held => keys.len(), + Replies::HeldThenTyped => keys.len().saturating_sub(2), }; - deliver(vec![welcome], session, &mut client); - let placed = vec![ - improvise_protocol::Command::SetSelected { - row: at.0, - col: at.1, - }, - improvise_protocol::Command::SetRowOffset(row_offset), - improvise_protocol::Command::SetColOffset(col_offset), - ]; - client.fold_outgoing(&placed); - deliver( - host.handle(session, ClientFrame::Commands(placed)), - session, - &mut client, - ); - let mut replies = Vec::new(); - for &k in keys { - let reply = sent(&mut host, session, &mut client, k); - match held { - true => replies.extend(reply), - false => deliver(reply, session, &mut client), + let mut held = Vec::new(); + for (i, &k) in keys.iter().enumerate() { + if i == typed_from { + deliver(std::mem::take(&mut held), session, &mut client); + } + held.extend(sent(&mut host, session, &mut client, k)); + if i >= typed_from { + deliver(std::mem::take(&mut held), session, &mut client); } } - if held { + if replies == Replies::Held { assert_eq!( written_cells(host.app()), Vec::::new(), @@ -2772,7 +2822,7 @@ fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { client.view.mode, client.view.status_msg ); - deliver(replies, session, &mut client); + deliver(std::mem::take(&mut held), session, &mut client); let retry = keys.last().expect("a key"); let reply = sent(&mut host, session, &mut client, retry); deliver(reply, session, &mut client); @@ -2782,8 +2832,13 @@ fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { written_cells(&tui), "{what}: the browser wrote a different cell from the TUI" ); - if !held { + if replies != Replies::Held { assert_eq!(document_text(host.app()), document_text(&tui), "{what}"); + assert_eq!( + client.view.buffers.get("edit"), + tui.view_state.buffers.get("edit"), + "{what}: the editor's buffer" + ); } assert_eq!( client.view.cursor().selected, @@ -2796,58 +2851,81 @@ fn keys_sent_before_a_scroll_is_answered_agree_with_the_tui() { "{what}: the mode; browser status {:?}", client.view.status_msg ); - assert_eq!( - client.view.buffers.get("edit"), - tui.view_state.buffers.get("edit"), - "{what}: the editor's buffer" - ); } } } -/// improvise-7mro: a cell inside the held window whose offsets the client has -/// moved is read at its own position. See `context/test-evidence/clients.md`. +/// Four regions by two measures, every cell stored and no two alike, so a cell +/// read at the wrong row or column is a different value. +fn four_by_two_host() -> App { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + for region in ["R0", "R1", "R2", "R3"] { + wb.add_item("Region", region).unwrap(); + } + for measure in ["A", "B"] { + wb.add_item("_Measure", measure).unwrap(); + } + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + for (r, region) in ["R0", "R1", "R2", "R3"].into_iter().enumerate() { + for (base, measure) in [(10.0, "A"), (20.0, "B")] { + let key = improvise_command::model::cell::CellKey::new(vec![ + ("Region".to_string(), region.to_string()), + ("_Measure".to_string(), measure.to_string()), + ]); + wb.model + .set_cell(key, improvise_protocol::CellValue::Number(base + r as f64)); + } + } + let mut app = App::new(wb, None); + app.rebuild_layout(); + app +} + +/// improvise-7mro: a cell inside the held window, read while the client's offsets +/// differ from the frame's origin. See `context/test-evidence/clients.md`. #[test] fn a_held_cell_is_read_at_its_position_and_not_its_cache_index() { - let keys = ["k", "k", "j", "i"]; - let mut tui = wrapping_host(); - tui.cursor_mut().selected = (2, 0); - for k in keys { - reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap()); + for keys in [ + &["k", "k", "j", "i", "7", "Enter"][..], + &["k", "k", "j", "i", "Tab"][..], + ] { + let mut tui = four_by_two_host(); + tui.cursor_mut().selected = (3, 0); + for &k in keys { + reduce_key(&mut tui, BrowserKey::new(k).to_key_event().unwrap()); + } + let (mut host, session, mut client) = + placed_client(four_by_two_host, (2, 6), ((3, 0), 2, 0)); + let mut held = Vec::new(); + for (i, &k) in keys.iter().enumerate() { + if i == 3 { + let cursor = client.view.cursor(); + assert_eq!( + ( + cursor.selected, + cursor.row_offset, + client.grid.row_frame.first + ), + ((2, 0), 1, 2), + "setup: inside the held window, the offsets not its origin" + ); + } + held.extend(sent(&mut host, session, &mut client, k)); + } + assert_eq!(document_text(host.app()), document_text(&tui), "{keys:?}"); + assert_eq!( + client.view.cursor().selected, + tui.view_state.cursor().selected + ); + assert_eq!( + client.view.buffers.get("edit"), + tui.view_state.buffers.get("edit") + ); + assert_eq!( + client.view.opened_editor, tui.view_state.opened_editor, + "{keys:?}: the editor opened on another cell's seed" + ); } - assert_eq!(tui.view_state.cursor().selected, (1, 0), "setup: on Profit/Jan"); - - let mut host = improvise_command::ui::host::Host::new(wrapping_host()); - let (session, welcome) = host.connect(2, 6); - let mut client = ClientState { - window: (2, 6), - ..ClientState::default() - }; - deliver(vec![welcome], session, &mut client); - let placed = vec![ - improvise_protocol::Command::SetSelected { row: 2, col: 0 }, - improvise_protocol::Command::SetRowOffset(1), - ]; - client.fold_outgoing(&placed); - deliver( - host.handle(session, ClientFrame::Commands(placed)), - session, - &mut client, - ); - for k in keys { - let _held = sent(&mut host, session, &mut client, k); - } - let cursor = client.view.cursor(); - assert_eq!( - (cursor.selected, cursor.row_offset, client.grid.row_frame.first), - ((1, 0), 0, 1), - "setup: inside the held window, offsets ahead of it" - ); - assert_eq!( - format!("{:?}", client.view.mode), - format!("{:?}", tui.view_state.mode), - "the client read another row's cell: {:?}", - client.view.buffers.get("edit") - ); - assert_eq!(client.view.status_msg, tui.view_state.status_msg); } From 51943d1bbcc904c6a99678180281537e2dfd0188 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 19:28:08 -0700 Subject: [PATCH 105/114] improvise-7mro: an empty grid has no cell to wait for Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- crates/improvise-protocol/src/resolve.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index b6719eca..ea1b7c10 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -4092,4 +4092,23 @@ mod tests { `KeyOutcome::Request` has nowhere to put one" ); } + + /// improvise-7mro: an empty grid has no cell to wait for, so nothing a cell op + /// says there is a wait. See `context/test-evidence/clients.md`. + #[test] + fn a_cursor_on_an_empty_grid_waits_for_nothing() { + let mut state = in_mode(AppMode::Normal); + state.window = (2, 2); + for key in ['i', 'x'] { + let said = match resolve(&state, ev(KeyCode::Char(key))) { + KeyOutcome::Local(cmds) => cmds, + _ => Vec::new(), + }; + assert!( + !said.contains(&Command::SetStatus(NOT_PROJECTED.to_string())) + && !said.contains(&Command::SetOpenedEditor(Some(OpenedEditor::awaiting()))), + "{key}: waits on a cell no grid has: {said:?}" + ); + } + } } From 6b604a9e3b3a420663eef13cb081b703e471738b Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 19:29:55 -0700 Subject: [PATCH 106/114] improvise-7mro: evidence and the clients.md account Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 56 +++++++++++++++++++++++ context/test-evidence/clients.md | 58 ++++++++++++++++++++++++ crates/improvise-protocol/src/resolve.rs | 5 +- 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index 83fc462f..1696dd54 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -562,6 +562,62 @@ the cache has two consequences: when the host re-projects its cell as declined for any other reason, such as another client adding a formula over it. + **Every cache read goes through the frame's origin, not only + `declined_at`** (improvise-7mro). `cell_at_cursor` and `seed_at` read through + `projected_at` too, and the cursor-offset read `cell_at` is gone. Before + this, every other read had the y5eb aliasing. A client folds its own scroll + at once, but its cache holds the old window until the host answers, so a key + resolved in that gap read another cell. Pipelined `$ i 7 Enter` in a + one-column window wrote **Jan/Revenue** where the TUI wrote Feb/Revenue: the + seed and the write's target both came from the cell at the old window's + index. `G i` and `gg i` refused an editable cell with a formula's refusal. + + The aliasing had a second form, inside the held window: after `k k j` the + cursor is on a cell the cache holds, but the offsets no longer match the + frame's origin, so index arithmetic through the offsets named the next row. + Reading at the frame's origin answers both forms with one rule. + + **A cell of the grid outside the held window is `unprojected`**: unknown, + never "the cell at the same index". Inside the grid's extent but outside + either frame's range. An empty grid has nothing to wait for, so it is not + unprojected. What each key does on such a cell: + - **`i` opens an editor awaiting its seed** (`OpenedEditor::awaiting()`). + The fold settles it once the host projects that cell + (`settle_awaited_seed`), and never over typing. The settle waits for a cell + with an **id**. Between a frame and its cells, a relabel leaves a blank + `CellDisplay` at the position, and settling on that seeded `""` before the + cell arrived. A declined cell closes through `settle_declined_editor`, + which runs first. + - **A commit, `x`, `yy` and `p` wait out loud.** `refused_here` declines the + step with "waiting for the host to show this cell", which keeps the mode + and the typed text, as ik6l's refusal does. Nothing is composed: the write + target is unknown, and guessing it is the bug. Pressing the key again after + the host answers does the write. This is the Codex finding on #282. + - **An advance's reopen keeps #282's shape.** It reads `seed_at`, which is + `""` for an unprojected landing. On this base the keymap's welded + `clear-buffer` empties the reopened buffer anyway, in the TUI too, so an + awaited seed there would be written into a buffer the TUI has cleared. + + **What a held reply still costs.** With every reply held, the browser cannot + know a seed the TUI shows, so `G i 7` types `7` into an empty editor, where + the TUI types `47`. The write lands on the TUI's record once the host has + answered, never on another. Matching the TUI exactly would need the client + to defer keys until the projection arrives. That means `on_frame` handing + queued frames back to `web/app.js`, which is a change to the page's surface + and is not made here. + + **Merging with #227/#272 and #274.** #272 adds the same `OpenedEditor::awaiting` + field and an `awaiting_seed` for the editor an advance reopens after a write, + settled once no write is unanswered. Merged, the settle must require both: + no write unanswered, and the cell projected (`projected_seed`). With #227's + commit-owned clear, the reopen then awaits for an unprojected landing too. + #274 captures the committed cell in `settle_opened` through + `opened_at_cursor`, which reads `cell_at_cursor`. Without this fix that + capture aliases as well: a horizontal wrap with replies **delivered** writes + Jan/Revenue on #274's head. With `cell_at_cursor` reading at the origin, the + capture waits for the projected cell, because `settle_opened` retries on + every fold. + **When `CellId::NonItem` lands (#265), both `declined_at` and `core::uneditable` need an arm for it.** Both matches are exhaustive, so the compiler will ask. The trap is answering in the `None` arm: a non-item cell diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index cc009193..d24bcd4f 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5243,3 +5243,61 @@ Rival injected: `claim_as_read` ignoring the captured generation and claiming the current one. That is the pre-fix behaviour, restated after the fix. Only this test failed in the whole workspace. The layout tier staying current is still pinned by `an_edit_writes_the_cell_the_user_clicked_after_the_rows_renumber`. + +### keys_sent_before_a_scroll_is_answered_agree_with_the_tui + +improvise-7mro, the offset-aliasing matrix from the review of #282. The fixture +is `wrapping_host`: `_Measure` rows Revenue, Profit (stored and a formula), +Cost, and Month columns Jan, Feb. Seven cases: the wrap, a horizontal wrap that +scrolls both axes, `G`, `$`, `gg`, `$ x` and `yy gg p`. Each runs three ways: +every reply delivered before the next key, every reply held to the end, and +replies held until the last two keys. Each run compares the host's document, +the cursor and the mode with the TUI. The delivered and held-then-typed runs +also compare the whole document text and the edit buffer. + +With every reply held, the browser cannot know the seed, so the held run asserts +something narrower. No write is composed before its target is projected. The +last key waits out loud, and an editor is open exactly when the keys contain +`i`. After the replies land, repeating the last key writes the cell the TUI +wrote. + +Red at `6942e53e` (#282), at gh/main `202e7eac` and at #274's `8d9de94e`. `$` +held wrote **Jan/Revenue**. `G` and `gg` held refused Cost with Profit's formula +refusal. The wrap and the horizontal wrap sent Enter `Upstream`, silently. At +#274's head, the horizontal wrap with replies **delivered** also wrote +Jan/Revenue, because its capture reads `cell_at_cursor`. + +**Rivals**, each failing here: +- `edit_or_drill` without the `unprojected` arm fails `G` held: Normal, no + status. +- `refused_here` without the wait on a commit fails the wrap held: Enter + silent. +- `refused_here` without the `clear-cell`/`yank`/`paste` arm fails `$ x` held. +- No `settle_awaited_seed` fails `G` held-then-typed: `7` where the TUI has `47`. +- A settle that accepts a cell with no id fails the same case. It seeded the + blank a relabel leaves between the frame and its cells. +- `cell_at_cursor` through the cursor offsets fails `G` held-then-typed here, + and the test below. + +### a_held_cell_is_read_at_its_position_and_not_its_cache_index + +improvise-7mro, the second form of the aliasing. `four_by_two_host` has four +regions by two measures, and no two cells are equal. The client's window is +rows 2 and 3. `k k j` puts the cursor on row 2, which the cache holds, with row +offset 1, so the offsets differ from the frame's origin. Every reply is held. +Two key runs, `i 7 Enter` and `i Tab`, are compared with the TUI: the document, +the cursor, the buffer and `opened_editor`. + +**Rivals:** `cell_at_cursor` through the cursor offsets fails on +`i 7 Enter`: it seeds and writes R3 where the TUI writes R2. `seed_at` through +the cursor offsets fails on `opened_editor`: the reopened editor's seed comes +from R3. The `#282` settle masks a bare `i` here: a Profit-like decline would +close it. That is why the fixture has no formula and the test goes on to +commit. + +### resolve::tests::a_cursor_on_an_empty_grid_waits_for_nothing + +improvise-7mro. `unprojected` asks whether the position is inside the grid's +extent, not only outside both frames. An empty grid has empty frames, so +without the extent every position is outside them. **Rival:** drop the extent +guard. `i` then opens an editor awaiting a seed that no cell will ever supply. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index ea1b7c10..c8cd2bbc 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -323,9 +323,8 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> St } } -/// A cell commit onto a cell `i` declines, refused as the TUI's -/// `committable_cell_key` refuses it: out loud, taking the binding with it (ik6l). -/// A cell op on a cell the host has not shown yet waits, out loud too (7mro). +/// A commit onto a cell `i` declines is refused as `committable_cell_key` refuses +/// it, out loud with the binding (ik6l); a cell op on an unprojected cell waits (7mro). fn refused_here(state: &ClientState, name: &str) -> Option { let (row, col) = state.view.cursor().selected; let waiting = unprojected(state, row, col).then(|| NOT_PROJECTED.to_string()); From 0458b435199a947a913cef885209663a411b3b58 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 20:04:29 -0700 Subject: [PATCH 107/114] improvise-3h9w: red test, a captured edit is refused for the cursor's aggregate Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../tests/browser_round_trip.rs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index 40a080ab..e8db507b 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -1925,3 +1925,76 @@ fn a_retry_after_a_crossing_write_to_its_own_record_is_a_force() { ); assert_eq!(region(&host, "North"), None); } + +/// `host()` with `Profit = Revenue - Cost`, so a grid with Region on None +/// shows aggregates and a derived column, and no stored cell. +fn host_with_profit() -> Host { + let mut wb = Workbook::new("Test"); + wb.add_category("Region").unwrap(); + wb.add_item("Region", "North").unwrap(); + wb.add_item("Region", "South").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + wb.add_item("_Measure", "Cost").unwrap(); + wb.model.add_formula( + improvise_command::formula::parse_formula("Profit = Revenue - Cost", "_Measure").unwrap(), + ); + wb.active_view_mut().set_axis("Region", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + wb.model.set_cell( + CellKey::new(vec![ + ("Region".into(), "North".into()), + ("_Measure".into(), "Revenue".into()), + ]), + CellValue::Number(1200.0), + ); + let mut app = App::new(wb, None); + app.rebuild_layout(); + Host::new(app) +} + +/// improvise-3h9w: B's reframe left A's cursor on an aggregate, and Enter was +/// refused for THAT cell instead of writing the captured one. Evidence file. +#[test] +fn a_captured_edit_commits_when_the_cursor_now_sits_on_an_aggregate() { + use improvise_command::ui::reduce::caught_up; + use improvise_protocol::ModelCommand; + + let mut host = host_with_profit(); + let (mut a, mut b) = (Client::new(), Client::new()); + let (a_id, a_snap) = host.connect(10, 10); + let (b_id, b_snap) = host.connect(10, 10); + a.set_viewport(10, 10); + b.set_viewport(10, 10); + deliver(&mut a, &[a_snap]); + deliver(&mut b, &[b_snap]); + for key in ["l", "i", "4", "2"] { + press(&mut host, a_id, &mut a, key); + } + let region_off = ModelCommand::SetAxis { + view: host.app().view_name().to_string(), + category: "Region".into(), + axis: Axis::None, + }; + let relayed = host.handle( + b_id, + ClientFrame::command(caught_up(host.app(), region_off)), + ); + route(&relayed, (a_id, &mut a), (b_id, &mut b)); + assert!(a.state().view.mode.is_editing(), "setup: still editing"); + let (row, col) = a.state().view.cursor().selected; + assert!( + matches!( + a.state().grid.cell(row, col).and_then(|c| c.id.clone()), + Some(improvise_protocol::CellId::Aggregate(_)) + ), + "setup: the cursor now sits on an aggregate" + ); + + press(&mut host, a_id, &mut a, "Enter"); + assert_eq!( + cell(&host, "North", "Cost"), + Some(CellValue::Number(42.0)), + "the captured cell must be written: status {:?}", + a.state().view.status_msg + ); +} From 3dad3b0954b3099934b6fa9c79f2e915854d05b2 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 20:10:36 -0700 Subject: [PATCH 108/114] improvise-3h9w: a captured commit is not refused for the cursor's cell refused_here (from #286) ran before commit() consulted the capture and, for the commit keys, judged the cell under the cursor. After a reframe left the cursor on an aggregate, a captured editor's Enter was declined on every retry. The commit-key guard now applies only to an editor that captured nothing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 8 ++++++++ context/test-evidence/clients.md | 22 ++++++++++++++++++++++ crates/improvise-protocol/src/resolve.rs | 3 ++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/context/code/clients.md b/context/code/clients.md index 067333ac..7c6d971c 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -3064,6 +3064,14 @@ held window is `None`, `settle_opened` simply tries again on the next fold, and the capture lands once the host projects the cell. #274 took #286 by merge rather than porting the reader; the PR body says why. +**A captured commit is not judged by the cursor's cell either (improvise-3h9w).** +#286's `refused_here` declines a commit key whose cursor cell `i` would decline, +and waits on one that is unprojected. It runs in `step()` before `commit()`, +so it must skip an editor that captured its target: that commit writes, or +basis-refuses, the captured cell, whatever now sits under the cursor. The guard +is kept for an editor that captured nothing, which is the case it was written +for. + **What the commit is follows the capture too.** Whether `Enter` writes a value or renames a coordinate is read off `opened.target()`, not off the cell under the cursor (ChatGPT Codex on PR #274): a relayed reframe can leave the cursor on diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index e4428803..03a21b6d 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -5708,3 +5708,25 @@ captured cell stick. On the merged tree the rival that reads the capture through the cursor's offsets (`opened_at_cursor` through `row_offset`/`col_offset` rather than `cell_at_cursor`) fails exactly that case, with `["Jan/Revenue"]` against `["Feb/Revenue"]`. + +### a_captured_edit_commits_when_the_cursor_now_sits_on_an_aggregate + +improvise-3h9w, a regression from merging #286 into #274, found by ChatGPT Codex +and the delta review at `8c28360f`. `step()` runs `refused_here` before +`commit()` consults the capture, and for the commit keys `refused_here` judged +the **cursor's** cell. A opens an editor on North/Cost and types `42`. B moves +Region to None, the relays reset A's cursor, and it now sits on an aggregate. +A's `Enter` was declined with "Cannot write to an aggregate" on every retry, no +frame was sent, and Esc lost the text, where `8d9de94e` wrote North/Cost. It +failed at the North/Cost assertion with `None`. + +The vanished-rows test could not see this, because `unprojected` exempts an +empty grid, so no guard fired there. This case needs the cell under the cursor +to change **kind** while it stays projected. + +- **Rival: `refused_here` without the `state.opened.is_some()` arm** (the state + before the fix). Fails only this test. +- **Not covered:** a captured editor whose cursor lands on a cell that is + inside the grid but outside the held window. The same arm exempts it, but no + test builds it, because every reframe here resets the cursor to the origin, + which is always inside the window. diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 3637bb35..6f5f9697 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -325,11 +325,12 @@ fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> St } /// A commit onto a cell `i` declines is refused as `committable_cell_key` refuses -/// it, out loud with the binding (ik6l); a cell op on an unprojected cell waits (7mro). +/// it (ik6l); a cell op on an unprojected cell waits (7mro). Not a captured commit (3h9w). fn refused_here(state: &ClientState, name: &str) -> Option { let (row, col) = state.view.cursor().selected; let waiting = unprojected(state, row, col).then(|| NOT_PROJECTED.to_string()); match name { + "commit-cell-edit" | "commit-and-advance-right" if state.opened.is_some() => None, "commit-cell-edit" | "commit-and-advance-right" => declined_at(state, row, col).or(waiting), "clear-cell" | "yank" | "paste" => waiting, _ => None, From 901f7fadc5e9f346c387a3da0b108c276c902d67 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 23:13:36 -0700 Subject: [PATCH 109/114] check-ci-graph: a defaults: run: shell: no longer disqualifies a step (the user's ruling) Main's PR #287 added a workflow-level defaults shell, and the refusal made every suite read as unrun. The two cases that pinned it are deleted; one shows a step under workflow and job defaults is counted. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/tooling.md | 13 +++++++++++-- context/test-evidence/tooling.md | 22 ++++++++++++++++++++++ scripts/check-ci-graph.py | 21 +++++---------------- 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/context/code/tooling.md b/context/code/tooling.md index 943409f5..8458afee 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -288,8 +288,6 @@ The gate reads no shell. It counts a step only when all of these hold: (improvise-1be2); - the call names its own job; - neither the step nor the job carries `if:`, `continue-on-error:` or `shell:`; -- no `defaults:` block, at workflow or job level, covers it, since that can - change the shell the step runs under; - no job it `needs:`, directly or through other jobs, carries a job-level `if:`. GitHub skips a job whose need was skipped, so a false `if:` two jobs away skips the call as surely as one on its own job (improvise-bbwa). @@ -472,6 +470,17 @@ wider than the exact header `ci_steps` accepts. Reading too wide only adds jobs `pages-deploy` must need, which fails closed. Reading too narrow is what lost `gate`. +**A `defaults: run: shell:` does not disqualify a step** (the user's ruling, +PR #228). An earlier version refused to count any step under a workflow- or +job-level `defaults:`, because a default shell can change what the call runs +under. The user called that "a silly check". It also broke on the day +`ci.yml` gained a real one: PR #287 set a workflow-level +`shell: nix develop .#ci --command bash …`, and every suite then read as run by +no CI step. A default shell is how the workflow chooses its environment, not a +way to skip a call. A shell that failed to run the call would fail the step, red +in CI. A step's own `shell:` still disqualifies it. That rule was not part of +the ruling. + **What this does not close, stated rather than guarded.** Once a counted step is reached, the gate trusts that it runs the script it names. An earlier step in the same job that rewrote `scripts/checkers.sh` or its table would defeat diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index c3b04189..8d5e1c00 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1352,3 +1352,25 @@ Five are new: The owned-content rival also fails the comment case because `owner` still holds the preceding `runs-on:` key's column when that line is checked. + +### A `defaults: run: shell:` no longer disqualifies a step — `scripts/check-ci-graph.py` (the user's ruling on #228) + +**What changed, and why.** The gate refused to count any step under a +workflow- or job-level `defaults:` block. The user ruled it "a silly check". +It also broke on contact with main: PR #287 added a workflow-level +`defaults: run: shell: nix develop .#ci --command bash …`, and on the merged +tree every suite read as run by no CI step. The refusal is removed. + +**Cases.** Two cases pinned the refusal: "a job's defaults: can change the +shell, so it does not count" and "workflow-level defaults: disqualifies every +step". Both are deleted. The new case "a step under a defaults: run: shell: is +counted" carries both a workflow-level default in #287's shape and a +job-level one, and expects no complaint. + +**Red first.** Against the code before the removal, the new case drew both +steps as "conditional (workflow defaults)" and kb's as "(job defaults, workflow +defaults)", so both rows went unrun. + +**Kept.** A step's own `shell:` still disqualifies it; the ruling did not cover +that. The w0j4 case refusing a quoted `"defaults":` key is about key spelling, +and it stands. diff --git a/scripts/check-ci-graph.py b/scripts/check-ci-graph.py index ae60bcc3..88ef1510 100755 --- a/scripts/check-ci-graph.py +++ b/scripts/check-ci-graph.py @@ -78,7 +78,6 @@ _STEP_KEY = re.compile(r"^ (?:- | )([A-Za-z_][\w-]*):(.*)$") _ANY_KEY = re.compile(r"^(\s*)(?:- )?([A-Za-z_][\w-]*):(.*)$") _CI_CALL = re.compile(r"^(?:nix shell \S+ --command )?\./scripts/checkers\.sh --ci ([\w-]+)$") _DISQUALIFY = ("if", "continue-on-error", "shell") -_JOB_DISQUALIFY = _DISQUALIFY + ("defaults",) _NAME = r"[A-Za-z_][\w-]*" @@ -154,7 +153,6 @@ def ci_steps(text): steps, stray, unread = [], [], [] job, in_steps, job_flags, step = None, False, set(), None in_jobs, job_keyed = False, False - workflow_flags = set() owner, owner_step = None, None needs, if_jobs, in_needs = {}, set(), False @@ -187,8 +185,6 @@ def ci_steps(text): if _COMMENT.search(raw) or not shaped or not readable: unread.append((n, "comment" if _COMMENT.search(raw) else "plain" if not readable else "value")) - if raw.startswith("defaults:"): - workflow_flags.add("workflow defaults") m = _JOB.match(raw) if m: close_step() @@ -211,7 +207,7 @@ def ci_steps(text): needs[job] = {x.strip() for x in value.strip("[]").split(",") if x.strip()} if key == "if": if_jobs.add(job) - if key in _JOB_DISQUALIFY: + if key in _DISQUALIFY: job_flags.add(key) for s in steps: if s["job"] == job: @@ -238,7 +234,6 @@ def ci_steps(text): owner_step = step if m and m.group(1) == "run" else None close_step() for s in steps: - s["flags"] |= workflow_flags s["flags"] |= {f"needs {j}, which has if" for j in _needed(s["job"], needs) & if_jobs} return steps, stray, unread @@ -544,16 +539,10 @@ SUITE_SELF_TEST = [ ["job `kb`'s `checkers.sh --ci` step is conditional (job if), so it does not count"] + _NO_PW), - ("a job's defaults: can change the shell, so it does not count", _REQ, - _wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n", - head=" defaults:\n run:\n shell: python {0}\n")), - ["job `kb`'s `checkers.sh --ci` step is conditional (job defaults), " - "so it does not count"] + _NO_PW), - - ("workflow-level defaults: disqualifies every step", ["assemble-stamp"], - "defaults:\n run:\n shell: python {0}\n" + _wf(_BUILD), - ["job `build`'s `checkers.sh --ci` step is conditional (workflow defaults), " - "so it does not count", _unrun("assemble-stamp")]), + ("a step under a defaults: run: shell: is counted (the user's ruling)", _REQ, + "defaults:\n run:\n shell: nix develop .#ci --command bash -e {0}\n" + + _wf(_BUILD, _job("kb", _KB_RUN, head=" defaults:\n run:\n shell: bash\n")), + []), ("continue-on-error does not count", _REQ, _wf(_BUILD, _job("kb", " - run: ./scripts/checkers.sh --ci kb\n" From e64e8f14ee7ac13aa3c0b07086625bec67ebe261 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 23:21:37 -0700 Subject: [PATCH 110/114] test: a non-item cell re-opens no editor, on either end The merge with #277 gave `uneditable` and `reopened_at` a declining NonItem arm. These tests fail if either arm is moved to the editable branch. Rivals and results are in context/test-evidence/command.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/command.md | 35 ++++++++++++++++++++++++ crates/improvise-command/src/ui/app.rs | 20 ++++++++++++++ crates/improvise-protocol/src/resolve.rs | 24 ++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 50e73b2e..7d127274 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -6559,3 +6559,38 @@ rival, the table fails at that row. The matching row of `refuse_and_stop` itself is no use here, because the table stops at its first failing row, which is the older `import-json of an absent CSV`. The rtud prose correction is landing separately, so these rows are coverage only. + +## `CellId::NonItem` meets `uneditable` (#265 merged with #277) + +#277 (improvise-enz6) folded the refusals of `committable_cell_key` and +`EnterEditAtCursor` into `cmd::core::uneditable`. The browser's twin is +`resolve::reopened_at`. #265 (improvise-yc9r, gk51) added `CellId::NonItem`. +When the two met, the exhaustive matches forced an arm in both functions, but +nothing forced the right one. The #277 review showed that a `NonItem` arm in the +editable branch compiled, passed the whole workspace, and let a TUI commit on a +stale page store `Region=Mars`. The merge gives both functions a declining arm, +with the sentence `i` already says, `non_item_refusal`. + +### an_advance_re_opens_no_editor_on_a_non_item_cell + +`EnterEditAtCursor`, the re-entry `CommitAndAdvance` ends in, is applied on a +page naming no item (`Region=Mars`). It must stay in `Normal` and say the +non-item sentence. The control is the same effect on a live page (`East`). It +must open, so a `uneditable` that declines everything cannot pass. + +**Rival:** `uneditable`'s `NonItem` arm returning `None`, which is the editable +branch. Under a full `--no-fail-fast` workspace run, two tests fail: +- this test; +- `ui::app::non_item_key_tests::a_commit_on_a_non_item_cell_writes_no_orphan`, + because `committable_cell_key` now asks `uneditable`. + +### an_advance_in_the_browser_re_opens_no_editor_on_a_non_item_cell + +The browser side of the same rule. A two-row client has a `Record` at row 0 and +a `NonItem` at row 1. `i` opens the editor on row 0, which is the setup. `Enter` +commits and lands on row 1. The client must be in `Normal`, with the non-item +sentence as its status. + +**Rival:** `reopened_at`'s `NonItem` arm calling `opened_on`, which is the +editable branch. Under a full `--no-fail-fast` workspace run, **only this test +fails**. Nothing else reaches `reopened_at` with a `NonItem` landing. diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index d8493ab3..878f32cd 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -5275,4 +5275,24 @@ mod non_item_key_tests { let status = &app.view_state.status_msg; assert!(status.ends_with(&refusal("Mars")), "{status}"); } + + /// `uneditable` declines a non-item cell, so an advance re-opens no editor + /// on one (#265 meets #277). Evidence: `context/test-evidence/command.md`. + #[test] + fn an_advance_re_opens_no_editor_on_a_non_item_cell() { + use crate::ui::effect::Effect; + let reopen = crate::ui::effect::EnterEditAtCursor { + target_mode: AppMode::editing(), + }; + let mut control = paged_app("East"); + reopen.apply(&mut control); + assert!( + control.view_state.mode.is_editing(), + "control: a live page re-opens" + ); + let mut app = paged_app("Mars"); + reopen.apply(&mut app); + assert_eq!(app.view_state.mode, AppMode::Normal, "no editor re-opened"); + assert_eq!(app.view_state.status_msg, refusal("Mars")); + } } diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index c4ba04bd..2da3170f 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -3375,6 +3375,30 @@ mod tests { ); } + /// `reopened_at` declines a non-item cell, as the host's `uneditable` does + /// (#265 meets #277). Evidence: `context/test-evidence/command.md`. + #[test] + fn an_advance_in_the_browser_re_opens_no_editor_on_a_non_item_cell() { + let mut client = client_on(record_cell()); + let mut landing = CellDisplay::number("900"); + landing.id = Some(CellId::NonItem { + category: "Region".into(), + item: "Mars".into(), + }); + client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells( + vec![((1, 0), landing)], + )))); + press_folded(&mut client, KeyCode::Char('i')); + assert!(client.view.mode.is_editing(), "setup: the editor opened"); + press_folded(&mut client, KeyCode::Enter); + assert_eq!(client.view.cursor().selected, (1, 0), "setup: Enter landed"); + assert_eq!(client.view.mode, AppMode::Normal, "no editor re-opened"); + assert_eq!( + client.view.status_msg, + crate::non_item_refusal("Region", "Mars") + ); + } + /// Two page axes, so the odometer's carry is visible: `Quarter` is least /// significant and `Year` only moves when it wraps. fn paged() -> ClientState { From 68f32a8a55c0059e3062017f52e440017e3dec0d Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 23:23:46 -0700 Subject: [PATCH 111/114] pr-reviews-test: resolve sleep and date from PATH, not /bin (improvise-3uow) On the NixOS runners /bin holds only sh. With no /bin/sleep, the watchdog in bounded_reviews exits at once and wins the `wait -n`, so every --wait case reports 124. Both tools are now resolved with `type -P` before any case stubs PATH. Red: with both paths pointed at /nonexistent/, the suite fails 25 of 25. Rival: REAL_SLEEP=/nonexistent/sleep fails 25 of 25 again. The date pass-through has no discriminating rival, because pr-reviews only calls `date +%s`. Evidence: context/test-evidence/tooling.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tooling.md | 31 +++++++++++++++++++++++++++++++ scripts/pr-reviews-test.sh | 7 ++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index cda31503..5cffd844 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1078,3 +1078,34 @@ the 5.1 control runs past the version check into `check_shebangs` and `check_shebangs || rc=1`, where `set -e` is off. A missing `head` makes every first line empty, and the shebang check then passes without checking anything. That is a defect in `checkers.sh` and is not fixed here. + +### The pr-reviews watchdog resolves its tools from PATH — `scripts/pr-reviews-test.sh` (improvise-3uow) + +**What broke.** The suite hard-coded `/bin/sleep` for the watchdog in +`bounded_reviews` and `/bin/date` for the fake clock's pass-through. On the +NixOS runners `/bin` holds only `sh`. With no `/bin/sleep`, the watchdog exits +at once. `wait -n` then sees it finish before `pr-reviews`, and every bounded +call reports 124 ("killed after 20s"). CI never showed this, because the kb job +stopped one step earlier on the section-5b failure (#296). + +**Reproduced** by pointing both paths at `/nonexistent/`. The result was 0 +passed and 25 failed, every `--wait` case exiting 124: the same shape the +reviewer simulated. + +**Fix.** `REAL_SLEEP` and `REAL_DATE` come from `type -P`, before any case puts +its stub `sleep` and `date` first on PATH. `type -P` returns a path even if +`sleep` has been loaded as a builtin. `REAL_DATE` is exported, because the fake +clock is a separate script. + +**Rivals:** +- **`REAL_SLEEP=/nonexistent/sleep`** fails the whole suite, 0 of 25, so the + resolved path is the one the watchdog runs. +- **The `REAL_DATE` half has no discriminating rival.** `pr-reviews` only ever + calls `date +%s`, so the fake clock's pass-through branch never runs. It was + fixed so that it cannot break later, but no test can see it today. + +**Scope sweep.** `grep -rnE '/(usr/)?bin/[a-z]'` over `scripts/`, `web/*.sh`, +`harness/*.sh`, `harness/tests` and `.github/workflows` finds only `#!/bin/sh` +shebangs, and `/bin/sh` exists on NixOS. It also finds the +`${PATH:-/usr/bin:/bin}` fallback in `checkers.sh`, which is used only when +PATH is unset. diff --git a/scripts/pr-reviews-test.sh b/scripts/pr-reviews-test.sh index 443be281..755ef46f 100755 --- a/scripts/pr-reviews-test.sh +++ b/scripts/pr-reviews-test.sh @@ -9,12 +9,13 @@ work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT # Every run is bounded, so a loop that never stops FAILS (exit 124) rather than -# hanging the suite. /bin/sleep, because some cases put a no-op `sleep` first on PATH. +# hanging the suite. Real tools resolved before any case stubs PATH; NixOS has no /bin/sleep. BOUND=20 +REAL_SLEEP="$(type -P sleep)"; export REAL_DATE; REAL_DATE="$(type -P date)" bounded_reviews() { "$here/pr-reviews" "$@" & local pid=$! dog done rc - /bin/sleep "$BOUND" & + "$REAL_SLEEP" "$BOUND" & dog=$! wait -n -p done "$pid" "$dog"; rc=$? if [ "$done" = "$pid" ]; then kill "$dog" 2>/dev/null; wait "$dog" 2>/dev/null; return "$rc"; fi @@ -218,7 +219,7 @@ clock="$work/clock" mkdir -p "$clock" cat > "$clock/date" <<'EOF' #!/usr/bin/env bash -[ "${1:-}" = "+%s" ] || exec /bin/date "$@" +[ "${1:-}" = "+%s" ] || exec "$REAL_DATE" "$@" n=$(( $(cat "$CLOCK_TICKS" 2>/dev/null || echo 0) + 1 )) echo "$n" > "$CLOCK_TICKS" echo $(( 1000 + n )) From 754add31e2c3def6c93683e3fda0b819cd7907b8 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 23:32:54 -0700 Subject: [PATCH 112/114] ci: take bash and tools from the .#ci dev shell; stop policing versions This follows the user's rulings: CI should not care which bash or which standard utilities it runs, and jobs take what the ci dev shell provides. - checkers.sh: remove find_modern_bash, the 5.1 floor (improvise-fd0j) and exit 3. Suites run under $BASH with PATH unchanged. - checkers-test.sh: remove sections 2, 5 and 5b (the fake bashes and utilbin). They existed only to pin the floor. - scripts/kb: drop the BSD `stat -f` fallback. kb needs the dev shell for tpl, and that shell's stat is GNU. - ci.yml: remove every `shell: bash` override, so every step runs in the dev shell. Remove the web-toolchain version probe and the Playwright version/browser probe. The shell's PLAYWRIGHT_BROWSERS_PATH pins the browsers. npm ci stays because @playwright/test is not nix-provided. - flake.nix: update the comments that promised those CI checks. Reasoning: context/code/tooling.md section 5. Evidence: context/test-evidence/tooling.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .github/workflows/ci.yml | 53 +++---------------------- context/code/tooling.md | 45 ++++++++++++++------- context/test-evidence/tooling.md | 36 +++++++++++++++-- flake.nix | 9 ++--- scripts/checkers-test.sh | 67 ++------------------------------ scripts/checkers.sh | 49 +++++------------------ scripts/kb | 2 +- 7 files changed, 88 insertions(+), 173 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fdaca1d..e4d51add 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,29 +53,6 @@ jobs: # below, so it fails under this job's name rather than another's. - name: The stamp gate refuses what it claims to run: ./scripts/checkers.sh assemble-stamp - # scripts/web-toolchain-versions.env stays the one home for the pins, so - # the release capture (scripts/build-web-release.sh, improvise-oujb) - # provisions the same versions. CI no longer installs them — the dev - # shell supplies wasm-pack and node — so this checks the shell against - # the pins instead: a nixpkgs bump that moves either fails here, by name. - # wasm-pack runs its own bundled `wasm-opt`, which is why - # improvise-wasm-client's Cargo.toml names `--enable-bulk-memory`. - - name: The dev shell supplies the pinned web toolchain - run: | - . scripts/web-toolchain-versions.env - test "$(wasm-pack --version)" = "wasm-pack ${WASM_PACK_VERSION#v}" - test "$(node -p 'process.versions.node.split(".")[0]')" = "$NODE_VERSION" - wasm-pack --version - node --version - # wasm-pack uses the shell's wasm-bindgen only at Cargo.lock's exact - # version; otherwise it cargo-installs one (71s) — so a lock bump - # must move flake.nix's wasmBindgenCli too, and this says so. - lock="$(awk '/^name = "wasm-bindgen"$/ {getline; gsub(/[^0-9.]/, ""); print}' Cargo.lock)" - test "$(wasm-bindgen --version)" = "wasm-bindgen $lock" || { - echo "flake.nix wasmBindgenCli is $(wasm-bindgen --version); Cargo.lock wants $lock" >&2 - exit 1 - } - # Built by Nix (packages.web and packages.site in nix/ci.nix), which runs # the same harness/build.sh and web/assemble.sh a developer runs, so there # is still one answer to "how is the bundle built". `web` is the three @@ -84,14 +61,12 @@ jobs: # the harness tree is stamped `harness`. Dependencies are their own # derivations, substituted from titan's cache when Cargo.lock is unchanged. - name: Build the bundles, the harness tree and the site - shell: bash run: | nix build --print-build-logs --out-link result-web .#web nix build --print-build-logs --out-link result-site .#site # The upload steps below read these paths. Copied rather than linked: # an artifact of a symlink into /nix/store is a dangling link elsewhere. - name: Lay the outputs out where the uploads read them - shell: bash run: | rm -rf harness/serve web/pkg web/worker-pkg site cp -rL result-web/harness-serve harness/serve @@ -143,12 +118,10 @@ jobs: # checks.clippy and checks.test in nix/ci.nix: the same cargo commands, # on the dev/test profiles, with dependencies as a cached derivation. - name: cargo clippy --workspace --all-targets -- -D warnings - shell: bash run: nix build --print-build-logs .#checks.x86_64-linux.clippy # Also runs the test-evidence gate (check-comment-runs.py # --check-evidence), which lists the tests this derivation compiled. - name: cargo test --workspace, and test-evidence headings name real tests - shell: bash run: nix build --print-build-logs .#checks.x86_64-linux.test # The comment cap and the test-evidence keys (improvise-9c8c); without # this step the rule is advisory. See context/design-principles.md §6. @@ -174,9 +147,7 @@ jobs: runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 - # Plain bash, not the dev shell: this step needs nix and nothing else. - name: nix build .#improvise - shell: bash run: nix build --print-build-logs .#improvise wasm: @@ -190,7 +161,6 @@ jobs: # depends on crossterm, which does not build for wasm — that is correct, # not a gap (improvise-ywd). The log names each crate as it is checked. - name: cargo check --target wasm32-unknown-unknown, per bundle crate - shell: bash run: nix build --print-build-logs .#checks.x86_64-linux.wasm-check # --------------------------------------------------------------------------- @@ -235,9 +205,9 @@ jobs: # bypass also found that nothing ran this suite. - name: The kb tool refuses what it claims to run: ./scripts/checkers.sh kb - # Every suite goes through scripts/checkers.sh, which pins a bash >= 5 - # and scrubs the environment, so the shell and the variables a suite - # passes under here are the ones it passes under locally (improvise-f9qf). + # Every suite goes through scripts/checkers.sh, which scrubs the + # environment, so the variables a suite passes under here are the ones it + # passes under locally (improvise-f9qf). Its bash is the dev shell's. - name: The checker runner refuses what it claims to run: ./scripts/checkers.sh --self-test # pr-watch is driven entirely from fixtures. The release preflight is the @@ -273,7 +243,6 @@ jobs: # checks.harness-lint in nix/ci.nix, run from inside harness/opfs-contract: # `cargo fmt -p opfs-contract -- --check` and clippy for wasm32. - name: cargo fmt -p opfs-contract and clippy --target wasm32 - shell: bash run: nix build --print-build-logs .#checks.x86_64-linux.harness-lint # --------------------------------------------------------------------------- @@ -301,19 +270,9 @@ jobs: - name: Install the harness working-directory: harness run: npm ci - # No browser install step: the dev shell points PLAYWRIGHT_BROWSERS_PATH - # at nixpkgs' Chromium, Firefox and WebKit, since Playwright's own - # downloads cannot run on NixOS. All three, because improvise-xf1's - # acceptance criterion names Chrome, Firefox and Safari and - # `createSyncAccessHandle` is implemented separately by each engine. - # WebKit's OPFS is absent today; the spec skips on a runtime probe. - # @playwright/test must equal nixpkgs' playwright-driver version, or it - # looks for browser builds that are not there; this says which. - - name: Playwright matches the dev shell's browsers - working-directory: harness - run: | - npx playwright --version - ls "$PLAYWRIGHT_BROWSERS_PATH" + # No browser install step and no version probe: the dev shell sets + # PLAYWRIGHT_BROWSERS_PATH to nixpkgs' browsers, which pin them. `npm ci` + # stays because @playwright/test itself is not nix-provided. # The tree `build` assembled, not one this job rebuilds. Same bytes as # the Pages artifact, modulo the cache-bust token `web/assemble.sh` # stamps — the harness tree is stamped `harness` and the site the commit diff --git a/context/code/tooling.md b/context/code/tooling.md index bed33ec1..0edc9366 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -660,32 +660,47 @@ sixteen scripts, and the review cost is the whole diff rather than the shebang line. The runner also does not ship zsh, so every job would need an `apt-get`. Declined. -What was adopted instead pins the *interpreter and the environment together*, in +What was adopted instead fixes the *environment* a suite runs in, in one place, with no per-invocation nix cost: **`scripts/checkers.sh` is the one entry point for every check_the_checker.** CI calls it and the local gate calls it, so there is no second invocation to drift. -It does three things a bare `./scripts/X-test.sh` does not: +It runs every suite under the bash that runs it: in CI and under direnv, that +is the dev shell's bash. It does two things a bare `./scripts/X-test.sh` does +not: -1. **Pins a bash ≥ 5.1.** The ambient one is tried first, so a runner that - already has bash 5.2 pays nothing; nix supplies one only when the ambient - bash is older, which on macOS outside the dev shell it is. No bash ≥ 5.1 and - no nix is **exit 3** — a refusal, never a quiet run under 3.2. The floor is - 5.1 and not 5 because `pr-reviews-test.sh` uses `wait -n -p`, and `-p` - arrived in 5.1. Under 5.0 it is an invalid option (improvise-fd0j). -2. **Scrubs the environment to an allowlist** (`env -i` plus `PATH`, `HOME`, +1. **Scrubs the environment to an allowlist** (`env -i` plus `PATH`, `HOME`, `TMPDIR`, the `NIX_*` and `GITHUB_*` names, extensible through `CHECKERS_KEEP`). This is the part that closes the direnv hole above, and it is a *positive* list on purpose: the failure mode of a missing name is a loud "command not found", where the failure mode of an extra name is the silence this section is about. -3. **Enforces the shebang rule**, so the `sh`/`bash` split cannot come back. +2. **Enforces the shebang rule**, so the `sh`/`bash` split cannot come back. -The pinned bash's directory goes on the front of `PATH`, so a script the suite -invokes through its *own* `#!/usr/bin/env bash` — or through `sh`, which -nixpkgs' bash also provides — gets the pinned one too. Pinning only the outer -shell would have left every inner one on the system bash, which is where the -fault actually lived. +**The runner no longer polices the bash version or where tools live.** It used +to pin a bash of at least 5.1 (`find_modern_bash`: try the ambient bash, fall +back to `nix shell nixpkgs#bash`, and exit 3 if neither was available). The +floor was 5.1 because `pr-reviews-test.sh` uses `wait -n -p` (improvise-fd0j). +All of that was removed on the user's ruling, when CI moved to the NixOS +runners: "we shouldn't care which version of bash and other standard utilities +are running in CI", and "most jobs should just take what comes in the ci dev +shell". + +So every CI step runs inside `nix develop .#ci`, which provides the bash and the +GNU coreutils. The runner calls suites with `$BASH` and an unchanged `PATH`. A +script that needs a newer bash feature fails where it uses it, not at a version +gate. + +Two things went with the floor: + +- **Its self-tests.** `checkers-test.sh` sections 2, 5 and 5b faked old + bashes, or stripped `PATH` down to hand-picked utilities, to prove the floor + held. Those were the tests that broke on the NixOS runners, where `/usr/bin` + and `/bin` hold only `env` and `sh` (#296, improvise-3uow). +- **The hard-coded tool paths** in the suites (`/bin/sleep` and `/bin/date` in + `pr-reviews-test.sh`) and `scripts/kb`'s BSD `stat -f` fallback. `scripts/kb` + already needs the dev shell for `tpl`, so GNU `stat` is always present when it + runs. **A suite is `X-test.sh` where `X` or `X.sh` is present beside it.** That rule exists because `scripts/node-test.sh` matches `*-test.sh` and is a test diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 5cffd844..23328d4e 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1106,6 +1106,36 @@ clock is a separate script. **Scope sweep.** `grep -rnE '/(usr/)?bin/[a-z]'` over `scripts/`, `web/*.sh`, `harness/*.sh`, `harness/tests` and `.github/workflows` finds only `#!/bin/sh` -shebangs, and `/bin/sh` exists on NixOS. It also finds the -`${PATH:-/usr/bin:/bin}` fallback in `checkers.sh`, which is used only when -PATH is unset. +shebangs, and `/bin/sh` exists on NixOS. It also found a +`${PATH:-/usr/bin:/bin}` fallback in `checkers.sh`, which is now removed (next +entry). + +### The bash floor and its self-tests are retired — `scripts/checkers.sh` + +**The ruling.** The user ruled that CI should not care which version of bash or +of the standard utilities it runs, and that CI jobs should use whatever the +`.#ci` dev shell provides. So `checkers.sh` no longer pins a bash: +`find_modern_bash`, the 5.1 floor (improvise-fd0j) and exit 3 are gone. Suites +run under `$BASH`, the bash running the runner, with `PATH` unchanged. The +reasoning is in `context/code/tooling.md` §5. + +**Tests removed.** Three sections of `checkers-test.sh` were removed. They +existed only to pin the floor, and the entries above that cite them describe +cases that no longer exist: +- Section 2, "a bash-3.2-only construct fails under the runner's pinned bash". + It asserted a bash-5 behaviour, so its result depended on the bash version. +- Section 5, "an old bash with no nix is exit 3". +- Section 5b, "a bash 5.0 with no nix is exit 3, not 5.x" and "a bash 5.1 is + accepted", together with the `utilbin` that #296 added for them. + +The suite keeps 10 cases: environment scrubbing (with its direct-run pair), the +shebang rule (with its control), fail and pass, unknown suite, and `--list`. + +**Kept deliberately.** The environment allowlist. That is what the +`set -u`/`$name` evidence above earns, and it does not depend on any version. + +**Also removed with the ruling.** `scripts/kb`'s `stat -f` fallback: `kb` +already requires the dev shell for `tpl`, and the dev shell's `stat` is GNU. +From `ci.yml`, the toolchain version probes ("the dev shell supplies the pinned +web toolchain", "Playwright matches the dev shell's browsers") and every +`shell: bash` override, so every step runs in `nix develop .#ci`. diff --git a/flake.nix b/flake.nix index 7f06ffe0..47dfc078 100644 --- a/flake.nix +++ b/flake.nix @@ -43,7 +43,7 @@ # behind the runners' TLS-auditing proxy those downloads fail (binaryen # with no fallback). On PATH, it downloads nothing. wasm-pack only takes # a PATH wasm-bindgen whose version equals Cargo.lock's `wasm-bindgen`, - # so this pin moves with the lock; CI checks that it does. + # so this pin moves with the lock: on a mismatch the sandboxed build fails. wasmBindgenCli = pkgs.buildWasmBindgenCli rec { src = pkgs.fetchCrate { pname = "wasm-bindgen-cli"; @@ -73,14 +73,13 @@ # `web/build.sh` — and so the Pages job's local dry run — needs this. # The `wasm` CI job only runs `cargo check`, which does not, so the # shell was missing the one tool that turns a passing check into a - # loadable page (improvise-d31). Must match WASM_PACK_VERSION in - # scripts/web-toolchain-versions.env; CI checks that it does. + # loadable page (improvise-d31). CI takes whatever version this is. pkgs.wasm-pack wasmBindgenCli pkgs.binaryen - # NODE_VERSION's major, same check. The js and browser jobs ran 22 - # while `build` ran the pinned 24; one shell means one node. + # The js and browser jobs once ran 22 while `build` ran 24; one shell + # means one node. pkgs.nodejs_24 pkgs.python3 pkgs.jq diff --git a/scripts/checkers-test.sh b/scripts/checkers-test.sh index 67a9b846..564ccacf 100755 --- a/scripts/checkers-test.sh +++ b/scripts/checkers-test.sh @@ -67,21 +67,7 @@ direct=$? unset name # --------------------------------------------------------------------------- -# 2. The bash-5 `local` semantics. A multi-assignment `local` does not read -# left to right on bash 5, so this suite passes on macOS bash 3.2 and dies -# under `set -u` on bash 5 -- PR #133's 19/19 local against 2/19 in CI. -d="$(sandbox bash5)" -suite "$d" vers <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -f() { local a="$1" b="$a/x"; echo "$b"; } -f one -EOF -expect "a bash-3.2-only construct fails under the runner's pinned bash" \ - 1 "$d/checkers.sh" vers - -# --------------------------------------------------------------------------- -# 3. The shebang rule. `#!/bin/sh` is dash in CI and bash-in-sh-mode on macOS. +# 2. The shebang rule. `#!/bin/sh` is dash in CI and bash-in-sh-mode on macOS. d="$(sandbox shebang)" suite "$d" fine <<'EOF' #!/usr/bin/env bash @@ -101,7 +87,7 @@ rm -f "$d/legacy.sh" expect "and the same sandbox passes once it is removed" 0 "$d/checkers.sh" fine # --------------------------------------------------------------------------- -# 4. A failing suite is reported, and a passing one is not. +# 3. A failing suite is reported, and a passing one is not. d="$(sandbox rc)" suite "$d" red <<'EOF' #!/usr/bin/env bash @@ -117,52 +103,7 @@ EOF expect "a passing suite passes the runner" 0 "$d/checkers.sh" blue # --------------------------------------------------------------------------- -# 5. No bash >= 5 and no nix is exit 3, not a silent run under whatever is -# there. This is the arm that would have refused the macOS system bash. -d="$(sandbox oldbash)" -suite "$d" any <<'EOF' -#!/usr/bin/env bash -exit 0 -EOF -mkdir -p "$work/fakebin" -cat > "$work/fakebin/bash" <<'EOF' -#!/bin/sh -case "$*" in *BASH_VERSINFO*) echo 3; exit 0 ;; esac -exit 0 -EOF -chmod +x "$work/fakebin/bash" -# The runner is invoked through a REAL bash, so the fake one below is only -# what its own `command -v bash` probe finds -- not its interpreter. -# utilbin carries only the utilities the runner itself needs, and no nix, so -# the fake bash is the only candidate and the nix fallback is absent. Linked -# one by one rather than taking /usr/bin:/bin, which on NixOS hold only `env` -# and `sh` -- the runner died on `dirname` there, exit 1 instead of 3. `head` -# and `env` are for 5b's 5.1 control, which runs past the version check. -mkdir -p "$work/utilbin" -for tool in dirname head env; do - ln -sf "$(command -v "$tool")" "$work/utilbin/$tool" -done -env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \ - "${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1 -got=$? -[ "$got" = 3 ] && ok "an old bash with no nix is exit 3" \ - || bad "an old bash with no nix is exit 3" "exit $got, wanted 3" - -# 5b. The floor is 5.1, not 5: `wait -n -p` is 5.1 (improvise-fd0j). The 5.1 -# control proves the 5.0 refusal is the version and not the fake itself. -for v in "5 0:3:a bash 5.0 with no nix is exit 3, not 5.x" \ - "5 1:0:a bash 5.1 is accepted"; do - printf '#!/bin/sh\ncase "$*" in *BASH_VERSINFO*) echo %s; exit 0 ;; esac\nexit 0\n' \ - "${v%%:*}" > "$work/fakebin/bash" - want="${v#*:}"; name="${want#*:}"; want="${want%%:*}" - env PATH="$work/fakebin:$work/utilbin" HOME="$HOME" \ - "${BASH:-bash}" "$d/checkers.sh" any > "$work/out" 2>&1 - got=$? - [ "$got" = "$want" ] && ok "$name" || bad "$name" "exit $got, wanted $want" -done - -# --------------------------------------------------------------------------- -# 6. A named suite that does not exist is a usage error, not a silent no-op. +# 4. A named suite that does not exist is a usage error, not a silent no-op. d="$(sandbox missing)" suite "$d" present <<'EOF' #!/usr/bin/env bash @@ -171,7 +112,7 @@ EOF expect "an unknown suite name is exit 4" 4 "$d/checkers.sh" absent # --------------------------------------------------------------------------- -# 7. --list names every suite in the directory and nothing else. +# 5. --list names every suite in the directory and nothing else. d="$(sandbox listing)" suite "$d" alpha <<'EOF' #!/usr/bin/env bash diff --git a/scripts/checkers.sh b/scripts/checkers.sh index 7cf099d7..e98e9e1e 100755 --- a/scripts/checkers.sh +++ b/scripts/checkers.sh @@ -3,15 +3,14 @@ # and so does the local gate, so a suite cannot be green under one shell and # red under the other. Reasoning: context/code/tooling.md section 3. # -# It does three things a bare `./scripts/X-test.sh` does not: +# It does two things a bare `./scripts/X-test.sh` does not. It runs every +# suite under the bash running it (the dev shell's), and it: # -# 1. pins a bash >= 5.1, taken from nix when the ambient one is older, so the -# macOS system bash 3.2 can never be the shell a suite passes under; -# 2. scrubs the environment down to an allowlist, because `nix develop` +# 1. scrubs the environment down to an allowlist, because `nix develop` # exports `name`, `out`, `system`, `shell`, `builder` and two dozen more # generically-named variables that CI does not have -- a `set -u` fault # reading one of them is SILENT inside the dev shell and fatal in CI; -# 3. refuses a shell script in scripts/ whose shebang is not +# 2. refuses a shell script in scripts/ whose shebang is not # `#!/usr/bin/env bash`, so the sh/bash split cannot come back. set -euo pipefail @@ -30,7 +29,6 @@ usage: checkers.sh [--list] [--self-test] [name ...] exit 0 every suite passed exit 1 a suite failed, or a shebang is not `#!/usr/bin/env bash` -exit 3 no bash >= 5.1 could be found and nix could not supply one exit 4 usage error THIS SCRIPT'S `$?` MUST BE THE ONE YOU READ. Nothing may follow it in the same @@ -46,22 +44,6 @@ ALLOW="HOME TMPDIR TMP TEMP TERM LANG LC_ALL USER LOGNAME NIX_PATH NIX_REMOTE NIX_SSL_CERT_FILE SSL_CERT_FILE XDG_CACHE_HOME XDG_CONFIG_HOME XDG_DATA_HOME" -# Prints the absolute path of a bash >= 5.1, or nothing. The ambient one is tried -# first so a machine that already has a modern bash pays no nix startup at all. -find_modern_bash() { - local candidate major minor - candidate="$(command -v bash || true)" - if [ -n "$candidate" ]; then - read -r major minor < <("$candidate" -c 'echo "${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}"' \ - 2>/dev/null || echo 0 0) - if [ "$(( ${major:-0} * 100 + ${minor:-0} ))" -ge 501 ] 2>/dev/null; then - echo "$candidate"; return 0 - fi - fi - command -v nix >/dev/null 2>&1 || return 0 - nix shell nixpkgs#bash --command bash -c 'command -v bash' 2>/dev/null || true -} - # Every shell script in scripts/ says `#!/usr/bin/env bash` and nothing else. # `#!/bin/sh` is dash in CI and bash 3.2 in sh-mode on macOS: a second platform # split, undetected for the same reason the first one was. @@ -94,19 +76,16 @@ suites() { done } -# $1 the pinned bash, $2 the suite file. The suite runs with the pinned bash -# FIRST on PATH, so a script it invokes through its own `#!/usr/bin/env bash` -# -- or through `sh` -- gets the pinned one too, not the system 3.2. +# $1 the suite file, run by the bash running this script, with PATH unchanged. run_suite() { - local pinned="$1" file="$2" - local bindir; bindir="$(dirname "$pinned")" + local file="$1" local -a env_args=(-i) local v for v in $ALLOW ${CHECKERS_KEEP:-}; do if [ -n "${!v+set}" ]; then env_args+=("$v=${!v}"); fi done - env_args+=("PATH=$bindir:${PATH:-/usr/bin:/bin}") - env "${env_args[@]}" "$pinned" "$file" + env_args+=("PATH=$PATH") + env "${env_args[@]}" "$BASH" "$file" } main() { @@ -123,15 +102,7 @@ main() { shift done - local pinned; pinned="$(find_modern_bash)" - if [ -z "$pinned" ]; then - echo 'checkers: no bash >= 5.1 on PATH and nix cannot supply one.' >&2 - echo ' Enter the dev shell (direnv allow / nix develop) and retry.' >&2 - return 3 - fi - printf 'checkers: bash %s at %s\n' \ - "$("$pinned" -c 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"')" "$pinned" - + printf 'checkers: bash %s at %s\n' "$BASH_VERSION" "$BASH" local rc=0 check_shebangs || rc=1 @@ -149,7 +120,7 @@ main() { rc=4; continue fi printf '\n=== %s ===\n' "$n" - run_suite "$pinned" "$file" || rc=1 + run_suite "$file" || rc=1 done return $rc } diff --git a/scripts/kb b/scripts/kb index f4465e6f..dcc571ff 100755 --- a/scripts/kb +++ b/scripts/kb @@ -87,7 +87,7 @@ ledger_path() { # first line; bytes alone are not enough, since a restore can reproduce them. file_stamp() { printf '%s %s' "$(stamp_of "$1")" \ - "$(stat -c '%i %.9Y' "$1" 2>/dev/null || stat -f '%i %Fm' "$1")" + "$(stat -c '%i %.9Y' "$1")" } ledger_holds() { [ -f "$1" ] && [ "$(head -n 1 "$1")" = "% kb-stamp $(file_stamp "$2")" ]; } From cfa8bf1e0a6eec921bb43f29f663d8bbc79b4e2e Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Wed, 23 Sep 2026 23:41:48 -0700 Subject: [PATCH 113/114] Refuse a frame whose level names no category; lend the open-run stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ChatGPT Codex findings on the #250 stack. - #250, P2 (improvise-e265): a FrameGroup.level from the wire sized the open-run stack in AxisFrame::groups, so a huge level drove a huge allocation. AxisFrame now deserializes through UncheckedAxisFrame and refuses any group or GroupEnd level not below cats.len(), naming it. context/code/protocol.md §3b. - #273, P3 (improvise-0r01): the lazy open_runs still cloned a stack per skipped row under .nth(idx). walk_open_runs lends one stack to a visitor that can stop the walk, so a lookup copies nothing for earlier rows. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/core.md | 14 ++-- context/code/protocol.md | 30 +++++++++ context/test-evidence/core.md | 34 +++++++++- context/test-evidence/protocol.md | 24 +++++++ crates/improvise-core/src/view/layout.rs | 84 ++++++++++++++++++------ crates/improvise-protocol/src/render.rs | 78 ++++++++++++++++++++++ 6 files changed, 235 insertions(+), 29 deletions(-) diff --git a/context/code/core.md b/context/code/core.md index 1e433034..7938f984 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -301,11 +301,15 @@ surviving row's whole open-run stack, and whether it shares a run with the previous survivor, pruned against unpruned. It runs over five fixtures, including a non-contiguous group, a level with no groups, and two adjacent outer groups. Without the non-contiguous fixture, a pruner that keyed runs by -name instead of header position passed every test. `open_runs` is the one -reading of the markers: `innermost_group` and the tests both use it. It is a -lazy iterator, so a single lookup (`z`, which asks about one row) walks the axis -only up to that row, rather than building and cloning a stack for every row of -it (improvise-0r01). +name instead of header position passed every test. `walk_open_runs` is the +one reading of the markers: `innermost_group` and the tests both use it. It +keeps one stack and lends it to a visitor at each data item, and the visitor +may stop the walk. So a single lookup (`z`, which asks about one row) walks +the axis only up to that row and copies nothing for the rows before it +(improvise-0r01). An iterator could not do this. Its first version yielded an +owned stack per row, and ChatGPT Codex found on #273 that `.nth(idx)` still +cloned one for every row it skipped. The tests collect every row's stack +through the same visitor. A header's level also names its category (`cats[level]`) on the wire, where `FrameGroup` carries `level` and not the category (improvise-7xyx removed that diff --git a/context/code/protocol.md b/context/code/protocol.md index 01836132..c1259a34 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -1498,6 +1498,36 @@ boundary is a matter of taste; a wrong collapse glyph is a wrong fact. So: two runs. Reversing it is one comparison, and the test is renamed `column_groups_coalesce_per_header_as_the_tui_does` rather than deleted. +## §3b. A frame's levels are checked as it arrives + +A `FrameGroup.level` and a `GroupEnd { level }` name a category: they index +`cats`. `AxisFrame::groups` keeps one open run per level and sizes that stack +with `open.resize(g.level, None)`. So a level arriving over the wire decides how +much it allocates. A frame whose level is `4294967295` would ask for a +multi-gigabyte vector, and the client would be taken down instead of the frame +being declined. ChatGPT Codex found this on #250, the PR that gave levels to the +wire (improvise-e265). + +The bound is enforced at deserialization, by `#[serde(try_from = +"UncheckedAxisFrame")]`. That is the one place a frame enters from outside. A +level that is not below `cats.len()` fails to parse, with an error that names the +level. A malformed frame therefore fails loudly: `Client::on_frame` returns the +error to the page, the same way it returns any other frame that does not parse. +It is not clamped, because a clamped level would be a guess about which group +the server meant, and a wrong group is a wrong fact. + +Three alternatives were weighed and not taken: + +- **Bounding inside `groups()`.** The same allocation would still happen in any + other reader, and a frame that is wrong would pass through silently. +- **Checking only the first marker.** A frame can be well-formed up to its last + group, so the check walks every marker. The test puts the bad level on the + second marker for that reason. +- **A frame built in Rust is still unchecked.** The fields are public, and the + producers build levels from `AxisEntry`, whose levels are category indices by + construction. The hazard this closes is the wire, where the input is not + ours. + ## 4. `frame.rs` — one envelope pair, two deployments ### The framing adds as little as it can get away with diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index 243243fc..65adb559 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -1609,8 +1609,10 @@ The flat-membership rival fails it again. improvise-0r01, which is ChatGPT Codex's P2 on #263. `innermost_group` built `open_runs` for the whole axis, cloning a level stack per row, to answer for one -row on every `z` keypress. `open_runs` is now lazy and the lookup takes -`.nth(idx)`. +row on every `z` keypress. `open_runs` was made lazy and the lookup took +`.nth(idx)`. That is superseded: the walk is now `walk_open_runs`, which lends +one stack to a visitor rather than yielding a copy per row (see +`a_late_lookup_answers_for_its_own_row`). The walk's extent cannot be seen through a well-formed axis, so the test builds an axis whose second header is unreadable on purpose: `level: usize::MAX`, which @@ -1618,7 +1620,9 @@ an axis whose second header is unreadable on purpose: `level: usize::MAX`, which reaching it. Rival: collecting `open_runs` before `.nth(idx)`, which is the eager shape. It -fails with `capacity overflow`, and the other 335 core tests pass. +fails with `capacity overflow`, and the other 335 core tests pass. That rival +was run against the iterator form. Under `walk_open_runs` the equivalent is a +visitor that never breaks, and the witness holds the same way. ### a_fold_row_evaluates_each_formula_once_across_the_row @@ -1686,3 +1690,27 @@ Three faults were injected, each run against the full `improvise-core` and The regression itself is a cost, which no test here can see. The evidence for it is the `grid_projection` bench's grouped cases, added for this ticket. + +### a_late_lookup_answers_for_its_own_row + +improvise-0r01, the second round: ChatGPT Codex's P3 on #273. The lazy +`open_runs` iterator still yielded `Some(open.clone())` for every data item, so +`innermost_group(..).nth(idx)` allocated and dropped a stack for every row +before the target. A `z` near the end of a large axis paid nearly the whole +original cost. `a_lookup_does_not_read_past_its_row` could not see this: it asks +for row 0, and it proves only that the suffix is not read. The walk is now +`walk_open_runs`, which lends one stack to a visitor and copies nothing. + +This test asks a late index. It covers rows 0 to 5 of a +`Q1{Jan, Feb}, Mar, Q2{Apr, May}` axis, and index 5 is past the end. So it pins +the row counting that the visitor form moved into `innermost_group`. Both rivals +were run against the whole `improvise-core` suite: +- A counter that never advances, so every lookup answers row 0: this test fails, + along with four others. +- A walker that ignores `GroupEnd`: this test fails, along with seven others. + +**What it cannot see is the allocation itself.** A visitor that clones the +stack at every row returns the same answers, so no assertion here can tell the +two apart without a counting allocator. The copy-free property is held by the +shape: the visitor receives a borrowed `&[Option]`, and no owned stack +leaves the walk. It is not held by a test. diff --git a/context/test-evidence/protocol.md b/context/test-evidence/protocol.md index aa7d04d9..7584bb49 100644 --- a/context/test-evidence/protocol.md +++ b/context/test-evidence/protocol.md @@ -1191,3 +1191,27 @@ compares **content in order**: the drawn keys rows must equal `help_bindings()` looked up through the same `keys`, row for row. The same injection now fails. What still passes is a bypass that draws exactly what the builders asked for, in the order they asked — which is harmless by definition. + +### a_frame_whose_group_level_names_no_category_does_not_deserialize + +improvise-e265, ChatGPT Codex's P2 on #250. A `FrameGroup.level` arrives over +the wire, and `AxisFrame::groups` sizes its open-run stack with +`open.resize(g.level, None)`. A frame carrying `level: 4294967295` therefore +drove a multi-gigabyte allocation in the client instead of being refused. At +`e44b1491` the frame deserialized without complaint. The fix checks every level +against `cats.len()` as the frame arrives: `context/code/protocol.md` §3b. + +The test serializes a well-formed two-category frame and checks that it reads +back equal. That is the control. It then injects two faults into the JSON, one +at a time: an oversized group level on the **second** marker, and a `GroupEnd` +one level past `cats`. For each fault it asserts two things. The mutated text is +still well-formed JSON. And the refusal names the level ("names no category"), +so the frame is refused for its level and not for its shape. The collapsed, +item-free frame keeps the fixture independent of `FrameEntry::Item`'s shape, +which #275 changes. + +Rivals, each run: +- The `try_from` attribute removed: `unwrap_err` panics, because the frame + parses. +- Only the first marker checked (`.next().filter(..)` instead of `.find(..)`): + it fails the same way, because the first marker is in range. diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index 3c042c97..c59f4dea 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::ops::ControlFlow; use std::rc::Rc; use crate::model::category::{DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY}; @@ -349,36 +350,42 @@ fn remark(input: &OpenRuns, output: &mut Vec>, kept: &mut Vec impl Iterator>> + '_ { +/// The one reading of the markers: each data item's open runs, by level, as +/// header positions, lent to `visit` — one stack, never copied (0r01). +fn walk_open_runs( + items: &[AxisEntry], + mut visit: impl FnMut(&[Option]) -> ControlFlow, +) -> Option { let mut open: Vec> = Vec::new(); - items - .iter() - .enumerate() - .filter_map(move |(at, entry)| match entry { + for (at, entry) in items.iter().enumerate() { + match entry { AxisEntry::GroupHeader { level, .. } => { open.truncate(*level); open.resize(*level, None); open.push(Some(at)); - None } - AxisEntry::GroupEnd { level } => { - open.truncate(*level); - None + AxisEntry::GroupEnd { level } => open.truncate(*level), + AxisEntry::DataItem { .. } => { + if let ControlFlow::Break(found) = visit(&open) { + return Some(found); + } } - AxisEntry::DataItem { .. } => Some(open.clone()), - }) + } + } + None } /// The innermost run open at the data item `idx` — the group nearest it, which /// is what `z` toggles (improvise-e265's ruling). fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> { - let at = open_runs(items) - .nth(idx)? - .into_iter() - .rev() - .find_map(|run| run)?; + let mut data_idx = 0usize; + let at = walk_open_runs(items, |open| match data_idx == idx { + true => ControlFlow::Break(open.iter().rev().find_map(|run| *run)), + false => { + data_idx += 1; + ControlFlow::Continue(()) + } + })??; match &items[at] { AxisEntry::GroupHeader { cat_name, @@ -2128,9 +2135,20 @@ mod tests { /// it (improvise-e265, improvise-6u6s). `context/test-evidence/core.md`. #[cfg(test)] mod group_membership { - use super::{AxisEntry, GridLayout, data_items, open_runs, prune_axis_items}; + use super::{AxisEntry, GridLayout, data_items, prune_axis_items, walk_open_runs}; use crate::view::Axis; use crate::workbook::Workbook; + use std::ops::ControlFlow; + + /// Every data item's open runs, collected through the one reader. + fn open_runs(items: &[AxisEntry]) -> Vec>> { + let mut all = Vec::new(); + walk_open_runs(items, |open| { + all.push(open.to_vec()); + ControlFlow::<()>::Continue(()) + }); + all + } type Level<'a> = (&'a str, &'a [(&'a str, Option<&'a str>)]); type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)]); @@ -2290,8 +2308,8 @@ mod group_membership { let kept: Vec = (0..data_items(full).count()) .filter(|i| mask & (1 << i) != 0) .collect(); - let full_runs: Vec<_> = open_runs(full).collect(); - let pruned_runs: Vec<_> = open_runs(pruned).collect(); + let full_runs = open_runs(full); + let pruned_runs = open_runs(pruned); assert_eq!(pruned_runs.len(), kept.len(), "{name}: the survivors"); if kept.len() == full_runs.len() { assert_eq!(pruned, full, "{name}: nothing pruned"); @@ -2388,6 +2406,30 @@ mod group_membership { ); } + /// A lookup past the first run answers for its own row, not a neighbour's + /// (improvise-0r01's late index). Evidence file. + #[test] + fn a_late_lookup_answers_for_its_own_row() { + let header = |group: &str| AxisEntry::GroupHeader { + level: 0, + cat_name: "Month".into(), + group_name: group.into(), + }; + let items = vec![ + header("Q1"), + AxisEntry::item(vec!["Jan".into()]), + AxisEntry::item(vec!["Feb".into()]), + AxisEntry::GroupEnd { level: 0 }, + AxisEntry::item(vec!["Mar".into()]), + header("Q2"), + AxisEntry::item(vec!["Apr".into()]), + AxisEntry::item(vec!["May".into()]), + ]; + let q = |group: &str| Some(("Month".to_string(), group.to_string())); + let found: Vec<_> = (0..6).map(|i| super::innermost_group(&items, i)).collect(); + assert_eq!(found, vec![q("Q1"), q("Q1"), None, q("Q2"), q("Q2"), None]); + } + /// The user's ruling on improvise-e265: membership is per level, and `z` /// names the innermost open group — the one nearest the cursor. #[test] diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 7739e1ac..2906aca3 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -223,6 +223,7 @@ pub enum FrameEntry { /// One axis of the grid's *frame*: header levels, label tuples and groups /// (improvise-anr9). **One** type, on purpose — `context/code/protocol.md` §3. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "UncheckedAxisFrame")] pub struct AxisFrame { /// The categories on this axis, outermost first. Empty is a real layout /// (one nameless row or column), not a gap — hence [`AxisFrame::levels`]. @@ -233,6 +234,42 @@ pub struct AxisFrame { pub entries: Vec, } +/// An [`AxisFrame`] as the wire delivers it, before its levels are checked +/// against its categories: `context/code/protocol.md` §3b. +#[derive(Deserialize)] +struct UncheckedAxisFrame { + cats: Vec, + first: usize, + entries: Vec, +} + +impl TryFrom for AxisFrame { + type Error = String; + + fn try_from(raw: UncheckedAxisFrame) -> Result { + let beyond = raw + .entries + .iter() + .filter_map(|e| match e { + FrameEntry::Group(g) => Some(g.level), + FrameEntry::GroupEnd { level } => Some(*level), + FrameEntry::Item { .. } => None, + }) + .find(|level| *level >= raw.cats.len()); + match beyond { + Some(level) => Err(format!( + "group level {level} names no category: this axis has {}", + raw.cats.len() + )), + None => Ok(Self { + cats: raw.cats, + first: raw.first, + entries: raw.entries, + }), + } + } +} + impl AxisFrame { /// A single-level axis with no groups. `cats` is left empty, so this is /// not a way to smuggle a multi-level axis in flattened. @@ -1222,6 +1259,47 @@ mod tests { } } + /// A level past the frame's categories is refused as it arrives, before + /// anything sizes a vector by it. Evidence file. + #[test] + fn a_frame_whose_group_level_names_no_category_does_not_deserialize() { + let group = |level: usize, name: &str| { + FrameEntry::Group(FrameGroup { + level, + name: name.into(), + collapsed: true, + }) + }; + let frame = AxisFrame { + cats: vec!["Region".into(), "Month".into()], + first: 0, + entries: vec![ + group(0, "Upper"), + group(1, "Q1"), + FrameEntry::GroupEnd { level: 1 }, + ], + }; + let json = serde_json::to_string(&frame).unwrap(); + let back: AxisFrame = serde_json::from_str(&json).expect("the control parses"); + assert_eq!(back, frame); + for (from, to) in [ + ( + r#""level":1,"name":"Q1""#, + r#""level":4294967295,"name":"Q1""#, + ), + (r#"{"GroupEnd":{"level":1}}"#, r#"{"GroupEnd":{"level":2}}"#), + ] { + let bad = json.replacen(from, to, 1); + assert_ne!(bad, json, "the fault {to} was injected"); + serde_json::from_str::(&bad).expect("still well-formed JSON"); + let err = serde_json::from_str::(&bad).unwrap_err(); + assert!( + err.to_string().contains("names no category"), + "refused for its level, not its shape: {err}" + ); + } + } + /// improvise-gd83's property, asserted in the crate that owns the type. /// See `context/test-evidence/protocol.md`. #[test] From c29e32f1bb0cf8fadee85a05700f3d687bcfd1e1 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Thu, 24 Sep 2026 01:38:37 -0700 Subject: [PATCH 114/114] pr-reviews-test: delete the fake clock's untested date pass-through Copilot on #297: `pr-reviews` only calls `date +%s`, so the REAL_DATE pass-through never ran. It is deleted rather than tested. The fake now refuses any other arguments with exit 2, so a new `date` call shows up as a failure. Rival: with pr-reviews calling `date -u +%s`, "a review arriving on the third poll is seen" and "--wait keeps polling until its deadline" fail (23/25). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tooling.md | 17 ++++++++++------- scripts/pr-reviews-test.sh | 6 +++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 23328d4e..c7b8d46c 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1092,17 +1092,20 @@ stopped one step earlier on the section-5b failure (#296). passed and 25 failed, every `--wait` case exiting 124: the same shape the reviewer simulated. -**Fix.** `REAL_SLEEP` and `REAL_DATE` come from `type -P`, before any case puts -its stub `sleep` and `date` first on PATH. `type -P` returns a path even if -`sleep` has been loaded as a builtin. `REAL_DATE` is exported, because the fake -clock is a separate script. +**Fix.** `REAL_SLEEP` comes from `type -P`, before any case puts its stub +`sleep` first on PATH. `type -P` returns a path even if `sleep` has been loaded +as a builtin. The fake clock's `/bin/date` pass-through was deleted rather than +re-pointed. `pr-reviews` only ever calls `date +%s`, so the branch never ran +(found by Copilot on #297). The fake now refuses any other arguments with +exit 2, so a new `date` call in `pr-reviews` shows up as a failure instead of +untested code. **Rivals:** - **`REAL_SLEEP=/nonexistent/sleep`** fails the whole suite, 0 of 25, so the resolved path is the one the watchdog runs. -- **The `REAL_DATE` half has no discriminating rival.** `pr-reviews` only ever - calls `date +%s`, so the fake clock's pass-through branch never runs. It was - fixed so that it cannot break later, but no test can see it today. +- **`pr-reviews` changed to call `date -u +%s`** fails "a review arriving on + the third poll is seen" and "--wait keeps polling until its deadline" (23 of + 25 pass). The fake's refusal is therefore visible, not swallowed. **Scope sweep.** `grep -rnE '/(usr/)?bin/[a-z]'` over `scripts/`, `web/*.sh`, `harness/*.sh`, `harness/tests` and `.github/workflows` finds only `#!/bin/sh` diff --git a/scripts/pr-reviews-test.sh b/scripts/pr-reviews-test.sh index 755ef46f..5050958d 100755 --- a/scripts/pr-reviews-test.sh +++ b/scripts/pr-reviews-test.sh @@ -9,9 +9,9 @@ work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT # Every run is bounded, so a loop that never stops FAILS (exit 124) rather than -# hanging the suite. Real tools resolved before any case stubs PATH; NixOS has no /bin/sleep. +# hanging the suite. The real sleep, resolved before any case stubs PATH; NixOS has no /bin/sleep. BOUND=20 -REAL_SLEEP="$(type -P sleep)"; export REAL_DATE; REAL_DATE="$(type -P date)" +REAL_SLEEP="$(type -P sleep)" bounded_reviews() { "$here/pr-reviews" "$@" & local pid=$! dog done rc @@ -219,7 +219,7 @@ clock="$work/clock" mkdir -p "$clock" cat > "$clock/date" <<'EOF' #!/usr/bin/env bash -[ "${1:-}" = "+%s" ] || exec "$REAL_DATE" "$@" +[ "$*" = "+%s" ] || { echo "fake date: only +%s is faked, got: $*" >&2; exit 2; } n=$(( $(cat "$CLOCK_TICKS" 2>/dev/null || echo 0) + 1 )) echo "$n" > "$CLOCK_TICKS" echo $(( 1000 + n ))