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}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct Generator {
42    pub tool: String,
43    pub version: String,
44    /// Pipeline stages that actually ran, in order: "enumerate", "classify",
45    /// "group", "order".
46    pub stages: Vec<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct Source {
51    pub kind: SourceKind,
52    /// Fully resolved commit sha — a raw tree oid for `staged`/`worktree`
53    /// sources, whose endpoints are synthesized snapshots of uncommitted
54    /// state.
55    pub base: String,
56    /// Fully resolved commit sha — a raw tree oid for `staged`/`worktree`
57    /// sources (see `base`).
58    pub head: String,
59    pub remote: Option<Remote>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum SourceKind {
65    Commit,
66    Range,
67    Mr,
68    Pr,
69    /// HEAD vs the index (additive in schema v1).
70    Staged,
71    /// The index vs the worktree (additive in schema v1).
72    Worktree,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct Remote {
77    pub forge: String,
78    pub project: String,
79    pub id: String,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct Stats {
84    pub files: u32,
85    pub hunks: u32,
86    pub classes: u32,
87    pub binary_files: u32,
88    pub submodules: u32,
89}
90
91/// One changed file in the canonical (`--no-renames`) view. A rename therefore
92/// appears as a D entry plus an A entry; the rename-detected view annotates both.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct FileEntry {
95    pub path: String,
96    pub disposition: Disposition,
97    /// New-side mode ("100644", "100755", "120000", "160000"); `None` on deletion.
98    pub mode: Option<String>,
99    /// Old-side mode when it differs from `mode`, and on deletion.
100    pub old_mode: Option<String>,
101    /// On the A side of a detected rename: where the content came from.
102    pub old_path: Option<String>,
103    /// On the D side of a detected rename: where the content went. Together with
104    /// `old_path` this makes "moved and modified" addressable from both ends.
105    pub new_path: Option<String>,
106    /// Similarity score 0-100 from git's rename detection. Present on both sides of
107    /// a detected rename. Below ~95 the change is a modification, not a relocation,
108    /// and must never be treated as skim-eligible.
109    pub rename_similarity: Option<u8>,
110    /// Binary files carry zero hunks; content is tracked by object id only.
111    pub binary: bool,
112    pub submodule: Option<SubmoduleChange>,
113    /// Hint for the noise tier. Computed (builtin list, gitattributes, repo config),
114    /// never claimed by a model.
115    pub generated: bool,
116    pub generated_by: Option<GeneratedBy>,
117    /// Ids into `hunks`, in file order.
118    pub hunk_ids: Vec<String>,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122pub enum Disposition {
123    A,
124    D,
125    M,
126}
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct SubmoduleChange {
130    pub old: Option<String>,
131    pub new: Option<String>,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "lowercase")]
136pub enum GeneratedBy {
137    /// Matched the built-in lockfile/artefact list.
138    Builtin,
139    /// Declared by the repo via a gitattributes attribute (e.g. linguist-generated).
140    Attr,
141    /// Matched a glob in the repo's `.differential.toml`.
142    Config,
143}
144
145/// One canonical hunk from `git diff -U0 --no-renames`. Ids are positional
146/// (`h0..hN` in enumeration order) and do NOT survive regeneration; `digest` does.
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct HunkEntry {
149    pub id: String,
150    pub file: String,
151    pub old_start: u32,
152    pub old_count: u32,
153    pub new_start: u32,
154    pub new_count: u32,
155    /// Shape class id into `classes`.
156    pub class: String,
157    /// Exact content hash of the hunk (removed ++ added bytes, un-normalised).
158    /// The stable anchor for comments and review state across regenerations.
159    pub digest: String,
160    /// `\ No newline at end of file` on the old side.
161    pub nonl_old: bool,
162    /// `\ No newline at end of file` on the new side.
163    pub nonl_new: bool,
164    /// Position in the forge's rename-detected diff, for posting comments.
165    pub forge_position: ForgePosition,
166}
167
168#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169pub struct ForgePosition {
170    /// Line in the new file; `None` for deletion-only hunks.
171    pub new_line: Option<u32>,
172    /// Line in the old file; `None` for insertion-only hunks.
173    pub old_line: Option<u32>,
174}
175
176/// A shape class: hunks whose diff text is identical after normalising away
177/// identifiers and literals on BOTH sides. Ids `C0..Cn`, numbered by descending
178/// member count. 100% hunk coverage is by construction.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct ClassEntry {
181    pub id: String,
182    pub hunk_ids: Vec<String>,
183    /// The member a reviewer reads to verify the whole class.
184    pub exemplar: String,
185    /// True iff, after erasing identifiers and literals, the removed and added
186    /// lines match — a structure-free substitution. Computed, never claimed.
187    pub pure_substitution: bool,
188    /// Symbols this class introduces, from `Language::file_symbols`.
189    /// Sorted and deduplicated.
190    pub defines: Vec<String>,
191    /// Classes this class consumes: it references a symbol they define. Sorted
192    /// by `on`. The graph is a fact about the diff, computed before grouping,
193    /// so it never depends on how the model merged classes.
194    pub depends_on: Vec<ClassEdge>,
195}
196
197/// One class-level dependency edge.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub struct ClassEdge {
200    /// The class this one consumes.
201    pub on: String,
202    /// The symbols that produced the edge — why the dependency exists. Sorted
203    /// and deduplicated. Extraction is heuristic (ADR 0015), so a consumer may
204    /// judge an edge by its cause rather than take it on trust.
205    pub via: Vec<String>,
206}
207
208/// A merged, labelled group of shape classes. Produced by the grouping stage.
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210pub struct Group {
211    pub id: String,
212    pub label: String,
213    pub description: String,
214    pub reason: String,
215    pub effort: Effort,
216    /// `None` until the ordering stage runs — role is an ordering-stage output.
217    pub role: Option<Role>,
218    /// Member classes, ordered foundation-first by the ordering stage.
219    pub class_ids: Vec<String>,
220    /// Groups this group depends on: it consumes what they define. The
221    /// contraction of the class graph onto groups.
222    pub depends_on: Vec<Edge>,
223    /// Position in the foundation-first ordering.
224    pub rank: u32,
225    /// How many leading `class_ids` depend on nothing ranked later — the index
226    /// at which this group stops being a foundation and starts being a
227    /// consumer.
228    ///
229    /// `None` unless the ordering had to break a cycle on this group. Nothing
230    /// splits the group: the number says where the group cannot be read as one
231    /// thing, and leaves what to do about it to the reader.
232    pub pivot: Option<u32>,
233}
234
235/// One group-level dependency edge.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct Edge {
238    /// The group this one depends on.
239    pub on: String,
240    /// The symbols that produced the edge — why the dependency exists.
241    pub via: Vec<String>,
242    /// `None` unless the ordering could not honour this edge.
243    ///
244    /// Whether it could is derivable from `rank`, so it is not repeated here.
245    /// Why it could not is NOT derivable, which is what this records.
246    pub cycle: Option<Cycle>,
247}
248
249/// Why a dependency edge could not be honoured.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "lowercase")]
252pub enum Cycle {
253    /// The class graph is acyclic here. The cycle exists only because groups
254    /// contract classes: one group both defines and consumes, against the same
255    /// other group, so no reading order can satisfy both.
256    Artefact,
257    /// The class graph is cyclic too. The mutual dependency is in the change.
258    Mutual,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "lowercase")]
263pub enum Effort {
264    /// Read every hunk, line by line.
265    Focus,
266    /// Read one exemplar per shape class; trust the rest.
267    Skim,
268    /// Generated content: folded entirely, no exemplars to read.
269    Noise,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[serde(rename_all = "lowercase")]
274pub enum Role {
275    Foundation,
276    Consumer,
277    Mechanical,
278    Noise,
279}
280
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282pub struct ReadingStep {
283    pub group: String,
284    pub action: ReadAction,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "lowercase")]
289pub enum ReadAction {
290    /// Read every hunk in the group.
291    Read,
292    /// Read one hunk per shape class.
293    Exemplars,
294    /// Remaining members of already-verified shapes.
295    Skip,
296    /// Noise group: collapsed entirely.
297    Fold,
298}
299
300/// Structural audit. The first four fields exist for every document; the rest are
301/// `null` until the grouping stage runs.
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303pub struct Audit {
304    /// "n/n" — files reconstructed byte-exactly from base + hunks.
305    pub applier_exact: String,
306    /// "pass" — built-from-hunks tree equals the head tree.
307    pub tree_assertion: String,
308    pub hunks_carried: u32,
309    /// Independent `@@` recount computed from git output, not from bookkeeping.
310    pub recount: u32,
311    pub coverage: Option<f64>,
312    pub classes_missing: Option<u32>,
313    pub classes_duplicated: Option<Vec<String>>,
314    pub classes_hallucinated: Option<Vec<String>>,
315    /// Hunks a reviewer actually reads (focus + exemplars). The honest number.
316    pub read_hunks: Option<u32>,
317    /// Hunks never opened (skim remainders + folded noise). The genuine saving.
318    pub skipped_hunks: Option<u32>,
319}
320
321#[derive(Debug)]
322pub enum SchemaError {
323    UnsupportedVersion { found: u32 },
324    Json(serde_json::Error),
325}
326
327impl std::fmt::Display for SchemaError {
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        match self {
330            SchemaError::UnsupportedVersion { found } => write!(
331                f,
332                "unsupported schema_version {found} (this reader understands {SCHEMA_VERSION})"
333            ),
334            SchemaError::Json(e) => write!(f, "invalid plan document: {e}"),
335        }
336    }
337}
338
339impl std::error::Error for SchemaError {
340    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
341        match self {
342            SchemaError::Json(e) => Some(e),
343            _ => None,
344        }
345    }
346}
347
348impl From<serde_json::Error> for SchemaError {
349    fn from(e: serde_json::Error) -> Self {
350        SchemaError::Json(e)
351    }
352}
353
354impl PlanDocument {
355    /// Parse and enforce the version gate. Use this instead of raw serde_json.
356    pub fn from_json(s: &str) -> Result<Self, SchemaError> {
357        #[derive(Deserialize)]
358        struct VersionProbe {
359            schema_version: u32,
360        }
361        let probe: VersionProbe = serde_json::from_str(s)?;
362        if probe.schema_version != SCHEMA_VERSION {
363            return Err(SchemaError::UnsupportedVersion {
364                found: probe.schema_version,
365            });
366        }
367        Ok(serde_json::from_str(s)?)
368    }
369
370    pub fn to_json_pretty(&self) -> Result<String, SchemaError> {
371        Ok(serde_json::to_string_pretty(self)?)
372    }
373
374    pub fn to_json(&self) -> Result<String, SchemaError> {
375        Ok(serde_json::to_string(self)?)
376    }
377}