From 4e31ef6249e538ff7df621129b41246ccc6ea951 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sun, 1 Nov 2026 17:51:19 +0000 Subject: [PATCH] feat(stack): write the solved dependency DAG as Graphviz DOT stack solve --dot-out and the MCP field dot_out write the solved dependency graph. A node is name/version plus the toolchain and the versionsuffix. An edge carries the requirement, and a builddependency is dashed. A .svg or .png path is rendered with dot. --- .github/workflows/ci_test.yml | 1 + CHANGELOG.md | 6 ++ docs/orgmode/reference/cli.org | 10 +- llms-full.txt | 10 +- src/lib.rs | 3 +- src/main.rs | 17 +++ src/mcp.rs | 4 + src/sbom.rs | 192 +++++++++++++++++++++++++++++++++ tests/solved_dot.rs | 153 ++++++++++++++++++++++++++ 9 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 tests/solved_dot.rs diff --git a/.github/workflows/ci_test.yml b/.github/workflows/ci_test.yml index 3494d5f5..d5264623 100644 --- a/.github/workflows/ci_test.yml +++ b/.github/workflows/ci_test.yml @@ -195,6 +195,7 @@ jobs: --test sbom_page_drift \ --test sbom_schema \ --test skill_easyblock_fix \ + --test solved_dot \ --test solver_completeness \ --test stack_files \ --test stack_order_filter \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1062e0..4694e7ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -387,6 +387,12 @@ All notable changes to this project are documented here. ### Added +- `stack solve --dot-out` and the MCP field `dot_out` write the solved + dependency DAG. A node is `name/version` plus the toolchain, and the + versionsuffix when the recipe has one. An edge points from a package to a + dependency it selected and is labelled with the requirement; a + builddependency is dashed. A `.svg` or `.png` path is rendered with + Graphviz `dot`. Any other suffix is the DOT source. - `stack generation` and `eb_stack_generation` check each added entry's toolchain generation against the generation the easystack file name names, the token after `eb--` such as `-2025b.yml`. An entry whose diff --git a/docs/orgmode/reference/cli.org b/docs/orgmode/reference/cli.org index 2f8be9cd..726e384a 100644 --- a/docs/orgmode/reference/cli.org +++ b/docs/orgmode/reference/cli.org @@ -412,7 +412,8 @@ eb-stack stack solve \ [--build-list-out /tmp/build.list] [--build-list-excludes-baseline] \ [--jenkins-build-list-out /tmp/next.list [--jenkins-flags-from /tmp/previous.list]... \ [--jenkins-gpu-systems gpu-a,gpu-b] [--jenkins-roots-only]] \ - [--stack-diff-out /tmp/stack.diff.md] + [--stack-diff-out /tmp/stack.diff.md] \ + [--dot-out /tmp/stack.dot] eb-stack stack sbom --lock /tmp/stack.lock.json [--out /tmp/stack.cdx.json] @@ -536,6 +537,13 @@ preferences use the TOML schema documented with the package workflow; whole stack solving uses the JSON ~Policy~ schema. The policy file is ~--stack-policy~, the same flag ~package plan~ and ~package bump~ take. +~--dot-out~ writes the solved dependency DAG. A node is ~name/version~ plus +the toolchain, and the versionsuffix when the recipe has one. An edge points +from a package to a dependency it selected and is labelled with the +requirement; a builddependency is dashed. A ~.svg~ or ~.png~ path is rendered +with the Graphviz ~dot~ program. Any other suffix, including ~.dot~, is the +DOT source. The MCP field ~dot_out~ writes the same file. + The baseline is either a previous generation (~--baseline-easyconfigs~, by default the first ~--easyconfigs~ tree) or what a set of easystacks installs (~--baseline-easystack~, repeatable). An easystack baseline is every entry diff --git a/llms-full.txt b/llms-full.txt index 545e8a27..f35a79b6 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4753,7 +4753,8 @@ eb-stack stack solve \ [--build-list-out /tmp/build.list] [--build-list-excludes-baseline] \ [--jenkins-build-list-out /tmp/next.list [--jenkins-flags-from /tmp/previous.list]... \ [--jenkins-gpu-systems gpu-a,gpu-b] [--jenkins-roots-only]] \ - [--stack-diff-out /tmp/stack.diff.md] + [--stack-diff-out /tmp/stack.diff.md] \ + [--dot-out /tmp/stack.dot] eb-stack stack sbom --lock /tmp/stack.lock.json [--out /tmp/stack.cdx.json] @@ -4877,6 +4878,13 @@ preferences use the TOML schema documented with the package workflow; whole stack solving uses the JSON `Policy` schema. The policy file is `--stack-policy`, the same flag `package plan` and `package bump` take. +`--dot-out` writes the solved dependency DAG. A node is `name/version` plus +the toolchain, and the versionsuffix when the recipe has one. An edge points +from a package to a dependency it selected and is labelled with the +requirement; a builddependency is dashed. A `.svg` or `.png` path is rendered +with the Graphviz `dot` program. Any other suffix, including `.dot`, is the +DOT source. The MCP field `dot_out` writes the same file. + The baseline is either a previous generation (`--baseline-easyconfigs`, by default the first `--easyconfigs` tree) or what a set of easystacks installs (`--baseline-easystack`, repeatable). An easystack baseline is every entry diff --git a/src/lib.rs b/src/lib.rs index 2728fe4e..513d38db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -196,7 +196,8 @@ pub use sbom::{ artifact_facts_for_lock, build_dep_map_from_universe, dep_map_from_universe, dep_maps_for_lock, host_build_environment, lock_to_bom, lock_to_bom_with_facts, lock_to_cyclonedx, lock_to_cyclonedx_from_recipes, lock_to_cyclonedx_with_deps, lock_to_cyclonedx_with_facts, - lock_to_cyclonedx_with_runtime_and_build, ArtifactFacts, SbomFacts, SbomReadStats, + lock_to_cyclonedx_with_runtime_and_build, solved_dependency_dot, write_solved_dependency_graph, + ArtifactFacts, SbomFacts, SbomReadStats, }; pub use select::{resolvo_resolve_dep_versions, select_stack, SelectError}; diff --git a/src/main.rs b/src/main.rs index 84a61785..359bc743 100644 --- a/src/main.rs +++ b/src/main.rs @@ -899,6 +899,15 @@ struct SolveArgs { /// Markdown diff of this solve against the baseline. #[arg(long)] stack_diff_out: Option, + /// Graphviz drawing of the solved dependency DAG. + /// + /// A node is `name/version` plus the toolchain, and the versionsuffix + /// when the recipe has one. An edge points from a package to a + /// dependency it selected and is labelled with that requirement; a + /// builddependency is dashed. A `.svg` or `.png` path is rendered with + /// `dot`. Any other suffix is the DOT source. + #[arg(long)] + dot_out: Option, } #[derive(clap::Args, Debug)] @@ -2483,6 +2492,7 @@ fn run_stack(command: StackCommand) -> Result<()> { jenkins_gpu_systems, jenkins_roots_only, stack_diff_out, + dot_out, } = *args; for path in &roots_from_build_list { let text = std::fs::read_to_string(path) @@ -2637,11 +2647,18 @@ fn run_stack(command: StackCommand) -> Result<()> { error } })?; + if let Some(path) = &dot_out { + eb_stack::write_solved_dependency_graph(&lock, &lock_out, path) + .map_err(|error| anyhow::anyhow!(error))?; + } println!( "lock={} packages={}", lock_out.display(), lock.packages.len() ); + if let Some(path) = &dot_out { + println!("dot={}", path.display()); + } Ok(()) } StackCommand::Order { diff --git a/src/mcp.rs b/src/mcp.rs index 014406ae..9aa69148 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -338,6 +338,7 @@ fn tool_catalog() -> Vec { ("jenkins_gpu_systems", "array"), ("jenkins_roots_only", "boolean"), ("stack_diff_out", "string"), + ("dot_out", "string"), ], ), tool_with_optional( @@ -2812,6 +2813,9 @@ fn stack_solve(arguments: &Value) -> Result { }, ) .map_err(|error| error.to_string())?; + if let Some(path) = optional_path(arguments, "dot_out") { + crate::sbom::write_solved_dependency_graph(&lock, &lock_out, &path)?; + } Ok(json!({ "lock": lock_out, "packages": lock.packages.len(), diff --git a/src/sbom.rs b/src/sbom.rs index 4f5dcd76..57799884 100644 --- a/src/sbom.rs +++ b/src/sbom.rs @@ -651,6 +651,198 @@ pub fn closure_edges_for_lock(lock: &StackLock, lock_file: &Path) -> HashMap String { + let lock_dir = lock_directory(lock_file); + let mut nodes: Vec<&crate::domain::LockPackage> = lock.packages.iter().collect(); + nodes.sort_by(|a, b| { + ( + &a.name, + &a.version, + a.versionsuffix.as_deref().unwrap_or(""), + toolchain_text(&a.toolchain), + ) + .cmp(&( + &b.name, + &b.version, + b.versionsuffix.as_deref().unwrap_or(""), + toolchain_text(&b.toolchain), + )) + }); + let mut id_of: HashMap = HashMap::new(); + for (index, package) in nodes.iter().enumerate() { + id_of.insert(package_identity(package), format!("n{index}")); + } + + let mut edges: BTreeMap<(String, String), (String, bool)> = BTreeMap::new(); + for package in &lock.packages { + let Some(resolved) = (!package.easyconfig_path.is_empty()) + .then(|| { + crate::eb_parse::resolve_easyconfig_file(&resolve_lock_easyconfig( + lock_dir, + &package.easyconfig_path, + )) + .ok() + }) + .flatten() + else { + continue; + }; + let candidate = resolved.to_candidate(); + let from = package_identity(package); + for (dep, runtime) in candidate + .dependencies + .iter() + .map(|dep| (dep, true)) + .chain(candidate.builddependencies.iter().map(|dep| (dep, false))) + { + let Some(target) = resolve_locked_dep(lock, package, dep) else { + continue; + }; + let key = (from.clone(), package_identity(target)); + let label = dependency_edge_label(dep); + if let Some(drawn) = edges.get_mut(&key) { + if runtime { + *drawn = (label, true); + } + } else { + edges.insert(key, (label, runtime)); + } + } + } + + let mut out = String::from("digraph solved {\n"); + out.push_str(" rankdir=TB;\n"); + out.push_str(" graph [fontname=\"Helvetica\"];\n"); + out.push_str(" node [shape=box, fontname=\"Helvetica\", fontsize=11];\n"); + out.push_str(" edge [fontname=\"Helvetica\", fontsize=9];\n"); + for package in &nodes { + let id = &id_of[&package_identity(package)]; + let version = format!( + "{}{}", + package.version, + package.versionsuffix.as_deref().unwrap_or("") + ); + // Graphviz `\n`, written as two characters so a node stays one source line. + let label = format!( + "{}\\n{}", + dot_escape(&format!("{}/{}", package.name, version)), + dot_escape(&toolchain_text(&package.toolchain)) + ); + out.push_str(&format!(" {id} [label=\"{label}\"];\n")); + } + for ((from, to), (label, runtime)) in &edges { + let Some(from_id) = id_of.get(from) else { + continue; + }; + let Some(to_id) = id_of.get(to) else { + continue; + }; + let style = if *runtime { "" } else { ", style=dashed" }; + out.push_str(&format!( + " {from_id} -> {to_id} [label=\"{}\", color=\"#004D40\"{style}];\n", + dot_escape(label) + )); + } + out.push_str("}\n"); + out +} + +/// Write the solved dependency DAG. +/// +/// `.svg` and `.png` are rendered with the Graphviz `dot` program. Any other +/// suffix, including `.dot`, is the DOT source. +pub fn write_solved_dependency_graph( + lock: &StackLock, + lock_file: &Path, + out: &Path, +) -> Result<(), String> { + let dot = solved_dependency_dot(lock, lock_file); + if let Some(parent) = out.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("create {}: {error}", parent.display()))?; + } + } + let kind = out + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if kind == "svg" || kind == "png" { + let mut child = std::process::Command::new("dot") + .arg(format!("-T{kind}")) + .arg("-o") + .arg(out) + .stdin(std::process::Stdio::piped()) + .spawn() + .map_err(|error| { + format!( + "rendering {} needs the graphviz dot program: {error}", + out.display() + ) + })?; + let stdin = child.stdin.as_mut().ok_or("dot stdin")?; + std::io::Write::write_all(stdin, dot.as_bytes()) + .map_err(|error| format!("write dot source: {error}"))?; + // Drop stdin before waiting so dot sees EOF. + child.stdin.take(); + let status = child + .wait() + .map_err(|error| format!("wait for dot: {error}"))?; + if !status.success() { + return Err(format!("dot exited {status}")); + } + return Ok(()); + } + std::fs::write(out, dot).map_err(|error| format!("write {}: {error}", out.display()))?; + Ok(()) +} + +fn toolchain_text(toolchain: &crate::domain::Toolchain) -> String { + if crate::hierarchy::is_system_toolchain(toolchain) { + "system".to_string() + } else { + format!("{}-{}", toolchain.name, toolchain.version) + } +} + +fn dependency_edge_label(dep: &crate::domain::DepReq) -> String { + let mut label = dep.version_req.clone(); + if let Some(suffix) = dep.versionsuffix.as_deref() { + if !suffix.is_empty() { + label.push_str(" suffix "); + label.push_str(suffix); + } + } + if let Some(toolchain) = &dep.toolchain { + label.push_str(" toolchain "); + if crate::hierarchy::is_system_toolchain(toolchain) { + label.push_str("system"); + } else { + label.push_str(&toolchain.name); + label.push('/'); + label.push_str(&toolchain.version); + } + } + label +} + +fn dot_escape(text: &str) -> String { + text.replace('\\', "\\\\").replace('"', "\\\"") +} + /// How many recipes a lock SBOM could read. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct SbomReadStats { diff --git a/tests/solved_dot.rs b/tests/solved_dot.rs new file mode 100644 index 00000000..bf0c35c5 --- /dev/null +++ b/tests/solved_dot.rs @@ -0,0 +1,153 @@ +//! The solved dependency DAG is the lock's recipes, not a second solver. + +use std::fs; +use std::path::Path; +use std::process::Command; + +use eb_stack::{ + solved_dependency_dot, write_solved_dependency_graph, LockPackage, SolverMeta, StackLock, + Toolchain, +}; + +fn toolchain(name: &str, version: &str) -> Toolchain { + Toolchain { + name: name.into(), + version: version.into(), + } +} + +fn package( + name: &str, + version: &str, + toolchain: Toolchain, + versionsuffix: Option<&str>, + path: &str, +) -> LockPackage { + LockPackage { + name: name.into(), + version: version.into(), + toolchain, + versionsuffix: versionsuffix.map(str::to_string), + easyconfig_path: path.into(), + } +} + +fn write(dir: &Path, name: &str, body: &str) { + fs::write(dir.join(name), body).unwrap(); +} + +#[test] +fn solved_dot_names_versions_toolchains_and_requirements() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + write( + dir, + "App-1.2-foss-2025a.eb", + "\ +name = 'App' +version = '1.2' +toolchain = {'name': 'foss', 'version': '2025a'} +dependencies = [ + ('Lib', '>=1.4,<1.9', '-fb', ('GCC', '14.2.0')), +] +builddependencies = [ + ('CMake', '3.30.0'), +] +", + ); + write( + dir, + "Lib-1.5-GCC-14.2.0-fb.eb", + "\ +name = 'Lib' +version = '1.5' +versionsuffix = '-fb' +toolchain = {'name': 'GCC', 'version': '14.2.0'} +dependencies = [] +", + ); + write( + dir, + "CMake-3.30.0-GCCcore-14.2.0.eb", + "\ +name = 'CMake' +version = '3.30.0' +toolchain = {'name': 'GCCcore', 'version': '14.2.0'} +dependencies = [] +", + ); + let lock = StackLock { + schema_version: 1, + toolchain: toolchain("foss", "2025a"), + generation_label: None, + packages: vec![ + package( + "App", + "1.2", + toolchain("foss", "2025a"), + None, + "App-1.2-foss-2025a.eb", + ), + package( + "Lib", + "1.5", + toolchain("GCC", "14.2.0"), + Some("-fb"), + "Lib-1.5-GCC-14.2.0-fb.eb", + ), + package( + "CMake", + "3.30.0", + toolchain("GCCcore", "14.2.0"), + None, + "CMake-3.30.0-GCCcore-14.2.0.eb", + ), + ], + solver: SolverMeta { + engine: "resolvo_cdcl_sat".into(), + engine_version: "test".into(), + timestamp: "STABLE".into(), + criteria: Vec::new(), + }, + }; + let lock_path = dir.join("stack.lock.json"); + fs::write(&lock_path, serde_json::to_string(&lock).unwrap()).unwrap(); + let dot = solved_dependency_dot(&lock, &lock_path); + assert!(dot.contains("label=\"App/1.2\\nfoss-2025a\""), "{dot}"); + assert!(dot.contains("label=\"Lib/1.5-fb\\nGCC-14.2.0\""), "{dot}"); + assert!( + dot.contains("label=\"CMake/3.30.0\\nGCCcore-14.2.0\""), + "{dot}" + ); + assert!( + dot.contains("label=\">=1.4,<1.9 suffix -fb toolchain GCC/14.2.0\""), + "{dot}" + ); + assert!( + dot.contains("label=\"==3.30.0\", color=\"#004D40\", style=dashed"), + "{dot}" + ); + let lib = dot + .lines() + .find(|line| line.contains("Lib/1.5-fb")) + .unwrap(); + let cmake = dot + .lines() + .find(|line| line.contains("CMake/3.30.0")) + .unwrap(); + let lib_id = lib.split_whitespace().next().unwrap(); + let cmake_id = cmake.split_whitespace().next().unwrap(); + assert!(dot.contains(&format!("-> {lib_id} [label=\">=1.4,<1.9"))); + assert!(dot.contains(&format!("-> {cmake_id} [label=\"==3.30.0\""))); + + let dot_path = dir.join("stack.dot"); + write_solved_dependency_graph(&lock, &lock_path, &dot_path).unwrap(); + assert_eq!(fs::read_to_string(&dot_path).unwrap(), dot); + + if Command::new("dot").arg("-V").output().is_ok() { + let png = dir.join("stack.png"); + write_solved_dependency_graph(&lock, &lock_path, &png).unwrap(); + let bytes = fs::read(&png).unwrap(); + assert!(bytes.starts_with(b"\x89PNG"), "png magic"); + } +}