From 3445e59fe3c00418c86bfca664919c6197e1ad82 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 21 Apr 2026 14:20:57 +0900 Subject: [PATCH] ActionView: Dependency analysis, state tracing, reverse node index --- .rubocop.yml | 4 + lib/herb/action_view/render_analyzer.rb | 74 +++ lib/herb/action_view/template_dependencies.rb | 598 ++++++++++++++++++ lib/herb/cli.rb | 137 +++- sig/herb/action_view/render_analyzer.rbs | 4 + .../action_view/template_dependencies.rbs | 150 +++++ .../action_view/template_dependencies_test.rb | 345 ++++++++++ 7 files changed, 1309 insertions(+), 3 deletions(-) create mode 100644 lib/herb/action_view/template_dependencies.rb create mode 100644 sig/herb/action_view/template_dependencies.rbs create mode 100644 test/action_view/template_dependencies_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index 90423f3f3..ce9b0259c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -57,6 +57,10 @@ Style/AccessorGrouping: Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: diff_comma +Metrics/BlockNesting: + Exclude: + - lib/herb/cli.rb + Metrics/CyclomaticComplexity: Max: 15 Exclude: diff --git a/lib/herb/action_view/render_analyzer.rb b/lib/herb/action_view/render_analyzer.rb index 61a10739b..9093edb11 100644 --- a/lib/herb/action_view/render_analyzer.rb +++ b/lib/herb/action_view/render_analyzer.rb @@ -51,6 +51,9 @@ def check! print_results(result, duration) + warnings = check_dependencies(erb_files, view_root) + print_dependency_warnings(warnings) if warnings.any? + result.issues? end @@ -673,6 +676,77 @@ def print_results(result, duration) puts "" end + def check_dependencies(erb_files, view_root) + require_relative "template_dependencies" + + dep_analyzer = TemplateDependencies.new(@project_path) + dep_analyzer.scan_helpers! + + warnings = [] #: Array[Hash[Symbol, untyped]] + + erb_files.each do |file| + result = dep_analyzer.analyze(file) + is_partial = File.basename(file).start_with?("_") + relative = relative_path(file) + + if is_partial && result.instance_variables.any? + result.instance_variables.each do |ivar| + warnings << { type: :ivar_in_partial, file: relative, ivar: ivar } + end + end + + if result.unknown_calls.any? + result.unknown_calls.each do |call| + warnings << { type: :unknown_call, file: relative, call: call } + end + end + end + + warnings + rescue StandardError => e + warn "Warning: Dependency analysis failed: #{e.message}" + [] #: Array[Hash[Symbol, untyped]] + end + + def print_dependency_warnings(warnings) + ivar_warnings = warnings.select { |w| w[:type] == :ivar_in_partial } + unknown_warnings = warnings.select { |w| w[:type] == :unknown_call } + + puts "" + puts " #{bold("Dependency warnings:")}" + puts "" + + if ivar_warnings.any? + grouped = ivar_warnings.group_by { |w| w[:file] } + puts " #{yellow("Instance variables in partials")} #{dimmed("(#{grouped.size} #{pluralize(grouped.size, "file")})")}" + puts " #{dimmed("Partials should receive data as locals for reactivity tracing.")}" + puts "" + + grouped.each do |file, file_warnings| + ivars = file_warnings.map { |w| w[:ivar] }.uniq.sort + puts " #{yellow(file)}" + ivars.each { |ivar| puts " #{dimmed(ivar)}" } + puts "" + end + end + + if unknown_warnings.any? + grouped = unknown_warnings.group_by { |w| w[:file] } + puts " #{yellow("Unknown method calls")} #{dimmed("(#{grouped.size} #{pluralize(grouped.size, "file")})")}" + puts " #{dimmed("Methods not in the ActionView helper registry or app/helpers/.")}" + puts "" + + grouped.each do |file, file_warnings| + calls = file_warnings.map { |w| w[:call] }.uniq.sort + puts " #{yellow(file)}" + calls.each { |call| puts " #{dimmed(call)}" } + puts "" + end + + puts "" + end + end + def find_erb_files patterns = configuration.file_include_patterns exclude = configuration.file_exclude_patterns diff --git a/lib/herb/action_view/template_dependencies.rb b/lib/herb/action_view/template_dependencies.rb new file mode 100644 index 000000000..492121101 --- /dev/null +++ b/lib/herb/action_view/template_dependencies.rb @@ -0,0 +1,598 @@ +# frozen_string_literal: true + +require "prism" +require "set" + +module Herb + module ActionView + class TemplateDependencies + Result = Data.define( + :file, + :instance_variables, + :constants, + :locals_declared, + :locals_received, + :render_calls, + :helper_calls, + :unknown_calls + ) + + def initialize(project_path) + @project_path = Pathname.new(project_path) + @view_root = find_view_root + @helper_registry = load_helper_registry + @custom_helpers = Set.new + end + + def analyze(file_path) + file_path = @project_path.join(file_path).to_s unless Pathname.new(file_path).absolute? + source = File.read(file_path) + + ast = ::Herb.parse(source, render_nodes: true, strict_locals: true, prism_nodes: true, track_whitespace: true).value + + known_helpers = @custom_helpers.dup + component_methods_for(file_path).each { |m| known_helpers.add(m) } + + prescan = LocalScanner.new + ast.accept(prescan) + + collector = DependencyCollector.new(@helper_registry, known_helpers, prescan.locals) + ast.accept(collector) + + Result.new( + file: file_path, + instance_variables: collector.instance_variables.to_a.sort, + constants: collector.constants.to_a.sort, + locals_declared: collector.locals_declared.to_a.sort, + locals_received: collector.locals_received, + render_calls: collector.render_calls, + helper_calls: collector.helper_calls.to_a.sort, + unknown_calls: collector.unknown_calls.to_a.sort + ) + end + + def analyze_all(erb_files = nil) + erb_files ||= find_erb_files + results = {} #: Hash[String, Result] + + erb_files.each do |file| + results[file] = analyze(file) + end + + results + end + + def affected_templates(entry_point, state) + entry_point = @project_path.join(entry_point).to_s unless Pathname.new(entry_point).absolute? + + all = {} #: Hash[String, Result] + + reachable = collect_reachable_files(entry_point) + reachable.each { |file| all[file] = analyze(file) } + + affected = Set.new #: Set[String] + partial_to_file = build_partial_to_file_map(reachable) + entry_result = all[entry_point] + + return [] unless entry_result + + if entry_result.instance_variables.include?(state) || entry_result.constants.include?(state) + affected.add(entry_point) + else + return [] + end + + state_locals = {} #: Hash[String, Set[String]] + reachable.each { |file| state_locals[file] = Set.new } + state_locals[entry_point].add(state) + + queue = [entry_point] + visited = Set.new #: Set[String] + + while (file = queue.shift) + next if visited.include?(file) + + visited.add(file) + result = all[file] + next unless result + + carrying = state_locals[file] + + result.render_calls.each do |call| + flowing_locals = {} #: Hash[String, bool] + + call[:locals].each do |local_name, value_expr| + flows = carrying.any? { |name| expression_references?(value_expr, name) } + flowing_locals[local_name] = true if flows + end + + collection_flows = call[:collection] && carrying.any? { |name| expression_references?(call[:collection], name) } + + next unless flowing_locals.any? || collection_flows + + partial_files = partial_to_file[call[:partial]] || [] + + partial_files.each do |partial_file| + state_locals[partial_file] ||= Set.new + flowing_locals.each_key { |local_name| state_locals[partial_file].add(local_name) } + + if collection_flows && call[:partial] + item_name = File.basename(call[:partial]) + state_locals[partial_file].add(item_name) + end + + unless affected.include?(partial_file) + affected.add(partial_file) + queue << partial_file + end + end + end + end + + affected.to_a.sort + end + + def affected_nodes(file_path, state) + file_path = @project_path.join(file_path).to_s unless Pathname.new(file_path).absolute? + source = File.read(file_path) + + ast = ::Herb.parse(source, render_nodes: true, strict_locals: true, prism_nodes: true, track_whitespace: true).value + + collector = NodeDependencyCollector.new(state, @helper_registry, @custom_helpers) + ast.accept(collector) + + collector.affected + end + + def dependency_index(file_path) + file_path = @project_path.join(file_path).to_s unless Pathname.new(file_path).absolute? + result = analyze(file_path) + + index = {} #: Hash[String, Array[Hash[Symbol, untyped]]] + + (result.instance_variables + result.constants).each do |state| + nodes = affected_nodes(file_path, state) + index[state] = nodes if nodes.any? + end + + index + end + + def scan_helpers! + helpers_dir = @project_path.join("app", "helpers") + + if helpers_dir.directory? + Dir[helpers_dir.join("**", "*.rb")].each do |file| + extract_helper_methods(file).each { |name| @custom_helpers.add(name) } + end + end + + @custom_helpers + end + + private + + def collect_reachable_files(entry_point) + reachable = Set.new([entry_point]) #: Set[String] + queue = [entry_point] + all_partials = build_partial_to_file_map(find_erb_files) + + while (file = queue.shift) + result = analyze(file) + + result.render_calls.each do |call| + partial_files = all_partials[call[:partial]] || [] + + partial_files.each do |partial_file| + unless reachable.include?(partial_file) + reachable.add(partial_file) + queue << partial_file + end + end + end + end + + reachable.to_a + end + + def component_methods_for(template_path) + rb_path = template_path.sub(/\.html\.erb\z/, ".rb").sub(/\.erb\z/, ".rb") + return [] unless File.exist?(rb_path) + + extract_helper_methods(rb_path) + end + + def find_erb_files + Dir[@project_path.join("app", "views", "**", "*.erb")].sort + end + + def build_partial_to_file_map(files) + map = {} #: Hash[String, Array[String]] + + files.each do |file| + basename = File.basename(file) + next unless basename.start_with?("_") + + relative = Pathname.new(file).relative_path_from(@view_root).to_s + directory = File.dirname(relative) + name = basename.sub(/\A_/, "").sub(/\.html\.erb\z/, "").sub(/\.erb\z/, "").sub(/\.\w+\.erb\z/, "") + partial_name = directory == "." ? name : "#{directory}/#{name}" + + map[partial_name] ||= [] #: Array[String] + map[partial_name] << file + end + + map + end + + def expression_references?(expression, name) + return false unless expression && name + + if name.start_with?("@") + expression.include?(name) + else + expression.match?(/\b#{Regexp.escape(name)}\b/) + end + end + + def extract_helper_methods(file) + methods = [] #: Array[String] + source = File.read(file) + result = Prism.parse(source) + + walk_for_defs(result.value, methods) + methods + rescue StandardError + [] #: Array[String] + end + + def walk_for_defs(node, methods) + if node.is_a?(Prism::DefNode) + methods << node.name.to_s + end + + node.child_nodes.compact.each { |child| walk_for_defs(child, methods) } + end + + def load_helper_registry + require_relative "helper_registry" + names = Set.new #: Set[String] + + HelperRegistry.entries.each do |entry| + names.add(entry.name.to_s) + end + + names + end + + def find_view_root + @project_path.join("app", "views") + end + end + + class DependencyCollector < ::Herb::Visitor + attr_reader :instance_variables, :constants, :locals_declared, + :locals_received, :helper_calls, :unknown_calls, :render_calls + + def initialize(helper_registry, custom_helpers, prescanned_locals = Set.new) + super() + @helper_registry = helper_registry + @custom_helpers = custom_helpers + @instance_variables = Set.new + @constants = Set.new + @locals_declared = Set.new + @locals_received = {} #: Hash[String, String] + @helper_calls = Set.new + @unknown_calls = Set.new + @known_locals = prescanned_locals.dup + @render_calls = [] #: Array[Hash[Symbol, untyped]] + end + + def visit_erb_node(node) + analyze_erb_node(node) + end + + def visit_erb_render_node(node) + locals = {} #: Hash[String, String] + + node.keywords&.locals&.each do |local| + name = local.name&.value + value = local.value&.content + + next unless name && value + + value = name if value == "#{name}:" + @locals_received[name] = value + locals[name] = value + + analyze_ruby_expression(value) + end + + if node.static_partial? + @render_calls << { + partial: node.partial_path, + locals: locals, + collection: node.keywords&.collection&.value + } + end + + if node.keywords&.collection + analyze_ruby_expression(node.keywords.collection.value) + end + + super + end + + def visit_erb_strict_locals_node(node) + node.locals&.each do |param| + @locals_declared.add(param.name&.value) if param.name&.value + @known_locals.add(param.name&.value) if param.name&.value + end + + super + end + + private + + def analyze_erb_node(erb_node) + prism_node = erb_node.deserialized_prism_node if erb_node.respond_to?(:deserialized_prism_node) + + if prism_node + walk_prism_node(prism_node) + else + analyze_ruby_expression(erb_node.content&.value&.strip) + end + rescue StandardError + nil + end + + def analyze_ruby_expression(code) + return if code.nil? || code.empty? + + result = Prism.parse(code) + return if result.errors.any? + + walk_prism_node(result.value) + rescue StandardError + nil + end + + def walk_prism_node(node) + case node + when Prism::InstanceVariableReadNode + @instance_variables.add(node.name.to_s) + when Prism::LocalVariableWriteNode, + Prism::LocalVariableOrWriteNode, + Prism::LocalVariableAndWriteNode, + Prism::LocalVariableOperatorWriteNode + @known_locals.add(node.name.to_s) + when Prism::BlockParameterNode, Prism::RequiredParameterNode + @known_locals.add(node.name.to_s) + when Prism::ConstantReadNode + # Standalone constants are just references, not state + when Prism::CallNode + check_call_node(node) + when Prism::LocalVariableReadNode + @known_locals.add(node.name.to_s) + end + + node.child_nodes.compact.each { |child| walk_prism_node(child) } + end + + def check_call_node(node) + name = node.name.to_s + + if node.receiver.nil? + if @helper_registry.include?(name) + @helper_calls.add(name) + elsif @custom_helpers.include?(name) + @helper_calls.add(name) + elsif name == "render" + # render calls are handled by visit_erb_render_node + elsif !@known_locals.include?(name) && !@locals_received.key?(name) && !@locals_declared.include?(name) + @unknown_calls.add(name) + end + elsif node.receiver.is_a?(Prism::ConstantReadNode) + @constants.add("#{node.receiver.name}.#{name}") + end + end + end + + class NodeDependencyCollector < ::Herb::Visitor + attr_reader :affected + + def initialize(state, helper_registry, custom_helpers) + super() + @state = state + @helper_registry = helper_registry + @custom_helpers = custom_helpers + @affected = [] #: Array[Hash[Symbol, untyped]] + @path = [] #: Array[Integer] + @child_index = [] #: Array[Integer] + end + + def visit_document_node(node) + visit_children_with_paths(node.child_nodes) + end + + def visit_html_element_node(node) + visit_children_with_paths(node.child_nodes) + end + + def visit_html_open_tag_node(node) + node.child_nodes.each_with_index do |child, i| + if child.is_a?(Herb::AST::HTMLAttributeNode) + check_attribute(child, @path + [i]) + end + end + end + + def visit_erb_content_node(node) + check_erb_expression(node, :text_content) + end + + def visit_erb_if_node(node) + check_block_for_state(node, :conditional) + end + + def visit_erb_unless_node(node) + check_block_for_state(node, :conditional) + end + + def visit_erb_case_node(node) + check_block_for_state(node, :conditional) + end + + def visit_erb_node(node) + check_erb_expression(node, :expression) + end + + def visit_erb_render_node(node) + check_erb_expression(node, :render) + end + + private + + def visit_children_with_paths(children) + return unless children + + children.each_with_index do |child, index| + @path.push(index) + visit(child) + @path.pop + end + end + + def check_block_for_state(node, type) + all_content = collect_all_expressions(node) + + if all_content.any? { |code| references_state?(code) } + location = node.location + condition = node.content&.value&.strip + + @affected << { + node_path: @path.dup, + type: type, + expression: condition, + location: location ? "#{location.start.line}:#{location.start.column}" : nil + } + end + + visit_children_with_paths(node.child_nodes) + end + + def collect_all_expressions(node) + expressions = [] #: Array[String] + + if node.respond_to?(:content) && node.content + value = node.content.respond_to?(:value) ? node.content.value&.strip : nil + + expressions << value if value && !value.empty? # steep:ignore + end + + children = node.respond_to?(:child_nodes) ? node.child_nodes.compact : [] # steep:ignore + children.each { |child| expressions.concat(collect_all_expressions(child)) } + + expressions + end + + def check_erb_expression(node, type) + code = node.content&.value&.strip + return unless code + + if references_state?(code) + location = node.location + + @affected << { + node_path: @path.dup, + type: type, + expression: code, + location: location ? "#{location.start.line}:#{location.start.column}" : nil + } + end + end + + def check_attribute(attribute_node, path) + attribute_name = nil + + attribute_node.child_nodes.each do |child| + if child.is_a?(Herb::AST::HTMLAttributeNameNode) + first = child.child_nodes&.first + + attribute_name = if first.respond_to?(:content) + content = first.content # steep:ignore + content.respond_to?(:value) ? content.value : content.to_s + end + end + + next unless child.is_a?(Herb::AST::HTMLAttributeValueNode) + + child.child_nodes&.each do |value_child| + next unless value_child.respond_to?(:content) && value_child.content # steep:ignore + + content = value_child.content # steep:ignore + code = (content.respond_to?(:value) ? content.value : content.to_s).strip + next unless code && !code.empty? && references_state?(code) + + location = value_child.location # steep:ignore + + @affected << { + node_path: path.dup, + type: :attribute_value, + attribute: attribute_name, + expression: code, + location: location ? "#{location.start.line}:#{location.start.column}" : nil + } + end + end + end + + def references_state?(code) + if @state.start_with?("@") + code.include?(@state) + elsif @state.include?(".") + constant = @state.split(".").first + code.include?(constant.to_s) + else + code.match?(/\b#{Regexp.escape(@state)}\b/) + end + end + end + + class LocalScanner < ::Herb::Visitor + attr_reader :locals + + def initialize + super + @locals = Set.new #: Set[String] + end + + def visit_erb_node(node) + return unless node.respond_to?(:deserialized_prism_node) + + pn = node.deserialized_prism_node + collect_locals(pn) if pn + rescue StandardError + nil + end + + private + + def collect_locals(node) + case node + when Prism::LocalVariableWriteNode, # title = ... + Prism::LocalVariableOrWriteNode, # title ||= ... + Prism::LocalVariableAndWriteNode, # title &&= ... + Prism::LocalVariableOperatorWriteNode # count += 1 + @locals.add(node.name.to_s) + when Prism::MultiWriteNode + node.lefts.each do |target| + @locals.add(target.name.to_s) if target.respond_to?(:name) # steep:ignore + end + end + + node.child_nodes.compact.each { |child| collect_locals(child) } + end + end + end +end diff --git a/lib/herb/cli.rb b/lib/herb/cli.rb index 588c26b14..0a2fc48cd 100644 --- a/lib/herb/cli.rb +++ b/lib/herb/cli.rb @@ -421,6 +421,96 @@ def run_actionview_command analyzer.graph! end + exit(0) + when "dependencies" + require_relative "action_view/template_dependencies" + + path = @file || "." + path = File.expand_path(path) + + if File.file?(path) + project_root = config.project_root&.to_s || File.dirname(path) + dep_analyzer = Herb::ActionView::TemplateDependencies.new(project_root) + dep_analyzer.scan_helpers! + result = dep_analyzer.analyze(path) + relative = Pathname.new(path).relative_path_from(project_root).to_s + is_entry_point = !File.basename(path).start_with?("_") + + puts "" + puts " #{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}" + puts "" + puts " #{cyan(relative)} #{dimmed(is_entry_point ? "(entry point)" : "(partial)")}" + puts "" + + print_dependency_result(result) + + if is_entry_point && result.instance_variables.any? + puts " #{bold("State flow")} #{dimmed("(which templates are affected by each state change)")}" + puts "" + + result.instance_variables.each do |ivar| + affected = dep_analyzer.affected_templates(path, ivar) + short = affected.map { |f| Pathname.new(f).relative_path_from(project_root).to_s } + + puts " #{yellow(ivar)} #{dimmed("(#{short.size} #{short.size == 1 ? "template" : "templates"})")}" + short.each { |f| puts " #{dimmed(f)}" } + puts "" + end + + # Show node-level dependency index + index = dep_analyzer.dependency_index(path) + + if index.any? + puts " #{bold("Node index")} #{dimmed("(which DOM nodes are affected by each state change)")}" + puts "" + + index.each do |state, nodes| + puts " #{yellow(state)} #{dimmed("(#{nodes.size} #{nodes.size == 1 ? "node" : "nodes"})")}" + + nodes.each_with_index do |n, i| + connector = i == nodes.size - 1 ? "\u2514\u2500\u2500" : "\u251c\u2500\u2500" + attr = n[:attribute] ? " #{dimmed("attr=#{n[:attribute]}")}" : "" + puts " #{connector} #{dimmed("[#{n[:node_path].join(",")}]")} #{n[:type]}#{attr} #{dimmed(n[:expression].to_s[0..60])}" + end + + puts "" + end + end + end + elsif File.directory?(path) + require_relative "action_view/render_analyzer" + + dep_analyzer = Herb::ActionView::TemplateDependencies.new(path) + dep_analyzer.scan_helpers! + + render_analyzer = Herb::ActionView::RenderAnalyzer.new(path) + erb_files = render_analyzer.send(:find_erb_files) + + puts "" + puts " #{bold("Herb")} \u{1f33f} #{dimmed("v#{Herb::VERSION}")}" + puts "" + puts dimmed(" Analyzing dependencies in #{erb_files.size} files...") + puts "" + + erb_files.each do |file| + result = dep_analyzer.analyze(file) + relative = Pathname.new(file).relative_path_from(path).to_s + + next if result.instance_variables.empty? && result.constants.empty? && result.unknown_calls.empty? + + puts " #{cyan(relative)}" + puts " #{dimmed("state:")} #{result.instance_variables.join(", ")}" if result.instance_variables.any? + puts " #{dimmed("constants:")} #{result.constants.join(", ")}" if result.constants.any? + puts " #{dimmed("locals declared:")} #{result.locals_declared.join(", ")}" if result.locals_declared.any? + puts " #{dimmed("locals received:")} #{result.locals_received.keys.join(", ")}" if result.locals_received.any? + puts " #{dimmed("unknown:")} #{result.unknown_calls.join(", ")}" if result.unknown_calls.any? + puts "" + end + else + puts "Not a file or directory: '#{path}'." + exit(1) + end + exit(0) when "render" @file = @args[2] @@ -433,14 +523,17 @@ def run_actionview_command bundle exec herb actionview [subcommand] [options] Subcommands: - check [path] Check if render calls resolve to valid partial files - graph [path] Show render dependency graph for a project or file - render [file] Render ERB template using ActionView helpers + check [path] Check render calls and flag dependency warnings + graph [path] Show render dependency graph for a project or file + dependencies [path] Show template dependency manifest (state, locals, helpers) + render [file] Render ERB template using ActionView helpers Examples: bundle exec herb actionview check bundle exec herb actionview graph bundle exec herb actionview graph app/views/posts/show.html.erb + bundle exec herb actionview dependencies app/views/posts/show.html.erb + bundle exec herb actionview dependencies bundle exec herb actionview render app/views/posts/show.html.erb HELP @@ -452,6 +545,44 @@ def run_actionview_command end end + def print_dependency_result(result) + if result.instance_variables.any? + puts " #{bold("Instance variables")} #{dimmed("(state)")}" + result.instance_variables.each { |v| puts " #{v}" } + puts "" + end + + if result.constants.any? + puts " #{bold("Constants")}" + result.constants.each { |c| puts " #{c}" } + puts "" + end + + if result.locals_declared.any? + puts " #{bold("Locals declared")} #{dimmed("(strict locals)")}" + result.locals_declared.each { |l| puts " #{l}" } + puts "" + end + + if result.locals_received.any? + puts " #{bold("Locals received")} #{dimmed("(from render calls)")}" + result.locals_received.each { |name, value| puts " #{name} #{dimmed("\u2190")} #{value}" } + puts "" + end + + if result.helper_calls.any? + puts " #{bold("Helper calls")} #{dimmed("(known)")}" + result.helper_calls.each { |h| puts " #{dimmed(h)}" } + puts "" + end + + return unless result.unknown_calls.any? + + puts " #{bold("Unknown calls")}" + result.unknown_calls.each { |u| puts " #{yellow(u)}" } + puts "" + end + def actionview_render require "action_view" diff --git a/sig/herb/action_view/render_analyzer.rbs b/sig/herb/action_view/render_analyzer.rbs index 3bde525fd..3d927df74 100644 --- a/sig/herb/action_view/render_analyzer.rbs +++ b/sig/herb/action_view/render_analyzer.rbs @@ -66,6 +66,10 @@ module Herb def print_results: (untyped result, untyped duration) -> untyped + def check_dependencies: (untyped erb_files, untyped view_root) -> untyped + + def print_dependency_warnings: (untyped warnings) -> untyped + def find_erb_files: () -> untyped def find_view_root: () -> untyped diff --git a/sig/herb/action_view/template_dependencies.rbs b/sig/herb/action_view/template_dependencies.rbs new file mode 100644 index 000000000..eed053401 --- /dev/null +++ b/sig/herb/action_view/template_dependencies.rbs @@ -0,0 +1,150 @@ +# Generated from lib/herb/action_view/template_dependencies.rb with RBS::Inline + +module Herb + module ActionView + class TemplateDependencies + class Result < Data + attr_reader file(): untyped + + attr_reader instance_variables(): untyped + + attr_reader constants(): untyped + + attr_reader locals_declared(): untyped + + attr_reader locals_received(): untyped + + attr_reader render_calls(): untyped + + attr_reader helper_calls(): untyped + + attr_reader unknown_calls(): untyped + + def self.new: (untyped file, untyped instance_variables, untyped constants, untyped locals_declared, untyped locals_received, untyped render_calls, untyped helper_calls, untyped unknown_calls) -> instance + | (file: untyped, instance_variables: untyped, constants: untyped, locals_declared: untyped, locals_received: untyped, render_calls: untyped, helper_calls: untyped, unknown_calls: untyped) -> instance + + def self.members: () -> [ :file, :instance_variables, :constants, :locals_declared, :locals_received, :render_calls, :helper_calls, :unknown_calls ] + + def members: () -> [ :file, :instance_variables, :constants, :locals_declared, :locals_received, :render_calls, :helper_calls, :unknown_calls ] + end + + def initialize: (untyped project_path) -> untyped + + def analyze: (untyped file_path) -> untyped + + def analyze_all: (?untyped erb_files) -> untyped + + def affected_templates: (untyped entry_point, untyped state) -> untyped + + def affected_nodes: (untyped file_path, untyped state) -> untyped + + def dependency_index: (untyped file_path) -> untyped + + def scan_helpers!: () -> untyped + + private + + def collect_reachable_files: (untyped entry_point) -> untyped + + def component_methods_for: (untyped template_path) -> untyped + + def find_erb_files: () -> untyped + + def build_partial_to_file_map: (untyped files) -> untyped + + def expression_references?: (untyped expression, untyped name) -> untyped + + def extract_helper_methods: (untyped file) -> untyped + + def walk_for_defs: (untyped node, untyped methods) -> untyped + + def load_helper_registry: () -> untyped + + def find_view_root: () -> untyped + end + + class DependencyCollector < ::Herb::Visitor + attr_reader instance_variables: untyped + + attr_reader constants: untyped + + attr_reader locals_declared: untyped + + attr_reader locals_received: untyped + + attr_reader helper_calls: untyped + + attr_reader unknown_calls: untyped + + attr_reader render_calls: untyped + + def initialize: (untyped helper_registry, untyped custom_helpers, ?untyped prescanned_locals) -> untyped + + def visit_erb_node: (untyped node) -> untyped + + def visit_erb_render_node: (untyped node) -> untyped + + def visit_erb_strict_locals_node: (untyped node) -> untyped + + private + + def analyze_erb_node: (untyped erb_node) -> untyped + + def analyze_ruby_expression: (untyped code) -> untyped + + def walk_prism_node: (untyped node) -> untyped + + def check_call_node: (untyped node) -> untyped + end + + class NodeDependencyCollector < ::Herb::Visitor + attr_reader affected: untyped + + def initialize: (untyped state, untyped helper_registry, untyped custom_helpers) -> untyped + + def visit_document_node: (untyped node) -> untyped + + def visit_html_element_node: (untyped node) -> untyped + + def visit_html_open_tag_node: (untyped node) -> untyped + + def visit_erb_content_node: (untyped node) -> untyped + + def visit_erb_if_node: (untyped node) -> untyped + + def visit_erb_unless_node: (untyped node) -> untyped + + def visit_erb_case_node: (untyped node) -> untyped + + def visit_erb_node: (untyped node) -> untyped + + def visit_erb_render_node: (untyped node) -> untyped + + private + + def visit_children_with_paths: (untyped children) -> untyped + + def check_block_for_state: (untyped node, untyped type) -> untyped + + def collect_all_expressions: (untyped node) -> untyped + + def check_erb_expression: (untyped node, untyped type) -> untyped + + def check_attribute: (untyped attribute_node, untyped path) -> untyped + + def references_state?: (untyped code) -> untyped + end + + class LocalScanner < ::Herb::Visitor + attr_reader locals: untyped + + def initialize: () -> untyped + + def visit_erb_node: (untyped node) -> untyped + + private + + def collect_locals: (untyped node) -> untyped + end + end +end diff --git a/test/action_view/template_dependencies_test.rb b/test/action_view/template_dependencies_test.rb new file mode 100644 index 000000000..d68db2dc6 --- /dev/null +++ b/test/action_view/template_dependencies_test.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require_relative "../../lib/herb/action_view/template_dependencies" + +require "tmpdir" +require "fileutils" + +class TemplateDependenciesTest < Minitest::Spec + def setup + @project_path = Dir.mktmpdir("herb_deps_test") + @view_root = File.join(@project_path, "app", "views") + @helpers_dir = File.join(@project_path, "app", "helpers") + FileUtils.mkdir_p(File.join(@view_root, "posts")) + FileUtils.mkdir_p(File.join(@view_root, "shared")) + FileUtils.mkdir_p(@helpers_dir) + end + + def teardown + FileUtils.rm_rf(@project_path) + end + + def analyzer + Herb::ActionView::TemplateDependencies.new(@project_path) + end + + def write_template(path, content) + full_path = File.join(@view_root, path) + FileUtils.mkdir_p(File.dirname(full_path)) + File.write(full_path, content) + full_path + end + + def write_helper(path, content) + full_path = File.join(@helpers_dir, path) + File.write(full_path, content) + full_path + end + + test "detects instance variables" do + path = write_template("posts/show.html.erb", "

