From f4e63ad4bf55adc809545f688bc65a4086919e58 Mon Sep 17 00:00:00 2001 From: Ben Lee Date: Wed, 12 Aug 2026 14:59:41 -0700 Subject: [PATCH 1/3] feat: add `pks graph` subcommand printing the dependency graph as JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only `pks graph` command that serializes the whole-repo pack dependency graph — data pks already parses — as deterministic JSON: nodes (name + optional layer/owner) and directed edges tagged by kind (`declared`, `ignored`, `todo`). Fully ordered (nodes by name, edges by from/to/kind) so repeated runs on unchanged config are byte-identical. No analysis (no cycle/SCC detection or simulation) — that is left to downstream tools consuming the raw graph. No new dependencies (reuses serde/serde_json). Co-Authored-By: Claude Opus 4.8 --- src/packs.rs | 5 ++ src/packs/cli.rs | 6 ++ src/packs/graph.rs | 181 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 src/packs/graph.rs diff --git a/src/packs.rs b/src/packs.rs index 128a4bb..ddda0b2 100644 --- a/src/packs.rs +++ b/src/packs.rs @@ -12,6 +12,7 @@ pub(crate) mod constant_resolver; pub(crate) mod creator; pub(crate) mod csv; pub(crate) mod dependencies; +pub(crate) mod graph; pub(crate) mod ignored; pub(crate) mod json; pub(crate) mod monkey_patch_detection; @@ -183,6 +184,10 @@ pub fn validate(configuration: &Configuration) -> anyhow::Result<()> { checker::validate_all(configuration) } +pub fn dump_graph(configuration: &Configuration) -> anyhow::Result<()> { + graph::dump(configuration) +} + pub fn configuration(project_root: PathBuf) -> anyhow::Result { let absolute_root = project_root.canonicalize()?; configuration::get(&absolute_root) diff --git a/src/packs/cli.rs b/src/packs/cli.rs index 7501180..c1cedc2 100644 --- a/src/packs/cli.rs +++ b/src/packs/cli.rs @@ -173,6 +173,11 @@ enum Command { about = "List the constants that packs sees and where it sees them (for debugging purposes)" )] ListDefinitions(ListDefinitionsArgs), + + #[clap( + about = "Print the pack dependency graph as deterministic JSON (nodes + declared/ignored/todo edges)" + )] + Graph, } #[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] @@ -334,5 +339,6 @@ pub fn run() -> anyhow::Result<()> { packs::lint_package_yml_files(&configuration) } Command::Create { name } => packs::create(&configuration, name), + Command::Graph => packs::dump_graph(&configuration), } } diff --git a/src/packs/graph.rs b/src/packs/graph.rs new file mode 100644 index 0000000..de0406a --- /dev/null +++ b/src/packs/graph.rs @@ -0,0 +1,181 @@ +use super::Configuration; +use serde::Serialize; + +/// A single pack (node) in the dependency graph. +#[derive(Serialize, Debug, PartialEq, Eq)] +struct GraphNode { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + layer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + owner: Option, +} + +/// A directed edge `from -> to`. `kind` records how the dependency is expressed +/// in the source pack's configuration: +/// - `declared`: listed under `dependencies:` in package.yml +/// - `ignored`: listed under `ignored_dependencies:` in package.yml +/// - `todo`: a recorded violation in the source pack's package_todo.yml +/// +/// This is raw, uninterpreted output: no cycle detection, SCC decomposition, or +/// simulation is performed here — downstream tools compute those from the graph. +#[derive(Serialize, Debug, PartialEq, Eq)] +struct GraphEdge { + from: String, + to: String, + kind: String, +} + +#[derive(Serialize, Debug, PartialEq, Eq)] +struct Graph { + nodes: Vec, + edges: Vec, +} + +/// Build the whole-repo pack dependency graph from the already-parsed pack set. +/// +/// Output is fully ordered — nodes by `name`, edges by `(from, to, kind)` — so +/// that two runs against the same code produce byte-identical output (stable hash), +/// independent of the parsed collections' iteration order. +fn build(configuration: &Configuration) -> Graph { + let mut nodes: Vec = configuration + .pack_set + .packs + .iter() + .map(|pack| GraphNode { + name: pack.name.clone(), + layer: pack.layer.clone(), + owner: pack.owner.clone(), + }) + .collect(); + nodes.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut edges: Vec = Vec::new(); + for pack in &configuration.pack_set.packs { + for to in &pack.dependencies { + edges.push(GraphEdge { + from: pack.name.clone(), + to: to.clone(), + kind: "declared".to_owned(), + }); + } + for to in &pack.ignored_dependencies { + edges.push(GraphEdge { + from: pack.name.clone(), + to: to.clone(), + kind: "ignored".to_owned(), + }); + } + for to in pack.package_todo.violations_by_defining_pack.keys() { + edges.push(GraphEdge { + from: pack.name.clone(), + to: to.clone(), + kind: "todo".to_owned(), + }); + } + } + edges.sort_by(|a, b| { + a.from + .cmp(&b.from) + .then_with(|| a.to.cmp(&b.to)) + .then_with(|| a.kind.cmp(&b.kind)) + }); + + Graph { nodes, edges } +} + +fn to_json(graph: &Graph) -> anyhow::Result { + Ok(serde_json::to_string_pretty(graph)?) +} + +/// Print the pack dependency graph as deterministic JSON to stdout. +pub(crate) fn dump(configuration: &Configuration) -> anyhow::Result<()> { + println!("{}", to_json(&build(configuration))?); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packs::configuration; + use std::path::PathBuf; + + fn config_for(fixture: &str) -> Configuration { + configuration::get( + PathBuf::from(fixture) + .canonicalize() + .expect("Could not canonicalize path") + .as_path(), + ) + .unwrap() + } + + #[test] + fn graph_output_is_deterministic() { + let configuration = config_for("tests/fixtures/simple_app"); + let first = to_json(&build(&configuration)).unwrap(); + let second = to_json(&build(&configuration)).unwrap(); + assert_eq!( + first, second, + "graph JSON must be byte-identical across runs" + ); + } + + #[test] + fn nodes_and_edges_are_ordered() { + let configuration = config_for("tests/fixtures/simple_app"); + let graph = build(&configuration); + + let node_names: Vec<&String> = + graph.nodes.iter().map(|n| &n.name).collect(); + let mut sorted_names = node_names.clone(); + sorted_names.sort(); + assert_eq!(node_names, sorted_names, "nodes must be ordered by name"); + + let edge_keys: Vec<(&String, &String, &String)> = graph + .edges + .iter() + .map(|e| (&e.from, &e.to, &e.kind)) + .collect(); + let mut sorted_keys = edge_keys.clone(); + sorted_keys.sort(); + assert_eq!( + edge_keys, sorted_keys, + "edges must be ordered by (from, to, kind)" + ); + } + + #[test] + fn includes_declared_edges_and_nodes() { + let configuration = config_for("tests/fixtures/simple_app"); + let graph = build(&configuration); + + assert!( + graph.nodes.iter().any(|n| n.name == "packs/foo"), + "expected a node for packs/foo" + ); + // In simple_app, packs/foo declares a dependency on packs/baz + // (mirrors dependencies.rs::find_explicit_dependencies). + assert!( + graph.edges.iter().any(|e| e.from == "packs/foo" + && e.to == "packs/baz" + && e.kind == "declared"), + "expected declared edge packs/foo -> packs/baz" + ); + } + + #[test] + fn includes_todo_edges() { + let configuration = config_for("tests/fixtures/contains_package_todo"); + let graph = build(&configuration); + + // packs/foo records a violation whose defining pack is packs/bar + // (mirrors dependencies.rs::find_implicit_dependencies). + assert!( + graph.edges.iter().any(|e| e.from == "packs/foo" + && e.to == "packs/bar" + && e.kind == "todo"), + "expected todo edge packs/foo -> packs/bar" + ); + } +} From bba898a74c2115394e29ecb94cfff1a960a9cb3b Mon Sep 17 00:00:00 2001 From: Ben Lee Date: Wed, 12 Aug 2026 15:45:12 -0700 Subject: [PATCH 2/3] test: add `pks graph` CLI integration tests End-to-end coverage mirroring the other subcommands' tests/*_test.rs: asserts declared, todo, and ignored edges over the simple_app / contains_package_todo / app_with_ignored_dependency fixtures, plus CLI-level determinism (two runs => byte-identical stdout). Co-Authored-By: Claude Opus 4.8 --- tests/graph_test.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/graph_test.rs diff --git a/tests/graph_test.rs b/tests/graph_test.rs new file mode 100644 index 0000000..bd676ee --- /dev/null +++ b/tests/graph_test.rs @@ -0,0 +1,81 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::prelude::*; +use std::error::Error; + +mod common; + +#[test] +fn graph_outputs_declared_edges_and_nodes() -> Result<(), Box> { + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/simple_app") + .arg("graph") + .assert() + .success() + .stdout(predicate::str::contains("\"nodes\"")) + .stdout(predicate::str::contains("\"name\": \"packs/foo\"")) + .stdout(predicate::str::contains("\"from\": \"packs/foo\"")) + .stdout(predicate::str::contains("\"to\": \"packs/baz\"")) + .stdout(predicate::str::contains("\"kind\": \"declared\"")); + + common::teardown(); + Ok(()) +} + +#[test] +fn graph_outputs_todo_edges() -> Result<(), Box> { + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/contains_package_todo") + .arg("graph") + .assert() + .success() + .stdout(predicate::str::contains("\"from\": \"packs/foo\"")) + .stdout(predicate::str::contains("\"to\": \"packs/bar\"")) + .stdout(predicate::str::contains("\"kind\": \"todo\"")); + + common::teardown(); + Ok(()) +} + +#[test] +fn graph_outputs_ignored_edges() -> Result<(), Box> { + // In app_with_ignored_dependency, packs/foo declares packs/baz and ignores packs/bar. + cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/app_with_ignored_dependency") + .arg("graph") + .assert() + .success() + .stdout(predicate::str::contains("\"to\": \"packs/bar\"")) + .stdout(predicate::str::contains("\"kind\": \"ignored\"")) + .stdout(predicate::str::contains("\"kind\": \"declared\"")); + + common::teardown(); + Ok(()) +} + +#[test] +fn graph_cli_output_is_deterministic() -> Result<(), Box> { + let first = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/simple_app") + .arg("graph") + .assert() + .success(); + let second = cargo_bin_cmd!("pks") + .arg("--project-root") + .arg("tests/fixtures/simple_app") + .arg("graph") + .assert() + .success(); + + assert_eq!( + first.get_output().stdout, + second.get_output().stdout, + "`pks graph` stdout must be byte-identical across runs" + ); + + common::teardown(); + Ok(()) +} From 626799319c559beec3dbe0a050e37c6648ab1f75 Mon Sep 17 00:00:00 2001 From: Ben Lee Date: Wed, 12 Aug 2026 16:10:15 -0700 Subject: [PATCH 3/3] refactor(graph): align `pks graph` output with pks JSON conventions Match the conventions MOD-122 established for `pks check -o json`: - Serialize compact via serde_json::to_writer (was pretty) - Model edge kind as an EdgeKind enum (declared/ignored/todo) - Add schema/graph-output.json (draft-07), mirroring schema/check-output.json (additionalProperties:false, required lists, $defs + $ref, enum) - Extract write_graph for a testable writer boundary; doc-comment references the schema file (like json.rs) Determinism preserved (ordered nodes/edges); tests updated for compact output. Co-Authored-By: Claude Opus 4.8 --- schema/graph-output.json | 59 ++++++++++++++++++++++++++++ src/packs/graph.rs | 85 +++++++++++++++++++++++++--------------- tests/graph_test.rs | 20 +++++----- 3 files changed, 122 insertions(+), 42 deletions(-) create mode 100644 schema/graph-output.json diff --git a/schema/graph-output.json b/schema/graph-output.json new file mode 100644 index 0000000..e6d899f --- /dev/null +++ b/schema/graph-output.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "pks graph JSON output", + "type": "object", + "required": ["nodes", "edges"], + "additionalProperties": false, + "properties": { + "nodes": { + "type": "array", + "items": { "$ref": "#/$defs/Node" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/Edge" } + } + }, + "$defs": { + "EdgeKind": { + "type": "string", + "enum": ["declared", "ignored", "todo"], + "description": "How the dependency is expressed in the source pack's config: `declared` (dependencies:), `ignored` (ignored_dependencies:), or `todo` (a recorded violation in package_todo.yml)." + }, + "Node": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Pack name (path relative to the project root)." + }, + "layer": { + "type": "string", + "description": "The pack's architecture layer, if configured." + }, + "owner": { + "type": "string", + "description": "The pack's owner, if configured." + } + } + }, + "Edge": { + "type": "object", + "required": ["from", "to", "kind"], + "additionalProperties": false, + "properties": { + "from": { + "type": "string", + "description": "Source pack name (the depending pack)." + }, + "to": { + "type": "string", + "description": "Target pack name (the depended-on pack)." + }, + "kind": { "$ref": "#/$defs/EdgeKind" } + } + } + } +} diff --git a/src/packs/graph.rs b/src/packs/graph.rs index de0406a..81e58b4 100644 --- a/src/packs/graph.rs +++ b/src/packs/graph.rs @@ -1,6 +1,29 @@ +//! JSON output for `pks graph`. +//! +//! Serializes the whole-repo pack dependency graph (nodes + declared/ignored/todo +//! edges) to JSON. Output is fully ordered — nodes by `name`, edges by +//! `(from, to, kind)` — so repeated runs on unchanged config produce byte-identical +//! output (stable hash). This is raw, uninterpreted output: no cycle detection, SCC +//! decomposition, or simulation is performed here — downstream tools compute those +//! from the graph. +//! +//! See `schema/graph-output.json` for the JSON Schema specification. + use super::Configuration; use serde::Serialize; +/// How a dependency edge is expressed in the source pack's configuration. +#[derive(Serialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum EdgeKind { + /// Listed under `dependencies:` in package.yml. + Declared, + /// Listed under `ignored_dependencies:` in package.yml. + Ignored, + /// A recorded violation in the source pack's package_todo.yml. + Todo, +} + /// A single pack (node) in the dependency graph. #[derive(Serialize, Debug, PartialEq, Eq)] struct GraphNode { @@ -11,19 +34,12 @@ struct GraphNode { owner: Option, } -/// A directed edge `from -> to`. `kind` records how the dependency is expressed -/// in the source pack's configuration: -/// - `declared`: listed under `dependencies:` in package.yml -/// - `ignored`: listed under `ignored_dependencies:` in package.yml -/// - `todo`: a recorded violation in the source pack's package_todo.yml -/// -/// This is raw, uninterpreted output: no cycle detection, SCC decomposition, or -/// simulation is performed here — downstream tools compute those from the graph. +/// A directed edge `from -> to`, tagged by how the dependency is expressed. #[derive(Serialize, Debug, PartialEq, Eq)] struct GraphEdge { from: String, to: String, - kind: String, + kind: EdgeKind, } #[derive(Serialize, Debug, PartialEq, Eq)] @@ -32,11 +48,8 @@ struct Graph { edges: Vec, } -/// Build the whole-repo pack dependency graph from the already-parsed pack set. -/// -/// Output is fully ordered — nodes by `name`, edges by `(from, to, kind)` — so -/// that two runs against the same code produce byte-identical output (stable hash), -/// independent of the parsed collections' iteration order. +/// Build the whole-repo pack dependency graph from the already-parsed pack set, +/// fully ordered for deterministic output. fn build(configuration: &Configuration) -> Graph { let mut nodes: Vec = configuration .pack_set @@ -56,21 +69,21 @@ fn build(configuration: &Configuration) -> Graph { edges.push(GraphEdge { from: pack.name.clone(), to: to.clone(), - kind: "declared".to_owned(), + kind: EdgeKind::Declared, }); } for to in &pack.ignored_dependencies { edges.push(GraphEdge { from: pack.name.clone(), to: to.clone(), - kind: "ignored".to_owned(), + kind: EdgeKind::Ignored, }); } for to in pack.package_todo.violations_by_defining_pack.keys() { edges.push(GraphEdge { from: pack.name.clone(), to: to.clone(), - kind: "todo".to_owned(), + kind: EdgeKind::Todo, }); } } @@ -84,14 +97,19 @@ fn build(configuration: &Configuration) -> Graph { Graph { nodes, edges } } -fn to_json(graph: &Graph) -> anyhow::Result { - Ok(serde_json::to_string_pretty(graph)?) +/// Write the pack dependency graph as compact JSON to `writer`. +fn write_graph( + configuration: &Configuration, + writer: W, +) -> anyhow::Result<()> { + // Compact, raw structured data (matches `pks check -o json`); consumers format as needed. + serde_json::to_writer(writer, &build(configuration))?; + Ok(()) } /// Print the pack dependency graph as deterministic JSON to stdout. pub(crate) fn dump(configuration: &Configuration) -> anyhow::Result<()> { - println!("{}", to_json(&build(configuration))?); - Ok(()) + write_graph(configuration, std::io::stdout()) } #[cfg(test)] @@ -110,13 +128,18 @@ mod tests { .unwrap() } + fn json_bytes(configuration: &Configuration) -> Vec { + let mut buf = Vec::new(); + write_graph(configuration, &mut buf).unwrap(); + buf + } + #[test] fn graph_output_is_deterministic() { let configuration = config_for("tests/fixtures/simple_app"); - let first = to_json(&build(&configuration)).unwrap(); - let second = to_json(&build(&configuration)).unwrap(); assert_eq!( - first, second, + json_bytes(&configuration), + json_bytes(&configuration), "graph JSON must be byte-identical across runs" ); } @@ -132,10 +155,10 @@ mod tests { sorted_names.sort(); assert_eq!(node_names, sorted_names, "nodes must be ordered by name"); - let edge_keys: Vec<(&String, &String, &String)> = graph + let edge_keys: Vec<(&String, &String, EdgeKind)> = graph .edges .iter() - .map(|e| (&e.from, &e.to, &e.kind)) + .map(|e| (&e.from, &e.to, e.kind)) .collect(); let mut sorted_keys = edge_keys.clone(); sorted_keys.sort(); @@ -154,12 +177,11 @@ mod tests { graph.nodes.iter().any(|n| n.name == "packs/foo"), "expected a node for packs/foo" ); - // In simple_app, packs/foo declares a dependency on packs/baz - // (mirrors dependencies.rs::find_explicit_dependencies). + // In simple_app, packs/foo declares a dependency on packs/baz. assert!( graph.edges.iter().any(|e| e.from == "packs/foo" && e.to == "packs/baz" - && e.kind == "declared"), + && e.kind == EdgeKind::Declared), "expected declared edge packs/foo -> packs/baz" ); } @@ -169,12 +191,11 @@ mod tests { let configuration = config_for("tests/fixtures/contains_package_todo"); let graph = build(&configuration); - // packs/foo records a violation whose defining pack is packs/bar - // (mirrors dependencies.rs::find_implicit_dependencies). + // packs/foo records a violation whose defining pack is packs/bar. assert!( graph.edges.iter().any(|e| e.from == "packs/foo" && e.to == "packs/bar" - && e.kind == "todo"), + && e.kind == EdgeKind::Todo), "expected todo edge packs/foo -> packs/bar" ); } diff --git a/tests/graph_test.rs b/tests/graph_test.rs index bd676ee..7d70059 100644 --- a/tests/graph_test.rs +++ b/tests/graph_test.rs @@ -13,10 +13,10 @@ fn graph_outputs_declared_edges_and_nodes() -> Result<(), Box> { .assert() .success() .stdout(predicate::str::contains("\"nodes\"")) - .stdout(predicate::str::contains("\"name\": \"packs/foo\"")) - .stdout(predicate::str::contains("\"from\": \"packs/foo\"")) - .stdout(predicate::str::contains("\"to\": \"packs/baz\"")) - .stdout(predicate::str::contains("\"kind\": \"declared\"")); + .stdout(predicate::str::contains("\"name\":\"packs/foo\"")) + .stdout(predicate::str::contains("\"from\":\"packs/foo\"")) + .stdout(predicate::str::contains("\"to\":\"packs/baz\"")) + .stdout(predicate::str::contains("\"kind\":\"declared\"")); common::teardown(); Ok(()) @@ -30,9 +30,9 @@ fn graph_outputs_todo_edges() -> Result<(), Box> { .arg("graph") .assert() .success() - .stdout(predicate::str::contains("\"from\": \"packs/foo\"")) - .stdout(predicate::str::contains("\"to\": \"packs/bar\"")) - .stdout(predicate::str::contains("\"kind\": \"todo\"")); + .stdout(predicate::str::contains("\"from\":\"packs/foo\"")) + .stdout(predicate::str::contains("\"to\":\"packs/bar\"")) + .stdout(predicate::str::contains("\"kind\":\"todo\"")); common::teardown(); Ok(()) @@ -47,9 +47,9 @@ fn graph_outputs_ignored_edges() -> Result<(), Box> { .arg("graph") .assert() .success() - .stdout(predicate::str::contains("\"to\": \"packs/bar\"")) - .stdout(predicate::str::contains("\"kind\": \"ignored\"")) - .stdout(predicate::str::contains("\"kind\": \"declared\"")); + .stdout(predicate::str::contains("\"to\":\"packs/bar\"")) + .stdout(predicate::str::contains("\"kind\":\"ignored\"")) + .stdout(predicate::str::contains("\"kind\":\"declared\"")); common::teardown(); Ok(())