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    /// The hunk's first line on each side, in the canonical `--no-renames` view. Not a
260    /// posting anchor: the forge consumer places a finding by its own anchor and the
261    /// file's `old_path` (spec/json-contract.md).
262    pub forge_position: ForgePosition,
263}
264
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct ForgePosition {
267    /// Line in the new file; `None` for deletion-only hunks.
268    pub new_line: Option<u32>,
269    /// Line in the old file; `None` for insertion-only hunks.
270    pub old_line: Option<u32>,
271}
272
273/// A shape class: hunks whose diff text is identical after normalising away
274/// identifiers and literals on BOTH sides. Ids `C0..Cn`, numbered by descending
275/// member count. 100% hunk coverage is by construction.
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277pub struct ClassEntry {
278    pub id: String,
279    pub hunk_ids: Vec<String>,
280    /// The member a reviewer reads to verify the whole class.
281    pub exemplar: String,
282    /// True iff, after erasing identifiers and literals, the removed and added
283    /// lines match — a structure-free substitution. Computed, never claimed.
284    pub pure_substitution: bool,
285    /// Symbols this class introduces, from `SymbolReaders::of_file`.
286    /// Sorted and deduplicated.
287    pub defines: Vec<String>,
288    /// Classes this class consumes: it references a symbol they define. Sorted
289    /// by `on`. The graph is a fact about the diff, computed before grouping,
290    /// so it never depends on how the model merged classes.
291    pub depends_on: Vec<ClassEdge>,
292}
293
294/// One class-level dependency edge.
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct ClassEdge {
297    /// The class this one consumes.
298    pub on: String,
299    /// The symbols that produced the edge — why the dependency exists. Sorted
300    /// and deduplicated. Extraction is heuristic (ADR 0015), so a consumer may
301    /// judge an edge by its cause rather than take it on trust.
302    pub via: Vec<String>,
303}
304
305/// A merged, labelled group of shape classes. Produced by the grouping stage.
306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307pub struct Group {
308    pub id: String,
309    pub label: String,
310    pub description: String,
311    pub reason: String,
312    pub effort: Effort,
313    /// `None` until the ordering stage runs — role is an ordering-stage output.
314    pub role: Option<Role>,
315    /// Member classes, ordered foundation-first by the ordering stage.
316    pub class_ids: Vec<String>,
317    /// Groups this group depends on: it consumes what they define. The
318    /// contraction of the class graph onto groups.
319    pub depends_on: Vec<Edge>,
320    /// Position in the foundation-first ordering.
321    pub rank: u32,
322    /// How many leading `class_ids` depend on nothing ranked later — the index
323    /// at which this group stops being a foundation and starts being a
324    /// consumer.
325    ///
326    /// `None` unless the ordering had to break a cycle on this group. Nothing
327    /// splits the group: the number says where the group cannot be read as one
328    /// thing, and leaves what to do about it to the reader.
329    pub pivot: Option<u32>,
330}
331
332/// One group-level dependency edge.
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct Edge {
335    /// The group this one depends on.
336    pub on: String,
337    /// The symbols that produced the edge — why the dependency exists.
338    pub via: Vec<String>,
339    /// `None` unless the ordering could not honour this edge.
340    ///
341    /// Whether it could is derivable from `rank`, so it is not repeated here.
342    /// Why it could not is NOT derivable, which is what this records.
343    pub cycle: Option<Cycle>,
344}
345
346/// Why a dependency edge could not be honoured.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "lowercase")]
349pub enum Cycle {
350    /// The class graph is acyclic here. The cycle exists only because groups
351    /// contract classes: one group both defines and consumes, against the same
352    /// other group, so no reading order can satisfy both.
353    Artefact,
354    /// The class graph is cyclic too. The mutual dependency is in the change.
355    Mutual,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(rename_all = "lowercase")]
360pub enum Effort {
361    /// Read every hunk, line by line.
362    Focus,
363    /// Read one exemplar per shape class; trust the rest.
364    Skim,
365    /// Generated content: folded entirely, no exemplars to read.
366    Noise,
367}
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370#[serde(rename_all = "lowercase")]
371pub enum Role {
372    Foundation,
373    Consumer,
374    Mechanical,
375    Noise,
376}
377
378#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
379pub struct ReadingStep {
380    pub group: String,
381    pub action: ReadAction,
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(rename_all = "lowercase")]
386pub enum ReadAction {
387    /// Read every hunk in the group.
388    Read,
389    /// Read one hunk per shape class.
390    Exemplars,
391    /// Remaining members of already-verified shapes.
392    Skip,
393    /// Noise group: collapsed entirely.
394    Fold,
395}
396
397/// Structural audit. The first four fields exist for every document; the rest are
398/// `null` until the grouping stage runs.
399#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
400pub struct Audit {
401    /// "n/n" — files reconstructed byte-exactly from base + hunks.
402    pub applier_exact: String,
403    /// "pass" — built-from-hunks tree equals the head tree.
404    pub tree_assertion: String,
405    pub hunks_carried: u32,
406    /// Independent `@@` recount computed from git output, not from bookkeeping.
407    pub recount: u32,
408    pub coverage: Option<f64>,
409    pub classes_missing: Option<u32>,
410    pub classes_duplicated: Option<Vec<String>>,
411    pub classes_hallucinated: Option<Vec<String>>,
412    /// Hunks a reviewer actually reads (focus + exemplars). The honest number.
413    pub read_hunks: Option<u32>,
414    /// Hunks never opened (skim remainders + folded noise). The genuine saving.
415    pub skipped_hunks: Option<u32>,
416}
417
418#[derive(Debug)]
419pub enum SchemaError {
420    UnsupportedVersion { found: u32 },
421    Json(serde_json::Error),
422}
423
424impl std::fmt::Display for SchemaError {
425    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        match self {
427            SchemaError::UnsupportedVersion { found } => write!(
428                f,
429                "unsupported schema_version {found} (this reader understands {SCHEMA_VERSION})"
430            ),
431            SchemaError::Json(e) => write!(f, "invalid plan document: {e}"),
432        }
433    }
434}
435
436impl std::error::Error for SchemaError {
437    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
438        match self {
439            SchemaError::Json(e) => Some(e),
440            _ => None,
441        }
442    }
443}
444
445impl From<serde_json::Error> for SchemaError {
446    fn from(e: serde_json::Error) -> Self {
447        SchemaError::Json(e)
448    }
449}
450
451impl PlanDocument {
452    /// Parse and enforce the version gate. Use this instead of raw serde_json.
453    pub fn from_json(s: &str) -> Result<Self, SchemaError> {
454        #[derive(Deserialize)]
455        struct VersionProbe {
456            schema_version: u32,
457        }
458        let probe: VersionProbe = serde_json::from_str(s)?;
459        if probe.schema_version != SCHEMA_VERSION {
460            return Err(SchemaError::UnsupportedVersion {
461                found: probe.schema_version,
462            });
463        }
464        Ok(serde_json::from_str(s)?)
465    }
466
467    pub fn to_json_pretty(&self) -> Result<String, SchemaError> {
468        Ok(serde_json::to_string_pretty(self)?)
469    }
470
471    pub fn to_json(&self) -> Result<String, SchemaError> {
472        Ok(serde_json::to_string(self)?)
473    }
474}