big_code_analysis/wire/vcs.rs
1//! VCS (change-history) wire structs and their projections — the
2//! `vcs-git`-gated arm of the wire shape (see the parent [`super`]
3//! module doc for the single-source-of-truth rationale).
4
5use super::*;
6
7/// Wire form of [`crate::vcs::Stats`] — per-file change-history metrics.
8///
9/// Always-slim by design (issue #635): the row carries only the metrics
10/// that vary per file. The four constant stamps that hold across every
11/// row of a single response — `vcs_schema_version`, `risk_score_version`,
12/// `long_window_days`, `recent_window_days` — live exactly once in the
13/// enclosing envelope (`bca vcs`'s `Report`, `POST /vcs`'s response, and
14/// the `/vcs/trend` document), never repeated per row or per trend point.
15///
16/// The remaining field names are the nested `vcs` object's output keys
17/// verbatim (issue #684). All scores are ordinal. `hotspot_score` and
18/// `author_ids` are elided when absent (no AST metrics alongside, and
19/// `--emit-author-details` off, respectively). Gated behind the
20/// `vcs-git` backend feature.
21#[cfg(feature = "vcs-git")]
22#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
23pub struct Vcs {
24 /// Distinct commits in the long window.
25 pub commits_long: u32,
26 /// Distinct commits in the recent window.
27 pub commits_recent: u32,
28 /// Σ(added + deleted) lines in the long window.
29 pub churn_long: u64,
30 /// Σ(added + deleted) lines in the recent window.
31 pub churn_recent: u64,
32 /// Distinct authors in the long window.
33 pub authors_long: u32,
34 /// Distinct authors in the recent window.
35 pub authors_recent: u32,
36 /// Top-author edit share in `[0, 1]`.
37 #[serde(default = "nan_default", with = "non_finite")]
38 pub ownership_top_share: f64,
39 /// `commits_recent / commits_long`, clamped to `[0, 1]`.
40 #[serde(default = "nan_default", with = "non_finite")]
41 pub burst: f64,
42 /// Long-window bug-fix commit count.
43 pub bug_fix_commits: u32,
44 /// Long-window security-fix commit count.
45 pub security_fix_commits: u32,
46 /// Long-window revert commit count.
47 pub revert_commits: u32,
48 /// Days since the file's first in-window commit (capped at window).
49 pub age_days: u32,
50 /// Days since the file's most recent in-window commit.
51 pub last_modified_days: u32,
52 /// Change entropy (bits) over the long window — Hassan 2009 History
53 /// Complexity Metric; higher means more scattered changes.
54 #[serde(default = "nan_default", with = "non_finite")]
55 pub change_entropy_long: f64,
56 /// Change entropy (bits) over the recent window.
57 #[serde(default = "nan_default", with = "non_finite")]
58 pub change_entropy_recent: f64,
59 /// Co-change graph entropy (bits) over the long window — arXiv
60 /// 2504.18511; higher means changes ripple across more partners.
61 /// `0.0` is computed (no co-changes), not missing.
62 #[serde(default = "nan_default", with = "non_finite")]
63 pub cochange_entropy_long: f64,
64 /// Co-change graph entropy (bits) over the recent window.
65 #[serde(default = "nan_default", with = "non_finite")]
66 pub cochange_entropy_recent: f64,
67 /// Ordinal composite risk score.
68 #[serde(default = "nan_default", with = "non_finite")]
69 pub risk_score: f64,
70 /// Complexity × recent-churn hotspot score, when AST metrics were
71 /// computed alongside the history.
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub hotspot_score: Option<f64>,
74 /// SHA-256-hashed canonical author identities, under
75 /// `--emit-author-details`.
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub author_ids: Option<Vec<String>>,
78}
79
80#[cfg(feature = "vcs-git")]
81impl From<&crate::vcs::Stats> for Vcs {
82 fn from(s: &crate::vcs::Stats) -> Self {
83 Self {
84 commits_long: s.commits_long,
85 commits_recent: s.commits_recent,
86 churn_long: s.churn_long,
87 churn_recent: s.churn_recent,
88 authors_long: s.authors_long,
89 authors_recent: s.authors_recent,
90 ownership_top_share: s.ownership_top_share,
91 burst: s.burst,
92 bug_fix_commits: s.bug_fix_commits,
93 security_fix_commits: s.security_fix_commits,
94 revert_commits: s.revert_commits,
95 age_days: s.age_days,
96 last_modified_days: s.last_modified_days,
97 change_entropy_long: s.change_entropy_long,
98 change_entropy_recent: s.change_entropy_recent,
99 cochange_entropy_long: s.cochange_entropy_long,
100 cochange_entropy_recent: s.cochange_entropy_recent,
101 risk_score: s.risk_score,
102 hotspot_score: s.hotspot_score,
103 author_ids: s.author_ids.clone(),
104 }
105 }
106}
107
108/// Wire form of one ranked file in a [`VcsReport`]: its repository-
109/// relative path plus the always-slim [`Vcs`] block, nested under a
110/// `vcs` key like every other metric group (issue #684).
111// Serialize-only (no `Deserialize`): `VcsAggregate` and its bus-factor
112// family are serialize-only upstream, so `VcsReport` cannot round-trip
113// through `Deserialize` the way [`Vcs`] / [`VcsTrend`] do. The only
114// consumer (`bca vcs` / `POST /vcs` / Python `vcs.rank()`) serializes
115// outward; nothing reads a report back in.
116#[cfg(feature = "vcs-git")]
117#[derive(Debug, Clone, PartialEq, Serialize)]
118pub struct VcsReportFile {
119 /// Repository-relative path.
120 pub path: String,
121 /// The file's change-history metrics.
122 pub vcs: Vcs,
123}
124
125/// Wire form of the file-ranking change-history report (issue #328) —
126/// the single serialized shape shared by `bca vcs`, `POST /vcs`, and the
127/// Python `vcs.rank()` (#664).
128///
129/// The four constant stamps (`long_window_days`, `recent_window_days`,
130/// `risk_score_version`, `vcs_schema_version`) sit once at the top level
131/// rather than per row (issue #635); each `files` row carries only the
132/// per-file metrics under a nested `vcs` key (issue #684). `vcs_aggregate`
133/// is the directory-/repo-level bus-factor summary, omitted when not
134/// computed.
135#[cfg(feature = "vcs-git")]
136#[derive(Debug, Clone, PartialEq, Serialize)]
137pub struct VcsReport {
138 /// Long window length, in days (constant across rows).
139 pub long_window_days: u32,
140 /// Recent window length, in days (constant across rows).
141 pub recent_window_days: u32,
142 /// Composite-formula version.
143 pub risk_score_version: u32,
144 /// Per-row metric-block shape version.
145 pub vcs_schema_version: u32,
146 /// Whether the history came from a shallow clone.
147 pub truncated_shallow_clone: bool,
148 /// Directory-/repo-level bus-factor aggregate, when computed.
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub vcs_aggregate: Option<crate::vcs::VcsAggregate>,
151 /// Files ranked by descending `vcs.risk_score`.
152 pub files: Vec<VcsReportFile>,
153}
154
155/// Wire form of one sampled point in a historical metric trend (issue
156/// #333): the sample timestamp plus the file's VCS block at that moment.
157/// `as_of` leads; the metrics sit under a nested `vcs` key (issue #684),
158/// the same always-slim [`Vcs`] row every other endpoint emits. The four
159/// constant stamps are carried once on the enclosing [`VcsTrend`], never
160/// repeated per point (issue #635).
161#[cfg(feature = "vcs-git")]
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163pub struct VcsTrendPoint {
164 /// Unix-second timestamp this point was sampled at.
165 pub as_of: i64,
166 /// The file's change-history metrics at `as_of`.
167 pub vcs: Vcs,
168}
169
170/// Wire form of one file's risk-score movement across the trend.
171#[cfg(feature = "vcs-git")]
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct VcsTrendDelta {
174 /// Repository-relative path.
175 pub path: String,
176 /// Timestamp of the file's earliest present point.
177 pub first_as_of: i64,
178 /// Timestamp of the file's latest present point.
179 pub last_as_of: i64,
180 /// `risk_score` at the earliest present point.
181 pub first_risk_score: f64,
182 /// `risk_score` at the latest present point.
183 pub last_risk_score: f64,
184 /// `last_risk_score - first_risk_score`; negative means improved.
185 pub delta: f64,
186}
187
188#[cfg(feature = "vcs-git")]
189impl VcsTrendDelta {
190 /// Project a compute-side [`crate::vcs::TrendDelta`], dropping a
191 /// non-UTF-8 path (which cannot be a JSON key) by returning `None` —
192 /// the same path policy the file map uses.
193 fn from_delta(d: &crate::vcs::TrendDelta) -> Option<Self> {
194 Some(Self {
195 path: d.path.to_str()?.to_owned(),
196 first_as_of: d.first_as_of,
197 last_as_of: d.last_as_of,
198 first_risk_score: d.first_risk_score,
199 last_risk_score: d.last_risk_score,
200 delta: d.delta,
201 })
202 }
203}
204
205/// Wire form of the improving / regressing delta summary.
206#[cfg(feature = "vcs-git")]
207#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
208pub struct VcsTrendDeltas {
209 /// Files whose risk fell, most-improved first.
210 pub improved: Vec<VcsTrendDelta>,
211 /// Files whose risk rose, most-regressed first.
212 pub regressed: Vec<VcsTrendDelta>,
213}
214
215/// Wire form of a historical metric trend (issue #333) — the single
216/// serialized shape shared by `bca vcs trend`, `POST /vcs/trend`, and the
217/// Python `vcs_trend()`.
218///
219/// `as_of_points` lists the sample timestamps oldest-first; every file's
220/// array in `files` aligns to it 1:1, with a `null` element where the file
221/// did not exist at that point. `files` is keyed by repository-relative
222/// path and ordered lexicographically for deterministic output.
223#[cfg(feature = "vcs-git")]
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225pub struct VcsTrend {
226 /// Trend-document shape version
227 /// ([`crate::vcs::TREND_SCHEMA_VERSION`]).
228 pub trend_schema_version: u32,
229 /// Per-point metric-block shape version.
230 pub vcs_schema_version: u32,
231 /// Composite-formula version.
232 pub risk_score_version: u32,
233 /// Long window length, in days (constant across points).
234 pub long_window_days: u32,
235 /// Recent window length, in days (constant across points).
236 pub recent_window_days: u32,
237 /// Whether any sampled snapshot came from a shallow clone.
238 pub truncated_shallow_clone: bool,
239 /// Sample timestamps, oldest-first.
240 pub as_of_points: Vec<i64>,
241 /// Per-file time series; `null` marks a point where the file was
242 /// absent.
243 pub files: std::collections::BTreeMap<String, Vec<Option<VcsTrendPoint>>>,
244 /// The most-improved / most-regressed files by risk delta.
245 pub deltas: VcsTrendDeltas,
246}
247
248#[cfg(feature = "vcs-git")]
249impl VcsTrend {
250 /// Project a compute-side [`crate::vcs::Trend`] into the wire shape,
251 /// keeping the `top_files` highest-risk files (by their most-recent
252 /// present `risk_score`; `0` keeps all) and the `top_deltas`
253 /// strongest movers in each delta list.
254 #[must_use]
255 pub fn from_trend(trend: &crate::vcs::Trend, top_files: usize, top_deltas: usize) -> Self {
256 let as_of_points = trend.as_of_points().to_vec();
257
258 // Rank files by most-recent present risk so `top_files` keeps the
259 // currently-riskiest. Reuse the shared `rank_by_risk` so the
260 // descending-risk + path tie-break and the `top` truncation match
261 // `bca vcs` / `POST /vcs` exactly (a non-UTF-8 path sorts as "" and
262 // is dropped below).
263 let mut ranked: Vec<(&std::path::PathBuf, &[Option<crate::vcs::Stats>], f64)> = trend
264 .iter()
265 .map(|(path, points)| (path, points, latest_present_risk(points)))
266 .collect();
267 crate::vcs::rank_by_risk(&mut ranked, top_files, |entry| {
268 (entry.0.to_str().unwrap_or(""), entry.2)
269 });
270
271 let files = ranked
272 .into_iter()
273 .filter_map(|(path, points, _)| {
274 // A non-UTF-8 path cannot be a JSON object key; drop it,
275 // matching the per-file endpoints' policy.
276 let key = path.to_str()?.to_owned();
277 let series = points
278 .iter()
279 .zip(&as_of_points)
280 .map(|(stats, &as_of)| {
281 stats.as_ref().map(|s| VcsTrendPoint {
282 as_of,
283 vcs: Vcs::from(s),
284 })
285 })
286 .collect();
287 Some((key, series))
288 })
289 .collect();
290
291 let compute_deltas = trend.deltas(top_deltas);
292 let deltas = VcsTrendDeltas {
293 improved: compute_deltas
294 .improved
295 .iter()
296 .filter_map(VcsTrendDelta::from_delta)
297 .collect(),
298 regressed: compute_deltas
299 .regressed
300 .iter()
301 .filter_map(VcsTrendDelta::from_delta)
302 .collect(),
303 };
304
305 Self {
306 trend_schema_version: crate::vcs::TREND_SCHEMA_VERSION,
307 vcs_schema_version: crate::vcs::stats::VCS_SCHEMA_VERSION,
308 risk_score_version: crate::vcs::score::RISK_SCORE_VERSION,
309 long_window_days: trend.long_window_days(),
310 recent_window_days: trend.recent_window_days(),
311 truncated_shallow_clone: trend.truncated_shallow_clone(),
312 as_of_points,
313 files,
314 deltas,
315 }
316 }
317}