differential-engine 0.13.0

Core engine: git io, diff parsing, byte-exact apply, shape classes, invariants
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! The frozen JSON contract for differential reading plans.
//!
//! This module is the product boundary (ADR 0008, superseded-in-form by ADR
//! 0018): every consumer (shadow-branch stack, TUI, forge review) depends on
//! these types and nothing else. It stays serde-only — consumer conveniences
//! and engine internals must not leak in here; that discipline is enforced in
//! review now that the crate boundary is gone.
//!
//! Contract rules:
//! - `schema_version` is 3 (v3 gave every dependency edge its cause and moved
//!   the graph onto `classes`, ADR 0022). Readers must reject versions they do
//!   not know.
//! - Deserialisation tolerates unknown fields, so additive changes are non-breaking.
//! - `groups`/`reading_plan` are `null` when the grouping stage has not run. That is
//!   distinct from `[]`, which would mean "grouping ran and produced nothing" and is
//!   always a bug. `generator.stages` states exactly which stages produced the document.
//! - Optional fields serialise as explicit `null`, never omitted.

use serde::{Deserialize, Serialize};

pub const SCHEMA_VERSION: u32 = 3;

/// The one JSON document: a grouped, ordered reading plan for a diff.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlanDocument {
    pub schema_version: u32,
    pub generator: Generator,
    pub source: Source,
    pub stats: Stats,
    pub files: Vec<FileEntry>,
    pub hunks: Vec<HunkEntry>,
    pub classes: Vec<ClassEntry>,
    /// `None` until the grouping stage runs. `Some(vec![])` is a bug, not a state.
    pub groups: Option<Vec<Group>>,
    /// `None` until the grouping stage runs; ordered foundation-first once present.
    pub reading_plan: Option<Vec<ReadingStep>>,
    pub audit: Audit,
    /// Symbol-level dependency sites: which token resolves to which
    /// declaration. Produced by `classify`, beside the class graph.
    ///
    /// `None` on a document written before this field existed. It is additive,
    /// so `schema_version` stays 3 — but a stored artefact does get re-read
    /// (`dfr agent --doc`, the grouping cache), which is why this defaults
    /// rather than requiring the key.
    #[serde(default)]
    pub symbols: Option<SymbolIndex>,
}

/// Where each resolvable name is declared, and every token that reads one.
///
/// Two flat lists rather than a map: ids are positional, a consumer indexes
/// them directly, and JSON has no set type worth the ceremony.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SymbolIndex {
    pub definitions: Vec<SymbolDef>,
    pub uses: Vec<SymbolUse>,
}

/// One declaration something in the change reads.
///
/// It may sit on ANY line of a file the change touches, not only one the change
/// wrote: the commonest question a reviewer has is what a newly added call
/// resolves to, and that is usually a helper which was already there.
///
/// Only names with exactly ONE definer appear, the same rule the class graph
/// draws edges by — a name declared twice is ambiguous, and nothing here can
/// say which one a reader meant.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SymbolDef {
    /// Document-local and positional, `s0…sn`, like `h<N>` and `C<N>`. Does not
    /// survive regeneration.
    pub id: String,
    pub name: String,
    /// Index into the document's `files[]`.
    ///
    /// **Not a path.** On a change of any size this index holds thousands of
    /// rows, and a repeated path was 56% of its bytes on the validation corpus.
    /// The document already lists every file exactly once, so pointing at that
    /// list costs nothing and duplicates nothing.
    ///
    /// It differs from `hunks[].file`, which is a path string and frozen that
    /// way — the inconsistency is unavoidable, and this is the side of it where
    /// the repetition is large enough to matter.
    pub file: u32,
    /// New-side line of the declaring token, counting from 1.
    pub line: u32,
    /// Last line of what the name declares. Equal to `line` where the reader
    /// could not see an extent — a regex has no tree to ask.
    pub through: u32,
    /// Byte offsets of the token within its RAW line, before any tab expansion.
    /// A renderer that expands tabs must translate these against its own
    /// expansion rather than index its display text with them.
    pub start: u32,
    pub end: u32,
    /// The shape class that introduces it, or `null` where the change did not
    /// write this line — the declaration is real, it is simply not part of the
    /// change.
    pub class: Option<String>,
}

/// One token that reads a [`SymbolDef`].
///
/// Recorded only on a line the change WROTE. Those are the lines the reviewer is
/// reading, and they bound the index: with any declaration resolvable, every
/// mention in every parsed file would grow this with the size of the files.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SymbolUse {
    /// The `SymbolDef` id this use resolves to.
    pub on: String,
    /// Index into the document's `files[]`, as on [`SymbolDef`].
    pub file: u32,
    pub line: u32,
    /// Raw-line byte offsets, as on [`SymbolDef`].
    pub start: u32,
    pub end: u32,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Generator {
    pub tool: String,
    pub version: String,
    /// Pipeline stages that actually ran, in order: "enumerate", "classify",
    /// "group", "order", and "verify" when the separate verify stage ran
    /// (ADR 0028). A consumer reads absence as "did not run", never as a pass.
    pub stages: Vec<String>,
}

