Merge branch 'loc'

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-07-21 11:02:32 +04:00
commit 3517fbd1a0
5 changed files with 447 additions and 29 deletions

View file

@ -35,7 +35,11 @@ remain available under `cstat advanced ...`.
Each accepts `--json` for structured output.
- `cstat loc --json --path .` — line counts and directory breakdown.
- `cstat loc --explain --json` — machine-readable `loc` contract: modes,
`code_lines` rules, project JSON fields, and selected-file JSON fields.
- `cstat loc --json --path .` — project size-shape data.
- `cstat loc --json --path src/lib.rs` — selected-file projected static line
reachability.
- `cstat symbols --json --path .` — symbol totals by kind and per file.
- `cstat deps --json --path .` — dependency edges, coupling, fan-in/fan-out,
and cohesion.
@ -82,5 +86,8 @@ Use these only when the focused report points to a question they answer:
test/benchmark roots can reach a function or edge. They are not runtime hit-count profiling.
- Dependency edges come from source-level `use`/`mod` relationships. Generated
code and macro expansion can hide edges.
- For exact `loc` `code_lines` rules and JSON fields, run
`cstat loc --explain` or `cstat loc --explain --json`; that command is the
canonical contract.
- Prefer targeted reductions: remove dead code, split large files, move symbols
across modules, then reduce per-function complexity.

View file

@ -64,30 +64,31 @@ when learning what the metrics mean.",
fn topic_size() -> TopicContent {
TopicContent {
description: "\
Size metrics measure the volume of code at file and function granularity. \
Large files and functions are not inherently problematic, but they correlate \
with higher defect density, harder navigation, and merge conflicts. The \
distribution shape matters more than any single value.",
Size metrics measure the volume and distribution of Rust source. `cstat loc` owns the line-count contract; run `cstat loc --explain` for modes, code_lines rules, and JSON fields.",
metrics: &[
("file LoC", "Total lines of code per file. High values may indicate a module doing too much."),
("function LoC", "Lines per function body. Long functions tend to have higher cognitive load."),
("mean / median", "Central tendency of the distribution. A large gap between mean and median indicates skew from outliers."),
("std_dev", "Spread of the distribution. High values mean uneven file sizes."),
("max", "The largest single file or function. Often the first place to investigate."),
(
"file size shape",
"Project-level file-size distribution from `cstat loc --path .`.",
),
(
"selected-file line reachability",
"Projected static reachable/unreachable production spans from `cstat loc --path <file.rs>`.",
),
(
"distribution statistics",
"Mean, median, std_dev, min, and max summarize project file sizes.",
),
],
commands: &[
"cstat loc — per-file LoC with bar charts and directory breakdown",
"cstat loc --json — structured file-level size data",
"cstat loc --explain — loc modes, code_lines rules, and JSON field contract",
"cstat loc --path . — project size-shape report",
"cstat loc --path src/lib.rs — selected-file projected line reachability",
"cstat dist --metric loc — histogram and outlier analysis of LoC distribution",
],
patterns: &[
"A right-skewed LoC distribution (long tail) usually means a few files have \
grown disproportionately. Check whether those files contain multiple concerns.",
"Files above 500 lines often contain function clusters that could be separate \
modules. Cross-reference with `cstat deps` to see if the file's functions form \
distinct groups with few cross-calls.",
"Uniform file sizes are not a goal — some modules are naturally larger. The \
signal is when a file is large AND has low cohesion or high internal complexity.",
"Use `cstat loc --explain` as the canonical reference before consuming loc output.",
"A right-skewed project-mode code_lines distribution means a few files dominate size.",
"Selected-file loc is a static projection from function spans and test/benchmark reachability; do not read it as runtime coverage.",
],
}
}

View file

