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.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..81e58b4 --- /dev/null +++ b/src/packs/graph.rs @@ -0,0 +1,202 @@ +//! 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 { + 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`, tagged by how the dependency is expressed. +#[derive(Serialize, Debug, PartialEq, Eq)] +struct GraphEdge { + from: String, + to: String, + kind: EdgeKind, +} + +#[derive(Serialize, Debug, PartialEq, Eq)] +struct Graph { + nodes: Vec, + edges: Vec, +} + +/// 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 + .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: EdgeKind::Declared, + }); + } + for to in &pack.ignored_dependencies { + edges.push(GraphEdge { + from: pack.name.clone(), + to: to.clone(), + 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: EdgeKind::Todo, + }); + } + } + 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 } +} + +/// 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<()> { + write_graph(configuration, std::io::stdout()) +} + +#[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() + } + + 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"); + assert_eq!( + json_bytes(&configuration), + json_bytes(&configuration), + "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, EdgeKind)> = 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. + assert!( + graph.edges.iter().any(|e| e.from == "packs/foo" + && e.to == "packs/baz" + && e.kind == EdgeKind::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. + assert!( + graph.edges.iter().any(|e| e.from == "packs/foo" + && e.to == "packs/bar" + && e.kind == EdgeKind::Todo), + "expected todo edge packs/foo -> packs/bar" + ); + } +} diff --git a/tests/graph_test.rs b/tests/graph_test.rs new file mode 100644 index 0000000..7d70059 --- /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(()) +}