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}