use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use foxguard::rules::semgrep_compat::parse_semgrep_str;
use serde_yaml_ng::Value as Yaml;
const STRUCTURAL_KEYS: &[&str] = &[
"id",
"message",
"severity",
"languages",
"language",
"metadata",
"paths",
"mode",
"fix",
"fix-regex",
"options",
"min-version",
"max-version",
"engine",
"script",
"script_path",
"query",
"database",
];
const SUPPORTED_PATTERN_OPERATORS: &[&str] = &[
"pattern",
"pattern-regex",
"pattern-either",
"pattern-not",
"pattern-not-regex",
"pattern-inside",
"pattern-not-inside",
"patterns",
"metavariable-regex",
"metavariable-comparison",
"metavariable-pattern",
"focus-metavariable",
"metavariable-analysis",
];
fn language_supported(lang: &str) -> bool {
matches!(
lang.to_lowercase().as_str(),
"javascript"
| "js"
| "typescript"
| "ts"
| "jsx"
| "tsx"
| "python"
| "py"
| "go"
| "golang"
| "ruby"
| "rb"
| "java"
| "php"
| "rust"
| "rs"
| "csharp"
| "c#"
| "cs"
| "swift"
| "kotlin"
| "kt"
| "c"
| "hcl"
| "terraform"
| "tf"
| "solidity"
| "sol"
| "yaml"
| "yml"
| "regex"
| "dockerfile"
| "docker"
| "bash"
| "sh"
| "ocaml"
| "ml"
| "mli"
| "scala"
| "sc"
| "elixir"
| "ex"
| "exs"
| "json"
| "apex"
| "clojure"
| "clj"
| "cljs"
| "cljc"
| "html"
| "htm"
| "xml"
| "dart"
)
}
fn taint_language_supported(lang: &str) -> bool {
matches!(
lang.to_lowercase().as_str(),
"python"
| "py"
| "javascript"
| "js"
| "typescript"
| "ts"
| "go"
| "golang"
| "java"
| "c"
| "kotlin"
| "kt"
| "ruby"
| "rb"
| "php"
| "csharp"
| "cs"
| "c#"
| "bash"
| "sh"
| "shell"
| "solidity"
| "sol"
| "scala"
| "apex"
| "swift"
)
}
#[derive(Debug)]
enum Outcome {
Loaded,
Skipped(String),
}
#[derive(Default)]
struct Stats {
files_total: usize,
files_parse_error: usize,
rules_total: usize,
rules_loaded: usize,
rules_skipped: usize,
skip_histogram: BTreeMap<String, usize>,
per_language: BTreeMap<String, (usize, usize)>,
per_language_reasons: BTreeMap<String, BTreeMap<String, usize>>,
}
impl Stats {
fn record(&mut self, lang: &str, outcome: &Outcome) {
self.rules_total += 1;
let entry = self.per_language.entry(lang.to_string()).or_default();
match outcome {
Outcome::Loaded => {
self.rules_loaded += 1;
entry.0 += 1;
}
Outcome::Skipped(reason) => {
self.rules_skipped += 1;
entry.1 += 1;
*self.skip_histogram.entry(reason.clone()).or_default() += 1;
*self
.per_language_reasons
.entry(lang.to_string())
.or_default()
.entry(reason.clone())
.or_default() += 1;
}
}
}
}
fn collect_keys(node: &Yaml, out: &mut Vec<String>) {
match node {
Yaml::Mapping(map) => {
for (k, v) in map {
if let Some(key) = k.as_str() {
out.push(key.to_string());
}
collect_keys(v, out);
}
}
Yaml::Sequence(seq) => {
for item in seq {
collect_keys(item, out);
}
}
_ => {}
}
}
fn rule_languages(rule: &Yaml) -> Vec<String> {
if let Some(seq) = rule.get("languages").and_then(Yaml::as_sequence) {
return seq
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
}
if let Some(one) = rule.get("language").and_then(Yaml::as_str) {
return vec![one.to_string()];
}
if let Some(one) = rule.get("languages").and_then(Yaml::as_str) {
return vec![one.to_string()];
}
Vec::new()
}
fn language_bucket(langs: &[String]) -> String {
if langs.is_empty() {
return "<none>".to_string();
}
let first = langs[0].to_lowercase();
match first.as_str() {
"js" | "jsx" | "tsx" | "ts" | "typescript" => "javascript".to_string(),
"golang" => "go".to_string(),
"py" => "python".to_string(),
"rb" => "ruby".to_string(),
"kt" => "kotlin".to_string(),
"rs" => "rust".to_string(),
"c#" | "cs" => "csharp".to_string(),
"generic" => "generic".to_string(),
"regex" => "regex".to_string(),
other => other.to_string(),
}
}
fn classify_rule(rule: &Yaml) -> Outcome {
let langs = rule_languages(rule);
if let Some(engine) = rule.get("engine").and_then(Yaml::as_str) {
let e = engine.to_lowercase();
if e == "coccinelle" || e == "codeql" {
return Outcome::Skipped(format!("engine: {e} (bridge, not pattern)"));
}
}
if langs.is_empty() {
return Outcome::Skipped("no/unknown languages".to_string());
}
let mode = rule.get("mode").and_then(Yaml::as_str).unwrap_or("search");
match mode {
"taint" => {
if !langs.iter().any(|l| taint_language_supported(l)) {
return Outcome::Skipped(format!(
"mode: taint (unsupported language: {})",
language_bucket(&langs)
));
}
return ground_truth(rule, "mode: taint (unsupported shape)");
}
"search" => {}
other => {
return Outcome::Skipped(format!("mode: {other}"));
}
}
let mut keys = Vec::new();
collect_keys(rule, &mut keys);
const UNSUPPORTED_OPERATORS: &[&str] = &[
"pattern-sources",
"pattern-sinks",
"pattern-sanitizers",
"pattern-propagators",
"semgrep-internal-metavariable-name",
"join",
];
for op in UNSUPPORTED_OPERATORS {
if keys.iter().any(|k| k == op) {
return Outcome::Skipped((*op).to_string());
}
}
if langs.iter().any(|l| l.eq_ignore_ascii_case("generic")) {
return ground_truth(rule, "generic mode (languages: [generic])");
}
if langs.iter().any(|l| l.eq_ignore_ascii_case("regex"))
&& !keys.iter().any(|k| k == "pattern-regex")
{
return Outcome::Skipped("regex language (no pattern-regex)".to_string());
}
if !langs.iter().any(|l| language_supported(l)) {
return Outcome::Skipped(format!("unsupported language: {}", language_bucket(&langs)));
}
let has_supported_pattern = keys
.iter()
.any(|k| SUPPORTED_PATTERN_OPERATORS.contains(&k.as_str()));
if !has_supported_pattern {
let unknown = keys
.iter()
.find(|k| {
!STRUCTURAL_KEYS.contains(&k.as_str())
&& !SUPPORTED_PATTERN_OPERATORS.contains(&k.as_str())
&& !k.starts_with("pattern")
})
.cloned();
return Outcome::Skipped(
unknown.unwrap_or_else(|| "no supported pattern operator".to_string()),
);
}
ground_truth(rule, "loader rejected (other)")
}
fn ground_truth(rule: &Yaml, fallback_reason: &str) -> Outcome {
let doc = Yaml::Mapping({
let mut m = serde_yaml_ng::Mapping::new();
m.insert(
Yaml::String("rules".into()),
Yaml::Sequence(vec![rule.clone()]),
);
m
});
let content = match serde_yaml_ng::to_string(&doc) {
Ok(c) => c,
Err(_) => return Outcome::Skipped(fallback_reason.to_string()),
};
match parse_semgrep_str(&content, "<registry-coverage>") {
Ok(rules) if !rules.is_empty() => Outcome::Loaded,
Ok(_) => Outcome::Skipped(fallback_reason.to_string()),
Err(_) => Outcome::Skipped(fallback_reason.to_string()),
}
}
fn is_rule_yaml(path: &Path) -> bool {
let ext = path.extension().and_then(|s| s.to_str());
if !matches!(ext, Some("yaml" | "yml")) {
return false;
}
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if name.ends_with(".test.yaml") || name.ends_with(".test.yml") {
return false;
}
true
}
fn cli_path(raw: &str) -> PathBuf {
let candidate = PathBuf::from(raw); if candidate
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
eprintln!("error: path '{raw}' contains a '..' traversal segment; refusing.");
std::process::exit(2);
}
candidate
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut root: Option<PathBuf> = None;
let mut out = PathBuf::from("docs/parity/registry-coverage.md");
let mut list_skips: Option<String> = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--out" => {
i += 1;
if let Some(p) = args.get(i) {
out = cli_path(p);
}
}
"--list-skips" => {
i += 1;
list_skips = args.get(i).map(|s| s.to_lowercase());
}
other => root = Some(cli_path(other)),
}
i += 1;
}
let Some(root) = root else {
eprintln!(
"usage: registry_coverage <registry-dir> [--out docs/parity/registry-coverage.md]\n\
\n\
Snapshot the registry first (gitignored):\n \
git clone --depth 1 https://github.com/semgrep/semgrep-rules .registry-snapshot"
);
std::process::exit(2);
};
if !root.exists() {
eprintln!(
"error: registry dir '{}' does not exist.\n\
Clone it first:\n \
git clone --depth 1 https://github.com/semgrep/semgrep-rules .registry-snapshot",
root.display()
);
std::process::exit(2);
}
let mut stats = Stats::default();
let walker = walkdir::WalkDir::new(&root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file() && is_rule_yaml(e.path()))
.filter(|e| !e.path().components().any(|c| c.as_os_str() == ".git"));
for entry in walker {
let content = match std::fs::read_to_string(entry.path()) {
Ok(c) => c,
Err(_) => continue,
};
let doc: Yaml = match serde_yaml_ng::from_str(&content) {
Ok(d) => d,
Err(_) => {
stats.files_total += 1;
stats.files_parse_error += 1;
continue;
}
};
let Some(rules) = doc.get("rules").and_then(Yaml::as_sequence) else {
continue;
};
stats.files_total += 1;
for rule in rules {
let langs = rule_languages(rule);
let bucket = language_bucket(&langs);
let outcome = match ground_truth(rule, "") {
Outcome::Loaded => Outcome::Loaded,
Outcome::Skipped(_) => match classify_rule(rule) {
Outcome::Skipped(reason) => Outcome::Skipped(reason),
Outcome::Loaded => Outcome::Skipped("loader rejected (other)".to_string()),
},
};
if let (Some(want), Outcome::Skipped(reason)) = (&list_skips, &outcome) {
if bucket.eq_ignore_ascii_case(want) {
let id = rule.get("id").and_then(Yaml::as_str).unwrap_or("<no-id>");
eprintln!("SKIP\t{bucket}\t{id}\t{reason}");
}
}
stats.record(&bucket, &outcome);
}
}
let report = render_report(&root, &stats);
if let Some(parent) = out.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&out, &report) {
eprintln!("error: failed to write report to {}: {e}", out.display());
std::process::exit(1);
}
let load_rate = pct(stats.rules_loaded, stats.rules_total);
println!(
"registry-coverage: {} files, {} rules, {} loaded ({:.1}%), {} skipped",
stats.files_total, stats.rules_total, stats.rules_loaded, load_rate, stats.rules_skipped
);
println!("report written to {}", out.display());
}
fn is_language_gap(reason: &str) -> bool {
reason.starts_with("unsupported language:")
|| reason.starts_with("generic mode")
|| reason.starts_with("no/unknown languages")
|| reason.starts_with("mode: taint (unsupported language")
}
fn pct(n: usize, d: usize) -> f64 {
if d == 0 {
0.0
} else {
100.0 * n as f64 / d as f64
}
}
fn render_report(root: &Path, stats: &Stats) -> String {
use std::fmt::Write;
let mut s = String::new();
let today = "2026-07-05";
let _ = writeln!(s, "# Semgrep registry coverage");
let _ = writeln!(s);
let _ = writeln!(
s,
"> Status: {today}. Generated by `cargo run --release --bin registry_coverage -- {}`.",
root.display()
);
let _ = writeln!(s, "> Living document — regenerate after loader changes.");
let _ = writeln!(s);
let _ = writeln!(
s,
"Measures how well foxguard's existing Semgrep-compat YAML loader \
(`src/rules/semgrep_compat.rs`) handles a snapshot of the real \
[semgrep-rules](https://github.com/semgrep/semgrep-rules) registry. \
Each rule is classified by whether the loader produces a live matcher, \
and skips are attributed to the single unsupported operator/key that \
blocks them. This drives the parity roadmap: build the operator at the \
top of the priority list to unlock the most rules."
);
let _ = writeln!(s);
let _ = writeln!(s, "## Overall");
let _ = writeln!(s);
let _ = writeln!(s, "| Metric | Value |");
let _ = writeln!(s, "|---|---|");
let _ = writeln!(s, "| Rule files scanned | {} |", stats.files_total);
let _ = writeln!(
s,
"| Files with YAML parse errors | {} |",
stats.files_parse_error
);
let _ = writeln!(s, "| Total rules | {} |", stats.rules_total);
let _ = writeln!(
s,
"| Rules loaded OK | {} ({:.1}%) |",
stats.rules_loaded,
pct(stats.rules_loaded, stats.rules_total)
);
let _ = writeln!(
s,
"| Rules skipped | {} ({:.1}%) |",
stats.rules_skipped,
pct(stats.rules_skipped, stats.rules_total)
);
let _ = writeln!(s);
let _ = writeln!(
s,
"**Headline load rate: {:.1}%** ({} / {} rules).",
pct(stats.rules_loaded, stats.rules_total),
stats.rules_loaded,
stats.rules_total
);
let _ = writeln!(s);
let _ = writeln!(s, "## Skip-reason histogram");
let _ = writeln!(s);
let _ = writeln!(
s,
"Sorted by frequency. The reason names the operator/key that blocks the \
rule today."
);
let _ = writeln!(s);
let mut sorted: Vec<(&String, &usize)> = stats.skip_histogram.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
let _ = writeln!(s, "| Skip reason | Rules | % of skipped | % of all rules |");
let _ = writeln!(s, "|---|---:|---:|---:|");
for (reason, count) in &sorted {
let _ = writeln!(
s,
"| `{}` | {} | {:.1}% | {:.1}% |",
reason,
count,
pct(**count, stats.rules_skipped),
pct(**count, stats.rules_total)
);
}
let _ = writeln!(s);
let (lang_reasons, op_reasons): (Vec<_>, Vec<_>) = sorted
.iter()
.partition(|(reason, _)| is_language_gap(reason));
let _ = writeln!(s, "## Priority order — operator/feature backlog");
let _ = writeln!(s);
let _ = writeln!(
s,
"Matcher capabilities (implementable in `semgrep_compat.rs` / \
`semgrep_taint.rs`) ranked by how many registry rules each would \
unlock. These are independent of adding new language grammars. Build \
top-down."
);
let _ = writeln!(s);
let _ = writeln!(s, "| Rank | Capability to add | Rules unlocked |");
let _ = writeln!(s, "|---:|---|---:|");
for (rank, (reason, count)) in op_reasons.iter().enumerate() {
let _ = writeln!(s, "| {} | `{}` | {} |", rank + 1, reason, count);
}
let op_total: usize = op_reasons.iter().map(|(_, c)| **c).sum();
let _ = writeln!(s);
let _ = writeln!(
s,
"Operator/feature gaps account for **{} rules** ({:.1}% of all rules). \
Closing the top of this list is the highest-leverage parity work that \
does not require a new parser.",
op_total,
pct(op_total, stats.rules_total)
);
let _ = writeln!(s);
let _ = writeln!(s, "## Priority order — missing language grammars");
let _ = writeln!(s);
let _ = writeln!(
s,
"Rules foxguard cannot run because it has no tree-sitter grammar for the \
target language (a separate, heavier lift than matcher operators)."
);
let _ = writeln!(s);
let _ = writeln!(s, "| Rank | Language to add | Rules unlocked |");
let _ = writeln!(s, "|---:|---|---:|");
for (rank, (reason, count)) in lang_reasons.iter().enumerate() {
let _ = writeln!(s, "| {} | `{}` | {} |", rank + 1, reason, count);
}
let lang_total: usize = lang_reasons.iter().map(|(_, c)| **c).sum();
let _ = writeln!(s);
let _ = writeln!(
s,
"Missing-grammar gaps account for **{} rules** ({:.1}% of all rules).",
lang_total,
pct(lang_total, stats.rules_total)
);
let _ = writeln!(s);
let _ = writeln!(s, "## Per-language breakdown");
let _ = writeln!(s);
let _ = writeln!(
s,
"Language is the rule's first declared language (js/ts/jsx/tsx \
collapsed to `javascript`, matching the loader)."
);
let _ = writeln!(s);
let _ = writeln!(s, "| Language | Total | Loaded | Skipped | Load rate |");
let _ = writeln!(s, "|---|---:|---:|---:|---:|");
let mut langs: Vec<(&String, &(usize, usize))> = stats.per_language.iter().collect();
langs.sort_by(|a, b| {
let at = a.1 .0 + a.1 .1;
let bt = b.1 .0 + b.1 .1;
bt.cmp(&at).then(a.0.cmp(b.0))
});
for (lang, (loaded, skipped)) in &langs {
let total = *loaded + *skipped;
let _ = writeln!(
s,
"| {} | {} | {} | {} | {:.1}% |",
lang,
total,
loaded,
skipped,
pct(*loaded, total)
);
}
let _ = writeln!(s);
let _ = writeln!(s, "## Top skip reasons per language");
let _ = writeln!(s);
for (lang, reasons) in &stats.per_language_reasons {
let mut rs: Vec<(&String, &usize)> = reasons.iter().collect();
if rs.is_empty() {
continue;
}
rs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
let top: Vec<String> = rs
.iter()
.take(3)
.map(|(r, c)| format!("`{r}` ({c})"))
.collect();
let _ = writeln!(s, "- **{}**: {}", lang, top.join(", "));
}
let _ = writeln!(s);
let _ = writeln!(
s,
"---\n\nRegenerate with `cargo run --release --bin registry_coverage -- <registry-dir>`. \
The registry snapshot is gitignored (`.registry-snapshot/`) and never committed."
);
s
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(yaml: &str) -> Yaml {
let doc: Yaml = serde_yaml_ng::from_str(yaml).expect("test fixture is valid YAML");
let rules = doc
.get("rules")
.and_then(Yaml::as_sequence)
.expect("test fixture has a rules: sequence");
rules
.first()
.cloned()
.expect("test fixture has at least one rule")
}
#[test]
fn simple_supported_rule_loads() {
let r = rule(
r#"
rules:
- id: eval-call
pattern: eval(...)
message: no eval
severity: ERROR
languages: [python]
"#,
);
assert!(matches!(classify_rule(&r), Outcome::Loaded));
}
#[test]
fn metavariable_pattern_now_loads() {
let r = rule(
r#"
rules:
- id: mvp
patterns:
- pattern: foo($X)
- metavariable-pattern:
metavariable: $X
pattern: bar(...)
message: m
severity: WARNING
languages: [python]
"#,
);
assert!(matches!(classify_rule(&r), Outcome::Loaded));
}
#[test]
fn focus_metavariable_now_loads() {
let r = rule(
r#"
rules:
- id: focus
patterns:
- pattern: foo($X)
- focus-metavariable: $X
message: m
severity: WARNING
languages: [java]
"#,
);
assert!(matches!(classify_rule(&r), Outcome::Loaded));
}
#[test]
fn generic_mode_simple_pattern_now_loads() {
let r = rule(
r#"
rules:
- id: gen
pattern: foo
message: m
severity: INFO
languages: [generic]
"#,
);
assert!(
matches!(classify_rule(&r), Outcome::Loaded),
"simple generic pattern: rule should now load"
);
}
#[test]
fn generic_mode_patterns_block_now_loads() {
let r = rule(
r#"
rules:
- id: gen-patterns
patterns:
- pattern: ssl_protocols ...
- pattern-not: ssl_protocols TLSv1_3
message: m
severity: WARNING
languages: [generic]
"#,
);
assert!(
matches!(classify_rule(&r), Outcome::Loaded),
"generic patterns: rule should now load"
);
}
#[test]
fn unsupported_language_is_skipped() {
let r = rule(
r#"
rules:
- id: cobol-rule
pattern: foo
message: m
severity: INFO
languages: [cobol]
"#,
);
match classify_rule(&r) {
Outcome::Skipped(reason) => assert_eq!(reason, "unsupported language: cobol"),
other => panic!("expected skip, got {other:?}"),
}
}
#[test]
fn apex_language_loads() {
let r = rule(
r#"
rules:
- id: apex-rule
pattern-regex: 'System\.debug'
message: m
severity: INFO
languages: [apex]
"#,
);
assert!(matches!(classify_rule(&r), Outcome::Loaded));
}
#[test]
fn yaml_language_loads() {
let r = rule(
r#"
rules:
- id: yaml-rule
pattern: "key: $VALUE"
message: found key
severity: INFO
languages: [yaml]
"#,
);
assert!(matches!(classify_rule(&r), Outcome::Loaded));
}
#[test]
fn non_python_taint_is_a_language_gap() {
let r = rule(
r#"
rules:
- id: elixir-taint
mode: taint
pattern-sources:
- pattern: source()
pattern-sinks:
- pattern: sink(...)
message: m
severity: ERROR
languages: [elixir]
"#,
);
match classify_rule(&r) {
Outcome::Skipped(reason) => {
assert!(reason.contains("unsupported language"));
assert!(is_language_gap(&reason));
}
other => panic!("expected skip, got {other:?}"),
}
}
#[test]
fn ruby_taint_now_loads() {
let r = rule(
r#"
rules:
- id: ruby-taint
mode: taint
pattern-sources:
- pattern: gets($X)
pattern-sinks:
- pattern: system($X)
message: m
severity: ERROR
languages: [ruby]
"#,
);
assert!(matches!(classify_rule(&r), Outcome::Loaded));
}
#[test]
fn java_taint_now_loads() {
let r = rule(
r#"
rules:
- id: java-taint
mode: taint
pattern-sources:
- pattern: request.getParameter($X)
pattern-sinks:
- pattern: Runtime.exec($X)
message: m
severity: ERROR
languages: [java]
"#,
);
assert!(matches!(classify_rule(&r), Outcome::Loaded));
}
#[test]
fn language_vs_operator_gap_partition() {
assert!(is_language_gap("unsupported language: hcl"));
assert!(is_language_gap("generic mode (languages: [generic])"));
assert!(!is_language_gap("metavariable-pattern"));
assert!(!is_language_gap("focus-metavariable"));
assert!(!is_language_gap("regex language (no pattern-regex)"));
}
}