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}