<%= @post.title %>

<%= @user.name %>

") + + result = analyzer.analyze(path) + + assert_includes result.instance_variables, "@post" + assert_includes result.instance_variables, "@user" + end + + test "detects constants with method calls" do + path = write_template("posts/show.html.erb", "<%= Current.user %><%= Post.count %>") + + result = analyzer.analyze(path) + + assert_includes result.constants, "Current.user" + assert_includes result.constants, "Post.count" + end + + test "detects strict locals" do + path = write_template("posts/_card.html.erb", "<%# locals: (title:, body:) %>\n

<%= title %>

") + + result = analyzer.analyze(path) + + assert_includes result.locals_declared, "title" + assert_includes result.locals_declared, "body" + end + + test "detects locals passed to render calls" do + write_template("shared/_header.html.erb", "

Header

") + path = write_template("posts/show.html.erb", '<%= render "shared/header", title: @post.title %>') + + result = analyzer.analyze(path) + + assert_equal "@post.title", result.locals_received["title"] + end + + test "detects known ActionView helpers" do + path = write_template("posts/show.html.erb", '<%= link_to "Home", "/" %>') + + result = analyzer.analyze(path) + + assert_includes result.helper_calls, "link_to" + end + + test "detects custom helpers after scanning" do + write_helper("application_helper.rb", <<~RUBY) + module ApplicationHelper + def markdown(text) + text + end + end + RUBY + + path = write_template("posts/show.html.erb", "<%= markdown(@post.body) %>") + + a = analyzer + a.scan_helpers! + result = a.analyze(path) + + assert_includes result.helper_calls, "markdown" + refute_includes result.unknown_calls, "markdown" + end + + test "flags unknown method calls" do + path = write_template("posts/show.html.erb", "<%= current_user.name %>") + + result = analyzer.analyze(path) + + assert_includes result.unknown_calls, "current_user" + end + + test "does not flag declared locals as unknown" do + path = write_template("posts/_card.html.erb", "<%# locals: (title:) %>\n<%= title %>") + + result = analyzer.analyze(path) + + assert_empty result.unknown_calls + assert_includes result.locals_declared, "title" + end + + test "detects instance variables in conditionals" do + path = write_template("posts/show.html.erb", "<% if @admin %>

