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    })
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}