Skip to main content

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}