Skip to main content

differential_engine/
pipeline.rs

1//! The core pipeline: enumerate → annotate → classify → emit. **Read-only.**
2//!
3//! Its bound list carries no write port, and that is the point: the pipeline
4//! cannot write, and a reader can see so without opening the body.
5//!
6//! Invariants 1 and 2 run here, and no document is emitted when either fails.
7//! Invariant 1b runs earlier still, in `rename_view::merge_raw`. Those three
8//! are what protect a renderer from a bad parse, a dropped file or broken
9//! accounting.
10//!
11//! Invariants 3 and 4 build a tree, so they write. They live in [`verify`],
12//! which a caller runs when it wants them — only a consumer that reconstructs
13//! a tree is protected by them.
14
15use std::collections::HashSet;
16
17use crate::schema;
18
19use crate::EngineError;
20use crate::artefact::symbols::SymbolReaders;
21use crate::config::Config;
22use crate::document::{SourceInfo, apply_tree_audit, assemble};
23use crate::invariants::{InvariantReport, check_fidelity, check_tree};
24use crate::lang::LanguageRegistry;
25use crate::plan;
26use crate::ports::{
27    AttributeSource, DiffSource, ObjectReader, ObjectWriter, RangeResolver, RecountSource,
28    TreeBuilder, TreeResolver, WorkingCopy,
29};
30use crate::review_identity::WORKTREE_SPEC;
31
32pub struct PipelineOutput {
33    pub base: String,
34    pub head: String,
35    pub report: InvariantReport,
36    /// `None` iff an invariant failed — no document is emitted on a violation.
37    pub document: Option<schema::PlanDocument>,
38    /// The canonical diff view (hunk content). Renderers that display bytes —
39    /// the stack builder and the TUI — read from here; the schema document
40    /// deliberately never carries content (ADR 0008).
41    pub view: crate::model::DiffView,
42}
43
44/// Resolve a revision-range spec into a review source.
45///
46/// Accepts `a..b`, `a...b` (base = merge-base, what an MR/PR diff is), or two
47/// separate revs. The spec is parsed once, in `plan::parse_range`, so the
48/// endpoints and the review's identity cannot disagree about which side is the
49/// head.
50pub fn resolve_range<G: RangeResolver>(
51    git: &G,
52    spec: &[&str],
53) -> Result<plan::ReviewSource, EngineError> {
54    let parsed = plan::parse_range(spec)?;
55    let head_spec = parsed.head_spec().to_string();
56    let (base, head) = match &parsed {
57        plan::RangeSpec::Direct { base, head } => (base.clone(), head.clone()),
58        plan::RangeSpec::MergeBase { a, b } => (git.merge_base(a, b)?, b.clone()),
59    };
60    Ok(plan::ReviewSource::range(base, head, head_spec))
61}
62
63/// Resolve the review picker's answer: a base commit, plus whether uncommitted
64/// work is included (ADR 0017).
65///
66/// With it, the head is a snapshot of the worktree and the review is filed
67/// under the base sha plus a stable literal, so it survives that snapshot tree
68/// churning under it on every edit. Without it, the head is `HEAD`.
69pub fn resolve_picked<G>(
70    git: &G,
71    base: String,
72    include_worktree: bool,
73) -> Result<plan::ReviewSource, EngineError>
74where
75    G: TreeBuilder + WorkingCopy,
76{
77    if !include_worktree {
78        return Ok(plan::ReviewSource::range(
79            base,
80            HEAD_SPEC.to_string(),
81            HEAD_SPEC.to_string(),
82        ));
83    }
84    let head = crate::worktree::worktree_tree(git)?;
85    Ok(plan::ReviewSource {
86        identity_base: Some(base.clone()),
87        base,
88        head,
89        kind: schema::SourceKind::Worktree,
90        head_spec: WORKTREE_SPEC.to_string(),
91        remote: None,
92    })
93}
94
95/// The identity literal for a picked `HEAD` source (ADR 0017). Not an endpoint
96/// — it names what the review is *of* while its synthesized endpoints move.
97///
98/// Its worktree counterpart is `review_identity::WORKTREE_SPEC`, imported
99/// rather than repeated: that module compares against the literal this one
100/// writes, and two copies of it could drift into a review that is filed as
101/// uncommitted work and then scanned as if it were a commit.
102const HEAD_SPEC: &str = "HEAD";
103
104/// Run the core pipeline (stages: enumerate, classify) over `base..head`.
105///
106/// Config is consulted ONLY for classification hints; enumeration is total and
107/// runs before config is even looked at (ADR 0012). Languages (ADR 0015) only
108/// influence classification, never enumeration.
109pub fn run_pipeline<G>(
110    git: &G,
111    source: &plan::ReviewSource,
112    config: &Config,
113    langs: &LanguageRegistry,
114    symbols: &SymbolReaders,
115) -> Result<PipelineOutput, EngineError>
116where
117    G: RangeResolver + DiffSource + AttributeSource + ObjectReader,
118{
119    run_core_with_progress(git, source, config, langs, symbols, None)
120}
121
122/// Core pipeline + the grouping stage (stages: enumerate, classify, group).
123///
124/// The engine is the single producer of the final document renderers consume;
125/// grouping runs in-process over the document the core stages produced — it
126/// takes no diff view, because everything it needs is in the document and the
127/// model fetches the rest (ADR 0022). On any invariant failure the grouping
128/// stage is skipped and `document` is `None`, exactly like the core pipeline.
129// The parameter list is the point, exactly as a bound list is: each entry is a
130// distinct authority this function may use. Bundling `langs` and `symbols`
131// behind a context struct would shorten the list without making it clearer,
132// and `CLAUDE.md` rule 2 refuses that shape.
133#[allow(clippy::too_many_arguments)]
134pub fn run_grouped_pipeline<G, C, A>(
135    git: &G,
136    source: &plan::ReviewSource,
137    config: &Config,
138    langs: &LanguageRegistry,
139    symbols: &SymbolReaders,
140    grouping: &crate::grouping::GroupingOptions<C, A>,
141) -> Result<PipelineOutput, EngineError>
142where
143    C: crate::ports::GroupingCache,
144    A: crate::ports::ArtefactStore,
145    G: RangeResolver + DiffSource + AttributeSource + ObjectReader,
146{
147    let mut out = run_core_with_progress(git, source, config, langs, symbols, grouping.progress)?;
148
149    if let Some(core_doc) = &out.document {
150        let mut grouped = crate::grouping::run(
151            core_doc,
152            grouping.backend,
153            grouping.cache,
154            grouping.artefacts,
155            grouping.fetch,
156            &langs.fingerprint(),
157            &symbols.fingerprint(),
158            grouping.progress,
159        )?;
160        // Ordering is deterministic and model-free: always runs after grouping.
161        if let Some(f) = grouping.progress {
162            f(crate::grouping::Progress::Ordering);
163        }
164        crate::ordering::apply(&mut grouped);
165        out.document = Some(grouped);
166    }
167    if let Some(f) = grouping.progress {
168        f(crate::grouping::Progress::Done);
169    }
170    Ok(out)
171}
172
173/// Invariants 3 and 4 over a pipeline's output. **This writes.**
174///
175/// Building a tree from the hunks is the only non-tautological way to prove
176/// every hunk was carried, and `write-tree` needs the blobs in the odb. They
177/// land unreferenced and `git gc` collects them.
178///
179/// Run it when the caller reconstructs a tree — `dfr check`, whose whole job
180/// this is, and the shadow-branch builder, whose commits are trees built from
181/// exactly these hunks. A reviewer that only reads a diff is protected by
182/// invariants 1b, 1 and 2, which have already run.
183///
184/// The result lands in `out.report.tree` and in the document's audit block,
185/// with `"verify"` appended to `generator.stages`. Absence of that stage is how
186/// a consumer tells "did not run" from "ran and passed".
187pub fn verify<G>(git: &G, out: &mut PipelineOutput) -> Result<(), EngineError>
188where
189    G: ObjectReader + ObjectWriter + TreeResolver + TreeBuilder + RecountSource,
190{
191    let tree = check_tree(git, &out.base, &out.head, &out.view, &out.report)?;
192    if let Some(doc) = &mut out.document {
193        apply_tree_audit(doc, &tree);
194    }
195    out.report.tree = Some(tree);
196    Ok(())
197}
198
199// The parameter list is the point, exactly as a bound list is: each entry is a
200// distinct authority this function may use. Bundling `langs` and `symbols`
201// behind a context struct would shorten the list without making it clearer,
202// and `CLAUDE.md` rule 2 refuses that shape.
203#[allow(clippy::too_many_arguments)]
204fn run_core_with_progress<G>(
205    git: &G,
206    source: &plan::ReviewSource,
207    config: &Config,
208    langs: &LanguageRegistry,
209    symbols: &SymbolReaders,
210    progress: Option<&(dyn Fn(crate::grouping::Progress) + Send + Sync)>,
211) -> Result<PipelineOutput, EngineError>
212where
213    G: RangeResolver + DiffSource + AttributeSource + ObjectReader,
214{
215    if let Some(f) = progress {
216        f(crate::grouping::Progress::Enumerating);
217    }
218    // Commits normally; raw tree oids for uncommitted-state reviews
219    // (ADR 0017) — every later stage treats the endpoints as trees anyway.
220    let base = git.resolve_endpoint(&source.base)?;
221    let head = git.resolve_endpoint(&source.head)?;
222
223    // The argv for these three is FROZEN and lives in the adapter, where a
224    // reviewer can see all of it at once (ADR 0002).
225    let raw_records = git.raw_records(&base, &head)?;
226    let canonical_patch = git.canonical_patch(&base, &head)?;
227    let rename_records = git.rename_records(&base, &head)?;
228
229    // Enumeration is total and knows nothing about config (ADR 0012) — see
230    // `plan::build_view`'s parameter list, which is where that is enforced.
231    let mut view = plan::build_view(&plan::Enumeration {
232        raw_records: &raw_records,
233        canonical_patch: &canonical_patch,
234        rename_records: &rename_records,
235    })?;
236
237    // Only now do config and languages get a say, and only over description.
238    let attr_marked = attr_marked_paths(git, config, &view)?;
239    if let Some(f) = progress {
240        f(crate::grouping::Progress::Classifying);
241    }
242    let part = plan::classify(&mut view, config, &attr_marked, langs);
243    // The dependency graph is classification too, and it is built from classes
244    // rather than from groups: what depends on what is a fact about the diff,
245    // so the model reads it before it groups and cannot change it by grouping
246    // (ADR 0022).
247    let graph = crate::artefact::graph::build(git, &head, &view, &part, symbols)?;
248
249    // Invariants 1 and 2, read-only; no document on violation. The tree half
250    // is `verify`'s, and a caller that never builds a tree never needs it.
251    let report = check_fidelity(git, &base, &head, &view)?;
252    let document = if report.fidelity_ok() {
253        Some(assemble(
254            &view,
255            &part,
256            graph,
257            &SourceInfo {
258                kind: source.kind,
259                base: base.clone(),
260                head: head.clone(),
261                remote: source.remote.clone(),
262            },
263            &report,
264        )?)
265    } else {
266        None
267    };
268
269    Ok(PipelineOutput {
270        base,
271        head,
272        report,
273        document,
274        view,
275    })
276}
277
278/// Paths marked generated by any of the configured gitattributes names.
279/// Note: `check-attr` consults the worktree/index `.gitattributes`, not the
280/// reviewed revisions — acceptable for a hint that never affects enumeration.
281fn attr_marked_paths<G: AttributeSource>(
282    git: &G,
283    config: &Config,
284    view: &crate::model::DiffView,
285) -> Result<HashSet<Vec<u8>>, EngineError> {
286    let mut marked = HashSet::new();
287    let paths: Vec<&[u8]> = view.files.iter().map(|f| f.path.as_slice()).collect();
288    for attr in &config.attributes {
289        for answer in git.check_attr(attr, &paths)? {
290            // Which attributes to ask about is config's business; what the
291            // answers mean is domain policy.
292            if plan::attr_marks_generated(&answer.value) {
293                marked.insert(answer.path);
294            }
295        }
296    }
297    Ok(marked)
298}