Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions schema/graph-output.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
}
5 changes: 5 additions & 0 deletions src/packs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Configuration> {
let absolute_root = project_root.canonicalize()?;
configuration::get(&absolute_root)
Expand Down
6 changes: 6 additions & 0 deletions src/packs/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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),
}
}
202 changes: 202 additions & 0 deletions src/packs/graph.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
owner: Option<String>,
}

/// 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<GraphNode>,
edges: Vec<GraphEdge>,
}

/// 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<GraphNode> = 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<GraphEdge> = 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<W: std::io::Write>(
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(())

@perryqh perryqh Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serde_json::to_writer emits no trailing newline. All other pks text output ends with \n, and a missing newline causes the shell prompt to render on the same line in some terminals. Consider:

let json = serde_json::to_string(&build(configuration))?;
writeln!(writer, "{json}")?;

or use to_writer_pretty if pretty-printing is ever desired — though compact is fine for machine consumption.

}

/// 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<u8> {
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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is a tautology: both calls run in the same process on the same Configuration object. Rust's HashMap randomizes iteration order per process startup (via RandomState), not per call, so the two json_bytes calls will always agree within one run — regardless of whether the sort is present. The test passes even if you strip every sort_by call from build().

The integration test graph_cli_output_is_deterministic in tests/graph_test.rs is the real check (separate processes, separate seeds). Consider dropping this unit test or replacing it with one that builds two separate Configuration instances from the same fixture path.

);
}

#[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"
);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test covers layer and owner appearing in node output when set. If a future refactor accidentally drops those fields or changes the skip_serializing_if predicate, nothing here would catch it. Consider adding a test that calls build() against a fixture that has a pack with layer or owner configured and asserts the node's fields are Some(...).

81 changes: 81 additions & 0 deletions tests/graph_test.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
// 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<dyn Error>> {
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(())
}