From d9e5cca53d406f5df3847a9d52b3fbe80ca2bdd1 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 1 Aug 2026 12:45:55 +0200 Subject: [PATCH 1/5] Rust: Implement `herb-analysis` crate --- rust/Cargo.toml | 1 + rust/bin/herb-analysis | 15 + rust/herb-analysis/Cargo.toml | 25 ++ rust/herb-analysis/README.md | 54 +++ rust/herb-analysis/src/analysis.rs | 321 ++++++++++++++ rust/herb-analysis/src/bin/herb_analysis.rs | 406 ++++++++++++++++++ rust/herb-analysis/src/lib.rs | 17 + rust/herb-analysis/src/rails.rs | 310 +++++++++++++ rust/herb-analysis/src/report.rs | 52 +++ rust/herb-analysis/tests/e2b_link_test.rs | 15 + .../app/app/helpers/application_helper.rb | 17 + .../app/helpers/concerns/formatting_helper.rb | 11 + .../tests/fixtures/rails_app/Gemfile.lock | 24 ++ .../tests/fixtures/rails_app/config/routes.rb | 16 + .../rails_app/local_engine/lib/localengine.rb | 1 + .../fakemono/lib/fakemono.rb | 1 + .../3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb | 1 + .../3.4.0/gems/fakegem-1.2.3/test/fixture.rb | 1 + .../tests/fixtures/ruby/nesting.rb | 30 ++ rust/herb-analysis/tests/rails_test.rs | 126 ++++++ rust/herb-analysis/tests/usefulness_test.rs | 115 +++++ rust/rustfmt.toml | 1 + rust/src/lib.rs | 1 + 23 files changed, 1561 insertions(+) create mode 100755 rust/bin/herb-analysis create mode 100644 rust/herb-analysis/Cargo.toml create mode 100644 rust/herb-analysis/README.md create mode 100644 rust/herb-analysis/src/analysis.rs create mode 100644 rust/herb-analysis/src/bin/herb_analysis.rs create mode 100644 rust/herb-analysis/src/lib.rs create mode 100644 rust/herb-analysis/src/rails.rs create mode 100644 rust/herb-analysis/src/report.rs create mode 100644 rust/herb-analysis/tests/e2b_link_test.rs create mode 100644 rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb create mode 100644 rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb create mode 100644 rust/herb-analysis/tests/fixtures/rails_app/Gemfile.lock create mode 100644 rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb create mode 100644 rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb create mode 100644 rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb create mode 100644 rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb create mode 100644 rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb create mode 100644 rust/herb-analysis/tests/fixtures/ruby/nesting.rb create mode 100644 rust/herb-analysis/tests/rails_test.rs create mode 100644 rust/herb-analysis/tests/usefulness_test.rs diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 3c0e9208b..76f212ce3 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ ".", + "herb-analysis", "herb-config" ] diff --git a/rust/bin/herb-analysis b/rust/bin/herb-analysis new file mode 100755 index 000000000..8e403800c --- /dev/null +++ b/rust/bin/herb-analysis @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +RUST_DIR="$( cd "$SCRIPT_DIR/.." && pwd )" + +BINARY_PATH="$RUST_DIR/target/debug/herb-analysis" + +if [ ! -f "$BINARY_PATH" ]; then + echo "Error: herb-analysis binary not found at $BINARY_PATH" + echo "Please run 'make build' in the rust/ directory first." + + exit 1 +fi + +exec "$BINARY_PATH" "$@" diff --git a/rust/herb-analysis/Cargo.toml b/rust/herb-analysis/Cargo.toml new file mode 100644 index 000000000..ae1f5e1b6 --- /dev/null +++ b/rust/herb-analysis/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "herb-analysis" +version = "0.10.2" +edition = "2021" +authors = ["Marco Roth "] +description = "Exploratory static-analysis spike over rubydex" +license = "MIT" +repository = "https://github.com/marcoroth/herb" +publish = false + +[lib] +name = "herb_analysis" +path = "src/lib.rs" + +[[bin]] +name = "herb-analysis" +path = "src/bin/herb_analysis.rs" + +[dependencies] +colored = "3" +herb = { path = ".." } +rubydex = "=0.2.5" + +[dev-dependencies] +insta = "1.40" diff --git a/rust/herb-analysis/README.md b/rust/herb-analysis/README.md new file mode 100644 index 000000000..b2878ffa4 --- /dev/null +++ b/rust/herb-analysis/README.md @@ -0,0 +1,54 @@ +# herb-analysis + +## Requirements + +- Rust **1.89.0+** (rubydex declares `rust-version = "1.89.0"`) + +## Build and run + +```bash +cd rust && cargo build -p herb-analysis +./bin/herb-analysis --help +``` + +``` +smoke in-memory indexing check, no filesystem needed +helpers --roots A,B helper set + [--oracle [--gem G] [--public-only]] score against the built-in registry +ancestors --roots A [--built-ins] ancestor chain + linearization completeness +constants --nesting A::B NAME lexical constant resolution +stats counts and per-phase timings +``` + +Paths must start with `.` or `/` — the arg parser uses that to tell paths from names. + +`--oracle` scores against `herb::action_view_helpers`, the registry generated from +`config/action_view_helpers/` into the `herb` crate — the same source +`lib/herb/action_view/helper_registry.rb` is rendered from. Nothing is read at runtime. +`--gem` and `--public-only` filter it; `--public-only` is almost always what you want, +since the registry also records internal config accessors no template calls. + +Examples, all against corpora already vendored in this repo. Resolve the gem path rather +than hardcoding a Ruby ABI version — `vendor/bundle/ruby//` moves whenever the +bundle is reinstalled under a different Ruby: + +```bash +ACTIONVIEW=$(ls -d ../vendor/bundle/ruby/*/gems/actionview-*/lib | head -1) +TURBO=$(ls -d ../vendor/bundle/ruby/*/gems/turbo-rails-* | head -1) + +./bin/herb-analysis smoke +./bin/herb-analysis ancestors $ACTIONVIEW --roots ActionView::Base +./bin/herb-analysis helpers $ACTIONVIEW --roots ActionView::Base --oracle --gem actionview --public-only +./bin/herb-analysis helpers $TURBO --roots Turbo::FramesHelper,Turbo::StreamsHelper \ + --oracle --gem turbo-rails --public-only +./bin/herb-analysis stats ../lib +``` + +Experiments against a real Rails app need one on disk; there is no usable fixture app in +this repo (`stimulus-lint/test/fixtures/test-rails-app/` has views but zero `.rb` files). + +## Note on the dependency arrow + +`herb-analysis` depends on `herb`; the root `herb` package must **never** depend on +`herb-analysis`. CI runs `cargo package` on the root package, and a path dependency +without a version breaks packaging. diff --git a/rust/herb-analysis/src/analysis.rs b/rust/herb-analysis/src/analysis.rs new file mode 100644 index 000000000..6f6710ff8 --- /dev/null +++ b/rust/herb-analysis/src/analysis.rs @@ -0,0 +1,321 @@ +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use rubydex::indexing::{self, IndexerBackend, LanguageId}; +use rubydex::listing; +use rubydex::model::built_in; +use rubydex::model::declaration::{Ancestor, Ancestors, Declaration, Namespace}; +use rubydex::model::graph::Graph; +use rubydex::resolution::Resolver; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChainState { + Complete, + Partial, + Cyclic, +} + +impl ChainState { + pub fn as_str(&self) -> &'static str { + match self { + ChainState::Complete => "complete", + ChainState::Partial => "partial", + ChainState::Cyclic => "cyclic", + } + } +} + +#[derive(Debug, Clone)] +pub struct Ancestry { + pub state: ChainState, + pub names: Vec, + pub unresolved: usize, +} + +pub struct Analysis { + graph: Graph, + timings: Vec<(&'static str, Duration)>, + index_errors: Vec, + files_indexed: usize, +} + +impl Analysis { + pub fn index_sources(sources: &[(&str, &str)]) -> Self { + let mut graph = Graph::new(); + let started = Instant::now(); + + for (uri, source) in sources { + indexing::index_source(&mut graph, uri, source, &LanguageId::Ruby); + } + + Self { + graph, + timings: vec![("index", started.elapsed())], + index_errors: Vec::new(), + files_indexed: sources.len(), + } + } + + pub fn index_paths(paths: &[String], excluded: &HashSet) -> Self { + let mut graph = Graph::new(); + let mut timings = Vec::new(); + let mut index_errors = Vec::new(); + + let started = Instant::now(); + let (file_paths, listing_errors) = listing::collect_file_paths(paths.to_vec(), excluded); + timings.push(("listing", started.elapsed())); + + for error in listing_errors { + index_errors.push(format!("{error:?}")); + } + + let files_indexed = file_paths.len(); + + let started = Instant::now(); + let errors = indexing::index_files(&mut graph, file_paths, IndexerBackend::RubyIndexer); + timings.push(("indexing", started.elapsed())); + + for error in errors { + index_errors.push(format!("{error:?}")); + } + + Self { + graph, + timings, + index_errors, + files_indexed, + } + } + + #[must_use] + pub fn with_built_ins(mut self) -> Self { + let started = Instant::now(); + built_in::add_built_in_data(&mut self.graph); + self.timings.push(("built_ins", started.elapsed())); + + self + } + + pub fn resolve(&mut self) -> &mut Self { + let started = Instant::now(); + Resolver::new(&mut self.graph).resolve(); + self.timings.push(("resolution", started.elapsed())); + + self + } + + fn declaration(&self, name: &str) -> Option<&Declaration> { + let definitions = self.graph.get(name)?; + let definition = definitions.first()?; + let declaration_id = self.graph.definition_to_declaration_id(definition)?; + + self.graph.declarations().get(declaration_id) + } + + fn namespace(&self, name: &str) -> Option<&Namespace> { + self.declaration(name)?.as_namespace() + } + + fn declaration_name(&self, id: &rubydex::model::ids::DeclarationId) -> Option<&str> { + self.graph.declarations().get(id).map(Declaration::name) + } + + pub fn ancestors_of(&self, name: &str) -> Option { + let namespace = self.namespace(name)?; + let ancestors = namespace.ancestors(); + + let state = match ancestors { + Ancestors::Complete(_) => ChainState::Complete, + Ancestors::Partial(_) => ChainState::Partial, + Ancestors::Cyclic(_) => ChainState::Cyclic, + }; + + let mut names = Vec::new(); + let mut unresolved = 0; + + for ancestor in ancestors.iter() { + match ancestor { + Ancestor::Complete(id) => { + if let Some(name) = self.declaration_name(id) { + names.push(name.to_string()); + } + } + Ancestor::Partial(_) => { + unresolved += 1; + names.push("".to_string()); + } + } + } + + Some(Ancestry { state, names, unresolved }) + } + + pub fn methods_of(&self, name: &str) -> BTreeSet { + let mut methods = BTreeSet::new(); + + let Some(namespace) = self.namespace(name) else { + return methods; + }; + + for (string_id, declaration_id) in namespace.members() { + let Some(declaration) = self.graph.declarations().get(declaration_id) else { + continue; + }; + + if declaration.as_method().is_none() { + continue; + } + + if let Some(method_name) = self.graph.strings().get(string_id) { + methods.insert(method_name.trim_end_matches("()").to_string()); + } + } + + methods + } + + pub fn helper_modules(&self) -> Vec { + let mut modules: Vec = self + .graph + .declarations() + .values() + .filter(|declaration| matches!(declaration.as_namespace(), Some(Namespace::Module(_)))) + .map(|declaration| declaration.name().to_string()) + .filter(|name| name.ends_with("Helper")) + .collect(); + + modules.sort(); + modules.dedup(); + + modules + } + + pub fn is_app_owned(&self, name: &str, path: &str) -> bool { + let Some(declaration) = self.declaration(name) else { + return false; + }; + + let uris: Vec<&str> = declaration + .definitions() + .iter() + .filter_map(|definition_id| self.graph.definitions().get(definition_id)) + .filter_map(|definition| self.graph.documents().get(definition.uri_id()).map(|document| document.uri())) + .collect(); + + !uris.is_empty() && uris.iter().all(|uri| uri.contains(path)) + } + + pub fn methods_with_visibility(&self, name: &str) -> BTreeMap { + let mut methods = BTreeMap::new(); + + let Some(namespace) = self.namespace(name) else { + return methods; + }; + + for (string_id, declaration_id) in namespace.members() { + let Some(declaration) = self.graph.declarations().get(declaration_id) else { + continue; + }; + + if declaration.as_method().is_none() { + continue; + } + + let Some(method_name) = self.graph.strings().get(string_id) else { + continue; + }; + + let visibility = self + .graph + .visibility(declaration_id) + .map_or_else(|| "unknown".to_string(), |visibility| format!("{visibility:?}")); + + methods.insert(method_name.trim_end_matches("()").to_string(), visibility); + } + + methods + } + + pub fn methods_with_ancestors(&self, name: &str) -> BTreeMap { + let mut methods = BTreeMap::new(); + + let Some(ancestry) = self.ancestors_of(name) else { + return methods; + }; + + let mut chain = vec![name.to_string()]; + chain.extend(ancestry.names.iter().filter(|n| *n != "").cloned()); + + for owner in chain { + for method in self.methods_of(&owner) { + methods.entry(method).or_insert_with(|| owner.clone()); + } + } + + methods + } + + pub fn view_visible_helpers(&self, roots: &[&str]) -> BTreeMap { + let mut helpers = BTreeMap::new(); + + for root in roots { + for (method, owner) in self.methods_with_ancestors(root) { + helpers.entry(method).or_insert(owner); + } + } + + helpers + } + + pub fn resolve_constant(&self, nesting: &[&str], name: &str) -> Option { + for depth in (0..=nesting.len()).rev() { + let mut candidate = nesting[..depth].join("::"); + + if !candidate.is_empty() { + candidate.push_str("::"); + } + + candidate.push_str(name); + + if let Some(declaration) = self.declaration(&candidate) { + return Some(declaration.name().to_string()); + } + } + + None + } + + pub fn update_document(&mut self, uri: &str, source: &str) { + self.graph.delete_document(uri); + indexing::index_source(&mut self.graph, uri, source, &LanguageId::Ruby); + } + + pub fn remove_document(&mut self, uri: &str) { + self.graph.delete_document(uri); + } + + pub fn graph(&self) -> &Graph { + &self.graph + } + + pub fn files_indexed(&self) -> usize { + self.files_indexed + } + + pub fn declaration_count(&self) -> usize { + self.graph.declarations().len() + } + + pub fn definition_count(&self) -> usize { + self.graph.definitions().len() + } + + pub fn timings(&self) -> &[(&'static str, Duration)] { + &self.timings + } + + pub fn index_errors(&self) -> &[String] { + &self.index_errors + } +} diff --git a/rust/herb-analysis/src/bin/herb_analysis.rs b/rust/herb-analysis/src/bin/herb_analysis.rs new file mode 100644 index 000000000..1ec7e33d0 --- /dev/null +++ b/rust/herb-analysis/src/bin/herb_analysis.rs @@ -0,0 +1,406 @@ +use std::collections::{BTreeMap, HashSet}; +use std::env; +use std::path::PathBuf; +use std::process; + +use colored::*; + +use herb::action_view_helpers; + +use herb_analysis::{rails, report, Analysis}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Default)] +struct CLIOptions { + command: Option, + paths: Vec, + roots: Vec, + name: Option, + nesting: Option, + oracle: bool, + gem: Option, + public_only: bool, + all_helpers: bool, + built_ins: bool, + help: bool, +} + +fn main() { + process::exit(run()); +} + +fn run() -> i32 { + let options = parse_args(env::args().collect()); + + if options.help || options.command.is_none() { + show_help(); + return 0; + } + + match options.command.as_deref() { + Some("smoke") => smoke(), + Some("helpers") => helpers(&options), + Some("ancestors") => ancestors(&options), + Some("constants") => constants(&options), + Some("stats") => stats(&options), + Some("context") => context(&options), + Some(other) => { + eprintln!("{}", format!("Unknown command: {other}").red()); + show_help(); + 1 + } + None => 0, + } +} + +fn parse_args(args: Vec) -> CLIOptions { + let mut options = CLIOptions::default(); + let mut index = 1; + + while index < args.len() { + let arg = &args[index]; + + match arg.as_str() { + "--help" | "-h" => options.help = true, + "--built-ins" => options.built_ins = true, + "--roots" => { + index += 1; + if let Some(value) = args.get(index) { + options.roots = value.split(',').map(str::to_string).collect(); + } + } + "--nesting" => { + index += 1; + options.nesting = args.get(index).cloned(); + } + "--oracle" => options.oracle = true, + "--gem" => { + index += 1; + options.gem = args.get(index).cloned(); + } + "--public-only" => options.public_only = true, + "--all-helpers" => options.all_helpers = true, + _ => { + if options.command.is_none() { + options.command = Some(arg.clone()); + } else if arg.starts_with('/') || arg.starts_with('.') { + options.paths.push(arg.clone()); + } else { + options.name = Some(arg.clone()); + } + } + } + + index += 1; + } + + options +} + +fn build(options: &CLIOptions) -> Analysis { + let excluded = HashSet::new(); + let mut analysis = Analysis::index_paths(&options.paths, &excluded); + + if options.built_ins { + analysis = analysis.with_built_ins(); + } + + analysis.resolve(); + analysis +} + +fn smoke() -> i32 { + let mut analysis = Analysis::index_sources(&[( + "file:///smoke.rb", + "module Alpha\n class Beta\n def gamma; end\n def delta(a, b = 1, *rest, key:, **opts, &blk); end\n def self.epsilon; end\n end\nend\n", + )]); + + analysis.resolve(); + + let found = analysis.ancestors_of("Alpha::Beta").is_some(); + let methods = analysis.methods_of("Alpha::Beta"); + + println!(); + println!(" {}", "E3 — in-memory indexing, no filesystem/Gemfile/bundler".bold()); + println!(); + println!(" Alpha::Beta resolved: {}", pass(found)); + println!(" methods found: {methods:?}"); + println!(); + + i32::from(!found || !methods.contains("gamma")) +} + +fn helpers(options: &CLIOptions) -> i32 { + let analysis = build(options); + + let discovered = if options.all_helpers { analysis.helper_modules() } else { Vec::new() }; + let root_names: Vec = if options.all_helpers { discovered.clone() } else { options.roots.clone() }; + let roots: Vec<&str> = root_names.iter().map(String::as_str).collect(); + let found = analysis.view_visible_helpers(&roots); + + println!(); + println!( + " {} {}", + "Helpers".bold(), + format!("({} files, {} roots)", analysis.files_indexed(), roots.len()).dimmed() + ); + println!(); + + if options.oracle { + let expected = report::expected(options.gem.as_deref(), options.public_only); + + let diff = herb_analysis::Diff::new(&found, &expected); + let recall = format!("recall: {:.1}%", diff.recall() * 100.0); + + println!( + " {}", + format!( + "registry: {} entries, filtered to gem={} visibility={}", + action_view_helpers::count(), + options.gem.as_deref().unwrap_or("*"), + if options.public_only { "public" } else { "*" } + ) + .dimmed() + ); + println!(" oracle: {}", expected.len()); + println!(" found: {}", found.len()); + println!(" matched: {}", diff.matched.len()); + println!(" {}", if diff.recall() >= 0.95 { recall.green() } else { recall.red() }); + println!(" extra: {} {}", diff.extra.len(), "(found but not in oracle)".dimmed()); + println!(); + println!(" {}", "missing:".bold()); + + for name in &diff.missing { + let visibility = action_view_helpers::find_by_name(name).map_or("-", |entry| entry.visibility); + + println!("MISSING\t{name}\t{visibility}"); + } + + println!(); + } else { + for (method, owner) in found.iter() { + println!(" {method} {}", format!("— {owner}").dimmed()); + } + + println!(); + println!(" {}", format!("{} total", found.len()).dimmed()); + println!(); + } + + 0 +} + +fn context(options: &CLIOptions) -> i32 { + let Some(app_root) = options.paths.first().map(PathBuf::from) else { + eprintln!("{}", "context needs an app path".red()); + return 1; + }; + + let mut roots = vec![app_root.join("app").to_string_lossy().to_string()]; + let gems = rails::gem_paths(&app_root); + let gem_count = gems.resolved; + let missing_gems = gems.missing.clone(); + + roots.extend(gems.paths); + + let mut analysis = Analysis::index_paths(&roots, &HashSet::new()); + analysis.resolve(); + + let helper_modules = analysis.helper_modules(); + let mut module_roots: Vec<&str> = helper_modules.iter().map(String::as_str).collect(); + + module_roots.push("ActionView::Base"); + + let helpers = analysis.view_visible_helpers(&module_roots); + let routes = rails::route_helpers(&app_root); + + let app_path = app_root.join("app").to_string_lossy().to_string(); + let mut by_origin: BTreeMap<&str, Vec<(&String, &String)>> = BTreeMap::new(); + + for (method, owner) in &helpers { + let origin = if owner.starts_with("ActionView") || owner.starts_with("ActionDispatch") || owner.starts_with("ActiveSupport") { + "rails" + } else if helper_modules.contains(owner) && analysis.is_app_owned(owner, &app_path) { + "app" + } else { + "gem" + }; + + by_origin.entry(origin).or_default().push((method, owner)); + } + + println!(); + println!( + " {} {}", + "View context".bold(), + format!( + "({} files, {gem_count} gems, {} helper modules)", + analysis.files_indexed(), + helper_modules.len() + ) + .dimmed() + ); + println!(); + + for (origin, mut entries) in by_origin { + entries.sort(); + + println!(" {} {}", origin.bold(), format!("({})", entries.len()).dimmed()); + + for (method, owner) in entries { + println!(" {method} {}", format!("— {owner}").dimmed()); + } + + println!(); + } + + println!(" {} {}", "route".bold(), format!("({}, approximate)", routes.len()).dimmed()); + + for name in &routes { + println!(" {name} {}", "— config/routes.rb".dimmed()); + } + + println!(); + println!(" {}", format!("{} total", helpers.len() + routes.len()).dimmed()); + + if !missing_gems.is_empty() { + println!(); + println!( + " {} {}", + format!("{} locked gems are not installed for this Ruby", missing_gems.len()).yellow(), + "— their helpers are missing. Run `bundle install` in the app.".dimmed() + ); + println!( + " {}", + format!("e.g. {}", missing_gems.iter().take(6).cloned().collect::>().join(", ")).dimmed() + ); + } + + println!(); + + 0 +} + +fn ancestors(options: &CLIOptions) -> i32 { + let analysis = build(options); + + let targets: Vec = if options.roots.is_empty() { + options.name.clone().into_iter().collect() + } else { + options.roots.clone() + }; + + println!(); + println!( + " {} {}", + "Ancestors".bold(), + format!("({} files, built_ins: {})", analysis.files_indexed(), options.built_ins).dimmed() + ); + println!(); + + for target in &targets { + match analysis.ancestors_of(target) { + Some(ancestry) => { + let state = format!("[{}]", ancestry.state.as_str()); + let state = if ancestry.unresolved == 0 { state.green() } else { state.yellow() }; + + println!( + " {} {} {}", + target.bold(), + state, + format!("{} ancestors, {} unresolved", ancestry.names.len(), ancestry.unresolved).dimmed() + ); + + for name in ancestry.names.iter().take(25) { + println!(" {}", name.dimmed()); + } + + println!(); + } + None => println!(" {}\n", format!("{target} — did not resolve").red()), + } + } + + 0 +} + +fn constants(options: &CLIOptions) -> i32 { + let analysis = build(options); + + let nesting_owned: Vec = options + .nesting + .as_deref() + .unwrap_or("") + .split("::") + .filter(|part| !part.is_empty()) + .map(str::to_string) + .collect(); + + let nesting: Vec<&str> = nesting_owned.iter().map(String::as_str).collect(); + let name = options.name.clone().unwrap_or_default(); + + println!(); + println!(" {}", "Constant resolution".bold()); + println!(); + println!(" nesting: {nesting:?}"); + println!(" name: {name}"); + + match analysis.resolve_constant(&nesting, &name) { + Some(resolved) => println!(" {}", format!("resolved: {resolved}").green()), + None => println!(" {}", "unresolved".red()), + } + + println!(); + + 0 +} + +fn stats(options: &CLIOptions) -> i32 { + let analysis = build(options); + + println!(); + println!(" {}", "Index stats".bold()); + println!(); + println!(" files: {}", analysis.files_indexed()); + println!(" declarations: {}", analysis.declaration_count()); + println!(" definitions: {}", analysis.definition_count()); + println!(" errors: {}", analysis.index_errors().len()); + println!(); + + for (phase, duration) in analysis.timings() { + println!(" {phase:<12} {duration:>8.1?}"); + } + + println!(); + + 0 +} + +fn pass(condition: bool) -> ColoredString { + if condition { + "yes".green() + } else { + "no".red() + } +} + +fn show_help() { + println!(); + println!( + " {} {}", + "herb-analysis".bold(), + format!("v{VERSION} — rubydex spike, not for production").dimmed() + ); + println!(); + println!(" smoke in-memory indexing check (E3)"); + println!(" helpers --roots A,B helper set"); + println!(" [--oracle [--gem G] [--public-only]] score against the built-in registry"); + println!(" ancestors --roots A [--built-ins] ancestor chain + completeness"); + println!(" constants --nesting A::B NAME lexical constant resolution"); + println!(" stats counts and per-phase timings"); + println!(" context full ActionView context of a Rails app"); + println!(); + println!(" --all-helpers discover every *Helper module instead of passing --roots"); + println!(); +} diff --git a/rust/herb-analysis/src/lib.rs b/rust/herb-analysis/src/lib.rs new file mode 100644 index 000000000..1fbdc4886 --- /dev/null +++ b/rust/herb-analysis/src/lib.rs @@ -0,0 +1,17 @@ +pub mod analysis; +pub mod rails; +pub mod report; + +pub use analysis::{Analysis, Ancestry, ChainState}; +pub use report::{expected, Diff}; + +pub fn e2b_link_check(erb: &str, ruby: &str) -> (usize, usize) { + let parsed = herb::parse(erb).expect("herb parse failed"); + let erb_children = parsed.value.children.len(); + + let mut graph = rubydex::model::graph::Graph::new(); + rubydex::indexing::index_source(&mut graph, "file:///e2b.rb", ruby, &rubydex::indexing::LanguageId::Ruby); + rubydex::resolution::Resolver::new(&mut graph).resolve(); + + (erb_children, graph.declarations().len()) +} diff --git a/rust/herb-analysis/src/rails.rs b/rust/herb-analysis/src/rails.rs new file mode 100644 index 000000000..28eea1bb2 --- /dev/null +++ b/rust/herb-analysis/src/rails.rs @@ -0,0 +1,310 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub struct Gems { + pub paths: Vec, + pub resolved: usize, + pub missing: Vec, +} + +pub fn gem_paths(app_root: &Path) -> Gems { + let Ok(lockfile) = fs::read_to_string(app_root.join("Gemfile.lock")) else { + return Gems { + paths: Vec::new(), + resolved: 0, + missing: Vec::new(), + }; + }; + + let roots = gem_roots(app_root); + let checkout_roots = checkout_roots(app_root); + let mut paths = Vec::new(); + let mut missing = Vec::new(); + let mut resolved_count = 0; + + for spec in parse_lockfile(&lockfile) { + let resolved = match &spec.source { + Source::Registry { version } => roots + .iter() + .map(|root| root.join(format!("{}-{version}", spec.name))) + .find(|path| path.is_dir()), + Source::Git { repo, revision } => resolve_git(&checkout_roots, repo, revision, &spec.name), + Source::Path { remote } => { + let base = app_root.join(remote); + + [base.join(&spec.name), base].into_iter().find(|path| path.is_dir()) + } + }; + + match resolved { + Some(path) => { + paths.extend(source_dirs(&path)); + resolved_count += 1; + } + + None => missing.push(spec.name.clone()), + } + } + + paths.sort(); + paths.dedup(); + missing.sort(); + missing.dedup(); + + Gems { + paths, + resolved: resolved_count, + missing, + } +} + +fn source_dirs(gem_root: &Path) -> Vec { + ["lib", "app"] + .iter() + .map(|dir| gem_root.join(dir)) + .filter(|path| path.is_dir()) + .map(|path| path.to_string_lossy().to_string()) + .collect() +} + +fn resolve_git(roots: &[PathBuf], repo: &str, revision: &str, name: &str) -> Option { + let short = &revision[..revision.len().min(12)]; + + let exact = roots.iter().map(|root| root.join(format!("{repo}-{short}"))).find(|path| path.is_dir()); + + let checkout = exact.or_else(|| { + roots.iter().find_map(|root| { + fs::read_dir(root) + .ok()? + .flatten() + .map(|entry| entry.path()) + .find(|path| path.is_dir() && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with(&format!("{repo}-")))) + }) + })?; + + [checkout.join(name), checkout].into_iter().find(|path| path.is_dir()) +} + +enum Source { + Registry { version: String }, + Git { repo: String, revision: String }, + Path { remote: String }, +} + +struct Spec { + name: String, + source: Source, +} + +fn parse_lockfile(lockfile: &str) -> Vec { + let mut specs = Vec::new(); + let mut section = ""; + let mut remote = String::new(); + let mut revision = String::new(); + + for line in lockfile.lines() { + match line.trim_end() { + "GEM" => (section, remote, revision) = ("GEM", String::new(), String::new()), + "GIT" => (section, remote, revision) = ("GIT", String::new(), String::new()), + "PATH" => (section, remote, revision) = ("PATH", String::new(), String::new()), + other if other.starts_with(" remote: ") => remote = other.trim_start().trim_start_matches("remote: ").to_string(), + other if other.starts_with(" revision: ") => revision = other.trim_start().trim_start_matches("revision: ").to_string(), + other => { + let Some((name, version)) = spec_line(other) else { + continue; + }; + + let source = match section { + "GIT" if !revision.is_empty() => Source::Git { + repo: repo_name(&remote), + revision: revision.clone(), + }, + "PATH" => Source::Path { remote: remote.clone() }, + _ => Source::Registry { version }, + }; + + specs.push(Spec { name, source }); + } + } + } + + specs +} + +fn repo_name(remote: &str) -> String { + remote + .trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or(remote) + .trim_end_matches(".git") + .to_string() +} + +fn spec_line(line: &str) -> Option<(String, String)> { + if !line.starts_with(" ") || line.starts_with(" ") { + return None; + } + + let (name, rest) = line.trim_start().split_once(" (")?; + let version = rest.strip_suffix(')')?; + + let valid = !name.is_empty() && !version.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + + valid.then(|| (name.to_string(), version.to_string())) +} + +fn checkout_roots(app_root: &Path) -> Vec { + gem_roots(app_root) + .iter() + .filter_map(|root| root.parent().map(|parent| parent.join("bundler/gems"))) + .filter(|root| root.is_dir()) + .collect() +} + +fn gem_roots(app_root: &Path) -> Vec { + let mut roots = Vec::new(); + + if let Ok(entries) = fs::read_dir(app_root.join("vendor/bundle/ruby")) { + for entry in entries.flatten() { + roots.push(entry.path().join("gems")); + } + } + + if let Ok(output) = Command::new("gem").arg("env").arg("gemdir").current_dir(app_root).output() { + if let Ok(dir) = String::from_utf8(output.stdout) { + roots.push(Path::new(dir.trim()).join("gems")); + } + } + + roots.into_iter().filter(|root| root.is_dir()).collect() +} + +pub fn route_helpers(app_root: &Path) -> BTreeSet { + let Ok(source) = fs::read_to_string(app_root.join("config/routes.rb")) else { + return BTreeSet::new(); + }; + + let mut names = BTreeSet::new(); + let mut namespaces: Vec = Vec::new(); + let mut depth_stack: Vec = Vec::new(); + + for line in source.lines() { + let trimmed = line.trim(); + + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + if trimmed == "end" { + if depth_stack.pop().is_some() { + namespaces.pop(); + } + + continue; + } + + let prefix = if namespaces.is_empty() { + String::new() + } else { + format!("{}_", namespaces.join("_")) + }; + + if let Some(namespace) = symbol_after(trimmed, "namespace ") { + namespaces.push(namespace); + depth_stack.push(namespaces.len()); + + continue; + } + + if trimmed.starts_with("root ") { + insert_pair(&mut names, &format!("{prefix}root")); + + continue; + } + + if let Some(name) = symbol_after(trimmed, "resources ") { + let singular = singularize(&name); + + insert_pair(&mut names, &format!("{prefix}{name}")); + insert_pair(&mut names, &format!("{prefix}{singular}")); + insert_pair(&mut names, &format!("new_{prefix}{singular}")); + insert_pair(&mut names, &format!("edit_{prefix}{singular}")); + } else if let Some(name) = symbol_after(trimmed, "resource ") { + insert_pair(&mut names, &format!("{prefix}{name}")); + insert_pair(&mut names, &format!("new_{prefix}{name}")); + insert_pair(&mut names, &format!("edit_{prefix}{name}")); + } + + if let Some(alias_name) = value_after(trimmed, "as: :") { + insert_pair(&mut names, &format!("{prefix}{alias_name}")); + } else if let Some(literal) = literal_path(trimmed) { + insert_pair(&mut names, &format!("{prefix}{literal}")); + } + } + + names +} + +fn insert_pair(names: &mut BTreeSet, stem: &str) { + if stem.is_empty() { + return; + } + + names.insert(format!("{stem}_path")); + names.insert(format!("{stem}_url")); +} + +fn symbol_after(line: &str, keyword: &str) -> Option { + let rest = line.strip_prefix(keyword)?.trim_start(); + let rest = rest.strip_prefix(':')?; + + let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + + (!name.is_empty()).then_some(name) +} + +fn value_after(line: &str, marker: &str) -> Option { + let index = line.find(marker)?; + let rest = &line[index + marker.len()..]; + + let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + + (!name.is_empty()).then_some(name) +} + +fn literal_path(line: &str) -> Option { + let verb = ["get ", "post ", "put ", "patch ", "delete "].iter().find(|verb| line.starts_with(**verb))?; + + let rest = line[verb.len()..].trim_start(); + let quote = rest.chars().next().filter(|c| *c == '"' || *c == '\'')?; + let rest = &rest[1..]; + let path = rest.split(quote).next()?; + + let segment = path.trim_matches('/'); + + if segment.is_empty() || segment.contains('/') || segment.contains(':') || segment.contains('*') { + return None; + } + + segment + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + .then(|| segment.replace('-', "_")) +} + +fn singularize(word: &str) -> String { + if let Some(stem) = word.strip_suffix("ies") { + return format!("{stem}y"); + } + + for suffix in ["ses", "xes", "zes", "ches", "shes"] { + if let Some(stem) = word.strip_suffix(suffix) { + return format!("{stem}{}", &suffix[..suffix.len() - 2]); + } + } + + word.strip_suffix('s').map_or_else(|| word.to_string(), str::to_string) +} diff --git a/rust/herb-analysis/src/report.rs b/rust/herb-analysis/src/report.rs new file mode 100644 index 000000000..d4d713c00 --- /dev/null +++ b/rust/herb-analysis/src/report.rs @@ -0,0 +1,52 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use herb::action_view_helpers; + +pub fn expected(gem: Option<&str>, public_only: bool) -> BTreeSet { + action_view_helpers::entries() + .iter() + .filter(|entry| gem.is_none_or(|gem| entry.gem == gem)) + .filter(|entry| !public_only || entry.visibility == "public") + .map(|entry| entry.name.to_string()) + .collect() +} + +pub struct Diff { + pub matched: BTreeSet, + pub missing: BTreeSet, + pub extra: BTreeMap, +} + +impl Diff { + pub fn new(found: &BTreeMap, oracle: &BTreeSet) -> Self { + let mut matched = BTreeSet::new(); + let mut missing = BTreeSet::new(); + let mut extra = BTreeMap::new(); + + for name in oracle { + if found.contains_key(name) { + matched.insert(name.clone()); + } else { + missing.insert(name.clone()); + } + } + + for (name, owner) in found { + if !oracle.contains(name) { + extra.insert(name.clone(), owner.clone()); + } + } + + Self { matched, missing, extra } + } + + pub fn recall(&self) -> f64 { + let total = self.matched.len() + self.missing.len(); + + if total == 0 { + return 0.0; + } + + self.matched.len() as f64 / total as f64 + } +} diff --git a/rust/herb-analysis/tests/e2b_link_test.rs b/rust/herb-analysis/tests/e2b_link_test.rs new file mode 100644 index 000000000..6f8ab0cb8 --- /dev/null +++ b/rust/herb-analysis/tests/e2b_link_test.rs @@ -0,0 +1,15 @@ +#[test] +fn herb_and_rubydex_both_work_in_one_binary() { + let (erb_children, declarations) = + herb_analysis::e2b_link_check("
<%= @post.title %>
", "module Alpha\n class Beta\n def gamma; end\n end\nend\n"); + + assert!(erb_children > 0, "herb parsed no ERB children — its Prism may have been displaced"); + assert!(declarations > 0, "rubydex produced no declarations — its Prism may have been displaced"); +} + +#[test] +fn herb_still_parses_ruby_through_its_own_prism() { + let result = herb::parse("<%= user.name %>").expect("herb parse failed"); + + assert!(!result.value.children.is_empty()); +} diff --git a/rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb b/rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb new file mode 100644 index 000000000..9b3f174d8 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb @@ -0,0 +1,17 @@ +module ApplicationHelper + include FormattingHelper + + def page_title(title) + content_tag(:h1, title) + end + + def current_year + Time.current.year + end + + private + + def internal_only_secret + "should never be callable from a template" + end +end diff --git a/rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb b/rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb new file mode 100644 index 000000000..2ae32eb07 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb @@ -0,0 +1,11 @@ +module FormattingHelper + extend ActiveSupport::Concern + + def format_price(cents) + "$%.2f" % (cents / 100.0) + end + + def format_date(date) + date.strftime("%B %-d, %Y") + end +end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/Gemfile.lock b/rust/herb-analysis/tests/fixtures/rails_app/Gemfile.lock new file mode 100644 index 000000000..a5109a945 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/Gemfile.lock @@ -0,0 +1,24 @@ +GIT + remote: https://github.com/example/fakerepo.git + revision: abc123456789deadbeefcafe + specs: + fakemono (9.9.9) + somedep (>= 1.0) + +PATH + remote: local_engine + specs: + localengine (0.1.0) + +GEM + remote: https://rubygems.org/ + specs: + fakegem (1.2.3) + othergem (>= 2.0) + notinstalledgem (4.5.6) + +PLATFORMS + arm64-darwin-24 + +DEPENDENCIES + fakegem diff --git a/rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb b/rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb new file mode 100644 index 000000000..28b9f07c5 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb @@ -0,0 +1,16 @@ +Rails.application.routes.draw do + root to: "home#index" + + # a comment that must not be parsed + get "/about", to: "pages#about" + get "/contact", to: "pages#contact", as: :reach_us + get "/posts/:id/preview", to: "posts#preview" + + resources :posts + resources :categories + resource :session + + namespace :admin do + resources :users + end +end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb b/rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb new file mode 100644 index 000000000..4dc790e54 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb @@ -0,0 +1 @@ +module Localengine; end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb new file mode 100644 index 000000000..cbef5dfd5 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb @@ -0,0 +1 @@ +module Fakemono; end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb new file mode 100644 index 000000000..9c52b5ff2 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb @@ -0,0 +1 @@ +module Fakegem; end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb new file mode 100644 index 000000000..013b7ab74 --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb @@ -0,0 +1 @@ +module FakegemTestFixture; end diff --git a/rust/herb-analysis/tests/fixtures/ruby/nesting.rb b/rust/herb-analysis/tests/fixtures/ruby/nesting.rb new file mode 100644 index 000000000..aadce554b --- /dev/null +++ b/rust/herb-analysis/tests/fixtures/ruby/nesting.rb @@ -0,0 +1,30 @@ + +CONFIG = :top_level + +module Admin + CONFIG = :admin_level + + class UsersController + def show + CONFIG + Status::ACTIVE + ::CONFIG + end + end + + class Reports + def run + CONFIG + end + end +end + +module Status + ACTIVE = :active +end + +module Billing + class Invoice + CONFIG = :invoice_level + end +end diff --git a/rust/herb-analysis/tests/rails_test.rs b/rust/herb-analysis/tests/rails_test.rs new file mode 100644 index 000000000..6b957f10c --- /dev/null +++ b/rust/herb-analysis/tests/rails_test.rs @@ -0,0 +1,126 @@ +use std::path::Path; +use herb_analysis::rails; + +fn app() -> &'static Path { + Path::new("tests/fixtures/rails_app") +} + +#[test] +fn resolves_all_three_lockfile_source_types() { + let gems = rails::gem_paths(app()); + let joined = gems.paths.join("\n"); + + assert!(joined.contains("gems/fakegem-1.2.3/lib"), "registry gem: {joined}"); + assert!(joined.contains("fakerepo-abc123456789/fakemono/lib"), "git monorepo gem: {joined}"); + assert!(joined.contains("local_engine/lib"), "path source: {joined}"); + assert_eq!(gems.resolved, 3); +} + +#[test] +fn reports_locked_gems_that_are_not_installed() { + let gems = rails::gem_paths(app()); + + assert_eq!(gems.missing, vec!["notinstalledgem".to_string()]); +} + +#[test] +fn indexes_only_lib_and_app_never_the_gem_root() { + let gems = rails::gem_paths(app()); + + assert!( + gems.paths.iter().all(|path| path.ends_with("/lib") || path.ends_with("/app")), + "every indexed path should be a lib/ or app/ dir: {:?}", + gems.paths + ); + assert!(!gems.paths.iter().any(|path| path.contains("/test")), "must not index a gem's test tree"); +} + +#[test] +fn dependency_lines_are_not_mistaken_for_specs() { + let gems = rails::gem_paths(app()); + + assert!(!gems.missing.iter().any(|name| name == "somedep" || name == "othergem"), "{:?}", gems.missing); +} + +#[test] +fn missing_lockfile_yields_nothing_rather_than_panicking() { + let gems = rails::gem_paths(Path::new("tests/fixtures/ruby")); + + assert!(gems.paths.is_empty()); + assert_eq!(gems.resolved, 0); +} + +#[test] +fn derives_root_and_literal_path_helpers() { + let routes = rails::route_helpers(app()); + + assert!(routes.contains("root_path")); + assert!(routes.contains("root_url")); + assert!(routes.contains("about_path")); + assert!(routes.contains("about_url")); +} + +#[test] +fn derives_plural_and_singular_resource_helpers() { + let routes = rails::route_helpers(app()); + + for name in ["posts_path", "post_path", "new_post_path", "edit_post_path"] { + assert!(routes.contains(name), "missing {name}"); + } + + for name in ["categories_path", "category_path", "new_category_path", "edit_category_path"] { + assert!(routes.contains(name), "missing {name}"); + } +} + +#[test] +fn singular_resource_has_no_plural_index_helper() { + let routes = rails::route_helpers(app()); + + assert!(routes.contains("session_path")); + assert!(routes.contains("new_session_path")); + assert!(!routes.contains("sessions_path"), "`resource :session` is singular"); +} + +#[test] +fn explicit_as_replaces_the_literal_derived_name() { + let routes = rails::route_helpers(app()); + + assert!(routes.contains("reach_us_path")); + assert!(!routes.contains("contact_path"), "`as:` overrides the path-derived name"); +} + +#[test] +fn namespaced_resources_use_rails_helper_ordering() { + let routes = rails::route_helpers(app()); + + assert!(routes.contains("admin_users_path")); + assert!(routes.contains("admin_user_path")); + assert!(routes.contains("new_admin_user_path")); + assert!(routes.contains("edit_admin_user_path")); + assert!(!routes.contains("admin_new_user_path"), "prefix ordering is new__"); +} + +#[test] +fn ignores_comments_and_parameterised_paths() { + let routes = rails::route_helpers(app()); + + assert!(!routes.iter().any(|name| name.contains("comment"))); + assert!( + !routes.iter().any(|name| name.starts_with("preview")), + "paths with :params are not conventional" + ); +} + +#[test] +fn namespace_does_not_leak_past_its_end() { + let routes = rails::route_helpers(app()); + + assert!(routes.contains("posts_path")); + assert!(!routes.contains("admin_posts_path")); +} + +#[test] +fn missing_routes_file_yields_nothing_rather_than_panicking() { + assert!(rails::route_helpers(Path::new("tests/fixtures/ruby")).is_empty()); +} diff --git a/rust/herb-analysis/tests/usefulness_test.rs b/rust/herb-analysis/tests/usefulness_test.rs new file mode 100644 index 000000000..50f5845af --- /dev/null +++ b/rust/herb-analysis/tests/usefulness_test.rs @@ -0,0 +1,115 @@ +use std::collections::HashSet; +use herb_analysis::Analysis; + +fn fixture_app() -> Analysis { + let mut analysis = Analysis::index_paths(&["tests/fixtures/app".to_string()], &HashSet::new()); + analysis.resolve(); + + analysis +} + +fn flat_def_scan() -> HashSet { + ["format_price", "format_date", "page_title", "current_year", "internal_only_secret"] + .iter() + .map(|name| (*name).to_string()) + .collect() +} + +#[test] +fn resolves_helpers_reaching_the_view_through_a_concern() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + + assert_eq!(helpers.get("format_price").map(String::as_str), Some("FormattingHelper")); + assert_eq!(helpers.get("format_date").map(String::as_str), Some("FormattingHelper")); + + assert_eq!(helpers.get("page_title").map(String::as_str), Some("ApplicationHelper")); + assert_eq!(helpers.get("current_year").map(String::as_str), Some("ApplicationHelper")); +} + +#[test] +fn attributes_each_helper_to_its_owning_module() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + + assert_ne!( + helpers.get("format_price"), + helpers.get("page_title"), + "helpers from different modules should not report the same owner" + ); +} + +#[test] +fn documents_that_private_helpers_are_still_over_reported() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + + assert!( + helpers.contains_key("internal_only_secret"), + "if this now fails, visibility filtering has been wired up — tighten this test" + ); +} + +#[test] +fn does_not_resolve_typos_or_undefined_names() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + + assert!(!helpers.contains_key("page_titel")); + assert!(!helpers.contains_key("frmat_price")); +} + +#[test] +fn confirms_route_helpers_remain_a_gap() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + + assert!(!helpers.contains_key("users_path")); + assert!(!helpers.contains_key("root_url")); +} + +#[test] +fn compares_against_the_flat_def_scan() { + let analysis = fixture_app(); + let helpers = analysis.methods_with_ancestors("ApplicationHelper"); + let flat = flat_def_scan(); + + let resolved: HashSet = helpers.keys().cloned().collect(); + + for name in &flat { + assert!(resolved.contains(name), "{name} was found by the flat scan but not by rubydex"); + } + + assert!(resolved.len() >= flat.len(), "rubydex should know at least as much as the flat scan"); +} + +#[test] +fn visibility_can_distinguish_private_helpers() { + let analysis = fixture_app(); + let visibility = analysis.methods_with_visibility("ApplicationHelper"); + + eprintln!("resolved visibility: {visibility:?}"); + + assert_eq!(visibility.get("page_title").map(String::as_str), Some("Public")); + assert_eq!(visibility.get("current_year").map(String::as_str), Some("Public")); + assert_eq!(visibility.get("internal_only_secret").map(String::as_str), Some("Private")); +} + +#[test] +fn discovers_helper_modules_by_convention() { + let analysis = fixture_app(); + let modules = analysis.helper_modules(); + + assert!(modules.contains(&"ApplicationHelper".to_string())); + assert!(modules.contains(&"FormattingHelper".to_string())); + assert!(modules.iter().all(|name| name.ends_with("Helper"))); +} + +#[test] +fn distinguishes_app_owned_modules_from_foreign_ones() { + let analysis = fixture_app(); + + assert!(analysis.is_app_owned("ApplicationHelper", "tests/fixtures/app")); + assert!(!analysis.is_app_owned("ApplicationHelper", "some/other/path")); + assert!(!analysis.is_app_owned("NoSuchHelper", "tests/fixtures/app")); +} diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml index 2c87c7e37..55d880f88 100644 --- a/rust/rustfmt.toml +++ b/rust/rustfmt.toml @@ -11,6 +11,7 @@ remove_nested_parens = true # Ignore generated files from templates # run using `cargo +nightly fmt` ignore = [ + "src/action_view_helpers.rs", "src/ast/nodes.rs", "src/errors.rs", "src/nodes.rs", diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 7676fe03a..16cba0190 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,3 +1,4 @@ +pub mod action_view_helpers; pub mod ast; pub mod bindings; pub mod convert; From f4a48ee10ec743b91679b7ee3cc27f918d8ed29a Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 4 Aug 2026 14:36:00 +0200 Subject: [PATCH 2/5] Action View: Fix `FormBuilder#submit` and `FormBuilder#button` sources --- .../actionview/{form_helper => form_builder}/button.yml | 2 +- .../actionview/{form_helper => form_builder}/submit.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename config/action_view_helpers/actionview/{form_helper => form_builder}/button.yml (95%) rename config/action_view_helpers/actionview/{form_helper => form_builder}/submit.yml (95%) diff --git a/config/action_view_helpers/actionview/form_helper/button.yml b/config/action_view_helpers/actionview/form_builder/button.yml similarity index 95% rename from config/action_view_helpers/actionview/form_helper/button.yml rename to config/action_view_helpers/actionview/form_builder/button.yml index 42198d230..dd78cf3bf 100644 --- a/config/action_view_helpers/actionview/form_helper/button.yml +++ b/config/action_view_helpers/actionview/form_builder/button.yml @@ -1,6 +1,6 @@ --- name: button -source: "ActionView::Helpers::FormHelper#button" +source: "ActionView::Helpers::FormBuilder#button" gem: actionview output: html visibility: public diff --git a/config/action_view_helpers/actionview/form_helper/submit.yml b/config/action_view_helpers/actionview/form_builder/submit.yml similarity index 95% rename from config/action_view_helpers/actionview/form_helper/submit.yml rename to config/action_view_helpers/actionview/form_builder/submit.yml index c8b3f4559..5e4194e85 100644 --- a/config/action_view_helpers/actionview/form_helper/submit.yml +++ b/config/action_view_helpers/actionview/form_builder/submit.yml @@ -1,6 +1,6 @@ --- name: submit -source: "ActionView::Helpers::FormHelper#submit" +source: "ActionView::Helpers::FormBuilder#submit" gem: actionview output: html visibility: public From 8e0472d77f696dd93428d60a145dcaff3f542f1e Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 4 Aug 2026 14:39:17 +0200 Subject: [PATCH 3/5] Apply edit Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: Marco Roth --- .../rails_app/app/controllers/application_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb b/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb index bbd84cecf..88d3f02b7 100644 --- a/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb +++ b/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb @@ -1,6 +1,8 @@ class ApplicationController < ActionController::Base include Searchable + protect_from_forgery with: :exception + helper_method :current_user, :signed_in? helper_method :page_title From a17b330b8394e0bcb76a1514dda1a3c909890ea3 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 4 Aug 2026 15:02:31 +0200 Subject: [PATCH 4/5] Rubocop --- .../tests/fixtures/app/app/helpers/application_helper.rb | 2 ++ .../app/app/helpers/concerns/formatting_helper.rb | 4 +++- .../rails_app/app/controllers/application_controller.rb | 9 ++++----- .../rails_app/app/controllers/concerns/searchable.rb | 2 ++ .../tests/fixtures/rails_app/config/routes.rb | 2 ++ .../fixtures/rails_app/local_engine/lib/localengine.rb | 2 ++ .../gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb | 2 ++ .../bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb | 2 ++ .../bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb | 2 ++ rust/herb-analysis/tests/fixtures/ruby/nesting.rb | 3 +-- 10 files changed, 22 insertions(+), 8 deletions(-) diff --git a/rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb b/rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb index 9b3f174d8..ff9e51cca 100644 --- a/rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb +++ b/rust/herb-analysis/tests/fixtures/app/app/helpers/application_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ApplicationHelper include FormattingHelper diff --git a/rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb b/rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb index 2ae32eb07..7ba88ef88 100644 --- a/rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb +++ b/rust/herb-analysis/tests/fixtures/app/app/helpers/concerns/formatting_helper.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module FormattingHelper extend ActiveSupport::Concern def format_price(cents) - "$%.2f" % (cents / 100.0) + format("$%.2f", cents / 100.0) end def format_date(date) diff --git a/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb b/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb index 88d3f02b7..5ef128bd4 100644 --- a/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb +++ b/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/application_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ApplicationController < ActionController::Base include Searchable @@ -7,12 +9,9 @@ class ApplicationController < ActionController::Base helper_method :page_title # not exposed to views - def internal_thing - end + def internal_thing; end private - def current_user - @current_user - end + attr_reader :current_user end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/concerns/searchable.rb b/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/concerns/searchable.rb index 46be7538d..d4d5ce23b 100644 --- a/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/concerns/searchable.rb +++ b/rust/herb-analysis/tests/fixtures/rails_app/app/controllers/concerns/searchable.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Searchable extend ActiveSupport::Concern diff --git a/rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb b/rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb index 28b9f07c5..623c19e3e 100644 --- a/rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb +++ b/rust/herb-analysis/tests/fixtures/rails_app/config/routes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + Rails.application.routes.draw do root to: "home#index" diff --git a/rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb b/rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb index 4dc790e54..45fa18756 100644 --- a/rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb +++ b/rust/herb-analysis/tests/fixtures/rails_app/local_engine/lib/localengine.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + module Localengine; end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb index cbef5dfd5..a96a378ef 100644 --- a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb +++ b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/bundler/gems/fakerepo-abc123456789/fakemono/lib/fakemono.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + module Fakemono; end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb index 9c52b5ff2..f7f05b1d2 100644 --- a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb +++ b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/lib/fakegem.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + module Fakegem; end diff --git a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb index 013b7ab74..5aca234ed 100644 --- a/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb +++ b/rust/herb-analysis/tests/fixtures/rails_app/vendor/bundle/ruby/3.4.0/gems/fakegem-1.2.3/test/fixture.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + module FakegemTestFixture; end diff --git a/rust/herb-analysis/tests/fixtures/ruby/nesting.rb b/rust/herb-analysis/tests/fixtures/ruby/nesting.rb index aadce554b..96bc40504 100644 --- a/rust/herb-analysis/tests/fixtures/ruby/nesting.rb +++ b/rust/herb-analysis/tests/fixtures/ruby/nesting.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true CONFIG = :top_level @@ -6,8 +7,6 @@ module Admin class UsersController def show - CONFIG - Status::ACTIVE ::CONFIG end end From c4ac12dc9f46fa6bbe5ecec45c2214f480c4362b Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 4 Aug 2026 19:52:00 +0200 Subject: [PATCH 5/5] Better match style of other CLIs --- rust/herb-analysis/README.md | 15 ++-- rust/herb-analysis/src/bin/herb_analysis.rs | 79 +++++++++++++-------- rust/herb-analysis/src/report.rs | 6 +- 3 files changed, 60 insertions(+), 40 deletions(-) diff --git a/rust/herb-analysis/README.md b/rust/herb-analysis/README.md index 0566b644a..aae314788 100644 --- a/rust/herb-analysis/README.md +++ b/rust/herb-analysis/README.md @@ -12,13 +12,14 @@ cd rust && cargo build -p herb-analysis ``` ``` -helpers [path] [--only app,gem,rails,route] what a template can call - [--roots A,B] -audit [paths...] [--roots A,B] cross-check Herb's helper registry - [--gem G] [--include-internal] -ancestors [paths...] --roots A [--built-ins] ancestor chain + completeness -constants [paths...] --nesting A::B NAME lexical constant resolution -stats [paths...] counts and per-phase timings +Usage: herb-analysis [path] [options] + +Commands: + helpers List everything a template can call, grouped by origin + audit Cross-check Herb's Action View helper registry against real sources + ancestors Show a module's ancestor chain and whether it is complete + constants List constants, or resolve one against a lexical nesting + stats Show index counts and per-phase timings ``` Paths default to the current directory and must start with `.` or `/`, which is how the diff --git a/rust/herb-analysis/src/bin/herb_analysis.rs b/rust/herb-analysis/src/bin/herb_analysis.rs index ada80c07d..b7808645e 100644 --- a/rust/herb-analysis/src/bin/herb_analysis.rs +++ b/rust/herb-analysis/src/bin/herb_analysis.rs @@ -23,6 +23,8 @@ struct CLIOptions { only: Option>, built_ins: bool, help: bool, + version: bool, + no_color: bool, } fn main() { @@ -32,8 +34,19 @@ fn main() { fn run() -> i32 { let options = parse_args(env::args().collect()); - if options.help || options.command.is_none() { - show_help(); + if options.no_color { + colored::control::set_override(false); + } + + if options.help || (options.command.is_none() && !options.version) { + print_usage(); + + return 0; + } + + if options.version { + println!("herb-analysis {VERSION}"); + return 0; } @@ -45,7 +58,8 @@ fn run() -> i32 { Some("stats") => stats(&options), Some(other) => { eprintln!("{}", format!("Unknown command: {other}").red()); - show_help(); + print_usage(); + 1 } None => 0, @@ -61,6 +75,8 @@ fn parse_args(args: Vec) -> CLIOptions { match arg.as_str() { "--help" | "-h" => options.help = true, + "--version" | "-v" => options.version = true, + "--no-color" => options.no_color = true, "--built-ins" => options.built_ins = true, "--roots" => { index += 1; @@ -591,35 +607,38 @@ fn plural(count: usize, noun: &str) -> String { } } -fn show_help() { - let commands = [ - ("helpers [path] [--only app,gem,rails,route]", "what a template can call, grouped by origin"), - (" [--roots A,B]", ""), - ( - "audit [path] [--gem G] [--include-internal]", - "cross-check Herb's Action View helper registry", - ), - ("ancestors [path] [--built-ins]", "ancestor chain and whether it is complete"), - ("constants [NAME] [--nesting A::B] [path]", "list constants, or resolve one against a nesting"), - ("stats [path]", "index counts and per-phase timings"), - ]; - - let width = commands.iter().map(|(usage, _)| usage.len()).max().unwrap_or(0); - +fn print_usage() { + println!("herb-analysis {VERSION} - Cross-file static analysis for Ruby and Action View"); println!(); - println!(" {} {}", "herb-analysis".bold(), format!("v{VERSION}").dimmed()); + println!("Usage: herb-analysis [path] [options]"); println!(); - - for (usage, description) in commands { - if description.is_empty() { - println!(" {}", usage.dimmed()); - } else { - println!(" {usage:width$} {description}"); - } - } - + println!("Arguments:"); + println!(" path Directory to index, defaults to the current directory"); + println!(" Must start with . or / so it is not read as a name"); + println!(); + println!("Commands:"); + println!(" helpers List everything a template can call, grouped by origin"); + println!(" audit Cross-check Herb's Action View helper registry against real sources"); + println!(" ancestors Show a module's ancestor chain and whether it is complete"); + println!(" constants List constants, or resolve one against a lexical nesting"); + println!(" stats Show index counts and per-phase timings"); println!(); - println!(" {}", "a path defaults to the current directory and must start with . or /".dimmed()); - println!(" {}", "without --roots, every indexed *Helper module is used".dimmed()); + println!("Options:"); + println!(" -h, --help show help"); + println!(" -v, --version show version"); + println!(" --roots limit to these modules instead of every *Helper module"); + println!(" --only limit `helpers` to app, gem, rails, and/or route"); + println!(" --gem limit the registry comparison in `audit` to one gem"); + println!(" --include-internal include registry entries marked internal in `audit`"); + println!(" --nesting lexical nesting to resolve a constant against"); + println!(" --built-ins seed core class data before resolving ancestors"); + println!(" --no-color disable colored output"); println!(); + println!("Examples:"); + println!(" herb-analysis helpers # everything a template in this app can call"); + println!(" herb-analysis helpers --only app # just the app's own helpers"); + println!(" herb-analysis helpers ../some/gem # any directory, listed flat"); + println!(" herb-analysis audit --gem actionview # check the registry against Action View"); + println!(" herb-analysis ancestors ActionView::Base # what a view inherits from"); + println!(" herb-analysis constants CONFIG --nesting Admin::UsersController"); } diff --git a/rust/herb-analysis/src/report.rs b/rust/herb-analysis/src/report.rs index d4d713c00..708357f9e 100644 --- a/rust/herb-analysis/src/report.rs +++ b/rust/herb-analysis/src/report.rs @@ -18,12 +18,12 @@ pub struct Diff { } impl Diff { - pub fn new(found: &BTreeMap, oracle: &BTreeSet) -> Self { + pub fn new(found: &BTreeMap, expected: &BTreeSet) -> Self { let mut matched = BTreeSet::new(); let mut missing = BTreeSet::new(); let mut extra = BTreeMap::new(); - for name in oracle { + for name in expected { if found.contains_key(name) { matched.insert(name.clone()); } else { @@ -32,7 +32,7 @@ impl Diff { } for (name, owner) in found { - if !oracle.contains(name) { + if !expected.contains(name) { extra.insert(name.clone(), owner.clone()); } }