Admin

<% end %>") + + result = analyzer.analyze(path) + + assert_includes result.instance_variables, "@admin" + end + + test "detects constants in conditionals" do + path = write_template("posts/show.html.erb", "<% if Current.user %>

Logged in

<% end %>") + + result = analyzer.analyze(path) + + assert_includes result.constants, "Current.user" + end + + test "tracks instance variables from render local values" do + write_template("shared/_header.html.erb", "

Header

") + path = write_template("posts/show.html.erb", '<%= render "shared/header", user: @current_user %>') + + result = analyzer.analyze(path) + + assert_includes result.instance_variables, "@current_user" + assert_equal "@current_user", result.locals_received["user"] + end + + test "detects collection expression dependencies" do + write_template("posts/_post.html.erb", "
Post
") + path = write_template("posts/index.html.erb", '<%= render partial: "posts/post", collection: @posts %>') + + result = analyzer.analyze(path) + + assert_includes result.instance_variables, "@posts" + end + + test "instance variables are deduplicated" do + path = write_template("posts/show.html.erb", "<%= @post.title %><%= @post.body %><%= @post.author %>") + + result = analyzer.analyze(path) + + assert_equal(1, result.instance_variables.count { |v| v == "@post" }) + end + + test "tracks render calls with partials and locals" do + write_template("shared/_header.html.erb", "

