Skip to main content

shifty_engine/
lib.rs

1//! Validation + SHACL-AF inference execution (Layers 3, 6, 7).
2//!
3//! Layer 3 lives here: the naive denotational evaluator that is the conformance
4//! oracle — relational path evaluation ([`path`]), value-type checks
5//! ([`value`]), and shape/schema satisfaction ([`validate`]). The rule/fixpoint
6//! inference engine (Layer 6) and compiled executors (Layer 7) come later; every
7//! execution mode must agree with this oracle.
8
9pub mod enumerate;
10pub mod frozen;
11pub mod gate;
12pub mod infer;
13mod native_exec;
14pub mod path;
15mod path_plan;
16pub mod profile;
17pub mod report;
18mod sparql;
19pub use sparql::SparqlDiagnostic;
20pub mod synthesize;
21pub mod validate;
22pub mod value;
23pub mod witness;
24
25pub use enumerate::{
26    EnumOptions, FixpointResult, RepairSolution, candidates, enumerate_repair, repair_to_fixpoint,
27};
28pub use gate::{RepairOutcome, apply, gate};
29pub use infer::{
30    InferenceOutcome, infer, infer_graphs, infer_with_context, infer_with_context_and_options,
31    infer_with_options, infer_with_owned_context_and_options,
32};
33pub use report::{
34    PropertyWitness, ValidationReport, ValidationResult, evaluate_function_expression,
35    property_witnesses_graphs_with_mode, property_witnesses_graphs_with_mode_and_options,
36    report_to_graph, validate_report, validate_report_graphs, validate_report_graphs_with_mode,
37    validate_report_graphs_with_mode_and_options, validate_report_with_options,
38};
39pub use synthesize::{synthesize, synthesize_focus};
40pub use validate::{
41    EngineOptions, NonStratifiable, Reason, UnsupportedPolicy, ValidationGraphMode,
42    ValidationOptions, ValidationOutcome, Violation, focus_nodes, graph_union, validate,
43    validate_graphs, validate_graphs_with_mode, validate_graphs_with_mode_and_options,
44    validate_plan, validate_plan_graphs, validate_plan_graphs_with_mode,
45    validate_plan_graphs_with_mode_and_options, validate_plan_with_context,
46    validate_plan_with_context_and_options, validate_plan_with_options, validate_with_context,
47    validate_with_context_and_options, validate_with_options,
48};
49pub use witness::{
50    BlockReason, FocusSat, FocusWitness, PathSupport, RelKind, SatTrace, Witness, satisfy_shape,
51    shape_id_for_iri, witness_node, witness_shape, witness_violations,
52};
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use oxrdf::Graph;
58    use shifty_parse::parse_turtle;
59
60    fn run(shapes_and_data: &str) -> ValidationOutcome {
61        let out = parse_turtle(shapes_and_data.as_bytes(), None).unwrap();
62        // data graph = the same graph (shapes + data coexist), as in the suite.
63        let loaded = shifty_parse::load_turtle(shapes_and_data.as_bytes(), None).unwrap();
64        validate(&loaded.graph, &out.schema).expect("stratifiable schema")
65    }
66
67    /// Like [`run`], but validates the *normalized* schema — the schema the CLI
68    /// and wasm front-ends actually run, where NNF rewriting can reshape the IR
69    /// that reporting inspects.
70    fn run_normalized(shapes_and_data: &str) -> ValidationOutcome {
71        let out = parse_turtle(shapes_and_data.as_bytes(), None).unwrap();
72        let loaded = shifty_parse::load_turtle(shapes_and_data.as_bytes(), None).unwrap();
73        let normalized = shifty_opt::normalize(&out.schema);
74        validate(&loaded.graph, &normalized).expect("stratifiable schema")
75    }
76
77    const PREFIXES: &str = r#"
78        @prefix sh:  <http://www.w3.org/ns/shacl#> .
79        @prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
80        @prefix ex:  <http://ex/> .
81        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
82    "#;
83
84    /// A `sh:class` value constraint on a property is the universal
85    /// `∀path.(instance of C)`, lowered to `∃≤0 path.¬(instance of C)`. After
86    /// NNF normalization the inner `¬(instance of C)` becomes an `∃≤0` class
87    /// count rather than a bare `Not`, which used to defeat the drill-in and
88    /// leave the raw "at most 0 value(s) … found 1" message. The offender should
89    /// instead be told which class the value must be an instance of.
90    #[test]
91    fn normalized_class_universal_names_the_required_class() {
92        let ttl = format!(
93            "{PREFIXES}
94            ex:SensorShape a sh:NodeShape ;
95                sh:targetClass ex:Sensor ;
96                sh:property [ sh:path ex:hasKind ; sh:class ex:QuantityKind ] .
97
98            ex:s1 a ex:Sensor ; ex:hasKind ex:Temperature .
99            # ex:Temperature is deliberately untyped (as if its ontology import
100            # were missing), so it fails the class check.
101            "
102        );
103
104        let outcome = run_normalized(&ttl);
105        let messages: Vec<&str> = outcome
106            .violations
107            .iter()
108            .flat_map(|v| v.reasons.iter())
109            .map(|r| r.message.as_str())
110            .collect();
111
112        assert!(
113            messages.contains(&"must be an instance of <http://ex/QuantityKind>"),
114            "expected an intuitive class message, got: {messages:?}"
115        );
116        // The old double-negated structural phrasing must not surface.
117        assert!(
118            !messages.iter().any(|m| m.contains("at most 0")),
119            "raw ∃≤0 message leaked: {messages:?}"
120        );
121
122        // The offending value node and path are carried on the reason.
123        let reason = outcome
124            .violations
125            .iter()
126            .flat_map(|v| &v.reasons)
127            .find(|r| r.message.starts_with("must be an instance of"))
128            .expect("class reason present");
129        assert_eq!(reason.value.to_string(), "<http://ex/Temperature>");
130        assert_eq!(reason.path.as_deref(), Some("<http://ex/hasKind>"));
131    }
132
133    /// A universal whose `¬φ` normalizes to a non-`Not`, non-class form — here a
134    /// complemented `sh:nodeKind` — still names the positive requirement (the
135    /// general `describe_negation` path) instead of the raw `∃≤0` count message.
136    #[test]
137    fn normalized_nodekind_universal_names_the_requirement() {
138        let ttl = format!(
139            "{PREFIXES}
140            ex:S a sh:NodeShape ;
141                sh:targetClass ex:T ;
142                sh:property [ sh:path ex:p ; sh:nodeKind sh:IRI ] .
143
144            ex:a a ex:T ; ex:p \"not-an-iri\" .
145            "
146        );
147
148        let outcome = run_normalized(&ttl);
149        let messages: Vec<&str> = outcome
150            .violations
151            .iter()
152            .flat_map(|v| v.reasons.iter())
153            .map(|r| r.message.as_str())
154            .collect();
155
156        assert!(
157            messages.contains(&"must satisfy `nodeKind(IRI)`"),
158            "expected a nodeKind requirement message, got: {messages:?}"
159        );
160        assert!(
161            !messages.iter().any(|m| m.contains("at most 0")),
162            "raw ∃≤0 message leaked: {messages:?}"
163        );
164    }
165
166    /// A source shape's `sh:message` rides through lowering + normalization on
167    /// `Shape::Annotated` and is stamped onto each reason (with `{$this}`
168    /// resolved to the focus node), while the generated `message` stays as a
169    /// fallback. Shapes without one leave `author_message` unset.
170    #[test]
171    fn author_sh_message_is_carried_onto_reasons() {
172        let ttl = format!(
173            "{PREFIXES}
174            ex:S a sh:NodeShape ;
175                sh:targetClass ex:T ;
176                sh:property [ sh:path ex:hasKind ; sh:class ex:QuantityKind ;
177                    sh:message \"{{$this}} needs a known QuantityKind\" ] .
178
179            ex:a a ex:T ; ex:hasKind ex:Temperature .
180            "
181        );
182
183        let outcome = run_normalized(&ttl);
184        let reason = outcome
185            .violations
186            .iter()
187            .flat_map(|v| &v.reasons)
188            .find(|r| r.author_message.is_some())
189            .expect("author message present");
190
191        // `{$this}` resolved to the focus node, generated message kept as fallback.
192        assert_eq!(
193            reason.author_message.as_deref(),
194            Some("<http://ex/a> needs a known QuantityKind")
195        );
196        assert_eq!(
197            reason.message,
198            "must be an instance of <http://ex/QuantityKind>"
199        );
200    }
201
202    /// Without `sh:message`, `author_message` stays `None` (generated only).
203    #[test]
204    fn absent_sh_message_leaves_author_message_unset() {
205        let ttl = format!(
206            "{PREFIXES}
207            ex:S a sh:NodeShape ;
208                sh:targetClass ex:T ;
209                sh:property [ sh:path ex:hasKind ; sh:class ex:QuantityKind ] .
210
211            ex:a a ex:T ; ex:hasKind ex:Temperature .
212            "
213        );
214
215        let outcome = run_normalized(&ttl);
216        assert!(
217            outcome
218                .violations
219                .iter()
220                .flat_map(|v| &v.reasons)
221                .all(|r| r.author_message.is_none()),
222            "no author message expected"
223        );
224    }
225
226    #[test]
227    fn entry_shape_names_limit_top_level_validation_but_keep_dependencies() {
228        let ttl = format!(
229            "{PREFIXES}
230            ex:Selected a sh:NodeShape ;
231                sh:targetNode ex:a ;
232                sh:node ex:Helper .
233
234            ex:Skipped a sh:NodeShape ;
235                sh:targetNode ex:b ;
236                sh:property [ sh:path ex:p ; sh:minCount 1 ] .
237
238            ex:Helper a sh:NodeShape ;
239                sh:property [ sh:path ex:required ; sh:minCount 1 ] .
240
241            ex:a ex:p \"present\" .
242            ex:b a ex:Thing .
243            "
244        );
245        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
246        let parsed = shifty_parse::parse_loaded(&loaded);
247        let normalized = shifty_opt::normalize(&parsed.schema);
248        let plan = shifty_opt::plan(&normalized);
249        let options = ValidationOptions {
250            entry_shape_names: vec!["http://ex/Selected".to_string()],
251            ..Default::default()
252        };
253
254        let outcome = validate_plan_with_options(&loaded.graph, &plan, &options).unwrap();
255        assert!(!outcome.conforms);
256        assert_eq!(outcome.violations.len(), 1);
257        assert_eq!(outcome.violations[0].focus.to_string(), "<http://ex/a>");
258
259        let report = validate_report_with_options(&loaded, &loaded.graph, &options);
260        assert!(!report.conforms);
261        assert_eq!(report.results.len(), 1);
262        assert_eq!(report.results[0].focus.to_string(), "<http://ex/a>");
263    }
264
265    #[test]
266    fn inference_rules_fire_for_implicit_class_targets() {
267        let ttl = br#"
268            @prefix rdf:   <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
269            @prefix rdfs:  <http://www.w3.org/2000/01/rdf-schema#> .
270            @prefix owl:   <http://www.w3.org/2002/07/owl#> .
271            @prefix sh:    <http://www.w3.org/ns/shacl#> .
272            @prefix ex:    <http://ex/> .
273
274            ex:Parent a owl:Class, sh:NodeShape ;
275                sh:rule [
276                    a sh:TripleRule ;
277                    sh:subject sh:this ;
278                    sh:predicate ex:hasTag ;
279                    sh:object ex:Tag
280                ] .
281
282            ex:Child rdfs:subClassOf ex:Parent .
283            ex:item a ex:Child .
284        "#;
285        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
286        let parsed = shifty_parse::parse_loaded(&loaded);
287        let normalized = shifty_opt::normalize(&parsed.schema);
288
289        let outcome = infer(&loaded.graph, &normalized).expect("stratifiable schema");
290
291        assert!(outcome.graph.contains(&oxrdf::Triple::new(
292            oxrdf::NamedNode::new_unchecked("http://ex/item"),
293            oxrdf::NamedNode::new_unchecked("http://ex/hasTag"),
294            oxrdf::NamedNode::new_unchecked("http://ex/Tag"),
295        )));
296    }
297
298    /// Parse + normalize + infer, asserting the parser emitted no diagnostics
299    /// (so the node expressions under test were actually lowered, not skipped).
300    fn infer_ttl(ttl: &[u8]) -> InferenceOutcome {
301        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
302        let parsed = shifty_parse::parse_loaded(&loaded);
303        assert!(
304            parsed.diagnostics.is_empty(),
305            "parse diagnostics: {:?}",
306            parsed.diagnostics
307        );
308        let normalized = shifty_opt::normalize(&parsed.schema);
309        infer(&loaded.graph, &normalized).expect("stratifiable schema")
310    }
311
312    fn triple_term(s: &str, p: &str, o: impl Into<oxrdf::Term>) -> oxrdf::Triple {
313        oxrdf::Triple::new(
314            oxrdf::NamedNode::new_unchecked(s),
315            oxrdf::NamedNode::new_unchecked(p),
316            o,
317        )
318    }
319
320    #[test]
321    fn rule_object_union_node_expression() {
322        // sh:union of two paths: both reachable values are inferred.
323        let outcome = infer_ttl(
324            br#"
325            @prefix sh: <http://www.w3.org/ns/shacl#> .
326            @prefix ex: <http://ex/> .
327            ex:PersonShape a sh:NodeShape ;
328                sh:targetClass ex:Person ;
329                sh:rule [
330                    a sh:TripleRule ;
331                    sh:subject sh:this ;
332                    sh:predicate ex:contact ;
333                    sh:object [ sh:union ( [ sh:path ex:email ] [ sh:path ex:phone ] ) ]
334                ] .
335            ex:alice a ex:Person ; ex:email "a@x.org" ; ex:phone "555-1234" .
336        "#,
337        );
338        let email = oxrdf::Literal::new_simple_literal("a@x.org");
339        let phone = oxrdf::Literal::new_simple_literal("555-1234");
340        assert!(outcome.graph.contains(&triple_term(
341            "http://ex/alice",
342            "http://ex/contact",
343            email
344        )));
345        assert!(outcome.graph.contains(&triple_term(
346            "http://ex/alice",
347            "http://ex/contact",
348            phone
349        )));
350    }
351
352    #[test]
353    fn rule_object_intersection_node_expression() {
354        // sh:intersection of two paths: only the value reachable by both is inferred.
355        let outcome = infer_ttl(
356            br#"
357            @prefix sh: <http://www.w3.org/ns/shacl#> .
358            @prefix ex: <http://ex/> .
359            ex:S a sh:NodeShape ;
360                sh:targetClass ex:T ;
361                sh:rule [
362                    a sh:TripleRule ;
363                    sh:subject sh:this ;
364                    sh:predicate ex:both ;
365                    sh:object [ sh:intersection ( [ sh:path ex:a ] [ sh:path ex:b ] ) ]
366                ] .
367            ex:x a ex:T ; ex:a ex:shared, ex:onlyA ; ex:b ex:shared, ex:onlyB .
368        "#,
369        );
370        let both = "http://ex/both";
371        assert!(outcome.graph.contains(&triple_term(
372            "http://ex/x",
373            both,
374            oxrdf::NamedNode::new_unchecked("http://ex/shared")
375        )));
376        assert!(!outcome.graph.contains(&triple_term(
377            "http://ex/x",
378            both,
379            oxrdf::NamedNode::new_unchecked("http://ex/onlyA")
380        )));
381        assert!(!outcome.graph.contains(&triple_term(
382            "http://ex/x",
383            both,
384            oxrdf::NamedNode::new_unchecked("http://ex/onlyB")
385        )));
386    }
387
388    #[test]
389    fn rule_object_filter_node_expression() {
390        // sh:filterShape + sh:nodes: keep only the values that conform to the shape.
391        let outcome = infer_ttl(
392            br#"
393            @prefix sh:  <http://www.w3.org/ns/shacl#> .
394            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
395            @prefix ex:  <http://ex/> .
396            ex:IntShape a sh:NodeShape ; sh:datatype xsd:integer .
397            ex:S a sh:NodeShape ;
398                sh:targetClass ex:T ;
399                sh:rule [
400                    a sh:TripleRule ;
401                    sh:subject sh:this ;
402                    sh:predicate ex:intValue ;
403                    sh:object [ sh:filterShape ex:IntShape ; sh:nodes [ sh:path ex:value ] ]
404                ] .
405            ex:x a ex:T ; ex:value 42, "hello" .
406        "#,
407        );
408        let int_value = "http://ex/intValue";
409        assert!(outcome.graph.contains(&triple_term(
410            "http://ex/x",
411            int_value,
412            oxrdf::Literal::new_typed_literal(
413                "42",
414                oxrdf::NamedNode::new_unchecked("http://www.w3.org/2001/XMLSchema#integer")
415            )
416        )));
417        assert!(!outcome.graph.contains(&triple_term(
418            "http://ex/x",
419            int_value,
420            oxrdf::Literal::new_simple_literal("hello")
421        )));
422    }
423
424    #[test]
425    fn planned_validation_preserves_severity_and_applies_threshold() {
426        let ttl = format!(
427            "{PREFIXES}
428            ex:S a sh:NodeShape ;
429                sh:targetNode ex:x ;
430                sh:property ex:InfoShape, ex:WarningShape .
431            ex:InfoShape a sh:PropertyShape ;
432                sh:path ex:required ;
433                sh:minCount 1 ;
434                sh:severity sh:Info .
435            ex:WarningShape a sh:PropertyShape ;
436                sh:path ex:required ;
437                sh:minCount 1 ;
438                sh:severity sh:Warning .
439            "
440        );
441        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
442        let parsed = shifty_parse::parse_loaded(&loaded);
443        let normalized = shifty_opt::normalize(&parsed.schema);
444        let plan = shifty_opt::plan(&normalized);
445
446        let info = validate_plan_with_options(
447            &loaded.graph,
448            &plan,
449            &ValidationOptions {
450                minimum_severity: shifty_algebra::Severity::Info,
451                sort_results: true,
452                ..Default::default()
453            },
454        )
455        .unwrap();
456        assert!(!info.conforms);
457        assert_eq!(info.violations.len(), 1);
458        assert_eq!(
459            info.violations[0].severity,
460            shifty_algebra::Severity::Warning
461        );
462        let mut severities: Vec<_> = info.violations[0]
463            .reasons
464            .iter()
465            .map(|reason| reason.severity.clone())
466            .collect();
467        severities.sort_by_key(shifty_algebra::Severity::rank);
468        assert_eq!(
469            severities,
470            vec![
471                shifty_algebra::Severity::Info,
472                shifty_algebra::Severity::Warning
473            ]
474        );
475
476        let warning = validate_plan_with_options(
477            &loaded.graph,
478            &plan,
479            &ValidationOptions {
480                minimum_severity: shifty_algebra::Severity::Warning,
481                sort_results: true,
482                ..Default::default()
483            },
484        )
485        .unwrap();
486        assert!(!warning.conforms);
487
488        let violation = validate_plan_with_options(
489            &loaded.graph,
490            &plan,
491            &ValidationOptions {
492                minimum_severity: shifty_algebra::Severity::Violation,
493                sort_results: true,
494                ..Default::default()
495            },
496        )
497        .unwrap();
498        assert!(violation.conforms);
499        assert_eq!(violation.violations.len(), 1);
500
501        let report = validate_report_with_options(
502            &loaded,
503            &loaded.graph,
504            &ValidationOptions {
505                minimum_severity: shifty_algebra::Severity::Violation,
506                sort_results: true,
507                ..Default::default()
508            },
509        );
510        assert!(report.conforms);
511        assert_eq!(report.results.len(), 2);
512    }
513
514    #[test]
515    fn validation_findings_sort_by_severity_then_focus_node() {
516        let ttl = format!(
517            "{PREFIXES}
518            ex:InfoShape a sh:NodeShape ;
519                sh:targetNode ex:a ;
520                sh:nodeKind sh:Literal ;
521                sh:severity sh:Info .
522            ex:WarningShape a sh:NodeShape ;
523                sh:targetNode ex:z ;
524                sh:nodeKind sh:Literal ;
525                sh:severity sh:Warning .
526            ex:ViolationShape a sh:NodeShape ;
527                sh:targetNode ex:m ;
528                sh:nodeKind sh:Literal .
529            "
530        );
531        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
532        let parsed = shifty_parse::parse_loaded(&loaded);
533        let plan = shifty_opt::plan(&shifty_opt::normalize(&parsed.schema));
534        let outcome = validate_plan(&loaded.graph, &plan).unwrap();
535
536        let ordered: Vec<_> = outcome
537            .violations
538            .iter()
539            .map(|finding| (finding.severity.clone(), finding.focus.to_string()))
540            .collect();
541        assert_eq!(
542            ordered,
543            vec![
544                (
545                    shifty_algebra::Severity::Violation,
546                    "<http://ex/m>".to_string()
547                ),
548                (
549                    shifty_algebra::Severity::Warning,
550                    "<http://ex/z>".to_string()
551                ),
552                (shifty_algebra::Severity::Info, "<http://ex/a>".to_string()),
553            ]
554        );
555    }
556
557    #[test]
558    fn reports_specific_failing_constraints() {
559        let ttl = format!(
560            "{PREFIXES}
561            ex:S a sh:NodeShape ;
562                sh:targetNode ex:x ;
563                sh:closed true ;
564                sh:ignoredProperties ( rdf:type ) ;
565                sh:property [ sh:path ex:age ; sh:datatype xsd:integer ; sh:maxCount 1 ] .
566            ex:x ex:age \"foo\" , 5 ; ex:extra 1 .
567            "
568        );
569        let outcome = run(&ttl);
570        assert!(!outcome.conforms);
571        assert_eq!(outcome.violations.len(), 1);
572        let msgs: Vec<&str> = outcome.violations[0]
573            .reasons
574            .iter()
575            .map(|r| r.message.as_str())
576            .collect();
577        // each distinct constraint is reported, not just "the node failed"
578        assert!(
579            msgs.iter().any(|m| m.contains("datatype(xsd:integer)")),
580            "missing datatype reason: {msgs:?}"
581        );
582        assert!(
583            msgs.iter().any(|m| m.contains("at most 1")),
584            "missing maxCount reason: {msgs:?}"
585        );
586        assert!(
587            msgs.iter()
588                .any(|m| m.contains("closed") && m.contains("extra")),
589            "missing closed reason: {msgs:?}"
590        );
591    }
592
593    #[test]
594    fn cardinality_and_datatype() {
595        let ttl = format!(
596            "{PREFIXES}
597            ex:S a sh:NodeShape ;
598                sh:targetNode ex:alice, ex:bob ;
599                sh:property [ sh:path ex:age ; sh:maxCount 1 ; sh:datatype xsd:integer ] .
600            ex:alice ex:age 30 .
601            ex:bob   ex:age 30 ; ex:age 40 .
602            "
603        );
604        let outcome = run(&ttl);
605        assert!(!outcome.conforms);
606        // only ex:bob violates maxCount 1
607        let bad: Vec<_> = outcome
608            .violations
609            .iter()
610            .map(|r| r.focus.to_string())
611            .collect();
612        assert_eq!(bad, vec!["<http://ex/bob>".to_string()]);
613    }
614
615    #[test]
616    fn qualified_value_shape_disjoint_uses_all_sibling_property_shapes() {
617        let ttl = format!(
618            "{PREFIXES}
619            ex:S a sh:NodeShape ;
620                sh:targetNode ex:x ;
621                sh:property ex:A, ex:B .
622            ex:A a sh:PropertyShape ;
623                sh:path ex:p ;
624                sh:qualifiedValueShape [ sh:class ex:TypeA ] ;
625                sh:qualifiedValueShapesDisjoint true ;
626                sh:qualifiedMinCount 1 .
627            ex:B a sh:PropertyShape ;
628                sh:path ex:q ;
629                sh:qualifiedValueShape [ sh:class ex:TypeB ] ;
630                sh:qualifiedValueShapesDisjoint true ;
631                sh:qualifiedMaxCount 10 .
632            ex:x ex:p ex:value .
633            ex:value a ex:TypeA, ex:TypeB .
634            "
635        );
636        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
637        assert!(
638            parsed.diagnostics.is_empty(),
639            "diags: {:?}",
640            parsed.diagnostics
641        );
642        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
643
644        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
645        assert!(!algebra.conforms);
646
647        let report = validate_report(&loaded, &loaded.graph);
648        assert!(!report.conforms);
649        assert_eq!(report.results.len(), 1);
650        assert_eq!(
651            report.results[0].component.as_str(),
652            "http://www.w3.org/ns/shacl#QualifiedMinCountConstraintComponent"
653        );
654    }
655
656    #[test]
657    fn disjoint_on_node_shape_uses_the_focus_node_as_the_value() {
658        let ttl = format!(
659            "{PREFIXES}
660            ex:S a sh:NodeShape ;
661                sh:targetNode ex:valid, ex:invalid ;
662                sh:disjoint ex:p .
663            ex:valid ex:p ex:other .
664            ex:invalid ex:p ex:invalid .
665            "
666        );
667        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
668        assert!(
669            parsed.diagnostics.is_empty(),
670            "diags: {:?}",
671            parsed.diagnostics
672        );
673        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
674
675        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
676        assert!(!algebra.conforms);
677        assert_eq!(algebra.violations.len(), 1);
678        assert_eq!(
679            algebra.violations[0].focus.to_string(),
680            "<http://ex/invalid>"
681        );
682
683        let normalized = shifty_opt::normalize(&parsed.schema);
684        let plan = shifty_opt::plan(&normalized);
685        let planned = validate_plan(&loaded.graph, &plan).unwrap();
686        assert_eq!(planned.conforms, algebra.conforms);
687        assert_eq!(planned.violations.len(), algebra.violations.len());
688
689        let report = validate_report(&loaded, &loaded.graph);
690        assert!(!report.conforms);
691        assert_eq!(report.results.len(), 1);
692        assert_eq!(
693            report.results[0].component.as_str(),
694            "http://www.w3.org/ns/shacl#DisjointConstraintComponent"
695        );
696        assert_eq!(
697            report.results[0].value.as_ref().map(ToString::to_string),
698            Some("<http://ex/invalid>".to_string())
699        );
700    }
701
702    #[test]
703    fn expression_constraint_reports_non_true_values() {
704        // The W3C booleans-001 shape: sh:expression sh:this over boolean foci.
705        let ttl = format!(
706            "{PREFIXES}
707            ex:S a sh:NodeShape ;
708                sh:targetNode true, false ;
709                sh:expression sh:this .
710            "
711        );
712        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
713        assert!(
714            parsed.diagnostics.is_empty(),
715            "diags: {:?}",
716            parsed.diagnostics
717        );
718        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
719
720        // algebra path: only the `false` focus violates.
721        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
722        assert!(!algebra.conforms);
723        assert_eq!(algebra.violations.len(), 1);
724        assert_eq!(
725            algebra.violations[0].focus,
726            oxrdf::Term::Literal(oxrdf::Literal::new_typed_literal(
727                "false",
728                oxrdf::vocab::xsd::BOOLEAN
729            ))
730        );
731
732        // planned path agrees with the algebra oracle.
733        let normalized = shifty_opt::normalize(&parsed.schema);
734        let plan = shifty_opt::plan(&normalized);
735        let planned = validate_plan(&loaded.graph, &plan).unwrap();
736        assert_eq!(planned.conforms, algebra.conforms);
737        assert_eq!(planned.violations.len(), algebra.violations.len());
738
739        // W3C report path: one ExpressionConstraintComponent result, sh:value false.
740        let report = validate_report(&loaded, &loaded.graph);
741        assert!(!report.conforms);
742        assert_eq!(report.results.len(), 1);
743        let result = &report.results[0];
744        assert_eq!(
745            result.component.as_str(),
746            "http://www.w3.org/ns/shacl#ExpressionConstraintComponent"
747        );
748        assert_eq!(result.path, None);
749        assert_eq!(
750            result.value.as_ref().map(ToString::to_string),
751            Some("\"false\"^^<http://www.w3.org/2001/XMLSchema#boolean>".to_string())
752        );
753    }
754
755    #[test]
756    fn expression_constraint_with_path_and_filter() {
757        // The expression traverses a path from the focus and filters the values
758        // by a shape; only nodes passing the filter must (here, fail to) be true,
759        // exercising Path + Filter node expressions on the report path.
760        let ttl = format!(
761            "{PREFIXES}
762            ex:S a sh:NodeShape ;
763                sh:targetNode ex:x ;
764                sh:expression [
765                    sh:filterShape [ sh:datatype xsd:boolean ] ;
766                    sh:nodes [ sh:path ex:flag ] ;
767                ] .
768            ex:x ex:flag true, false, \"not-a-bool\" .
769            "
770        );
771        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
772        assert!(
773            parsed.diagnostics.is_empty(),
774            "diags: {:?}",
775            parsed.diagnostics
776        );
777        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
778
779        // The boolean values that survive the filter are {true, false}; `false`
780        // is the lone non-true value, so exactly one result, sh:value false.
781        let report = validate_report(&loaded, &loaded.graph);
782        assert!(!report.conforms);
783        assert_eq!(report.results.len(), 1);
784        assert_eq!(
785            report.results[0].component.as_str(),
786            "http://www.w3.org/ns/shacl#ExpressionConstraintComponent"
787        );
788        assert_eq!(
789            report.results[0].value.as_ref().map(ToString::to_string),
790            Some("\"false\"^^<http://www.w3.org/2001/XMLSchema#boolean>".to_string())
791        );
792
793        // algebra path agrees on (non-)conformance.
794        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
795        assert!(!algebra.conforms);
796        assert_eq!(algebra.violations.len(), 1);
797    }
798
799    #[test]
800    fn expression_constraint_with_function_is_diagnosed() {
801        // A function application inside sh:expression cannot be evaluated by the
802        // validation paths yet, so it is diagnosed rather than silently dropped.
803        let ttl = format!(
804            "{PREFIXES}
805            ex:S a sh:NodeShape ;
806                sh:targetNode ex:x ;
807                sh:expression [ ex:fn ( sh:this ) ] .
808            "
809        );
810        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
811        assert!(
812            parsed
813                .diagnostics
814                .iter()
815                .any(|d| d.message.contains("sh:expression")),
816            "expected an unsupported-expression diagnostic, got: {:?}",
817            parsed.diagnostics
818        );
819    }
820
821    #[test]
822    fn sparql_function_expression_evaluates() {
823        // The W3C simpleSPARQLFunction shapes: a no-arg ASK function and a
824        // two-argument SELECT function, called via dash:expression strings.
825        let ttl = br#"
826            @prefix sh: <http://www.w3.org/ns/shacl#> .
827            @prefix ex: <http://ex/> .
828            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
829            ex:booleanFunction a sh:SPARQLFunction ;
830                sh:returnType xsd:boolean ;
831                sh:ask "ASK { FILTER (true) }" .
832            ex:withArguments a sh:SPARQLFunction ;
833                sh:parameter [ sh:name "arg1" ; sh:path ex:arg1 ] ,
834                             [ sh:name "arg2" ; sh:path ex:arg2 ] ;
835                sh:returnType xsd:string ;
836                sh:select "SELECT ?result WHERE { BIND (CONCAT($arg1, \"-\", $arg2) AS ?result) }" .
837        "#;
838        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
839
840        let b = evaluate_function_expression(&loaded, "ex:booleanFunction()").unwrap();
841        assert_eq!(b, Some(oxrdf::Term::Literal(oxrdf::Literal::from(true))));
842
843        let s = evaluate_function_expression(&loaded, "ex:withArguments(\"A\", \"B\")").unwrap();
844        assert_eq!(
845            s,
846            Some(oxrdf::Term::Literal(oxrdf::Literal::new_simple_literal(
847                "A-B"
848            )))
849        );
850    }
851
852    #[test]
853    fn sparql_function_called_from_sparql_constraint() {
854        // A SHACL function is callable inside a sh:sparql constraint query: the
855        // constraint flags values for which ex:isOk returns false.
856        let ttl = br#"
857            @prefix sh: <http://www.w3.org/ns/shacl#> .
858            @prefix ex: <http://ex/> .
859            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
860            ex:isOk a sh:SPARQLFunction ;
861                sh:parameter [ sh:path ex:arg ] ;
862                sh:returnType xsd:boolean ;
863                sh:ask "ASK { FILTER (STR($arg) = \"ok\") }" .
864            ex:S a sh:NodeShape ;
865                sh:targetNode ex:x, ex:y ;
866                sh:sparql [ sh:select """SELECT $this ?value WHERE {
867                    $this <http://ex/val> ?value .
868                    FILTER (! <http://ex/isOk>(?value))
869                }""" ] .
870            ex:x <http://ex/val> "ok" .
871            ex:y <http://ex/val> "bad" .
872        "#;
873        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
874        let report = validate_report(&loaded, &loaded.graph);
875        assert!(!report.conforms);
876        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
877        assert_eq!(report.results[0].focus.to_string(), "<http://ex/y>");
878        assert_eq!(
879            report.results[0].value.as_ref().map(ToString::to_string),
880            Some("\"bad\"".to_string())
881        );
882    }
883
884    #[test]
885    fn graph_reading_function_policy_gates_loud_failure() {
886        // `ex:exists` reads the data graph, so from a SPARQL context it can only
887        // be evaluated over an empty dataset. The UnsupportedPolicy decides what
888        // happens when it is called from a sh:sparql constraint.
889        let ttl = br#"
890            @prefix sh: <http://www.w3.org/ns/shacl#> .
891            @prefix ex: <http://ex/> .
892            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
893            ex:exists a sh:SPARQLFunction ;
894                sh:returnType xsd:boolean ;
895                sh:ask "ASK { ?s <http://ex/marker> ?o }" .
896            ex:S a sh:NodeShape ;
897                sh:targetNode ex:x ;
898                sh:sparql [ sh:select
899                    "SELECT $this WHERE { $this a <http://ex/T> . FILTER (<http://ex/exists>()) }" ] .
900            ex:x a <http://ex/T> .
901            ex:y <http://ex/marker> "m" .
902        "#;
903        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
904
905        // Ignore (default): the function runs over an empty dataset (best-effort);
906        // here it returns false, so the constraint silently conforms.
907        let lenient = validate_report(&loaded, &loaded.graph);
908        assert!(lenient.conforms, "results: {:?}", lenient.results);
909
910        // Error: the graph-reading function is not registered, so the SPARQL call
911        // fails and the constraint fails closed (loud) rather than silently wrong.
912        let strict_opts = ValidationOptions {
913            engine: EngineOptions {
914                unsupported: UnsupportedPolicy::Error,
915            },
916            ..Default::default()
917        };
918        let strict = validate_report_with_options(&loaded, &loaded.graph, &strict_opts);
919        assert!(!strict.conforms);
920        assert_eq!(strict.results.len(), 1, "results: {:?}", strict.results);
921    }
922
923    #[test]
924    fn custom_component_ask_validator() {
925        // A two-parameter ASK component (the W3C validator-001 shape): a value
926        // conforms iff it is the concatenation of the two parameters.
927        let ttl = br#"
928            @prefix sh: <http://www.w3.org/ns/shacl#> .
929            @prefix ex: <http://ex/> .
930            ex:TestConstraintComponent a sh:ConstraintComponent ;
931                sh:parameter [ sh:path ex:test1 ] , [ sh:path ex:test2 ] ;
932                sh:validator [ a sh:SPARQLAskValidator ;
933                    sh:ask "ASK { FILTER (?value = CONCAT($test1, $test2)) }" ] .
934            ex:TestShape a sh:NodeShape ;
935                ex:test1 "Hello " ;
936                ex:test2 "World" ;
937                sh:targetNode "Hallo Welt", "Hello World" .
938        "#;
939        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
940        let report = validate_report(&loaded, &loaded.graph);
941        assert!(!report.conforms);
942        assert_eq!(report.results.len(), 1);
943        let r = &report.results[0];
944        assert_eq!(r.component.as_str(), "http://ex/TestConstraintComponent");
945        assert_eq!(r.focus.to_string(), "\"Hallo Welt\"");
946        assert_eq!(
947            r.value.as_ref().map(ToString::to_string),
948            Some("\"Hallo Welt\"".to_string())
949        );
950        assert_eq!(r.source_shape.to_string(), "<http://ex/TestShape>");
951        assert_eq!(r.path, None);
952
953        // The algebra path now evaluates components too: "Hallo Welt" should
954        // still be the only violation.
955        let parse_out = shifty_parse::parse_loaded(&loaded);
956        let schema = shifty_opt::normalize(&parse_out.schema);
957        let plan = shifty_opt::plan(&schema);
958        let outcome = validate_plan_graphs(&loaded.graph, &loaded.graph, &plan).unwrap();
959        assert!(!outcome.conforms, "algebra: Hallo Welt must still violate");
960        assert_eq!(
961            outcome.violations.len(),
962            1,
963            "algebra: exactly one violation: {:?}",
964            outcome.violations
965        );
966        assert_eq!(
967            outcome.violations[0].focus.to_string(),
968            "\"Hallo Welt\"",
969            "algebra: wrong focus node"
970        );
971    }
972
973    #[test]
974    fn custom_component_select_node_validator() {
975        // A SELECT node validator with a required parameter: a focus violates
976        // when it lacks an ex:property edge equal to the bound $requiredParam.
977        let ttl = br#"
978            @prefix sh: <http://www.w3.org/ns/shacl#> .
979            @prefix ex: <http://ex/> .
980            ex:C a sh:ConstraintComponent ;
981                sh:parameter [ sh:path ex:requiredParam ] ;
982                sh:nodeValidator [ a sh:SPARQLSelectValidator ;
983                    sh:select """SELECT $this WHERE {
984                        $this ?p ?o .
985                        FILTER NOT EXISTS { $this <http://ex/property> $requiredParam }
986                    }""" ] .
987            ex:S a sh:NodeShape ;
988                ex:requiredParam "Value" ;
989                sh:targetNode ex:Good, ex:Bad .
990            ex:Good <http://ex/property> "Value" .
991            ex:Bad <http://ex/property> "Other" .
992        "#;
993        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
994        let report = validate_report(&loaded, &loaded.graph);
995        assert!(!report.conforms);
996        assert_eq!(report.results.len(), 1);
997        let r = &report.results[0];
998        assert_eq!(r.component.as_str(), "http://ex/C");
999        assert_eq!(r.focus.to_string(), "<http://ex/Bad>");
1000        assert_eq!(
1001            r.value.as_ref().map(ToString::to_string),
1002            Some("<http://ex/Bad>".to_string())
1003        );
1004    }
1005
1006    #[test]
1007    fn custom_component_not_activated_when_mandatory_param_absent() {
1008        // The component is only activated for shapes that supply every mandatory
1009        // parameter; a shape missing it produces no results (and no diagnostic).
1010        let ttl = br#"
1011            @prefix sh: <http://www.w3.org/ns/shacl#> .
1012            @prefix ex: <http://ex/> .
1013            ex:C a sh:ConstraintComponent ;
1014                sh:parameter [ sh:path ex:requiredParam ] ;
1015                sh:validator [ a sh:SPARQLAskValidator ; sh:ask "ASK { FILTER (false) }" ] .
1016            ex:S a sh:NodeShape ;
1017                sh:targetNode ex:x .
1018            ex:x ex:other "z" .
1019        "#;
1020        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
1021        let report = validate_report(&loaded, &loaded.graph);
1022        assert!(report.conforms, "results: {:?}", report.results);
1023
1024        let parsed = parse_turtle(ttl, None).unwrap();
1025        assert!(
1026            !parsed
1027                .diagnostics
1028                .iter()
1029                .any(|d| d.message.contains("custom constraint component")),
1030            "an inactive component must not be diagnosed: {:?}",
1031            parsed.diagnostics
1032        );
1033    }
1034
1035    #[test]
1036    fn custom_component_property_validator_complex_path() {
1037        // A property shape with a *sequence* path activates a SELECT property
1038        // validator: `$PATH` is pre-bound to ex:a/ex:b, so the validator reaches
1039        // the value nodes two hops away and flags the forbidden one.
1040        let ttl = br#"
1041            @prefix sh: <http://www.w3.org/ns/shacl#> .
1042            @prefix ex: <http://ex/> .
1043            ex:ForbidComponent a sh:ConstraintComponent ;
1044                sh:parameter [ sh:path ex:forbidden ] ;
1045                sh:propertyValidator [ a sh:SPARQLSelectValidator ;
1046                    sh:select """SELECT $this ?value WHERE {
1047                        $this $PATH ?value .
1048                        FILTER (STR(?value) = STR($forbidden))
1049                    }""" ] .
1050            ex:S a sh:NodeShape ;
1051                sh:targetNode ex:x ;
1052                sh:property [ sh:path ( ex:a ex:b ) ; ex:forbidden "bad" ] .
1053            ex:x ex:a ex:m .
1054            ex:m ex:b "bad", "ok" .
1055        "#;
1056        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
1057        let report = validate_report(&loaded, &loaded.graph);
1058        assert!(!report.conforms);
1059        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
1060        let r = &report.results[0];
1061        assert_eq!(r.component.as_str(), "http://ex/ForbidComponent");
1062        assert_eq!(r.focus.to_string(), "<http://ex/x>");
1063        assert_eq!(
1064            r.value.as_ref().map(ToString::to_string),
1065            Some("\"bad\"".to_string())
1066        );
1067    }
1068
1069    #[test]
1070    fn custom_component_property_validator_inverse_path() {
1071        // An inverse path `^ex:parent`: the value nodes are the subjects that
1072        // point at the focus via ex:parent. The ASK validator runs per value node
1073        // with `$PATH` pre-bound, flagging values whose label is not "ok".
1074        let ttl = br#"
1075            @prefix sh: <http://www.w3.org/ns/shacl#> .
1076            @prefix ex: <http://ex/> .
1077            ex:OkComponent a sh:ConstraintComponent ;
1078                sh:parameter [ sh:path ex:want ] ;
1079                sh:validator [ a sh:SPARQLAskValidator ;
1080                    sh:ask "ASK { $value <http://ex/label> $want }" ] .
1081            ex:S a sh:NodeShape ;
1082                sh:targetNode ex:p ;
1083                sh:property [ sh:path [ sh:inversePath ex:parent ] ; ex:want "ok" ] .
1084            ex:c1 ex:parent ex:p ; ex:label "ok" .
1085            ex:c2 ex:parent ex:p ; ex:label "no" .
1086        "#;
1087        let loaded = shifty_parse::load_turtle(ttl, None).unwrap();
1088        let report = validate_report(&loaded, &loaded.graph);
1089        // Value nodes of ex:p along ^ex:parent are {ex:c1, ex:c2}; only ex:c2
1090        // lacks `ex:label "ok"`, so the ASK is false there → one violation.
1091        assert!(!report.conforms);
1092        assert_eq!(report.results.len(), 1, "results: {:?}", report.results);
1093        assert_eq!(
1094            report.results[0].component.as_str(),
1095            "http://ex/OkComponent"
1096        );
1097        assert_eq!(
1098            report.results[0].value.as_ref().map(ToString::to_string),
1099            Some("<http://ex/c2>".to_string())
1100        );
1101    }
1102
1103    #[test]
1104    fn custom_component_ask_validator_subquery_count() {
1105        // ASK validator that counts graph-wide instances of $class and checks
1106        // the total equals $exactCount (the BuildingMOTIF exactCount pattern).
1107        // The correct SPARQL uses a subquery to aggregate, then FILTER in the
1108        // outer query — avoiding the invalid `SELECT * … HAVING (aggregate)`
1109        // pattern that spargebra rightly rejects.
1110        let shapes_ttl = br#"
1111            @prefix sh:  <http://www.w3.org/ns/shacl#> .
1112            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
1113            @prefix ex:  <urn:ex/> .
1114
1115            ex:countComponent a sh:ConstraintComponent ;
1116                sh:parameter [ sh:path ex:exactCount ; sh:datatype xsd:integer ] ;
1117                sh:parameter [ sh:path ex:class ] ;
1118                sh:validator ex:hasExactCount .
1119
1120            ex:hasExactCount a sh:SPARQLAskValidator ;
1121                sh:message "Wrong count" ;
1122                sh:ask """
1123                    ASK {
1124                        {
1125                            SELECT (COUNT(DISTINCT ?i) AS ?count)
1126                            WHERE { ?i a $class . }
1127                        }
1128                        FILTER (?count = $exactCount)
1129                    }
1130                """ .
1131
1132            ex:shape a sh:NodeShape ;
1133                sh:targetNode ex:sentinel ;
1134                ex:class ex:Thing ;
1135                ex:exactCount 1 .
1136        "#;
1137        let shapes = shifty_parse::load_turtle(shapes_ttl, None).unwrap();
1138
1139        // Zero instances → violates
1140        let data_none =
1141            shifty_parse::load_turtle(b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel .", None)
1142                .unwrap();
1143        let report = validate_report_graphs(&shapes, &data_none.graph);
1144        assert!(
1145            !report.conforms,
1146            "zero Things with exactCount=1 must not conform: {:?}",
1147            report.results
1148        );
1149
1150        // Exactly one instance → conforms
1151        let data_one = shifty_parse::load_turtle(
1152            b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel . ex:t1 a ex:Thing .",
1153            None,
1154        )
1155        .unwrap();
1156        let report_ok = validate_report_graphs(&shapes, &data_one.graph);
1157        assert!(
1158            report_ok.conforms,
1159            "exactly one Thing with exactCount=1 must conform: {:?}",
1160            report_ok.results
1161        );
1162
1163        // Two instances → violates
1164        let data_two = shifty_parse::load_turtle(
1165            b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel . ex:t1 a ex:Thing . ex:t2 a ex:Thing .",
1166            None,
1167        )
1168        .unwrap();
1169        let report_two = validate_report_graphs(&shapes, &data_two.graph);
1170        assert!(
1171            !report_two.conforms,
1172            "two Things with exactCount=1 must not conform: {:?}",
1173            report_two.results
1174        );
1175
1176        // Same checks via the algebra path.
1177        let parse_out = shifty_parse::parse_loaded(&shapes);
1178        let schema = shifty_opt::normalize(&parse_out.schema);
1179        let plan = shifty_opt::plan(&schema);
1180        let alg_none = validate_plan_graphs(&data_none.graph, &shapes.graph, &plan).unwrap();
1181        assert!(!alg_none.conforms, "algebra: zero Things must not conform");
1182        let alg_one = validate_plan_graphs(&data_one.graph, &shapes.graph, &plan).unwrap();
1183        assert!(alg_one.conforms, "algebra: one Thing must conform");
1184        let alg_two = validate_plan_graphs(&data_two.graph, &shapes.graph, &plan).unwrap();
1185        assert!(!alg_two.conforms, "algebra: two Things must not conform");
1186    }
1187
1188    #[test]
1189    fn custom_component_invalid_sparql_ignored_by_default() {
1190        // A validator whose sh:ask is invalid SPARQL (SELECT * with an implicit
1191        // GROUP BY from HAVING) is silently skipped under the default Ignore
1192        // policy, so the component is not enforced and the shape appears to
1193        // conform even when the data does not.  This documents the known
1194        // silent-skip behaviour; use on_unsupported="error" to surface it.
1195        let shapes_ttl = br#"
1196            @prefix sh:  <http://www.w3.org/ns/shacl#> .
1197            @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
1198            @prefix ex:  <urn:ex/> .
1199
1200            ex:badComponent a sh:ConstraintComponent ;
1201                sh:parameter [ sh:path ex:exactCount ; sh:datatype xsd:integer ] ;
1202                sh:parameter [ sh:path ex:class ] ;
1203                sh:validator [ a sh:SPARQLAskValidator ;
1204                    sh:ask """
1205                        ASK WHERE {
1206                            {
1207                                SELECT *
1208                                WHERE { ?i a $class . }
1209                                HAVING (COUNT(DISTINCT ?i) = $exactCount)
1210                            }
1211                        }
1212                    """ ] .
1213
1214            ex:shape a sh:NodeShape ;
1215                sh:targetNode ex:sentinel ;
1216                ex:class ex:Thing ;
1217                ex:exactCount 1 .
1218        "#;
1219        let shapes = shifty_parse::load_turtle(shapes_ttl, None).unwrap();
1220        let data =
1221            shifty_parse::load_turtle(b"@prefix ex: <urn:ex/> . ex:sentinel a ex:Sentinel .", None)
1222                .unwrap();
1223
1224        // Ignore policy (default): bad query silently skipped → appears to conform
1225        let report = validate_report_graphs(&shapes, &data.graph);
1226        assert!(
1227            report.conforms,
1228            "bad validator query silently skipped under Ignore: {:?}",
1229            report.results
1230        );
1231    }
1232
1233    #[test]
1234    fn equals_on_node_shape_uses_the_focus_node_as_the_value() {
1235        let ttl = format!(
1236            "{PREFIXES}
1237            ex:S a sh:NodeShape ;
1238                sh:targetNode ex:valid, ex:extra, ex:missing ;
1239                sh:equals ex:p .
1240            ex:valid ex:p ex:valid .
1241            ex:extra ex:p ex:extra, ex:other .
1242            "
1243        );
1244        let parsed = parse_turtle(ttl.as_bytes(), None).unwrap();
1245        assert!(
1246            parsed.diagnostics.is_empty(),
1247            "diags: {:?}",
1248            parsed.diagnostics
1249        );
1250        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1251
1252        let algebra = validate(&loaded.graph, &parsed.schema).unwrap();
1253        assert!(!algebra.conforms);
1254        let mut foci: Vec<_> = algebra
1255            .violations
1256            .iter()
1257            .map(|violation| violation.focus.to_string())
1258            .collect();
1259        foci.sort();
1260        assert_eq!(
1261            foci,
1262            [
1263                "<http://ex/extra>".to_string(),
1264                "<http://ex/missing>".to_string()
1265            ]
1266        );
1267
1268        let normalized = shifty_opt::normalize(&parsed.schema);
1269        let plan = shifty_opt::plan(&normalized);
1270        let planned = validate_plan(&loaded.graph, &plan).unwrap();
1271        assert_eq!(planned.conforms, algebra.conforms);
1272        assert_eq!(planned.violations.len(), algebra.violations.len());
1273
1274        let report = validate_report(&loaded, &loaded.graph);
1275        assert!(!report.conforms);
1276        assert_eq!(report.results.len(), 2);
1277        assert!(report.results.iter().all(|result| result.component.as_str()
1278            == "http://www.w3.org/ns/shacl#EqualsConstraintComponent"));
1279    }
1280
1281    #[test]
1282    fn datatype_violation() {
1283        let ttl = format!(
1284            "{PREFIXES}
1285            ex:S a sh:NodeShape ;
1286                sh:targetNode ex:x ;
1287                sh:property [ sh:path ex:p ; sh:datatype xsd:integer ] .
1288            ex:x ex:p \"hello\" .
1289            "
1290        );
1291        assert!(!run(&ttl).conforms);
1292    }
1293
1294    #[test]
1295    fn nodekind_and_class_target() {
1296        let ttl = format!(
1297            "{PREFIXES}
1298            ex:S a sh:NodeShape ;
1299                sh:targetClass ex:Person ;
1300                sh:property [ sh:path ex:knows ; sh:nodeKind sh:IRI ] .
1301            ex:alice a ex:Person ; ex:knows ex:bob .
1302            ex:carol a ex:Person ; ex:knows \"notaniri\" .
1303            "
1304        );
1305        let outcome = run(&ttl);
1306        assert!(!outcome.conforms);
1307        let bad: Vec<_> = outcome
1308            .violations
1309            .iter()
1310            .map(|r| r.focus.to_string())
1311            .collect();
1312        assert_eq!(bad, vec!["<http://ex/carol>".to_string()]);
1313    }
1314
1315    #[test]
1316    fn recursion_over_cyclic_data_terminates() {
1317        // S requires every ex:knows neighbour to also satisfy S; data is a cycle.
1318        let ttl = format!(
1319            "{PREFIXES}
1320            ex:S a sh:NodeShape ;
1321                sh:targetNode ex:a ;
1322                sh:property [ sh:path ex:knows ; sh:node ex:S ; sh:nodeKind sh:IRI ] .
1323            ex:a ex:knows ex:b .
1324            ex:b ex:knows ex:a .
1325            "
1326        );
1327        // Must terminate; with all-IRI neighbours it conforms under the
1328        // provisional cycle-breaking semantics.
1329        assert!(run(&ttl).conforms);
1330    }
1331
1332    #[test]
1333    fn empty_graph_conforms() {
1334        let outcome = validate(&Graph::new(), &shifty_algebra::Schema::new()).unwrap();
1335        assert!(outcome.conforms);
1336    }
1337
1338    #[test]
1339    fn non_stratifiable_schema_is_diagnosed() {
1340        // S := ¬∃p.S — recursion through negation; no defined 2-valued semantics.
1341        let ttl = format!(
1342            "{PREFIXES}
1343            ex:S a sh:NodeShape ;
1344                sh:targetNode ex:x ;
1345                sh:not [ sh:path ex:p ; sh:qualifiedValueShape ex:S ; sh:qualifiedMinCount 1 ] .
1346            ex:x ex:p ex:y .
1347            "
1348        );
1349        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1350        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1351        assert!(validate(&loaded.graph, &out.schema).is_err());
1352    }
1353
1354    fn triple(s: &str, p: &str, o: &str) -> oxrdf::Triple {
1355        use oxrdf::NamedNode;
1356        oxrdf::Triple::new(
1357            NamedNode::new(s).unwrap(),
1358            NamedNode::new(p).unwrap(),
1359            NamedNode::new(o).unwrap(),
1360        )
1361    }
1362
1363    #[test]
1364    fn triple_rule_infers_from_path() {
1365        // copy each ex:knows value to ex:knows2
1366        let ttl = format!(
1367            "{PREFIXES}
1368            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
1369                sh:rule [ a sh:TripleRule ;
1370                    sh:subject sh:this ; sh:predicate ex:knows2 ;
1371                    sh:object [ sh:path ex:knows ] ] .
1372            ex:a a ex:Person ; ex:knows ex:b .
1373            "
1374        );
1375        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1376        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1377        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1378        assert_eq!(outcome.inferred.len(), 1);
1379        assert!(
1380            outcome
1381                .graph
1382                .contains(&triple("http://ex/a", "http://ex/knows2", "http://ex/b"))
1383        );
1384    }
1385
1386    #[test]
1387    fn inference_reaches_a_fixpoint() {
1388        // ex:reaches := ex:knows ∪ (ex:knows / ex:reaches) — transitive closure
1389        // a→b→c, so a reaches c is derivable only after b reaches c.
1390        let ttl = format!(
1391            "{PREFIXES}
1392            ex:S a sh:NodeShape ; sh:targetClass ex:Person ;
1393                sh:rule [ a sh:TripleRule ;
1394                    sh:subject sh:this ; sh:predicate ex:reaches ;
1395                    sh:object [ sh:path [ sh:alternativePath ( ex:knows ( ex:knows ex:reaches ) ) ] ] ] .
1396            ex:a a ex:Person ; ex:knows ex:b .
1397            ex:b a ex:Person ; ex:knows ex:c .
1398            ex:c a ex:Person .
1399            "
1400        );
1401        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1402        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1403        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1404        assert!(
1405            outcome
1406                .graph
1407                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/b"))
1408        );
1409        assert!(
1410            outcome
1411                .graph
1412                .contains(&triple("http://ex/b", "http://ex/reaches", "http://ex/c"))
1413        );
1414        // the fixpoint result: a reaches c (only via b reaches c)
1415        assert!(
1416            outcome
1417                .graph
1418                .contains(&triple("http://ex/a", "http://ex/reaches", "http://ex/c"))
1419        );
1420    }
1421
1422    #[test]
1423    fn later_order_output_reactivates_an_earlier_rule() {
1424        let ttl = format!(
1425            "{PREFIXES}
1426            ex:S a sh:NodeShape ; sh:targetNode ex:x ;
1427                sh:rule [
1428                    a sh:TripleRule ; sh:order 0 ;
1429                    sh:subject sh:this ; sh:predicate ex:done ;
1430                    sh:object [ sh:path ex:ready ]
1431                ] ;
1432                sh:rule [
1433                    a sh:TripleRule ; sh:order 1 ;
1434                    sh:subject sh:this ; sh:predicate ex:ready ;
1435                    sh:object ex:y
1436                ] .
1437            "
1438        );
1439        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1440        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1441        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1442
1443        assert!(
1444            outcome
1445                .graph
1446                .contains(&triple("http://ex/x", "http://ex/ready", "http://ex/y"))
1447        );
1448        assert!(
1449            outcome
1450                .graph
1451                .contains(&triple("http://ex/x", "http://ex/done", "http://ex/y"))
1452        );
1453    }
1454
1455    #[test]
1456    fn inferred_triples_can_create_new_rule_targets() {
1457        let ttl = format!(
1458            "{PREFIXES}
1459            ex:Seed a sh:NodeShape ; sh:targetNode ex:x ;
1460                sh:rule [
1461                    a sh:TripleRule ;
1462                    sh:subject sh:this ; sh:predicate ex:eligible ;
1463                    sh:object ex:y
1464                ] .
1465            ex:Eligible a sh:NodeShape ; sh:targetSubjectsOf ex:eligible ;
1466                sh:rule [
1467                    a sh:TripleRule ;
1468                    sh:subject sh:this ; sh:predicate ex:classified ;
1469                    sh:object ex:yes
1470                ] .
1471            "
1472        );
1473        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
1474        let loaded = shifty_parse::load_turtle(ttl.as_bytes(), None).unwrap();
1475        let outcome = infer(&loaded.graph, &out.schema).unwrap();
1476
1477        assert!(outcome.graph.contains(&triple(
1478            "http://ex/x",
1479            "http://ex/classified",
1480            "http://ex/yes",
1481        )));
1482    }
1483
1484    #[test]
1485    fn split_inference_uses_shapes_graph_as_rule_context() {
1486        let shapes_ttl = format!(
1487            "{PREFIXES}
1488            ex:InverseShape a sh:NodeShape ;
1489                sh:targetClass ex:Thing ;
1490                sh:rule [
1491                    a sh:SPARQLRule ;
1492                    sh:construct \"\"\"
1493                        CONSTRUCT {{ ?o ?inverse $this }}
1494                        WHERE {{
1495                            $this ?predicate ?o .
1496                            ?predicate ex:inverseOf ?inverse .
1497                        }}
1498                    \"\"\"
1499                ] .
1500            ex:p ex:inverseOf ex:q .
1501            "
1502        );
1503        let data_ttl = format!(
1504            "{PREFIXES}
1505            ex:a a ex:Thing ; ex:p ex:b .
1506            "
1507        );
1508        let shapes = shifty_parse::load_turtle(shapes_ttl.as_bytes(), None).unwrap();
1509        let parsed = shifty_parse::parse_loaded(&shapes);
1510        let data = shifty_parse::load_turtle(data_ttl.as_bytes(), None).unwrap();
1511
1512        let outcome = infer_graphs(&data.graph, &shapes.graph, &parsed.schema).unwrap();
1513
1514        assert!(
1515            outcome
1516                .graph
1517                .contains(&triple("http://ex/b", "http://ex/q", "http://ex/a"))
1518        );
1519        assert_eq!(outcome.inferred.len(), 1);
1520    }
1521}