Skip to content

Commit 11b4563

Browse files
fabio-rovaiclaude
andcommitted
shacl, enforce, unpack and monitor exit non-zero on a failing verdict (#323)
They printed conforms:false, an error-severity violation, certificate_refused or a blocking watcher and exited 0, so `open-ontologies shacl shapes.ttl || exit 1` passed on exactly the data it exists to catch. output_result_checked could not help: those outcomes are verdicts, not error fields. gate_exit_code maps each command's own verdict: 0 pass, 1 fail, 2 not determined (conforms:null, or no checker re-ran the certificate). plan and drift go through output_result_checked like lint. Each command's --help lists its codes. tests/cli_gate_exit_codes_test.rs: 7 tests over a persistent store; on main's src/main.rs 5 fail (the two exit-0 controls pass). Full cargo test 1957 passed, 0 failed; clippy --all-targets -D warnings clean. Reported by @noahgift in #323. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3d99110 commit 11b4563

3 files changed

Lines changed: 327 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ All notable changes to Open Ontologies are documented here.
66

77
### Fixed
88

9+
- **`shacl`, `enforce`, `unpack` and `monitor` exit non-zero on a failing
10+
verdict** (#323, reported by @noahgift). They printed `conforms: false`, an
11+
error-severity violation, `certificate_refused` or a blocking watcher and
12+
exited 0, so `open-ontologies shacl shapes.ttl || exit 1` passed on the data
13+
it exists to catch. Codes are 0 pass, 1 fail, 2 not determined (`conforms:
14+
null`, or no checker re-ran the certificate), and each command's `--help`
15+
lists them. `plan` and `drift` now exit 1 on an error, as `lint` does.
916
- Preserve carriage returns and CRLF text when importing dataframe rows into the Python engine.
1017
- Report non-object JSON from a configured Python certificate checker as unchecked instead of raising an attribute error.
1118
- Preserve RDF literal datatypes when comparing Python KGCL graph changes, while retaining lexical label and annotation values.

‎src/main.rs‎

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,9 @@ enum Commands {
456456
base_iri: Option<String>,
457457
},
458458
/// Validate against SHACL shapes
459+
///
460+
/// Exit code: 0 conforms, 1 does not conform or failed, 2 not determined
461+
/// (`conforms: null`).
459462
Shacl {
460463
shapes: String,
461464
/// Validate the snapshot that was TRUE at this instant (#108). Read as
@@ -844,6 +847,8 @@ enum Commands {
844847
/// Detect drift between two ontology versions
845848
Drift { file_a: String, file_b: String },
846849
/// Run design pattern enforcement
850+
///
851+
/// Exit code: 0 no error-severity violation, 1 at least one, or failed.
847852
Enforce {
848853
#[arg(default_value = "generic")]
849854
pack: String,
@@ -856,6 +861,9 @@ enum Commands {
856861
/// the load; or this build has no checker, so nothing was checked. The last
857862
/// one is not a pass.
858863
///
864+
/// Exit code: 0 accepted or no certificate, 1 refused, unreadable, not
865+
/// about this pack, or failed, 2 nothing was checked.
866+
///
859867
/// `pack` stays MCP-only and this does not, on purpose. The receiver is the
860868
/// party this exists for, and requiring them to run an MCP client to audit
861869
/// a pack would defeat it.
@@ -879,6 +887,8 @@ enum Commands {
879887
load_even_if_refused: bool,
880888
},
881889
/// Run active SPARQL watchers
890+
///
891+
/// Exit code: 1 when a watcher blocked the next apply or rolled back, else 0.
882892
Monitor,
883893
/// Clear monitor block state
884894
MonitorClear,
@@ -1603,6 +1613,73 @@ fn output_result_with_exit(result: &str, pretty: bool) {
16031613
}
16041614
}
16051615

1616+
/// The commands whose output is a verdict, not just a result.
1617+
#[derive(Clone, Copy)]
1618+
enum Gate {
1619+
Shacl,
1620+
Enforce,
1621+
Unpack,
1622+
Monitor,
1623+
}
1624+
1625+
/// Map a gate command's own verdict to an exit code: 0 pass, 1 fail, 2 not
1626+
/// determined.
1627+
///
1628+
/// `output_result_checked` sees only an `error` field, and the failures that
1629+
/// matter here are not errors. `conforms: false` and `certificate_refused` are
1630+
/// verdicts, so `shacl shapes.ttl || exit 1` and `unpack x.oopack || exit 1`
1631+
/// passed on exactly the inputs they exist to catch (#323). An undetermined
1632+
/// verdict (`conforms: null`, no checker to re-run the proof) is not a pass
1633+
/// and not a failure, so it gets its own code rather than being folded into
1634+
/// either.
1635+
fn gate_exit_code(gate: Gate, result: &str) -> i32 {
1636+
let Ok(v) = serde_json::from_str::<serde_json::Value>(result) else {
1637+
return 1;
1638+
};
1639+
if v.get("error").is_some() {
1640+
return 1;
1641+
}
1642+
match gate {
1643+
Gate::Shacl => match v.get("conforms") {
1644+
Some(serde_json::Value::Bool(true)) => 0,
1645+
Some(serde_json::Value::Bool(false)) => 1,
1646+
_ => 2,
1647+
},
1648+
Gate::Enforce => {
1649+
let errors = v
1650+
.get("violations")
1651+
.and_then(|x| x.as_array())
1652+
.is_some_and(|vs| vs.iter().any(|x| x["severity"] == "error"));
1653+
i32::from(errors)
1654+
}
1655+
Gate::Unpack => {
1656+
let verdict = v.pointer("/certificate/verdict").and_then(|x| x.as_str());
1657+
match verdict {
1658+
Some(
1659+
"certificate_refused"
1660+
| "certificate_unreadable"
1661+
| "certificate_not_about_this_pack",
1662+
) => 1,
1663+
Some("checker_absent_nothing_was_checked") => 2,
1664+
_ if v.get("ok") == Some(&serde_json::Value::Bool(false)) => 1,
1665+
_ => 0,
1666+
}
1667+
}
1668+
Gate::Monitor => match v.get("status").and_then(|x| x.as_str()) {
1669+
Some("blocked" | "auto_rolled_back") => 1,
1670+
_ => 0,
1671+
},
1672+
}
1673+
}
1674+
1675+
fn output_result_gated(result: &str, pretty: bool, gate: Gate) {
1676+
let code = gate_exit_code(gate, result);
1677+
output_result(result, pretty);
1678+
if code != 0 {
1679+
std::process::exit(code);
1680+
}
1681+
}
1682+
16061683
fn output_result(result: &str, pretty: bool) {
16071684
if json_mode() || pretty {
16081685
if pretty {
@@ -3117,7 +3194,7 @@ async fn async_main() -> anyhow::Result<()> {
31173194
open_ontologies::shacl_verified::validate_verified(&graph, &shapes_content)
31183195
.unwrap_or_else(|e| serde_json::json!({"error": e.to_string()}))
31193196
};
3120-
output_result(&result.to_string(), cli.pretty);
3197+
output_result_gated(&result.to_string(), cli.pretty, Gate::Shacl);
31213198
return Ok(());
31223199
}
31233200
let result = match open_ontologies::temporal::ScopeRequest::from_args(
@@ -3129,7 +3206,7 @@ async fn async_main() -> anyhow::Result<()> {
31293206
.unwrap_or_else(|e| serde_json::json!({"error": e.to_string()}).to_string()),
31303207
Err(e) => serde_json::json!({"error": e.to_string()}).to_string(),
31313208
};
3132-
output_result(&result, cli.pretty);
3209+
output_result_gated(&result, cli.pretty, Gate::Shacl);
31333210
}
31343211
Commands::VocabCheck { data } => {
31353212
let (_db, graph) = setup(&cli.data_dir)?;
@@ -3438,7 +3515,7 @@ async fn async_main() -> anyhow::Result<()> {
34383515
let result = planner
34393516
.plan_checked(&turtle, opts)
34403517
.unwrap_or_else(|e| format!(r#"{{"error":"{}"}}"#, e));
3441-
output_result(&result, cli.pretty);
3518+
output_result_checked(&result, cli.pretty);
34423519
}
34433520
Commands::Apply { mode, plan_id } => {
34443521
let (db, graph) = setup(&cli.data_dir)?;
@@ -3472,15 +3549,15 @@ async fn async_main() -> anyhow::Result<()> {
34723549
let result = detector
34733550
.detect(&v1, &v2)
34743551
.unwrap_or_else(|e| format!(r#"{{"error":"{}"}}"#, e));
3475-
output_result(&result, cli.pretty);
3552+
output_result_checked(&result, cli.pretty);
34763553
}
34773554
Commands::Enforce { pack } => {
34783555
let (db, graph) = setup(&cli.data_dir)?;
34793556
let enforcer = open_ontologies::enforce::Enforcer::new(db.clone(), graph);
34803557
let result = enforcer
34813558
.enforce_with_feedback(&pack, Some(&db))
34823559
.unwrap_or_else(|e| format!(r#"{{"error":"{}"}}"#, e));
3483-
output_result(&result, cli.pretty);
3560+
output_result_gated(&result, cli.pretty, Gate::Enforce);
34843561
}
34853562
Commands::Unpack {
34863563
path,
@@ -3501,15 +3578,15 @@ async fn async_main() -> anyhow::Result<()> {
35013578
let result = open_ontologies::pack::Packer::new(graph)
35023579
.unpack(&path, &opts)
35033580
.unwrap_or_else(|e| format!(r#"{{"error":"{e}"}}"#));
3504-
output_result(&result, cli.pretty);
3581+
output_result_gated(&result, cli.pretty, Gate::Unpack);
35053582
}
35063583
Commands::Monitor => {
35073584
let (db, graph) = setup(&cli.data_dir)?;
35083585
let monitor = open_ontologies::monitor::Monitor::new(db, graph);
35093586
let result = monitor.run_watchers();
35103587
let json = serde_json::to_string(&result)
35113588
.unwrap_or_else(|e| format!(r#"{{"error":"{}"}}"#, e));
3512-
output_result(&json, cli.pretty);
3589+
output_result_gated(&json, cli.pretty, Gate::Monitor);
35133590
}
35143591
Commands::MonitorClear => {
35153592
let (db, graph) = setup(&cli.data_dir)?;

0 commit comments

Comments
 (0)