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}