From 9ff6bfe45a14cd4db8a796dd4e317797ac1f9c02 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 17:20:23 +0300 Subject: [PATCH 1/2] Exclude development dependencies by default Co-authored-by: Medulla --- README.md | 1 + crates/tinyanalyzer-core/README.md | 4 +- crates/tinyanalyzer-core/src/config/test.rs | 2 + crates/tinyanalyzer-core/src/config/types.rs | 8 +- crates/tinyanalyzer-core/src/deps/mod.rs | 26 ++++-- crates/tinyanalyzer-core/tests/public_api.rs | 94 ++++++++++++++++++-- docs/specs/analysis-contract.md | 9 +- 7 files changed, 124 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index da60cab..b4dbec7 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ ignore = ["main", "some_macro_target"] tests_count_as_uses = false # an item only its tests call is dead weight [dependencies] +include_dev = false # opt in to test/benchmark-only dependency cost ignore_unused = ["thiserror"] # reached only through a derive macro [ui] diff --git a/crates/tinyanalyzer-core/README.md b/crates/tinyanalyzer-core/README.md index c01b9c9..30cd8e2 100644 --- a/crates/tinyanalyzer-core/README.md +++ b/crates/tinyanalyzer-core/README.md @@ -59,7 +59,9 @@ their callers may not be in this workspace at all. **Unused dependencies** (`deps`) are dependencies no source file *names*. A crate reached only through a derive macro or a linker side effect has no `use` naming -it, which is what `ignore_unused` is for. +it, which is what `ignore_unused` is for. Development dependencies and their +transitive packages are excluded by default so the graph describes production +cost; set `dependencies.include_dev = true` to include test and benchmark tooling. **Everything else** — line counts, item counts, function lengths, nesting, the dependency graph — is exact. The graph in particular comes from `cargo metadata` diff --git a/crates/tinyanalyzer-core/src/config/test.rs b/crates/tinyanalyzer-core/src/config/test.rs index 5627a4d..445e667 100644 --- a/crates/tinyanalyzer-core/src/config/test.rs +++ b/crates/tinyanalyzer-core/src/config/test.rs @@ -41,6 +41,8 @@ fn the_defaults_are_the_documented_ones() { assert!(!config.scan.follow_symlinks); assert!(config.dead_code.enabled); assert!(!config.dead_code.tests_count_as_uses); + assert!(config.dependencies.enabled); + assert!(!config.dependencies.include_dev); assert_eq!(config.ui.start_view, StartView::Overview); assert!(!config.ui.hide_tests); assert_eq!(config.ui.table_rows, 20); diff --git a/crates/tinyanalyzer-core/src/config/types.rs b/crates/tinyanalyzer-core/src/config/types.rs index 5af038b..76bde01 100644 --- a/crates/tinyanalyzer-core/src/config/types.rs +++ b/crates/tinyanalyzer-core/src/config/types.rs @@ -201,7 +201,11 @@ pub struct DependencyConfig { /// Turning this off makes the analysis pure filesystem work, which is what /// you want against a tree that does not resolve. pub enabled: bool, - /// Whether development and build dependencies are included in the graph. + /// Whether development dependencies are included in the graph. + /// + /// Off by default so dependency cost describes production builds. Build + /// dependencies remain included because Cargo needs them to compile a + /// production target. pub include_dev: bool, /// Crate names never reported as unused, however unreferenced they look. /// @@ -214,7 +218,7 @@ impl Default for DependencyConfig { fn default() -> Self { Self { enabled: true, - include_dev: true, + include_dev: false, ignore_unused: Vec::new(), } } diff --git a/crates/tinyanalyzer-core/src/deps/mod.rs b/crates/tinyanalyzer-core/src/deps/mod.rs index d0a7512..6883f4a 100644 --- a/crates/tinyanalyzer-core/src/deps/mod.rs +++ b/crates/tinyanalyzer-core/src/deps/mod.rs @@ -53,14 +53,7 @@ pub fn analyze( references: &CrateReferences, ) -> Result { let root = root.as_ref(); - - let metadata = MetadataCommand::new() - .manifest_path(root.join("Cargo.toml")) - .exec() - .map_err(|source| Error::CargoMetadata { - root: root.to_path_buf(), - message: source.to_string(), - })?; + let metadata = resolved_metadata(root)?; let resolve = metadata .resolve @@ -103,10 +96,16 @@ pub fn analyze( let depths = shortest_depths(&members, &adjacency); let direct = direct_dependencies(&members, &adjacency); + let member_ids: Vec = members.iter().cloned().collect(); + let mut included = reachable_from(&member_ids, &adjacency); + included.extend(members.iter().cloned()); let mut packages = Vec::new(); for node in &resolve.nodes { let id = node.id.to_string(); + if !included.contains(&id) { + continue; + } let Some(package) = metadata.packages.iter().find(|entry| entry.id == node.id) else { continue; }; @@ -169,6 +168,17 @@ pub fn analyze( }) } +/// Asks Cargo for the workspace graph, preserving its diagnostic on failure. +fn resolved_metadata(root: &Path) -> Result { + MetadataCommand::new() + .manifest_path(root.join("Cargo.toml")) + .exec() + .map_err(|source| Error::CargoMetadata { + root: root.to_path_buf(), + message: source.to_string(), + }) +} + /// Measures the checked-out source Cargo would compile for one package. fn package_source_bytes(root: &Path) -> u64 { WalkBuilder::new(root) diff --git a/crates/tinyanalyzer-core/tests/public_api.rs b/crates/tinyanalyzer-core/tests/public_api.rs index 07c0340..2596565 100644 --- a/crates/tinyanalyzer-core/tests/public_api.rs +++ b/crates/tinyanalyzer-core/tests/public_api.rs @@ -28,7 +28,7 @@ fn write(root: &Path, relative: &str, contents: &str) { /// A real cargo workspace with real edges in it. /// /// Two members, one depending on the other by path, plus a path dev-dependency -/// and one declared-but-never-named dependency. Everything resolves from disk: +/// outside the workspace and one declared-but-never-named dependency. Everything resolves from disk: /// a fixture that reached the network would make this suite fail on a machine /// with no network rather than on a real defect, and a fixture with no edges at /// all would leave the entire graph half of the analyzer unexercised. @@ -38,7 +38,7 @@ fn workspace() -> TempDir { write( root.path(), "Cargo.toml", - "[workspace]\nresolver = \"3\"\nmembers = [\"crates/*\"]\n", + "[workspace]\nresolver = \"3\"\nmembers = [\"crates/*\"]\nexclude = [\"support/dev-tool\", \"support/build-tool\"]\n", ); write( @@ -86,6 +86,27 @@ pub fn hot(values: &[String]) -> Vec { "//! Declared by `app` and never named by it.\n\n/// Does nothing in particular.\npub fn assist() {}\n", ); + write( + root.path(), + "support/dev-tool/Cargo.toml", + "[package]\nname = \"dev-tool\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ); + write( + root.path(), + "support/dev-tool/src/lib.rs", + "//! Used only while developing the fixture.\n\n/// Sets up a test.\npub fn setup() {}\n", + ); + write( + root.path(), + "support/build-tool/Cargo.toml", + "[package]\nname = \"build-tool\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ); + write( + root.path(), + "support/build-tool/src/lib.rs", + "//! Required while compiling the fixture.\n\n/// Configures a build.\npub fn configure() {}\n", + ); + write( root.path(), "crates/app/Cargo.toml", @@ -98,8 +119,11 @@ edition = "2021" engine = { path = "../engine" } helper = { path = "../helper" } +[build-dependencies] +build-tool = { path = "../../support/build-tool" } + [dev-dependencies] -engine = { path = "../engine" } +dev-tool = { path = "../../support/dev-tool" } "#, ); write( @@ -107,6 +131,11 @@ engine = { path = "../engine" } "crates/app/src/lib.rs", "//! The fixture application.\n\n/// Runs the engine.\npub fn run() -> u8 {\n engine::add(1, 2)\n}\n", ); + write( + root.path(), + "crates/app/build.rs", + "fn main() {\n build_tool::configure();\n}\n", + ); root } @@ -189,18 +218,18 @@ fn it_resolves_the_dependency_graph_of_a_real_workspace() { } #[test] -fn development_edges_can_be_excluded_from_the_graph() { +fn development_dependencies_are_excluded_by_default_and_can_be_included() { let root = workspace(); - let with_dev = analyze(root.path()).expect("a resolvable workspace"); + let without_dev = analyze(root.path()).expect("a resolvable workspace"); let config = Config { dependencies: tinyanalyzer_core::DependencyConfig { - include_dev: false, + include_dev: true, ..tinyanalyzer_core::DependencyConfig::default() }, ..Config::default() }; - let without_dev = analyze_with(root.path(), &config).expect("a resolvable workspace"); + let with_dev = analyze_with(root.path(), &config).expect("a resolvable workspace"); let development = |report: &Report| { report @@ -211,11 +240,60 @@ fn development_edges_can_be_excluded_from_the_graph() { .count() }; + assert_eq!(development(&without_dev), 0); + assert!( + without_dev + .dependencies + .packages + .iter() + .all(|package| package.name != "dev-tool"), + "a dev-only package must not affect production package metrics" + ); + assert!( + without_dev + .dependencies + .unused + .iter() + .all(|dependency| dependency.dependency != "dev-tool"), + "a dev-only declaration must not be reported as unused production weight" + ); + assert!( + without_dev + .dependencies + .packages + .iter() + .any(|package| package.name == "build-tool"), + "build dependencies remain part of production compilation" + ); + assert!( + without_dev + .dependencies + .edges + .iter() + .any(|edge| edge.kind == tinyanalyzer_core::DependencyKind::Build), + "build edges remain in the production graph" + ); + assert!( development(&with_dev) > 0, "the fixture has a dev-dependency" ); - assert_eq!(development(&without_dev), 0); + assert!( + with_dev + .dependencies + .packages + .iter() + .any(|package| package.name == "dev-tool"), + "opting in restores the dev-only package" + ); + assert!( + with_dev + .dependencies + .unused + .iter() + .any(|dependency| dependency.dependency == "dev-tool"), + "opting in applies unused analysis to development declarations" + ); } #[test] diff --git a/docs/specs/analysis-contract.md b/docs/specs/analysis-contract.md index 29c1628..1139c1d 100644 --- a/docs/specs/analysis-contract.md +++ b/docs/specs/analysis-contract.md @@ -32,7 +32,10 @@ somebody acts on it. function. - **The dependency graph.** From `cargo metadata` — cargo's own resolution, including features, optional dependencies, platform-specific edges, and - version unification. A tool that re-implemented any of those would disagree + version unification. Development-only edges and packages are excluded by + default so this graph describes production cost; `dependencies.include_dev = + true` opts into them. Build dependencies remain because production targets + require them. A tool that re-implemented Cargo's resolution would disagree with the build it is describing. - **Dependency source size.** The bytes in each resolved external package's checked-out source directory, excluding `.git` and `target`. This measures the @@ -102,6 +105,10 @@ A declared dependency that no source file in the declaring package *names*, with hyphens folded to underscores. A crate reached only through a derive macro, a build script, or a linker side effect has no `use` naming it. +Development dependencies are not unused-production candidates by default. When +`dependencies.include_dev = true`, they participate in this check alongside the +development dependency graph. + The remedy in the finding says so: remove it and build. If the build passes it was costing compile time for nothing; if it fails, the crate belongs in `ignore_unused`. From b8abe7901fcd374fc178d5fc57f38f1653a41abd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 17:43:01 +0300 Subject: [PATCH 2/2] Resolve production dependency features Co-authored-by: Medulla --- README.md | 2 +- crates/tinyanalyzer-core/README.md | 10 +- crates/tinyanalyzer-core/src/deps/mod.rs | 195 ++++++++++++++++--- crates/tinyanalyzer-core/tests/public_api.rs | 149 +++++++++++--- docs/specs/analysis-contract.md | 4 +- 5 files changed, 294 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index b4dbec7..811fdaf 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ ignore = ["main", "some_macro_target"] tests_count_as_uses = false # an item only its tests call is dead weight [dependencies] -include_dev = false # opt in to test/benchmark-only dependency cost +include_dev = true # opt in to test/benchmark-only dependency cost ignore_unused = ["thiserror"] # reached only through a derive macro [ui] diff --git a/crates/tinyanalyzer-core/README.md b/crates/tinyanalyzer-core/README.md index 30cd8e2..2ed80c4 100644 --- a/crates/tinyanalyzer-core/README.md +++ b/crates/tinyanalyzer-core/README.md @@ -64,11 +64,11 @@ transitive packages are excluded by default so the graph describes production cost; set `dependencies.include_dev = true` to include test and benchmark tooling. **Everything else** — line counts, item counts, function lengths, nesting, the -dependency graph — is exact. The graph in particular comes from `cargo metadata` -rather than from re-parsing manifests, because features, optional dependencies, -platform-specific edges, and version unification are decided by the resolver, and -a tool that re-implements any of them will disagree with the build it is -describing. +dependency graph — is exact. The graph in particular comes from Cargo's metadata +and production tree rather than from re-parsing manifests, because features, +optional dependencies, platform-specific edges, and version unification are +decided by the resolver, and a tool that re-implements any of them will disagree +with the build it is describing. ## Example diff --git a/crates/tinyanalyzer-core/src/deps/mod.rs b/crates/tinyanalyzer-core/src/deps/mod.rs index 6883f4a..919b14c 100644 --- a/crates/tinyanalyzer-core/src/deps/mod.rs +++ b/crates/tinyanalyzer-core/src/deps/mod.rs @@ -1,11 +1,10 @@ //! Reading the resolved dependency graph. //! -//! The graph comes from `cargo metadata` rather than from re-parsing manifests. -//! That is the whole design decision of this module: features, optional -//! dependencies, platform-specific edges, and version unification are decided -//! by the resolver, and a tool that re-implements any of them will disagree -//! with the build it is describing. Being slower and correct beats being -//! instant and plausible. +//! The graph comes from Cargo rather than from re-parsing manifests. Metadata +//! supplies package identities and every resolved edge; Cargo's production tree +//! supplies the normal/build-only feature context when development dependencies +//! are excluded. Features, optional dependencies, platform-specific edges, and +//! version unification remain Cargo's decisions rather than this module's. //! //! What this module adds on top of cargo's answer is the arithmetic cargo does //! not do: @@ -32,6 +31,13 @@ use cargo_metadata::MetadataCommand; use ignore::WalkBuilder; use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::path::Path; +use std::process::Command; + +#[derive(Debug)] +struct ProductionResolution { + package_ids: BTreeSet, + features: BTreeMap>, +} /// Which crate names each workspace member's source files mention. /// @@ -54,6 +60,9 @@ pub fn analyze( ) -> Result { let root = root.as_ref(); let metadata = resolved_metadata(root)?; + let production = (!config.include_dev) + .then(|| production_resolution(root, &metadata)) + .transpose()?; let resolve = metadata .resolve @@ -69,36 +78,14 @@ pub fn analyze( .map(ToString::to_string) .collect(); - let mut edges = Vec::new(); - let mut adjacency: BTreeMap> = BTreeMap::new(); - - for node in &resolve.nodes { - let from = node.id.to_string(); - - for dep in &node.deps { - let kinds = edge_kinds(dep, config.include_dev); - if kinds.is_empty() { - continue; - } - - let to = dep.pkg.to_string(); - adjacency.entry(from.clone()).or_default().push(to.clone()); - - for kind in kinds { - edges.push(DependencyEdge { - from: from.clone(), - to: to.clone(), - kind, - }); - } - } - } + let (mut edges, adjacency) = resolved_edges(resolve, config.include_dev, production.as_ref()); let depths = shortest_depths(&members, &adjacency); let direct = direct_dependencies(&members, &adjacency); let member_ids: Vec = members.iter().cloned().collect(); let mut included = reachable_from(&member_ids, &adjacency); included.extend(members.iter().cloned()); + edges.retain(|edge| included.contains(&edge.from) && included.contains(&edge.to)); let mut packages = Vec::new(); for node in &resolve.nodes { @@ -118,7 +105,10 @@ pub fn analyze( is_root_package: package.manifest_path.as_std_path() == root.join("Cargo.toml"), is_direct: direct.contains(&id), kinds: kinds_for(&id, &edges), - features: node.features.iter().map(ToString::to_string).collect(), + features: production.as_ref().map_or_else( + || node.features.iter().map(ToString::to_string).collect(), + |resolution| resolution.features.get(&id).cloned().unwrap_or_default(), + ), available_features: package.features.keys().map(ToString::to_string).collect(), transitive_count: reachable.len(), exclusive_count: exclusive, @@ -160,7 +150,7 @@ pub fn analyze( Ok(DependencyReport { duplicates: find_duplicates(&packages), - unused: find_unused(&metadata, &members, config, references), + unused: find_unused(&metadata, &members, &adjacency, config, references), packages, edges, external_packages, @@ -179,6 +169,122 @@ fn resolved_metadata(root: &Path) -> Result { }) } +/// Resolves the package and feature set Cargo uses for production targets. +fn production_resolution( + root: &Path, + metadata: &cargo_metadata::Metadata, +) -> Result { + let output = Command::new("cargo") + .args([ + "tree", + "--workspace", + "--target", + "all", + "--edges", + "normal,build", + "--prefix", + "none", + "--format", + "{p}|{f}", + "--manifest-path", + ]) + .arg(root.join("Cargo.toml")) + .output() + .map_err(|source| Error::CargoMetadata { + root: root.to_path_buf(), + message: source.to_string(), + })?; + + if !output.status.success() { + return Err(Error::CargoMetadata { + root: root.to_path_buf(), + message: String::from_utf8_lossy(&output.stderr).trim().to_owned(), + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut features_by_key: BTreeMap<(String, String), BTreeSet> = BTreeMap::new(); + for line in stdout.lines() { + let Some((package, features)) = line.split_once('|') else { + continue; + }; + let Some(key) = tree_package_key(package) else { + continue; + }; + features_by_key.entry(key).or_default().extend( + features + .split(',') + .filter(|feature| !feature.is_empty()) + .map(ToOwned::to_owned), + ); + } + + let mut package_ids = BTreeSet::new(); + let mut resolved_features = BTreeMap::new(); + for package in &metadata.packages { + let key = (package.name.to_string(), package.version.to_string()); + let Some(features) = features_by_key.get(&key) else { + continue; + }; + let id = package.id.to_string(); + package_ids.insert(id.clone()); + resolved_features.insert(id, features.iter().cloned().collect()); + } + + Ok(ProductionResolution { + package_ids, + features: resolved_features, + }) +} + +/// Extracts the package name and version from Cargo's controlled tree format. +fn tree_package_key(package: &str) -> Option<(String, String)> { + let mut fields = package.split_whitespace(); + let name = fields.next()?.to_owned(); + let version = fields.next()?.strip_prefix('v')?.to_owned(); + Some((name, version)) +} + +/// Builds the closed edge list and adjacency map for one Cargo resolution. +fn resolved_edges( + resolve: &cargo_metadata::Resolve, + include_dev: bool, + production: Option<&ProductionResolution>, +) -> (Vec, BTreeMap>) { + let mut edges = Vec::new(); + let mut adjacency: BTreeMap> = BTreeMap::new(); + + for node in &resolve.nodes { + let from = node.id.to_string(); + + for dep in &node.deps { + let to = dep.pkg.to_string(); + if production.is_some_and(|resolution| { + !resolution.package_ids.contains(&from) || !resolution.package_ids.contains(&to) + }) { + continue; + } + + let kinds = edge_kinds(dep, include_dev); + if kinds.is_empty() { + continue; + } + + adjacency.entry(from.clone()).or_default().push(to.clone()); + + for kind in kinds { + edges.push(DependencyEdge { + from: from.clone(), + to: to.clone(), + kind, + }); + } + } + } + + (edges, adjacency) +} + /// Measures the checked-out source Cargo would compile for one package. fn package_source_bytes(root: &Path) -> u64 { WalkBuilder::new(root) @@ -373,6 +479,7 @@ fn find_duplicates(packages: &[PackageNode]) -> Vec { fn find_unused( metadata: &cargo_metadata::Metadata, members: &BTreeSet, + adjacency: &BTreeMap>, config: &DependencyConfig, references: &CrateReferences, ) -> Vec { @@ -398,6 +505,10 @@ fn find_unused( }; for dependency in &package.dependencies { + if !dependency_is_resolved(package, dependency, adjacency, metadata) { + continue; + } + let kind = match dependency.kind { cargo_metadata::DependencyKind::Normal => DependencyKind::Normal, cargo_metadata::DependencyKind::Development if config.include_dev => { @@ -434,6 +545,26 @@ fn find_unused( unused } +/// Whether Cargo retained this declaration in the selected dependency graph. +fn dependency_is_resolved( + package: &cargo_metadata::Package, + dependency: &cargo_metadata::Dependency, + adjacency: &BTreeMap>, + metadata: &cargo_metadata::Metadata, +) -> bool { + adjacency + .get(&package.id.to_string()) + .into_iter() + .flatten() + .any(|id| { + metadata.packages.iter().any(|candidate| { + candidate.id.to_string() == *id + && candidate.name == dependency.name + && dependency.req.matches(&candidate.version) + }) + }) +} + /// Folds a manifest crate name into the identifier a `use` statement writes. #[must_use] pub fn normalize_crate_name(name: &str) -> String { diff --git a/crates/tinyanalyzer-core/tests/public_api.rs b/crates/tinyanalyzer-core/tests/public_api.rs index 2596565..e700f43 100644 --- a/crates/tinyanalyzer-core/tests/public_api.rs +++ b/crates/tinyanalyzer-core/tests/public_api.rs @@ -38,13 +38,23 @@ fn workspace() -> TempDir { write( root.path(), "Cargo.toml", - "[workspace]\nresolver = \"3\"\nmembers = [\"crates/*\"]\nexclude = [\"support/dev-tool\", \"support/build-tool\"]\n", + "[workspace]\nresolver = \"3\"\nmembers = [\"crates/*\"]\nexclude = [\"support/dev-tool\", \"support/dev-leaf\", \"support/feature-leaf\", \"support/build-tool\"]\n", ); write( root.path(), "crates/engine/Cargo.toml", - "[package]\nname = \"engine\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + r#"[package] +name = "engine" +version = "0.1.0" +edition = "2021" + +[features] +dev-extra = ["dep:feature-leaf"] + +[dependencies] +feature-leaf = { path = "../../support/feature-leaf", optional = true } +"#, ); write( root.path(), @@ -89,13 +99,40 @@ pub fn hot(values: &[String]) -> Vec { write( root.path(), "support/dev-tool/Cargo.toml", - "[package]\nname = \"dev-tool\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + r#"[package] +name = "dev-tool" +version = "0.1.0" +edition = "2021" + +[dependencies] +dev-leaf = { path = "../dev-leaf" } +"#, ); write( root.path(), "support/dev-tool/src/lib.rs", "//! Used only while developing the fixture.\n\n/// Sets up a test.\npub fn setup() {}\n", ); + write( + root.path(), + "support/dev-leaf/Cargo.toml", + "[package]\nname = \"dev-leaf\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ); + write( + root.path(), + "support/dev-leaf/src/lib.rs", + "//! Reached transitively only through a development dependency.\n", + ); + write( + root.path(), + "support/feature-leaf/Cargo.toml", + "[package]\nname = \"feature-leaf\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ); + write( + root.path(), + "support/feature-leaf/src/lib.rs", + "//! Enabled only by a feature on the development declaration.\n", + ); write( root.path(), "support/build-tool/Cargo.toml", @@ -124,6 +161,7 @@ build-tool = { path = "../../support/build-tool" } [dev-dependencies] dev-tool = { path = "../../support/dev-tool" } +engine = { path = "../engine", features = ["dev-extra"] } "#, ); write( @@ -150,6 +188,15 @@ fn no_cargo() -> Config { } } +fn development_edges(report: &Report) -> usize { + report + .dependencies + .edges + .iter() + .filter(|edge| edge.kind == tinyanalyzer_core::DependencyKind::Development) + .count() +} + #[test] fn it_analyzes_a_real_workspace_end_to_end() { let root = workspace(); @@ -218,36 +265,38 @@ fn it_resolves_the_dependency_graph_of_a_real_workspace() { } #[test] -fn development_dependencies_are_excluded_by_default_and_can_be_included() { +fn development_dependencies_are_excluded_from_the_default_production_graph() { let root = workspace(); let without_dev = analyze(root.path()).expect("a resolvable workspace"); - let config = Config { - dependencies: tinyanalyzer_core::DependencyConfig { - include_dev: true, - ..tinyanalyzer_core::DependencyConfig::default() - }, - ..Config::default() - }; - let with_dev = analyze_with(root.path(), &config).expect("a resolvable workspace"); - - let development = |report: &Report| { - report + assert_eq!(development_edges(&without_dev), 0); + assert!( + without_dev .dependencies - .edges + .packages .iter() - .filter(|edge| edge.kind == tinyanalyzer_core::DependencyKind::Development) - .count() - }; - - assert_eq!(development(&without_dev), 0); + .all(|package| !matches!(package.name.as_str(), "dev-tool" | "dev-leaf")), + "a dev-only subtree must not affect production package metrics" + ); assert!( without_dev .dependencies .packages .iter() - .all(|package| package.name != "dev-tool"), - "a dev-only package must not affect production package metrics" + .all(|package| package.name != "feature-leaf"), + "a feature enabled only by the dev declaration is not production cost" + ); + let production_engine = without_dev + .dependencies + .packages + .iter() + .find(|package| package.name == "engine") + .expect("the normal engine dependency remains"); + assert!( + !production_engine + .features + .iter() + .any(|feature| feature == "dev-extra") ); assert!( without_dev @@ -273,9 +322,37 @@ fn development_dependencies_are_excluded_by_default_and_can_be_included() { .any(|edge| edge.kind == tinyanalyzer_core::DependencyKind::Build), "build edges remain in the production graph" ); + let package_ids: std::collections::BTreeSet<&str> = without_dev + .dependencies + .packages + .iter() + .map(|package| package.id.as_str()) + .collect(); + assert!( + without_dev + .dependencies + .edges + .iter() + .all(|edge| package_ids.contains(edge.from.as_str()) + && package_ids.contains(edge.to.as_str())), + "every returned edge must connect two returned packages" + ); +} + +#[test] +fn development_dependencies_can_be_included_explicitly() { + let root = workspace(); + let config = Config { + dependencies: tinyanalyzer_core::DependencyConfig { + include_dev: true, + ..tinyanalyzer_core::DependencyConfig::default() + }, + ..Config::default() + }; + let with_dev = analyze_with(root.path(), &config).expect("a resolvable workspace"); assert!( - development(&with_dev) > 0, + development_edges(&with_dev) > 0, "the fixture has a dev-dependency" ); assert!( @@ -283,8 +360,28 @@ fn development_dependencies_are_excluded_by_default_and_can_be_included() { .dependencies .packages .iter() - .any(|package| package.name == "dev-tool"), - "opting in restores the dev-only package" + .any(|package| package.name == "dev-leaf"), + "opting in restores the transitive dev-only package" + ); + assert!( + with_dev + .dependencies + .packages + .iter() + .any(|package| package.name == "feature-leaf"), + "opting in restores dependencies activated by development features" + ); + let development_engine = with_dev + .dependencies + .packages + .iter() + .find(|package| package.name == "engine") + .expect("the engine remains present"); + assert!( + development_engine + .features + .iter() + .any(|feature| feature == "dev-extra") ); assert!( with_dev diff --git a/docs/specs/analysis-contract.md b/docs/specs/analysis-contract.md index 1139c1d..8cbbbf6 100644 --- a/docs/specs/analysis-contract.md +++ b/docs/specs/analysis-contract.md @@ -30,8 +30,8 @@ somebody acts on it. - **Item counts, function lengths, parameter counts, nesting depth.** From a real parse, not a regular expression. A `fn` inside a doc comment is not a function. -- **The dependency graph.** From `cargo metadata` — cargo's own resolution, - including features, optional dependencies, platform-specific edges, and +- **The dependency graph.** From Cargo metadata and its production dependency + tree — Cargo's own resolution, including features, optional dependencies, platform-specific edges, and version unification. Development-only edges and packages are excluded by default so this graph describes production cost; `dependencies.include_dev = true` opts into them. Build dependencies remain because production targets