Skip to content

Commit 26b6753

Browse files
martinemdeivyhellosweta
authored
Add json output format option to check command (#24)
* Implement json output format option. Validate against the included schema for check json output * Control exit status for `check` command This follows the convention used by linters like eslint, rubocop, and shellcheck. Builds on clap's existing behavior. All output formats (packwerk, json, csv) behave consistently: - Exit 0 when no violations - Exit 1 when violations found - Exit 2 on internal errors --------- Co-authored-by: Ivy Evans <ivy.evans@gusto.com> Co-authored-by: Sweta Sanghavi <sweta.sanghavi@gusto.com>
1 parent 6c55064 commit 26b6753

10 files changed

Lines changed: 551 additions & 54 deletions

File tree

Cargo.toml

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,34 +24,35 @@ name = "packs"
2424
path = "src/lib.rs"
2525

2626
[dependencies]
27-
anyhow = { version = "1.0.75", features = [] } # for error handling
28-
clap = { version = "4.2.1", features = ["derive"] } # cli
29-
clap_derive = "4.2.0" # cli
30-
csv = "1.3.0" # csv de/serialize
31-
itertools = "0.13.0" # tools for iterating over iterable things
32-
jwalk = "0.8.1" # for walking the file tree
33-
path-clean = "1.0.1" # Pathname#cleaname in Ruby
34-
rayon = "1.7.0" # for parallel iteration
27+
anyhow = { version = "1.0.75", features = [] } # for error handling
28+
clap = { version = "4.2.1", features = ["derive"] } # cli
29+
clap_derive = "4.2.0" # cli
30+
csv = "1.3.0" # csv de/serialize
31+
itertools = "0.13.0" # tools for iterating over iterable things
32+
jwalk = "0.8.1" # for walking the file tree
33+
path-clean = "1.0.1" # Pathname#cleaname in Ruby
34+
rayon = "1.7.0" # for parallel iteration
3535
regex = "1.7.3"
36-
serde = { version = "~1", features = ["derive"] } # de(serialization)
37-
serde_yaml = "0.9.19" # de(serialization)
38-
serde_json = "1.0.96" # de(serialization)
39-
serde_magnus = "0.7.0" # permits a ruby gem to interface with this library
40-
tracing = "0.1.37" # logging
36+
serde = { version = "~1", features = ["derive"] } # de(serialization)
37+
serde_yaml = "0.9.19" # de(serialization)
38+
serde_json = "1.0.96" # de(serialization)
39+
serde_magnus = "0.7.0" # permits a ruby gem to interface with this library
40+
tracing = "0.1.37" # logging
4141
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } # logging
42-
glob = "0.3.1" # globbing
43-
globset = "0.4.10" # globbing
44-
lib-ruby-parser = "4.0.6" # ruby parser
45-
md5 = "0.7.0" # md5 hashing to take and compare md5 digests of file contents to ensure cache validity
46-
line-col = "0.2.1" # for creating source maps of violations
47-
ruby_inflector = '0.0.8' # for inflecting strings, e.g. turning `has_many :companies` into `Company`
48-
petgraph = "0.6.3" # for running graph algorithms (e.g. does the dependency graph contain a cycle?)
42+
glob = "0.3.1" # globbing
43+
globset = "0.4.10" # globbing
44+
lib-ruby-parser = "4.0.6" # ruby parser
45+
md5 = "0.7.0" # md5 hashing to take and compare md5 digests of file contents to ensure cache validity
46+
line-col = "0.2.1" # for creating source maps of violations
47+
ruby_inflector = '0.0.8' # for inflecting strings, e.g. turning `has_many :companies` into `Company`
48+
petgraph = "0.6.3" # for running graph algorithms (e.g. does the dependency graph contain a cycle?)
4949
fnmatch-regex2 = "0.3.0"
5050
strip-ansi-escapes = "0.2.0"
5151

5252
[dev-dependencies]
53-
assert_cmd = "2.1.1" # testing CLI
54-
rusty-hook = "^0.11.2" # git hooks
55-
predicates = "3.0.2" # kind of like rspec assertions
53+
assert_cmd = "2.1.1" # testing CLI
54+
jsonschema = "0.27" # JSON schema validation
55+
rusty-hook = "^0.11.2" # git hooks
56+
predicates = "3.0.2" # kind of like rspec assertions
5657
pretty_assertions = "1.3.0" # Shows a more readable diff when comparing objects
57-
serial_test = "3.1.1" # Run specific tests in serial
58+
serial_test = "3.1.1" # Run specific tests in serial

