rto_graph/review_corpus.rs
1//! The adjudicated review corpus, as a type (Stage 35).
2//!
3//! `crates/rto-graph/tests/fixtures/review/review-corpus.jsonl` records what an
4//! automated reviewer said about specific commits of this repository and what the
5//! maintainer decided about each comment. It existed with **no consumer**, which
6//! is how a fixture rots: nothing held its shape except a test that re-parsed it
7//! as untyped JSON, and nothing could *score* a reviewer against it.
8//!
9//! This module is that consumer's foundation — the corpus as a value, so that
10//! [`crate::review_score`] can compute recall from it and a future reviewer can be
11//! measured rather than guessed at. See the fixture's `README.md` for what each
12//! field means and `docs/REVIEW_CHECKLIST.md` for the adjudication rule that
13//! decides a [`Verdict`].
14//!
15//! # The field set is enforced by the type, not by a test
16//!
17//! [`CorpusRow`] is `deny_unknown_fields` with no optional fields, so a row with
18//! an extra, missing or misspelled key fails to deserialise. The schema check that
19//! used to compare key sets by hand is thereby structural: a corpus this crate can
20//! load is a corpus with exactly the eleven documented fields.
21//!
22//! # Loading never touches the network
23//!
24//! The corpus is a **historical record**: the rows describe what a reviewer said
25//! about a particular tree at a particular moment, and that must not change
26//! because a comment was later edited or a thread resolved. So there is no
27//! "refresh from the GitHub API" here, and there must not be one — this crate's
28//! `gix` dependency is pinned without transports precisely so that such a call
29//! cannot be written (the same reasoning as [`crate::model_choice`]).
30//!
31//! # Not [`crate::findings`]
32//!
33//! `findings` models *analyzer* findings (ADR-0012): store-backed, keyed by
34//! analyzer identity, owned by an [`crate::AnalysisRun`] that records a runner,
35//! an isolation mode and an advisory-database digest. A corpus row is none of
36//! that — it is an adjudicated opinion about a commit, held in a file, never in
37//! `nodes`/`edges` and never in a table. Reusing `Finding` would drag persistence
38//! and analyzer provenance into a scorer whose whole value is being pure and
39//! offline.
40
41use std::collections::{BTreeMap, BTreeSet};
42
43use serde::{Deserialize, Serialize};
44
45/// Highest pull-request number for which the corpus is the **complete, unfiltered**
46/// set of that reviewer's comments.
47///
48/// Rows up to and including this PR are every comment the reviewer left on those
49/// twelve PRs — nothing dropped — so a ratio computed over them means something.
50/// Later rows are *selected* comments, added because they extended a class; a
51/// ratio over all rows is therefore slightly biased toward the class that was
52/// selected for. [`Corpus::complete_subset`] is how a caller restricts to the
53/// meaningful part, and the fixture README states the same boundary in prose.
54pub const COMPLETE_THROUGH_PR: u32 = 343;
55
56/// The corpus this repository ships, embedded at compile time.
57///
58/// Embedded rather than read from disk so that `roteiro review --score` works from
59/// any directory and from an installed binary: the corpus is a fixed historical
60/// record, so there is nothing for a copy to go stale against. It also makes the
61/// fixture a *shipped asset* rather than test-only data, which is the point of
62/// giving it a consumer at all.
63pub const BUILTIN: &str = include_str!("../tests/fixtures/review/review-corpus.jsonl");
64
65/// The embedded corpus, parsed.
66///
67/// # Errors
68/// [`CorpusError`] if the shipped file is malformed — which the crate's own tests
69/// make impossible, so a caller may reasonably treat this as infallible.
70pub fn builtin() -> Result<Corpus, CorpusError> {
71 Corpus::parse(BUILTIN)
72}
73
74/// What the maintainer decided about a comment.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
76#[serde(rename_all = "lowercase")]
77pub enum Verdict {
78 /// A genuine defect: accepted, and fixed by a commit.
79 Real,
80 /// The claim was wrong: refuted in a maintainer reply.
81 False,
82}
83
84impl Verdict {
85 /// Stable token (`real` | `false`), as it appears in the corpus file.
86 #[must_use]
87 pub fn as_str(self) -> &'static str {
88 match self {
89 Self::Real => "real",
90 Self::False => "false",
91 }
92 }
93}
94
95impl std::fmt::Display for Verdict {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.write_str(self.as_str())
98 }
99}
100
101/// The kind of defect a comment asserted.
102///
103/// The vocabulary is closed: adding a variant means the corpus README's class
104/// table gains a row, and `review_corpus.rs`'s table check holds the two together.
105/// Variants are named for what goes wrong, not for the subsystem it happens in,
106/// because the useful question a score answers is "which *kinds* of defect can
107/// this reviewer see?".
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
109#[serde(rename_all = "kebab-case")]
110pub enum DefectClass {
111 /// A guard stops a cleanup path doing its job.
112 CleanupGap,
113 /// A doc, comment or ADR contradicts the code it describes.
114 ContractDrift,
115 /// An error message does not state the rule it enforces.
116 ErrorTextDrift,
117 /// Asserts the code will not build. **Every row in this class is a false
118 /// positive** — see [`crate::compile_claim`], which is the suppression rule
119 /// that measurement licenses.
120 FalseCompileClaim,
121 /// A suppression lacks the justification the house style requires.
122 LintConvention,
123 /// A key derived from a lossy conversion, so distinct inputs collide.
124 LossyIdentity,
125 /// An early return skips a documented side effect.
126 MissingEvent,
127 /// An aggregate computed after the mutation it must precede.
128 OrderingBug,
129 /// The implementation defeats a field's stated design goal.
130 PerfContract,
131 /// A constraint permits the state it exists to forbid.
132 PermissiveConstraint,
133 /// Wording only.
134 ProseClarity,
135 /// A read or copy drops a remainder without erroring.
136 SilentTruncation,
137 /// A message tells the user to do the wrong thing.
138 UxDiagnostic,
139 /// A test passes while the behaviour it names is broken.
140 VacuousTest,
141}
142
143/// Every class, in the order a report prints them (the corpus file's own
144/// alphabetical order, so a diff of two reports lines up).
145pub const CLASSES: [DefectClass; 14] = [
146 DefectClass::CleanupGap,
147 DefectClass::ContractDrift,
148 DefectClass::ErrorTextDrift,
149 DefectClass::FalseCompileClaim,
150 DefectClass::LintConvention,
151 DefectClass::LossyIdentity,
152 DefectClass::MissingEvent,
153 DefectClass::OrderingBug,
154 DefectClass::PerfContract,
155 DefectClass::PermissiveConstraint,
156 DefectClass::ProseClarity,
157 DefectClass::SilentTruncation,
158 DefectClass::UxDiagnostic,
159 DefectClass::VacuousTest,
160];
161
162impl DefectClass {
163 /// Stable token as it appears in the corpus file (`contract-drift`, …).
164 ///
165 /// Written out rather than derived from the `serde` rename so that a report
166 /// does not have to serialise a value to name it;
167 /// `as_str_matches_the_serialised_form` holds the two together.
168 #[must_use]
169 pub fn as_str(self) -> &'static str {
170 match self {
171 Self::CleanupGap => "cleanup-gap",
172 Self::ContractDrift => "contract-drift",
173 Self::ErrorTextDrift => "error-text-drift",
174 Self::FalseCompileClaim => "false-compile-claim",
175 Self::LintConvention => "lint-convention",
176 Self::LossyIdentity => "lossy-identity",
177 Self::MissingEvent => "missing-event",
178 Self::OrderingBug => "ordering-bug",
179 Self::PerfContract => "perf-contract",
180 Self::PermissiveConstraint => "permissive-constraint",
181 Self::ProseClarity => "prose-clarity",
182 Self::SilentTruncation => "silent-truncation",
183 Self::UxDiagnostic => "ux-diagnostic",
184 Self::VacuousTest => "vacuous-test",
185 }
186 }
187
188 /// The class from its corpus token, or `None` if it names no class.
189 #[must_use]
190 pub fn from_token(token: &str) -> Option<Self> {
191 CLASSES.into_iter().find(|c| c.as_str() == token)
192 }
193}
194
195impl std::fmt::Display for DefectClass {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 f.write_str(self.as_str())
198 }
199}
200
201/// One adjudicated review comment.
202///
203/// `deny_unknown_fields` and no `Option`s: the eleven documented fields, exactly.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct CorpusRow {
207 /// GitHub review-comment id — the primary key, unique across the corpus.
208 pub id: u64,
209 /// Pull-request number.
210 pub pr: u32,
211 /// Which reviewer produced it.
212 pub reviewer: String,
213 /// **The commit the comment was made against** — the comment's
214 /// `original_commit_id`, the tree the reviewer was looking at.
215 ///
216 /// Never the merged PR head. The merged head contains the *fix* commits, so a
217 /// reviewer scored against it is asked to find defects that are no longer
218 /// there and will appear to have missed all of them. The fixture README states
219 /// how to reconstruct the diff this names, and the integration test
220 /// `every_row_reconstructs_a_non_empty_reviewed_diff` holds that recipe to the
221 /// data — the prose form of it had already gone wrong for most of the rows.
222 pub reviewed_sha: String,
223 /// File the comment is anchored to.
224 pub path: String,
225 /// Line in that file, new-side.
226 pub line: u32,
227 /// What the maintainer decided.
228 pub verdict: Verdict,
229 /// The kind of defect asserted.
230 pub defect_class: DefectClass,
231 /// Short sha of the commit that fixed it, or empty where no single commit is
232 /// attributable (three rows legitimately have none — a blank is honest where a
233 /// plausible-looking guess would corrupt every future score).
234 pub fix_commit: String,
235 /// One line stating the defect, or stating why the claim is wrong.
236 pub description: String,
237 /// Permalink to the original comment.
238 pub comment_url: String,
239}
240
241/// Why a corpus file could not be loaded. Every variant names the 1-based line so
242/// a maintainer is not left grepping.
243#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
244pub enum CorpusError {
245 /// A line was not a JSON object matching [`CorpusRow`] — a bad type, an
246 /// unknown key, a missing key, or a token outside a documented vocabulary.
247 #[error("line {line}: {message}")]
248 Malformed {
249 /// 1-based line number in the corpus file.
250 line: usize,
251 /// The `serde_json` message, which names the offending field.
252 message: String,
253 },
254 /// A field was well-typed but not a usable value.
255 #[error("line {line}: {field} {message}")]
256 Invalid {
257 /// 1-based line number in the corpus file.
258 line: usize,
259 /// The field at fault.
260 field: &'static str,
261 /// What is wrong with it.
262 message: String,
263 },
264 /// Two rows carry the same comment id. The id is the primary key, and a
265 /// repeat would double-count that comment in every score computed from the
266 /// file.
267 #[error("line {line}: duplicate comment id {id}, first seen on line {first}")]
268 DuplicateId {
269 /// 1-based line number of the repeat.
270 line: usize,
271 /// 1-based line number of the first occurrence.
272 first: usize,
273 /// The repeated id.
274 id: u64,
275 },
276}
277
278/// The corpus: adjudicated rows, in file order.
279#[derive(Debug, Clone, Default, PartialEq, Eq)]
280pub struct Corpus {
281 rows: Vec<CorpusRow>,
282}
283
284impl Corpus {
285 /// Parse a corpus from JSONL text. Blank lines are skipped; everything else
286 /// must be a valid [`CorpusRow`].
287 ///
288 /// # Errors
289 /// [`CorpusError`] naming the offending line.
290 pub fn parse(text: &str) -> Result<Self, CorpusError> {
291 let mut rows = Vec::new();
292 let mut seen: BTreeMap<u64, usize> = BTreeMap::new();
293 for (idx, raw) in text.lines().enumerate() {
294 let line = idx + 1;
295 if raw.trim().is_empty() {
296 continue;
297 }
298 let row: CorpusRow = serde_json::from_str(raw).map_err(|e| CorpusError::Malformed {
299 line,
300 message: e.to_string(),
301 })?;
302 validate(&row, line)?;
303 if let Some(&first) = seen.get(&row.id) {
304 return Err(CorpusError::DuplicateId {
305 line,
306 first,
307 id: row.id,
308 });
309 }
310 seen.insert(row.id, line);
311 rows.push(row);
312 }
313 Ok(Self { rows })
314 }
315
316 /// The rows, in file order.
317 #[must_use]
318 pub fn rows(&self) -> &[CorpusRow] {
319 &self.rows
320 }
321
322 /// How many rows the corpus holds.
323 #[must_use]
324 pub fn len(&self) -> usize {
325 self.rows.len()
326 }
327
328 /// Whether the corpus is empty.
329 #[must_use]
330 pub fn is_empty(&self) -> bool {
331 self.rows.is_empty()
332 }
333
334 /// The rows over which a *ratio* is meaningful: the PRs whose comments were
335 /// captured completely and unfiltered (`pr <= `[`COMPLETE_THROUGH_PR`]).
336 ///
337 /// A caller reporting anything of the form "x out of the comments" should use
338 /// this, or say plainly that it did not.
339 #[must_use]
340 pub fn complete_subset(&self) -> Self {
341 Self {
342 rows: self
343 .rows
344 .iter()
345 .filter(|r| r.pr <= COMPLETE_THROUGH_PR)
346 .cloned()
347 .collect(),
348 }
349 }
350
351 /// Rows with the given verdict.
352 pub fn with_verdict(&self, verdict: Verdict) -> impl Iterator<Item = &CorpusRow> {
353 self.rows.iter().filter(move |r| r.verdict == verdict)
354 }
355
356 /// Every distinct `reviewed_sha`, sorted — the trees a full scoring run has to
357 /// reconstruct. Fewer than there are rows, since several comments were left on
358 /// the same commit.
359 #[must_use]
360 pub fn reviewed_shas(&self) -> BTreeSet<&str> {
361 self.rows.iter().map(|r| r.reviewed_sha.as_str()).collect()
362 }
363
364 /// `(real, false)` counts per class, over every class present in the data.
365 ///
366 /// The single computation behind both the fixture README's class table and a
367 /// per-class score, so the documented counts and the scored counts cannot come
368 /// from two different readings of the file.
369 #[must_use]
370 pub fn class_counts(&self) -> BTreeMap<DefectClass, (usize, usize)> {
371 let mut counts: BTreeMap<DefectClass, (usize, usize)> = BTreeMap::new();
372 for row in &self.rows {
373 let entry = counts.entry(row.defect_class).or_insert((0, 0));
374 match row.verdict {
375 Verdict::Real => entry.0 += 1,
376 Verdict::False => entry.1 += 1,
377 }
378 }
379 counts
380 }
381}
382
383/// Whether `s` is a full 40-character hex object id.
384fn is_full_sha(s: &str) -> bool {
385 s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit())
386}
387
388/// Field-level checks `serde` cannot express: positive identifiers, a full-length
389/// `reviewed_sha`, and no blank text where a reader needs text.
390fn validate(row: &CorpusRow, line: usize) -> Result<(), CorpusError> {
391 let invalid = |field: &'static str, message: String| CorpusError::Invalid {
392 line,
393 field,
394 message,
395 };
396 for (field, value) in [
397 ("id", row.id),
398 ("pr", u64::from(row.pr)),
399 ("line", u64::from(row.line)),
400 ] {
401 if value == 0 {
402 return Err(invalid(field, "must be positive, got 0".to_owned()));
403 }
404 }
405 if !is_full_sha(&row.reviewed_sha) {
406 return Err(invalid(
407 "reviewed_sha",
408 format!(
409 "{:?} is not a 40-character hex sha. It must be the comment's \
410 `original_commit_id` — the tree the reviewer saw — never the \
411 merged PR head, which contains the fix commits",
412 row.reviewed_sha
413 ),
414 ));
415 }
416 // Optional by design, but when present it must look like a sha rather than a
417 // note to the reader.
418 let looks_like_sha =
419 row.fix_commit.len() >= 7 && row.fix_commit.chars().all(|c| c.is_ascii_hexdigit());
420 if !row.fix_commit.is_empty() && !looks_like_sha {
421 return Err(invalid(
422 "fix_commit",
423 format!("{:?} is neither empty nor a hex sha", row.fix_commit),
424 ));
425 }
426 for (field, value) in [
427 ("reviewer", &row.reviewer),
428 ("path", &row.path),
429 ("description", &row.description),
430 ("comment_url", &row.comment_url),
431 ] {
432 if value.trim().is_empty() {
433 return Err(invalid(field, "must not be blank".to_owned()));
434 }
435 }
436 Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441 use super::{
442 CLASSES, COMPLETE_THROUGH_PR, Corpus, CorpusError, CorpusRow, DefectClass, Verdict,
443 };
444
445 /// A row with every field valid, for a test to spoil one field of.
446 fn row_json(overrides: &[(&str, &str)]) -> String {
447 let mut fields: Vec<(&str, String)> = vec![
448 ("id", "3788975371".to_owned()),
449 ("pr", "292".to_owned()),
450 ("reviewer", "\"github-copilot\"".to_owned()),
451 (
452 "reviewed_sha",
453 "\"97938e013380d66f44ea0cb587b637d06fda1bbb\"".to_owned(),
454 ),
455 ("path", "\"crates/rto-graph/src/engine_slot.rs\"".to_owned()),
456 ("line", "16".to_owned()),
457 ("verdict", "\"real\"".to_owned()),
458 ("defect_class", "\"contract-drift\"".to_owned()),
459 ("fix_commit", "\"41cb5e9\"".to_owned()),
460 (
461 "description",
462 "\"module doc contradicts the lock\"".to_owned(),
463 ),
464 ("comment_url", "\"https://example.invalid/1\"".to_owned()),
465 ];
466 for &(key, value) in overrides {
467 if let Some(slot) = fields.iter_mut().find(|(k, _)| *k == key) {
468 slot.1 = value.to_owned();
469 } else {
470 fields.push((key, value.to_owned()));
471 }
472 }
473 let body: Vec<String> = fields.iter().map(|(k, v)| format!("{k:?}: {v}")).collect();
474 format!("{{{}}}", body.join(", "))
475 }
476
477 /// `as_str` is written out by hand for reports; `serde` renames by rule. A
478 /// mismatch would make a scored report and the corpus file disagree about a
479 /// class name, so the two are held together here rather than trusted to stay
480 /// in step.
481 #[test]
482 fn as_str_matches_the_serialised_form() {
483 for class in CLASSES {
484 let serialised = serde_json::to_string(&class).expect("a class serialises");
485 assert_eq!(
486 serialised,
487 format!("{:?}", class.as_str()),
488 "{class:?}: as_str and the serde rename disagree"
489 );
490 assert_eq!(DefectClass::from_token(class.as_str()), Some(class));
491 }
492 for verdict in [Verdict::Real, Verdict::False] {
493 let serialised = serde_json::to_string(&verdict).expect("a verdict serialises");
494 assert_eq!(serialised, format!("{:?}", verdict.as_str()));
495 }
496 assert_eq!(DefectClass::from_token("no-such-class"), None);
497 }
498
499 /// The class list and the enum cannot drift: every variant is listed exactly
500 /// once. `CLASSES` is what a report iterates, so a variant missing from it
501 /// would silently vanish from every score.
502 #[test]
503 fn every_class_is_listed_exactly_once() {
504 let tokens: Vec<&str> = CLASSES.iter().map(|c| c.as_str()).collect();
505 let mut sorted = tokens.clone();
506 sorted.sort_unstable();
507 // Order is asserted against the *original* list, not against a copy that has
508 // already been sorted — the earlier form compared `sorted` with `sorted` and
509 // could not have failed.
510 assert_eq!(
511 tokens, sorted,
512 "CLASSES is not in token order, so two reports would not line up"
513 );
514 let mut deduped = sorted.clone();
515 deduped.dedup();
516 assert_eq!(deduped.len(), tokens.len(), "CLASSES repeats a class");
517 }
518
519 #[test]
520 fn a_well_formed_row_parses() {
521 let corpus = Corpus::parse(&row_json(&[])).expect("parses");
522 assert_eq!(corpus.len(), 1);
523 let row = &corpus.rows()[0];
524 assert_eq!(row.verdict, Verdict::Real);
525 assert_eq!(row.defect_class, DefectClass::ContractDrift);
526 assert_eq!(row.pr, 292);
527 }
528
529 /// Blank lines are skipped, not parsed — a trailing newline is not a row.
530 #[test]
531 fn blank_lines_are_skipped() {
532 let text = format!("{}\n\n \n", row_json(&[]));
533 assert_eq!(Corpus::parse(&text).expect("parses").len(), 1);
534 }
535
536 /// An unknown key is refused. This is the schema check the untyped test used
537 /// to do by comparing key sets: a corpus this crate can load has exactly the
538 /// documented fields.
539 #[test]
540 fn an_unknown_field_is_refused() {
541 let err = Corpus::parse(&row_json(&[("severity", "\"high\"")]))
542 .expect_err("an extra field is not the documented schema");
543 let CorpusError::Malformed { line, ref message } = err else {
544 panic!("expected Malformed, got {err:?}");
545 };
546 assert_eq!(line, 1);
547 assert!(message.contains("severity"), "names the field: {message}");
548 }
549
550 /// A missing key is refused, and named.
551 #[test]
552 fn a_missing_field_is_refused() {
553 let json = row_json(&[]).replace("\"fix_commit\": \"41cb5e9\", ", "");
554 let err = Corpus::parse(&json).expect_err("a missing field is not the schema");
555 assert!(
556 err.to_string().contains("fix_commit"),
557 "names the field: {err}"
558 );
559 }
560
561 /// A token outside a documented vocabulary is refused rather than silently
562 /// bucketed. A new class must be added to the enum *and* the README table.
563 #[test]
564 fn an_undocumented_class_or_verdict_is_refused() {
565 for (field, value) in [
566 ("defect_class", "\"off-by-one\""),
567 ("verdict", "\"probably\""),
568 ] {
569 let err =
570 Corpus::parse(&row_json(&[(field, value)])).expect_err("not a documented token");
571 assert!(
572 matches!(err, CorpusError::Malformed { .. }),
573 "{field}: {err:?}"
574 );
575 }
576 }
577
578 /// **The most expensive mistake the corpus can absorb.** A truncated or
579 /// short-form `reviewed_sha` is refused with a message that says what the
580 /// field must be, because a row whose sha is the PR head scores every future
581 /// reviewer against a tree that already contains the fix.
582 #[test]
583 fn a_short_reviewed_sha_is_refused_and_says_why() {
584 let err = Corpus::parse(&row_json(&[("reviewed_sha", "\"97938e0\"")]))
585 .expect_err("a short sha is not the review commit");
586 let text = err.to_string();
587 assert!(text.contains("reviewed_sha"), "names the field: {text}");
588 assert!(
589 text.contains("original_commit_id"),
590 "says what the field is: {text}"
591 );
592 assert!(
593 text.contains("fix commits"),
594 "says why the head is wrong: {text}"
595 );
596 }
597
598 #[test]
599 fn a_zero_identifier_is_refused() {
600 for field in ["id", "pr", "line"] {
601 let err =
602 Corpus::parse(&row_json(&[(field, "0")])).expect_err("zero is not an identifier");
603 let CorpusError::Invalid { field: got, .. } = err else {
604 panic!("expected Invalid, got {err:?}");
605 };
606 assert_eq!(got, field);
607 }
608 }
609
610 #[test]
611 fn a_fix_commit_that_is_not_a_sha_is_refused_but_blank_is_allowed() {
612 // Three rows legitimately carry no fix commit.
613 let ok = Corpus::parse(&row_json(&[("fix_commit", "\"\"")])).expect("blank is allowed");
614 assert!(ok.rows()[0].fix_commit.is_empty());
615 let err = Corpus::parse(&row_json(&[("fix_commit", "\"landed in a rework\"")]))
616 .expect_err("prose is not a sha");
617 assert!(err.to_string().contains("fix_commit"), "{err}");
618 }
619
620 #[test]
621 fn a_blank_text_field_is_refused() {
622 for field in ["reviewer", "path", "description", "comment_url"] {
623 let err = Corpus::parse(&row_json(&[(field, "\" \"")]))
624 .expect_err("blank text is not text");
625 assert!(err.to_string().contains(field), "{field}: {err}");
626 }
627 }
628
629 /// The id is the primary key: a repeat would double-count that comment in
630 /// every score computed from the file, so it is refused, and both lines are
631 /// named.
632 #[test]
633 fn a_duplicate_id_is_refused_and_names_both_lines() {
634 let text = format!("{}\n{}", row_json(&[]), row_json(&[("pr", "293")]));
635 let err = Corpus::parse(&text).expect_err("the id repeats");
636 let CorpusError::DuplicateId { line, first, id } = err else {
637 panic!("expected DuplicateId, got {err:?}");
638 };
639 assert_eq!((line, first, id), (2, 1, 3_788_975_371));
640 }
641
642 /// `complete_subset` keeps only the PRs whose comments were captured
643 /// completely, because that is the only subset over which a ratio means
644 /// anything. The later selected row must not be in it.
645 #[test]
646 fn complete_subset_drops_selectively_added_rows() {
647 let text = format!(
648 "{}\n{}",
649 row_json(&[]),
650 row_json(&[("id", "9999"), ("pr", "352")])
651 );
652 let corpus = Corpus::parse(&text).expect("parses");
653 assert_eq!(corpus.len(), 2);
654 let complete = corpus.complete_subset();
655 assert_eq!(complete.len(), 1);
656 assert!(complete.rows().iter().all(|r| r.pr <= COMPLETE_THROUGH_PR));
657 }
658
659 /// Counting is one computation, used by both the README table check and a
660 /// score, so the two cannot disagree about the same file.
661 #[test]
662 fn class_counts_splits_real_from_false() {
663 let text = format!(
664 "{}\n{}\n{}",
665 row_json(&[]),
666 row_json(&[
667 ("id", "2"),
668 ("defect_class", "\"false-compile-claim\""),
669 ("verdict", "\"false\"")
670 ]),
671 row_json(&[
672 ("id", "3"),
673 ("defect_class", "\"false-compile-claim\""),
674 ("verdict", "\"false\"")
675 ]),
676 );
677 let counts = Corpus::parse(&text).expect("parses").class_counts();
678 assert_eq!(counts[&DefectClass::ContractDrift], (1, 0));
679 assert_eq!(counts[&DefectClass::FalseCompileClaim], (0, 2));
680 assert_eq!(counts.len(), 2, "absent classes are not invented");
681 }
682
683 /// Several comments share a commit, so a scoring run reconstructs fewer trees
684 /// than there are rows — and it must reconstruct each exactly once.
685 #[test]
686 fn reviewed_shas_are_deduplicated() {
687 let text = format!("{}\n{}", row_json(&[]), row_json(&[("id", "2")]));
688 let corpus = Corpus::parse(&text).expect("parses");
689 assert_eq!(corpus.len(), 2);
690 assert_eq!(corpus.reviewed_shas().len(), 1);
691 }
692
693 /// A round trip through `serde` preserves every field, so a tool may re-emit a
694 /// row (into a report, a filtered corpus) without losing provenance.
695 #[test]
696 fn a_row_round_trips() {
697 let corpus = Corpus::parse(&row_json(&[])).expect("parses");
698 let json = serde_json::to_string(&corpus.rows()[0]).expect("serialises");
699 let back: CorpusRow = serde_json::from_str(&json).expect("deserialises");
700 assert_eq!(&back, &corpus.rows()[0]);
701 }
702}