codelore-lib 0.29.0

CodeLore — Behavioral Code Analyzer library
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! `architecture-trend` analysis — structural decay over the commit
//! sequence.
//!
//! The HEAD-only architecture metrics (`architecture-metrics`,
//! `dependency-cycles`) answer "how tangled is the code *now*". This
//! answers the more useful question — "is it getting *worse*, and when
//! did it start?" — by recomputing the structural-health metrics at a
//! series of historical revisions and emitting one row per sample point.
//!
//! ## How it samples history
//!
//! Up to [`SAMPLE_POINTS`] commits are picked evenly across the full
//! date-ordered history (the newest is always included). At each sampled
//! rev the import graph is rebuilt **from scratch in memory** — no
//! persistent table is touched:
//!
//! 1. files live at that rev come from the `changes`/`commits` history
//!    (latest change at-or-before the rev's date that isn't a deletion —
//!    the same date-anchored liveness `code-age` uses),
//! 2. each live source blob is read at that rev (`Repo::read_blob_at`),
//!    parsed for imports, and resolved in memory with the same
//!    per-language resolvers the HEAD scan uses,
//! 3. the resolved edges feed the shared SCC + reachability kernel.
//!
//! ## Cost
//!
//! This is the one analysis that re-parses source at many revisions, so
//! it is markedly heavier than the SQL-only analyses (roughly
//! `SAMPLE_POINTS ×` a HEAD import scan). It is computed on demand and
//! never cached.

use std::collections::HashSet;

use crate::analyses::import_graph::{build_import_graph_seeded, graph_metrics};
use crate::complexity::Tier1Language;
use crate::facts::FactsDb;
use crate::facts::ingest::coverage::{
    REASON_BLOB_READ, REASON_PARSE_ERROR, ScanCoverage, ScanOutcome,
};
use crate::repo::Repo;
use crate::{Options, Result};

/// Maximum number of historical sample points on the trend.
pub const SAMPLE_POINTS: usize = 12;

/// One sampled point on the architecture-decay trend.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ArchitectureTrendRow {
    /// Sample date (`YYYY-MM-DD`).
    pub date: String,
    /// The sampled commit's short SHA (first 12 chars).
    pub rev: String,
    /// Live Tier-1 source files in the import graph at this rev (isolated
    /// files included, not only resolved-import participants).
    pub files: u32,
    /// Propagation cost — density of the transitive-closure matrix
    /// (`sum(vfo)/n²`); "a change to a random file reaches this fraction
    /// of the system". The headline decay signal.
    pub propagation_cost: f64,
    /// Number of non-trivial dependency cycles (SCCs of size ≥ 2).
    pub cycle_count: u32,
    /// Size of the largest dependency cycle (0 if acyclic).
    pub largest_cycle: u32,
}

/// The ≤`SAMPLE_POINTS` evenly-spaced `(rev, timestamp)` commit samples,
/// oldest→newest (newest always included). Shared by `architecture-trend` and
/// `health-trend` so the rev set is identical between the two views.
///
/// # Errors
///
/// Returns [`crate::CodeLoreError::Analysis`] on `DuckDB` query errors.
pub(crate) fn sampled_commits(db: &FactsDb) -> Result<Vec<(String, String)>> {
    let commits: Vec<(String, String)> = crate::analyses::query::query_map_collect(
        db,
        "SELECT rev, CAST(date AS TEXT) FROM commits ORDER BY date ASC, rowid DESC",
        [],
        "sampled-commits",
        |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
    )?;
    if commits.is_empty() {
        return Ok(Vec::new());
    }
    let picks = evenly_spaced_indices(commits.len(), SAMPLE_POINTS);
    Ok(picks.into_iter().map(|i| commits[i].clone()).collect())
}

/// Run the `architecture-trend` analysis. Returns one row per sampled
/// rev, oldest first. Needs repository access (it reads historical
/// blobs), unlike the SQL-only analyses.
///
/// # Errors
///
/// Returns [`crate::CodeLoreError::Analysis`] on `DuckDB` query errors
/// and [`crate::CodeLoreError::Repo`] on object-database I/O failures.
#[tracing::instrument(name = "architecture-trend", skip_all)]
pub fn run_architecture_trend<R: Repo>(
    db: &FactsDb,
    repo: &R,
    // Unused, but kept for signature parity with the other analyses.
    _opts: &Options,
) -> Result<Vec<ArchitectureTrendRow>> {
    let samples = sampled_commits(db)?;
    let mut rows = Vec::with_capacity(samples.len());
    for (rev, ts) in &samples {
        let graph = import_graph_at_rev(db, repo, rev, ts)?;
        // Shared kernel — so `architecture-trend` and the HEAD
        // `architecture-metrics` report the same numbers by construction.
        let m = graph_metrics(&graph);
        rows.push(ArchitectureTrendRow {
            // Calendar date = the `YYYY-MM-DD` prefix of the timestamp text.
            date: ts.get(..10).unwrap_or(ts).to_string(),
            rev: rev.chars().take(12).collect(),
            files: u32::try_from(m.n).unwrap_or(u32::MAX),
            propagation_cost: m.propagation_cost,
            cycle_count: m.cycle_count,
            largest_cycle: m.largest_cycle,
        });
    }
    Ok(rows)
}

