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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` — 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` |
| **Kotlin** | JetBrains/kotlin | `example/kotlin` | `-l kotlin` (sparse `libraries` `plugins` `analysis`) | `RGCTL_KOTLIN_REPO` |
Expand Down
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions crates/rgctl-ast-coverage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
7 changes: 7 additions & 0 deletions crates/rgctl-ast-coverage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions crates/rgctl-extraction/src/graph_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,8 @@ fn relation_allows_external_stub(relation_type: RelationType) -> bool {
| RelationType::Extends
| RelationType::Implements
| RelationType::Permits
| RelationType::UsesVariable
| RelationType::UsesFact
)
}

Expand All @@ -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,
_ => {}
}
}
Expand Down
21 changes: 21 additions & 0 deletions crates/rgctl-lang-erb/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
18 changes: 18 additions & 0 deletions crates/rgctl-lang-erb/erb-ast-coverage.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"grammar": "tree-sitter-embedded-template@0.25.0",
"handlers": {
"code": "Literal",
"comment": "Skip",
"comment_directive": "Symbol",
"content": "Skip",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

content is Skip (HTML/text between ERB tags).

Fine for a first slice focused on Ruby/Puppet refs. If later you care about template layout or “what markup surrounds this block,” this is the place to emit lightweight structure (e.g. file-level skeleton or CONTAINS-style anchors).

No action required for merge — calling it out as a known coverage gap.

"directive": "Symbol",
"graphql_directive": "Skip",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

graphql_directive is listed as Skip and never walked.

walk_blocks only matches output_directive / directive / comment_directive, so <%graphql … %> blobs are invisible to the graph.

Suggestion (when useful): treat graphql_directive like directive (or a dedicated block kind) so GraphQL-in-ERB templates aren’t a silent hole. Until then, a one-line honesty note in the crate README / PR description would help reviewers.

"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"
}
}
76 changes: 76 additions & 0 deletions crates/rgctl-lang-erb/src/ast_coverage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! 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<String, String> {
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 node_types_json = tree_sitter_embedded_template::NODE_TYPES;
let types: Vec<serde_json::Value> =
serde_json::from_str(node_types_json).expect("parse NODE_TYPES");

let grammar_named: HashSet<String> = 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();

// Every manifest key must use an allowed handler.
for (kind, handler) in &manifest {
assert!(
allowed.contains(handler.as_str()),
"handler {handler:?} for {kind:?} not in ALLOWED"
);
}

// 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"
);
}
}
}
38 changes: 38 additions & 0 deletions crates/rgctl-lang-erb/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//! 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.
//!
//! ## 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;

#[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")));
}
Loading