diff --git a/Cargo.lock b/Cargo.lock index dd75574..4a32ba6 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#844b42aedbb8abdd8c3e2b45ff4ed644c37946d2" +source = "git+https://github.com/AdaWorldAPI/ruff?branch=main#38f8a618cbcc4233d5bace17276f340d9c5638c3" 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#844b42aedbb8abdd8c3e2b45ff4ed644c37946d2" +source = "git+https://github.com/AdaWorldAPI/ruff?branch=main#38f8a618cbcc4233d5bace17276f340d9c5638c3" 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#844b42aedbb8abdd8c3e2b45ff4ed644c37946d2" +source = "git+https://github.com/AdaWorldAPI/ruff?branch=main#38f8a618cbcc4233d5bace17276f340d9c5638c3" dependencies = [ "serde", "serde_json", diff --git a/crates/op-codegen-pipeline/src/lib.rs b/crates/op-codegen-pipeline/src/lib.rs index 39de956..c7a1687 100644 --- a/crates/op-codegen-pipeline/src/lib.rs +++ b/crates/op-codegen-pipeline/src/lib.rs @@ -48,6 +48,11 @@ use ruff_spo_triplet::Triple as RuffTriple; #[cfg(feature = "ogar-emit")] pub mod ogar_consumer; +/// Klickweg harvest bridge: run the Rails `navigates_to` harvest (ruff #62) +/// over an OpenProject source tree to produce the board's navigation edges — +/// the harvested source of what `op-server::nav::NAV_EDGES` bakes by hand. +pub mod nav_harvest; + // `lance_graph_contract::codegen_spine::TripletProjection` is implemented // on the projection type — we don't reference the trait directly here, only // its associated method, which keeps the import surface narrow. diff --git a/crates/op-codegen-pipeline/src/nav_harvest.rs b/crates/op-codegen-pipeline/src/nav_harvest.rs new file mode 100644 index 0000000..08d240d --- /dev/null +++ b/crates/op-codegen-pipeline/src/nav_harvest.rs @@ -0,0 +1,91 @@ +//! Klickweg harvest bridge — Rails navigation topology into op-nexgen. +//! +//! Runs [`ruff_ruby_spo::extract_nav_edges`] (the two-shape Rails +//! `navigates_to` harvest — ERB `link_to`/`button_to` clicks + controller +//! `redirect_to` redirects, ruff #62) over an OpenProject Rails source tree +//! and returns the navigation edges between served screens. +//! +//! This is the **harvested** source of the board's klickweg — the same +//! screen-to-screen connectivity that `op-server::nav::NAV_EDGES` currently +//! encodes **by hand** (derived from the OGAR `ClassView` association graph) +//! and proves connected via the Core brick +//! `lance_graph_contract::class_view::nav_is_fully_connected` (lance-graph +//! #670/#673). When the OpenProject Rails source is present in the pipeline +//! input, these harvested edges are the ground truth and `NAV_EDGES` is their +//! baked mirror; `nav_harvest_probe` proves the harvest reproduces exactly +//! that edge set on a synthetic fixture. +//! +//! ## Two vocabularies, one graph +//! +//! The harvest speaks Rails **resource** stems (`work_packages`, `projects`); +//! op-server's `ClassView` graph speaks **concept** names (`ProjectWorkItem`, +//! `Project`). The mapping is 1:1 ([`RESOURCE_TO_CONCEPT`], the inverse of +//! `op-server::nav::SCREEN_UNIVERSE`), so the connectivity graph is identical +//! under it — a `(work_packages → projects)` harvested edge IS the +//! `(ProjectWorkItem → Project)` ClassView edge. + +use std::collections::BTreeSet; +use std::path::Path; + +use ruff_ruby_spo::{ + extract_nav_edges, extract_nav_edges_with_report, NavScanReport, NavVocab, RubyNavEdge, +}; + +/// The OpenProject board's served screens, as Rails resource stems — the +/// closed nav vocabulary. 1:1 with `op-server::nav::SCREEN_UNIVERSE` (which +/// names the same screens as `ClassView` concepts; see [`RESOURCE_TO_CONCEPT`]). +pub const OPENPROJECT_SCREENS: &[&str] = &["work_packages", "projects"]; + +/// Rails resource stem → op-server `ClassView` concept name. The 1:1 bridge +/// between the harvest vocabulary and `op-server::nav::SCREEN_UNIVERSE` +/// (`["ProjectWorkItem", "Project"]`). +pub const RESOURCE_TO_CONCEPT: &[(&str, &str)] = &[ + ("work_packages", "ProjectWorkItem"), + ("projects", "Project"), +]; + +/// Harvest the klickweg edges from a Rails `app_root`, restricted to the +/// OpenProject served screens ([`OPENPROJECT_SCREENS`]). +#[must_use] +pub fn harvest_klickweg(app_root: &Path) -> Vec { + extract_nav_edges(app_root, &openproject_vocab()) +} + +/// Like [`harvest_klickweg`] but also returns the [`NavScanReport`] ledger +/// (files scanned, files with edges, raw target references — the honest +/// denominator). +#[must_use] +pub fn harvest_klickweg_with_report(app_root: &Path) -> (Vec, NavScanReport) { + extract_nav_edges_with_report(app_root, &openproject_vocab()) +} + +/// The distinct `(source, target)` screen pairs of a harvested edge set — the +/// connectivity graph, shape-agnostic (ERB-click and controller-redirect +/// edges between the same two screens collapse to one pair). This is what +/// must match `op-server::nav::NAV_EDGES`' connectivity. +#[must_use] +pub fn klickweg_pairs(edges: &[RubyNavEdge]) -> BTreeSet<(String, String)> { + edges + .iter() + .map(|e| (e.source.clone(), e.target.clone())) + .collect() +} + +/// The op-server `ClassView` concept name for a Rails resource stem, or +/// `None` if it is not a served screen. +#[must_use] +pub fn concept_for(resource: &str) -> Option<&'static str> { + RESOURCE_TO_CONCEPT + .iter() + .find(|(r, _)| *r == resource) + .map(|(_, c)| *c) +} + +fn openproject_vocab() -> NavVocab { + NavVocab { + screens: OPENPROJECT_SCREENS + .iter() + .map(|s| (*s).to_string()) + .collect(), + } +} diff --git a/crates/op-codegen-pipeline/tests/fixtures/rails_nav/app/controllers/work_packages_controller.rb b/crates/op-codegen-pipeline/tests/fixtures/rails_nav/app/controllers/work_packages_controller.rb new file mode 100644 index 0000000..2cfb443 --- /dev/null +++ b/crates/op-codegen-pipeline/tests/fixtures/rails_nav/app/controllers/work_packages_controller.rb @@ -0,0 +1,10 @@ +class WorkPackagesController < ApplicationController + def create + @work_package = WorkPackage.new(wp_params) + if @work_package.save + redirect_to projects_path + else + render :new + end + end +end diff --git a/crates/op-codegen-pipeline/tests/fixtures/rails_nav/app/views/projects/show.html.erb b/crates/op-codegen-pipeline/tests/fixtures/rails_nav/app/views/projects/show.html.erb new file mode 100644 index 0000000..b7deb18 --- /dev/null +++ b/crates/op-codegen-pipeline/tests/fixtures/rails_nav/app/views/projects/show.html.erb @@ -0,0 +1,2 @@ +