Header

") + path = write_template("posts/show.html.erb", '<%= render "shared/header", title: @post.title, user: @user %>') + + result = analyzer.analyze(path) + + assert_equal 1, result.render_calls.size + assert_equal "shared/header", result.render_calls.first[:partial] + assert_equal "@post.title", result.render_calls.first[:locals]["title"] + assert_equal "@user", result.render_calls.first[:locals]["user"] + end + + test "does not flag template-defined locals as unknown" do + path = write_template("posts/show.html.erb", "<% title = @post.title %>\n<%= title %>") + + result = analyzer.analyze(path) + + refute_includes result.unknown_calls, "title" + assert_includes result.instance_variables, "@post" + end + + test "does not flag block parameters as unknown" do + path = write_template("posts/index.html.erb", "<% @posts.each do |post| %>\n<%= post.name %>\n<% end %>") + + result = analyzer.analyze(path) + + refute_includes result.unknown_calls, "post" + assert_includes result.instance_variables, "@posts" + end + + test "does not flag nested block parameters as unknown" do + path = write_template("posts/index.html.erb", "<% @posts.each_with_index do |post, index| %>\n<%= post.name %><%= index %>\n<% end %>") + + result = analyzer.analyze(path) + + refute_includes result.unknown_calls, "post" + refute_includes result.unknown_calls, "index" + end + + test "detects instance variables inside string interpolation" do + path = write_template("posts/show.html.erb", '<%= "Hello #{@user.name}" %>') + + result = analyzer.analyze(path) + + assert_includes result.instance_variables, "@user" + end + + test "conditional assignment registers as local" do + path = write_template("posts/show.html.erb", "<% title ||= \"Default\" %>\n<%= title %>") + + result = analyzer.analyze(path) + + refute_includes result.unknown_calls, "title" + end + + test "operator assignment registers as local" do + path = write_template("posts/show.html.erb", "<% count += 1 %>\n<%= count %>") + + result = analyzer.analyze(path) + + refute_includes result.unknown_calls, "count" + end + + test "multiple assignment registers all locals" do + path = write_template("posts/show.html.erb", "<% a, b = [1, 2] %>\n<%= a %><%= b %>") + + result = analyzer.analyze(path) + + refute_includes result.unknown_calls, "a" + refute_includes result.unknown_calls, "b" + end + + test "detects multiple instance variables in ternary" do + path = write_template("posts/show.html.erb", '<%= @admin ? @post.title : "Hidden" %>') + + result = analyzer.analyze(path) + + assert_includes result.instance_variables, "@admin" + assert_includes result.instance_variables, "@post" + end + + test "block parameters are scoped and not treated as template-wide locals" do + path = write_template("posts/index.html.erb", + "<%= user %>\n<% @users.each do |user| %>\n<%= user %>\n<% end %>\n<%= user %>") + + result = analyzer.analyze(path) + + assert_includes result.unknown_calls, "user" + assert_includes result.instance_variables, "@users" + end + + test "affected_templates traces state through render graph" do + entry = write_template("posts/show.html.erb", '<%= @post.title %><%= render "posts/header", post: @post %>') + write_template("posts/_header.html.erb", "