/// Reconstruct the resolved import graph as it existed at `rev` (whose
/// commit timestamp is `ts`), entirely in memory — no `imports`-table
/// writes. The shared historical-scan primitive: `architecture-trend`
/// calls it once per sample point; `cycle-origins` calls it repeatedly
/// while bisecting history for a cycle's formation commit.
///
/// # Errors
///
/// Returns [`crate::CodeLoreError`] on the `DuckDB` live-paths query.
/// Per-file blob/parse failures are swallowed (the file is skipped),
/// matching the HEAD scan's tolerance.
pub(crate) fn import_graph_at_rev<R: Repo>(
    db: &FactsDb,
    repo: &R,
    rev: &str,
    ts: &str,
) -> Result<crate::analyses::import_graph::ImportGraph> {
    let live = live_paths_at(db, ts)?;
    Ok(import_graph_from_live_paths(repo, rev, &live))
}

/// Build the resolved import graph at `rev` from an already-computed
/// live-path set. Split out of [`import_graph_at_rev`] so a caller that also
/// needs `live_paths_at`'s result for other work (e.g. a per-rev complexity
/// scan) can compute the live set once and feed it to both.
pub(crate) fn import_graph_from_live_paths<R: Repo>(
    repo: &R,
    rev: &str,
    live: &[String],
) -> crate::analyses::import_graph::ImportGraph {
    // Seed from the Tier-1 source files live at this rev so isolated files
    // are counted, keeping the shared kernel's `n` in step with the HEAD
    // `architecture-metrics` node universe (`complexity_metrics`) — the
    // newest trend sample must equal the HEAD tile by construction.
    // Documented acceptable divergence: the historical seed is the
    // extension-filtered live-path set, which can't cheaply reproduce
    // HEAD's oversized-blob exclusion at a past rev, so an over-cap file
    // that the HEAD scan would drop can appear here as a singleton.
    let seeds: Vec<String> = live
        .iter()
        .filter(|p| Tier1Language::from_path(p.as_str()).is_some())
        .cloned()
        .collect();
    let edges = resolve_imports_at_rev(repo, rev, live);
    build_import_graph_seeded(&seeds, &edges)
}

/// Pick up to `k` evenly-spaced indices over `0..len`, always including
/// the last (newest commit). Returns `0..len` when `len <= k`.
pub(crate) fn evenly_spaced_indices(len: usize, k: usize) -> Vec<usize> {
    if len == 0 {
        return Vec::new();
    }
    if len <= k {
        return (0..len).collect();
    }
    let mut out = Vec::with_capacity(k);
    for i in 0..k {
        // Map i ∈ [0, k-1] → index ∈ [0, len-1], hitting both ends.
        out.push(i * (len - 1) / (k - 1));
    }
    out.dedup();
    out
}

/// Files live at the rev whose timestamp is `ts`: the latest change
/// at-or-before that instant that isn't a deletion, excluding paths a
/// rename retired at-or-before `ts`. Date-anchored liveness (mirrors
/// `code-age`), so it approximates tree membership on mostly-linear
/// histories without needing a tree walk at the rev.
///
/// The rename exclusion is era-bounded, NOT lineage-folding: the returned
/// names feed `Repo::blob_reader_at(rev)`, so they must be the names that
/// exist in that era's tree — folding to today's canonical names would
/// make every pre-rename blob read miss. A rename writes no deletion row
/// for its source, so without the exclusion a renamed-away path read as
/// live forever and the historical import graph carried the same file
/// under two names. A recycled name stays live: its own newer rows
/// postdate the rename that retired the earlier file.
pub(crate) fn live_paths_at(db: &FactsDb, ts: &str) -> Result<Vec<String>> {
    crate::analyses::query::query_map_collect(
        db,
        "SELECT path FROM ( \
            SELECT c.path, \
                   arg_max(c.change_type, ROW(commits.date, -commits.rowid)) AS change_type, \
                   MAX(ROW(commits.date, -commits.rowid)) AS last_seen \
            FROM changes c \
            INNER JOIN commits ON commits.rev = c.rev \
            WHERE commits.date <= CAST(? AS TIMESTAMP) \
            GROUP BY c.path \
         ) l \
         WHERE l.change_type != 'deleted' \
           AND NOT EXISTS ( \
             SELECT 1 FROM changes r \
             INNER JOIN commits rc ON rc.rev = r.rev \
             WHERE r.rename_from = l.path \
               AND r.change_type = 'renamed' \
               AND rc.date <= CAST(? AS TIMESTAMP) \
               AND ROW(rc.date, -rc.rowid) > l.last_seen \
           )",
        duckdb::params![ts, ts],
        "architecture-trend live-paths",
        |r| r.get::<_, String>(0),
    )
}