impl Generator {
    /// This build of the tool, having run `stages` so far.
    ///
    /// One spelling of the tool's name and version. The producer and the
    /// `dfr agents` probe each wrote it out, and each read
    /// `CARGO_PKG_VERSION` from its own crate — equal only because the
    /// workspace shares one version.
    pub fn current(stages: &[&str]) -> Generator {
        Generator {
            tool: "differential".to_string(),
            version: env!("CARGO_PKG_VERSION").to_string(),
            stages: stages.iter().map(|s| s.to_string()).collect(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Source {
    pub kind: SourceKind,
    /// Fully resolved commit sha — a raw tree oid for `staged`/`worktree`
    /// sources, whose endpoints are synthesized snapshots of uncommitted
    /// state.
    pub base: String,
    /// Fully resolved commit sha — a raw tree oid for `staged`/`worktree`
    /// sources (see `base`).
    pub head: String,
    pub remote: Option<Remote>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceKind {
    Commit,
    Range,
    Mr,
    Pr,
    /// HEAD vs the index (additive in schema v1).
    Staged,
    /// The index vs the worktree (additive in schema v1).
    Worktree,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Remote {
    pub forge: String,
    pub project: String,
    pub id: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Stats {
    pub files: u32,
    pub hunks: u32,
    pub classes: u32,
    pub binary_files: u32,
    pub submodules: u32,
}

/// One changed file in the canonical (`--no-renames`) view. A rename therefore
/// appears as a D entry plus an A entry; the rename-detected view annotates both.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FileEntry {
    pub path: String,
    pub disposition: Disposition,
    /// New-side mode ("100644", "100755", "120000", "160000"); `None` on deletion.
    pub mode: Option<String>,
    /// Old-side mode when it differs from `mode`, and on deletion.
    pub old_mode: Option<String>,
    /// On the A side of a detected rename: where the content came from.
    pub old_path: Option<String>,
    /// On the D side of a detected rename: where the content went. Together with
    /// `old_path` this makes "moved and modified" addressable from both ends.
    pub new_path: Option<String>,
    /// Similarity score 0-100 from git's rename detection. Present on both sides of
    /// a detected rename. Below ~95 the change is a modification, not a relocation,
    /// and must never be treated as skim-eligible.
    pub rename_similarity: Option<u8>,
    /// Binary files carry zero hunks; content is tracked by object id only.
    pub binary: bool,
    pub submodule: Option<SubmoduleChange>,
    /// Hint for the noise tier. Computed (builtin list, gitattributes, repo config),
    /// never claimed by a model.
    pub generated: bool,
    pub generated_by: Option<GeneratedBy>,
    /// Ids into `hunks`, in file order.
    pub hunk_ids: Vec<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Disposition {
    A,
    D,
    M,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SubmoduleChange {
    pub old: Option<String>,
    pub new: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GeneratedBy {
    /// Matched the built-in lockfile/artefact list.
    Builtin,
    /// Declared by the repo via a gitattributes attribute (e.g. linguist-generated).
    Attr,
    /// Matched a glob in the repo's `.differential.toml`.
    Config,
}

/// One canonical hunk from `git diff -U0 --no-renames`. Ids are positional
/// (`h0..hN` in enumeration order) and do NOT survive regeneration; `digest` does.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HunkEntry {
    pub id: String,
    pub file: String,
    pub old_start: u32,
    pub old_count: u32,
    pub new_start: u32,
    pub new_count: u32,
    /// Shape class id into `classes`.
    pub class: String,
    /// Exact content hash of the hunk (removed ++ added bytes, un-normalised).
    /// The stable anchor for comments and review state across regenerations.
    pub digest: String,
    /// `\ No newline at end of file` on the old side.
    pub nonl_old: bool,
    /// `\ No newline at end of file` on the new side.
    pub nonl_new: bool,
    /// The hunk's first line on each side, in the canonical `--no-renames` view. Not a
    /// posting anchor: the forge consumer places a finding by its own anchor and the
    /// file's `old_path` (spec/json-contract.md).
    pub forge_position: ForgePosition,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ForgePosition {
    /// Line in the new file; `None` for deletion-only hunks.
    pub new_line: Option<u32>,
    /// Line in the old file; `None` for insertion-only hunks.
    pub old_line: Option<u32>,
}

/// A shape class: hunks whose diff text is identical after normalising away
/// identifiers and literals on BOTH sides. Ids `C0..Cn`, numbered by descending
/// member count. 100% hunk coverage is by construction.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ClassEntry {
    pub id: String,
    pub hunk_ids: Vec<String>,
    /// The member a reviewer reads to verify the whole class.
    pub exemplar: String,
    /// True iff, after erasing identifiers and literals, the removed and added
    /// lines match — a structure-free substitution. Computed, never claimed.
    pub pure_substitution: bool,
    /// Symbols this class introduces, from `SymbolReaders::of_file`.
    /// Sorted and deduplicated.
    pub defines: Vec<String>,
    /// Classes this class consumes: it references a symbol they define. Sorted
    /// by `on`. The graph is a fact about the diff, computed before grouping,
    /// so it never depends on how the model merged classes.
    pub depends_on: Vec<ClassEdge>,
}

/// One class-level dependency edge.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClassEdge {
    /// The class this one consumes.
    pub on: String,
    /// The symbols that produced the edge — why the dependency exists. Sorted
    /// and deduplicated. Extraction is heuristic (ADR 0015), so a consumer may
    /// judge an edge by its cause rather than take it on trust.
    pub via: Vec<String>,
}

/// A merged, labelled group of shape classes. Produced by the grouping stage.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Group {
    pub id: String,
    pub label: String,
    pub description: String,
    pub reason: String,
    pub effort: Effort,
    /// `None` until the ordering stage runs — role is an ordering-stage output.
    pub role: Option<Role>,
    /// Member classes, ordered foundation-first by the ordering stage.
    pub class_ids: Vec<String>,
    /// Groups this group depends on: it consumes what they define. The
    /// contraction of the class graph onto groups.
    pub depends_on: Vec<Edge>,
    /// Position in the foundation-first ordering.
    pub rank: u32,
    /// How many leading `class_ids` depend on nothing ranked later — the index
    /// at which this group stops being a foundation and starts being a
    /// consumer.
    ///
    /// `None` unless the ordering had to break a cycle on this group. Nothing
    /// splits the group: the number says where the group cannot be read as one
    /// thing, and leaves what to do about it to the reader.
    pub pivot: Option<u32>,
}

/// One group-level dependency edge.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Edge {
    /// The group this one depends on.
    pub on: String,
    /// The symbols that produced the edge — why the dependency exists.
    pub via: Vec<String>,
    /// `None` unless the ordering could not honour this edge.
    ///
    /// Whether it could is derivable from `rank`, so it is not repeated here.
    /// Why it could not is NOT derivable, which is what this records.
    pub cycle: Option<Cycle>,
}

/// Why a dependency edge could not be honoured.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Cycle {
    /// The class graph is acyclic here. The cycle exists only because groups
    /// contract classes: one group both defines and consumes, against the same
    /// other group, so no reading order can satisfy both.
    Artefact,
    /// The class graph is cyclic too. The mutual dependency is in the change.
    Mutual,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Effort {
    /// Read every hunk, line by line.
    Focus,
    /// Read one exemplar per shape class; trust the rest.
    Skim,
    /// Generated content: folded entirely, no exemplars to read.
    Noise,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    Foundation,
    Consumer,
    Mechanical,
    Noise,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReadingStep {
    pub group: String,
    pub action: ReadAction,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReadAction {
    /// Read every hunk in the group.
    Read,
    /// Read one hunk per shape class.
    Exemplars,
    /// Remaining members of already-verified shapes.
    Skip,
    /// Noise group: collapsed entirely.
    Fold,
}

/// Structural audit. The first four fields exist for every document; the rest are
/// `null` until the grouping stage runs.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Audit {
    /// "n/n" — files reconstructed byte-exactly from base + hunks.
    pub applier_exact: String,
    /// "pass" — built-from-hunks tree equals the head tree.
    pub tree_assertion: String,
    pub hunks_carried: u32,
    /// Independent `@@` recount computed from git output, not from bookkeeping.
    pub recount: u32,
    pub coverage: Option<f64>,
    pub classes_missing: Option<u32>,
    pub classes_duplicated: Option<Vec<String>>,
    pub classes_hallucinated: Option<Vec<String>>,
    /// Hunks a reviewer actually reads (focus + exemplars). The honest number.
    pub read_hunks: Option<u32>,
    /// Hunks never opened (skim remainders + folded noise). The genuine saving.
    pub skipped_hunks: Option<u32>,
}

#[derive(Debug)]
pub enum SchemaError {
    UnsupportedVersion { found: u32 },
    Json(serde_json::Error),
}

impl std::fmt::Display for SchemaError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SchemaError::UnsupportedVersion { found } => write!(
                f,
                "unsupported schema_version {found} (this reader understands {SCHEMA_VERSION})"
            ),
            SchemaError::Json(e) => write!(f, "invalid plan document: {e}"),
        }
    }
}

impl std::error::Error for SchemaError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            SchemaError::Json(e) => Some(e),
            _ => None,
        }
    }
}

impl From<serde_json::Error> for SchemaError {
    fn from(e: serde_json::Error) -> Self {
        SchemaError::Json(e)
    }
}

impl PlanDocument {
    /// Parse and enforce the version gate. Use this instead of raw serde_json.
    pub fn from_json(s: &str) -> Result<Self, SchemaError> {
        #[derive(Deserialize)]
        struct VersionProbe {
            schema_version: u32,
        }
        let probe: VersionProbe = serde_json::from_str(s)?;
        if probe.schema_version != SCHEMA_VERSION {
            return Err(SchemaError::UnsupportedVersion {
                found: probe.schema_version,
            });
        }
        Ok(serde_json::from_str(s)?)
    }

    pub fn to_json_pretty(&self) -> Result<String, SchemaError> {
        Ok(serde_json::to_string_pretty(self)?)
    }

    pub fn to_json(&self) -> Result<String, SchemaError> {
        Ok(serde_json::to_string(self)?)
    }
}