Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .claude/findings/2026-07-08-erb-vs-odoo-klickweg-synergy-drift.md
Original file line number Diff line number Diff line change
@@ -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 `<menuitem> → 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 (`<record model="account.move">` 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, `<menuitem>`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` `<field/>` 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 → <tab-target>` (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.
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 27 additions & 8 deletions crates/op-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

Expand Down
174 changes: 170 additions & 4 deletions crates/op-server/src/nav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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**: `<menuitem>` 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 `<menuitem>` root.
pub const MENU_ROOT: u8 = 0;

/// The menu-rooted navigation edges over [`MENU_UNIVERSE`] positions:
/// `Menu → <screen>` 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
Expand Down Expand Up @@ -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();
Expand Down