schema/check-output.json

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "pks check JSON output",
4+
"type": "object",
5+
"required": ["violations", "stale_todos", "summary"],
6+
"additionalProperties": false,
7+
"properties": {
8+
"violations": {
9+
"type": "array",
10+
"items": { "$ref": "#/$defs/Violation" }
11+
},
12+
"stale_todos": {
13+
"type": "array",
14+
"items": { "$ref": "#/$defs/StaleTodo" }
15+
},
16+
"summary": {
17+
"type": "object",
18+
"required": [
19+
"violation_count",
20+
"stale_todo_count",
21+
"strict_violation_count",
22+
"success"
23+
],
24+
"additionalProperties": false,
25+
"properties": {
26+
"violation_count": { "type": "integer", "minimum": 0 },
27+
"stale_todo_count": { "type": "integer", "minimum": 0 },
28+
"strict_violation_count": {
29+
"type": "integer",
30+
"minimum": 0,
31+
"description": "Count of violations where strict=true (subset of violation_count)"
32+
},
33+
"success": { "type": "boolean" }
34+
}
35+
}
36+
},
37+
"$defs": {
38+
"ViolationType": {
39+
"type": "string",
40+
"enum": ["dependency", "privacy", "visibility", "layer", "folder_privacy"]
41+
},
42+
"Violation": {
43+
"type": "object",
44+
"required": [
45+
"violation_type",
46+
"file",
47+
"line",
48+
"column",
49+
"constant_name",
50+
"referencing_pack_name",
51+
"defining_pack_name",
52+
"strict",
53+
"message"
54+
],
55+
"additionalProperties": false,
56+
"properties": {
57+
"violation_type": { "$ref": "#/$defs/ViolationType" },
58+
"file": { "type": "string" },
59+
"line": { "type": "integer", "minimum": 1 },
60+
"column": { "type": "integer", "minimum": 0 },
61+
"constant_name": { "type": "string" },
62+
"referencing_pack_name": { "type": "string" },
63+
"defining_pack_name": { "type": "string" },
64+
"strict": { "type": "boolean" },
65+
"message": { "type": "string" }
66+
}
67+
},
68+
"StaleTodo": {
69+
"type": "object",
70+
"required": [
71+
"violation_type",
72+
"file",
73+
"constant_name",
74+
"referencing_pack_name",
75+
"defining_pack_name"
76+
],
77+
"additionalProperties": false,
78+
"properties": {
79+
"violation_type": { "$ref": "#/$defs/ViolationType" },
80+
"file": { "type": "string" },
81+
"constant_name": { "type": "string" },
82+
"referencing_pack_name": { "type": "string" },
83+
"defining_pack_name": { "type": "string" }
84+
}
85+
}
86+
}
87+
}

src/main.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
use packs::packs::cli;
2+
use std::process::ExitCode;
23

3-
pub fn main() -> anyhow::Result<()> {
4-
cli::run()
4+
pub fn main() -> ExitCode {
5+
match cli::run() {
6+
Ok(()) => ExitCode::SUCCESS,
7+
Err(e) => {
8+
if e.downcast_ref::<cli::ViolationsFound>().is_some() {
9+
// ViolationsFound already printed its output; exit 1 for violations
10+
ExitCode::from(1)
11+
} else {
12+
// Other errors (IO, config, etc.) exit 2; usage errors handled by clap
13+
eprintln!("Error: {e:#}");
14+
ExitCode::from(2)
15+
}
16+
}
17+
}
518
}

src/packs.rs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub(crate) mod creator;
1313
pub(crate) mod csv;
1414
pub(crate) mod dependencies;
1515
pub(crate) mod ignored;
16+
pub(crate) mod json;
1617
pub(crate) mod monkey_patch_detection;
1718
pub(crate) mod pack;
1819
pub(crate) mod parsing;
@@ -40,6 +41,7 @@ pub(crate) use self::parsing::ParsedDefinition;
4041
pub(crate) use self::parsing::UnresolvedReference;
4142
use anyhow::bail;
4243
use cli::OutputFormat;
44+
use cli::ViolationsFound;
4345
pub(crate) use configuration::Configuration;
4446
pub(crate) use package_todo::PackageTodo;
4547

@@ -79,13 +81,17 @@ pub fn check(
7981
match output_format {
8082
OutputFormat::Packwerk => {
8183
println!("{}", result);
82-
if result.has_violations() {
83-
bail!("Violations found!")
84-
}
8584
}
8685
OutputFormat::CSV => {
8786
csv::write_csv(&result, std::io::stdout())?;
8887
}
88+
OutputFormat::JSON => {
89+
json::write_json(&result, std::io::stdout())?;
90+
}
91+
}
92+
93+
if result.has_violations() {
94+
return Err(ViolationsFound.into());
8995
}
9096

9197
Ok(())
@@ -230,10 +236,12 @@ pub struct ProcessedFile {
230236
pub definitions: Vec<ParsedDefinition>,
231237
}
232238

233-
#[derive(Debug, PartialEq, Serialize, Deserialize, Default, Eq, Clone)]
239+
#[derive(
240+
Debug, PartialEq, Serialize, Deserialize, Default, Eq, Clone, Hash,
241+
)]
234242
pub struct SourceLocation {
235-
line: usize,
236-
column: usize,
243+
pub line: usize,
244+
pub column: usize,
237245
}
238246