/// Classify one file for the at-rev import scan.
///
/// Named rather than inlined for the same reason `analyses::clones::scan_one`
/// is: the difference between a counted loss and a silent drop is a warning
/// and the coverage denominator, and a caller of `resolve_imports_at_rev`
/// sees neither — an end-to-end test passes whether or not the accounting
/// exists. Taking the read RESULT rather than a reader makes every branch
/// reachable without a `Repo` test double.
///
/// Each branch here used to be a bare `return out`, with no log at any
/// level. The consequence is one-directional and lands on the trend chart:
/// the graph's node count is seeded from the live-path list rather than from
/// successful reads, so a rev whose blobs fail keeps its denominator while
/// losing its edges — propagation cost falls and `arch_health` rises. A rev
/// that scanned nothing rendered as full coverage at perfect health.
fn classify_import_file(
    rel: &str,
    read: crate::Result<Option<Vec<u8>>>,
    lang: crate::imports::ImportLanguage,
    live_set: &HashSet<String>,
) -> ScanOutcome<Vec<(String, String)>> {
    use crate::imports::{extract_imports, resolve_by_extension};

    let code = match read {
        Ok(Some(code)) => code,
        // Absent at this rev: `live_paths_at` is derived from history, so a
        // path the rev does not carry is expected rather than a loss.
        Ok(None) => return ScanOutcome::NotCounted,
        Err(e) => {
            tracing::warn!("architecture-trend: blob read failed for {rel}: {e}");
            return ScanOutcome::Lost(REASON_BLOB_READ);
        }
    };
    if code.len() > crate::constants::DEFAULT_MAX_AST_FILE_BYTES {
        return ScanOutcome::SkippedOversize;
    }
    let Ok(imports) = extract_imports(&code, lang) else {
        tracing::warn!("architecture-trend: import parse failed for {rel}");
        return ScanOutcome::Lost(REASON_PARSE_ERROR);
    };
    let mut out: Vec<(String, String)> = Vec::new();
    for imp in imports {
        if let Some(target_path) = resolve_by_extension(rel, &imp.target, live_set) {
            out.push((rel.to_string(), target_path));
        }
    }
    // A file with no resolvable imports is still fully covered — read and
    // parsed, it simply contributes no edges.
    ScanOutcome::Scored(out)
}

/// Extract + resolve every import edge among `live_paths` at `rev`,
/// entirely in memory. Mirrors the HEAD scan's extract pass
/// (`populate_imports_at_head`) and resolver dispatch
/// (`resolve_imports_at_head`), but reads blobs at an arbitrary rev and
/// never touches the `imports` table.
fn resolve_imports_at_rev<R: Repo>(
    repo: &R,
    rev: &str,
    live_paths: &[String],
) -> Vec<(String, String)> {
    use crate::imports::ImportLanguage;
    use rayon::prelude::*;

    let live_set: HashSet<String> = live_paths.iter().cloned().collect();
    let candidates: Vec<(String, ImportLanguage)> = live_paths
        .iter()
        .filter_map(|rel| {
            let lang = ImportLanguage::from_path(std::path::Path::new(rel))?;
            Some((rel.clone(), lang))
        })
        .collect();

    // Parallel blob-read + extract + per-language resolve. One `BlobReader`
    // per rayon worker (`map_init`) resolves `rev`'s root tree once and
    // reuses a warm object-decode cache for every file that worker reads —
    // `resolve_imports_at_rev` runs once per `architecture-trend` sample
    // point (and repeatedly during `cycle-origins`' bisection), so this is
    // the "worse offender" the per-call `read_blob_at` path used to re-pay
    // in full every time. A read/parse failure on one file skips that file
    // (a corrupt blob mustn't sink the whole sample point), matching the
    // HEAD scan's tolerance.
    let outcomes: Vec<ScanOutcome<Vec<(String, String)>>> = candidates
        .into_par_iter()
        .map_init(
            || repo.blob_reader_at(rev),
            |reader, (rel, lang)| classify_import_file(&rel, reader.read(&rel), lang, &live_set),
        )
        .collect();

    let coverage = ScanCoverage::tally(&outcomes);
    coverage.warn_if_degraded("architecture-trend import", "import graph");
    coverage.warn_if_mostly_oversize("architecture-trend import", "import graph");

    outcomes
        .into_iter()
        .filter_map(|o| match o {
            ScanOutcome::Scored(edges) => Some(edges),
            _ => None,
        })
        .flatten()
        .collect()
}

