1use std::fs;
4use std::io::{self, Write};
5use std::path::Path;
6
7use devscout_rs::{graph, query};
8use serde::Deserialize;
9use serde_json::{json, Value};
10
11#[derive(Deserialize)]
12struct Spec {
13 candidates: Vec<usize>,
14 seeds: Vec<String>,
15}
16
17fn admit(index: &mut query::GraphIndex<'_>, numbers: &[usize]) -> Result<(), String> {
18 for &number in numbers {
19 let Some(graph::Edge::UsesMember {
20 to,
21 heuristic: true,
22 tier: Some(graph::HeuristicTier::Ext),
23 ..
24 }) = index.graph.edges.get(number)
25 else {
26 return Err(format!("candidate {number} is not extension evidence"));
27 };
28 let entry = index
29 .heuristic_inbound
30 .get_mut(to)
31 .ok_or_else(|| format!("candidate {number} has no heuristic adjacency"))?;
32 let position = entry
33 .uses_member
34 .iter()
35 .position(|&value| value == number)
36 .ok_or_else(|| format!("candidate {number} is missing or duplicated"))?;
37 entry.uses_member.remove(position);
38 index
39 .inbound
40 .entry(to.clone())
41 .or_default()
42 .uses_member
43 .push(number);
44 }
45 Ok(())
46}
47
48fn answer(index: &query::GraphIndex<'_>, seed: &str, cap: usize) -> Result<Value, String> {
49 let query::ImpactResult::Resolved(model) = query::build_impact_model(
50 index,
51 seed,
52 query::DEFAULT_HOPS,
53 cap,
54 true,
55 query::DEFAULT_IFACE_MAX_FANIN,
56 query::DEFAULT_HUB_MAX_INDEGREE,
57 ) else {
58 return Err(format!("seed is not uniquely resolved: {seed}"));
59 };
60 Ok(json!({
61 "rows": model.rows.iter().map(|row| json!({
62 "file": row.file, "hop": row.hop, "heuristic": row.heuristic,
63 "why": row.why.as_str(), "from_lines": row.from_lines,
64 "symbols": row.top_symbols, "infra": row.infra,
65 })).collect::<Vec<_>>(),
66 "dropped": model.dropped,
67 "total_affected": model.total_affected,
68 "heuristic_affected": model.heuristic_affected,
69 "tests_affected": model.tests_affected,
70 "braked_interfaces": model.braked.iter().map(|b| json!({
71 "iface": b.iface, "fanin": b.fanin,
72 })).collect::<Vec<_>>(),
73 "braked_files": model.braked_files.iter().map(|b| json!({
74 "file": b.file, "indegree": b.indegree,
75 })).collect::<Vec<_>>(),
76 }))
77}
78
79fn truth(index: &query::GraphIndex<'_>, seed: &str) -> Result<Value, String> {
80 let query::SeedResolution::Resolved { ids, .. } = query::resolve_impact_seed(index, seed)
81 else {
82 return Err(format!("oracle seed is not uniquely resolved: {seed}"));
83 };
84 let walk = query::impact_walk(index, &ids, query::DEFAULT_HOPS, true, 0, 0);
86 Ok(json!({"rows": walk.visited.iter()
87 .filter(|(_, row)| row.via_count > 0 || row.ambiguous_count > 0)
88 .map(|(file, row)| json!({"file": file, "hop": row.hop, "heuristic": false}))
89 .collect::<Vec<_>>() }))
90}
91
92fn run(directory: &Path) -> Result<(), Box<dyn std::error::Error>> {
93 let baseline: graph::Graph =
94 serde_json::from_slice(&fs::read(directory.join("baseline.json"))?)?;
95 let oracle: graph::Graph = serde_json::from_slice(&fs::read(directory.join("oracle.json"))?)?;
96 let spec: Spec = serde_json::from_slice(&fs::read(directory.join("spec.json"))?)?;
97 let root = directory.join("unmapped");
98 let baseline_index = query::load_graph_index(&baseline, &root);
99 let mut candidate_index = query::load_graph_index(&baseline, &root);
100 let oracle_index = query::load_graph_index(&oracle, &root);
101 admit(&mut candidate_index, &spec.candidates)?;
102 let stdout = io::stdout();
103 let mut out = io::BufWriter::new(stdout.lock());
104 for seed in spec.seeds {
105 let record = json!({
106 "seed": seed,
107 "baseline": answer(&baseline_index, &seed, query::DEFAULT_CAP)?,
108 "candidate": answer(&candidate_index, &seed, query::DEFAULT_CAP)?,
109 "oracle": truth(&oracle_index, &seed)?,
110 });
111 serde_json::to_writer(&mut out, &record)?;
112 writeln!(out)?;
113 }
114 Ok(())
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 fn fixture() -> graph::Graph {
122 serde_json::from_value(json!({
123 "schema_version": 3, "built_at_head": null,
124 "defs": (["Alpha", "Beta", "Gamma", "Delta", "Epsilon"].iter().map(|name| json!({
125 "id": name, "name": name, "namespace": "", "kind": "class",
126 "file": format!("{name}.cs"), "line": 1, "methods": ["Run"],
127 })).collect::<Vec<_>>()),
128 "edges": [
129 graph::Edge::uses_member("Beta.cs".into(), 7, "Alpha".into(), "Alpha.cs".into(),
130 Some("Run".into()), Some(graph::HeuristicTier::Ext)),
131 graph::Edge::uses_member("Gamma.cs".into(), 7, "Beta".into(), "Beta.cs".into(),
132 Some("Run".into()), Some(graph::HeuristicTier::Ext)),
133 graph::Edge::uses_member("Delta.cs".into(), 7, "Gamma".into(), "Gamma.cs".into(),
134 Some("Run".into()), Some(graph::HeuristicTier::Guess)),
135 graph::Edge::uses_member("Epsilon.cs".into(), 7, "Delta".into(), "Delta.cs".into(),
136 Some("Run".into()), None),
137 ],
138 "stats": {"def_count": 5, "file_count": 5, "edges_by_kind": graph::EdgesByKind::default(),
139 "ambiguous_count": 0, "ambiguous_pct": 0.0, "unresolved_external_count": 0},
140 })).unwrap()
141 }
142
143 #[test]
144 fn two_extension_hops_add_a_file_without_changing_graph_evidence() {
145 let graph = fixture();
146 let before = graph.clone();
147 let mut index = query::load_graph_index(&graph, Path::new("unmapped"));
148 assert_eq!(
149 answer(&index, "Alpha.cs", 50).unwrap()["rows"]
150 .as_array()
151 .unwrap()
152 .len(),
153 1
154 );
155 admit(&mut index, &[0, 1]).unwrap();
156 let result = answer(&index, "Alpha.cs", 50).unwrap();
157 let rows = result["rows"].as_array().unwrap();
158 assert!(rows
159 .iter()
160 .any(|row| row["file"] == "Gamma.cs" && row["hop"] == 2));
161 assert!(!rows.iter().any(|row| row["file"] == "Delta.cs"));
162 assert_eq!(graph, before);
163 }
164
165 #[test]
166 fn guess_cannot_be_admitted_or_used_to_reach_its_caller() {
167 let graph = fixture();
168 let mut index = query::load_graph_index(&graph, Path::new("unmapped"));
169 assert!(admit(&mut index, &[2]).is_err());
170 let result = answer(&index, "Gamma.cs", 50).unwrap();
171 let rows = result["rows"].as_array().unwrap();
172 assert_eq!(rows.len(), 1);
173 assert_eq!(rows[0]["file"], "Delta.cs");
174 assert_eq!(rows[0]["heuristic"], true);
175 }
176
177 #[test]
178 fn oracle_truth_is_not_truncated_by_a_numerical_hub_brake() {
179 let mut graph = fixture();
180 graph.edges[0] = graph::Edge::uses_member(
181 "Beta.cs".into(),
182 7,
183 "Alpha".into(),
184 "Alpha.cs".into(),
185 Some("Run".into()),
186 None,
187 );
188 graph.edges[1] = graph::Edge::uses_member(
189 "Gamma.cs".into(),
190 7,
191 "Beta".into(),
192 "Beta.cs".into(),
193 Some("Run".into()),
194 None,
195 );
196 for number in 0..35 {
197 let mut definition = graph.defs[0].clone();
198 definition.id = format!("Caller{number}");
199 definition.name = definition.id.clone();
200 definition.file = format!("Caller{number}.cs");
201 graph.edges.push(graph::Edge::uses_member(
202 definition.file.clone(),
203 7,
204 "Beta".into(),
205 "Beta.cs".into(),
206 Some("Run".into()),
207 None,
208 ));
209 graph.defs.push(definition);
210 }
211 let index = query::load_graph_index(&graph, Path::new("unmapped"));
212 let predicted = answer(&index, "Alpha.cs", 50).unwrap();
213 let expected = truth(&index, "Alpha.cs").unwrap();
214 assert!(!predicted["rows"]
215 .as_array()
216 .unwrap()
217 .iter()
218 .any(|r| r["file"] == "Gamma.cs"));
219 assert!(expected["rows"]
220 .as_array()
221 .unwrap()
222 .iter()
223 .any(|r| r["file"] == "Gamma.cs"));
224 }
225
226 #[test]
227 fn an_empty_cohort_restores_the_baseline_answer() {
228 let graph = fixture();
229 let baseline = query::load_graph_index(&graph, Path::new("unmapped"));
230 let mut suppressed = query::load_graph_index(&graph, Path::new("unmapped"));
231 admit(&mut suppressed, &[]).unwrap();
232 assert_eq!(
233 answer(&baseline, "Alpha.cs", 50),
234 answer(&suppressed, "Alpha.cs", 50)
235 );
236 }
237}
238
239fn main() -> Result<(), Box<dyn std::error::Error>> {
240 let directory = std::env::args_os()
241 .nth(1)
242 .ok_or("usage: extension_impact <replay-directory>")?;
243 run(Path::new(&directory))
244}