<%= @project.name %>

+<%= link_to "Work packages", work_packages_path(project_id: @project.id) %> diff --git a/crates/op-codegen-pipeline/tests/fixtures/rails_nav/app/views/work_packages/index.html.erb b/crates/op-codegen-pipeline/tests/fixtures/rails_nav/app/views/work_packages/index.html.erb new file mode 100644 index 0000000..fcffc02 --- /dev/null +++ b/crates/op-codegen-pipeline/tests/fixtures/rails_nav/app/views/work_packages/index.html.erb @@ -0,0 +1,7 @@ +

Work packages

+ +<%= link_to "Back to project", project_path(@project) %> diff --git a/crates/op-codegen-pipeline/tests/nav_harvest_probe.rs b/crates/op-codegen-pipeline/tests/nav_harvest_probe.rs new file mode 100644 index 0000000..e2fabdf --- /dev/null +++ b/crates/op-codegen-pipeline/tests/nav_harvest_probe.rs @@ -0,0 +1,93 @@ +//! Probe: the Rails `navigates_to` harvest (ruff #62) reproduces the board's +//! klickweg — the same screen graph `op-server::nav::NAV_EDGES` bakes by hand. +//! +//! Drives a synthetic OpenProject Rails fixture (`tests/fixtures/rails_nav/`, +//! no proprietary source) through `op_codegen_pipeline::nav_harvest` and +//! asserts: +//! 1. the harvested `(source, target)` screen pairs are EXACTLY the +//! OpenProject board klickweg (`work_packages ↔ projects`), both +//! directions — proving the harvest yields the connectivity that +//! `op-server::nav::NAV_EDGES` encodes by hand; +//! 2. both harvest shapes fire (ERB `link_to` click + controller +//! `redirect_to` redirect); +//! 3. the resource→concept bridge maps every harvested screen onto an +//! `op-server::nav::SCREEN_UNIVERSE` concept. + +use std::path::PathBuf; + +use op_codegen_pipeline::nav_harvest::{ + concept_for, harvest_klickweg_with_report, klickweg_pairs, OPENPROJECT_SCREENS, +}; +use ruff_ruby_spo::NavShape; + +fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/rails_nav") +} + +/// The harvested screen pairs are exactly the OpenProject board klickweg — +/// `work_packages ↔ projects`, both directions. +#[test] +fn harvest_reproduces_the_board_klickweg() { + let (edges, report) = harvest_klickweg_with_report(&fixture_root()); + let pairs = klickweg_pairs(&edges); + + let expected: std::collections::BTreeSet<(String, String)> = + [("work_packages", "projects"), ("projects", "work_packages")] + .iter() + .map(|(s, t)| ((*s).to_string(), (*t).to_string())) + .collect(); + + assert_eq!( + pairs, expected, + "harvested klickweg must be exactly work_packages <-> projects; got {edges:?}" + ); + + // The ledger accounts for the fixture: 2 ERB views + 1 controller, every + // file producing at least one edge. + assert_eq!(report.erb_files, 2, "{report:?}"); + assert_eq!(report.controller_files, 1, "{report:?}"); + assert_eq!(report.files_with_edges, 3, "{report:?}"); +} + +/// Both harvest shapes fire: the ERB `link_to` click edges AND the controller +/// `redirect_to` redirect edge are all present (Ruby's two-shape harvest, not +/// a single body-walk). +#[test] +fn both_harvest_shapes_are_present() { + let (edges, _) = harvest_klickweg_with_report(&fixture_root()); + + assert!( + edges.iter().any(|e| e.shape == NavShape::ErbClick), + "expected at least one ERB click edge: {edges:?}" + ); + assert!( + edges + .iter() + .any(|e| e.shape == NavShape::ControllerRedirect), + "expected the controller redirect edge (work_packages -> projects): {edges:?}" + ); + + // The redirect edge specifically is work_packages -> projects. + assert!( + edges.iter().any(|e| e.shape == NavShape::ControllerRedirect + && e.source == "work_packages" + && e.target == "projects"), + "controller redirect must be work_packages -> projects: {edges:?}" + ); +} + +/// Every served screen maps 1:1 onto an `op-server::nav::SCREEN_UNIVERSE` +/// concept — the harvest (resource-stem) and the ClassView graph +/// (concept-name) speak the same connectivity graph under the bridge. +#[test] +fn every_screen_bridges_to_a_classview_concept() { + for resource in OPENPROJECT_SCREENS { + assert!( + concept_for(resource).is_some(), + "served screen {resource} has no ClassView concept in RESOURCE_TO_CONCEPT" + ); + } + assert_eq!(concept_for("work_packages"), Some("ProjectWorkItem")); + assert_eq!(concept_for("projects"), Some("Project")); + assert_eq!(concept_for("not_a_screen"), None); +} diff --git a/crates/op-server/src/nav.rs b/crates/op-server/src/nav.rs index 6565982..8577c02 100644 --- a/crates/op-server/src/nav.rs +++ b/crates/op-server/src/nav.rs @@ -77,6 +77,17 @@ pub fn screen_id(concept: &str) -> Option { /// class; `nav_edges_match_static_table` cross-checks this table against the /// live derivation so it can never silently drift. `inputs` is `&'static` /// (the Core edge shape), so this is a plain `const` manifest. +/// +/// **Baked mirror of the harvest.** This table is the hand-authored (from the +/// `ClassView` association graph) equivalent of what +/// `op_codegen_pipeline::nav_harvest::harvest_klickweg` produces from the +/// OpenProject Rails source via the `ruff_ruby_spo` `navigates_to` harvest +/// (ruff #62). `op-codegen-pipeline`'s `nav_harvest_probe` proves the harvest +/// yields exactly the `work_packages ↔ projects` klickweg these edges encode +/// (`RESOURCE_TO_CONCEPT` bridges the Rails resource stems to the +/// `ProjectWorkItem`/`Project` concepts here). Once the OpenProject source is +/// in the pipeline input, this manifest regenerates from that harvest rather +/// than being hand-authored. pub const NAV_EDGES: &[ComputeEdge] = &[ // ProjectWorkItem.project (belongs_to) → Project ComputeEdge {