@@ -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+
16061683fn 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