<%= post.name %>

") + + a = analyzer + affected = a.affected_templates(entry, "@post") + + assert_includes affected, File.join(@view_root, "posts/show.html.erb") + assert_includes affected, File.join(@view_root, "posts/_header.html.erb") + end + + test "affected_templates does not include unrelated templates" do + entry = write_template("posts/show.html.erb", '<%= @post.title %><%= render "posts/header", post: @post %>') + write_template("posts/_header.html.erb", "

<%= post.name %>

") + write_template("pages/about.html.erb", "

About

") + + a = analyzer + affected = a.affected_templates(entry, "@post") + + refute_includes affected, File.join(@view_root, "pages/about.html.erb") + end + + test "affected_templates traces through nested renders" do + entry = write_template("posts/show.html.erb", '<%= render "posts/header", post: @post %>') + write_template("posts/_header.html.erb", '<%= render "posts/title", title: post.title %>') + write_template("posts/_title.html.erb", "

<%= title %>

") + + a = analyzer + affected = a.affected_templates(entry, "@post") + + assert_includes affected, File.join(@view_root, "posts/show.html.erb") + assert_includes affected, File.join(@view_root, "posts/_header.html.erb") + assert_includes affected, File.join(@view_root, "posts/_title.html.erb") + end + + test "affected_templates handles constants" do + entry = write_template("posts/index.html.erb", "<%= Post.count %>") + + a = analyzer + affected = a.affected_templates(entry, "Post.count") + + assert_includes affected, File.join(@view_root, "posts/index.html.erb") + end + + test "dependency_index maps state to affected nodes" do + path = write_template("posts/show.html.erb", "

