From 647254c197a0ddd6621b1493cf3efa83ad067873 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 21:53:53 +0000 Subject: [PATCH] =?UTF-8?q?nav:=20finalize=20Klickweg=20structure=20parity?= =?UTF-8?q?=20=E2=80=94=20menu-rooted=20connectivity=20(Odoo=20#66=20parit?= =?UTF-8?q?y)=20+=20ERB=E2=86=94Odoo=20synergy/drift=20finding=20+=20ruff?= =?UTF-8?q?=20#67=20rebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-frontend parity. ruff #66 gave Odoo a two-shape navigates_to whose data-side records emit a SYNTHETIC MENU ROOT — menu → res_model edges that give nav_is_fully_connected its entry points, so "connected" means every screen is reachable FROM THE MENU. op-nexgen's top nav (nav::menu) is the identical structure but was not wired into the connectivity graph; NAV_ROOT was a screen (ProjectWorkItem). This brings op to parity: - MENU_UNIVERSE (synthetic `Menu` node + served screens), MENU_ROOT, MENU_NAV_EDGES (Menu → each tab-target, + the inter-screen NAV_EDGES lifted +1), menu_screens_mask, menu_target_concept (inverse of route_for for menu hrefs), menu_klickweg_is_connected(). - The menu-rooted check is STRONGER than the screen-only one: it catches a menu-orphan screen (exists + inter-linked but no top-nav click-path) that klickweg_is_connected() would pass. Same Core brick (nav_is_fully_connected), same "menu gives entry points" shape as Odoo's menuitem roots. - menu_nav_edges_match_derived pins MENU_NAV_EDGES against menu() + NAV_EDGES (no drift); menu_klickweg_is_fully_connected proves reachability. Boot affirms BOTH layers + resolves every menu tab to a served screen. The render-side screen graph (SCREEN_UNIVERSE / NAV_EDGES / render masks) is UNCHANGED — the menu universe is a separate connectivity graph so the synthetic node never pollutes a render-mask position. Finding: .claude/findings/2026-07-08-erb-vs-odoo-klickweg-synergy-drift.md maps the ERB (Ruby, consumed by op) vs Odoo (Python) arms — 6 synergies (one predicate, one ViewFieldSet type literally shared, one mask mint, two-shape nav, honest denominator, menu roots) and 6 principled divergences (receiver- vs record-scoping, plural-tolerant vs exact match, per-file vs cross-file join, framework nav idiom, selects_view plane, meta/arch split). op-relevant lesson: D3 cross-file join, needed when the real OpenProject corpus enters the harvest. Also rebases the ruff pins to main b5fde66 (#67 odoo_views relation-hop + tokenizer fixes — ruff_python_spo-only, behavior-inert for op, bumped for consistency). Tests: op-server nav:: 9/9 (2 new menu-rooted), full op-server + pipeline + ruff_openproject 79/79. New code clippy-clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U9t3fRwXmPC6tCpMtnfTrM --- ...7-08-erb-vs-odoo-klickweg-synergy-drift.md | 71 +++++++ Cargo.lock | 6 +- crates/op-server/src/main.rs | 35 +++- crates/op-server/src/nav.rs | 174 +++++++++++++++++- 4 files changed, 271 insertions(+), 15 deletions(-) create mode 100644 .claude/findings/2026-07-08-erb-vs-odoo-klickweg-synergy-drift.md diff --git a/.claude/findings/2026-07-08-erb-vs-odoo-klickweg-synergy-drift.md b/.claude/findings/2026-07-08-erb-vs-odoo-klickweg-synergy-drift.md new file mode 100644 index 0000000..0effcde --- /dev/null +++ b/.claude/findings/2026-07-08-erb-vs-odoo-klickweg-synergy-drift.md @@ -0,0 +1,71 @@ +# ERB (Rails) vs Odoo (Python) Klickweg — synergy / drift map + +**Date:** 2026-07-08 · **Trigger:** ruff #66/#67 landed the Odoo +`navigates_to` (two-shape) + `ir.ui.view` field-set arms; this audits them +against the Ruby/ERB arms (#62, `ruff_ruby_spo::{navigation,views}`) that +op-nexgen actually consumes, and finalizes op's Klickweg structure parity. + +## Bottom line + +The two arms **converge on structure and vocabulary, diverge only on +framework-shaped surface details** — and every divergence is documented, +principled, and *not* accidental drift. op-nexgen consumes the **Ruby** +arms, so the Odoo-specific divergences are inert here; the *synergies* are +what carry across, and the one op-relevant lesson is the **cross-file menu +join** (see Drift-3). + +## Synergies (the shared bricks — these are the point) + +| # | Shared thing | Evidence | +|---|---|---| +| S1 | **One predicate** — `Predicate::NavigatesTo` | Ruby `RubyNavEdge`, Odoo `OdooNavEdge`, C# harvester, Flask `PyNavEdge` all emit it. Screen→screen is frontend-agnostic. | +| S2 | **One field-set type** — `ViewFieldSet`/`ViewTarget` | `odoo_views.rs` literally `use crate::templates::{ViewFieldSet, ViewTarget}` — the SAME types, not parallel ones. | +| S3 | **One mask mint** — `WideFieldMask::from_universe_present(basis, fields)` | ERB · askama · Jinja · **Odoo XML** = four renderers over one projection brick. | +| S4 | **Two-shape nav** | Ruby (ERB `link_to` + controller `redirect_to`) and Odoo (code `act_window` dict + data `menuitem` XML) BOTH split the Klickweg across two artifacts — same structural insight. | +| S5 | **Honest denominator** | `fields ⊆ referenced` / `raw_*_refs` in every arm; closed vocab is the gate, raw count is the truth. | +| S6 | **Menu = entry-point root** | Odoo ` → res_model` gives `nav_is_fully_connected` its roots; op's top-nav `menu()` is the identical structure — **this finding wires it the same way** (see Parity below). | + +## Drift (divergences — each principled, none accidental) + +| # | Axis | ERB (Rails) | Odoo (Python) | Why it's correct, not drift | +|---|---|---|---|---| +| D1 | **View scoping** | receiver-scoped (`ViewTarget::receivers` — `issue`/`@issue` bind the model in-template) | record-scoped (`` IS the receiver; `receivers` ignored) | The artifact carries its model differently: ERB binds via a local/ivar, Odoo declares it on the record. | +| D2 | **Target match** | singular/plural tolerant (`project`↔`projects`) | EXACT (`account.move` ≠ `account.move.line`) | Rails targets are route *stems* (fuzzy); Odoo targets are canonical model *identifiers*. | +| D3 | **Join locality** | per-file (a view/controller names its own edges) | **cross-file** (actions in per-model view files, ``s central in `account_menuitem.xml`) | Measured: a per-file join found ZERO menu edges on the real `account` addon. **This is the op-relevant lesson** (see below). | +| D4 | **Nav idiom** | `link_to`/`button_to`/`redirect_to` | `act_window` dict + `menuitem` XML (SPA — `url_for` is 2 stray noise hits) | The Klickweg lives in a different construct per framework; the harvester must know the framework's idiom. (The #64 "synthetic-fixture blind spot": green on a fixture, zero on the real app.) | +| D5 | **Selector plane** | none | none (C# only: `selects_view`, the ribbon/tab selector that is NOT a screen jump, #64/#65) | Keeps `navigates_to` a pure screen→screen graph; a tab-selector-without-a-jump is a DevExpress-shaped concept neither Rails nor Odoo has. | +| D6 | **Meta/arch split** | none (every `receiver.field` is a projection) | load-bearing (`name`/`model`/`inherit_id` are meta; only `arch` `` count) | Odoo XML bundles record metadata with the projection; ERB doesn't. | + +## Consequences for op-nexgen + +- **Inert here:** D1/D2/D4/D5/D6 are Odoo-frontend specifics. op consumes + the Ruby arms (`ruff_ruby_spo`), so none of these change op today. +- **The one that matters — D3 (cross-file join):** op's `nav_harvest` + is per-file, which is correct for its current `link_to`/`redirect_to` + shapes. But OpenProject's real menu wiring is Rails-central + (`config/routes.rb` + a menu registry), so when the **real corpus** + enters the pipeline, op's harvest will need the Odoo cross-file-join + lesson — the same "measured zero on the real app" trap #64 and D3 both + hit. Tracked as the deferred real-corpus harvest arc (also the trigger + for regenerating `NAV_EDGES` from harvest rather than hand-authoring). + +## Parity finalized this PR (the "necessary dependent doing") + +op's Klickweg was rooted at a *screen* (`NAV_ROOT = ProjectWorkItem`); +connectivity meant "reachable from that screen." The cross-frontend model +(S6) roots it at the **menu**. This PR adds the render-side twin of Odoo's +menuitem roots to `op-server::nav`: + +- `MENU_UNIVERSE` = synthetic `Menu` node + served screens; + `MENU_NAV_EDGES` = `Menu → ` (one per `menu()` tab) + the + inter-screen `NAV_EDGES` (+1 shifted). +- `menu_klickweg_is_connected()` = `nav_is_fully_connected(MENU_ROOT, …)` + — **every screen must be reachable by clicking from the top nav**, which + catches a menu-orphan screen that the screen-only check + (`klickweg_is_connected`) would pass. +- `menu_nav_edges_match_derived` pins the static table against + `menu()` + `NAV_EDGES` (no drift); boot affirms both layers. + +Same Core brick (`nav_is_fully_connected`), same "menu gives entry points" +shape as Odoo — op's Klickweg is now structurally at parity with the +cross-frontend model, screen-graph AND menu-reachability. diff --git a/Cargo.lock b/Cargo.lock index c9a4aa7..665f428 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2311,7 +2311,7 @@ dependencies = [ [[package]] name = "ruff_ruby_spo" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/ruff?branch=main#93836cee751b2f08476062317728635154a423ee" +source = "git+https://github.com/AdaWorldAPI/ruff?branch=main#b5fde66d030c13b11cbc07e5dea5cc6e126872d6" dependencies = [ "lib-ruby-parser", "ruff_spo_triplet", @@ -2320,7 +2320,7 @@ dependencies = [ [[package]] name = "ruff_spo_address" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/ruff?branch=main#93836cee751b2f08476062317728635154a423ee" +source = "git+https://github.com/AdaWorldAPI/ruff?branch=main#b5fde66d030c13b11cbc07e5dea5cc6e126872d6" dependencies = [ "ruff_spo_triplet", ] @@ -2328,7 +2328,7 @@ dependencies = [ [[package]] name = "ruff_spo_triplet" version = "0.1.0" -source = "git+https://github.com/AdaWorldAPI/ruff?branch=main#93836cee751b2f08476062317728635154a423ee" +source = "git+https://github.com/AdaWorldAPI/ruff?branch=main#b5fde66d030c13b11cbc07e5dea5cc6e126872d6" dependencies = [ "serde", "serde_json", diff --git a/crates/op-server/src/main.rs b/crates/op-server/src/main.rs index a5315d8..9b719ad 100644 --- a/crates/op-server/src/main.rs +++ b/crates/op-server/src/main.rs @@ -48,27 +48,46 @@ async fn main() -> anyhow::Result<()> { // Klickweg connectivity affirmation — the runtime counterpart to the // `scripts/nav-crawl.sh` BFS, proven at boot via the sanctioned Core brick - // `nav_is_fully_connected` (lance-graph #670/#673). A disconnected nav - // graph (orphan screen / dangling click / unserved root) is a level-editor - // bug, not a fatal one — the board still serves, so we WARN rather than - // abort, but it is never silent. + // `nav_is_fully_connected` (lance-graph #670/#673), in TWO layers: the + // screen↔screen graph, and the stronger MENU-rooted reachability — every + // screen must be reachable by clicking from the top nav (the render-side + // twin of Odoo's menuitem roots, ruff #66). A menu-orphan screen fails the + // second even if it passes the first. A disconnected graph is a + // level-editor bug, not a fatal one — the board still serves, so we WARN + // rather than abort, but it is never silent. let nav_connected = nav::klickweg_is_connected(); - if nav_connected { + let menu_connected = nav::menu_klickweg_is_connected(); + if nav_connected && menu_connected { let root_screen = nav::SCREEN_UNIVERSE .get(nav::NAV_ROOT as usize) .copied() .unwrap_or("?"); debug_assert_eq!(nav::screen_id(root_screen), Some(nav::NAV_ROOT)); + // Every top-nav tab must resolve to a served screen — the menu-root + // entry-point wiring (each tab is a `Menu → screen` edge). + let menu_tabs = nav::menu() + .iter() + .filter(|(_, href)| nav::menu_target_concept(href).is_some()) + .count(); + debug_assert_eq!( + menu_tabs, + nav::menu().len(), + "a menu tab resolves to no served screen" + ); info!( screens = nav::SCREEN_UNIVERSE.len(), root = root_screen, - "Klickweg fully connected (nav_is_fully_connected via Core brick)" + menu_tabs, + menu_reachable = true, + "Klickweg fully connected (screen graph + menu-rooted reachability, Core brick)" ); } else { tracing::warn!( screens = nav::SCREEN_UNIVERSE.len(), - "Klickweg NOT fully connected — an orphan screen, dangling click, \ - or unserved root exists in the nav graph" + nav_connected, + menu_connected, + "Klickweg NOT fully connected — an orphan screen (menu-unreachable), \ + dangling click, or unserved root exists in the nav graph" ); } diff --git a/crates/op-server/src/nav.rs b/crates/op-server/src/nav.rs index 8577c02..9ddb8bd 100644 --- a/crates/op-server/src/nav.rs +++ b/crates/op-server/src/nav.rs @@ -116,15 +116,110 @@ pub fn served_screens_mask() -> WideFieldMask { .expect("2-screen universe is within the 256-field SoC cap") } -/// Whether the klickweg is fully connected: every served screen is reachable -/// from [`NAV_ROOT`] and no navigation edge dangles off the served set — the -/// Core-brick ([`nav_is_fully_connected`]) level-editor validator, at -/// compile/test time rather than only via the live crawl. +/// Whether the inter-screen klickweg is fully connected: every served +/// screen is reachable from [`NAV_ROOT`] and no navigation edge dangles +/// off the served set — the Core-brick ([`nav_is_fully_connected`]) +/// level-editor validator over the screen-only graph. +/// +/// This is the *screen↔screen* layer. The stronger **menu-reachability** +/// check ([`menu_klickweg_is_connected`]) verifies every screen is +/// reachable *from the top-nav menu* — see that function's docs. #[must_use] pub fn klickweg_is_connected() -> bool { nav_is_fully_connected(NAV_ROOT, NAV_EDGES, &served_screens_mask()) } +// ── Menu-rooted connectivity (cross-frontend Klickweg parity) ──────── +// +// The Odoo `navigates_to` arm (ruff #66) introduced the **synthetic menu +// root**: `` records emit `menu → res_model` edges that give +// `nav_is_fully_connected` its entry points, so "connected" means *every +// screen is reachable FROM THE MENU* — an orphan screen the user cannot +// click to from the top nav fails, even if it is reachable from some +// other screen. op-nexgen's top nav ([`menu`]) is the identical +// structure — each tab is a `menu → screen` edge — so we model it the +// same way: a synthetic `Menu` node at position 0, screens after it, and +// the connectivity check rooted at the menu. This is the render-side twin +// of Odoo's menuitem roots (same Core brick, same "menu gives entry +// points" shape). + +/// The menu-inclusive screen universe: the synthetic `Menu` node +/// ([`MENU_ROOT`] = index 0) followed by every served screen. Distinct +/// from [`SCREEN_UNIVERSE`] (screens only) because the render masks index +/// screen positions and must NOT see the synthetic node; this universe is +/// the connectivity graph only. +pub const MENU_UNIVERSE: &[&str] = &["Menu", "ProjectWorkItem", "Project"]; + +/// The synthetic top-nav menu node — the entry point of the klickweg, +/// mirroring Odoo's `` root. +pub const MENU_ROOT: u8 = 0; + +/// The menu-rooted navigation edges over [`MENU_UNIVERSE`] positions: +/// `Menu → ` for each top-nav tab (the twin of Odoo's +/// `menuitem → res_model`), plus the inter-screen [`NAV_EDGES`] lifted +/// into the menu-inclusive positions (+1 shift). `menu_nav_edges_match_derived` +/// cross-checks this table against [`menu`] + [`NAV_EDGES`] so it cannot +/// drift. +pub const MENU_NAV_EDGES: &[ComputeEdge] = &[ + // Menu → ProjectWorkItem (the "Board" tab, href "/") + ComputeEdge { + target: 1, + inputs: &[0], + }, + // Menu → Project (the "Projects" tab, href "/projects") + ComputeEdge { + target: 2, + inputs: &[0], + }, + // ProjectWorkItem → Project (NAV_EDGES[0], shifted +1) + ComputeEdge { + target: 2, + inputs: &[1], + }, + // Project → ProjectWorkItem (NAV_EDGES[1], shifted +1) + ComputeEdge { + target: 1, + inputs: &[2], + }, +]; + +/// The menu-inclusive universe as a [`WideFieldMask`], minted on-brick +/// (universe = present = every node incl. the synthetic menu). +/// +/// # Panics +/// +/// Never: [`MENU_UNIVERSE`] has 3 entries, far under the 256-field cap. +#[must_use] +pub fn menu_screens_mask() -> WideFieldMask { + WideFieldMask::from_universe_present(MENU_UNIVERSE, MENU_UNIVERSE) + .expect("3-node menu universe is within the 256-field SoC cap") +} + +/// The concept a top-nav menu href routes to (the inverse of +/// [`route_for`], with `"/"` special-cased to the board = `ProjectWorkItem`). +/// `None` for an href no served screen owns. +#[must_use] +pub fn menu_target_concept(href: &str) -> Option<&'static str> { + if href == "/" { + return Some("ProjectWorkItem"); + } + SCREEN_UNIVERSE + .iter() + .copied() + .find(|c| route_for(c) == Some(href)) +} + +/// Whether the klickweg is fully connected **from the top-nav menu**: +/// every served screen is reachable by clicking from [`menu`], and no menu +/// or nav edge dangles off the served set. This is the cross-frontend +/// parity check (ruff #66 Odoo menuitem roots): a screen that exists and +/// is inter-linked but has NO menu path is an orphan the user cannot reach, +/// and this — unlike [`klickweg_is_connected`] — catches it. +#[must_use] +pub fn menu_klickweg_is_connected() -> bool { + nav_is_fully_connected(MENU_ROOT, MENU_NAV_EDGES, &menu_screens_mask()) +} + /// Association targets referenced by `project_work_item()` / `project()` /// that have NO page today, and that gap is a known, intentional debt /// (not a bug). Adding a new association without a route AND without an @@ -328,6 +423,77 @@ mod tests { ); } + /// Cross-frontend parity: the klickweg is fully connected **from the + /// menu root** — every served screen is reachable by clicking from the + /// top nav (the render-side twin of Odoo's `menuitem → res_model` + /// roots, ruff #66). The synthetic `Menu` node itself is in the reached + /// set, so `reached == MENU_UNIVERSE`. + #[test] + fn menu_klickweg_is_fully_connected() { + assert!( + menu_klickweg_is_connected(), + "menu-rooted klickweg not connected: a served screen has no \ + click-path from the top-nav menu (orphan), or a menu/nav edge \ + dangles off the served set" + ); + let reached = + lance_graph_contract::class_view::screens_reachable_from(MENU_ROOT, MENU_NAV_EDGES); + assert_eq!( + reached, + menu_screens_mask(), + "reached set from the menu root must equal the menu-inclusive universe" + ); + } + + /// The static [`MENU_NAV_EDGES`] table must equal the edges derived from + /// [`menu`] (menu → tab-target) + [`NAV_EDGES`] (inter-screen, +1 shifted + /// into the menu-inclusive positions) — no drift between the hand-written + /// menu-rooted manifest and the live menu + screen graph. + #[test] + fn menu_nav_edges_match_derived() { + // Position of a concept in MENU_UNIVERSE (menu node offsets screens +1). + let menu_pos = |c: &str| MENU_UNIVERSE.iter().position(|m| *m == c).map(|i| i as u8); + + let mut derived: Vec<(u8, u8)> = Vec::new(); // (target, source) + + // Menu → each tab's target concept. + for (_label, href) in menu() { + if *href == "/" || route_for_is_menu_reachable(href) { + if let Some(target) = menu_target_concept(href).and_then(menu_pos) { + derived.push((target, MENU_ROOT)); + } + } + } + // Inter-screen NAV_EDGES, lifted into menu-inclusive positions (+1: + // SCREEN_UNIVERSE[i] == MENU_UNIVERSE[i+1]). + for e in NAV_EDGES { + for &src in e.inputs { + derived.push((e.target + 1, src + 1)); + } + } + derived.sort_unstable(); + derived.dedup(); + + let mut table: Vec<(u8, u8)> = MENU_NAV_EDGES + .iter() + .flat_map(|e| e.inputs.iter().map(move |&src| (e.target, src))) + .collect(); + table.sort_unstable(); + table.dedup(); + + assert_eq!( + table, derived, + "MENU_NAV_EDGES drifted from menu() + NAV_EDGES" + ); + } + + // Every menu href resolves to a served screen (proven by + // `menu_targets_are_all_routable`); this helper keeps the derivation + // above readable. + fn route_for_is_menu_reachable(href: &str) -> bool { + menu_target_concept(href).is_some() + } + #[test] fn census_lists_the_known_dead_lanes() { let census = dead_lane_census();