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 })
92}
93
94/// The identity literal for a picked `HEAD` source (ADR 0017). Not an endpoint
95/// — it names what the review is *of* while its synthesized endpoints move.
96///
97/// Its worktree counterpart is `review_identity::WORKTREE_SPEC`, imported
98/// rather than repeated: that module compares against the literal this one
99/// writes, and two copies of it could drift into a review that is filed as
100/// uncommitted work and then scanned as if it were a commit.
101const HEAD_SPEC: &str = "HEAD";
102
103/// Run the core pipeline (stages: enumerate, classify) over `base..head`.
104///
105/// Config is consulted ONLY for classification hints; enumeration is total and
106/// runs before config is even looked at (ADR 0012). Languages (ADR 0015) only
107/// influence classification, never enumeration.
108pub fn run_pipeline<G>(
109 git: &G,
110 base_rev: &str,
111 head_rev: &str,
112 kind: schema::SourceKind,
113 config: &Config,
114 langs: &LanguageRegistry,
115 symbols: &SymbolReaders,
116) -> Result<PipelineOutput, EngineError>
117where
118 G: RangeResolver + DiffSource + AttributeSource + ObjectReader,
119{
120 run_core_with_progress(git, base_rev, head_rev, kind, config, langs, symbols, None)
121}
122
123/// Core pipeline + the grouping stage (stages: enumerate, classify, group).
124///
125/// The engine is the single producer of the final document renderers consume;
126/// grouping runs in-process over the document the core stages produced — it
127/// takes no diff view, because everything it needs is in the document and the
128/// model fetches the rest (ADR 0022). On any invariant failure the grouping
129/// stage is skipped and `document` is `None`, exactly like the core pipeline.
130// The parameter list is the point, exactly as a bound list is: each entry is a
131// distinct authority this function may use. Bundling `langs` and `symbols`
132// behind a context struct would shorten the list without making it clearer,
133// and `CLAUDE.md` rule 2 refuses that shape.
134#[allow(clippy::too_many_arguments)]
135pub fn run_grouped_pipeline<G, C, A>(
136 git: &G,
137 base_rev: &str,
138 head_rev: &str,
139 kind: schema::SourceKind,
140 config: &Config,
141 langs: &LanguageRegistry,
142 symbols: &SymbolReaders,
143 grouping: &crate::grouping::GroupingOptions<C, A>,
144) -> Result<PipelineOutput, EngineError>
145where
146 C: crate::ports::GroupingCache,
147 A: crate::ports::ArtefactStore,
148 G: RangeResolver + DiffSource + AttributeSource + ObjectReader,
149{
150 let mut out = run_core_with_progress(
151 git,
152 base_rev,
153 head_rev,
154 kind,
155 config,
156 langs,
157 symbols,
158 grouping.progress,
159 )?;
160
161 if let Some(core_doc) = &out.document {
162 let mut grouped = crate::grouping::run(
163 core_doc,
164 grouping.backend,
165 grouping.cache,
166 grouping.artefacts,
167 grouping.fetch,
168 &langs.fingerprint(),
169 &symbols.fingerprint(),
170 grouping.progress,
171 )?;
172 // Ordering is deterministic and model-free: always runs after grouping.
173 if let Some(f) = grouping.progress {
174 f(crate::grouping::Progress::Ordering);
175 }
176 crate::ordering::apply(&mut grouped);
177 out.document = Some(grouped);
178 }
179 if let Some(f) = grouping.progress {
180 f(crate::grouping::Progress::Done);
181 }
182 Ok(out)
183}
184
185/// Invariants 3 and 4 over a pipeline's output. **This writes.**
186///
187/// Building a tree from the hunks is the only non-tautological way to prove
188/// every hunk was carried, and `write-tree` needs the blobs in the odb. They
189/// land unreferenced and `git gc` collects them.
190///
191/// Run it when the caller reconstructs a tree — `dfr check`, whose whole job
192/// this is, and the shadow-branch builder, whose commits are trees built from
193/// exactly these hunks. A reviewer that only reads a diff is protected by
194/// invariants 1b, 1 and 2, which have already run.
195///
196/// The result lands in `out.report.tree` and in the document's audit block,
197/// with `"verify"` appended to `generator.stages`. Absence of that stage is how
198/// a consumer tells "did not run" from "ran and passed".
199pub fn verify<G>(git: &G, out: &mut PipelineOutput) -> Result<(), EngineError>
200where
201 G: ObjectReader + ObjectWriter + TreeResolver + TreeBuilder + RecountSource,
202{
203 let tree = check_tree(git, &out.base, &out.head, &out.view, &out.report)?;
204 if let Some(doc) = &mut out.document {
205 apply_tree_audit(doc, &tree);
206 }
207 out.report.tree = Some(tree);
208 Ok(())
209}
210
211// The parameter list is the point, exactly as a bound list is: each entry is a
212// distinct authority this function may use. Bundling `langs` and `symbols`
213// behind a context struct would shorten the list without making it clearer,
214// and `CLAUDE.md` rule 2 refuses that shape.
215#[allow(clippy::too_many_arguments)]
216fn run_core_with_progress<G>(
217 git: &G,
218 base_rev: &str,
219 head_rev: &str,
220 kind: schema::SourceKind,
221 config: &Config,
222 langs: &LanguageRegistry,
223 symbols: &SymbolReaders,
224 progress: Option<&(dyn Fn(crate::grouping::Progress) + Send + Sync)>,
225) -> Result<PipelineOutput, EngineError>
226where
227 G: RangeResolver + DiffSource + AttributeSource + ObjectReader,
228{
229 if let Some(f) = progress {
230 f(crate::grouping::Progress::Enumerating);
231 }
232 // Commits normally; raw tree oids for uncommitted-state reviews
233 // (ADR 0017) — every later stage treats the endpoints as trees anyway.
234 let base = git.resolve_endpoint(base_rev)?;
235 let head = git.resolve_endpoint(head_rev)?;
236
237 // The argv for these three is FROZEN and lives in the adapter, where a
238 // reviewer can see all of it at once (ADR 0002).
239 let raw_records = git.raw_records(&base, &head)?;
240 let canonical_patch = git.canonical_patch(&base, &head)?;
241 let rename_records = git.rename_records(&base, &head)?;
242
243 // Enumeration is total and knows nothing about config (ADR 0012) — see
244 // `plan::build_view`'s parameter list, which is where that is enforced.
245 let mut view = plan::build_view(&plan::Enumeration {
246 raw_records: &raw_records,
247 canonical_patch: &canonical_patch,
248 rename_records: &rename_records,
249 })?;
250
251 // Only now do config and languages get a say, and only over description.
252 let attr_marked = attr_marked_paths(git, config, &view)?;
253 if let Some(f) = progress {
254 f(crate::grouping::Progress::Classifying);
255 }
256 let part = plan::classify(&mut view, config, &attr_marked, langs);
257 // The dependency graph is classification too, and it is built from classes
258 // rather than from groups: what depends on what is a fact about the diff,
259 // so the model reads it before it groups and cannot change it by grouping
260 // (ADR 0022).
261 let graph = crate::artefact::graph::build(git, &head, &view, &part, symbols)?;
262
263 // Invariants 1 and 2, read-only; no document on violation. The tree half
264 // is `verify`'s, and a caller that never builds a tree never needs it.
265 let report = check_fidelity(git, &base, &head, &view)?;
266 let document = if report.fidelity_ok() {
267 Some(assemble(
268 &view,
269 &part,
270 graph,
271 &SourceInfo {
272 kind,
273 base: base.clone(),
274 head: head.clone(),
275 },
276 &report,
277 )?)
278 } else {
279 None
280 };
281
282 Ok(PipelineOutput {
283 base,
284 head,
285 report,
286 document,
287 view,
288 })
289}
290
291/// Paths marked generated by any of the configured gitattributes names.
292/// Note: `check-attr` consults the worktree/index `.gitattributes`, not the
293/// reviewed revisions — acceptable for a hint that never affects enumeration.
294fn attr_marked_paths<G: AttributeSource>(
295 git: &G,
296 config: &Config,
297 view: &crate::model::DiffView,
298) -> Result<HashSet<Vec<u8>>, EngineError> {
299 let mut marked = HashSet::new();
300 let paths: Vec<&[u8]> = view.files.iter().map(|f| f.path.as_slice()).collect();
301 for attr in &config.attributes {
302 for answer in git.check_attr(attr, &paths)? {
303 // Which attributes to ask about is config's business; what the
304 // answers mean is domain policy.
305 if plan::attr_marks_generated(&answer.value) {
306 marked.insert(answer.path);
307 }
308 }
309 }
310 Ok(marked)
311}