clispec 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {clispec-0.2.2 → clispec-0.2.3}/CHANGELOG.md +7 -0
  2. {clispec-0.2.2 → clispec-0.2.3}/Cargo.lock +1 -1
  3. {clispec-0.2.2 → clispec-0.2.3}/Cargo.toml +1 -1
  4. {clispec-0.2.2 → clispec-0.2.3}/PKG-INFO +1 -1
  5. clispec-0.2.3/src/checks/mod.rs +283 -0
  6. {clispec-0.2.2 → clispec-0.2.3}/src/display.rs +35 -2
  7. clispec-0.2.2/src/checks/mod.rs +0 -145
  8. {clispec-0.2.2 → clispec-0.2.3}/.github/workflows/ci.yml +0 -0
  9. {clispec-0.2.2 → clispec-0.2.3}/.github/workflows/release.yml +0 -0
  10. {clispec-0.2.2 → clispec-0.2.3}/.gitignore +0 -0
  11. {clispec-0.2.2 → clispec-0.2.3}/Makefile +0 -0
  12. {clispec-0.2.2 → clispec-0.2.3}/README.md +0 -0
  13. {clispec-0.2.2 → clispec-0.2.3}/prek.toml +0 -0
  14. {clispec-0.2.2 → clispec-0.2.3}/pyproject.toml +0 -0
  15. {clispec-0.2.2 → clispec-0.2.3}/schemas/v0.2.json +0 -0
  16. {clispec-0.2.2 → clispec-0.2.3}/src/checks/bounded.rs +0 -0
  17. {clispec-0.2.2 → clispec-0.2.3}/src/checks/idempotent.rs +0 -0
  18. {clispec-0.2.2 → clispec-0.2.3}/src/checks/interactive.rs +0 -0
  19. {clispec-0.2.2 → clispec-0.2.3}/src/checks/output.rs +0 -0
  20. {clispec-0.2.2 → clispec-0.2.3}/src/checks/schema.rs +0 -0
  21. {clispec-0.2.2 → clispec-0.2.3}/src/checks/streams.rs +0 -0
  22. {clispec-0.2.2 → clispec-0.2.3}/src/help.rs +0 -0
  23. {clispec-0.2.2 → clispec-0.2.3}/src/main.rs +0 -0
  24. {clispec-0.2.2 → clispec-0.2.3}/src/runner.rs +0 -0
  25. {clispec-0.2.2 → clispec-0.2.3}/src/schema_cmd.rs +0 -0
  26. {clispec-0.2.2 → clispec-0.2.3}/src/scorer.rs +0 -0
  27. {clispec-0.2.2 → clispec-0.2.3}/tests/integration.rs +0 -0
@@ -11,6 +11,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
11
11
 
12
12
 
13
13
 
