use std::io::Write;
use anyhow::{Context, Result};
use codelore_lib::cli_api::facts::FactsDb;
use codelore_lib::cli_api::repo::{GixRepo, Repo as _};
use codelore_lib::cli_api::{AnalysisName, CodeLoreError, Options};
use codelore_lib::cli_api::{analyses, output};
use crate::args::AnalyzeArgs;
use crate::notice_corpus_lens_absent;
#[allow(clippy::too_many_lines)] pub(crate) fn analyze(args: &AnalyzeArgs, no_banner: bool) -> Result<()> {
use codelore_lib::cli_api::output::banner;
let started_at = std::time::Instant::now();
let analysis = args.analysis;
for warning in crate::args::ignored_flag_warnings(args, analysis) {
eprintln!("{warning}");
}
let format = args.format.as_str();
if matches!(format, "parquet" | "sqlite") && args.output.is_none() {
return Err(CodeLoreError::Output(format!(
"--format {format} requires --output PATH (binary format, cannot stream to stdout)"
))
.into());
}
if format == "sarif"
&& !matches!(
analysis,
AnalysisName::Hotspots | AnalysisName::Clones | AnalysisName::CloneCoupling
)
{
return Err(CodeLoreError::Analysis(
"--format sarif currently supports --analysis hotspots, clones, and clone-coupling"
.to_string(),
)
.into());
}
let complexity_sample = codelore_lib::cli_api::options::ComplexitySample::Head;
let defect_calibration = codelore_lib::cli_api::quality_gates::resolve_defect_calibration(
args.defect_calibration.clone(),
&args.repo,
)
.context("resolve defect calibration")?;
let opts = Options {
repo_path: args.repo.clone(),
min_revs: args.min_revs,
rows_limit: args.rows,
complexity_sample,
group_file: args.group_file.clone(),
team_map_file: args.team_map_file.clone(),
explain: args.explain,
use_canonical_lineage: !args.no_canonical_lineage && !args.code_maat_compat,
exclude_patterns: args.exclude.clone(),
include_ignored: args.include_ignored,
min_shared_revs: args.min_shared_revs,
min_coupling_pct: args.min_coupling_pct,
max_coupling_pct: args.max_coupling_pct,
max_changeset_size: args.max_changeset_size,
age_time_now: args.age_time_now,
after: args.after,
before: args.before,
include_merges: args.include_merges,
message_regex: args.message_regex.clone(),
min_soc: args.min_soc,
code_maat_compat: args.code_maat_compat,
fdr_correction: args.fdr_correction,
strict_grouping: args.strict_grouping || args.code_maat_compat,
time_bucket: args.time_bucket.map(Into::into),
departed_threshold_days: args.departed_threshold_days,
window_days: args.window_days,
knowledge_model: args.knowledge_model.clone(),
rework_window_days: args.rework_window_days,
release_tag_glob: args.release_tag_glob.clone(),
target: args.target.clone(),
calibration: args.calibration.clone(),
defect_calibration,
allow_foreign_calibration: args.allow_foreign_calibration,
temp_dir: args.temp_dir.clone(),
..Options::default()
};
opts.validate().context("validate options")?;
if opts.time_bucket.is_some() && !analysis.supports_time_bucket() {
return Err(CodeLoreError::Analysis(format!(
"--time-bucket is not supported for analysis {:?}. \
Bucketing only applies to co-change analyses; supported: \
coupling, soc, hotspots, code-health. Remove --time-bucket \
or switch to one of those analyses.",
analysis.as_str()
))
.into());
}
if opts.group_file.is_some() && matches!(analysis, AnalysisName::FunctionHotspots) {
return Err(CodeLoreError::InvalidOptions(
"--group-file is not supported for analysis function-hotspots \
(grouping discards the hunks of collapsed paths, which \
function-hotspots ranks over — the result would be silently \
incomplete). Run function-hotspots without --group-file."
.to_string(),
)
.into());
}
let analysis_name = args.analysis.as_str();
if matches!(analysis, AnalysisName::Clones)
&& matches!(format, "csv" | "json" | "markdown" | "sarif")
{
let rows =
codelore_lib::cli_api::analyses::clones::run_clones(&opts).context("run clones")?;
emit_to_output_or_stdout(args.output.as_deref(), |out| {
match format {
"csv" => {
codelore_lib::cli_api::output::csv::write_clones_csv(&rows, out)
.context("write csv")?;
}
"json" => {
codelore_lib::cli_api::output::json::write_json(&rows, out)
.context("write json")?;
}
"markdown" => {
codelore_lib::cli_api::output::markdown::write_clones_markdown(&rows, out)
.context("write markdown")?;
}
"sarif" => {
codelore_lib::cli_api::output::sarif::write_clones_sarif(
&rows,
&sarif_repo_root(&args.repo),
out,
)
.context("write sarif")?;
}
_ => unreachable!("format validated by outer matches!()"),
}
Ok(())
})?;
return Ok(());
}
let repo = {
let _span = tracing::info_span!(target: "codelore::bench", "bench.open_repo").entered();
preflight_and_open_repo(args, &opts, analysis_name, no_banner)?
};
let needs_writable_db = format == "sqlite";
let db = {
let _span =
tracing::info_span!(target: "codelore::bench", "bench.cache_or_ingest").entered();
if args.no_cache || needs_writable_db {
let db = FactsDb::new_in_memory_with_temp_dir(opts.temp_dir.as_deref())
.context("open fact store (in-memory)")?;
db.ingest(&repo, &opts).context("ingest commits")?;
db
} else if let Some(cache_dir) = &args.cache_dir {
FactsDb::open_or_ingest_with_cache_root(&opts, &repo, cache_dir)
.context("open or ingest (cache-dir)")?
} else {
FactsDb::open_or_ingest(&opts, &repo).context("open or ingest")?
}
};
let head_sha = repo.head_sha().context("get HEAD sha")?;
if opts.after.is_some() || opts.before.is_some() {
if !head_sha.is_empty() && db.commit_count().context("count ingested commits")? == 0 {
if repo.is_shallow() {
db.ensure_ingest_witnessed(&head_sha)?;
} else {
tracing::warn!(
"no commits were ingested — the --after/--before window excludes all \
history on this full clone. The analysis below is over an empty history."
);
}
}
} else {
db.ensure_ingest_witnessed(&head_sha)?;
}
if format == "parquet" {
let path = args.output.as_ref().expect("validated above");
write_parquet(&db, &opts, analysis, path)?;
write_provenance_sidecar(&db, &opts, analysis_name, path)?;
return Ok(());
}
if format == "sqlite" {
let path = args.output.as_ref().expect("validated above");
codelore_lib::cli_api::output::sqlite::write_full_fact_store_sqlite(&db, &opts, path)
.context("write sqlite")?;
return Ok(());
}
if format == "spa" {
#[cfg(feature = "spa")]
{
let default_path = std::path::PathBuf::from(".codelore/spa.html");
let path: &std::path::Path = if let Some(p) = args.output.as_deref() {
p
} else {
if let Some(parent) = default_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
}
&default_path
};
run_spa_dispatch(&db, &opts, &args.repo, path)?;
return Ok(());
}
#[cfg(not(feature = "spa"))]
{
anyhow::bail!(
"--format spa requires CodeLore to be built with the `spa` Cargo feature. \
Reinstall with `cargo install codelore --features spa`, build from source \
with `cargo build --features spa`, or use a prebuilt binary from \
https://github.com/emrecdr/codelore/releases (which ship with `spa` enabled)."
);
}
}
if format == "step-summary" {
#[cfg(feature = "spa")]
{
run_step_summary_dispatch(&db, &opts, &args.repo, args.output.as_deref())?;
return Ok(());
}
#[cfg(not(feature = "spa"))]
{
anyhow::bail!(
"--format step-summary requires CodeLore to be built with the `spa` Cargo feature \
(the step-summary writer consumes the same SpaDashboard as the SPA HTML emitter). \
Reinstall with `cargo install codelore --features spa`, build from source \
with `cargo build --features spa`, or use a prebuilt binary from \
https://github.com/emrecdr/codelore/releases (which ship with `spa` enabled)."
);
}
}
let mut row_count = 0usize;
{
let _span =
tracing::info_span!(target: "codelore::bench", "bench.analyze_and_emit").entered();
let now = time::OffsetDateTime::now_utc();
let generated_at = format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second(),
);
let ctx = EmitCtx {
repo_root: sarif_repo_root(&args.repo),
title: format!("CodeLore: {}", analysis.as_str()),
generated_at,
analysis,
};
emit_to_output_or_stdout(args.output.as_deref(), |out| {
row_count = run_streaming_dispatch(
&db,
&repo,
&opts,
args.cache_dir.as_deref(),
format,
&ctx,
out,
)?;
Ok(())
})?;
}
if let Some(path) = args.output.as_ref() {
write_provenance_sidecar(&db, &opts, analysis_name, path)?;
}
if row_count == 0 {
tracing::warn!(
"{analysis_name}: 0 rows — the analysis ran and matched nothing. \
Options in effect: {}.",
format_options_summary(&opts)
);
}
if banner::should_print(false, no_banner) {
let footer = banner::Footer {
analysis: analysis_name,
elapsed: started_at.elapsed(),
rows: Some(row_count),
};
eprint!("{}", footer.render(banner::should_color()));
}
Ok(())
}
struct EmitCtx {
repo_root: String,
title: String,
generated_at: String,
analysis: AnalysisName,
}
fn sarif_repo_root(repo: &std::path::Path) -> String {
repo.canonicalize()
.unwrap_or_else(|_| repo.to_path_buf())
.to_string_lossy()
.into_owned()
}
fn emit_to_output_or_stdout<F>(dest: Option<&std::path::Path>, emit: F) -> Result<()>
where
F: FnOnce(&mut Box<dyn Write>) -> Result<()>,
{
if let Some(path) = dest {
codelore_lib::cli_api::output::atomic_publish(path, |tmp| {
let mut out: Box<dyn Write> = Box::new(std::fs::File::create(tmp)?);
emit(&mut out)
})
} else {
let mut out: Box<dyn Write> = Box::new(std::io::stdout().lock());
emit(&mut out)
}
}
fn supported_formats(name: AnalysisName) -> &'static [&'static str] {
use AnalysisName::{
AbsChurn, ArchViolations, ArchitectureMetrics, ArchitectureRoles, ArchitectureTrend,
AuthorChurn, Authors, BusFactor, Centrality, CloneCoupling, Clones, CodeAge,
CodeFamiliarity, CodeHealth, Communication, Communities, CoordinationNeeds, Coupling,
Crossing, CycleHealth, CycleOrigins, DefectValidation, DeliveryFriction, DeliveryMetrics,
DependencyCycles, EffortExposure, EntityChurn, EntityEffort, EntityOwnership,
FindingHotspotOverlap, FunctionCoupling, FunctionHotspots, FunctionXray, GodClasses,
HealthTrend, HotspotVelocity, Hotspots, Instability, KnowledgeIslands, LeadTime, MainDev,
MainDevByDeletions, MainDevByRevs, MarginalOwnerRisk, Messages, ModularityViolations,
Ownership, PairProgramming, RefactoringTargets, ReleaseCadence, Revisions, Soc, StaleCode,
Summary, TeamComposition, TopCommitters, UnstableInterface,
};
const STREAM: &[&str] = &["csv", "json", "markdown"];
const STREAM_HTML: &[&str] = &["csv", "json", "markdown", "html"];
match name {
Hotspots => &["csv", "json", "markdown", "sarif", "ndjson", "gha", "html"],
CodeHealth | RefactoringTargets => &["csv", "json", "markdown", "ndjson", "html"],
CloneCoupling => &["csv", "json", "markdown", "sarif", "html"],
Coupling => &["csv", "json", "markdown", "ndjson"],
Clones => &["csv", "json", "markdown", "sarif"],
LeadTime => &["csv", "json", "ndjson", "markdown"],
KnowledgeIslands | Summary | Revisions | Authors | TopCommitters => STREAM_HTML,
HotspotVelocity
| Ownership
| CodeAge
| AbsChurn
| AuthorChurn
| EntityChurn
| Communication
| Soc
| Messages
| MainDev
| MainDevByRevs
| MainDevByDeletions
| EntityEffort
| EntityOwnership
| Centrality
| Communities
| GodClasses
| ArchViolations
| DependencyCycles
| ArchitectureRoles
| Instability
| ArchitectureMetrics
| ArchitectureTrend
| HealthTrend
| CycleOrigins
| ModularityViolations
| UnstableInterface
| Crossing
| StaleCode
| PairProgramming
| BusFactor
| DeliveryFriction
| DeliveryMetrics
| EffortExposure
| CodeFamiliarity
| TeamComposition
| CoordinationNeeds
| MarginalOwnerRisk
| ReleaseCadence
| FunctionXray
| FunctionHotspots
| FunctionCoupling
| FindingHotspotOverlap
| CycleHealth
| DefectValidation => STREAM,
}
}
const HTML_WIRED: &[AnalysisName] = &[
AnalysisName::Hotspots,
AnalysisName::CodeHealth,
AnalysisName::KnowledgeIslands,
AnalysisName::CloneCoupling,
AnalysisName::Summary,
AnalysisName::Revisions,
AnalysisName::Authors,
AnalysisName::TopCommitters,
AnalysisName::RefactoringTargets,
];
fn html_not_wired(analysis_name: &str) -> anyhow::Error {
let covered = HTML_WIRED
.iter()
.map(|a| a.as_str())
.collect::<Vec<_>>()
.join(", ");
CodeLoreError::InvalidOptions(format!(
"--format html for analysis `{analysis_name}` not yet wired (covered: {covered} — file an issue if you need another)"
))
.into()
}
fn unsupported_format(analysis: AnalysisName, fmt: &str) -> anyhow::Error {
CodeLoreError::InvalidOptions(format!(
"{} analysis supports {}; got {fmt:?}",
analysis.as_str(),
supported_formats(analysis).join("|"),
))
.into()
}
macro_rules! dispatch {
(
$ctx:expr, $format:expr, $out:expr, $run:expr,
{ $( $fmt:literal => $w:expr ),+ , html $(,)? }
) => {{
match $format {
$( $fmt => {
let rows = $run.with_context(|| format!("run {}", $ctx.analysis.as_str()))?;
let emit = $w;
emit(&rows, $out).with_context(|| format!("write {}", $fmt))?;
rows.len()
} )+
"html" => {
let rows = $run.with_context(|| format!("run {}", $ctx.analysis.as_str()))?;
output::html::write_html(&rows, $out, &$ctx.title, &$ctx.repo_root, &$ctx.generated_at)
.context("write html")?;
rows.len()
}
other => return Err(unsupported_format($ctx.analysis, other)),
}
}};
(
$ctx:expr, $format:expr, $out:expr, $run:expr,
{ $( $fmt:literal => $w:expr ),+ $(,)? }
) => {{
match $format {
$( $fmt => {
let rows = $run.with_context(|| format!("run {}", $ctx.analysis.as_str()))?;
let emit = $w;
emit(&rows, $out).with_context(|| format!("write {}", $fmt))?;
rows.len()
} )+
"html" => return Err(html_not_wired($ctx.analysis.as_str())),
other => return Err(unsupported_format($ctx.analysis, other)),
}
}};
}
#[allow(clippy::too_many_lines)] fn run_streaming_dispatch(
db: &FactsDb,
repo: &GixRepo,
opts: &Options,
cache_dir: Option<&std::path::Path>,
format: &str,
ctx: &EmitCtx,
out: &mut Box<dyn Write>,
) -> Result<usize> {
let rows = match ctx.analysis {
AnalysisName::Hotspots => dispatch!(ctx, format, out,
analyses::hotspots::run_hotspots_anchored(db, opts),
{
"csv" => output::csv::write_hotspots_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_hotspots_markdown,
"sarif" => |r, o| output::sarif::write_hotspots_sarif(r, &ctx.repo_root, o),
"ndjson" => output::ndjson::write_ndjson,
"gha" => output::gha::write_hotspots_gha,
html,
}),
AnalysisName::HotspotVelocity => dispatch!(ctx, format, out,
analyses::hotspot_velocity::run_hotspot_velocity(db, opts),
{
"csv" => output::csv::write_hotspot_velocity_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_hotspot_velocity_markdown,
}),
AnalysisName::CodeHealth => {
notice_corpus_lens_absent(opts, false);
dispatch!(ctx, format, out,
analyses::code_health::run_code_health(db, opts),
{
"csv" => output::csv::write_code_health_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_code_health_markdown,
"ndjson" => output::ndjson::write_ndjson,
html,
})
}
AnalysisName::CodeAge => dispatch!(ctx, format, out,
analyses::code_age::run_code_age(db, opts),
{
"csv" => |r, o| output::csv::write_code_age_csv(r, o, opts.code_maat_compat),
"json" => output::json::write_json,
"markdown" => output::markdown::write_code_age_markdown,
}),
AnalysisName::AbsChurn => dispatch!(ctx, format, out,
analyses::churn::run_abs_churn(db, opts),
{
"csv" => output::csv::write_abs_churn_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_abs_churn_markdown,
}),
AnalysisName::AuthorChurn => dispatch!(ctx, format, out,
analyses::churn::run_author_churn(db, opts),
{
"csv" => output::csv::write_author_churn_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_author_churn_markdown,
}),
AnalysisName::EntityChurn => dispatch!(ctx, format, out,
analyses::churn::run_entity_churn(db, opts),
{
"csv" => output::csv::write_entity_churn_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_entity_churn_markdown,
}),
AnalysisName::Communication => dispatch!(ctx, format, out,
analyses::communication::run_communication(db, opts),
{
"csv" => |r, o| output::csv::write_communication_csv(r, o, opts.code_maat_compat),
"json" => output::json::write_json,
"markdown" => output::markdown::write_communication_markdown,
}),
AnalysisName::Ownership => dispatch!(ctx, format, out,
analyses::ownership::run_ownership(db, opts),
{
"csv" => |r, o| output::csv::write_ownership_csv(r, o, opts.code_maat_compat),
"json" => output::json::write_json,
"markdown" => output::markdown::write_ownership_markdown,
}),
AnalysisName::Coupling => dispatch!(ctx, format, out,
analyses::coupling::run_coupling(db, opts),
{
"csv" => |r, o| output::csv::write_coupling_csv(r, o, opts.code_maat_compat),
"json" => output::json::write_json,
"markdown" => output::markdown::write_coupling_markdown,
"ndjson" => output::ndjson::write_ndjson,
}),
AnalysisName::Summary => dispatch!(ctx, format, out,
analyses::summary::run_summary(db, opts),
{
"csv" => |r, o| output::csv::write_summary_csv(r, o, opts.code_maat_compat),
"json" => output::json::write_json,
"markdown" => output::markdown::write_summary_markdown,
html,
}),
AnalysisName::Clones => dispatch!(ctx, format, out,
analyses::clones::run_clones(opts),
{
"csv" => output::csv::write_clones_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_clones_markdown,
"sarif" => |r, o| output::sarif::write_clones_sarif(r, &ctx.repo_root, o),
}),
AnalysisName::Revisions => dispatch!(ctx, format, out,
analyses::revisions::run_revisions(db, opts),
{
"csv" => output::csv::write_revisions_csv,
"json" => output::json::write_revisions_json,
"markdown" => output::markdown::write_revisions_markdown,
html,
}),
AnalysisName::Authors => dispatch!(ctx, format, out,
analyses::authors::run_authors(db, opts),
{
"csv" => |r, o| output::csv::write_authors_csv(r, o, opts.code_maat_compat),
"json" => output::json::write_json,
"markdown" => output::markdown::write_authors_markdown,
html,
}),
AnalysisName::TopCommitters => dispatch!(ctx, format, out,
analyses::top_committers::run_top_committers(db, opts),
{
"csv" => output::csv::write_top_committers_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_top_committers_markdown,
html,
}),
AnalysisName::GodClasses => dispatch!(ctx, format, out,
analyses::god_classes::run_god_classes(db, opts),
{
"csv" => output::csv::write_god_classes_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_god_classes_markdown,
}),
AnalysisName::ArchViolations => dispatch!(ctx, format, out,
analyses::arch_violations::run_arch_violations(db, opts),
{
"csv" => output::csv::write_arch_violations_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_arch_violations_markdown,
}),
AnalysisName::DependencyCycles => dispatch!(ctx, format, out,
analyses::dependency_cycles::run_dependency_cycles(db, opts),
{
"csv" => output::csv::write_dependency_cycles_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_dependency_cycles_markdown,
}),
AnalysisName::ArchitectureRoles => dispatch!(ctx, format, out,
analyses::architecture_roles::run_architecture_roles(db, opts),
{
"csv" => output::csv::write_architecture_roles_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_architecture_roles_markdown,
}),
AnalysisName::Instability => dispatch!(ctx, format, out,
analyses::instability::run_instability(db, opts),
{
"csv" => output::csv::write_instability_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_instability_markdown,
}),
AnalysisName::CycleHealth => dispatch!(ctx, format, out,
analyses::cycle_health::run_cycle_health(db, opts),
{
"csv" => output::csv::write_cycle_health_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_cycle_health_markdown,
}),
AnalysisName::DefectValidation => dispatch!(ctx, format, out,
analyses::defect_validation::run_defect_validation(opts),
{
"csv" => output::csv::write_defect_validation_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_defect_validation_markdown,
}),
AnalysisName::ArchitectureMetrics => dispatch!(ctx, format, out,
analyses::architecture_metrics::run_architecture_metrics(db, opts),
{
"csv" => output::csv::write_architecture_metrics_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_architecture_metrics_markdown,
}),
AnalysisName::ArchitectureTrend => dispatch!(ctx, format, out,
analyses::architecture_trend::run_architecture_trend(db, repo, opts),
{
"csv" => output::csv::write_architecture_trend_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_architecture_trend_markdown,
}),
AnalysisName::HealthTrend => dispatch!(ctx, format, out,
analyses::health_trend::run_health_trend(db, repo, opts),
{
"csv" => output::csv::write_health_trend_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_health_trend_markdown,
}),
AnalysisName::CycleOrigins => dispatch!(ctx, format, out,
analyses::cycle_origins::run_cycle_origins(db, repo, opts),
{
"csv" => output::csv::write_cycle_origins_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_cycle_origins_markdown,
}),
AnalysisName::ModularityViolations => dispatch!(ctx, format, out,
analyses::modularity_violations::run_modularity_violations(db, opts),
{
"csv" => output::csv::write_modularity_violations_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_modularity_violations_markdown,
}),
AnalysisName::UnstableInterface => dispatch!(ctx, format, out,
analyses::unstable_interface::run_unstable_interface(db, opts),
{
"csv" => output::csv::write_unstable_interface_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_unstable_interface_markdown,
}),
AnalysisName::Crossing => dispatch!(ctx, format, out,
analyses::crossing::run_crossing(db, opts),
{
"csv" => output::csv::write_crossing_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_crossing_markdown,
}),
AnalysisName::StaleCode => dispatch!(ctx, format, out,
analyses::stale_code::run_stale_code(db, opts),
{
"csv" => output::csv::write_stale_code_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_stale_code_markdown,
}),
AnalysisName::PairProgramming => dispatch!(ctx, format, out,
analyses::pair_programming::run_pair_programming(db, opts),
{
"csv" => output::csv::write_pair_programming_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_pair_programming_markdown,
}),
AnalysisName::LeadTime => dispatch!(ctx, format, out,
analyses::lead_time::run_lead_time(db, opts),
{
"csv" => output::csv::write_lead_time_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_lead_time_markdown,
"ndjson" => output::ndjson::write_ndjson,
}),
AnalysisName::BusFactor => dispatch!(ctx, format, out,
analyses::bus_factor::run_bus_factor(db, opts),
{
"csv" => output::csv::write_bus_factor_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_bus_factor_markdown,
}),
AnalysisName::DeliveryFriction => dispatch!(ctx, format, out,
analyses::delivery_friction::run_delivery_friction(db, opts),
{
"csv" => output::csv::write_delivery_friction_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_delivery_friction_markdown,
}),
AnalysisName::RefactoringTargets => dispatch!(ctx, format, out,
analyses::refactoring_targets::run_refactoring_targets(db, opts),
{
"csv" => output::csv::write_refactoring_targets_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_refactoring_targets_markdown,
"ndjson" => output::ndjson::write_ndjson,
html,
}),
AnalysisName::KnowledgeIslands => dispatch!(ctx, format, out,
analyses::knowledge_islands::run_knowledge_islands(db, opts),
{
"csv" => output::csv::write_knowledge_islands_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_knowledge_islands_markdown,
html,
}),
AnalysisName::Soc => dispatch!(ctx, format, out,
analyses::soc::run_soc(db, opts),
{
"csv" => output::csv::write_soc_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_soc_markdown,
}),
AnalysisName::Messages => dispatch!(ctx, format, out,
analyses::messages::run_messages(db, opts),
{
"csv" => output::csv::write_messages_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_messages_markdown,
}),
AnalysisName::MainDev => dispatch!(ctx, format, out,
analyses::main_dev::run_main_dev(db, opts),
{
"csv" => output::csv::write_main_dev_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_main_dev_markdown,
}),
AnalysisName::MainDevByRevs => dispatch!(ctx, format, out,
analyses::main_dev::run_main_dev_by_revs(db, opts),
{
"csv" => |r, o| output::csv::write_main_dev_by_revs_csv(r, o, opts.code_maat_compat),
"json" => output::json::write_json,
"markdown" => output::markdown::write_main_dev_by_revs_markdown,
}),
AnalysisName::MainDevByDeletions => dispatch!(ctx, format, out,
analyses::main_dev::run_main_dev_by_deletions(db, opts),
{
"csv" => output::csv::write_main_dev_by_deletions_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_main_dev_by_deletions_markdown,
}),
AnalysisName::EntityEffort => dispatch!(ctx, format, out,
analyses::entity_effort::run_entity_effort(db, opts),
{
"csv" => output::csv::write_entity_effort_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_entity_effort_markdown,
}),
AnalysisName::EntityOwnership => dispatch!(ctx, format, out,
analyses::entity_ownership::run_entity_ownership(db, opts),
{
"csv" => output::csv::write_entity_ownership_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_entity_ownership_markdown,
}),
AnalysisName::CloneCoupling => dispatch!(ctx, format, out,
analyses::clone_coupling::run_clone_coupling(db, opts),
{
"csv" => output::csv::write_clone_coupling_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_clone_coupling_markdown,
"sarif" => |r, o| output::sarif::write_clone_coupling_sarif(r, &ctx.repo_root, o),
html,
}),
AnalysisName::Centrality => dispatch!(ctx, format, out,
analyses::centrality::run_centrality(db, opts),
{
"csv" => output::csv::write_centrality_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_centrality_markdown,
}),
AnalysisName::Communities => dispatch!(ctx, format, out,
analyses::communities::run_communities(db, opts),
{
"csv" => output::csv::write_communities_csv,
"json" => output::json::write_communities_json,
"markdown" => output::markdown::write_communities_markdown,
}),
AnalysisName::EffortExposure => dispatch!(ctx, format, out,
analyses::effort_exposure::run_effort_exposure_decomposed_scan(db, repo, opts),
{
"csv" => output::csv::write_effort_exposure_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_effort_exposure_markdown,
}),
AnalysisName::CodeFamiliarity => dispatch!(ctx, format, out,
analyses::code_familiarity::run_code_familiarity(db, opts),
{
"csv" => output::csv::write_code_familiarity_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_code_familiarity_markdown,
}),
AnalysisName::TeamComposition => dispatch!(ctx, format, out,
analyses::team_composition::run_team_composition(db, opts),
{
"csv" => output::csv::write_team_composition_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_team_composition_markdown,
}),
AnalysisName::CoordinationNeeds => dispatch!(ctx, format, out,
analyses::coordination_needs::run_coordination_needs(db, opts),
{
"csv" => output::csv::write_coordination_needs_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_coordination_needs_markdown,
}),
AnalysisName::MarginalOwnerRisk => dispatch!(ctx, format, out,
analyses::marginal_owner_risk::run_marginal_owner_risk(db, opts),
{
"csv" => output::csv::write_marginal_owner_risk_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_marginal_owner_risk_markdown,
}),
AnalysisName::ReleaseCadence => dispatch!(ctx, format, out,
analyses::release_cadence::run_release_cadence(repo, opts),
{
"csv" => output::csv::write_release_cadence_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_release_cadence_markdown,
}),
AnalysisName::DeliveryMetrics => dispatch!(ctx, format, out,
analyses::delivery_metrics::run_delivery_metrics(db, opts),
{
"csv" => output::csv::write_delivery_metrics_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_delivery_metrics_markdown,
}),
AnalysisName::FunctionHotspots => dispatch!(ctx, format, out,
analyses::function_hotspots::run_function_hotspots(db, opts),
{
"csv" => output::csv::write_function_hotspots_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_function_hotspots_markdown,
}),
AnalysisName::FunctionXray => {
let target = opts.target.as_deref().ok_or_else(|| {
CodeLoreError::Analysis("--target <path> is required for function-xray".to_string())
})?;
dispatch!(ctx, format, out,
analyses::function_xray::run_function_xray(db, repo, opts, target),
{
"csv" => output::csv::write_function_xray_csv,
"json" => output::json::write_json,
"markdown" => |r, o| output::markdown::write_function_xray_markdown(r, target, o),
})
}
AnalysisName::FunctionCoupling => {
let target = opts.target.as_deref().ok_or_else(|| {
CodeLoreError::Analysis(
"--target <path> is required for function-coupling".to_string(),
)
})?;
dispatch!(ctx, format, out,
analyses::function_coupling::run_function_coupling(db, repo, opts, target),
{
"csv" => output::csv::write_function_coupling_csv,
"json" => output::json::write_json,
"markdown" => |r, o| output::markdown::write_function_coupling_markdown(r, target, o),
})
}
AnalysisName::FindingHotspotOverlap => {
let cache_root = cache_dir.map_or_else(
codelore_lib::cli_api::cache::default_cache_root,
std::path::Path::to_path_buf,
);
let store = codelore_lib::cli_api::external::ExternalStore::open_nonempty(
&cache_root,
&opts.repo_path,
)
.context("open external findings store")?
.ok_or_else(|| {
CodeLoreError::Analysis(
"finding-hotspot-overlap requires prior `codelore ingest-sarif` \
(no external findings found)"
.to_string(),
)
})?;
dispatch!(ctx, format, out,
analyses::finding_hotspot_overlap::run_finding_hotspot_overlap(db, opts, &store),
{
"csv" => output::csv::write_finding_hotspot_overlap_csv,
"json" => output::json::write_json,
"markdown" => output::markdown::write_finding_hotspot_overlap_markdown,
})
}
};
Ok(rows)
}
fn preflight_and_open_repo(
args: &AnalyzeArgs,
opts: &Options,
analysis_name: &str,
no_banner: bool,
) -> Result<GixRepo> {
use codelore_lib::cli_api::output::banner::{self, Banner, Preflight};
use codelore_lib::cli_api::provenance::{DUCKDB_VERSION, GIX_VERSION};
let repo_path_str = args.repo.display().to_string();
let options_summary = format_options_summary(opts);
let pkg_version = env!("CARGO_PKG_VERSION");
let make_banner =
|branch: Option<String>, head_short: Option<String>, preflight: Preflight| Banner {
codelore_version: pkg_version,
gix_version: GIX_VERSION,
duckdb_version: DUCKDB_VERSION,
repo_path: repo_path_str.clone(),
branch,
head_short,
analysis: analysis_name,
options_summary: options_summary.clone(),
preflight,
};
if !args.repo.exists() {
let b = make_banner(
None,
None,
Preflight::RepoPathMissing {
repo_path: repo_path_str.clone(),
},
);
eprint!("{}", b.render(banner::should_color()));
return Err(
CodeLoreError::Repo(format!("--repo path does not exist: {repo_path_str}")).into(),
);
}
let repo = match GixRepo::open(&args.repo) {
Ok(r) => r,
Err(e) => {
let b = make_banner(
None,
None,
Preflight::NotARepository {
repo_path: repo_path_str.clone(),
},
);
eprint!("{}", b.render(banner::should_color()));
return Err(anyhow::Error::new(e).context("open repo"));
}
};
let Ok(head_sha) = repo.head_sha() else {
let b = make_banner(repo.head_branch_name(), None, Preflight::EmptyRepository);
eprint!("{}", b.render(banner::should_color()));
return Err(CodeLoreError::Repo("repository has no commits".to_string()).into());
};
let head_short: String = head_sha.chars().take(7).collect();
let branch = repo.head_branch_name();
if let Some(out_path) = &args.output
&& let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
let b = make_banner(
branch.clone(),
Some(head_short.clone()),
Preflight::OutputNotWritable {
path: out_path.display().to_string(),
reason: format!("parent directory does not exist: {}", parent.display()),
},
);
eprint!("{}", b.render(banner::should_color()));
return Err(CodeLoreError::Output(format!(
"--output parent directory does not exist: {}",
parent.display()
))
.into());
}
let b = make_banner(branch, Some(head_short), Preflight::Ready);
if banner::should_print(false, no_banner) {
eprint!("{}", b.render(banner::should_color()));
}
Ok(repo)
}
fn format_options_summary(opts: &Options) -> String {
let mut parts = vec![format!("min-revs={}", opts.min_revs)];
if let Some(n) = opts.rows_limit {
parts.push(format!("rows={n}"));
}
if opts.include_merges {
parts.push("merges=on".to_string());
}
if opts.code_maat_compat {
parts.push("code-maat-compat".to_string());
}
if opts.explain {
parts.push("explain".to_string());
}
parts.join(", ")
}
fn write_provenance_sidecar(
db: &FactsDb,
opts: &Options,
analysis_name: &str,
output_path: &std::path::Path,
) -> Result<()> {
let manifest = codelore_lib::cli_api::provenance::Manifest::capture(db, opts, analysis_name)
.context("capture provenance manifest")?;
let json = manifest
.to_json()
.context("serialize provenance manifest")?;
let sidecar = std::path::PathBuf::from(format!("{}.provenance.json", output_path.display()));
let mut tmp_name = sidecar.as_os_str().to_owned();
tmp_name.push(format!(".tmp.{}", std::process::id()));
let tmp_path = std::path::PathBuf::from(tmp_name);
{
use std::io::Write as _;
let mut f = std::fs::File::create(&tmp_path).with_context(|| {
format!(
"create provenance sidecar tmp file at {}",
tmp_path.display()
)
})?;
f.write_all(json.as_bytes()).with_context(|| {
format!("write provenance sidecar payload to {}", tmp_path.display())
})?;
f.sync_all().with_context(|| {
format!(
"fsync provenance sidecar tmp file at {}",
tmp_path.display()
)
})?;
}
std::fs::rename(&tmp_path, &sidecar).with_context(|| {
let _ = std::fs::remove_file(&tmp_path);
format!(
"atomically rename {} -> {}",
tmp_path.display(),
sidecar.display()
)
})?;
Ok(())
}
fn write_parquet(
db: &FactsDb,
opts: &Options,
analysis: AnalysisName,
path: &std::path::Path,
) -> Result<()> {
match analysis {
AnalysisName::Hotspots => {
codelore_lib::cli_api::output::parquet::write_hotspots_parquet(db, opts, path)
.context("write parquet")
}
AnalysisName::Revisions => {
codelore_lib::cli_api::output::parquet::write_revisions_parquet(db, opts, path)
.context("write parquet")
}
AnalysisName::Summary => {
codelore_lib::cli_api::output::parquet::write_summary_parquet(db, opts, path)
.context("write parquet")
}
other => anyhow::bail!(
"--format parquet currently supports hotspots, revisions, summary only; \
got {other:?}"
),
}
}
#[cfg(feature = "spa")]
#[allow(clippy::too_many_lines)] fn build_spa_dashboard(
db: &codelore_lib::cli_api::facts::FactsDb,
opts: &codelore_lib::cli_api::Options,
repo_path: &std::path::Path,
) -> anyhow::Result<codelore_lib::cli_api::output::spa::SpaDashboard> {
use codelore_lib::cli_api::analyses::dashboard::{
run_clone_summary, run_daily_commits, run_trends, run_xray,
};
use codelore_lib::cli_api::output::spa::SpaDashboard;
const SPA_OWNERSHIP_FILE_CAP: usize = 200;
const SPA_REFACTORING_TARGET_CAP: usize = 20;
let hotspots = codelore_lib::cli_api::analyses::hotspots::run_hotspots(db, opts)
.context("run hotspots for dashboard")?;
let summary = codelore_lib::cli_api::analyses::summary::run_summary(db, opts)
.context("run summary for dashboard")?;
let code_health = codelore_lib::cli_api::analyses::code_health::run_code_health(db, opts)
.context("run code-health for dashboard")?;
let coupling = codelore_lib::cli_api::analyses::coupling::run_coupling(db, opts)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: coupling analysis failed; skipping: {e}");
Vec::new()
});
let knowledge_islands =
codelore_lib::cli_api::analyses::knowledge_islands::run_knowledge_islands(db, opts)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: knowledge-islands analysis failed; skipping: {e}");
Vec::new()
});
let (entity_ownership, entity_ownership_cap) = {
let full = codelore_lib::cli_api::analyses::entity_ownership::run_entity_ownership(
db,
&opts.with_no_row_limit(),
)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: entity-ownership analysis failed; skipping: {e}");
Vec::new()
});
let hotspot_paths: std::collections::HashSet<&str> =
hotspots.iter().map(|h| h.path.as_str()).collect();
let capped_paths: std::collections::HashSet<&str> = hotspots
.iter()
.take(SPA_OWNERSHIP_FILE_CAP)
.map(|h| h.path.as_str())
.collect();
let mut dropped_displayable = false;
let kept: Vec<_> = full
.into_iter()
.filter(|r| {
let keep = capped_paths.contains(r.entity.as_str());
if !keep && hotspot_paths.contains(r.entity.as_str()) {
dropped_displayable = true;
}
keep
})
.collect();
let cap =
dropped_displayable.then(|| u32::try_from(SPA_OWNERSHIP_FILE_CAP).unwrap_or(u32::MAX));
(kept, cap)
};
let xray = run_xray(db, 500).unwrap_or_else(|e| {
tracing::warn!("dashboard: xray query failed; skipping: {e}");
Vec::new()
});
let daily_commits = run_daily_commits(db).unwrap_or_else(|e| {
tracing::warn!("dashboard: daily_commits query failed; skipping: {e}");
Vec::new()
});
let top_paths: Vec<String> = hotspots.iter().take(50).map(|r| r.path.clone()).collect();
let trends = run_trends(db, opts, &top_paths).unwrap_or_else(|e| {
tracing::warn!("dashboard: trends query failed; skipping: {e}");
Vec::new()
});
let mi_rollup = Some(codelore_lib::cli_api::analyses::mi::MiRollup::from_hotspots(&hotspots));
let coupling_density = compute_spa_coupling_density(db, opts, &coupling);
let clones = run_clone_summary(db).unwrap_or_else(|e| {
tracing::warn!("dashboard: clone summary query failed; skipping: {e}");
Vec::new()
});
let kamei_risk = codelore_lib::cli_api::analyses::dashboard::run_kamei_risk(db, 100)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: kamei risk query failed; skipping: {e}");
Vec::new()
});
let imports = codelore_lib::cli_api::analyses::dashboard::run_imports_for_arch_graph(db)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: imports query failed; skipping: {e}");
Vec::new()
});
let modularity_violations =
codelore_lib::cli_api::analyses::modularity_violations::run_modularity_violations(db, opts)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: modularity-violations failed; skipping: {e}");
Vec::new()
});
let unstable_interface =
codelore_lib::cli_api::analyses::unstable_interface::run_unstable_interface(db, opts)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: unstable-interface failed; skipping: {e}");
Vec::new()
});
let architecture_roles =
codelore_lib::cli_api::analyses::architecture_roles::run_architecture_roles(
db,
&opts.with_no_row_limit(),
)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: architecture-roles failed; skipping: {e}");
Vec::new()
});
let spa_repo = match codelore_lib::cli_api::repo::GixRepo::open(repo_path) {
Ok(r) => Some(r),
Err(e) => {
tracing::warn!(
"dashboard: could not open repo for history/xray scans; skipping those widgets: {e}"
);
None
}
};
let (architecture_trend, health_trend, file_health_series, health_transitions) = spa_repo
.as_ref()
.and_then(|repo| {
match codelore_lib::cli_api::analyses::health_trend::run_sample_trends(db, repo, opts) {
Ok(t) => Some((
t.architecture,
t.health.trend,
t.health.file_series,
t.health.transitions,
)),
Err(e) => {
tracing::warn!("dashboard: trend scan failed; skipping: {e}");
None
}
}
})
.unwrap_or_else(|| (Vec::new(), Vec::new(), Vec::new(), Vec::new()));
let effort_exposure = {
use codelore_lib::cli_api::analyses::effort_exposure::{
run_effort_exposure, run_effort_exposure_decomposed_scan,
};
let result = match &spa_repo {
Some(r) => run_effort_exposure_decomposed_scan(db, r, opts),
None => run_effort_exposure(db, opts),
};
result.unwrap_or_else(|e| {
tracing::warn!("dashboard: effort-exposure analysis failed; skipping: {e}");
Vec::new()
})
};
let marginal_owner_risk =
codelore_lib::cli_api::analyses::marginal_owner_risk::run_marginal_owner_risk(db, opts)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: marginal-owner-risk analysis failed; skipping: {e}");
Vec::new()
});
let delivery_metrics = {
let mut dm_opts = opts.clone();
dm_opts.include_merges = true;
codelore_lib::cli_api::analyses::delivery_metrics::run_delivery_metrics(db, &dm_opts)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: delivery-metrics failed; skipping: {e}");
Vec::new()
})
};
let release_cadence = spa_repo
.as_ref()
.and_then(
|repo| match codelore_lib::cli_api::analyses::release_cadence::run_release_cadence(
repo, opts,
) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!("dashboard: release-cadence failed; skipping: {e}");
None
}
},
)
.unwrap_or_default();
let delivery_friction =
codelore_lib::cli_api::analyses::delivery_friction::run_delivery_friction(db, opts)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: delivery-friction failed; skipping: {e}");
Vec::new()
})
.into_iter()
.take(5)
.collect::<Vec<_>>();
let function_xray: Vec<codelore_lib::cli_api::output::spa::FileFunctionXray> = spa_repo
.as_ref()
.map(|xray_repo| {
hotspots
.iter()
.take(10)
.filter_map(|h| {
match codelore_lib::cli_api::analyses::function_xray::run_function_xray(
db, xray_repo, opts, &h.path,
) {
Ok(rows) if !rows.is_empty() => {
Some(codelore_lib::cli_api::output::spa::FileFunctionXray {
path: h.path.clone(),
rows,
})
}
Ok(_) => None,
Err(e) => {
tracing::debug!("dashboard: function-xray skipped for {}: {e}", h.path);
None
}
}
})
.collect()
})
.unwrap_or_default();
let refactoring_targets =
codelore_lib::cli_api::analyses::refactoring_targets::run_refactoring_targets(
db,
&opts.with_no_row_limit(),
)
.map_or_else(
|e| {
tracing::warn!("dashboard: refactoring-targets failed; skipping: {e}");
Vec::new()
},
|mut rows| {
rows.truncate(SPA_REFACTORING_TARGET_CAP);
rows
},
);
let mut factors = codelore_lib::cli_api::analyses::factors::health_trend_factors(&health_trend);
if let Some(arch_tile) = factors.iter_mut().find(|t| t.name == "Architecture") {
let arch_metric_rows =
codelore_lib::cli_api::analyses::architecture_metrics::run_architecture_metrics(
db, opts,
)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: architecture-metrics for factor tile failed: {e}");
Vec::new()
});
let metric_value = |name: &str| {
arch_metric_rows
.iter()
.find(|r| r.metric == name)
.map(|r| r.value.as_str())
};
if let (Some(p), Some(n)) = (
metric_value("corpus_percentile:propagation_cost"),
metric_value("corpus_n"),
) && let Ok(p) = p.parse::<f64>()
{
use std::fmt::Write as _;
let _ = write!(arch_tile.detail, ", P{:.0} of {n} corpus repos", p * 100.0);
}
}
let code_familiarity =
codelore_lib::cli_api::analyses::code_familiarity::run_code_familiarity(db, opts)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: code-familiarity for spa failed; skipping: {e}");
Vec::new()
});
let knowledge_tile = code_familiarity
.first()
.map(|r| {
codelore_lib::cli_api::analyses::factors::knowledge_factor_from_familiarity(
r.familiarity_pct,
r.islands_pct,
)
})
.or_else(|| {
let total_live_files =
codelore_lib::cli_api::analyses::knowledge_islands::count_live_files(db)
.unwrap_or_else(|e| {
tracing::warn!(
"dashboard: live-file count for knowledge tile failed; skipping: {e}"
);
0
});
codelore_lib::cli_api::analyses::factors::knowledge_factor_from_islands(
&knowledge_islands,
total_live_files,
)
});
if let Some(kt) = knowledge_tile {
factors.push(kt);
}
if let Some(dt) = codelore_lib::cli_api::analyses::factors::delivery_factor_from_metrics(
&delivery_metrics,
&release_cadence,
) {
factors.push(dt);
}
let delivery_metrics: Vec<_> = delivery_metrics
.into_iter()
.filter(|r| {
matches!(
r.metric.as_str(),
"rework_pct" | "branch_duration_hours" | "lead_proxy_hours"
)
})
.collect();
let release_cadence: Vec<_> = release_cadence
.into_iter()
.filter(|r| r.tag == "__summary__")
.collect();
let team_composition =
codelore_lib::cli_api::analyses::team_composition::run_team_composition(db, opts)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: team-composition for spa failed; skipping: {e}");
Vec::new()
});
let coordination_needs = {
let tier_rank = |t: &str| match t {
"high" => 3u8,
"medium" => 2,
"low" => 1,
_ => 0, };
let mut cn =
codelore_lib::cli_api::analyses::coordination_needs::run_coordination_needs(db, opts)
.unwrap_or_else(|e| {
tracing::warn!("dashboard: coordination-needs for spa failed; skipping: {e}");
Vec::new()
});
cn.sort_by(|a, b| {
tier_rank(&b.tier).cmp(&tier_rank(&a.tier)).then(
b.cochange_entropy
.partial_cmp(&a.cochange_entropy)
.unwrap_or(std::cmp::Ordering::Equal),
)
});
cn.truncate(10);
cn
};
Ok(SpaDashboard {
hotspots,
summary,
code_health,
coupling,
knowledge_islands,
entity_ownership,
entity_ownership_cap,
xray,
daily_commits,
trends,
mi_rollup,
coupling_density,
clones,
kamei_risk,
imports,
modularity_violations,
unstable_interface,
architecture_roles,
architecture_trend,
health_trend,
file_health_series,
health_transitions,
effort_exposure,
marginal_owner_risk,
code_familiarity,
team_composition,
coordination_needs,
delivery_metrics,
release_cadence,
delivery_friction,
function_xray,
refactoring_targets,
factors,
options: codelore_lib::cli_api::output::spa::SpaOptionsSnapshot::from_options(opts),
})
}
#[cfg(feature = "spa")]
fn run_spa_dispatch(
db: &codelore_lib::cli_api::facts::FactsDb,
opts: &codelore_lib::cli_api::Options,
repo_path: &std::path::Path,
output: &std::path::Path,
) -> anyhow::Result<()> {
use codelore_lib::cli_api::output::spa::write_spa;
let dash = build_spa_dashboard(db, opts, repo_path)?;
let now = time::OffsetDateTime::now_utc();
let generated_at = format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second(),
);
let title = "CodeLore Dashboard";
let repo_display = repo_path.display().to_string();
codelore_lib::cli_api::output::atomic_publish(output, |tmp| {
let mut out = std::fs::File::create(tmp)
.with_context(|| format!("create spa output {}", output.display()))?;
write_spa(&dash, title, &repo_display, &generated_at, &mut out)
.context("write spa dashboard")
})?;
let size_bytes = std::fs::metadata(output).map_or(0, |m| m.len());
#[allow(clippy::cast_precision_loss)]
let size_human = if size_bytes >= 1_000_000 {
format!("{:.1} MB", size_bytes as f64 / 1_000_000.0)
} else if size_bytes >= 1_000 {
format!("{:.1} kB", size_bytes as f64 / 1_000.0)
} else {
format!("{size_bytes} bytes")
};
let abs = output
.canonicalize()
.unwrap_or_else(|_| output.to_path_buf());
eprintln!(
"✓ spa dashboard written to {} ({})",
output.display(),
size_human
);
eprintln!(" open in browser: file://{}", abs.display());
Ok(())
}
#[cfg(feature = "spa")]
fn run_step_summary_dispatch(
db: &codelore_lib::cli_api::facts::FactsDb,
opts: &codelore_lib::cli_api::Options,
repo_path: &std::path::Path,
output: Option<&std::path::Path>,
) -> anyhow::Result<()> {
use codelore_lib::cli_api::output::step_summary::write_step_summary;
let dash = build_spa_dashboard(db, opts, repo_path)?;
let now = time::OffsetDateTime::now_utc();
let generated_at = format!(
"{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second(),
);
let title = "CodeLore Analysis";
let repo_display = repo_path.display().to_string();
if let Some(path) = output {
codelore_lib::cli_api::output::atomic_publish(path, |tmp| {
let mut out = std::fs::File::create(tmp)
.with_context(|| format!("create step-summary output {}", path.display()))?;
write_step_summary(&dash, title, &repo_display, &generated_at, &mut out)
.context("write step-summary")
})?;
eprintln!("✓ step-summary written to {}", path.display());
} else {
let mut out = std::io::stdout().lock();
write_step_summary(&dash, title, &repo_display, &generated_at, &mut out)
.context("write step-summary")?;
}
Ok(())
}
#[cfg(feature = "spa")]
fn compute_spa_coupling_density(
db: &codelore_lib::cli_api::facts::FactsDb,
opts: &codelore_lib::cli_api::Options,
coupling: &[codelore_lib::cli_api::analyses::coupling::CouplingRow],
) -> Option<f64> {
if coupling.is_empty() {
return None;
}
match codelore_lib::cli_api::analyses::coupling::count_coupling_nodes(db, opts) {
Ok(n) => Some(codelore_lib::cli_api::analyses::coupling::density(
n,
coupling.len(),
)),
Err(e) => {
tracing::warn!("spa: coupling-density node count failed; skipping: {e}");
None
}
}
}
#[cfg(test)]
mod registration_surfaces {
use codelore_lib::cli_api::AnalysisName;
use super::{HTML_WIRED, supported_formats};
fn renders_in_spa(name: AnalysisName) -> bool {
use AnalysisName::{
AbsChurn, ArchViolations, ArchitectureMetrics, ArchitectureRoles, ArchitectureTrend,
AuthorChurn, Authors, BusFactor, Centrality, CloneCoupling, Clones, CodeAge,
CodeFamiliarity, CodeHealth, Communication, Communities, CoordinationNeeds, Coupling,
Crossing, CycleHealth, CycleOrigins, DefectValidation, DeliveryFriction,
DeliveryMetrics, DependencyCycles, EffortExposure, EntityChurn, EntityEffort,
EntityOwnership, FindingHotspotOverlap, FunctionCoupling, FunctionHotspots,
FunctionXray, GodClasses, HealthTrend, HotspotVelocity, Hotspots, Instability,
KnowledgeIslands, LeadTime, MainDev, MainDevByDeletions, MainDevByRevs,
MarginalOwnerRisk, Messages, ModularityViolations, Ownership, PairProgramming,
RefactoringTargets, ReleaseCadence, Revisions, Soc, StaleCode, Summary,
TeamComposition, TopCommitters, UnstableInterface,
};
match name {
Hotspots | Coupling | CodeHealth | Summary | Clones | EntityOwnership
| KnowledgeIslands | ArchitectureRoles | ArchitectureMetrics | ArchitectureTrend
| HealthTrend | ModularityViolations | UnstableInterface | DeliveryFriction
| DeliveryMetrics | RefactoringTargets | EffortExposure | CodeFamiliarity
| TeamComposition | CoordinationNeeds | MarginalOwnerRisk | ReleaseCadence
| FunctionXray => true,
HotspotVelocity
| Ownership
| CodeAge
| AbsChurn
| AuthorChurn
| EntityChurn
| Communication
| Revisions
| Authors
| CloneCoupling
| Soc
| Messages
| MainDev
| MainDevByRevs
| MainDevByDeletions
| EntityEffort
| TopCommitters
| Centrality
| Communities
| GodClasses
| ArchViolations
| DependencyCycles
| Instability
| CycleOrigins
| Crossing
| StaleCode
| PairProgramming
| LeadTime
| BusFactor
| FunctionCoupling
| FunctionHotspots
| FindingHotspotOverlap
| CycleHealth
| DefectValidation => false,
}
}
const DOCUMENTED_ABSENT_CSV: &[(AnalysisName, &str)] = &[];
const DOCUMENTED_ABSENT_MARKDOWN: &[(AnalysisName, &str)] = &[];
const DOCUMENTED_ABSENT_SPA: &[(AnalysisName, &str)] = &[
(
AnalysisName::HotspotVelocity,
"no widget; change-acceleration is a CLI early-warning table",
),
(
AnalysisName::Ownership,
"SPA renders ownership via entity-ownership; the repo fractal has no widget",
),
(AnalysisName::CodeAge, "no code-age widget"),
(
AnalysisName::AbsChurn,
"no widget; absolute churn is summarised in the KPI tiles",
),
(
AnalysisName::AuthorChurn,
"no widget; per-author churn is a CLI table",
),
(
AnalysisName::EntityChurn,
"no widget; per-file churn is subsumed by the hotspots widget",
),
(
AnalysisName::Communication,
"no widget; the author-communication graph is CLI-only",
),
(
AnalysisName::Revisions,
"no widget; per-file revision counts are subsumed by the hotspots widget",
),
(
AnalysisName::Authors,
"no widget; the per-file author-risk table is CLI-only",
),
(
AnalysisName::CloneCoupling,
"no widget; the live-clone x co-change intersection is CLI-only",
),
(
AnalysisName::Soc,
"no widget; sum-of-coupling is a scalar CLI metric",
),
(
AnalysisName::Messages,
"no widget; commit-message matching is CLI-only",
),
(
AnalysisName::MainDev,
"no widget; main-developer tables are CLI-only",
),
(
AnalysisName::MainDevByRevs,
"no widget; main-developer (by revisions) is CLI-only",
),
(
AnalysisName::MainDevByDeletions,
"no widget; main-developer (by deletions) is CLI-only",
),
(
AnalysisName::EntityEffort,
"no widget; per-(entity, author) effort rows are CLI-only",
),
(
AnalysisName::TopCommitters,
"no widget; the committer leaderboard is CLI-only",
),
(
AnalysisName::Centrality,
"no widget; coupling-graph centrality is CLI-only",
),
(
AnalysisName::Communities,
"no widget; Leiden community detection is CLI-only",
),
(
AnalysisName::GodClasses,
"no widget; god-class detection is CLI-only",
),
(
AnalysisName::ArchViolations,
"no widget; layer-rule validation is opt-in CLI-only",
),
(
AnalysisName::DependencyCycles,
"no widget; the architecture graph rings cycle members via architecture-roles",
),
(
AnalysisName::Instability,
"no widget; Martin instability is CLI-only",
),
(
AnalysisName::CycleOrigins,
"no widget; cycle-origin bisection is CLI-only",
),
(
AnalysisName::Crossing,
"no widget; the DV8 crossing pattern is CLI-only",
),
(
AnalysisName::StaleCode,
"no widget; stale-code surfacing is CLI-only",
),
(
AnalysisName::PairProgramming,
"no widget; co-author pairing is CLI-only",
),
(
AnalysisName::LeadTime,
"no widget; the delivery card uses delivery-metrics and release-cadence",
),
(
AnalysisName::BusFactor,
"no widget; per-module bus factor is CLI-only",
),
(
AnalysisName::FunctionCoupling,
"no widget; per-function-pair co-change needs a --target file",
),
(
AnalysisName::FunctionHotspots,
"no widget; repo-wide function-level ranking is CLI-only",
),
(
AnalysisName::FindingHotspotOverlap,
"no widget; external-findings fusion needs prior ingest-sarif",
),
(
AnalysisName::CycleHealth,
"no widget; per-SCC heat/verdict is CLI-only",
),
(
AnalysisName::DefectValidation,
"no widget; defect-calibration evidence is a diagnostic CLI dump",
),
];
fn assert_surface_partition(
surface: &str,
handled: impl Fn(AnalysisName) -> bool,
absent: &[(AnalysisName, &str)],
) {
let absent_names: std::collections::BTreeSet<&'static str> =
absent.iter().map(|(a, _)| a.as_str()).collect();
assert_eq!(
absent_names.len(),
absent.len(),
"{surface}: the documented-absence list has duplicate entries",
);
for (a, reason) in absent {
assert!(
!reason.trim().is_empty(),
"{surface}: documented-absence entry for `{}` has an empty reason",
a.as_str(),
);
}
let mut unaccounted = Vec::new();
let mut double_counted = Vec::new();
for &a in AnalysisName::all() {
match (handled(a), absent_names.contains(a.as_str())) {
(false, false) => unaccounted.push(a.as_str()),
(true, true) => double_counted.push(a.as_str()),
_ => {}
}
}
assert!(
unaccounted.is_empty(),
"{surface}: {} analysis/analyses are neither wired nor documented-absent — \
wire a `{surface}` emitter/widget, or add each to the `{surface}` \
documented-absence list with a reason: {}",
unaccounted.len(),
unaccounted.join(", "),
);
assert!(
double_counted.is_empty(),
"{surface}: {} analysis/analyses are BOTH wired and documented-absent — \
remove the stale documented-absence entry: {}",
double_counted.len(),
double_counted.join(", "),
);
}
#[test]
fn dispatch_surface_reaches_every_analysis() {
for &a in AnalysisName::all() {
assert!(
!supported_formats(a).is_empty(),
"dispatch: `{}` declares no output format (unreachable analysis)",
a.as_str(),
);
}
}
#[test]
fn csv_surface_accounts_for_every_analysis() {
assert_surface_partition(
"csv",
|a| supported_formats(a).contains(&"csv"),
DOCUMENTED_ABSENT_CSV,
);
}
#[test]
fn markdown_surface_accounts_for_every_analysis() {
assert_surface_partition(
"markdown",
|a| supported_formats(a).contains(&"markdown"),
DOCUMENTED_ABSENT_MARKDOWN,
);
}
#[test]
fn spa_surface_accounts_for_every_analysis() {
assert_surface_partition("spa", renders_in_spa, DOCUMENTED_ABSENT_SPA);
}
#[test]
fn html_emitter_set_is_documented() {
let derived: std::collections::BTreeSet<&str> = AnalysisName::all()
.iter()
.copied()
.filter(|&a| supported_formats(a).contains(&"html"))
.map(AnalysisName::as_str)
.collect();
let documented: std::collections::BTreeSet<&str> =
HTML_WIRED.iter().map(|a| a.as_str()).collect();
assert_eq!(
derived, documented,
"html: the bespoke-HTML-emitter set drifted from HTML_WIRED — reconcile \
`supported_formats` with the analyses whose dispatch arm wires a real `write_html`",
);
}
}