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 2 (v2 renamed the `close` effort tier to `focus`,
11//!   ADR 0019). Readers must reject versions they do not know.
12//! - Deserialisation tolerates unknown fields, so additive changes are non-breaking.
13//! - `groups`/`reading_plan` are `null` when the grouping stage has not run. That is
14//!   distinct from `[]`, which would mean "grouping ran and produced nothing" and is
15//!   always a bug. `generator.stages` states exactly which stages produced the document.
16//! - Optional fields serialise as explicit `null`, never omitted.
17
18use serde::{Deserialize, Serialize};
19
20pub const SCHEMA_VERSION: u32 = 2;
21
22/// The one JSON document: a grouped, ordered reading plan for a diff.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct PlanDocument {
25    pub schema_version: u32,
26    pub generator: Generator,
27    pub source: Source,
28    pub stats: Stats,
29    pub files: Vec<FileEntry>,
30    pub hunks: Vec<HunkEntry>,
31    pub classes: Vec<ClassEntry>,
32    /// `None` until the grouping stage runs. `Some(vec![])` is a bug, not a state.
33    pub groups: Option<Vec<Group>>,
34    /// `None` until the grouping stage runs; ordered foundation-first once present.
35    pub reading_plan: Option<Vec<ReadingStep>>,
36    pub audit: Audit,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct Generator {
41    pub tool: String,
42    pub version: String,
43    /// Pipeline stages that actually ran, in order: "enumerate", "classify",
44    /// "group", "order".
45    pub stages: Vec<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49pub struct Source {
50    pub kind: SourceKind,
51    /// Fully resolved commit sha — a raw tree oid for `staged`/`worktree`
52    /// sources, whose endpoints are synthesized snapshots of uncommitted
53    /// state.
54    pub base: String,
55    /// Fully resolved commit sha — a raw tree oid for `staged`/`worktree`
56    /// sources (see `base`).
57    pub head: String,
58    pub remote: Option<Remote>,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum SourceKind {
64    Commit,
65    Range,
66    Mr,
67    Pr,
68    /// HEAD vs the index (additive in schema v1).
69    Staged,
70    /// The index vs the worktree (additive in schema v1).
71    Worktree,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct Remote {
76    pub forge: String,
77    pub project: String,
78    pub id: String,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct Stats {
83    pub files: u32,
84    pub hunks: u32,
85    pub classes: u32,
86    pub binary_files: u32,
87    pub submodules: u32,
88}
89
90/// One changed file in the canonical (`--no-renames`) view. A rename therefore
91/// appears as a D entry plus an A entry; the rename-detected view annotates both.
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93pub struct FileEntry {
94    pub path: String,
95    pub disposition: Disposition,
96    /// New-side mode ("100644", "100755", "120000", "160000"); `None` on deletion.
97    pub mode: Option<String>,
98    /// Old-side mode when it differs from `mode`, and on deletion.
99    pub old_mode: Option<String>,
100    /// On the A side of a detected rename: where the content came from.
101    pub old_path: Option<String>,
102    /// On the D side of a detected rename: where the content went. Together with
103    /// `old_path` this makes "moved and modified" addressable from both ends.
104    pub new_path: Option<String>,
105    /// Similarity score 0-100 from git's rename detection. Present on both sides of
106    /// a detected rename. Below ~95 the change is a modification, not a relocation,
107    /// and must never be treated as skim-eligible.
108    pub rename_similarity: Option<u8>,
109    /// Binary files carry zero hunks; content is tracked by object id only.
110    pub binary: bool,
111    pub submodule: Option<SubmoduleChange>,
112    /// Hint for the noise tier. Computed (builtin list, gitattributes, repo config),
113    /// never claimed by a model.
114    pub generated: bool,
115    pub generated_by: Option<GeneratedBy>,
116    /// Ids into `hunks`, in file order.
117    pub hunk_ids: Vec<String>,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
121pub enum Disposition {
122    A,
123    D,
124    M,
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub struct SubmoduleChange {
129    pub old: Option<String>,
130    pub new: Option<String>,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum GeneratedBy {
136    /// Matched the built-in lockfile/artefact list.
137    Builtin,
138    /// Declared by the repo via a gitattributes attribute (e.g. linguist-generated).
139    Attr,
140    /// Matched a glob in the repo's `.differential.toml`.
141    Config,
142}
143
144/// One canonical hunk from `git diff -U0 --no-renames`. Ids are positional
145/// (`h0..hN` in enumeration order) and do NOT survive regeneration; `digest` does.
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct HunkEntry {
148    pub id: String,
149    pub file: String,
150    pub old_start: u32,
151    pub old_count: u32,
152    pub new_start: u32,
153    pub new_count: u32,
154    /// Shape class id into `classes`.
155    pub class: String,
156    /// Exact content hash of the hunk (removed ++ added bytes, un-normalised).
157    /// The stable anchor for comments and review state across regenerations.
158    pub digest: String,
159    /// `\ No newline at end of file` on the old side.
160    pub nonl_old: bool,
161    /// `\ No newline at end of file` on the new side.
162    pub nonl_new: bool,
163    /// Position in the forge's rename-detected diff, for posting comments.
164    pub forge_position: ForgePosition,
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct ForgePosition {
169    /// Line in the new file; `None` for deletion-only hunks.
170    pub new_line: Option<u32>,
171    /// Line in the old file; `None` for insertion-only hunks.
172    pub old_line: Option<u32>,
173}
174
175/// A shape class: hunks whose diff text is identical after normalising away
176/// identifiers and literals on BOTH sides. Ids `C0..Cn`, numbered by descending
177/// member count. 100% hunk coverage is by construction.
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179pub struct ClassEntry {
180    pub id: String,
181    pub hunk_ids: Vec<String>,
182    /// The member a reviewer reads to verify the whole class.
183    pub exemplar: String,
184    /// True iff, after erasing identifiers and literals, the removed and added
185    /// lines match — a structure-free substitution. Computed, never claimed.
186    pub pure_substitution: bool,
187}
188
189/// A merged, labelled group of shape classes. Produced by the grouping stage.
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191pub struct Group {
192    pub id: String,
193    pub label: String,
194    pub description: String,
195    pub reason: String,
196    pub effort: Effort,
197    /// `None` until the ordering stage runs — role is an ordering-stage output.
198    pub role: Option<Role>,
199    pub class_ids: Vec<String>,
200    /// Group ids this group depends on (it consumes what they define).
201    pub depends_on: Vec<String>,
202    /// Position in the foundation-first ordering.
203    pub rank: u32,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "lowercase")]
208pub enum Effort {
209    /// Read every hunk, line by line.
210    Focus,
211    /// Read one exemplar per shape class; trust the rest.
212    Skim,
213    /// Generated content: folded entirely, no exemplars to read.
214    Noise,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum Role {
220    Foundation,
221    Consumer,
222    Mechanical,
223    Noise,
224}
225
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227pub struct ReadingStep {
228    pub group: String,
229    pub action: ReadAction,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "lowercase")]
234pub enum ReadAction {
235    /// Read every hunk in the group.
236    Read,
237    /// Read one hunk per shape class.
238    Exemplars,
239    /// Remaining members of already-verified shapes.
240    Skip,
241    /// Noise group: collapsed entirely.
242    Fold,
243}
244
245/// Structural audit. The first four fields exist for every document; the rest are
246/// `null` until the grouping stage runs.
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248pub struct Audit {
249    /// "n/n" — files reconstructed byte-exactly from base + hunks.
250    pub applier_exact: String,
251    /// "pass" — built-from-hunks tree equals the head tree.
252    pub tree_assertion: String,
253    pub hunks_carried: u32,
254    /// Independent `@@` recount computed from git output, not from bookkeeping.
255    pub recount: u32,
256    pub coverage: Option<f64>,
257    pub classes_missing: Option<u32>,
258    pub classes_duplicated: Option<Vec<String>>,
259    pub classes_hallucinated: Option<Vec<String>>,
260    /// Hunks a reviewer actually reads (focus + exemplars). The honest number.
261    pub read_hunks: Option<u32>,
262    /// Hunks never opened (skim remainders + folded noise). The genuine saving.
263    pub skipped_hunks: Option<u32>,
264}
265
266#[derive(Debug)]
267pub enum SchemaError {
268    UnsupportedVersion { found: u32 },
269    Json(serde_json::Error),
270}
271
272impl std::fmt::Display for SchemaError {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        match self {
275            SchemaError::UnsupportedVersion { found } => write!(
276                f,
277                "unsupported schema_version {found} (this reader understands {SCHEMA_VERSION})"
278            ),
279            SchemaError::Json(e) => write!(f, "invalid plan document: {e}"),
280        }
281    }
282}
283
284impl std::error::Error for SchemaError {
285    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
286        match self {
287            SchemaError::Json(e) => Some(e),
288            _ => None,
289        }
290    }
291}
292
293impl From<serde_json::Error> for SchemaError {
294    fn from(e: serde_json::Error) -> Self {
295        SchemaError::Json(e)
296    }
297}
298
299impl PlanDocument {
300    /// Parse and enforce the version gate. Use this instead of raw serde_json.
301    pub fn from_json(s: &str) -> Result<Self, SchemaError> {
302        #[derive(Deserialize)]
303        struct VersionProbe {
304            schema_version: u32,
305        }
306        let probe: VersionProbe = serde_json::from_str(s)?;
307        if probe.schema_version != SCHEMA_VERSION {
308            return Err(SchemaError::UnsupportedVersion {
309                found: probe.schema_version,
310            });
311        }
312        Ok(serde_json::from_str(s)?)
313    }
314
315    pub fn to_json_pretty(&self) -> Result<String, SchemaError> {
316        Ok(serde_json::to_string_pretty(self)?)
317    }
318
319    pub fn to_json(&self) -> Result<String, SchemaError> {
320        Ok(serde_json::to_string(self)?)
321    }
322}