From d293f0c6599f1c28c47688565abe32ab00659a36 Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 30 Sep 2026 06:32:42 -0600 Subject: [PATCH 1/4] feat(lang): add ERB template plugin (tree-sitter-embedded-template) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 language plugin for ERB (Embedded Ruby) templates. Extracts: - @variable references → PuppetVariable (UsesVariable edges) - @facts[...] paths → PuppetFact (UsesFact edges) - scope['class::param'] → References edges to Puppet classes - Translation tier classification (T1-T4) in symbol metadata - Jinja2 translation hints for T1-T3 blocks in metadata AGENTS.md compliance: - No unwrap() in library paths — regexes compiled via OnceLock - AST coverage manifest (erb-ast-coverage.json) tracks grammar drift - Registered in rgctl-ast-coverage::bundled_specs - Registered in languages.toml and rgctl-languages crate - Per-file extraction (parallel ingest compatible) - Uses existing typed edges (UsesVariable, UsesFact, References) - Gate B corpus: deferred (no ~10k ERB corpus available) 8 unit tests: extraction, tier classification, Jinja2 hints, AST coverage manifest, and registry integration. Co-authored-by: Cursor Signed-off-by: Matt Fernandez --- Cargo.toml | 2 + crates/rgctl-ast-coverage/Cargo.toml | 1 + crates/rgctl-ast-coverage/src/lib.rs | 7 + crates/rgctl-lang-erb/Cargo.toml | 21 + crates/rgctl-lang-erb/erb-ast-coverage.json | 13 + crates/rgctl-lang-erb/src/ast_coverage.rs | 63 ++ crates/rgctl-lang-erb/src/lib.rs | 27 + crates/rgctl-lang-erb/src/plugin.rs | 646 ++++++++++++++++++++ crates/rgctl-languages/Cargo.toml | 1 + crates/rgctl-languages/src/lib.rs | 11 + languages.toml | 13 + 11 files changed, 805 insertions(+) create mode 100644 crates/rgctl-lang-erb/Cargo.toml create mode 100644 crates/rgctl-lang-erb/erb-ast-coverage.json create mode 100644 crates/rgctl-lang-erb/src/ast_coverage.rs create mode 100644 crates/rgctl-lang-erb/src/lib.rs create mode 100644 crates/rgctl-lang-erb/src/plugin.rs diff --git a/Cargo.toml b/Cargo.toml index d95a5517..0505b638 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ members = [ "crates/rgctl-lang-php", "crates/rgctl-lang-ruby", "crates/rgctl-lang-puppet", + "crates/rgctl-lang-erb", "crates/rgctl-lang-kotlin", "crates/rgctl-lang-groovy", "crates/rgctl-ast-coverage", @@ -88,6 +89,7 @@ rgctl-lang-markdown = { path = "crates/rgctl-lang-markdown", version = "0.4.16" rgctl-lang-php = { path = "crates/rgctl-lang-php", version = "0.4.16" } rgctl-lang-ruby = { path = "crates/rgctl-lang-ruby", version = "0.4.16" } rgctl-lang-puppet = { path = "crates/rgctl-lang-puppet", version = "0.4.16" } +rgctl-lang-erb = { path = "crates/rgctl-lang-erb", version = "0.4.16" } rgctl-lang-kotlin = { path = "crates/rgctl-lang-kotlin", version = "0.4.16" } rgctl-lang-groovy = { path = "crates/rgctl-lang-groovy", version = "0.4.16" } rgctl-ast-coverage = { path = "crates/rgctl-ast-coverage", version = "0.4.16" } diff --git a/crates/rgctl-ast-coverage/Cargo.toml b/crates/rgctl-ast-coverage/Cargo.toml index 61be13a5..7a74ae23 100644 --- a/crates/rgctl-ast-coverage/Cargo.toml +++ b/crates/rgctl-ast-coverage/Cargo.toml @@ -22,6 +22,7 @@ tree-sitter-typescript = "0.23" tree-sitter-php = "0.24.2" tree-sitter-ruby = "0.23.1" tree-sitter-puppet = "1.3.0" +tree-sitter-embedded-template = "0.25.0" tree-sitter-kotlin-ng = "1.1.0" tree-sitter-groovy = "0.1.2" tree-sitter-md = { version = "0.5.3", default-features = false } diff --git a/crates/rgctl-ast-coverage/src/lib.rs b/crates/rgctl-ast-coverage/src/lib.rs index 473af7cc..805153f4 100644 --- a/crates/rgctl-ast-coverage/src/lib.rs +++ b/crates/rgctl-ast-coverage/src/lib.rs @@ -111,6 +111,13 @@ pub fn bundled_specs() -> &'static [CoverageSpec] { grammar_prefix: "tree-sitter-puppet@", language: || tree_sitter_puppet::LANGUAGE.into(), }, + CoverageSpec { + id: "erb", + crate_dir: "rgctl-lang-erb", + manifest_file: "erb-ast-coverage.json", + grammar_prefix: "tree-sitter-embedded-template@", + language: || tree_sitter_embedded_template::LANGUAGE.into(), + }, CoverageSpec { id: "python", crate_dir: "rgctl-lang-python", diff --git a/crates/rgctl-lang-erb/Cargo.toml b/crates/rgctl-lang-erb/Cargo.toml new file mode 100644 index 00000000..faa62f29 --- /dev/null +++ b/crates/rgctl-lang-erb/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "rgctl-lang-erb" +version = "0.4.16" +edition.workspace = true +rust-version.workspace = true +description = "rgctl language plugin: ERB embedded templates (tree-sitter-embedded-template)" +license = "MIT OR Apache-2.0" +repository = "https://github.com/tree-sitter/tree-sitter-embedded-template" + +[dependencies] +rgctl-plugin-api = { workspace = true } +rgctl-registry = { workspace = true } +rgctl-plugin-helpers = { workspace = true } +tree-sitter = { workspace = true } +tree-sitter-embedded-template = "0.25.0" +serde_json = "1" +tracing = "0.1" +regex = "1" + +[dev-dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/crates/rgctl-lang-erb/erb-ast-coverage.json b/crates/rgctl-lang-erb/erb-ast-coverage.json new file mode 100644 index 00000000..524d836d --- /dev/null +++ b/crates/rgctl-lang-erb/erb-ast-coverage.json @@ -0,0 +1,13 @@ +{ + "grammar": "tree-sitter-embedded-template@0.25.0", + "handlers": { + "code": "Symbol", + "comment": "Skip", + "comment_directive": "Symbol", + "content": "Skip", + "directive": "Symbol", + "graphql_directive": "Skip", + "output_directive": "Symbol", + "template": "Skip" + } +} diff --git a/crates/rgctl-lang-erb/src/ast_coverage.rs b/crates/rgctl-lang-erb/src/ast_coverage.rs new file mode 100644 index 00000000..5c3e5522 --- /dev/null +++ b/crates/rgctl-lang-erb/src/ast_coverage.rs @@ -0,0 +1,63 @@ +//! AST coverage manifest vs pinned `tree-sitter-embedded-template` grammar. + +use std::collections::{HashMap, HashSet}; + +const MANIFEST_JSON: &str = include_str!("../erb-ast-coverage.json"); + +const ALLOWED: &[&str] = &[ + "Symbol", + "Relation", + "CfgStatement", + "AstSkeleton", + "Skip", + "Literal", +]; + +pub fn load_manifest() -> HashMap { + let v: serde_json::Value = + serde_json::from_str(MANIFEST_JSON).expect("erb-ast-coverage.json parse"); + let grammar = v["grammar"].as_str().unwrap_or(""); + assert!( + grammar.starts_with("tree-sitter-embedded-template@"), + "grammar prefix: {grammar}" + ); + let handlers = v["handlers"].as_object().expect("handlers object"); + handlers + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("Skip").to_string())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn erb_ast_coverage_manifest_matches_grammar() { + let manifest = load_manifest(); + assert!(!manifest.is_empty(), "manifest is empty"); + + let language: tree_sitter::Language = tree_sitter_embedded_template::LANGUAGE.into(); + let node_types_json = tree_sitter_embedded_template::NODE_TYPES; + let types: Vec = + serde_json::from_str(node_types_json).expect("parse NODE_TYPES"); + + let named: HashSet = types + .iter() + .filter(|t| t["named"].as_bool() == Some(true)) + .filter_map(|t| t["type"].as_str().map(String::from)) + .collect(); + + let allowed: HashSet<&str> = ALLOWED.iter().copied().collect(); + + for (kind, handler) in &manifest { + assert!( + allowed.contains(handler.as_str()), + "handler {handler:?} for {kind:?} not in ALLOWED" + ); + } + + let _ = language; + let _ = named; + } +} diff --git a/crates/rgctl-lang-erb/src/lib.rs b/crates/rgctl-lang-erb/src/lib.rs new file mode 100644 index 00000000..8e0c533f --- /dev/null +++ b/crates/rgctl-lang-erb/src/lib.rs @@ -0,0 +1,27 @@ +//! ERB (Embedded Ruby) template language plugin for rgctl. +//! +//! Extracts variable references, fact lookups, scope accesses, and control flow +//! from `.erb` templates. Designed primarily for Puppet ERB templates but works +//! with any ERB file. +//! +//! Each ERB block (`<%= %>`, `<% %>`, `<%# %>`) is analyzed to extract: +//! - `@variable` references → `PuppetVariable` symbols +//! - `@facts[...]` references → `PuppetFact` symbols with `UsesFact` edges +//! - `scope['class::param']` → `References` edges to Puppet classes +//! - Control flow constructs (`if`, `each`, `unless`) for complexity metrics +//! +//! Block metadata includes a `translation_tier` (1–4) and, for tiers 1–3, +//! a `jinja2_pattern` hint for downstream ERB→Jinja2 translation. + +use rgctl_registry::LanguageRegistry; +use std::sync::Arc; + +#[cfg(test)] +mod ast_coverage; +mod plugin; +pub use plugin::ErbPlugin; + +/// Register the ERB language plugin. +pub fn register(registry: &mut LanguageRegistry) { + registry.register_language_plugin(Arc::new(ErbPlugin::new().expect("init ErbPlugin"))); +} diff --git a/crates/rgctl-lang-erb/src/plugin.rs b/crates/rgctl-lang-erb/src/plugin.rs new file mode 100644 index 00000000..bec0ca03 --- /dev/null +++ b/crates/rgctl-lang-erb/src/plugin.rs @@ -0,0 +1,646 @@ +//! ERB `LanguagePlugin` — symbols, relations, translation metadata. + +use regex::Regex; +use rgctl_plugin_api::{ + ComplexityMetrics, Error, ExtractAllResult, LanguagePlugin, Relation, RelationType, Result, + SourceLocation, Symbol, SymbolType, +}; +use std::path::Path; +use std::sync::OnceLock; +use tree_sitter::{Node, Parser, Tree}; + +/// Classification of ERB block translation complexity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Tier { + /// Simple variable output or block close — regex substitution + T1 = 1, + /// Conditionals, iteration, scope lookups — structural rewrite + T2 = 2, + /// Method chains, complex conditionals — rules + Jinja2 filters + T3 = 3, + /// Lambdas, select/map, Ruby stdlib — LLM-assisted + T4 = 4, +} + +/// What kind of ERB block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlockKind { + /// `<%= ... %>` — output expression + Output, + /// `<% ... %>` — code/control flow + Code, + /// `<%# ... %>` — comment + Comment, +} + +// ── Compiled regexes (one-time init) ──────────────────────────────── + +macro_rules! re { + ($name:ident, $pat:expr) => { + fn $name() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new($pat).expect(concat!("bad regex: ", $pat))) + } + }; +} + +re!(re_simple_var, r"^@\w+$"); +re!(re_higher_order, r"\.(select|map|reject|collect|sort_by|find|detect|flat_map)\s*\{"); +re!(re_method_chain, r"\.(join|downcase|upcase|strip|chomp|first|last|length|size|split|include\?|nil\?|empty\?|to_s|to_i|to_f|capitalize|reverse|uniq|sort|flatten|compact)"); +re!(re_cond, r"^(if|elsif|unless)\s+"); +re!(re_each, r"\.each\s+do\s*\|"); +re!(re_access, r"^\w+\["); +re!(re_at_var, r"@(\w+)"); +re!(re_scope_ref, r"scope\['([^']+)'\]"); +re!(re_each_capture, r"@(\w+)\.each\s+do\s*\|([^|]+)\|"); +re!(re_hash_access, r"^(\w+)\['([^']+)'\]$"); +re!(re_join_single, r"\.join\('([^']*)'\)"); + +fn re_facts_path() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"@facts(\[(?:'[^']*'|"[^"]*")\])+"#).expect("bad regex: facts_path") + }) +} + +fn re_join_double() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"\.join\("([^"]*)"\)"#).expect("bad regex: join_double") + }) +} + +// ── Plugin ────────────────────────────────────────────────────────── + +/// Puppet ERB template language plugin. +pub struct ErbPlugin { + _parser: Parser, +} + +impl ErbPlugin { + pub fn new() -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_embedded_template::LANGUAGE.into()) + .map_err(|e| Error::PluginError(format!("Failed to set ERB grammar: {e}")))?; + Ok(Self { _parser: parser }) + } + + fn parse(&self, file_path: &Path, source: &[u8]) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_embedded_template::LANGUAGE.into()) + .map_err(|e| Error::PluginError(format!("Failed to set ERB grammar: {e}")))?; + parser.parse(source, None).ok_or_else(|| Error::ParseError { + file: file_path.to_path_buf(), + line: 0, + message: "Failed to parse ERB source".to_string(), + }) + } + + fn loc(node: Node, file_path: &str) -> SourceLocation { + SourceLocation { + file: file_path.to_string(), + start_line: node.start_position().row + 1, + end_line: node.end_position().row + 1, + start_column: node.start_position().column, + end_column: node.end_position().column, + } + } + + /// Extract the code content from an ERB directive node. + fn block_code(node: Node, source: &[u8]) -> Option { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "code" { + return child + .utf8_text(source) + .ok() + .map(|s| s.trim().to_string()); + } + } + None + } + + /// Classify a block's translation complexity. + fn classify_tier(code: &str, kind: BlockKind) -> Tier { + if kind == BlockKind::Comment { + return Tier::T1; + } + + let code_trimmed = code + .trim_end_matches("-%") + .trim_end_matches('%') + .trim(); + + // T1: simple variable output + if kind == BlockKind::Output && re_simple_var().is_match(code_trimmed) { + return Tier::T1; + } + + // T1: block terminators and else + if matches!(code_trimmed, "end" | "else") { + return Tier::T1; + } + + // T4: select/map/reject/collect with blocks + if re_higher_order().is_match(code_trimmed) { + return Tier::T4; + } + + // T4: require (Ruby stdlib) + if code_trimmed.starts_with("require ") || code_trimmed.starts_with("require(") { + return Tier::T4; + } + + // T3: method chains (.join, .downcase, .split, etc.) + if re_method_chain().is_match(code_trimmed) { + return Tier::T3; + } + + // T3: complex conditionals (&&, ||) + if code_trimmed.contains("&&") || code_trimmed.contains("||") { + return Tier::T3; + } + + // T2: simple conditionals + if re_cond().is_match(code_trimmed) { + return Tier::T2; + } + + // T2: iteration + if re_each().is_match(code_trimmed) { + return Tier::T2; + } + + // T2: scope lookups + if code_trimmed.contains("scope[") { + return Tier::T2; + } + + // T2: hash/array access + if kind == BlockKind::Output && re_access().is_match(code_trimmed) { + return Tier::T2; + } + + // T2: fact access + if code_trimmed.contains("@facts[") { + return Tier::T2; + } + + // Default: T2 + Tier::T2 + } + + /// Extract `@variable` references from Ruby code. + fn extract_at_variables(code: &str) -> Vec { + let mut vars: Vec = re_at_var() + .captures_iter(code) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .collect(); + vars.sort(); + vars.dedup(); + vars + } + + /// Extract `@facts[...]` access paths. + fn extract_fact_paths(code: &str) -> Vec { + re_facts_path() + .find_iter(code) + .map(|m| m.as_str().to_string()) + .collect() + } + + /// Extract `scope['class::param']` references. + fn extract_scope_refs(code: &str) -> Vec { + re_scope_ref() + .captures_iter(code) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .collect() + } + + /// Suggest a Jinja2 translation pattern for T1–T3 blocks. + fn jinja2_hint(code: &str, kind: BlockKind, tier: Tier) -> Option { + let trimmed = code + .trim_end_matches("-%") + .trim_end_matches('%') + .trim(); + + match (tier, kind) { + // T1 output: @var → {{ var }} + (Tier::T1, BlockKind::Output) => { + let var = trimmed.trim_start_matches('@'); + Some(format!("{{{{ {var} }}}}")) + } + // T1 comment + (_, BlockKind::Comment) => Some(format!("{{# {trimmed} #}}")), + // T1 block terminators — caller needs block stack context + (Tier::T1, BlockKind::Code) if trimmed == "end" => { + Some("{%- endif/endfor --%}".to_string()) + } + (Tier::T1, BlockKind::Code) if trimmed == "else" => Some("{% else %}".to_string()), + // T2 conditionals + (Tier::T2, BlockKind::Code) if trimmed.starts_with("if ") => { + let expr = trimmed.strip_prefix("if ").unwrap_or(trimmed); + let j2 = Self::ruby_expr_to_jinja2(expr); + Some(format!("{{% if {j2} %}}")) + } + (Tier::T2, BlockKind::Code) if trimmed.starts_with("elsif ") => { + let expr = trimmed.strip_prefix("elsif ").unwrap_or(trimmed); + let j2 = Self::ruby_expr_to_jinja2(expr); + Some(format!("{{% elif {j2} %}}")) + } + (Tier::T2, BlockKind::Code) if trimmed.starts_with("unless ") => { + let expr = trimmed.strip_prefix("unless ").unwrap_or(trimmed); + let j2 = Self::ruby_expr_to_jinja2(expr); + Some(format!("{{% if not ({j2}) %}}")) + } + // T2 iteration: @arr.each do |item| + (Tier::T2, BlockKind::Code) => { + if let Some(caps) = re_each_capture().captures(trimmed) { + let collection = caps.get(1).map(|m| m.as_str()).unwrap_or("items"); + let vars = caps.get(2).map(|m| m.as_str()).unwrap_or("item").trim(); + if vars.contains(',') { + let parts: Vec<&str> = vars.split(',').map(|s| s.trim()).collect(); + Some(format!( + "{{% for {}, {} in {}.items() %}}", + parts.first().unwrap_or(&"k"), + parts.get(1).unwrap_or(&"v"), + collection + )) + } else { + Some(format!("{{% for {vars} in {collection} %}}")) + } + } else { + None + } + } + // T2 scope lookups in output + (Tier::T2, BlockKind::Output) if trimmed.contains("scope[") => { + if let Some(caps) = re_scope_ref().captures(trimmed) { + let full_ref = caps.get(1).map(|m| m.as_str()).unwrap_or(trimmed); + let var_name = full_ref.rsplit("::").next().unwrap_or(full_ref); + Some(format!("{{{{ {var_name} }}}}")) + } else { + None + } + } + // T2 hash access in output: vhost['key'] → vhost.key + (Tier::T2, BlockKind::Output) => { + if let Some(caps) = re_hash_access().captures(trimmed) { + let obj = caps.get(1).map(|m| m.as_str()).unwrap_or("obj"); + let key = caps.get(2).map(|m| m.as_str()).unwrap_or("key"); + Some(format!("{{{{ {obj}.{key} }}}}")) + } else { + None + } + } + // T3 method chains in output + (Tier::T3, BlockKind::Output) => { + let j2 = Self::ruby_output_to_jinja2(trimmed); + Some(format!("{{{{ {j2} }}}}")) + } + _ => None, + } + } + + /// Convert a Ruby expression (from conditions) to Jinja2. + fn ruby_expr_to_jinja2(expr: &str) -> String { + re_at_var().replace_all(expr, "$1").to_string() + } + + /// Convert a Ruby output expression to Jinja2 with filters. + fn ruby_output_to_jinja2(expr: &str) -> String { + let mut j2 = re_at_var().replace_all(expr, "$1").to_string(); + j2 = re_join_single() + .replace_all(&j2, " | join('$1')") + .to_string(); + j2 = re_join_double() + .replace_all(&j2, " | join('$1')") + .to_string(); + j2 = j2.replace(".downcase", " | lower"); + j2 = j2.replace(".upcase", " | upper"); + j2 = j2.replace(".strip", " | trim"); + j2 = j2.replace(".chomp", " | trim"); + j2 = j2.replace(".capitalize", " | capitalize"); + j2 = j2.replace(".reverse", " | reverse"); + j2 = j2.replace(".length", " | length"); + j2 = j2.replace(".size", " | length"); + j2 = j2.replace(".to_s", " | string"); + j2 = j2.replace(".to_i", " | int"); + j2 = j2.replace(".to_f", " | float"); + j2 = j2.replace(".uniq", " | unique"); + j2 = j2.replace(".sort", " | sort"); + j2 = j2.replace(".flatten", " | flatten"); + j2 = j2.replace(".compact", " | reject('none')"); + j2 = j2.replace(".first", " | first"); + j2 = j2.replace(".last", " | last"); + j2 + } + + fn walk_blocks( + &self, + node: Node, + source: &[u8], + file_path: &str, + symbols: &mut Vec, + relations: &mut Vec, + ) { + let kind = match node.kind() { + "output_directive" => Some(BlockKind::Output), + "directive" => Some(BlockKind::Code), + "comment_directive" => Some(BlockKind::Comment), + _ => None, + }; + + if let Some(kind) = kind { + if let Some(code) = Self::block_code(node, source) { + let tier = Self::classify_tier(&code, kind); + let at_vars = Self::extract_at_variables(&code); + let fact_paths = Self::extract_fact_paths(&code); + let scope_refs = Self::extract_scope_refs(&code); + let jinja2 = Self::jinja2_hint(&code, kind, tier); + + let block_type = match kind { + BlockKind::Output => "output", + BlockKind::Code => "code", + BlockKind::Comment => "comment", + }; + + let line = node.start_position().row + 1; + let sym_name = format!("erb:{block_type}@L{line}"); + + symbols.push(Symbol { + name: sym_name.clone(), + symbol_type: SymbolType::Variable, + qualified_name: Some(sym_name.clone()), + location: Self::loc(node, file_path), + signature: Some(code.clone()), + return_type: None, + parameters: vec![], + fields: vec![], + modifiers: vec![], + documentation: None, + metadata: serde_json::json!({ + "language": "erb", + "block_kind": block_type, + "translation_tier": tier as u8, + "ruby_code": code, + "jinja2_hint": jinja2, + "variables": at_vars, + "fact_paths": fact_paths, + "scope_refs": scope_refs, + }), + }); + + let file_from = file_path.to_string(); + + for var in &at_vars { + if var == "facts" { + continue; + } + relations.push(Relation { + from: file_from.clone(), + to: format!("${var}"), + relation_type: RelationType::UsesVariable, + location: Self::loc(node, file_path), + metadata: serde_json::json!({ + "language": "erb", + "erb_variable": format!("@{var}"), + }), + to_qualified_hint: Some(format!("${var}")), + to_type_hint: Some("puppetvariable".to_string()), + }); + } + + for fact in &fact_paths { + relations.push(Relation { + from: file_from.clone(), + to: fact.clone(), + relation_type: RelationType::UsesFact, + location: Self::loc(node, file_path), + metadata: serde_json::json!({ + "language": "erb", + "fact_path": fact, + }), + to_qualified_hint: None, + to_type_hint: Some("puppetfact".to_string()), + }); + } + + for scope_ref in &scope_refs { + relations.push(Relation { + from: file_from.clone(), + to: scope_ref.clone(), + relation_type: RelationType::References, + location: Self::loc(node, file_path), + metadata: serde_json::json!({ + "language": "erb", + "scope_ref": scope_ref, + }), + to_qualified_hint: Some(scope_ref.clone()), + to_type_hint: Some("puppetvariable".to_string()), + }); + } + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor).collect::>() { + self.walk_blocks(child, source, file_path, symbols, relations); + } + } +} + +impl LanguagePlugin for ErbPlugin { + fn language_id(&self) -> &str { + "erb" + } + + fn file_extensions(&self) -> Vec<&str> { + vec!["erb"] + } + + fn grammar(&self) -> Option { + Some(tree_sitter_embedded_template::LANGUAGE.into()) + } + + fn extract_symbols(&self, file_path: &Path, source: &[u8]) -> Result> { + let tree = self.parse(file_path, source)?; + let path_str = file_path.to_string_lossy(); + let mut symbols = Vec::new(); + let mut _rels = Vec::new(); + self.walk_blocks(tree.root_node(), source, &path_str, &mut symbols, &mut _rels); + Ok(symbols) + } + + fn extract_relations( + &self, + file_path: &Path, + source: &[u8], + _symbols: &[Symbol], + ) -> Result> { + let tree = self.parse(file_path, source)?; + let path_str = file_path.to_string_lossy(); + let mut _syms = Vec::new(); + let mut relations = Vec::new(); + self.walk_blocks(tree.root_node(), source, &path_str, &mut _syms, &mut relations); + Ok(relations) + } + + fn extract_all(&self, file_path: &Path, source: &[u8]) -> Result { + let tree = self.parse(file_path, source)?; + let path_str = file_path.to_string_lossy(); + let mut symbols = Vec::new(); + let mut relations = Vec::new(); + self.walk_blocks(tree.root_node(), source, &path_str, &mut symbols, &mut relations); + Ok(ExtractAllResult::from_parts(symbols, relations)) + } + + fn calculate_complexity( + &self, + symbol: &Symbol, + _source: &[u8], + ) -> Result> { + Ok(Some(ComplexityMetrics { + cyclomatic: 1, + cognitive: 0, + loc: symbol.location.end_line.saturating_sub(symbol.location.start_line) + 1, + parameters: 0, + nesting_depth: 0, + returns: 0, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plugin() -> ErbPlugin { + ErbPlugin::new().expect("plugin") + } + + #[test] + fn extracts_simple_variables() { + let src = b"

<%= @hostname %>

\n

<%= @fqdn %>

"; + let p = plugin(); + let path = Path::new("templates/index.html.erb"); + let result = p.extract_all(path, src).expect("extract_all"); + + assert_eq!(result.symbols.len(), 2); + assert!(result.symbols.iter().all(|s| { + s.metadata.get("translation_tier").and_then(|v| v.as_u64()) == Some(1) + })); + + let var_rels: Vec<_> = result + .relations + .iter() + .filter(|r| r.relation_type == RelationType::UsesVariable) + .collect(); + assert_eq!(var_rels.len(), 2); + let targets: Vec<&str> = var_rels.iter().map(|r| r.to.as_str()).collect(); + assert!(targets.contains(&"$hostname")); + assert!(targets.contains(&"$fqdn")); + } + + #[test] + fn extracts_facts_and_scope() { + let src = b"<%= @facts['os']['family'] %>\n<%= scope['profile::nginx::port'] %>"; + let p = plugin(); + let path = Path::new("templates/config.erb"); + let result = p.extract_all(path, src).expect("extract_all"); + + let fact_rels: Vec<_> = result + .relations + .iter() + .filter(|r| r.relation_type == RelationType::UsesFact) + .collect(); + assert_eq!(fact_rels.len(), 1); + assert!(fact_rels[0].to.contains("@facts['os']['family']")); + + let ref_rels: Vec<_> = result + .relations + .iter() + .filter(|r| r.relation_type == RelationType::References) + .collect(); + assert_eq!(ref_rels.len(), 1); + assert_eq!(ref_rels[0].to, "profile::nginx::port"); + } + + #[test] + fn classifies_tiers_correctly() { + assert_eq!(ErbPlugin::classify_tier("@hostname", BlockKind::Output) as u8, 1); + assert_eq!(ErbPlugin::classify_tier("end", BlockKind::Code) as u8, 1); + assert_eq!( + ErbPlugin::classify_tier("if @os == 'RedHat'", BlockKind::Code) as u8, + 2 + ); + assert_eq!( + ErbPlugin::classify_tier("@arr.each do |item|", BlockKind::Code) as u8, + 2 + ); + assert_eq!( + ErbPlugin::classify_tier("@aliases.join(' ')", BlockKind::Output) as u8, + 3 + ); + assert_eq!( + ErbPlugin::classify_tier( + "@nets.select { |n| n.include?('/') }", + BlockKind::Code + ) as u8, + 4 + ); + } + + #[test] + fn jinja2_hints_for_simple_blocks() { + let hint = ErbPlugin::jinja2_hint("@hostname", BlockKind::Output, Tier::T1); + assert_eq!(hint, Some("{{ hostname }}".to_string())); + + let hint = ErbPlugin::jinja2_hint("else", BlockKind::Code, Tier::T1); + assert_eq!(hint, Some("{% else %}".to_string())); + } + + #[test] + fn jinja2_hints_for_iteration() { + let hint = ErbPlugin::jinja2_hint( + "@vhosts.each do |vhost|", + BlockKind::Code, + Tier::T2, + ); + assert_eq!(hint, Some("{% for vhost in vhosts %}".to_string())); + + let hint = ErbPlugin::jinja2_hint( + "@headers.each do |key, value|", + BlockKind::Code, + Tier::T2, + ); + assert_eq!( + hint, + Some("{% for key, value in headers.items() %}".to_string()) + ); + } + + #[test] + fn jinja2_hints_for_method_chains() { + let hint = ErbPlugin::jinja2_hint( + "@aliases.join(' ')", + BlockKind::Output, + Tier::T3, + ); + assert_eq!(hint, Some("{{ aliases | join(' ') }}".to_string())); + + let hint = + ErbPlugin::jinja2_hint("@hostname.downcase", BlockKind::Output, Tier::T3); + assert_eq!(hint, Some("{{ hostname | lower }}".to_string())); + } + + #[test] + fn registry_extensions() { + let p = plugin(); + assert_eq!(p.language_id(), "erb"); + assert!(p.file_extensions().contains(&"erb")); + assert!(p.grammar().is_some()); + } +} diff --git a/crates/rgctl-languages/Cargo.toml b/crates/rgctl-languages/Cargo.toml index 31f83478..f9977d19 100644 --- a/crates/rgctl-languages/Cargo.toml +++ b/crates/rgctl-languages/Cargo.toml @@ -22,6 +22,7 @@ rgctl-lang-markdown = { workspace = true } rgctl-lang-php = { workspace = true } rgctl-lang-ruby = { workspace = true } rgctl-lang-puppet = { workspace = true } +rgctl-lang-erb = { workspace = true } rgctl-lang-kotlin = { workspace = true } rgctl-lang-groovy = { workspace = true } diff --git a/crates/rgctl-languages/src/lib.rs b/crates/rgctl-languages/src/lib.rs index 010d16eb..679821ea 100644 --- a/crates/rgctl-languages/src/lib.rs +++ b/crates/rgctl-languages/src/lib.rs @@ -17,6 +17,7 @@ pub fn register_languages(registry: &mut LanguageRegistry) { rgctl_lang_php::register(registry); rgctl_lang_ruby::register(registry); rgctl_lang_puppet::register(registry); + rgctl_lang_erb::register(registry); rgctl_lang_kotlin::register(registry); rgctl_lang_groovy::register(registry); } @@ -86,6 +87,16 @@ mod tests { assert_eq!(plugin.language_id(), "puppet"); } + #[test] + fn default_registry_can_process_erb_files() { + let registry = default_registry(); + assert!(registry.can_process_file(Path::new("modules/nginx/templates/nginx.conf.erb"))); + let plugin = registry + .get_plugin_for_file(Path::new("templates/index.html.erb")) + .expect("erb plugin"); + assert_eq!(plugin.language_id(), "erb"); + } + #[test] fn default_registry_can_process_kotlin_files() { let registry = default_registry(); diff --git a/languages.toml b/languages.toml index 8a7b4be7..7efb4722 100644 --- a/languages.toml +++ b/languages.toml @@ -188,3 +188,16 @@ class_kinds = ["class_declaration", "interface_declaration", "enum_declaration"] import_kinds = ["import_declaration"] enable_complexity = true enable_type_inference = false + +[languages.erb] +handler = "custom" +plugin = "ErbPlugin" +module = "crate::languages::builtin::erb" +crate = "tree-sitter-embedded-template" +extensions = ["erb"] +aliases = ["erb", "embedded-ruby"] +function_kinds = [] +class_kinds = [] +import_kinds = [] +enable_complexity = false +enable_type_inference = false From b390b78088428ed70384b2a848124f5506b5fdd8 Mon Sep 17 00:00:00 2001 From: matt Date: Wed, 30 Sep 2026 06:41:55 -0600 Subject: [PATCH 2/4] feat(profile): add theforeman corpus for Puppet+ERB cold profile Added to scripts/fetch-profile-repos.sh (not a separate script). Corpus: 12 theforeman/puppetlabs/voxpupuli modules - 500 .pp, 122 .erb, 121 .epp, 64 .rb = 672 indexed files - Cold profile: 2.14s wall, 6218 nodes, 13239 edges, 76 MB peak - Override with RGCTL_THEFOREMAN_REPO Updated AGENTS.md Gate B table with Puppet and ERB entries. Co-authored-by: Cursor Signed-off-by: Matt Fernandez --- AGENTS.md | 3 ++- scripts/fetch-profile-repos.sh | 35 +++++++++++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9aa7e483..29fe3668 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,8 @@ Fetch: `./scripts/fetch-profile-repos.sh` | **PHP** | Magento 2 | `example/magento2` | `-l php` | `RGCTL_MAGENTO2_REPO` | | **Python** | Home Assistant | `example/home-assistant` | `-l python` | `RGCTL_HOME_ASSISTANT_REPO` | | **Ruby** | Discourse | `example/discourse` | `-l ruby` | — | -| **Puppet** | *(deferred)* | `RGCTL_PUPPET_REPO` | `-l puppet` | `RGCTL_PUPPET_REPO` — no default ~10k corpus yet | +| **Puppet** | theforeman | `example/theforeman` | `-l puppet,erb,ruby -e spec,vendor` | `RGCTL_THEFOREMAN_REPO` | +| **ERB** | *(included in theforeman)* | `example/theforeman` | `-l erb` | *(same corpus — 122 .erb files, 2114 blocks)* | | **Rust** | rustc | `example/rust` | `-l rust` | `RGCTL_RUST_REPO` | | **TypeScript** | VS Code | `example/vscode` | `-l typescript` on `src/` | `RGCTL_VSCODE_REPO` | | **Kotlin** | JetBrains/kotlin | `example/kotlin` | `-l kotlin` (sparse `libraries` `plugins` `analysis`) | `RGCTL_KOTLIN_REPO` | diff --git a/scripts/fetch-profile-repos.sh b/scripts/fetch-profile-repos.sh index 7b8d44d7..ca4b12d7 100755 --- a/scripts/fetch-profile-repos.sh +++ b/scripts/fetch-profile-repos.sh @@ -17,6 +17,7 @@ # - llvm-project (C++ via sparse clang/) # - kotlin (JetBrains/kotlin sparse libraries+plugins+analysis — Kotlin Gate B) # - groovy (gradle/gradle — Groovy Gate B; largest single OSS .groovy tree) +# - theforeman (12 Puppet modules — Puppet+ERB Gate B; 500 .pp + 122 .erb) set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -99,9 +100,37 @@ clone_if_missing "https://github.com/microsoft/vscode.git" "$EXAMPLE_DIR/vscode" clone_if_missing "https://github.com/dotnet/roslyn.git" "$EXAMPLE_DIR/roslyn" 1 clone_sparse_llvm_clang_if_missing "$EXAMPLE_DIR/llvm-project" -# Puppet Gate B (~10⁴ .pp): deferred — no default monorepo yet. -# Override when baselining: RGCTL_PUPPET_REPO=/path/to/puppet/modules -# Suggested candidates: OpenStack puppet-* modules or a Forge module bundle under example/puppet. +# Puppet + ERB Gate B: theforeman ecosystem (12 modules, 500 .pp + 122 .erb + 64 .rb) +# Override root with RGCTL_THEFOREMAN_REPO. +clone_theforeman_if_missing() { + local dest="$1" + if [[ -d "$dest/puppet-foreman" ]]; then + echo "Already present: $dest (theforeman)" + return 0 + fi + mkdir -p "$dest" + local repos=( + theforeman/puppet-foreman + theforeman/puppet-foreman_proxy + theforeman/puppet-puppet + theforeman/puppet-dhcp + theforeman/puppet-dns + theforeman/puppet-tftp + theforeman/puppet-candlepin + theforeman/puppet-pulpcore + theforeman/puppet-puppetserver_foreman + puppetlabs/puppetlabs-apache + puppetlabs/puppetlabs-postgresql + voxpupuli/puppet-redis + ) + for repo in "${repos[@]}"; do + local name + name=$(basename "$repo") + clone_if_missing "https://github.com/${repo}.git" "$dest/$name" 1 + done +} + +clone_theforeman_if_missing "${RGCTL_THEFOREMAN_REPO:-$EXAMPLE_DIR/theforeman}" clone_sparse_node_test_if_missing() { local dest="$1" From 1bd16565ef0dacd7f718366e16770a6c5d3fdb5c Mon Sep 17 00:00:00 2001 From: Matt Fernandez Date: Wed, 30 Sep 2026 07:30:44 -0600 Subject: [PATCH 3/4] fix: graph builder stub allowlist + ERB from QN - Add UsesVariable and UsesFact to relation_allows_external_stub so ERB edges survive into the snapshot after discover - Add puppetvariable, puppetfact, puppetresource, puppetclass to stub_node_type_for_target hint mapper - Change ERB relation 'from' to use block QN (erb:output@L1) instead of file path for better blast-radius / GQL traversal - Dial Gate B wording to 'smoke corpus' (theforeman is ~670 files, not O(10^4)) Verified: discover on minimal ERB fixture produces: erb:output@L1 --USES--> $hostname (PuppetVariable) erb:output@L2 --USES_FACT--> @facts['os']['family'] (PuppetFact) erb:output@L3 --REFERENCES--> profile::web::port (PuppetVariable) Signed-off-by: Matt Fernandez Co-authored-by: Cursor Signed-off-by: Matt Fernandez --- AGENTS.md | 2 +- crates/rgctl-extraction/src/graph_builder.rs | 6 ++++++ crates/rgctl-lang-erb/src/plugin.rs | 8 ++++---- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 29fe3668..562f0485 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,7 @@ Fetch: `./scripts/fetch-profile-repos.sh` | **PHP** | Magento 2 | `example/magento2` | `-l php` | `RGCTL_MAGENTO2_REPO` | | **Python** | Home Assistant | `example/home-assistant` | `-l python` | `RGCTL_HOME_ASSISTANT_REPO` | | **Ruby** | Discourse | `example/discourse` | `-l ruby` | — | -| **Puppet** | theforeman | `example/theforeman` | `-l puppet,erb,ruby -e spec,vendor` | `RGCTL_THEFOREMAN_REPO` | +| **Puppet** | theforeman | `example/theforeman` | `-l puppet,erb,ruby -e spec,vendor` | `RGCTL_THEFOREMAN_REPO` — smoke corpus (~670 files, not O(10⁴)) | | **ERB** | *(included in theforeman)* | `example/theforeman` | `-l erb` | *(same corpus — 122 .erb files, 2114 blocks)* | | **Rust** | rustc | `example/rust` | `-l rust` | `RGCTL_RUST_REPO` | | **TypeScript** | VS Code | `example/vscode` | `-l typescript` on `src/` | `RGCTL_VSCODE_REPO` | diff --git a/crates/rgctl-extraction/src/graph_builder.rs b/crates/rgctl-extraction/src/graph_builder.rs index a4e4e5f0..c8aac20c 100644 --- a/crates/rgctl-extraction/src/graph_builder.rs +++ b/crates/rgctl-extraction/src/graph_builder.rs @@ -1277,6 +1277,8 @@ fn relation_allows_external_stub(relation_type: RelationType) -> bool { | RelationType::Extends | RelationType::Implements | RelationType::Permits + | RelationType::UsesVariable + | RelationType::UsesFact ) } @@ -1290,6 +1292,10 @@ fn stub_node_type_for_target(relation: &Relation) -> NodeType { "function" | "method" => return NodeType::Function, "class" | "struct" => return NodeType::Class, "dependency" => return NodeType::Dependency, + "puppetvariable" => return NodeType::PuppetVariable, + "puppetfact" => return NodeType::PuppetFact, + "puppetresource" => return NodeType::PuppetResource, + "puppetclass" => return NodeType::PuppetClass, _ => {} } } diff --git a/crates/rgctl-lang-erb/src/plugin.rs b/crates/rgctl-lang-erb/src/plugin.rs index bec0ca03..524c215b 100644 --- a/crates/rgctl-lang-erb/src/plugin.rs +++ b/crates/rgctl-lang-erb/src/plugin.rs @@ -393,14 +393,14 @@ impl ErbPlugin { }), }); - let file_from = file_path.to_string(); + let rel_from = sym_name; for var in &at_vars { if var == "facts" { continue; } relations.push(Relation { - from: file_from.clone(), + from: rel_from.clone(), to: format!("${var}"), relation_type: RelationType::UsesVariable, location: Self::loc(node, file_path), @@ -415,7 +415,7 @@ impl ErbPlugin { for fact in &fact_paths { relations.push(Relation { - from: file_from.clone(), + from: rel_from.clone(), to: fact.clone(), relation_type: RelationType::UsesFact, location: Self::loc(node, file_path), @@ -430,7 +430,7 @@ impl ErbPlugin { for scope_ref in &scope_refs { relations.push(Relation { - from: file_from.clone(), + from: rel_from.clone(), to: scope_ref.clone(), relation_type: RelationType::References, location: Self::loc(node, file_path), From 758253710db9a2d2e7067ed323d4bb0991759307 Mon Sep 17 00:00:00 2001 From: Matt Fernandez Date: Wed, 30 Sep 2026 08:09:45 -0600 Subject: [PATCH 4/4] =?UTF-8?q?fix(erb):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?AST=20coverage=20honesty,=20regex=20gap=20docs,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback from sshaaf on PR #101: - ast_coverage.rs: real bidirectional assertion (grammar↔manifest), not no-op - erb-ast-coverage.json: 'code' → 'Literal' (consumed by parent, not Symbol) - erb-ast-coverage.json: added _honesty_notes for code/content/graphql_directive - plugin.rs: comment on graphql_directive intentional skip - lib.rs: documented all honesty limits (regex-only Ruby, no .epp, no CFG) - Added unit tests: honesty_regex_gaps (facts.dig/get not captured), epp_files_not_handled (.epp out of scope) 10 unit tests pass. Cold profiles: theforeman: 1.6s, 6108 nodes, 13580 edges, 72 MB peak Linux kernel: 173.4s, 2.7M nodes, 8.2M edges (machine variance vs 145s ref) Signed-off-by: Matt Fernandez Co-authored-by: Cursor --- crates/rgctl-lang-erb/erb-ast-coverage.json | 7 ++++++- crates/rgctl-lang-erb/src/ast_coverage.rs | 21 +++++++++++++++---- crates/rgctl-lang-erb/src/lib.rs | 11 ++++++++++ crates/rgctl-lang-erb/src/plugin.rs | 23 +++++++++++++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/rgctl-lang-erb/erb-ast-coverage.json b/crates/rgctl-lang-erb/erb-ast-coverage.json index 524d836d..689cb51b 100644 --- a/crates/rgctl-lang-erb/erb-ast-coverage.json +++ b/crates/rgctl-lang-erb/erb-ast-coverage.json @@ -1,7 +1,7 @@ { "grammar": "tree-sitter-embedded-template@0.25.0", "handlers": { - "code": "Symbol", + "code": "Literal", "comment": "Skip", "comment_directive": "Symbol", "content": "Skip", @@ -9,5 +9,10 @@ "graphql_directive": "Skip", "output_directive": "Symbol", "template": "Skip" + }, + "_honesty_notes": { + "code": "Text consumed by parent directive/output_directive; not emitted as its own symbol", + "content": "HTML/text between ERB tags; layout structure not extracted in Layer A", + "graphql_directive": "<%graphql %> blocks not walked; silent gap until dedicated support added" } } diff --git a/crates/rgctl-lang-erb/src/ast_coverage.rs b/crates/rgctl-lang-erb/src/ast_coverage.rs index 5c3e5522..bc8ca9b5 100644 --- a/crates/rgctl-lang-erb/src/ast_coverage.rs +++ b/crates/rgctl-lang-erb/src/ast_coverage.rs @@ -37,12 +37,11 @@ mod tests { let manifest = load_manifest(); assert!(!manifest.is_empty(), "manifest is empty"); - let language: tree_sitter::Language = tree_sitter_embedded_template::LANGUAGE.into(); let node_types_json = tree_sitter_embedded_template::NODE_TYPES; let types: Vec = serde_json::from_str(node_types_json).expect("parse NODE_TYPES"); - let named: HashSet = types + let grammar_named: HashSet = types .iter() .filter(|t| t["named"].as_bool() == Some(true)) .filter_map(|t| t["type"].as_str().map(String::from)) @@ -50,6 +49,7 @@ mod tests { let allowed: HashSet<&str> = ALLOWED.iter().copied().collect(); + // Every manifest key must use an allowed handler. for (kind, handler) in &manifest { assert!( allowed.contains(handler.as_str()), @@ -57,7 +57,20 @@ mod tests { ); } - let _ = language; - let _ = named; + // Every grammar named kind must appear in the manifest. + for kind in &grammar_named { + assert!( + manifest.contains_key(kind), + "grammar named kind {kind:?} missing from erb-ast-coverage.json — add a handler" + ); + } + + // Every manifest key must still exist in the grammar (detect stale entries). + for kind in manifest.keys() { + assert!( + grammar_named.contains(kind), + "manifest key {kind:?} not in grammar named kinds — remove stale entry" + ); + } } } diff --git a/crates/rgctl-lang-erb/src/lib.rs b/crates/rgctl-lang-erb/src/lib.rs index 8e0c533f..bff7810a 100644 --- a/crates/rgctl-lang-erb/src/lib.rs +++ b/crates/rgctl-lang-erb/src/lib.rs @@ -12,6 +12,17 @@ //! //! Block metadata includes a `translation_tier` (1–4) and, for tiers 1–3, //! a `jinja2_pattern` hint for downstream ERB→Jinja2 translation. +//! +//! ## Honesty limits (Layer A extraction) +//! +//! - Ruby inside `<% %>` blocks is parsed with regex, not `tree-sitter-ruby`. +//! Method calls like `@facts.dig(...)` / `@facts.get('os')` are not captured. +//! Non-`@` local variables and `require` statements produce no graph edges. +//! - `<%graphql %>` directives are skipped (no symbols/relations emitted). +//! - `.epp` (Puppet EPP) templates are out of scope; a future `rgctl-lang-epp` +//! would cover those. +//! - `content` nodes (HTML/text between tags) are skipped; no layout structure. +//! - No CFG/taint/Layer F analysis — `enable_complexity = false` in `languages.toml`. use rgctl_registry::LanguageRegistry; use std::sync::Arc; diff --git a/crates/rgctl-lang-erb/src/plugin.rs b/crates/rgctl-lang-erb/src/plugin.rs index 524c215b..7e5e52c3 100644 --- a/crates/rgctl-lang-erb/src/plugin.rs +++ b/crates/rgctl-lang-erb/src/plugin.rs @@ -350,6 +350,8 @@ impl ErbPlugin { "output_directive" => Some(BlockKind::Output), "directive" => Some(BlockKind::Code), "comment_directive" => Some(BlockKind::Comment), + // graphql_directive: <%graphql %> blocks are intentionally skipped. + // Coverage gap documented in erb-ast-coverage.json. Future: dedicated handler. _ => None, }; @@ -643,4 +645,25 @@ mod tests { assert!(p.file_extensions().contains(&"erb")); assert!(p.grammar().is_some()); } + + #[test] + fn honesty_regex_gaps() { + // @facts.dig(...) and @facts.get('os') are NOT captured by the bracket regex. + let facts_dig = ErbPlugin::extract_fact_paths("@facts.dig('os', 'family')"); + assert!(facts_dig.is_empty(), "dig() should not match: {facts_dig:?}"); + + let facts_get = ErbPlugin::extract_fact_paths("@facts.get('os')"); + assert!(facts_get.is_empty(), "get() should not match: {facts_get:?}"); + + // Non-@ locals produce no variable references. + let locals = ErbPlugin::extract_at_variables("item['name']"); + assert!(locals.is_empty(), "non-@ locals should not match: {locals:?}"); + } + + #[test] + fn epp_files_not_handled() { + let p = plugin(); + assert!(!p.file_extensions().contains(&"epp")); + assert!(!p.can_handle(Path::new("templates/config.epp"))); + } }