14
+
15
+ ## [0.2.3](https://github.com/rvben/clispec-cli/compare/v0.2.2...v0.2.3) - 2026-06-11
16
+
17
+ ### Added
18
+
19
+ - **checks**: cite the conformance checklist item behind every check ([e8e9b5d](https://github.com/rvben/clispec-cli/commit/e8e9b5db819f3b29cce2b548670d4e056ad44291))
20
+
14
21
  ## [0.2.2](https://github.com/rvben/clispec-cli/compare/v0.2.1...v0.2.2) - 2026-06-11
15
22
 
16
23
  ### Added
@@ -183,7 +183,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
183
183
 
184
184
  [[package]]
185
185
  name = "clispec"
186
- version = "0.2.2"
186
+ version = "0.2.3"
187
187
  dependencies = [
188
188
  "clap",
189
189
  "clap_complete",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "clispec"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "Score CLI tools against The CLI Spec"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clispec
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -0,0 +1,283 @@
1
+ pub mod bounded;
2
+ pub mod idempotent;
3
+ pub mod interactive;
4
+ pub mod output;
5
+ pub mod schema;
6
+ pub mod streams;
7
+
8
+ use serde::Serialize;
9
+
10
+ /// The v0.2 conformance checklist (clispec.dev/#conformance), one entry per
11
+ /// checklist item. Every scored check MUST map to exactly one of these ids;
12
+ /// a check that cites no checklist item has no basis in the spec and must
13
+ /// not award or deduct points. The summaries are abbreviations for review,
14
+ /// not normative text - the published spec is authoritative.
15
+ pub const CHECKLIST_ITEMS: [(&str, &str); 10] = [
16
+ (
17
+ "structured-output",
18
+ "Structured output when piped; explicit format flag wins over TTY detection",
19
+ ),
20
+ (
21
+ "error-envelope",
22
+ "On failure, exits non-zero with the error envelope as the last line of stderr",
23
+ ),
24
+ (
25
+ "schema-validates",
26
+ "Exposes a schema subcommand whose output validates against clispec.dev/schema/v0.2.json",
27
+ ),
28
+ (
29
+ "schema-offline",
30
+ "schema succeeds with no authentication, no configuration file, and no network",
31
+ ),
32
+ (
33
+ "help-mentions-schema",
34
+ "Root --help output mentions the schema subcommand",
35
+ ),
36
+ (
37
+ "stream-separation",
38
+ "Data to stdout and diagnostics to stderr in every output mode",
39
+ ),
40
+ (
41
+ "non-interactive",
42
+ "Runs to completion without a TTY; flag alternative for every interactive prompt",
43
+ ),
44
+ (
45
+ "confirmation-refusal",
46
+ "Commands that would prompt refuse without a TTY via confirmation_required",
47
+ ),
48
+ (
49
+ "idempotent-repeats",
50
+ "Re-running a satisfied command exits zero; incompatible repeats emit conflict",
51
+ ),
52
+ (
53
+ "bounded-lists",
54
+ "List commands support --limit/--offset and --fields with in-band truncation metadata",
55
+ ),
56
+ ];
57
+
58
+ /// The checklist item a named check verifies. Returns `None` for unknown
59
+ /// check names; the unit tests below reject any check without a mapping.
60
+ pub fn checklist_item(check_name: &str) -> Option<&'static str> {
61
+ let id = match check_name {
62
+ "JSON output flag"
63
+ | "Valid JSON output"
64
+ | "Auto-JSON when piped"
65
+ | "Explicit format wins" => "structured-output",
66
+ "Structured errors" => "error-envelope",
67
+ "schema command exists"
68
+ | "Valid JSON schema"
69
+ | "Validates against clispec v0.2"
70
+ | "Error kinds documented"
71
+ | "Output fields declared"
72
+ | "Global args declared"
73
+ | "Exit codes on error kinds"
74
+ | "Mutation markers on all commands" => "schema-validates",
75
+ "schema works without config" => "schema-offline",
76
+ "schema mentioned in --help" => "help-mentions-schema",
77
+ "Clean stdout when piped" | "Messages on stderr only" => "stream-separation",
78
+ "No TTY hang" => "non-interactive",
79
+ "--yes flag" => "confirmation-refusal",
80
+ "Mutating markers in schema" | "Conflict error kind" => "idempotent-repeats",
81
+ "--limit flag" | "Pagination flag" | "--fields flag" => "bounded-lists",
82
+ _ => return None,
83
+ };
84
+ Some(id)
85
+ }
86
+
87
+ #[derive(Debug, Clone, Serialize)]
88
+ pub struct CheckResult {
89
+ pub name: String,
90
+ pub passed: bool,
91
+ pub detail: Option<String>,
92
+ /// Conformance checklist item this check verifies (see CHECKLIST_ITEMS).
93
+ pub checklist: Option<&'static str>,
94
+ }
95
+
96
+ impl CheckResult {
97
+ pub fn pass(name: &str) -> Self {
98
+ Self {
99
+ name: name.to_string(),
100
+ passed: true,
101
+ detail: None,
102
+ checklist: checklist_item(name),
103
+ }
104
+ }
105
+
106
+ pub fn fail(name: &str) -> Self {
107
+ Self {
108
+ name: name.to_string(),
109
+ passed: false,
110
+ detail: None,
111
+ checklist: checklist_item(name),
112
+ }
113
+ }
114
+
115
+ pub fn fail_with(name: &str, detail: &str) -> Self {
116
+ Self {
117
+ name: name.to_string(),
118
+ passed: false,
119
+ detail: Some(detail.to_string()),
120
+ checklist: checklist_item(name),
121
+ }
122
+ }
123
+ }
124
+
125
+ #[derive(Debug, Clone, Serialize)]
126
+ pub struct PrincipleScore {
127
+ pub name: String,
128
+ pub score: u32,
129
+ pub max: u32,
130
+ pub checks: Vec<CheckResult>,
131
+ }
132
+
133
+ impl PrincipleScore {
134
+ pub fn new(name: &str, checks: Vec<CheckResult>, max: u32) -> Self {
135
+ let score = checks.iter().filter(|c| c.passed).count() as u32;
136
+ Self {
137
+ name: name.to_string(),
138
+ score,
139
+ max,
140
+ checks,
141
+ }
142
+ }
143
+ }
144
+
145
+ pub struct CheckContext {
146
+ pub binary: String,
147
+ pub subcommand: Vec<String>,
148
+ pub help_text: String,
149
+ pub schema_json: Option<serde_json::Value>,
150
+ }
151
+
152
+ /// Run `binary subcommand... --help` and parse the help output.
153
+ /// Returns `None` if the subcommand is empty or the command fails.
154
+ pub fn subcommand_help_info(ctx: &CheckContext) -> Option<crate::help::HelpInfo> {
155
+ if ctx.subcommand.is_empty() {
156
+ return None;
157
+ }
158
+ let mut args: Vec<&str> = ctx.subcommand.iter().map(|s| s.as_str()).collect();
159
+ args.push("--help");
160
+ let result = crate::runner::run(&ctx.binary, &args, std::time::Duration::from_secs(5));
161
+ if result.exit_code < 0 {
162
+ return None;
163
+ }
164
+ Some(crate::help::parse_help(&result.stdout))
165
+ }
166
+
167
+ #[cfg(test)]
168
+ mod tests {
169
+ use super::*;
170
+
171
+ fn test_context() -> CheckContext {
172
+ CheckContext {
173
+ binary: "echo".to_string(),
174
+ subcommand: vec![],
175
+ help_text: String::new(),
176
+ schema_json: None,
177
+ }
178
+ }
179
+
180
+ #[test]
181
+ fn check_result_constructors() {
182
+ let pass = CheckResult::pass("test");
183
+ assert!(pass.passed);
184
+ assert!(pass.detail.is_none());
185
+
186
+ let fail = CheckResult::fail("test");
187
+ assert!(!fail.passed);
188
+
189
+ let fail_detail = CheckResult::fail_with("test", "reason");
190
+ assert!(!fail_detail.passed);
191
+ assert_eq!(fail_detail.detail.as_deref(), Some("reason"));
192
+ }
193
+
194
+ #[test]
195
+ fn principle_score_counts_passes() {
196
+ let checks = vec![
197
+ CheckResult::pass("a"),
198
+ CheckResult::fail("b"),
199
+ CheckResult::pass("c"),
200
+ ];
201
+ let score = PrincipleScore::new("test", checks, 3);
202
+ assert_eq!(score.score, 2);
203
+ assert_eq!(score.max, 3);
204
+ }
205
+
206
+ #[test]
207
+ fn checks_return_correct_max_scores() {
208
+ let ctx = test_context();
209
+ assert_eq!(output::check(&ctx).max, 5);
210
+ assert_eq!(schema::check(&ctx).max, 10);
211
+ assert_eq!(streams::check(&ctx).max, 2);
212
+ assert_eq!(interactive::check(&ctx).max, 2);
213
+ assert_eq!(idempotent::check(&ctx).max, 2);
214
+ assert_eq!(bounded::check(&ctx).max, 3);
215
+ }
216
+
217
+ #[test]
218
+ fn checks_produce_expected_number_of_results() {
219
+ let ctx = test_context();
220
+ assert_eq!(output::check(&ctx).checks.len(), 5);
221
+ assert_eq!(schema::check(&ctx).checks.len(), 10);
222
+ assert_eq!(streams::check(&ctx).checks.len(), 2);
223
+ assert_eq!(interactive::check(&ctx).checks.len(), 2);
224
+ assert_eq!(idempotent::check(&ctx).checks.len(), 2);
225
+ assert_eq!(bounded::check(&ctx).checks.len(), 3);
226
+ }
227
+
228
+ fn all_check_results() -> Vec<CheckResult> {
229
+ let ctx = test_context();
230
+ [
231
+ output::check(&ctx),
232
+ schema::check(&ctx),
233
+ streams::check(&ctx),
234
+ interactive::check(&ctx),
235
+ idempotent::check(&ctx),
236
+ bounded::check(&ctx),
237
+ ]
238
+ .into_iter()
239
+ .flat_map(|p| p.checks)
240
+ .collect()
241
+ }
242
+
243
+ #[test]
244
+ fn every_check_cites_a_checklist_item() {
245
+ for check in all_check_results() {
246
+ assert!(
247
+ check.checklist.is_some(),
248
+ "check '{}' cites no conformance checklist item; checks without \
249
+ a basis in the published spec must not be scored",
250
+ check.name
251
+ );
252
+ }
253
+ }
254
+
255
+ #[test]
256
+ fn every_checklist_item_is_verified_by_a_check() {
257
+ let cited: std::collections::HashSet<&str> = all_check_results()
258
+ .iter()
259
+ .filter_map(|c| c.checklist)
260
+ .collect();
261
+ for (id, summary) in CHECKLIST_ITEMS {
262
+ assert!(
263
+ cited.contains(id),
264
+ "checklist item '{id}' ({summary}) has no check verifying it"
265
+ );
266
+ }
267
+ }
268
+
269
+ #[test]
270
+ fn checklist_mapping_targets_are_canonical() {
271
+ let ids: std::collections::HashSet<&str> =
272
+ CHECKLIST_ITEMS.iter().map(|(id, _)| *id).collect();
273
+ for check in all_check_results() {
274
+ if let Some(item) = check.checklist {
275
+ assert!(
276
+ ids.contains(item),
277
+ "check '{}' cites unknown checklist item '{item}'",
278
+ check.name
279
+ );
280
+ }
281
+ }
282
+ }
283
+ }
@@ -25,12 +25,45 @@ pub fn print_score(score: &Score, json: bool) {
25
25
  if check.passed {
26
26
  eprintln!(" {} {}", "\u{2713}".green(), check.name.green());
27
27
  } else {
28
- eprintln!(" {} {}", "\u{2717}".red(), check.name.red());
28
+ // Cite the conformance checklist item behind the failure so
29
+ // the score points at spec text, not scorer behavior.
30
+ let citation = check
31
+ .checklist
32
+ .map(|id| format!(" [{id}]"))
33
+ .unwrap_or_default();
34
+ eprintln!(
35
+ " {} {}{}",
36
+ "\u{2717}".red(),
37
+ check.name.red(),
38
+ citation.dimmed()
39
+ );
29
40
  }
30
41
  }
31
42
  eprintln!();
32
43
  }
33
44
 
45
+ // Summarize which conformance checklist items are not yet satisfied,
46
+ // in checklist order, so the score points back at spec text.
47
+ let failing: Vec<&str> = score
48
+ .principles
49
+ .iter()
50
+ .flat_map(|p| &p.checks)
51
+ .filter(|c| !c.passed)
52
+ .filter_map(|c| c.checklist)
53
+ .collect();
54
+ let unsatisfied: Vec<(&str, &str)> = crate::checks::CHECKLIST_ITEMS
55
+ .iter()
56
+ .filter(|(id, _)| failing.contains(id))
57
+ .copied()
58
+ .collect();
59
+ if !unsatisfied.is_empty() {
60
+ eprintln!(" {}", "Unsatisfied checklist items:".bold());
61
+ for (id, summary) in unsatisfied {
62
+ eprintln!(" {} {}", format!("[{id}]").yellow(), summary);
63
+ }
64
+ eprintln!();
65
+ }
66
+
34
67
  eprintln!(
35
68
  " {}",
36
69
  format!(
@@ -39,7 +72,7 @@ pub fn print_score(score: &Score, json: bool) {
39
72
  )
40
73
  .bold()
41
74
  );
42
- eprintln!(" Spec: https://clispec.dev");
75
+ eprintln!(" Spec: https://clispec.dev/#conformance");
43
76
  eprintln!();
44
77
  }
45
78
 
@@ -1,145 +0,0 @@
1
- pub mod bounded;
2
- pub mod idempotent;
3
- pub mod interactive;
4
- pub mod output;
5
- pub mod schema;
6
- pub mod streams;
7
-
8
- use serde::Serialize;
9
-
10
- #[derive(Debug, Clone, Serialize)]
11
- pub struct CheckResult {
12
- pub name: String,
13
- pub passed: bool,
14
- pub detail: Option<String>,
15
- }
16
-
17
- impl CheckResult {
18
- pub fn pass(name: &str) -> Self {
19
- Self {
20
- name: name.to_string(),
21
- passed: true,
22
- detail: None,
23
- }
24
- }
25
-
26
- pub fn fail(name: &str) -> Self {
27
- Self {
28
- name: name.to_string(),
29
- passed: false,
30
- detail: None,
31
- }
32
- }
33
-
34
- pub fn fail_with(name: &str, detail: &str) -> Self {
35
- Self {
36
- name: name.to_string(),
37
- passed: false,
38
- detail: Some(detail.to_string()),
39
- }
40
- }
41
- }
42
-
43
- #[derive(Debug, Clone, Serialize)]
44
- pub struct PrincipleScore {
45
- pub name: String,
46
- pub score: u32,
47
- pub max: u32,
48
- pub checks: Vec<CheckResult>,
49
- }
50
-
51
- impl PrincipleScore {
52
- pub fn new(name: &str, checks: Vec<CheckResult>, max: u32) -> Self {
53
- let score = checks.iter().filter(|c| c.passed).count() as u32;
54
- Self {
55
- name: name.to_string(),
56
- score,
57
- max,
58
- checks,
59
- }
60
- }
61
- }
62
-
63
- pub struct CheckContext {
64
- pub binary: String,
65
- pub subcommand: Vec<String>,
66
- pub help_text: String,
67
- pub schema_json: Option<serde_json::Value>,
68
- }
69
-
70
- /// Run `binary subcommand... --help` and parse the help output.
71
- /// Returns `None` if the subcommand is empty or the command fails.
72
- pub fn subcommand_help_info(ctx: &CheckContext) -> Option<crate::help::HelpInfo> {
73
- if ctx.subcommand.is_empty() {
74
- return None;
75
- }
76
- let mut args: Vec<&str> = ctx.subcommand.iter().map(|s| s.as_str()).collect();
77
- args.push("--help");
78
- let result = crate::runner::run(&ctx.binary, &args, std::time::Duration::from_secs(5));
79
- if result.exit_code < 0 {
80
- return None;
81
- }
82
- Some(crate::help::parse_help(&result.stdout))
83
- }
84
-
85
- #[cfg(test)]
86
- mod tests {
87
- use super::*;
88
-
89
- fn test_context() -> CheckContext {
90
- CheckContext {
91
- binary: "echo".to_string(),
92
- subcommand: vec![],
93
- help_text: String::new(),
94
- schema_json: None,
95
- }
96
- }
97
-
98
- #[test]
99
- fn check_result_constructors() {
100
- let pass = CheckResult::pass("test");
101
- assert!(pass.passed);
102
- assert!(pass.detail.is_none());
103
-
104
- let fail = CheckResult::fail("test");
105
- assert!(!fail.passed);
106
-
107
- let fail_detail = CheckResult::fail_with("test", "reason");
108
- assert!(!fail_detail.passed);
109
- assert_eq!(fail_detail.detail.as_deref(), Some("reason"));
110
- }
111
-
112
- #[test]
113
- fn principle_score_counts_passes() {
114
- let checks = vec![
115
- CheckResult::pass("a"),
116
- CheckResult::fail("b"),
117
- CheckResult::pass("c"),
118
- ];
119
- let score = PrincipleScore::new("test", checks, 3);
120
- assert_eq!(score.score, 2);
121
- assert_eq!(score.max, 3);
122
- }
123
-
124
- #[test]
125
- fn checks_return_correct_max_scores() {
126
- let ctx = test_context();
127
- assert_eq!(output::check(&ctx).max, 5);
128
- assert_eq!(schema::check(&ctx).max, 10);
129
- assert_eq!(streams::check(&ctx).max, 2);
130
- assert_eq!(interactive::check(&ctx).max, 2);
131
- assert_eq!(idempotent::check(&ctx).max, 2);
132
- assert_eq!(bounded::check(&ctx).max, 3);
133
- }
134
-
135
- #[test]
136
- fn checks_produce_expected_number_of_results() {
137
- let ctx = test_context();
138
- assert_eq!(output::check(&ctx).checks.len(), 5);
139
- assert_eq!(schema::check(&ctx).checks.len(), 10);
140
- assert_eq!(streams::check(&ctx).checks.len(), 2);
141
- assert_eq!(interactive::check(&ctx).checks.len(), 2);
142
- assert_eq!(idempotent::check(&ctx).checks.len(), 2);
143
- assert_eq!(bounded::check(&ctx).checks.len(), 3);
144
- }
145
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes