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}