239247
pub(crate) fn list_definitions(

src/packs/checker.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use crate::packs::pack::write_pack_to_disk;
1616
use crate::packs::pack::Pack;
1717
use crate::packs::package_todo;
1818
use crate::packs::Configuration;
19+
use crate::packs::SourceLocation;
1920

2021
use anyhow::bail;
2122
// External imports
@@ -42,10 +43,20 @@ pub struct ViolationIdentifier {
4243
pub referencing_pack_name: String,
4344
pub defining_pack_name: String,
4445
}
46+
/// A violation combines an identifier with display metadata.
47+
///
48+
/// `source_location` is intentionally separate from `ViolationIdentifier` because:
49+
/// - The identifier defines "sameness" for deduplication and comparison with
50+
/// recorded violations in `package_todo.yml`, which doesn't store line/column
51+
/// - Multiple references to the same constant in the same file are considered
52+
/// one violation, even if they occur at different lines
53+
/// - Keeping line/column out of the identity makes violations stable across
54+
/// minor code movements that shift line numbers
4555
#[derive(PartialEq, Clone, Eq, Hash, Debug)]
4656
pub struct Violation {
4757
pub message: String,
4858
pub identifier: ViolationIdentifier,
59+
pub source_location: SourceLocation,
4960
}
5061

5162
pub(crate) trait CheckerInterface {
@@ -513,6 +524,7 @@ mod tests {
513524
use crate::packs::checker::{
514525
CheckAllResult, Violation, ViolationIdentifier,
515526
};
527+
use crate::packs::SourceLocation;
516528

517529
#[test]
518530
fn test_write_violations() {
@@ -526,7 +538,8 @@ mod tests {
526538
constant_name: "::Foo::PrivateClass".to_string(),
527539
referencing_pack_name: "bar".to_string(),
528540
defining_pack_name: "foo".to_string(),
529-
}
541+
},
542+
source_location: SourceLocation { line: 10, column: 5 },
530543
},
531544
Violation {
532545
message: "foo/bar/file2.rb:15:3\nDependency violation: `::Foo::AnotherClass` is not allowed to depend on `::Bar::SomeClass`".to_string(),
@@ -537,7 +550,8 @@ mod tests {
537550
constant_name: "::Foo::AnotherClass".to_string(),
538551
referencing_pack_name: "foo".to_string(),
539552
defining_pack_name: "bar".to_string(),
540-
}
553+
},
554+
source_location: SourceLocation { line: 15, column: 3 },
541555
}].iter().cloned().collect(),
542556
stale_violations: Vec::new(),
543557
strict_mode_violations: HashSet::new(),

src/packs/checker/common_test.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ pub mod tests {
5454
referencing_pack_name: String::from("packs/foo"),
5555
defining_pack_name: String::from("packs/bar"),
5656
},
57+
source_location: SourceLocation { line: 3, column: 1 },
5758
}
5859
}
5960

src/packs/checker/pack_checker.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ impl<'a> PackChecker<'a> {
161161
Ok(Some(Violation {
162162
message: self.interpolate_violation_message(extra_template_fields),
163163
identifier: self.violation_identifier(),
164+
source_location: self.reference.source_location.clone(),
164165
}))
165166
}
166167

src/packs/cli.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ use tracing::debug;
88

99
use super::logger::install_logger;
1010

11+
/// Error returned when violations are found during check.
12+
///
13+
/// This is a marker type used to distinguish "violations found" (exit 1)
14+
/// from internal errors (exit 2) following linter conventions (eslint, rubocop, shellcheck).
15+
#[derive(Debug)]
16+
pub struct ViolationsFound;
17+
18+
impl std::fmt::Display for ViolationsFound {
19+
fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20+
// Output is already printed; this marker carries no additional message
21+
Ok(())
22+
}
23+
}
24+
25+
impl std::error::Error for ViolationsFound {}
26+
1127
/// A CLI to interact with packs
1228
#[derive(Parser, Debug)]
1329
#[command(author, version, about, long_about = None)]
@@ -159,6 +175,7 @@ enum Command {
159175
pub enum OutputFormat {
160176
Packwerk,
161177
CSV,
178+
JSON,
162179
}
163180

164181
#[derive(Debug, Args)]
@@ -191,9 +208,9 @@ impl Args {
191208

192209
pub fn run() -> anyhow::Result<()> {
193210
let args = Args::parse();
194-
let absolute_root = args
195-
.absolute_project_root()
196-
.expect("Issue getting absolute_project_root!");
211+
let absolute_root = args.absolute_project_root().map_err(|e| {
212+
anyhow::anyhow!("Issue getting absolute_project_root: {}", e)
213+
})?;
197214

198215
install_logger(args.debug);
199216

0 commit comments

Comments
 (0)