<%= @post.title %>

<%= @post.body %>

") + + a = analyzer + index = a.dependency_index(path) + + assert index.key?("@post") + assert_equal 2, index["@post"].size + assert_equal :text_content, index["@post"][0][:type] + assert_equal :text_content, index["@post"][1][:type] + end + + test "dependency_index includes attribute nodes" do + path = write_template("posts/show.html.erb", '
">Content
') + + a = analyzer + index = a.dependency_index(path) + + assert index.key?("@active") + attr_node = index["@active"].find { |n| n[:type] == :attribute_value } + assert attr_node + assert_equal "class", attr_node[:attribute] + end + + test "dependency_index marks if-blocks containing state as conditional" do + path = write_template("posts/show.html.erb", "
<% if @admin %><%= @post.name %><% end %>
") + + a = analyzer + index = a.dependency_index(path) + + assert index.key?("@post") + types = index["@post"].map { |n| n[:type] } + assert_includes types, :conditional + assert_includes types, :text_content + + assert index.key?("@admin") + assert_equal :conditional, index["@admin"].first[:type] + end + + test "affected_templates returns empty when state not used in entry point" do + entry = write_template("posts/show.html.erb", "<%= @title %>") + + a = analyzer + affected = a.affected_templates(entry, "@post") + + assert_empty affected + end +end