#[cfg(all(test, feature = "test-support"))]
mod tests {
    use super::live_paths_at;
    use crate::facts::FactsDb;

    /// The classification is the whole of the fix, and it is the one thing a
    /// caller cannot observe: `resolve_imports_at_rev` returns the same edge
    /// list whether a failed read is counted or silently dropped, because the
    /// difference lives in a warning and the coverage denominator. An
    /// end-to-end test therefore passes with the fix reverted — that exact
    /// mistake was made and caught by probe on the clone scan — so the
    /// classifier is asserted directly.
    #[test]
    fn an_unreadable_blob_is_a_counted_loss_not_a_silent_drop() {
        use super::{ScanOutcome, classify_import_file};
        use crate::imports::ImportLanguage;
        use std::collections::HashSet;

        let live: HashSet<String> = HashSet::new();
        let err = Err(crate::CodeLoreError::Repo("simulated odb failure".into()));
        let outcome = classify_import_file("src/a.rs", err, ImportLanguage::Rust, &live);
        assert!(
            matches!(outcome, ScanOutcome::Lost(_)),
            "a failed blob read must be counted as lost — the trend chart seeds \
             its node count from live paths, so an uncounted failure keeps the \
             denominator, drops edges, and reads as improving health"
        );

        // Absent at this rev is NOT a loss: `live_paths_at` is derived from
        // history, so a path the rev does not carry is expected. Counting it
        // would mark healthy repositories degraded.
        let absent = classify_import_file("src/a.rs", Ok(None), ImportLanguage::Rust, &live);
        assert!(matches!(absent, ScanOutcome::NotCounted));

        // Read and parsed with no resolvable imports is still full coverage.
        let empty = classify_import_file(
            "src/a.rs",
            Ok(Some(b"fn main() {}\n".to_vec())),
            ImportLanguage::Rust,
            &live,
        );
        assert!(
            matches!(empty, ScanOutcome::Scored(ref e) if e.is_empty()),
            "a file with no imports was still reached and parsed — covered, not uncounted"
        );
    }

    fn seed_commit(db: &FactsDb, rev: &str, day: u32) {
        db.execute_batch(&format!(
            "INSERT INTO commits (rev, author_email, author_name, committer_email, \
             canonical_author, date, committer_date, message, is_merge, parent_count) \
             VALUES ('{rev}', 'a@x', 'A', 'a@x', 'a@x', \
             '2026-03-{day:02} 12:00:00', '2026-03-{day:02} 12:00:00', 'm', false, 1)"
        ))
        .expect("seed commit");
    }

    /// A renamed-away source must drop out of the live set once the rename
    /// happened, stay live BEFORE it (era-correct), and come back when the
    /// name is recycled by a new file — all under raw era names, because the
    /// caller reads these paths' blobs at the historical rev.
    #[test]
    fn renamed_away_paths_are_dead_after_the_rename_and_live_before_it() {
        let db = FactsDb::new_in_memory().expect("db");
        // Newest first: c3 recycles a.rs; c2 renames a.rs -> b.rs; c1 adds a.rs.
        seed_commit(&db, "c3", 5);
        seed_commit(&db, "c2", 3);
        seed_commit(&db, "c1", 1);
        for stmt in [
            "INSERT INTO changes VALUES ('c1', 'a.rs', 'added', NULL, 5, 0)",
            "INSERT INTO changes VALUES ('c2', 'b.rs', 'renamed', 'a.rs', 0, 0)",
            "INSERT INTO changes VALUES ('c3', 'a.rs', 'added', NULL, 7, 0)",
        ] {
            db.execute_batch(stmt).expect("seed");
        }

        let at = |ts: &str| {
            let mut v = live_paths_at(&db, ts).expect("live_paths_at");
            v.sort();
            v
        };
        assert_eq!(
            at("2026-03-02 00:00:00"),
            vec!["a.rs"],
            "before the rename the source is live under its era name"
        );
        assert_eq!(
            at("2026-03-04 00:00:00"),
            vec!["b.rs"],
            "after the rename only the new name is live — the retired source must not linger"
        );
        assert_eq!(
            at("2026-03-06 00:00:00"),
            vec!["a.rs", "b.rs"],
            "a recycled name is a NEW live file alongside the rename target"
        );
    }
}