big_code_analysis/vcs/mod.rs
1//! Change-history (VCS) metrics: per-file signals derived from version
2//! control history rather than the AST.
3//!
4//! This is the project's first metric family that is language-agnostic
5//! and not AST-derived (issue #328). It surfaces files most likely to
6//! harbour bugs or vulnerabilities using the signals the empirical
7//! literature most consistently backs — recent churn, commit frequency,
8//! author count and ownership dilution, bug- and security-fix history —
9//! combined into an ordinal `risk_score`.
10//!
11//! # Layout
12//!
13//! The generic surface (`error`, `options`, `stats`, `identity`,
14//! `classify`, `score`, `hotspot`, `jit`, and `build_history_index`)
15//! carries no backend reference, so a future backend (Mercurial,
16//! Jujutsu, …; issue #335) reuses it unchanged. Backend-specific code
17//! lives under the `git` module behind the `vcs-git` Cargo feature.
18//!
19//! Two scoring granularities are offered: `build_history_index` ranks
20//! *files* at a ref (issue #328), while `score_commit` scores a single
21//! *commit* for just-in-time defect-induction risk (issue #331).
22//!
23//! v1 deliberately omits a `Backend` trait: with a single backend it
24//! would be premature abstraction. `build_history_index` delegates to
25//! the one available backend; the trait is extracted when a second
26//! backend lands.
27
28pub mod bus_factor;
29pub mod cache;
30pub mod classify;
31pub mod entropy;
32pub mod error;
33pub mod hotspot;
34pub mod identity;
35pub mod jit;
36pub mod options;
37pub mod score;
38pub mod stats;
39pub mod trend;
40
41pub(crate) mod replay;
42
43#[cfg(feature = "vcs-git")]
44pub mod git;
45
46pub use bus_factor::{
47 BUS_FACTOR_SCHEMA_VERSION, BusFactor, DirectoryBusFactor, GroupBusFactor, VcsAggregate,
48};
49pub use cache::{CACHE_SCHEMA_VERSION, CacheConfig};
50pub use error::Error;
51pub use identity::AuthorHashKey;
52pub use jit::{
53 JIT_SCHEMA_VERSION, JIT_SCORE_VERSION, JitCommit, JitContributions, JitDiffContributions,
54 JitDiffReport, JitDiffusion, JitExperience, JitFeatures, JitHistory, JitPurpose, JitReport,
55 JitSize, JitSource,
56};
57pub use options::{FileTypeScope, Options, RiskFormula, parse_window};
58pub use stats::Stats;
59pub use trend::{TREND_SCHEMA_VERSION, Trend, TrendDelta, TrendDeltas};
60
61/// Per-function change-history attribution (issue #329), surfaced when a
62/// front end opts into per-function VCS metrics. See [`PerFunctionBlame`].
63#[cfg(feature = "vcs-git")]
64pub use git::{BlameSession, LineSpan, PerFunctionBlame};
65
66use std::collections::HashMap;
67use std::path::{Path, PathBuf};
68
69/// The result of one history walk: per-file [`Stats`] keyed by
70/// repository-relative path, plus walk-level metadata.
71#[derive(Clone, Debug, Default)]
72pub struct HistoryIndex {
73 files: HashMap<PathBuf, Stats>,
74 workdir: Option<PathBuf>,
75 truncated_shallow_clone: bool,
76 bus_factor: Option<bus_factor::BusFactor>,
77}
78
79impl HistoryIndex {
80 /// Construct an index from its parts. Used by backends.
81 #[must_use]
82 pub fn new(
83 files: HashMap<PathBuf, Stats>,
84 workdir: Option<PathBuf>,
85 truncated_shallow_clone: bool,
86 ) -> Self {
87 Self {
88 files,
89 workdir,
90 truncated_shallow_clone,
91 bus_factor: None,
92 }
93 }
94
95 /// Attach the directory- / repo-level bus-factor aggregate (issue
96 /// #332). A builder rather than a `new` parameter so the established
97 /// constructor signature stays source-compatible for downstream
98 /// backends; the aggregate is computed only when a front end opts in
99 /// via [`Options::compute_bus_factor`].
100 #[must_use]
101 pub fn with_bus_factor(mut self, bus_factor: Option<bus_factor::BusFactor>) -> Self {
102 self.bus_factor = bus_factor;
103 self
104 }
105
106 /// The bus-factor aggregate, if it was computed for this walk.
107 #[must_use]
108 pub fn bus_factor(&self) -> Option<&bus_factor::BusFactor> {
109 self.bus_factor.as_ref()
110 }
111
112 /// The walk's whole-repo aggregates wrapped in the top-level
113 /// [`bus_factor::VcsAggregate`] object the front ends
114 /// emit, or `None` when no aggregate was computed. The single
115 /// projection shared by the CLI / web / Python surfaces so the
116 /// `vcs_aggregate` shape cannot drift between them.
117 #[must_use]
118 pub fn vcs_aggregate(&self) -> Option<bus_factor::VcsAggregate> {
119 self.bus_factor
120 .clone()
121 .map(|bus_factor| bus_factor::VcsAggregate { bus_factor })
122 }
123
124 /// Look up stats by repository-relative path.
125 #[must_use]
126 pub fn get(&self, repo_relative: &Path) -> Option<&Stats> {
127 self.files.get(repo_relative)
128 }
129
130 /// Look up stats for an absolute filesystem path by stripping the
131 /// working-tree prefix. Returns `None` for paths outside the work
132 /// tree or when the repository is bare (no work tree).
133 ///
134 /// `absolute` must be in the **same canonical form** as the workdir
135 /// the index was built with (the `gix`-backed builder stores an
136 /// already-canonical workdir, and the CLI canonicalizes its inputs to
137 /// match). A non-canonical caller — a symlinked or `..`-laden path that
138 /// does not share the stored prefix — silently yields `None` rather
139 /// than an error, so a future backend or external caller passing raw
140 /// paths must canonicalize first.
141 #[must_use]
142 pub fn get_for_path(&self, absolute: &Path) -> Option<&Stats> {
143 let workdir = self.workdir.as_deref()?;
144 let relative = absolute.strip_prefix(workdir).ok()?;
145 self.files.get(relative)
146 }
147
148 /// Iterate `(repo-relative path, stats)` pairs.
149 pub fn iter(&self) -> impl Iterator<Item = (&PathBuf, &Stats)> {
150 self.files.iter()
151 }
152
153 /// Number of files in the index.
154 #[must_use]
155 pub fn len(&self) -> usize {
156 self.files.len()
157 }
158
159 /// Whether the index is empty (empty repo, or no tracked text
160 /// files at the target ref).
161 #[must_use]
162 pub fn is_empty(&self) -> bool {
163 self.files.is_empty()
164 }
165
166 /// `true` when the repository is a shallow clone and history was
167 /// therefore truncated; the front end surfaces this as a warning.
168 #[must_use]
169 pub fn truncated_shallow_clone(&self) -> bool {
170 self.truncated_shallow_clone
171 }
172
173 /// The repository working-tree root, if any (`None` for bare repos).
174 #[must_use]
175 pub fn workdir(&self) -> Option<&Path> {
176 self.workdir.as_deref()
177 }
178
179 /// Consume the index, yielding its per-file map.
180 #[must_use]
181 pub fn into_files(self) -> HashMap<PathBuf, Stats> {
182 self.files
183 }
184}
185
186/// Walk the change history rooted at `root` and build a per-file
187/// [`HistoryIndex`].
188///
189/// Runs **once** per invocation (before any AST walk): walking history
190/// per file would be catastrophic on large repositories. The single
191/// available backend is selected automatically by probing the working
192/// tree.
193///
194/// # Errors
195///
196/// Returns [`Error::NotARepository`] when `root` is not inside a
197/// supported VCS working tree, or a backend-specific variant when the
198/// walk itself fails.
199#[cfg(feature = "vcs-git")]
200pub fn build_history_index(root: &Path, options: &Options) -> Result<HistoryIndex, Error> {
201 git::build(root, options)
202}
203
204/// Like [`build_history_index`], but reuse and update the persistent
205/// change-history cache per `config` (issue #334).
206///
207/// On an unchanged tree this replays a cached event log instead of
208/// re-walking; when `HEAD` has advanced it walks only the new commits and
209/// splices them onto the cached tail. The result is bit-identical to an
210/// uncached [`build_history_index`] at the same reference time — the cache
211/// is a pure optimization. A missing or corrupt entry is silently
212/// recomputed; an entry is ignored when the schema, score, or option
213/// fingerprint differs (window changes force a fresh walk). With
214/// [`CacheConfig::enabled`] `false` this degrades to a plain walk (still
215/// honouring [`CacheConfig::clear`]).
216///
217/// # Errors
218///
219/// The same variants as [`build_history_index`], plus [`Error::Cache`]
220/// when `--clear-cache` is requested but the cache directory cannot be
221/// removed. A failure to *write* a fresh entry is logged, not returned.
222#[cfg(feature = "vcs-git")]
223pub fn build_history_index_cached(
224 root: &Path,
225 options: &Options,
226 config: &CacheConfig,
227) -> Result<HistoryIndex, Error> {
228 git::build_cached(root, options, config)
229}
230
231/// Parse an `--as-of` timestamp into Unix seconds.
232///
233/// Accepts RFC 3339 / ISO 8601, a bare `@<unix>` epoch, and the git
234/// date spellings gix understands. Front ends use this to fill
235/// [`Options::as_of`] for reproducible snapshots.
236///
237/// # Errors
238///
239/// Returns [`Error::InvalidTimestamp`] when the input is unparseable.
240#[cfg(feature = "vcs-git")]
241pub fn parse_timestamp(input: &str) -> Result<i64, Error> {
242 git::parse_timestamp(input)
243}
244
245/// Score a single commit for just-in-time defect-induction risk
246/// (issue #331).
247///
248/// `spec` is any revision spelling the backend resolves to a commit
249/// (`HEAD`, a SHA, a tag, `main~3`, …). The commit is scored against its
250/// first parent; the touched files' priors and the author's experience
251/// are measured from the history *before* it, windowed by `options`.
252/// Returns a [`JitReport`] with the feature breakdown, per-group
253/// contributions, and the ordinal composite [`JitReport::risk_score`].
254///
255/// # Errors
256///
257/// Returns [`Error::NotARepository`] when `root` is not inside a
258/// supported VCS working tree, [`Error::ResolveRef`] when `spec` does not
259/// resolve to a commit, or a walk/diff variant when the history walk
260/// itself fails.
261#[cfg(feature = "vcs-git")]
262pub fn score_commit(root: &Path, spec: &str, options: &Options) -> Result<jit::JitReport, Error> {
263 git::score_commit(root, spec, options)
264}
265
266/// Score an arbitrary unified `diff` for just-in-time defect-induction risk
267/// (issue #580).
268///
269/// Unlike [`score_commit`], a bare diff carries **no author, parent, or
270/// file history**, so only the *size* and *diffusion* feature groups are
271/// computable. The result is a partial [`jit::JitDiffReport`] whose
272/// history / experience / purpose groups are **absent from the type**
273/// (not present as zero), and whose
274/// [`partial_risk_score`](jit::JitDiffReport::partial_risk_score) is **not
275/// comparable** to a commit score — rank diffs against other diffs only.
276/// See [`jit::JitDiffReport`] for the full contract.
277///
278/// `diff` must be a git-style unified diff carrying `diff --git` file
279/// headers (as produced by `git diff` / `git format-patch`), with one or
280/// more file stanzas. Plain `diff -u` output without those headers and
281/// combined / merge diffs (`git diff --cc`, `@@@` headers) are not
282/// supported. No repository access is needed; `options` does not
283/// participate (a bare diff has nothing to window).
284///
285/// # Errors
286///
287/// Returns [`Error::InvalidDiff`] when the diff is structurally malformed
288/// (a bad `@@` hunk header, or a `+`/`-` body line outside any hunk) or
289/// carries diff content with no `diff --git` file header at all (plain
290/// `diff -u` or a combined/merge diff).
291#[cfg(feature = "vcs-git")]
292pub fn score_diff(diff: &str) -> Result<jit::JitDiffReport, Error> {
293 git::score_diff(diff)
294}
295
296/// Sample the change-history metrics at `points` evenly-spaced moments
297/// across `span_secs`, ending at `options.as_of` (or wall-clock now),
298/// building a [`trend::Trend`] time series (issue #333).
299///
300/// Each point re-anchors at the mainline tip that existed at or before
301/// that moment, so the result is a faithful historical snapshot rather
302/// than today's tree windowed differently — see [`trend`] for the schema
303/// and the cross-snapshot rename limitation. `options` supplies the
304/// windows / bot / merge / rename / formula knobs shared by every point;
305/// its `reference` selects which mainline to follow.
306///
307/// # Errors
308///
309/// Returns [`Error::InvalidTrend`] when `points` is outside
310/// `[MIN_TREND_POINTS, MAX_TREND_POINTS]`
311/// ([`trend::MIN_TREND_POINTS`] / [`trend::MAX_TREND_POINTS`]),
312/// [`Error::NotARepository`] when `root` is not a working tree,
313/// [`Error::ResolveRef`] when the base reference does not resolve, or a
314/// walk/diff variant when a sampled snapshot fails.
315#[cfg(feature = "vcs-git")]
316pub fn build_trend(
317 root: &Path,
318 options: &Options,
319 points: usize,
320 span_secs: i64,
321) -> Result<trend::Trend, Error> {
322 git::build_trend(root, options, points, span_secs)
323}
324
325/// Rank `entries` by descending risk score, breaking ties on the file
326/// path (ascending), then truncate to the top `top` (`0` = keep all).
327///
328/// The single definition of the `bca vcs` / `POST /vcs` / `vcs_metrics`
329/// output ordering, shared by all three front ends so the float-compare
330/// and tie-break contract cannot drift between them. `key` extracts the
331/// `(path, risk_score)` pair from each entry, so callers keep their own
332/// per-crate entry types.
333pub fn rank_by_risk<T>(entries: &mut Vec<T>, top: usize, key: impl Fn(&T) -> (&str, f64)) {
334 entries.sort_by(|a, b| {
335 let (path_a, risk_a) = key(a);
336 let (path_b, risk_b) = key(b);
337 // Descending risk; NaN (never produced today) sorts as equal so
338 // the path tie-break still yields a stable, deterministic order.
339 risk_b
340 .partial_cmp(&risk_a)
341 .unwrap_or(std::cmp::Ordering::Equal)
342 .then_with(|| path_a.cmp(path_b))
343 });
344 if top > 0 && entries.len() > top {
345 entries.truncate(top);
346 }
347}
348
349/// Discover the working-tree root of the repository containing `path`.
350///
351/// Returns the canonicalised work-tree directory of the repository that
352/// encloses `path` (a file or directory), or `None` when `path` is not
353/// inside a repository or the repository is bare (no work tree). Repository
354/// discovery walks upward from `path` the same way `git` itself does.
355///
356/// Front ends use this to coalesce a batch of files onto the repository
357/// each belongs to — two files in different subdirectories of the same
358/// checkout resolve to the **same** root, so a per-repo [`HistoryIndex`]
359/// (or [`PerFunctionBlame`] engine) can be built once and shared across
360/// them rather than rebuilt per directory (issue #670).
361///
362/// ```no_run
363/// use std::path::Path;
364/// // Two files in different subdirectories of the *same* checkout both
365/// // resolve to that checkout's work-tree root: `workdir_root` walks
366/// // upward from each file and lands on the same `Some(root)`. (Shown
367/// // with absolute paths under a real checkout; `no_run` because no such
368/// // repository exists at doctest time.)
369/// let a = big_code_analysis::vcs::workdir_root(Path::new("/checkout/src/a.rs"));
370/// let b = big_code_analysis::vcs::workdir_root(Path::new("/checkout/tests/b.rs"));
371/// // When `/checkout` is a git work tree, both are `Some("/checkout")`, so a
372/// // per-repo index can be built once and shared across the batch.
373/// if let (Some(root_a), Some(root_b)) = (a, b) {
374/// assert_eq!(root_a, root_b);
375/// }
376/// ```
377#[cfg(feature = "vcs-git")]
378#[must_use]
379pub fn workdir_root(path: &Path) -> Option<PathBuf> {
380 git::workdir_root(path)
381}
382
383#[cfg(test)]
384#[path = "mod_tests.rs"]
385mod tests;