Skip to main content

differential_engine/
schema.rs

1//! The frozen JSON contract for differential reading plans.
2//!
3//! This module is the product boundary (ADR 0008, superseded-in-form by ADR
4//! 0018): every consumer (shadow-branch stack, TUI, forge review) depends on
5//! these types and nothing else. It stays serde-only — consumer conveniences
6//! and engine internals must not leak in here; that discipline is enforced in
7//! review now that the crate boundary is gone.
8//!
9//! Contract rules:
10//! - `schema_version` is 3 (v3 gave every dependency edge its cause and moved
11//!   the graph onto `classes`, ADR 0022). Readers must reject versions they do
12//!   not know.
13//! - Deserialisation tolerates unknown fields, so additive changes are non-breaking.
14//! - `groups`/`reading_plan` are `null` when the grouping stage has not run. That is
15//!   distinct from `[]`, which would mean "grouping ran and produced nothing" and is
16//!   always a bug. `generator.stages` states exactly which stages produced the document.
17//! - Optional fields serialise as explicit `null`, never omitted.
18
19use serde::{Deserialize, Serialize};
20
21pub const SCHEMA_VERSION: u32 = 3;
22
23/// The one JSON document: a grouped, ordered reading plan for a diff.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct PlanDocument {
26    pub schema_version: u32,
27    pub generator: Generator,
28    pub source: Source,
29    pub stats: Stats,
30    pub files: Vec<FileEntry>,
31    pub hunks: Vec<HunkEntry>,
32    pub classes: Vec<ClassEntry>,
33    /// `None` until the grouping stage runs. `Some(vec![])` is a bug, not a state.
34    pub groups: Option<Vec<Group>>,
35    /// `None` until the grouping stage runs; ordered foundation-first once present.
36    pub reading_plan: Option<Vec<ReadingStep>>,
37    pub audit: Audit,
38    /// Symbol-level dependency sites: which token resolves to which
39    /// declaration. Produced by `classify`, beside the class graph.
40    ///
41    /// `None` on a document written before this field existed. It is additive,
42    /// so `schema_version` stays 3 — but a stored artefact does get re-read
43    /// (`dfr agent --doc`, the grouping cache), which is why this defaults
44    /// rather than requiring the key.
45    #[serde(default)]
46    pub symbols: Option<SymbolIndex>,
47}
48
49/// Where each resolvable name is declared, and every token that reads one.
50///
51/// Two flat lists rather than a map: ids are positional, a consumer indexes
52/// them directly, and JSON has no set type worth the ceremony.
53#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
54pub struct SymbolIndex {
55    pub definitions: Vec<SymbolDef>,
56    pub uses: Vec<SymbolUse>,
57}
58
59/// One declaration something in the change reads.
60///
61/// It may sit on ANY line of a file the change touches, not only one the change
62/// wrote: the commonest question a reviewer has is what a newly added call
63/// resolves to, and that is usually a helper which was already there.
64///
65/// Only names with exactly ONE definer appear, the same rule the class graph
66/// draws edges by — a name declared twice is ambiguous, and nothing here can
67/// say which one a reader meant.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct SymbolDef {
70    /// Document-local and positional, `s0…sn`, like `h<N>` and `C<N>`. Does not
71    /// survive regeneration.
72    pub id: String,
73    pub name: String,
74    /// Index into the document's `files[]`.
75    ///
76    /// **Not a path.** On a change of any size this index holds thousands of
77    /// rows, and a repeated path was 56% of its bytes on the validation corpus.
78    /// The document already lists every file exactly once, so pointing at that
79    /// list costs nothing and duplicates nothing.
80    ///
81    /// It differs from `hunks[].file`, which is a path string and frozen that
82    /// way — the inconsistency is unavoidable, and this is the side of it where
83    /// the repetition is large enough to matter.
84    pub file: u32,
85    /// New-side line of the declaring token, counting from 1.
86    pub line: u32,
87    /// Last line of what the name declares. Equal to `line` where the reader
88    /// could not see an extent — a regex has no tree to ask.
89    pub through: u32,
90    /// Byte offsets of the token within its RAW line, before any tab expansion.
91    /// A renderer that expands tabs must translate these against its own
92    /// expansion rather than index its display text with them.
93    pub start: u32,
94    pub end: u32,
95    /// The shape class that introduces it, or `null` where the change did not
96    /// write this line — the declaration is real, it is simply not part of the
97    /// change.
98    pub class: Option<String>,
99}
100
101/// One token that reads a [`SymbolDef`].
102///
103/// Recorded only on a line the change WROTE. Those are the lines the reviewer is
104/// reading, and they bound the index: with any declaration resolvable, every
105/// mention in every parsed file would grow this with the size of the files.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct SymbolUse {
108    /// The `SymbolDef` id this use resolves to.
109    pub on: String,
110    /// Index into the document's `files[]`, as on [`SymbolDef`].
111    pub file: u32,
112    pub line: u32,
113    /// Raw-line byte offsets, as on [`SymbolDef`].
114    pub start: u32,
115    pub end: u32,
116}
117
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct Generator {
120    pub tool: String,
121    pub version: String,
122    /// Pipeline stages that actually ran, in order: "enumerate", "classify",
123    /// "group", "order", and "verify" when the separate verify stage ran
124    /// (ADR 0028). A consumer reads absence as "did not run", never as a pass.
125    pub stages: Vec<String>,
126}
127
128impl Generator {
129    /// This build of the tool, having run `stages` so far.
130    ///
131    /// One spelling of the tool's name and version. The producer and the
132    /// `dfr agents` probe each wrote it out, and each read
133    /// `CARGO_PKG_VERSION` from its own crate — equal only because the
134    /// workspace shares one version.
135    pub fn current(stages: &[&str]) -> Generator {
136        Generator {
137            tool: "differential".to_string(),
138            version: env!("CARGO_PKG_VERSION").to_string(),
139            stages: stages.iter().map(|s| s.to_string()).collect(),
140        }
141    }
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct Source {
146    pub kind: SourceKind,
147    /// Fully resolved commit sha — a raw tree oid for `staged`/`worktree`
148    /// sources, whose endpoints are synthesized snapshots of uncommitted
149    /// state.
150    pub base: String,
151    /// Fully resolved commit sha — a raw tree oid for `staged`/`worktree`
152    /// sources (see `base`).
153    pub head: String,
154    pub remote: Option<Remote>,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "lowercase")]
159pub enum SourceKind {
160    Commit,
161    Range,
162    Mr,
163    Pr,
164    /// HEAD vs the index (additive in schema v1).
165    Staged,
166    /// The index vs the worktree (additive in schema v1).
167    Worktree,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct Remote {
172    pub forge: String,
173    pub project: String,
174    pub id: String,
175}
176
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178pub struct Stats {
179    pub files: u32,
180    pub hunks: u32,
181    pub classes: u32,
182    pub binary_files: u32,
183    pub submodules: u32,
184}
185
186/// One changed file in the canonical (`--no-renames`) view. A rename therefore
187/// appears as a D entry plus an A entry; the rename-detected view annotates both.
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct FileEntry {
190    pub path: String,
191    pub disposition: Disposition,
192    /// New-side mode ("100644", "100755", "120000", "160000"); `None` on deletion.
193    pub mode: Option<String>,
194    /// Old-side mode when it differs from `mode`, and on deletion.
195    pub old_mode: Option<String>,
196    /// On the A side of a detected rename: where the content came from.
197    pub old_path: Option<String>,
198    /// On the D side of a detected rename: where the content went. Together with
199    /// `old_path` this makes "moved and modified" addressable from both ends.
200    pub new_path: Option<String>,
201    /// Similarity score 0-100 from git's rename detection. Present on both sides of
202    /// a detected rename. Below ~95 the change is a modification, not a relocation,
203    /// and must never be treated as skim-eligible.
204    pub rename_similarity: Option<u8>,
205    /// Binary files carry zero hunks; content is tracked by object id only.
206    pub binary: bool,
207    pub submodule: Option<SubmoduleChange>,
208    /// Hint for the noise tier. Computed (builtin list, gitattributes, repo config),
209    /// never claimed by a model.
210    pub generated: bool,
211    pub generated_by: Option<GeneratedBy>,
212    /// Ids into `hunks`, in file order.
213    pub hunk_ids: Vec<String>,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217pub enum Disposition {
218    A,
219    D,
220    M,
221}
222
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224pub struct SubmoduleChange {
225    pub old: Option<String>,
226    pub new: Option<String>,
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "lowercase")]
231pub enum GeneratedBy {
232    /// Matched the built-in lockfile/artefact list.
233    Builtin,
234    /// Declared by the repo via a gitattributes attribute (e.g. linguist-generated).
235    Attr,
236    /// Matched a glob in the repo's `.differential.toml`.
237    Config,
238}
239
240/// One canonical hunk from `git diff -U0 --no-renames`. Ids are positional
241/// (`h0..hN` in enumeration order) and do NOT survive regeneration; `digest` does.
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243pub struct HunkEntry {
244    pub id: String,
245    pub file: String,
246    pub old_start: u32,
247    pub old_count: u32,
248    pub new_start: u32,
249    pub new_count: u32,
250    /// Shape class id into `classes`.
251    pub class: String,
252    /// Exact content hash of the hunk (removed ++ added bytes, un-normalised).
253    /// The stable anchor for comments and review state across regenerations.
254    pub digest: String,
255    /// `\ No newline at end of file` on the old side.
256    pub nonl_old: bool,
257    /// `\ No newline at end of file` on the new side.
258    pub nonl_new: bool,
259    /// Position in the forge's rename-detected diff, for posting comments.
260    pub forge_position: ForgePosition,
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264pub struct ForgePosition {
265    /// Line in the new file; `None` for deletion-only hunks.
266    pub new_line: Option<u32>,
267    /// Line in the old file; `None` for insertion-only hunks.
268    pub old_line: Option<u32>,
269}
270
271/// A shape class: hunks whose diff text is identical after normalising away
272/// identifiers and literals on BOTH sides. Ids `C0..Cn`, numbered by descending
273/// member count. 100% hunk coverage is by construction.
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
275pub struct ClassEntry {
276    pub id: String,
277    pub hunk_ids: Vec<String>,
278    /// The member a reviewer reads to verify the whole class.
279    pub exemplar: String,
280    /// True iff, after erasing identifiers and literals, the removed and added
281    /// lines match — a structure-free substitution. Computed, never claimed.
282    pub pure_substitution: bool,
283    /// Symbols this class introduces, from `SymbolReaders::of_file`.
284    /// Sorted and deduplicated.
285    pub defines: Vec<String>,
286    /// Classes this class consumes: it references a symbol they define. Sorted
287    /// by `on`. The graph is a fact about the diff, computed before grouping,
288    /// so it never depends on how the model merged classes.
289    pub depends_on: Vec<ClassEdge>,
290}
291
292/// One class-level dependency edge.
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294pub struct ClassEdge {
295    /// The class this one consumes.
296    pub on: String,
297    /// The symbols that produced the edge — why the dependency exists. Sorted
298    /// and deduplicated. Extraction is heuristic (ADR 0015), so a consumer may
299    /// judge an edge by its cause rather than take it on trust.
300    pub via: Vec<String>,
301}
302
303/// A merged, labelled group of shape classes. Produced by the grouping stage.
304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
305pub struct Group {
306    pub id: String,
307    pub label: String,
308    pub description: String,
309    pub reason: String,
310    pub effort: Effort,
311    /// `None` until the ordering stage runs — role is an ordering-stage output.
312    pub role: Option<Role>,
313    /// Member classes, ordered foundation-first by the ordering stage.
314    pub class_ids: Vec<String>,
315    /// Groups this group depends on: it consumes what they define. The
316    /// contraction of the class graph onto groups.
317    pub depends_on: Vec<Edge>,
318    /// Position in the foundation-first ordering.
319    pub rank: u32,
320    /// How many leading `class_ids` depend on nothing ranked later — the index
321    /// at which this group stops being a foundation and starts being a
322    /// consumer.
323    ///
324    /// `None` unless the ordering had to break a cycle on this group. Nothing
325    /// splits the group: the number says where the group cannot be read as one
326    /// thing, and leaves what to do about it to the reader.
327    pub pivot: Option<u32>,
328}
329
330/// One group-level dependency edge.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct Edge {
333    /// The group this one depends on.
334    pub on: String,
335    /// The symbols that produced the edge — why the dependency exists.
336    pub via: Vec<String>,
337    /// `None` unless the ordering could not honour this edge.
338    ///
339    /// Whether it could is derivable from `rank`, so it is not repeated here.
340    /// Why it could not is NOT derivable, which is what this records.
341    pub cycle: Option<Cycle>,
342}
343
344/// Why a dependency edge could not be honoured.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346#[serde(rename_all = "lowercase")]
347pub enum Cycle {
348    /// The class graph is acyclic here. The cycle exists only because groups
349    /// contract classes: one group both defines and consumes, against the same
350    /// other group, so no reading order can satisfy both.
351    Artefact,
352    /// The class graph is cyclic too. The mutual dependency is in the change.
353    Mutual,
354}
355
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
357#[serde(rename_all = "lowercase")]
358pub enum Effort {
359    /// Read every hunk, line by line.
360    Focus,
361    /// Read one exemplar per shape class; trust the rest.
362    Skim,
363    /// Generated content: folded entirely, no exemplars to read.
364    Noise,
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(rename_all = "lowercase")]
369pub enum Role {
370    Foundation,
371    Consumer,
372    Mechanical,
373    Noise,
374}
375
376#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
377pub struct ReadingStep {
378    pub group: String,
379    pub action: ReadAction,
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "lowercase")]
384pub enum ReadAction {
385    /// Read every hunk in the group.
386    Read,
387    /// Read one hunk per shape class.
388    Exemplars,
389    /// Remaining members of already-verified shapes.
390    Skip,
391    /// Noise group: collapsed entirely.
392    Fold,
393}
394
395/// Structural audit. The first four fields exist for every document; the rest are
396/// `null` until the grouping stage runs.
397#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
398pub struct Audit {
399    /// "n/n" — files reconstructed byte-exactly from base + hunks.
400    pub applier_exact: String,
401    /// "pass" — built-from-hunks tree equals the head tree.
402    pub tree_assertion: String,
403    pub hunks_carried: u32,
404    /// Independent `@@` recount computed from git output, not from bookkeeping.
405    pub recount: u32,
406    pub coverage: Option<f64>,
407    pub classes_missing: Option<u32>,
408    pub classes_duplicated: Option<Vec<String>>,
409    pub classes_hallucinated: Option<Vec<String>>,
410    /// Hunks a reviewer actually reads (focus + exemplars). The honest number.
411    pub read_hunks: Option<u32>,
412    /// Hunks never opened (skim remainders + folded noise). The genuine saving.
413    pub skipped_hunks: Option<u32>,
414}
415
416#[derive(Debug)]
417pub enum SchemaError {
418    UnsupportedVersion { found: u32 },
419    Json(serde_json::Error),
420}
421
422impl std::fmt::Display for SchemaError {
423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424        match self {
425            SchemaError::UnsupportedVersion { found } => write!(
426                f,
427                "unsupported schema_version {found} (this reader understands {SCHEMA_VERSION})"
428            ),
429            SchemaError::Json(e) => write!(f, "invalid plan document: {e}"),
430        }
431    }
432}
433
434impl std::error::Error for SchemaError {
435    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
436        match self {
437            SchemaError::Json(e) => Some(e),
438            _ => None,
439        }
440    }
441}
442
443impl From<serde_json::Error> for SchemaError {
444    fn from(e: serde_json::Error) -> Self {
445        SchemaError::Json(e)
446    }
447}
448
449impl PlanDocument {
450    /// Parse and enforce the version gate. Use this instead of raw serde_json.
451    pub fn from_json(s: &str) -> Result<Self, SchemaError> {
452        #[derive(Deserialize)]
453        struct VersionProbe {
454            schema_version: u32,
455        }
456        let probe: VersionProbe = serde_json::from_str(s)?;
457        if probe.schema_version != SCHEMA_VERSION {
458            return Err(SchemaError::UnsupportedVersion {
459                found: probe.schema_version,
460            });
461        }
462        Ok(serde_json::from_str(s)?)
463    }
464
465    pub fn to_json_pretty(&self) -> Result<String, SchemaError> {
466        Ok(serde_json::to_string_pretty(self)?)
467    }
468
469    pub fn to_json(&self) -> Result<String, SchemaError> {
470        Ok(serde_json::to_string(self)?)
471    }
472}