@ -377,11 +377,13 @@ pub fn render_loc(files: &[PathBuf], project_path: &Path, top_n: Option<usize>,
if verbose {
render::verbose_block(&[
"Bar chart: files ranked by code lines (excluding blank lines and comments).",
"Bar length is proportional to LoC relative to the largest file.",
"Bar chart: files ranked by code_lines.",
"code_lines excludes blank lines, // comment-only lines, and block-comment-only regions.",
"Line classification is static text scanning, not semantic Rust parsing.",
"Bar length is proportional to code_lines relative to the largest file.",
"Color gradient: red = top of the ranking (most lines), green = bottom.",
"Full loc reference: cstat loc --explain.",
]);
render::guide_ref("size");
}
println!(
@ -477,6 +479,71 @@ pub fn render_loc_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Pa
}
}
pub fn render_loc_explain(json: bool) {
if json {
render_loc_explain_json();
} else {
render_loc_explain_human();
}
}
fn render_loc_explain_human() {
render::section_header("loc reference");
println!(" Project mode: cstat loc --path .");
println!(" Project JSON: cstat loc --path . --json");
println!(" Selected-file mode: cstat loc --path src/lib.rs");
println!(" Selected-file JSON: cstat loc --path src/lib.rs --json");
println!(" Machine contract: cstat loc --explain --json");
println!();
println!(" code_lines: static textual classification");
println!(" excludes blank lines, // comment-only lines, and block-comment-only regions");
println!(" not semantic Rust parsing");
println!();
println!(" Project JSON fields: cstat_version, files, aggregate, directory_breakdown");
println!(" Selected-file JSON fields: file, total_lines, code_lines, projected_reachable_lines, projected_unreachable_lines, reachable_spans, unreachable_spans");
}
fn render_loc_explain_json() {
let output = serde_json::json!({
"cstat_version": env!("CARGO_PKG_VERSION"),
"probe": "loc",
"commands": {
"project_human": "cstat loc --path .",
"project_json": "cstat loc --path . --json",
"selected_file_human": "cstat loc --path src/lib.rs",
"selected_file_json": "cstat loc --path src/lib.rs --json",
"explain_human": "cstat loc --explain",
"explain_json": "cstat loc --explain --json",
},
"code_lines": {
"kind": "static textual classification",
"excludes": [
"blank lines",
"// comment-only lines",
"block-comment-only regions via simple /* ... */ state tracking",
],
"not": "semantic Rust parsing",
},
"project_json_fields": {
"cstat_version": "cstat version string",
"files": ["path", "total_lines", "code_lines"],
"aggregate": ["total_files", "total_loc", "mean", "std_dev", "median", "min", "max"],
"directory_breakdown": ["directory", "code_lines"],
},
"selected_file_json_fields": {
"file": "selected file path relative to the crate root",
"total_lines": "physical lines in the selected file",
"code_lines": "production function-span code lines after exclusions",
"projected_reachable_lines": "production code lines in statically reachable function spans",
"projected_unreachable_lines": "production code lines not in statically reachable function spans",
"reachable_spans": ["function", "line_start", "line_end"],
"unreachable_spans": ["function", "line_start", "line_end"],
},
});
println!("{}", serde_json::to_string(&output).unwrap());
}
pub fn analyze_file_projected_line_reachability_from_files(
rs_files: &[PathBuf],
project_path: &Path,
@ -511,8 +578,10 @@ pub fn render_file_projected_line_reachability_report(
if verbose {
render::verbose_block(&[
"Physical lines count every source line in the selected file.",
"Code lines exclude blank/comment-only lines and test/support/wrapper spans for selected-file production accounting.",
"code_lines is production function-span code after excluding tests, benches, test-support helpers, and selected binary wrapper main().",
"Line classification excludes blank lines, // comment-only lines, and block-comment-only regions with simple text scanning.",
"Projected reachable lines are production code lines inside functions statically reached from project tests/benches.",
"Full loc reference: cstat loc --explain.",
]);
}

View file

@ -33,7 +33,7 @@ struct Cli {
#[command(subcommand)]
command: Option<Commands>,
/// Path to the Rust project directory (defaults to current directory)
/// Rust project directory or Rust source file to analyze (defaults to current directory)
#[arg(long, default_value = ".", global = true)]
path: PathBuf,
@ -56,11 +56,40 @@ enum Commands {
Summary,
/// Alias for the focused module metrics report
Report,
/// Lines-of-code analysis with bar charts
#[command(
about = "Lines-of-code size-shape analysis",
long_about = r#"Lines-of-code size-shape analysis for Rust source.
Project mode:
cstat loc --path .
cstat loc --path . --json
Selected-file mode:
cstat loc --path src/lib.rs
cstat loc --path src/lib.rs --json
Explain mode:
cstat loc --explain
cstat loc --explain --json
Project mode reports total physical lines, code_lines, aggregate stats, per-file ranking, and directory breakdown for discovered Rust files. Selected-file mode reports projected static reachable/unreachable production line spans for one Rust source file.
code_lines is a static textual classification: blank lines are excluded, // comment-only lines are excluded, and block-comment-only regions are excluded using simple /* ... */ state tracking. It is not semantic Rust parsing.
Project JSON fields: cstat_version, files[{path,total_lines,code_lines}], aggregate{total_files,total_loc,mean,std_dev,median,min,max}, directory_breakdown[{directory,code_lines}].
Selected-file JSON fields: file, total_lines, code_lines, projected_reachable_lines, projected_unreachable_lines, reachable_spans[{function,line_start,line_end}], unreachable_spans[{function,line_start,line_end}].
Use --explain to print this usage and JSON field contract without running analysis. --path, --top, and -v are ignored by --explain.
--top only limits the project-mode per-file ranking (ignored for selected-file and --explain modes)."#
)]
Loc {
/// Show only the top N files
/// Show only the top N files in project mode (ignored for selected-file and --explain modes)
#[arg(long)]
top: Option<usize>,
/// Print the loc usage and JSON field contract without running analysis
#[arg(long)]
explain: bool,
},
/// Symbol counts by kind and file
Symbols,
@ -162,6 +191,12 @@ fn main() {
}
let verbose = cli.verbose;
let command = cli.command.unwrap_or(Commands::Summary);
if let Commands::Loc { explain: true, .. } = &command {
loc::render_loc_explain(json);
return;
}
let target = match discovery::resolve_target(&cli.path) {
Ok(target) => target,
Err(e) => {
@ -177,8 +212,6 @@ fn main() {
let project_path = &target.project_path;
let rs_files = discovery::files_for_project(&target);
let command = cli.command.unwrap_or(Commands::Summary);
if let Some(file) = discovery::selected_file(&target) {
let project_rs_files = discovery::files_for_project(&target);
let target_rs_files = discovery::files_for_target(&target);
@ -307,7 +340,7 @@ fn main() {
report::render_report(&rs_files, &project_path, verbose);
}
}
Commands::Loc { top } => {
Commands::Loc { top, .. } => {
if json {
loc::render_loc_json(&rs_files, &project_path);
} else {

308
tests/loc_cli.rs Normal file
View file

@ -0,0 +1,308 @@
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_project(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("cstat-loc-cli-{name}-{unique}"));
fs::create_dir_all(root.join("src")).unwrap();
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
fs::write(
root.join("src/lib.rs"),
r#"
pub fn live() {
helper();
}
/*
block comment only
*/
fn helper() {}
/* single-line block comment only */
fn orphan() {
// comment-only
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn covers_live() {
live();
}
}
"#,
)
.unwrap();
root
}
fn run_cstat_raw(args: &[&str]) -> Output {
let bin = env!("CARGO_BIN_EXE_cstat");
let mut command = Command::new(bin);
command.args(args);
command.output().expect("invoke cstat binary")
}
fn run_cstat(path: &Path, args: &[&str]) -> Output {
let bin = env!("CARGO_BIN_EXE_cstat");
let mut command = Command::new(bin);
command.args(["--no-color", "--path"]);
command.arg(path);
command.args(args);
command.output().expect("invoke cstat binary")
}
fn assert_success(output: &Output) {
assert!(
output.status.success(),
"cstat failed: status={:?}\nstderr={}\nstdout={}",
output.status,
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout),
);
}
fn span_array_contains_function(array: &Value, expected: &str) -> bool {
array
.as_array()
.expect("span array")
.iter()
.any(|item| item.get("function").and_then(Value::as_str) == Some(expected))
}
#[test]
fn loc_help_exposes_project_and_selected_file_modes() {
let output = run_cstat_raw(&["loc", "--help"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for expected in [
"Rust project directory or Rust source file",
"Project mode:",
"Selected-file mode:",
"cstat loc --path .",
"cstat loc --path src/lib.rs",
"cstat loc --explain",
"cstat loc --explain --json",
"code_lines is a static textual classification",
"blank lines are excluded",
"// comment-only lines are excluded",
"block-comment-only regions are excluded",
"Project JSON fields:",
"Selected-file JSON fields:",
"--top only limits the project-mode",
"ignored for selected-file and --explain modes",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
}
#[test]
fn loc_explain_human_is_canonical_reference() {
let output = run_cstat_raw(&[
"--path",
"/definitely/missing/cstat/path",
"loc",
"--explain",
]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for expected in [
"loc reference",
"Project mode: cstat loc --path .",
"Machine contract: cstat loc --explain --json",
"code_lines: static textual classification",
"not semantic Rust parsing",
"Project JSON fields:",
"Selected-file JSON fields:",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("Error discovering files"),
"unexpected discovery error: {stderr}",
);
}
#[test]
fn loc_explain_json_exposes_machine_contract() {
let output = run_cstat_raw(&["--json", "loc", "--explain"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse loc explain JSON");
assert_eq!(value["probe"], "loc");
assert_eq!(
value["commands"]["explain_json"],
"cstat loc --explain --json",
);
assert_eq!(value["code_lines"]["kind"], "static textual classification",);
assert!(
value["project_json_fields"]["files"]
.as_array()
.expect("project fields")
.iter()
.any(|field| field.as_str() == Some("code_lines")),
"json={stdout}",
);
assert!(
value["selected_file_json_fields"]["reachable_spans"]
.as_array()
.expect("selected fields")
.iter()
.any(|field| field.as_str() == Some("function")),
"json={stdout}",
);
}
#[test]
fn loc_verbose_points_to_loc_explain_not_guide() {
let root = temp_project("verbose-loc-reference");
let output = run_cstat(&root, &["-v", "loc"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Full loc reference: cstat loc --explain"),
"stdout={stdout}",
);
assert!(
!stdout.contains("cstat guide size"),
"stdout unexpectedly referenced guide: {stdout}",
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn loc_project_json_exposes_shape_contract() {
let root = temp_project("project-json");
let output = run_cstat(&root, &["--json", "loc"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse loc JSON");
assert_eq!(value["cstat_version"], env!("CARGO_PKG_VERSION"));
let files = value["files"].as_array().expect("files array");
assert!(!files.is_empty(), "json={stdout}");
let lib = files
.iter()
.find(|file| file["path"] == "src/lib.rs")
.expect("src/lib.rs row");
let total_lines = lib["total_lines"].as_u64().expect("total_lines");
let code_lines = lib["code_lines"].as_u64().expect("code_lines");
assert!(
total_lines >= code_lines && code_lines > 0,
"lib row={lib:?}",
);
let aggregate = &value["aggregate"];
for field in [
"total_files",
"total_loc",
"mean",
"std_dev",
"median",
"min",
"max",
] {
assert!(aggregate.get(field).is_some(), "missing {field}: {stdout}");
}
let directories = value["directory_breakdown"]
.as_array()
.expect("directory_breakdown array");
assert!(
directories.iter().any(|directory| {
directory["directory"] == "src"
&& directory["code_lines"]
.as_u64()
.is_some_and(|code_lines| code_lines > 0)
}),
"directory_breakdown={directories:?}",
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn loc_selected_file_human_is_usable() {
let root = temp_project("selected-human");
let file = root.join("src/lib.rs");
let output = run_cstat(&file, &["loc"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for expected in [
"Projected line reachability",
"file src/lib.rs",
"physical lines",
"production code lines",
"statically reachable lines",
"not statically reachable lines",
"reachable function spans:",
"unreachable function spans:",
"live",
"orphan",
] {
assert!(stdout.contains(expected), "missing {expected}: {stdout}");
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn loc_selected_file_json_exposes_projected_reachability_contract() {
let root = temp_project("selected-json");
let file = root.join("src/lib.rs");
let output = run_cstat(&file, &["--json", "loc"]);
assert_success(&output);
let stdout = String::from_utf8(output.stdout).unwrap();
let value: Value = serde_json::from_str(&stdout).expect("parse selected-file loc JSON");
assert_eq!(value["file"], "src/lib.rs");
let total_lines = value["total_lines"].as_u64().expect("total_lines");
let code_lines = value["code_lines"].as_u64().expect("code_lines");
let projected_reachable_lines = value["projected_reachable_lines"]
.as_u64()
.expect("projected_reachable_lines");
let projected_unreachable_lines = value["projected_unreachable_lines"]
.as_u64()
.expect("projected_unreachable_lines");
assert!(total_lines >= code_lines, "json={stdout}");
assert_eq!(
projected_reachable_lines + projected_unreachable_lines,
code_lines,
"json={stdout}",
);
assert!(
span_array_contains_function(&value["reachable_spans"], "live"),
"json={stdout}",
);
assert!(
span_array_contains_function(&value["reachable_spans"], "helper"),
"json={stdout}",
);
assert!(
span_array_contains_function(&value["unreachable_spans"], "orphan"),
"json={stdout}",
);
fs::remove_dir_all(root).unwrap();
}