Skip to main content

devscout_rs/truth/
producer_reader.rs

1//! Reads actual producer output into observations, rather than hand-building
2//! them from a manifest's own expectations.
3//!
4//! Three readers, each grounded in bytes already committed to this
5//! repository: the Roslyn oracle's `refs.jsonl`/`units.jsonl`
6//! (`fixtures/csharp-semantic/oracle/`), the flow-tracer's `facts.json`
7//! (`fixtures/csharp-flowtrace/facts.json`), and devscout's own native
8//! extract-and-resolve pipeline (`crate::extract`, `crate::graph`,
9//! `crate::resolve`), run here exactly as `devscout map` runs it -- offline,
10//! no compiler subprocess, no network. [`native_dispatch_implements_edges`]
11//! is what grades the
12//! `type-argument-pair-produces-implements-edges-on-a-clean-build` red row:
13//! the edges it returns are not asserted, they are the resolver's own
14//! output for the fixture it is handed.
15
16use std::path::Path;
17
18use serde::Deserialize;
19
20use crate::graph::Edge;
21
22/// One line of the Roslyn oracle's `refs.jsonl`.
23///
24/// A ground-truth reference the oracle exporter recorded for one
25/// call/access site. Only the fields this harness grades against are kept;
26/// the exporter's own richer shape is read past rather than mirrored field
27/// for field.
28#[derive(Debug, Clone, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct OracleRef {
31    /// The repository-relative source file.
32    pub file: String,
33    /// The one-based source line the exporter recorded.
34    pub line: u32,
35    /// The member name the reference resolved to.
36    pub member: String,
37    /// The resolved target's fully qualified name, when the exporter
38    /// resolved one.
39    pub target: Option<String>,
40    /// Whether the exporter classified this reference as external.
41    pub external: bool,
42    /// Whether the exporter classified this reference as ambiguous.
43    pub ambiguous: bool,
44    /// Which compiled unit (project) this reference belongs to.
45    pub unit: String,
46}
47
48/// Parses `refs.jsonl`: one [`OracleRef`] per non-empty line, in file order.
49/// A malformed line aborts the parse and names which one.
50pub fn read_oracle_refs(text: &str) -> Result<Vec<OracleRef>, String> {
51    text.lines()
52        .enumerate()
53        .filter(|(_, l)| !l.trim().is_empty())
54        .map(|(i, l)| {
55            serde_json::from_str(l).map_err(|e| format!("refs.jsonl line {}: {e}", i + 1))
56        })
57        .collect()
58}
59
60/// One line of the Roslyn oracle's `units.jsonl`: one compiled project and
61/// the build health the exporter observed for it.
62#[derive(Debug, Clone, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct OracleUnit {
65    /// The project's name.
66    pub name: String,
67    /// The target framework moniker this unit was built for.
68    pub tfm: String,
69    /// The build status the exporter recorded (`"ok"` or otherwise).
70    pub status: String,
71    /// How many compiler diagnostics the exporter recorded for this unit.
72    pub diagnostics: u32,
73    /// Every source file this unit compiled.
74    pub files: Vec<String>,
75}
76
77/// Parses `units.jsonl`: one [`OracleUnit`] per non-empty line.
78pub fn read_oracle_units(text: &str) -> Result<Vec<OracleUnit>, String> {
79    text.lines()
80        .enumerate()
81        .filter(|(_, l)| !l.trim().is_empty())
82        .map(|(i, l)| {
83            serde_json::from_str(l).map_err(|e| format!("units.jsonl line {}: {e}", i + 1))
84        })
85        .collect()
86}
87
88/// One flow-tracer fact from `facts.json`.
89///
90/// Shape varies by `kind` (`"type"` on the wire); every field the eight
91/// registered fact kinds can carry is kept optional here rather than
92/// modeled as one enum per kind, so a fact kind this reader does not
93/// specifically grade against still parses rather than aborting the whole
94/// document.
95#[derive(Debug, Clone, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct FlowtraceFact {
98    /// The fact's kind (`"publish"`, `"consume"`, `"route"`,
99    /// `"di_binding"`, `"message_class"`, `"iface_impl"`, `"ctor_field"`,
100    /// `"method_span"`).
101    #[serde(rename = "type")]
102    pub kind: String,
103    /// The repository-relative source file.
104    pub file: String,
105    /// The one-based source line the tracer recorded.
106    pub line: u32,
107    /// The declaring class, when this fact kind carries one.
108    #[serde(default)]
109    pub class: Option<String>,
110    /// The message/event type name, for `publish`/`consume`/`message_class`
111    /// facts.
112    #[serde(default)]
113    pub message: Option<String>,
114    /// The fully qualified name the tracer resolved, when it carries one.
115    #[serde(default)]
116    pub fqn: Option<String>,
117}
118
119#[derive(Debug, Clone, Deserialize)]
120struct FlowtraceDocument {
121    facts: Vec<FlowtraceFact>,
122}
123
124/// Parses `facts.json`'s flat `facts` array, dropping the header fields this
125/// harness does not grade against.
126pub fn read_flowtrace_facts(text: &str) -> Result<Vec<FlowtraceFact>, String> {
127    let doc: FlowtraceDocument =
128        serde_json::from_str(text).map_err(|e| format!("facts.json: {e}"))?;
129    Ok(doc.facts)
130}
131
132/// Runs devscout's own native extract-and-resolve pipeline over one C#
133/// source file -- the same two calls `devscout map` makes on every mapped
134/// file -- and returns every `implements` edge it produced.
135///
136/// This is real analyzer output, not an assertion about what the resolver
137/// would do: a caller grading the
138/// `type-argument-pair-produces-implements-edges-on-a-clean-build` red row
139/// against this function's result is grading the resolver's own decision,
140/// the same one `devscout map` would persist to `graph.json` for this file.
141pub fn native_dispatch_implements_edges(root: &Path, rel_path: &str, source: &str) -> Vec<Edge> {
142    let extraction = crate::extract::extract(source);
143    let fragment = crate::graph::fragment_from_extraction(&extraction);
144    let fragments = vec![(rel_path.to_string(), fragment)];
145    let graph = crate::resolve::resolve_graph(root, &fragments);
146    graph
147        .edges
148        .into_iter()
149        .filter(|e| matches!(e, Edge::Implements { .. }))
150        .collect()
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    const SAMPLE_REFS: &str = "{\"file\":\"src/App/Repo.cs\",\"line\":7,\"member\":\"Load\",\"target\":\"Fixture.Domain.Order\",\"external\":false,\"ambiguous\":false,\"unit\":\"App\"}\n";
158
159    #[test]
160    fn oracle_refs_parse_one_record_per_line() {
161        let refs = read_oracle_refs(SAMPLE_REFS).unwrap();
162        assert_eq!(refs.len(), 1);
163        assert_eq!(refs[0].member, "Load");
164        assert!(!refs[0].external);
165    }
166
167    #[test]
168    fn oracle_refs_skip_blank_lines_and_name_a_malformed_one() {
169        let text = format!("{SAMPLE_REFS}\n{{not json}}\n");
170        let err = read_oracle_refs(&text).unwrap_err();
171        assert!(
172            err.contains("line 3"),
173            "error must name the bad line: {err}"
174        );
175    }
176
177    #[test]
178    fn oracle_units_parse_status_and_diagnostics() {
179        let text = "{\"name\":\"App\",\"tfm\":\"net9.0\",\"status\":\"ok\",\"diagnostics\":0,\"files\":[\"src/App/Repo.cs\"]}\n";
180        let units = read_oracle_units(text).unwrap();
181        assert_eq!(units.len(), 1);
182        assert_eq!(units[0].status, "ok");
183        assert_eq!(units[0].diagnostics, 0);
184    }
185
186    #[test]
187    fn flowtrace_facts_parse_the_flat_facts_array() {
188        let text = r#"{"schemaVersion":1,"facts":[{"type":"publish","file":"src/Api/Program.cs","line":25,"message":"ParcelDispatchedMessage","fqn":"Courier.Api.Messaging.Messages.ParcelDispatchedMessage"}]}"#;
189        let facts = read_flowtrace_facts(text).unwrap();
190        assert_eq!(facts.len(), 1);
191        assert_eq!(facts[0].kind, "publish");
192        assert_eq!(facts[0].message.as_deref(), Some("ParcelDispatchedMessage"));
193    }
194
195    #[test]
196    fn native_dispatch_reads_the_committed_oracle_and_flowtrace_bytes() {
197        // Grounds this reader in the bytes already committed to the
198        // repository, not just synthetic strings above.
199        let refs_text =
200            std::fs::read_to_string("fixtures/csharp-semantic/oracle/refs.jsonl").unwrap();
201        let refs = read_oracle_refs(&refs_text).unwrap();
202        assert!(refs.iter().any(|r| r.member == "Load" && !r.external));
203
204        let units_text =
205            std::fs::read_to_string("fixtures/csharp-semantic/oracle/units.jsonl").unwrap();
206        let units = read_oracle_units(&units_text).unwrap();
207        assert!(units.iter().any(|u| u.name == "App" && u.status == "ok"));
208
209        let facts_text = std::fs::read_to_string("fixtures/csharp-flowtrace/facts.json").unwrap();
210        let facts = read_flowtrace_facts(&facts_text).unwrap();
211        assert!(facts.iter().any(|f| f.kind == "publish"));
212    }
213
214    #[test]
215    fn native_dispatch_reproduces_the_counterexample_from_real_resolver_output() {
216        let source =
217            std::fs::read_to_string("fixtures/csharp-truth/src/NativeDispatchCounterexample.cs")
218                .unwrap();
219        let edges = native_dispatch_implements_edges(
220            Path::new(env!("CARGO_MANIFEST_DIR")),
221            "src/NativeDispatchCounterexample.cs",
222            &source,
223        );
224        assert!(
225            !edges.is_empty(),
226            "the two-type-argument generic call on an unrelated class must still \
227             produce implements edges from today's resolver -- if this now emits \
228             nothing, the fifth red-baseline row is stale and must be re-reviewed, \
229             not silently kept"
230        );
231    }
232}