use std::collections::BTreeSet;
use rto_graph::{NodeContext, NodeKind, Store, StoreError, build_context, dependents};
use serde::Serialize;
pub const REVIEW_SCHEMA: &str = "roteiro.review/v1";
#[derive(Debug, Serialize)]
pub struct ReviewReport {
pub schema: &'static str,
pub changed_files: usize,
pub files: Vec<FileReview>,
pub drift: Vec<DriftItem>,
pub impacted: Vec<Impacted>,
}
impl ReviewReport {
#[must_use]
pub fn has_drift(&self) -> bool {
!self.drift.is_empty()
}
}
#[derive(Debug, Serialize)]
pub struct FileReview {
pub path: String,
pub status: &'static str,
pub symbols: Vec<SymbolReview>,
pub debt: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct SymbolReview {
pub key: String,
pub name: String,
pub kind: String,
pub callers: Vec<String>,
pub callees: Vec<String>,
pub governed_by: Vec<String>,
pub related: Vec<Related>,
}
#[derive(Debug, Serialize)]
pub struct Related {
pub node: String,
pub confidence: Option<f64>,
}
#[derive(Debug, Serialize)]
pub struct DriftItem {
pub kind: String,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct Impacted {
pub key: String,
pub name: String,
pub kind: String,
}
pub fn build(
store: &Store,
changed: &[rto_graph::ChangedFile],
violations: &[rto_spec::Violation],
) -> Result<ReviewReport, StoreError> {
let changed_paths: BTreeSet<&str> = changed.iter().map(|c| c.path.as_str()).collect();
let mut files = Vec::new();
let mut changed_keys: Vec<String> = Vec::new();
for cf in changed {
if cf.status == rto_graph::ChangeStatus::Deleted {
files.push(FileReview {
path: cf.path.clone(),
status: "deleted",
symbols: Vec::new(),
debt: Vec::new(),
});
continue;
}
let mut symbols = Vec::new();
let mut debt = Vec::new();
for node in store.nodes_by_path(&cf.path)? {
match node.kind {
NodeKind::File => continue,
NodeKind::Marker => {
debt.push(node.name.clone());
continue;
}
_ => {}
}
changed_keys.push(node.key.clone());
let ctx = build_context(store, &node.key)?;
symbols.push(symbol_review(&node, ctx.as_ref()));
}
files.push(FileReview {
path: cf.path.clone(),
status: cf.status.as_str(),
symbols,
debt,
});
}
let mut drift = Vec::new();
for v in violations {
if violation_touches(store, v, &changed_paths)? {
drift.push(DriftItem {
kind: v.kind.label().to_owned(),
message: v.message.clone(),
});
}
}
let changed_set: BTreeSet<&str> = changed_keys.iter().map(String::as_str).collect();
let mut impacted = Vec::new();
for key in dependents(store, &changed_keys)? {
if changed_set.contains(key.as_str()) {
continue;
}
let Some(node) = store.get_node(&key)? else {
continue;
};
if node
.path
.as_deref()
.is_some_and(|p| changed_paths.contains(p))
{
continue;
}
impacted.push(Impacted {
key: node.key,
name: node.name,
kind: node.kind.as_str().to_owned(),
});
}
Ok(ReviewReport {
schema: REVIEW_SCHEMA,
changed_files: changed.len(),
files,
drift,
impacted,
})
}
fn violation_touches(
store: &Store,
violation: &rto_spec::Violation,
changed_paths: &BTreeSet<&str>,
) -> Result<bool, StoreError> {
if changed_paths.iter().any(|p| violation.message.contains(p)) {
return Ok(true);
}
if let Some((key, _)) = violation.message.split_once(": ")
&& let Some(node) = store.get_node(key)?
{
return Ok(node
.path
.as_deref()
.is_some_and(|p| changed_paths.contains(p)));
}
Ok(false)
}
#[allow(clippy::similar_names)]
fn symbol_review(node: &rto_graph::Node, ctx: Option<&NodeContext>) -> SymbolReview {
let mut callers = Vec::new();
let mut callees = Vec::new();
let mut governed_by = Vec::new();
let mut related = Vec::new();
if let Some(ctx) = ctx {
for e in &ctx.incoming {
if e.kind == "calls" {
callers.push(e.node.clone());
}
if e.provenance == "authored" {
governed_by.push(e.node.clone());
}
if e.kind == "related" {
related.push(Related {
node: e.node.clone(),
confidence: e.confidence,
});
}
}
for e in &ctx.outgoing {
if e.kind == "calls" {
callees.push(e.node.clone());
}
if e.kind == "related" {
related.push(Related {
node: e.node.clone(),
confidence: e.confidence,
});
}
}
}
SymbolReview {
key: node.key.clone(),
name: node.name.clone(),
kind: node.kind.as_str().to_owned(),
callers,
callees,
governed_by,
related,
}
}
pub fn run_score(run_path: &str, corpus_path: Option<&str>, json: bool) -> anyhow::Result<()> {
use rto_graph::review_corpus::Corpus;
use rto_graph::review_score::{CandidateRun, score};
let corpus = match corpus_path {
Some(path) => {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("reading corpus {path}: {e}"))?;
Corpus::parse(&text).map_err(|e| anyhow::anyhow!("{path}: {e}"))?
}
None => rto_graph::review_corpus::builtin()?,
};
let text = std::fs::read_to_string(run_path)
.map_err(|e| anyhow::anyhow!("reading run {run_path}: {e}"))?;
let run = CandidateRun::parse(&text).map_err(|e| anyhow::anyhow!("{run_path}: {e}"))?;
let scored = score(&corpus, &run)?;
if json {
crate::emit_json(&scored)?;
} else {
print_score(&scored);
}
Ok(())
}
fn print_score(score: &rto_graph::review_score::Score) {
println!(
"scored {} of {} corpus commit(s)",
score.attempted_shas, score.corpus_shas
);
println!("\nrecall by defect class (found/real):");
for class in &score.per_class {
if class.real == 0 {
continue;
}
let rate = match class.recall() {
Some(r) => format!("{:>4.0}%", r * 100.0),
None => " —".to_owned(),
};
let weight = if class.real == 1 { " (n=1)" } else { "" };
println!(
" {rate} {:>2}/{:<2} {}{weight}",
class.found,
class.real,
class.class.as_str()
);
if class.misclassified > 0 {
println!(
" {} found but labelled as another class",
class.misclassified
);
}
for miss in &class.missed {
println!(
" miss {}:{} — {}",
miss.path, miss.line, miss.description
);
println!(" {}", miss.comment_url);
}
}
println!(
"\n{}/{} real defect(s) found; {}/{} known-false claim(s) reproduced",
score.found, score.real_in_scope, score.known_false_reproduced, score.known_false_in_scope
);
match score.corpus_precision() {
Some(p) => println!(
"precision over adjudicated findings only: {:.0}% \
({} unadjudicated finding(s) excluded — see below)",
p * 100.0,
score.unadjudicated
),
None => println!(
"precision: not computable — no finding matched an adjudicated row \
({} unadjudicated)",
score.unadjudicated
),
}
if score.suppressed_real + score.suppressed_known_false + score.suppressed_unadjudicated > 0 {
println!(
"suppression filter withheld: {} real, {} known-false, {} unadjudicated",
score.suppressed_real, score.suppressed_known_false, score.suppressed_unadjudicated
);
}
let caveats = score.caveats();
if !caveats.is_empty() {
println!("\nread these numbers with:");
for caveat in &caveats {
println!(" - {caveat}");
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn json_schema_tags_are_frozen() {
assert_eq!(super::REVIEW_SCHEMA, "roteiro.review/v1");
assert_eq!(
rto_graph::review_score::SCORE_SCHEMA,
"roteiro.review-score/v1"
);
assert_eq!(rto_graph::review_score::RUN_SCHEMA, "roteiro.review-run/v1");
assert_eq!(rto_graph::SCHEMA, "roteiro.query/v1");
assert_eq!(rto_graph::ARTIFACT_SCHEMA, "roteiro.graph/v1");
assert_eq!(rto_graph::ORACLE_SCHEMA, "roteiro.oracle/v1");
assert_eq!(rto_spec::SPEC_SCHEMA, "roteiro.spec/v1");
}
}