1use std::collections::{BTreeMap, BTreeSet};
37
38use schemars::JsonSchema;
39use serde::{Deserialize, Serialize};
40use serde_json::Value;
41
42use crate::capability::{Capability, CapabilityKind, CapabilityRegistry, Provenance};
43use crate::index::Index;
44use crate::model::Object;
45use crate::web::Topology;
46
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
51pub struct Node {
52 pub id: String,
54 pub kind: String,
56 pub label: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub summary: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub route: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub source: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub status: Option<String>,
70 #[serde(default, skip_serializing_if = "is_false")]
73 pub external: bool,
74 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
80 pub facts: BTreeMap<String, Fact>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
85#[serde(untagged)]
86pub enum Fact {
87 Flag(bool),
89 Count(i64),
91 Text(String),
93 List(Vec<String>),
95}
96
97const FACT_LIMIT: usize = 200;
109
110const PROJECTED_FIELDS: &[&str] = &[
113 "id",
114 "version",
115 "kind",
116 "title",
117 "description",
118 "status",
119 "schema",
120];
121
122fn facts(metadata: &Value) -> BTreeMap<String, Fact> {
126 let Some(map) = metadata.as_object() else {
127 return BTreeMap::new();
128 };
129 let short = |s: &str| s.len() <= FACT_LIMIT;
130 map.iter()
131 .filter(|(k, _)| !PROJECTED_FIELDS.contains(&k.as_str()))
132 .filter_map(|(k, v)| {
133 let fact = match v {
134 Value::Bool(b) => Fact::Flag(*b),
135 Value::Number(n) => Fact::Count(n.as_i64()?),
136 Value::String(s) if short(s) => Fact::Text(s.clone()),
137 Value::Array(items) => {
138 let texts: Option<Vec<String>> = items
141 .iter()
142 .map(|i| match i {
143 Value::String(s) if short(s) => Some(s.clone()),
144 Value::Bool(b) => Some(b.to_string()),
145 Value::Number(n) => Some(n.to_string()),
146 _ => None,
147 })
148 .collect();
149 Fact::List(texts?)
150 }
151 _ => return None,
152 };
153 Some((k.clone(), fact))
154 })
155 .collect()
156}
157
158fn is_false(b: &bool) -> bool {
159 !*b
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
165pub struct Edge {
166 pub source: String,
168 pub target: String,
170 pub kind: String,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
176pub struct GraphMetadata {
177 pub nodes: usize,
179 pub edges: usize,
181 pub acyclic: bool,
184 pub truncated: bool,
187}
188
189pub const MAX_NODES: usize = 2000;
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197pub struct Graph {
198 pub id: String,
200 pub title: String,
202 pub description: String,
204 pub source: String,
206 pub node_kinds: BTreeMap<String, String>,
208 pub edge_kinds: BTreeMap<String, String>,
210 pub nodes: Vec<Node>,
212 pub edges: Vec<Edge>,
214 pub metadata: GraphMetadata,
216}
217
218#[derive(Debug)]
221pub struct Builder {
222 id: &'static str,
223 title: &'static str,
224 description: &'static str,
225 source: String,
226 node_kinds: BTreeMap<String, String>,
227 edge_kinds: BTreeMap<String, String>,
228 nodes: BTreeMap<String, Node>,
229 edges: BTreeSet<Edge>,
230 truncated: bool,
231 limit: usize,
234}
235
236impl Builder {
237 pub fn new(
239 id: &'static str,
240 title: &'static str,
241 description: &'static str,
242 source: impl Into<String>,
243 ) -> Self {
244 Builder {
245 id,
246 title,
247 description,
248 source: source.into(),
249 node_kinds: BTreeMap::new(),
250 edge_kinds: BTreeMap::new(),
251 nodes: BTreeMap::new(),
252 edges: BTreeSet::new(),
253 truncated: false,
254 limit: MAX_NODES,
255 }
256 }
257
258 pub fn limit(mut self, limit: usize) -> Self {
274 self.limit = limit;
275 self
276 }
277
278 pub fn node_kind(mut self, kind: &str, meaning: &str) -> Self {
280 self.node_kinds.insert(kind.into(), meaning.into());
281 self
282 }
283
284 pub fn edge_kind(mut self, kind: &str, meaning: &str) -> Self {
286 self.edge_kinds.insert(kind.into(), meaning.into());
287 self
288 }
289
290 pub fn node(&mut self, node: Node) -> bool {
293 if self.nodes.len() >= self.limit && !self.nodes.contains_key(&node.id) {
294 self.truncated = true;
295 return false;
296 }
297 self.nodes.entry(node.id.clone()).or_insert(node);
298 true
299 }
300
301 pub fn edge(&mut self, source: &str, target: &str, kind: &str) {
303 self.edges.insert(Edge {
304 source: source.into(),
305 target: target.into(),
306 kind: kind.into(),
307 });
308 }
309
310 pub fn has(&self, id: &str) -> bool {
312 self.nodes.contains_key(id)
313 }
314
315 pub fn finish(self) -> Graph {
322 let nodes: Vec<Node> = self.nodes.into_values().collect();
323 let present: BTreeSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
324 let edges: Vec<Edge> = self
325 .edges
326 .into_iter()
327 .filter(|e| present.contains(e.source.as_str()) && present.contains(e.target.as_str()))
328 .collect();
329 let acyclic = is_acyclic(&nodes, &edges);
330 Graph {
331 id: self.id.into(),
332 title: self.title.into(),
333 description: self.description.into(),
334 source: self.source,
335 node_kinds: self.node_kinds,
336 edge_kinds: self.edge_kinds,
337 metadata: GraphMetadata {
338 nodes: nodes.len(),
339 edges: edges.len(),
340 acyclic,
341 truncated: self.truncated,
342 },
343 nodes,
344 edges,
345 }
346 }
347}
348
349pub fn is_acyclic(nodes: &[Node], edges: &[Edge]) -> bool {
362 let mut outgoing: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
363 let mut indegree: BTreeMap<&str, usize> = nodes.iter().map(|n| (n.id.as_str(), 0)).collect();
364 for e in edges {
365 outgoing
366 .entry(e.source.as_str())
367 .or_default()
368 .push(e.target.as_str());
369 *indegree.entry(e.target.as_str()).or_insert(0) += 1;
370 }
371 let mut ready: Vec<&str> = indegree
372 .iter()
373 .filter(|(_, d)| **d == 0)
374 .map(|(id, _)| *id)
375 .collect();
376 let mut seen = 0usize;
377 while let Some(id) = ready.pop() {
378 seen += 1;
379 for target in outgoing.get(id).into_iter().flatten() {
380 let d = indegree.entry(target).or_insert(0);
381 *d = d.saturating_sub(1);
382 if *d == 0 {
383 ready.push(target);
384 }
385 }
386 }
387 seen == indegree.len()
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
392pub struct GraphInfo {
393 pub id: String,
395 pub title: String,
397 pub description: String,
399 pub source: String,
401}
402
403type Derivation = fn(&CapabilityRegistry, &Index) -> Graph;
407
408pub const COMPOSED: &str = "composed";
411
412const DERIVATIONS: &[(&str, Derivation)] = &[
413 ("registry", registry_graph),
414 ("layer", layer_graph),
415 ("rules", rules_graph),
416 ("adrs", adrs_graph),
417 ("use-cases", use_cases_graph),
418 ("why", why_graph),
419 ("product", product_graph),
420 (COMPOSED, composed_graph),
421];
422
423pub fn ids() -> Vec<&'static str> {
429 DERIVATIONS.iter().map(|(id, _)| *id).collect()
430}
431
432pub fn derive(id: &str, registry: &CapabilityRegistry, index: &Index) -> Option<Graph> {
434 let _phase = crate::perf::phase(crate::perf::Phase::GraphBuild);
435 crate::perf::Counters::bump(&crate::perf::COUNTERS.graph_builds);
436 DERIVATIONS
437 .iter()
438 .find(|(known, _)| *known == id)
439 .map(|(_, f)| f(registry, index))
440}
441
442pub fn whole(graph: Graph) -> Result<Graph, String> {
453 if graph.metadata.truncated {
454 return Err(format!(
455 "the `{}` graph stopped at {} nodes (the bound a person is shown is {MAX_NODES}) and would be committed as a prefix of the repository; derive it whole (graph::composed_complete) or raise the bound",
456 graph.id, graph.metadata.nodes
457 ));
458 }
459 Ok(graph)
460}
461
462pub fn derive_whole(
475 id: &str,
476 registry: &CapabilityRegistry,
477 index: &Index,
478) -> Result<Graph, String> {
479 derive(id, registry, index)
480 .ok_or_else(|| format!("this executable derives no `{id}` graph"))
481 .and_then(whole)
482}
483
484pub fn list(registry: &CapabilityRegistry, index: &Index) -> Vec<GraphInfo> {
487 DERIVATIONS
488 .iter()
489 .map(|(_id, f)| {
490 let g = f(registry, index);
494 GraphInfo {
495 id: g.id,
496 title: g.title,
497 description: g.description,
498 source: g.source,
499 }
500 })
501 .collect()
502}
503
504fn capability_route(id: &str) -> String {
505 format!(
506 "/cockpit/capabilities/{}",
507 crate::http::router::percent_encode(id)
508 )
509}
510
511fn object_route(uri: &str) -> String {
514 match uri
515 .strip_prefix("majordomus://")
516 .and_then(|r| r.split_once('/'))
517 {
518 Some((kind, identity)) => crate::entity::route(kind, identity),
519 None => format!(
522 "/cockpit/object?uri={}",
523 crate::http::router::percent_encode(uri)
524 ),
525 }
526}
527
528fn registry_graph(registry: &CapabilityRegistry, _index: &Index) -> Graph {
533 let mut b = Builder::new(
534 "registry",
535 "Capability registry",
536 "Every module, the capabilities it composes, the source file each was declared in, and the MCP, HTTP, command-line and Cockpit projections derived from it. The executable's own architecture, read back from the registry that produces every interface — this page among them.",
537 "the capability registry: builtin descriptors and the declarative objects of the layer",
538 )
539 .node_kind("module", "a module composed with module! and named in compose_modules!, or one kind of declarative object")
540 .node_kind("capability", "one canonical declaration: a query, a command, or a declarative resource")
541 .node_kind("source", "the file the declaration was read from")
542 .node_kind("projection", "an external interface the descriptor is projected onto, the Cockpit included")
543 .edge_kind("composes", "the module composes the capability")
544 .edge_kind("declared_in", "the capability was declared in that file")
545 .edge_kind("projects", "the capability is exposed through that interface");
546
547 for m in registry.modules() {
548 b.node(Node {
549 id: format!("module:{}", m.id),
550 kind: "module".into(),
551 label: m.id.to_string(),
552 summary: Some(m.title.clone()),
553 route: Some(format!(
554 "/cockpit/capabilities?module={}",
555 crate::http::router::percent_encode(m.id.as_str())
556 )),
557 source: None,
558 status: Some(enum_word(&m.source)),
559 external: false,
560 facts: BTreeMap::new(),
561 });
562 }
563
564 for (id, label, meaning) in [
566 (
567 "projection:mcp",
568 "MCP",
569 "tools and resources an MCP client sees",
570 ),
571 (
572 "projection:http",
573 "HTTP",
574 "routes under /api/v1/, and the OpenAPI document over them",
575 ),
576 ("projection:cli", "CLI", "the words after `majordomus`"),
577 (
578 "projection:cockpit",
579 "Cockpit",
580 "a page for every capability, and a form generated from the input schema for every one the Cockpit can call",
581 ),
582 ] {
583 b.node(Node {
584 id: id.into(),
585 kind: "projection".into(),
586 label: label.into(),
587 summary: Some(meaning.into()),
588 route: None,
589 source: None,
590 status: None,
591 external: false,
592 facts: BTreeMap::new(),
593 });
594 }
595
596 for c in registry.iter() {
597 if !matches!(c.provenance, Provenance::Builtin { .. }) {
600 continue;
601 }
602 let node_id = format!("capability:{}", c.id);
603 if !b.node(Node {
604 id: node_id.clone(),
605 kind: "capability".into(),
606 label: c.id.to_string(),
607 summary: Some(c.title.clone()),
608 route: Some(capability_route(c.id.as_str())),
609 source: Some(c.provenance.source_path()),
610 status: Some(kind_word(c.kind)),
611 external: false,
612 facts: BTreeMap::new(),
613 }) {
614 break;
615 }
616 b.edge(&format!("module:{}", c.module), &node_id, "composes");
617
618 let path = c.provenance.source_path();
619 let source_id = format!("source:{path}");
620 b.node(Node {
621 id: source_id.clone(),
622 kind: "source".into(),
623 label: path.rsplit('/').next().unwrap_or(&path).to_string(),
624 summary: Some(path.clone()),
625 route: None,
626 source: Some(path.clone()),
627 status: None,
628 external: false,
629 facts: BTreeMap::new(),
630 });
631 b.edge(&node_id, &source_id, "declared_in");
632
633 if c.exposure.mcp.is_some() {
634 b.edge(&node_id, "projection:mcp", "projects");
635 }
636 if c.exposure.http.is_some() {
637 b.edge(&node_id, "projection:http", "projects");
638 }
639 if c.exposure.cli.is_some() {
640 b.edge(&node_id, "projection:cli", "projects");
641 }
642 b.edge(&node_id, "projection:cockpit", "projects");
647 }
648 b.finish()
649}
650
651fn layer_graph(_registry: &CapabilityRegistry, index: &Index) -> Graph {
655 let mut b = Builder::new(
656 "layer",
657 "The layer's shape",
658 "Every directory an object was discovered in, nested as the tree nests them, and the kinds each directory holds with how many objects of that kind. Derived from the provenance of the indexed objects, so a directory appears here exactly when something in it was read.",
659 "the provenance of every object of the index",
660 )
661 .node_kind("directory", "a directory at least one indexed object sits in")
662 .node_kind("kind", "one kind of object, under the directory that holds it")
663 .edge_kind("contains", "the directory contains the directory or the kind")
664 .edge_kind("read_as", "the kind's objects in that directory");
665
666 let mut per_directory: BTreeMap<&str, BTreeMap<&str, usize>> = BTreeMap::new();
667 for o in &index.objects {
668 *per_directory
669 .entry(o.provenance.directory.as_str())
670 .or_default()
671 .entry(o.kind.as_str())
672 .or_insert(0) += 1;
673 }
674
675 for (dir, kinds) in &per_directory {
676 let mut prefix: Vec<&str> = Vec::new();
678 for segment in dir.split('/').filter(|s| !s.is_empty() && *s != ".") {
679 prefix.push(segment);
680 let path = prefix.join("/");
681 let id = format!("directory:{path}");
682 if !b.node(Node {
683 id: id.clone(),
684 kind: "directory".into(),
685 label: segment.to_string(),
686 summary: Some(path.clone()),
687 route: None,
688 source: Some(path.clone()),
689 status: None,
690 external: false,
691 facts: BTreeMap::new(),
692 }) {
693 break;
694 }
695 if prefix.len() > 1 {
696 let parent = prefix[..prefix.len() - 1].join("/");
697 b.edge(&format!("directory:{parent}"), &id, "contains");
698 }
699 }
700 for (kind, count) in kinds {
701 let id = format!("kind:{dir}:{kind}");
702 if !b.node(Node {
703 id: id.clone(),
704 kind: "kind".into(),
705 label: (*kind).to_string(),
706 summary: Some(format!("{count} object(s)")),
707 route: Some(format!(
708 "/cockpit/objects?kind={}",
709 crate::http::router::percent_encode(kind)
710 )),
711 source: Some((*dir).to_string()),
712 status: None,
713 external: false,
714 facts: BTreeMap::new(),
715 }) {
716 break;
717 }
718 if *dir != "." {
719 b.edge(&format!("directory:{dir}"), &id, "read_as");
720 }
721 }
722 }
723 b.finish()
724}
725
726fn rules_graph(_registry: &CapabilityRegistry, index: &Index) -> Graph {
731 let mut b = Builder::new(
732 "rules",
733 "Rule dependencies",
734 "Every rule of the effective set and the rules it declares it depends on, with the enforcement class each carries. A rule whose dependency the repository does not hold is drawn against an external node rather than left dangling.",
735 "the `depends_on` front matter of every object of kind `rule`",
736 )
737 .node_kind("rule", "a rule of the effective set, vendored or the project's own")
738 .node_kind("missing", "a rule named as a dependency that the index does not hold")
739 .edge_kind("depends_on", "the rule states it depends on the other");
740
741 for o in index.objects.iter().filter(|o| o.kind == "rule") {
742 let id = format!("rule:{}", o.identity);
743 if !b.node(Node {
744 id: id.clone(),
745 kind: "rule".into(),
746 label: o.identity.clone(),
747 summary: o.title.clone(),
748 route: Some(object_route(&o.uri)),
749 source: Some(o.provenance.path.clone()),
750 status: metadata_string(&o.metadata, "class"),
751 external: false,
752 facts: BTreeMap::new(),
753 }) {
754 break;
755 }
756 }
757 for o in index.objects.iter().filter(|o| o.kind == "rule") {
758 for dep in metadata_strings(&o.metadata, "depends_on") {
759 let target = format!("rule:{dep}");
760 if !b.has(&target) {
761 b.node(Node {
762 id: target.clone(),
763 kind: "missing".into(),
764 label: dep.clone(),
765 summary: Some("named as a dependency; not in this index".into()),
766 route: None,
767 source: None,
768 status: None,
769 external: true,
770 facts: BTreeMap::new(),
771 });
772 }
773 b.edge(&format!("rule:{}", o.identity), &target, "depends_on");
774 }
775 }
776 b.finish()
777}
778
779fn adrs_graph(_registry: &CapabilityRegistry, index: &Index) -> Graph {
783 let mut b = Builder::new(
784 "adrs",
785 "Decisions and what they put in force",
786 "Every architecture decision, the decisions it stands in for, and the rules, claims, files and tests it names as what it put in force. The reverse question — what decided this rule — is the same edge read backwards.",
787 "the front matter of every object of kind `adr`",
788 )
789 .node_kind("adr", "one architecture decision record")
790 .node_kind("rule", "a rule the decision put in force")
791 .node_kind("claim", "a claim of docs/CLAIMS.yaml the decision backs")
792 .node_kind("file", "a document or an implementation the decision names")
793 .node_kind("test", "a behavioural case the decision names")
794 .edge_kind("supersedes", "the decision stands in for the other")
795 .edge_kind("put_in_force", "the decision put the thing in force");
796
797 for o in index.objects.iter().filter(|o| o.kind == "adr") {
798 let id = format!("adr:{}", o.identity);
799 if !b.node(Node {
800 id: id.clone(),
801 kind: "adr".into(),
802 label: metadata_string(&o.metadata, "id").unwrap_or_else(|| o.identity.clone()),
803 summary: o.title.clone(),
804 route: Some(object_route(&o.uri)),
805 source: Some(o.provenance.path.clone()),
806 status: metadata_string(&o.metadata, "status"),
807 external: false,
808 facts: BTreeMap::new(),
809 }) {
810 break;
811 }
812 }
813 let by_adr_id: BTreeMap<String, String> = index
814 .objects
815 .iter()
816 .filter(|o| o.kind == "adr")
817 .filter_map(|o| {
818 metadata_string(&o.metadata, "id").map(|id| (id, format!("adr:{}", o.identity)))
819 })
820 .collect();
821
822 for o in index.objects.iter().filter(|o| o.kind == "adr") {
823 let from = format!("adr:{}", o.identity);
824 for superseded in metadata_strings(&o.metadata, "supersedes") {
825 if let Some(target) = by_adr_id.get(&superseded) {
826 b.edge(&from, target, "supersedes");
827 }
828 }
829 for reference in metadata_strings(&o.metadata, "related") {
830 let Some((prefix, rest)) = reference.split_once(':') else {
831 continue;
832 };
833 if !["rule", "claim", "file", "test"].contains(&prefix) {
834 continue;
835 }
836 let target = format!("{prefix}:{rest}");
837 if !b.has(&target) {
838 let resolved = index
841 .objects
842 .iter()
843 .find(|c| {
844 c.kind == prefix
845 && (c.identity == rest || c.identity.starts_with(&format!("{rest}@")))
846 })
847 .or_else(|| index.objects.iter().find(|c| c.provenance.path == rest));
848 let node = match resolved {
849 Some(c) => Node {
850 id: target.clone(),
851 kind: prefix.into(),
852 label: rest.to_string(),
853 summary: c.title.clone(),
854 route: Some(object_route(&c.uri)),
855 source: Some(c.provenance.path.clone()),
856 status: None,
857 external: false,
858 facts: BTreeMap::new(),
859 },
860 None => Node {
861 id: target.clone(),
862 kind: prefix.into(),
863 label: rest.to_string(),
864 summary: Some("named by a decision; not an object of this index".into()),
865 route: None,
866 source: (prefix == "file" || prefix == "test").then(|| rest.to_string()),
867 status: None,
868 external: true,
869 facts: BTreeMap::new(),
870 },
871 };
872 if !b.node(node) {
873 break;
874 }
875 }
876 b.edge(&from, &target, "put_in_force");
877 }
878 }
879 b.finish()
880}
881
882fn use_cases_graph(_registry: &CapabilityRegistry, index: &Index) -> Graph {
886 let mut b = Builder::new(
887 "use-cases",
888 "Use cases and what they exercise",
889 "Every executable use case with the commands it runs, the doctrines it exercises and the claims it stands behind. Read backwards it answers the coverage question: what proves this command, what exercises this doctrine.",
890 "the front matter of every object of kind `use-case`",
891 )
892 .node_kind("use-case", "one executable use case")
893 .node_kind("command", "a command the scenario runs")
894 .node_kind("doctrine", "a doctrine the scenario exercises")
895 .node_kind("claim", "a claim the use case stands behind")
896 .edge_kind("runs", "the scenario runs that command")
897 .edge_kind("exercises", "the scenario exercises that doctrine")
898 .edge_kind("evidences", "the use case is evidence for that claim");
899
900 for o in index.objects.iter().filter(|o| o.kind == "use-case") {
901 let id = format!("use-case:{}", o.identity);
902 if !b.node(Node {
903 id: id.clone(),
904 kind: "use-case".into(),
905 label: o.identity.clone(),
906 summary: o.title.clone(),
907 route: Some(object_route(&o.uri)),
908 source: Some(o.provenance.path.clone()),
909 status: metadata_string(&o.metadata, "status"),
910 external: false,
911 facts: BTreeMap::new(),
912 }) {
913 break;
914 }
915 for (field, kind, edge) in [
916 ("commands", "command", "runs"),
917 ("doctrines", "doctrine", "exercises"),
918 ("claims", "claim", "evidences"),
919 ] {
920 for name in metadata_strings(&o.metadata, field) {
921 let target = format!("{kind}:{name}");
922 if !b.has(&target)
923 && !b.node(Node {
924 id: target.clone(),
925 kind: kind.into(),
926 label: name.clone(),
927 summary: None,
928 route: None,
929 source: None,
930 status: None,
931 external: false,
932 facts: BTreeMap::new(),
933 })
934 {
935 break;
936 }
937 b.edge(&id, &target, edge);
938 }
939 }
940 }
941 b.finish()
942}
943
944fn composed_graph(registry: &CapabilityRegistry, index: &Index) -> Graph {
965 compose(registry, &index.objects)
966}
967
968pub fn composed_complete(registry: &CapabilityRegistry, index: &Index) -> Graph {
991 let _phase = crate::perf::phase(crate::perf::Phase::GraphBuild);
992 crate::perf::Counters::bump(&crate::perf::COUNTERS.graph_builds);
993 compose_with(registry, &index.objects, usize::MAX)
994}
995
996fn compose(registry: &CapabilityRegistry, objects: &[Object]) -> Graph {
999 compose_with(registry, objects, MAX_NODES)
1000}
1001
1002fn compose_with(registry: &CapabilityRegistry, objects: &[Object], limit: usize) -> Graph {
1004 let mut b = Builder::new(
1005 "composed",
1006 "Everything, composed",
1007 "The capability registry and every object of the layer in one graph: each module with the capabilities it composes, each capability with the transports it is projected through, and every indexed object under the kind that owns it. The node kinds are the kinds that were indexed, so a kind added to the layer appears here without this derivation being edited.",
1008 "the capability registry and every object of the index",
1009 )
1010 .limit(limit)
1011 .node_kind("module", "a capability module of this executable")
1012 .node_kind("capability", "one capability, declared once and projected")
1013 .node_kind(
1014 "projection",
1015 "a transport a capability is projected through",
1016 )
1017 .node_kind("kind", "one kind of object the layer indexed")
1018 .node_kind("file", "a file the layer names and does not hold as an object")
1019 .node_kind("test", "a behavioural case the layer names")
1020 .node_kind("command", "a command a scenario runs")
1021 .node_kind("doctrine", "a doctrine a scenario exercises")
1022 .node_kind("claim", "a claim the layer stands behind")
1023 .edge_kind("composes", "the module composes the capability")
1024 .edge_kind("projects", "the capability is projected through the transport")
1025 .edge_kind("is_a", "the object is of that kind")
1026 .edge_kind("depends_on", "the rule states it depends on the other")
1027 .edge_kind("supersedes", "the decision stands in for the other")
1028 .edge_kind("put_in_force", "the decision put the thing in force")
1029 .edge_kind("related_to", "the object names the other as related")
1030 .edge_kind("runs", "the scenario runs that command")
1031 .edge_kind("exercises", "the scenario exercises that doctrine")
1032 .edge_kind("evidences", "the object is evidence for that claim")
1033 .edge_kind("tracks", "the document tracks that file")
1034 .edge_kind("defined_in", "the claim is defined by that file")
1035 .edge_kind("implemented_by", "the claim is implemented by that file")
1036 .edge_kind("tested_by", "the claim is proved by that case")
1037 .edge_kind("belongs_to", "the issue belongs to that milestone")
1038 .edge_kind("serves", "the use case serves that application")
1039 .edge_kind("governed_by", "the application is governed by that doctrine");
1040
1041 let kinds: BTreeSet<&str> = objects.iter().map(|o| o.kind.as_str()).collect();
1044 for kind in &kinds {
1045 b = b.node_kind(kind, &format!("an object of kind `{kind}`"));
1046 }
1047
1048 for kind in &kinds {
1049 let id = format!("kind:{kind}");
1050 if !b.node(Node {
1051 id,
1052 kind: "kind".into(),
1053 label: (*kind).to_string(),
1054 summary: Some(format!("every object of kind `{kind}`")),
1055 route: None,
1056 source: None,
1057 status: None,
1058 external: false,
1059 facts: BTreeMap::new(),
1060 }) {
1061 break;
1062 }
1063 }
1064
1065 for projection in ["mcp", "http", "cli", "cockpit"] {
1066 b.node(Node {
1067 id: format!("projection:{projection}"),
1068 kind: "projection".into(),
1069 label: projection.into(),
1070 summary: Some(format!("the {projection} projection of the registry")),
1071 route: None,
1072 source: None,
1073 status: None,
1074 external: false,
1075 facts: BTreeMap::new(),
1076 });
1077 }
1078
1079 for c in registry.iter() {
1080 if !matches!(c.provenance, Provenance::Builtin { .. }) {
1083 continue;
1084 }
1085 let module = format!("module:{}", c.module);
1086 if !b.has(&module)
1087 && !b.node(Node {
1088 id: module.clone(),
1089 kind: "module".into(),
1090 label: c.module.to_string(),
1091 summary: None,
1092 route: None,
1093 source: None,
1094 status: None,
1095 external: false,
1096 facts: BTreeMap::new(),
1097 })
1098 {
1099 break;
1100 }
1101 let id = format!("capability:{}", c.id);
1102 if !b.node(Node {
1103 id: id.clone(),
1104 kind: "capability".into(),
1105 label: c.id.to_string(),
1106 summary: Some(c.title.clone()),
1107 route: Some(capability_route(c.id.as_str())),
1108 source: Some(c.provenance.source_path()),
1109 status: Some(kind_word(c.kind)),
1110 external: false,
1111 facts: BTreeMap::new(),
1112 }) {
1113 break;
1114 }
1115 b.edge(&module, &id, "composes");
1116 if c.exposure.mcp.is_some() {
1117 b.edge(&id, "projection:mcp", "projects");
1118 }
1119 if c.exposure.http.is_some() {
1120 b.edge(&id, "projection:http", "projects");
1121 }
1122 if c.exposure.cli.is_some() {
1123 b.edge(&id, "projection:cli", "projects");
1124 }
1125 b.edge(&id, "projection:cockpit", "projects");
1126 }
1127
1128 for o in objects {
1129 if !b.node(Node {
1131 id: o.uri.clone(),
1132 kind: o.kind.clone(),
1133 label: o.identity.clone(),
1134 summary: o.title.clone().or_else(|| o.description.clone()),
1135 route: Some(object_route(&o.uri)),
1136 source: Some(o.provenance.path.clone()),
1137 status: metadata_string(&o.metadata, "status"),
1138 external: false,
1139 facts: facts(&o.metadata),
1140 }) {
1141 break;
1142 }
1143 b.edge(&o.uri, &format!("kind:{}", o.kind), "is_a");
1144 }
1145
1146 let resolver = Resolver::new(registry, objects);
1150 for o in objects {
1151 for rel in RELATIONS {
1152 if !rel.kinds.is_empty() && !rel.kinds.contains(&o.kind.as_str()) {
1153 continue;
1154 }
1155 for reference in metadata_strings(&o.metadata, rel.field) {
1156 if is_absence(&reference) {
1157 continue;
1158 }
1159 let target = match resolver.resolve(rel, &reference) {
1162 Outcome::Node(target) => Some(target),
1163 Outcome::External(node) => {
1164 let id = node.id.clone();
1165 if !b.has(&id) && !b.node(node) {
1166 break;
1167 }
1168 Some(id)
1169 }
1170 Outcome::Missing(_) => None,
1173 };
1174 if let Some(target) = target {
1175 if rel.inverted {
1176 b.edge(&target, &o.uri, rel.edge);
1177 } else {
1178 b.edge(&o.uri, &target, rel.edge);
1179 }
1180 }
1181 }
1182 }
1183 }
1184
1185 b.finish()
1186}
1187
1188pub(crate) struct Relation {
1197 pub(crate) kinds: &'static [&'static str],
1199 pub(crate) field: &'static str,
1201 pub(crate) edge: &'static str,
1203 pub(crate) target: Target,
1205 pub(crate) inverted: bool,
1210}
1211
1212#[derive(Clone, Copy)]
1214pub(crate) enum Target {
1215 Object(&'static str),
1217 DeclaredAdr,
1219 Prefixed,
1221 ObjectOrName(&'static str, &'static str),
1224 Name(&'static str),
1226 Path,
1228 McpTool,
1230}
1231
1232pub(crate) fn is_absence(reference: &str) -> bool {
1236 reference == "-"
1237}
1238
1239pub(crate) const RELATIONS: &[Relation] = &[
1240 Relation {
1241 kinds: &["rule"],
1242 field: "depends_on",
1243 edge: "depends_on",
1244 target: Target::Object("rule"),
1245 inverted: false,
1246 },
1247 Relation {
1248 kinds: &["adr"],
1249 field: "supersedes",
1250 edge: "supersedes",
1251 target: Target::DeclaredAdr,
1252 inverted: false,
1253 },
1254 Relation {
1255 kinds: &["adr"],
1256 field: "related",
1257 edge: "put_in_force",
1258 target: Target::Prefixed,
1259 inverted: false,
1260 },
1261 Relation {
1264 kinds: &["skill"],
1265 field: "related",
1266 edge: "related_to",
1267 target: Target::Object("skill"),
1268 inverted: false,
1269 },
1270 Relation {
1271 kinds: &["use-case"],
1272 field: "commands",
1273 edge: "runs",
1274 target: Target::Name("command"),
1275 inverted: false,
1276 },
1277 Relation {
1278 kinds: &["use-case"],
1279 field: "doctrines",
1280 edge: "exercises",
1281 target: Target::ObjectOrName("rule", "doctrine"),
1282 inverted: false,
1283 },
1284 Relation {
1287 kinds: &["use-case"],
1288 field: "claims",
1289 edge: "evidences",
1290 target: Target::Object("claim"),
1291 inverted: false,
1292 },
1293 Relation {
1294 kinds: &[],
1295 field: "tracks",
1296 edge: "tracks",
1297 target: Target::Path,
1298 inverted: false,
1299 },
1300 Relation {
1305 kinds: &["claim"],
1306 field: "source",
1307 edge: "defined_in",
1308 target: Target::Path,
1309 inverted: false,
1310 },
1311 Relation {
1312 kinds: &["claim"],
1313 field: "implementation",
1314 edge: "implemented_by",
1315 target: Target::Path,
1316 inverted: false,
1317 },
1318 Relation {
1319 kinds: &["claim"],
1320 field: "test",
1321 edge: "tested_by",
1322 target: Target::Path,
1323 inverted: false,
1324 },
1325 Relation {
1328 kinds: &["issue"],
1329 field: "milestone",
1330 edge: "belongs_to",
1331 target: Target::Object("milestone"),
1332 inverted: false,
1333 },
1334 Relation {
1335 kinds: &["issue"],
1336 field: "depends_on",
1337 edge: "depends_on",
1338 target: Target::Object("issue"),
1339 inverted: false,
1340 },
1341 Relation {
1342 kinds: &["milestone"],
1343 field: "depends_on",
1344 edge: "depends_on",
1345 target: Target::Object("milestone"),
1346 inverted: false,
1347 },
1348 Relation {
1349 kinds: &["milestone"],
1350 field: "claims",
1351 edge: "evidences",
1352 target: Target::Object("claim"),
1353 inverted: false,
1354 },
1355 Relation {
1358 kinds: &["use-case"],
1359 field: "applications",
1360 edge: "serves",
1361 target: Target::Object("application"),
1362 inverted: false,
1363 },
1364 Relation {
1365 kinds: &["application"],
1366 field: "use_cases",
1367 edge: "serves",
1368 target: Target::Object("use-case"),
1369 inverted: true,
1370 },
1371 Relation {
1372 kinds: &["application"],
1373 field: "doctrines",
1374 edge: "governed_by",
1375 target: Target::ObjectOrName("rule", "doctrine"),
1376 inverted: false,
1377 },
1378 Relation {
1381 kinds: &["use-case"],
1382 field: "mcp_tools",
1383 edge: "exercises",
1384 target: Target::McpTool,
1385 inverted: false,
1386 },
1387];
1388
1389#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
1396pub struct Unresolved {
1397 pub declared_in: String,
1399 pub key: String,
1401 pub reference: String,
1403 pub correction: String,
1405}
1406
1407pub fn unresolved_relations(registry: &CapabilityRegistry, objects: &[Object]) -> Vec<Unresolved> {
1413 let r = Resolver::new(registry, objects);
1414 let mut out = Vec::new();
1415 for o in objects {
1416 for rel in RELATIONS {
1417 if !rel.kinds.is_empty() && !rel.kinds.contains(&o.kind.as_str()) {
1418 continue;
1419 }
1420 for reference in metadata_strings(&o.metadata, rel.field) {
1421 if is_absence(&reference) {
1422 continue;
1423 }
1424 if let Outcome::Missing(correction) = r.resolve(rel, &reference) {
1425 out.push(Unresolved {
1426 declared_in: o.provenance.path.clone(),
1427 key: rel.field.into(),
1428 reference,
1429 correction,
1430 });
1431 }
1432 }
1433 }
1434 }
1435 out.sort();
1436 out.dedup();
1437 out
1438}
1439
1440pub(crate) enum Outcome {
1442 Node(String),
1444 External(Node),
1446 Missing(String),
1448}
1449
1450pub(crate) struct Resolver<'a> {
1453 registry: &'a CapabilityRegistry,
1454 by_kind_identity: BTreeMap<(&'a str, &'a str), &'a Object>,
1455 by_kind_stem: BTreeMap<(&'a str, &'a str), &'a Object>,
1456 by_path: BTreeMap<&'a str, &'a Object>,
1457 adr_by_declared_id: BTreeMap<String, &'a Object>,
1458 kinds: BTreeSet<&'a str>,
1459}
1460
1461impl<'a> Resolver<'a> {
1462 pub(crate) fn new(registry: &'a CapabilityRegistry, objects: &'a [Object]) -> Self {
1463 let mut by_kind_identity = BTreeMap::new();
1464 let mut by_kind_stem = BTreeMap::new();
1465 let mut by_path = BTreeMap::new();
1466 let mut adr_by_declared_id = BTreeMap::new();
1467 let mut kinds = BTreeSet::new();
1468 for o in objects {
1469 kinds.insert(o.kind.as_str());
1470 by_kind_identity.insert((o.kind.as_str(), o.identity.as_str()), o);
1471 if let Some((stem, _)) = o.identity.split_once('@') {
1474 by_kind_stem.entry((o.kind.as_str(), stem)).or_insert(o);
1475 }
1476 by_path.insert(o.provenance.path.as_str(), o);
1477 if o.kind == "adr" {
1478 if let Some(id) = metadata_string(&o.metadata, "id") {
1479 adr_by_declared_id.insert(id, o);
1480 }
1481 }
1482 }
1483 Resolver {
1484 registry,
1485 by_kind_identity,
1486 by_kind_stem,
1487 by_path,
1488 adr_by_declared_id,
1489 kinds,
1490 }
1491 }
1492
1493 fn object(&self, kind: &str, name: &str) -> Option<&'a Object> {
1494 self.by_kind_identity
1495 .get(&(kind, name))
1496 .or_else(|| self.by_kind_stem.get(&(kind, name)))
1497 .copied()
1498 }
1499
1500 pub(crate) fn resolve(&self, rel: &Relation, reference: &str) -> Outcome {
1501 match rel.target {
1502 Target::Object(kind) => match self.object(kind, reference) {
1503 Some(o) => Outcome::Node(o.uri.clone()),
1504 None => Outcome::Missing(format!(
1505 "no object of kind `{kind}` has the identity `{reference}`; register it or correct the name"
1506 )),
1507 },
1508 Target::DeclaredAdr => match self.adr_by_declared_id.get(reference) {
1509 Some(o) => Outcome::Node(o.uri.clone()),
1510 None => Outcome::Missing(format!(
1511 "no decision declares the id `{reference}`; correct the name or record the decision"
1512 )),
1513 },
1514 Target::Prefixed => {
1515 let Some((prefix, rest)) = reference.split_once(':') else {
1516 return Outcome::Missing(format!(
1517 "`{reference}` is not `<kind>:<name>`; a reference names what kind of thing it points at"
1518 ));
1519 };
1520 if prefix == "file" || prefix == "test" {
1522 return match self.by_path.get(rest) {
1523 Some(o) => Outcome::Node(o.uri.clone()),
1524 None => Outcome::External(external_node(prefix, rest, Some(rest))),
1525 };
1526 }
1527 match self.object(prefix, rest) {
1528 Some(o) => Outcome::Node(o.uri.clone()),
1529 None if !self.kinds.contains(prefix) => {
1530 Outcome::External(external_node(prefix, rest, None))
1533 }
1534 None => Outcome::Missing(format!(
1535 "no object of kind `{prefix}` has the identity `{rest}`; register it or correct the reference"
1536 )),
1537 }
1538 }
1539 Target::ObjectOrName(kind, fallback) => match self.object(kind, reference) {
1540 Some(o) => Outcome::Node(o.uri.clone()),
1541 None => Outcome::External(external_node(fallback, reference, None)),
1542 },
1543 Target::Name(kind) => Outcome::External(external_node(kind, reference, None)),
1544 Target::Path => match self.by_path.get(reference) {
1545 Some(o) => Outcome::Node(o.uri.clone()),
1546 None => Outcome::External(external_node("file", reference, Some(reference))),
1547 },
1548 Target::McpTool => match self.registry.by_mcp_tool(reference) {
1549 Some(c) => Outcome::Node(format!("capability:{}", c.id)),
1550 None => Outcome::Missing(format!(
1551 "no capability is exposed as the MCP tool `{reference}`; correct the name or declare the exposure"
1552 )),
1553 },
1554 }
1555 }
1556}
1557
1558fn external_node(kind: &str, name: &str, path: Option<&str>) -> Node {
1560 Node {
1561 id: format!("{kind}:{name}"),
1562 kind: kind.into(),
1563 label: name.rsplit('/').next().unwrap_or(name).to_string(),
1564 summary: Some("named by the layer; not an object of this index".to_string()),
1565 route: None,
1566 source: path.map(str::to_string),
1567 status: None,
1568 external: true,
1569 facts: BTreeMap::new(),
1570 }
1571}
1572
1573#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1582pub struct RuntimeState {
1583 pub nodes: BTreeMap<String, NodeState>,
1585}
1586
1587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1589pub struct NodeState {
1590 pub status: String,
1592 #[serde(skip_serializing_if = "Option::is_none")]
1594 pub detail: Option<String>,
1595}
1596
1597impl RuntimeState {
1598 pub fn narrowed_to(&self, graph: &Graph) -> RuntimeState {
1625 let ids: BTreeSet<&str> = graph.nodes.iter().map(|n| n.id.as_str()).collect();
1626 RuntimeState {
1627 nodes: self
1628 .nodes
1629 .iter()
1630 .filter(|(id, _)| ids.contains(id.as_str()))
1631 .map(|(id, state)| (id.clone(), state.clone()))
1632 .collect(),
1633 }
1634 }
1635
1636 pub fn of(&self, node_id: &str) -> Option<&NodeState> {
1653 self.nodes.get(node_id)
1654 }
1655}
1656
1657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1688pub struct ObservedGraph {
1689 pub graph: Graph,
1691 pub runtime: RuntimeState,
1694}
1695
1696impl ObservedGraph {
1697 pub fn new(graph: Graph, observed: &RuntimeState) -> ObservedGraph {
1720 let runtime = observed.narrowed_to(&graph);
1721 ObservedGraph { graph, runtime }
1722 }
1723}
1724
1725fn metadata_string(metadata: &Value, key: &str) -> Option<String> {
1727 metadata
1728 .get(key)
1729 .and_then(Value::as_str)
1730 .map(str::to_string)
1731}
1732
1733pub(crate) fn metadata_strings(metadata: &Value, key: &str) -> Vec<String> {
1737 match metadata.get(key) {
1738 Some(Value::Array(a)) => a
1739 .iter()
1740 .filter_map(Value::as_str)
1741 .map(str::to_string)
1742 .collect(),
1743 Some(Value::String(s)) => vec![s.clone()],
1744 _ => Vec::new(),
1745 }
1746}
1747
1748fn kind_word(kind: CapabilityKind) -> String {
1749 kind.as_str().into()
1750}
1751
1752fn enum_word<T: Serialize>(v: &T) -> String {
1753 serde_json::to_value(v)
1754 .ok()
1755 .and_then(|v| v.as_str().map(str::to_string))
1756 .unwrap_or_default()
1757}
1758
1759pub fn routes(object: &Object) -> String {
1762 object_route(&object.uri)
1763}
1764
1765pub fn capability_page(c: &Capability) -> String {
1767 capability_route(c.id.as_str())
1768}
1769
1770fn why_graph(registry: &CapabilityRegistry, index: &Index) -> Graph {
1780 let catalogue = crate::why::Catalogue::build(index, registry);
1781 let mut b = Builder::new(
1782 "why",
1783 "The moments, and what answers them",
1784 "Every operational moment this tool is a response to, with who recognises it, the operational area it falls under, and the commands, capabilities, claims, rules and use cases that answer it. A moment that named something this repository does not have would fail validation rather than draw an edge to nothing.",
1785 "the front matter of every object of kind `moment`",
1786 )
1787 .node_kind("moment", "one operational failure mode")
1788 .node_kind("audience", "who recognises it")
1789 .node_kind("area", "the operational area it falls under")
1790 .node_kind("command", "a command that answers it")
1791 .node_kind("capability", "a capability of this executable that answers it")
1792 .node_kind("claim", "a claim that says what is guaranteed here")
1793 .node_kind("rule", "a rule of the effective set that governs it")
1794 .node_kind("use-case", "a use case that shows the way out")
1795 .edge_kind("recognised_by", "that audience recognises this moment")
1796 .edge_kind("falls_under", "this moment falls under that area")
1797 .edge_kind("answered_by", "that command or capability answers this moment")
1798 .edge_kind("backed_by", "that claim says what is guaranteed here")
1799 .edge_kind("governed_by", "that rule governs this moment")
1800 .edge_kind("resolved_by", "that use case shows the way out")
1801 .edge_kind("related_to", "the moment names that one as related");
1802
1803 'outer: for m in catalogue
1804 .all()
1805 .iter()
1806 .filter(|m| m.status == crate::why::STABLE)
1807 {
1808 let id = format!("moment:{}", m.id);
1809 if !b.node(Node {
1810 id: id.clone(),
1811 kind: "moment".into(),
1812 label: m.label().to_string(),
1813 summary: Some(m.summary.clone()),
1814 route: Some(m.route.clone()),
1815 source: Some(m.source.clone()),
1816 status: Some(m.severity.clone()),
1817 external: false,
1818 facts: Default::default(),
1821 }) {
1822 break;
1823 }
1824 for (values, kind, edge) in [
1825 (&m.audiences, "audience", "recognised_by"),
1826 (&m.areas, "area", "falls_under"),
1827 (&m.commands, "command", "answered_by"),
1828 (&m.capabilities, "capability", "answered_by"),
1829 (&m.claims, "claim", "backed_by"),
1830 (&m.doctrines, "rule", "governed_by"),
1831 (&m.use_cases, "use-case", "resolved_by"),
1832 (&m.related, "moment", "related_to"),
1833 ] {
1834 for name in values {
1835 let target = format!("{kind}:{name}");
1836 if !b.has(&target) {
1837 let route = match kind {
1838 "audience" => Some(format!("{}audiences/{name}/", crate::why::ROUTE)),
1839 "area" => Some(format!("{}areas/{name}/", crate::why::ROUTE)),
1840 "moment" => Some(format!("{}{name}/", crate::why::ROUTE)),
1841 "command" => Some(format!("/commands/{name}/")),
1842 "claim" => Some(format!("/guarantees/{name}/")),
1843 "use-case" => Some(format!("/use-cases/{name}/")),
1844 _ => None,
1845 };
1846 if !b.node(Node {
1847 id: target.clone(),
1848 kind: kind.into(),
1849 label: name.clone(),
1850 summary: None,
1851 route,
1852 source: None,
1853 status: None,
1854 external: false,
1855 facts: Default::default(),
1856 }) {
1857 break 'outer;
1858 }
1859 }
1860 b.edge(&id, &target, edge);
1861 }
1862 }
1863 }
1864 b.finish()
1865}
1866
1867fn product_graph(registry: &CapabilityRegistry, index: &Index) -> Graph {
1874 let why = crate::why::Catalogue::build(index, registry);
1875 let web = Topology::new(crate::web::discover::native(
1876 crate::web::discover::Runtime::full(),
1877 ));
1878 let model = crate::product::ProductModel::build(index, registry, &why, &web);
1879 let mut b = Builder::new(
1880 "product",
1881 "The product, composed",
1882 "Every stable feature of the product, the capability modules, shell commands and object kinds it is made of, and the interfaces those project onto: the command line, the HTTP API, MCP, the Cockpit and the documentation. Every edge is derived from a feature's typed references and the registry's exposures; a feature that named something the repository does not have would fail validation rather than draw an edge to nothing.",
1883 "the features of the layer, the capability registry and the command registry",
1884 )
1885 .node_kind("feature", "one product feature, as its file declares it")
1886 .node_kind("module", "a capability module of the executable")
1887 .node_kind("command", "a public command of the shell tool")
1888 .node_kind("kind", "one kind of object of the layer")
1889 .node_kind("surface", "an interface a feature is exposed through")
1890 .edge_kind("made_of", "the feature is made of that module, command or kind")
1891 .edge_kind("exposed_through", "the module, command or kind is reached through that interface")
1892 .edge_kind("related_to", "the feature names that one as related");
1893
1894 for (id, title, route) in crate::product::SURFACES {
1895 b.node(Node {
1896 id: format!("surface:{id}"),
1897 kind: "surface".into(),
1898 label: (*title).to_string(),
1899 summary: None,
1900 route: Some((*route).to_string()),
1901 source: None,
1902 status: None,
1903 external: false,
1904 facts: BTreeMap::new(),
1905 });
1906 }
1907 'outer: for r in model.public() {
1908 let f = &r.feature;
1909 let fid = format!("feature:{}", f.id);
1910 if !b.node(Node {
1911 id: fid.clone(),
1912 kind: "feature".into(),
1913 label: f.label().to_string(),
1914 summary: Some(f.summary.clone()),
1915 route: Some(f.route.clone()),
1916 source: Some(f.source.clone()),
1917 status: Some(if f.featured {
1918 "featured".into()
1919 } else {
1920 f.status.clone()
1921 }),
1922 external: false,
1923 facts: BTreeMap::new(),
1924 }) {
1925 break;
1926 }
1927 for m in &r.module_refs {
1928 let id = format!("module:{}", m.id);
1929 if !b.has(&id)
1930 && !b.node(Node {
1931 id: id.clone(),
1932 kind: "module".into(),
1933 label: m.id.clone(),
1934 summary: Some(m.title.clone()),
1935 route: Some(format!(
1936 "/registry/modules/{}/",
1937 m.id.replace(['.', '_'], "-")
1938 )),
1939 source: Some(m.source_path.clone()),
1940 status: m.stability.clone(),
1941 external: false,
1942 facts: BTreeMap::new(),
1943 })
1944 {
1945 break 'outer;
1946 }
1947 b.edge(&fid, &id, "made_of");
1948 let caps = &m.capabilities;
1949 if caps.iter().any(|c| c.cli.is_some()) {
1950 b.edge(&id, "surface:cli", "exposed_through");
1951 }
1952 if caps.iter().any(|c| c.route.is_some()) {
1953 b.edge(&id, "surface:api", "exposed_through");
1954 }
1955 if caps
1956 .iter()
1957 .any(|c| c.tool.is_some() || c.resource.is_some())
1958 {
1959 b.edge(&id, "surface:mcp", "exposed_through");
1960 }
1961 b.edge(&id, "surface:cockpit", "exposed_through");
1962 }
1963 for c in &r.command_refs {
1964 let id = format!("command:{}", c.id);
1965 if !b.has(&id)
1966 && !b.node(Node {
1967 id: id.clone(),
1968 kind: "command".into(),
1969 label: format!("majordomus {}", c.id),
1970 summary: Some(c.summary.clone()),
1971 route: Some(format!("/commands/{}/", c.id)),
1972 source: Some("share/commands.yaml".into()),
1973 status: c.stage.clone(),
1974 external: false,
1975 facts: BTreeMap::new(),
1976 })
1977 {
1978 break 'outer;
1979 }
1980 b.edge(&fid, &id, "made_of");
1981 b.edge(&id, "surface:cli", "exposed_through");
1982 }
1983 for k in &r.kind_refs {
1984 let id = format!("kind:{}", k.name);
1985 if !b.has(&id)
1986 && !b.node(Node {
1987 id: id.clone(),
1988 kind: "kind".into(),
1989 label: k.name.clone(),
1990 summary: Some(format!("{} object(s) of the layer", k.objects)),
1991 route: None,
1995 source: Some("share/kinds.yaml".into()),
1996 status: None,
1997 external: false,
1998 facts: BTreeMap::new(),
1999 })
2000 {
2001 break 'outer;
2002 }
2003 b.edge(&fid, &id, "made_of");
2004 b.edge(&id, "surface:mcp", "exposed_through");
2006 b.edge(&id, "surface:cockpit", "exposed_through");
2007 }
2008 if !r.doc_refs.is_empty() {
2009 b.edge(&fid, "surface:docs", "exposed_through");
2010 }
2011 for rel in &f.related {
2012 b.edge(&fid, &format!("feature:{rel}"), "related_to");
2013 }
2014 }
2015 b.finish()
2016}
2017
2018#[cfg(test)]
2019mod tests {
2020 use super::*;
2021
2022 fn node(id: &str) -> Node {
2023 Node {
2024 id: id.into(),
2025 kind: "x".into(),
2026 label: id.into(),
2027 summary: None,
2028 route: None,
2029 source: None,
2030 status: None,
2031 external: false,
2032 facts: BTreeMap::new(),
2033 }
2034 }
2035
2036 #[test]
2037 fn an_edge_to_a_missing_node_is_dropped() {
2038 let mut b = Builder::new("t", "T", "d", "s");
2039 b.node(node("a"));
2040 b.edge("a", "gone", "k");
2041 let g = b.finish();
2042 assert_eq!(g.edges, vec![]);
2043 assert!(g.metadata.acyclic);
2044 }
2045
2046 #[test]
2047 fn a_cycle_is_reported_and_not_hidden() {
2048 let mut b = Builder::new("t", "T", "d", "s");
2049 b.node(node("a"));
2050 b.node(node("b"));
2051 b.edge("a", "b", "k");
2052 b.edge("b", "a", "k");
2053 assert!(!b.finish().metadata.acyclic);
2054 }
2055
2056 #[test]
2057 fn nodes_are_sorted_and_deduplicated() {
2058 let mut b = Builder::new("t", "T", "d", "s");
2059 b.node(node("b"));
2060 b.node(node("a"));
2061 b.node(node("a"));
2062 let g = b.finish();
2063 assert_eq!(
2064 g.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(),
2065 ["a", "b"]
2066 );
2067 }
2068
2069 #[test]
2070 fn metadata_strings_reads_a_list_and_a_lone_string() {
2071 let v = serde_json::json!({ "a": ["x", "y"], "b": "z", "c": 1 });
2072 assert_eq!(metadata_strings(&v, "a"), ["x", "y"]);
2073 assert_eq!(metadata_strings(&v, "b"), ["z"]);
2074 assert!(metadata_strings(&v, "c").is_empty());
2075 assert!(metadata_strings(&v, "missing").is_empty());
2076 }
2077 fn object(kind: &str, identity: &str, title: &str) -> Object {
2078 Object {
2079 kind: kind.into(),
2080 identity: identity.into(),
2081 uri: format!("majordomus://{kind}/{identity}"),
2082 title: Some(title.into()),
2083 description: None,
2084 metadata: serde_json::json!({}),
2085 body: String::new(),
2086 content: String::new(),
2087 media_type: "text/markdown",
2088 provenance: crate::model::Provenance {
2089 path: format!(".ai/repo/{kind}s/{identity}.md"),
2090 directory: format!(".ai/repo/{kind}s"),
2091 source_class: kind.into(),
2092 section: None,
2093 bytes: 0,
2094 member: None,
2095 },
2096 }
2097 }
2098
2099 fn registry() -> CapabilityRegistry {
2100 CapabilityRegistry::builder()
2101 .with_builtin(crate::capability::builtin::all())
2102 .build()
2103 .unwrap()
2104 }
2105
2106 #[test]
2107 fn a_kind_nobody_wrote_here_is_still_a_kind_of_the_graph() {
2108 let g = compose(®istry(), &[object("gizmo", "one", "A gizmo")]);
2111 assert!(g.node_kinds.contains_key("gizmo"));
2112 assert!(g.nodes.iter().any(|n| n.id == "kind:gizmo"));
2113 assert!(g
2114 .nodes
2115 .iter()
2116 .any(|n| n.id == "majordomus://gizmo/one" && n.kind == "gizmo"));
2117 assert!(g
2118 .edges
2119 .iter()
2120 .any(|e| e.source == "majordomus://gizmo/one" && e.target == "kind:gizmo"));
2121 }
2122
2123 #[test]
2124 fn an_object_is_identified_by_its_uri_and_not_by_its_title() {
2125 let before = compose(®istry(), &[object("rule", "one", "The old words")]);
2126 let after = compose(®istry(), &[object("rule", "one", "Entirely new words")]);
2127 let id = |g: &Graph| {
2128 g.nodes
2129 .iter()
2130 .find(|n| n.kind == "rule")
2131 .map(|n| n.id.clone())
2132 };
2133 assert_eq!(id(&before), id(&after));
2134 assert_eq!(id(&before).as_deref(), Some("majordomus://rule/one"));
2135 }
2136
2137 #[test]
2138 fn many_objects_of_one_kind_share_the_one_kind_node() {
2139 let g = compose(
2140 ®istry(),
2141 &[
2142 object("rule", "one", "One"),
2143 object("rule", "two", "Two"),
2144 object("rule", "three", "Three"),
2145 ],
2146 );
2147 assert_eq!(g.nodes.iter().filter(|n| n.id == "kind:rule").count(), 1);
2148 assert_eq!(g.nodes.iter().filter(|n| n.kind == "rule").count(), 3);
2149 }
2150
2151 #[test]
2152 fn the_composition_does_not_depend_on_the_order_it_read_things_in() {
2153 let r = registry();
2154 let forwards = vec![
2155 object("rule", "a", "A"),
2156 object("adr", "b", "B"),
2157 object("skill", "c", "C"),
2158 ];
2159 let backwards: Vec<Object> = forwards.iter().rev().cloned().collect();
2160 assert_eq!(compose(&r, &forwards), compose(&r, &backwards));
2161 }
2162
2163 #[test]
2164 fn nodes_and_edges_come_out_sorted() {
2165 let g = compose(
2166 ®istry(),
2167 &[object("rule", "z", "Z"), object("rule", "a", "A")],
2168 );
2169 let mut sorted = g.nodes.clone();
2170 sorted.sort();
2171 assert_eq!(g.nodes, sorted);
2172 let mut edges = g.edges.clone();
2173 edges.sort();
2174 assert_eq!(g.edges, edges);
2175 }
2176
2177 #[test]
2178 fn a_graph_that_stopped_at_the_bound_is_refused_as_data() {
2179 let objects: Vec<Object> = (0..MAX_NODES + 1)
2180 .map(|i| object("rule", &format!("r{i:05}"), "A rule"))
2181 .collect();
2182 let refused = whole(compose(®istry(), &objects)).unwrap_err();
2183 assert!(refused.contains("`composed` graph stopped at"), "{refused}");
2184 assert!(whole(compose(®istry(), &objects[..3])).is_ok());
2185 }
2186
2187 #[test]
2188 fn the_composed_graph_read_as_data_is_whole_past_the_display_bound() {
2189 let objects: Vec<Object> = (0..MAX_NODES + 3)
2190 .map(|i| object("rule", &format!("r{i:05}"), "A rule"))
2191 .collect();
2192 let shown = compose(®istry(), &objects);
2193 assert!(
2194 shown.metadata.truncated,
2195 "a graph a person is shown stops at the bound"
2196 );
2197 assert_eq!(shown.nodes.len(), MAX_NODES);
2198 let whole = compose_with(®istry(), &objects, usize::MAX);
2199 assert!(!whole.metadata.truncated);
2200 let last = format!("majordomus://rule/r{:05}", MAX_NODES + 2);
2201 assert!(
2202 whole.nodes.iter().any(|n| n.id == last),
2203 "the object sorted last is in the graph read as data"
2204 );
2205 }
2206
2207 #[test]
2208 fn every_capability_reaches_its_module_and_its_projections() {
2209 let g = compose(®istry(), &[]);
2210 let c = g
2211 .nodes
2212 .iter()
2213 .find(|n| n.kind == "capability")
2214 .expect("the registry composes capabilities");
2215 assert!(g
2216 .edges
2217 .iter()
2218 .any(|e| e.target == c.id && e.kind == "composes"));
2219 assert!(g
2220 .edges
2221 .iter()
2222 .any(|e| e.source == c.id && e.target == "projection:cockpit"));
2223 }
2224
2225 #[test]
2226 fn the_graph_holds_definitions_and_the_overlay_holds_what_a_process_saw() {
2227 let g = compose(®istry(), &[object("rule", "one", "One")]);
2228 let json = serde_json::to_string(&g).unwrap();
2229 assert!(!json.contains("\"runtime\""));
2230 let mut state = RuntimeState::default();
2231 state.nodes.insert(
2232 "majordomus://rule/one".into(),
2233 NodeState {
2234 status: "ok".into(),
2235 detail: None,
2236 },
2237 );
2238 assert_eq!(state.nodes.len(), 1);
2239 }
2240 fn object_with(kind: &str, identity: &str, metadata: serde_json::Value) -> Object {
2241 Object {
2242 metadata,
2243 ..object(kind, identity, "A thing")
2244 }
2245 }
2246
2247 #[test]
2248 fn a_rule_reaches_the_rule_it_depends_on_across_its_version() {
2249 let objects = vec![
2252 object_with("rule", "project.first@1", serde_json::json!({})),
2253 object_with(
2254 "rule",
2255 "project.second@1",
2256 serde_json::json!({ "depends_on": ["project.first"] }),
2257 ),
2258 ];
2259 let g = compose(®istry(), &objects);
2260 assert!(g
2261 .edges
2262 .iter()
2263 .any(|e| e.source == "majordomus://rule/project.second@1"
2264 && e.target == "majordomus://rule/project.first@1"
2265 && e.kind == "depends_on"));
2266 assert_eq!(unresolved_relations(®istry(), &objects), vec![]);
2267 }
2268
2269 #[test]
2270 fn a_reference_out_of_the_layer_is_a_boundary_and_not_a_finding() {
2271 let objects = vec![object_with(
2272 "adr",
2273 "0001-a-decision",
2274 serde_json::json!({ "id": "adr-0001", "related": ["file:docs/COCKPIT.md"] }),
2275 )];
2276 let g = compose(®istry(), &objects);
2277 let node = g
2278 .nodes
2279 .iter()
2280 .find(|n| n.id == "file:docs/COCKPIT.md")
2281 .expect("a file the layer names is drawn as an external node");
2282 assert!(node.external);
2283 assert!(g
2284 .edges
2285 .iter()
2286 .any(|e| e.target == node.id && e.kind == "put_in_force"));
2287 assert_eq!(unresolved_relations(®istry(), &objects), vec![]);
2288 }
2289
2290 #[test]
2291 fn a_reference_to_a_kind_we_hold_that_names_nothing_is_a_finding() {
2292 let objects = vec![
2293 object_with("rule", "project.real@1", serde_json::json!({})),
2294 object_with(
2295 "adr",
2296 "0001-a-decision",
2297 serde_json::json!({ "id": "adr-0001", "related": ["rule:project.imaginary"] }),
2298 ),
2299 ];
2300 let found = unresolved_relations(®istry(), &objects);
2301 assert_eq!(found.len(), 1, "one reference names nothing: {found:?}");
2302 let f = &found[0];
2303 assert_eq!(f.declared_in, ".ai/repo/adrs/0001-a-decision.md");
2304 assert_eq!(f.key, "related");
2305 assert_eq!(f.reference, "rule:project.imaginary");
2306 assert!(
2307 f.correction.contains("project.imaginary") && f.correction.contains("register"),
2308 "the correction says what to do: {}",
2309 f.correction
2310 );
2311 }
2312
2313 #[test]
2314 fn a_finding_is_never_drawn_as_an_edge() {
2315 let objects = vec![
2316 object_with("rule", "project.real@1", serde_json::json!({})),
2317 object_with(
2318 "adr",
2319 "0001-a-decision",
2320 serde_json::json!({ "id": "adr-0001", "related": ["rule:project.imaginary"] }),
2321 ),
2322 ];
2323 let g = compose(®istry(), &objects);
2324 assert!(
2325 !g.nodes.iter().any(|n| n.label == "project.imaginary"),
2326 "a reference that resolves to nothing is a finding, not a phantom node"
2327 );
2328 assert!(!g.edges.iter().any(|e| e.kind == "put_in_force"));
2329 }
2330
2331 #[test]
2332 fn a_decision_stands_in_for_the_one_it_names_by_declared_id() {
2333 let objects = vec![
2334 object_with(
2335 "adr",
2336 "0001-the-first",
2337 serde_json::json!({ "id": "adr-0001" }),
2338 ),
2339 object_with(
2340 "adr",
2341 "0002-the-second",
2342 serde_json::json!({ "id": "adr-0002", "supersedes": ["adr-0001"] }),
2343 ),
2344 ];
2345 let g = compose(®istry(), &objects);
2346 assert!(g
2347 .edges
2348 .iter()
2349 .any(|e| e.source == "majordomus://adr/0002-the-second"
2350 && e.target == "majordomus://adr/0001-the-first"
2351 && e.kind == "supersedes"));
2352 }
2353
2354 #[test]
2355 fn a_use_case_reaches_what_it_runs_exercises_and_evidences() {
2356 let objects = vec![
2357 object_with("rule", "majordomus.a-doctrine@1", serde_json::json!({})),
2358 object_with("claim", "claim-one", serde_json::json!({})),
2359 object_with(
2360 "use-case",
2361 "a-scenario",
2362 serde_json::json!({
2363 "commands": ["doctor"],
2364 "doctrines": ["majordomus.a-doctrine"],
2365 "claims": ["claim-one"],
2366 }),
2367 ),
2368 ];
2369 let g = compose(®istry(), &objects);
2370 let from = "majordomus://use-case/a-scenario";
2371 let edge = |kind: &str| g.edges.iter().find(|e| e.source == from && e.kind == kind);
2372 assert_eq!(
2373 edge("runs").map(|e| e.target.as_str()),
2374 Some("command:doctor")
2375 );
2376 assert_eq!(
2377 edge("exercises").map(|e| e.target.as_str()),
2378 Some("majordomus://rule/majordomus.a-doctrine@1"),
2379 "a doctrine the layer holds resolves to the rule itself"
2380 );
2381 assert_eq!(
2382 edge("evidences").map(|e| e.target.as_str()),
2383 Some("majordomus://claim/claim-one"),
2384 "the claims of docs/CLAIMS.yaml are indexed objects, so the edge reaches the claim itself"
2385 );
2386 assert_eq!(unresolved_relations(®istry(), &objects), vec![]);
2387 }
2388
2389 #[test]
2390 fn every_edge_has_both_ends_in_the_graph() {
2391 let objects = vec![
2392 object_with(
2393 "context",
2394 "a-document",
2395 serde_json::json!({ "tracks": ["lib/session.sh"] }),
2396 ),
2397 object_with("rule", "project.real@1", serde_json::json!({})),
2398 ];
2399 let g = compose(®istry(), &objects);
2400 let ids: BTreeSet<&str> = g.nodes.iter().map(|n| n.id.as_str()).collect();
2401 for e in &g.edges {
2402 assert!(ids.contains(e.source.as_str()), "source missing: {e:?}");
2403 assert!(ids.contains(e.target.as_str()), "target missing: {e:?}");
2404 }
2405 assert!(g.edges.iter().any(|e| e.kind == "tracks"));
2406 }
2407 #[test]
2408 fn a_skill_names_its_sibling_by_identity_and_that_resolves() {
2409 let objects = vec![
2412 object_with("skill", "repo-review", serde_json::json!({})),
2413 object_with(
2414 "skill",
2415 "implement",
2416 serde_json::json!({ "related": ["repo-review"] }),
2417 ),
2418 ];
2419 let g = compose(®istry(), &objects);
2420 assert!(g
2421 .edges
2422 .iter()
2423 .any(|e| e.source == "majordomus://skill/implement"
2424 && e.target == "majordomus://skill/repo-review"
2425 && e.kind == "related_to"));
2426 assert_eq!(unresolved_relations(®istry(), &objects), vec![]);
2427 }
2428 #[test]
2429 fn a_rule_carries_its_class_and_its_statement_and_an_issue_does_not_carry_its_prose() {
2430 let long = "x".repeat(FACT_LIMIT + 1);
2431 let objects = vec![
2432 object_with(
2433 "rule",
2434 "project.thing@1",
2435 serde_json::json!({
2436 "id": "project.thing",
2437 "class": "blocking",
2438 "statement": "A capability is defined once and every interface is derived from it.",
2439 "tags": ["architecture", "capabilities"],
2440 }),
2441 ),
2442 object_with(
2443 "issue",
2444 "I0001",
2445 serde_json::json!({ "priority": "p0", "objective": long }),
2446 ),
2447 ];
2448 let g = compose(®istry(), &objects);
2449 let rule = g
2450 .nodes
2451 .iter()
2452 .find(|n| n.kind == "rule")
2453 .expect("the rule is a node");
2454 assert_eq!(
2455 rule.facts.get("class"),
2456 Some(&Fact::Text("blocking".into())),
2457 "how a rule is enforced is a fact about it"
2458 );
2459 assert!(matches!(rule.facts.get("statement"), Some(Fact::Text(_))));
2460 assert_eq!(
2461 rule.facts.get("tags"),
2462 Some(&Fact::List(vec![
2463 "architecture".into(),
2464 "capabilities".into()
2465 ]))
2466 );
2467 assert!(
2468 !rule.facts.contains_key("id") && !rule.facts.contains_key("title"),
2469 "the node already shows these; carrying them again says one thing twice"
2470 );
2471 let issue = g.nodes.iter().find(|n| n.kind == "issue").unwrap();
2472 assert_eq!(issue.facts.get("priority"), Some(&Fact::Text("p0".into())));
2473 assert!(
2474 !issue.facts.contains_key("objective"),
2475 "prose belongs to the object, which the node's route reaches"
2476 );
2477 }
2478
2479 #[test]
2480 fn a_claim_reaches_what_defines_implements_and_proves_it() {
2481 let mut implementation = object("implementation", "lib/session.sh", "session");
2484 implementation.provenance.path = "lib/session.sh".into();
2485 let mut case = object("test", "test/cases/01_a.sh", "a case");
2486 case.provenance.path = "test/cases/01_a.sh".into();
2487 let mut source = object("document", "docs/A.md", "a document");
2488 source.provenance.path = "docs/A.md".into();
2489 let objects = vec![
2490 implementation,
2491 case,
2492 source,
2493 object_with(
2494 "claim",
2495 "a-claim",
2496 serde_json::json!({
2497 "source": "docs/A.md",
2498 "implementation": "lib/session.sh",
2499 "test": "test/cases/01_a.sh",
2500 }),
2501 ),
2502 ];
2503 let g = compose(®istry(), &objects);
2504 let from = "majordomus://claim/a-claim";
2505 for (edge, target) in [
2506 ("defined_in", "majordomus://document/docs/A.md"),
2507 (
2508 "implemented_by",
2509 "majordomus://implementation/lib/session.sh",
2510 ),
2511 ("tested_by", "majordomus://test/test/cases/01_a.sh"),
2512 ] {
2513 assert!(
2514 g.edges
2515 .iter()
2516 .any(|e| e.source == from && e.kind == edge && e.target == target),
2517 "the claim should reach {target} by {edge}"
2518 );
2519 }
2520 assert_eq!(unresolved_relations(®istry(), &objects), vec![]);
2521 }
2522
2523 #[test]
2524 fn a_claim_that_nothing_implements_yet_says_so_and_that_is_not_a_finding() {
2525 let objects = vec![object_with(
2526 "claim",
2527 "a-claim",
2528 serde_json::json!({ "implementation": "-", "test": "-" }),
2529 )];
2530 let g = compose(®istry(), &objects);
2531 assert!(
2532 !g.edges.iter().any(|e| e.kind == "implemented_by"),
2533 "an absence is not a reference"
2534 );
2535 assert_eq!(unresolved_relations(®istry(), &objects), vec![]);
2536 }
2537
2538 #[test]
2539 fn the_plan_is_a_graph_of_the_graph() {
2540 let objects = vec![
2541 object_with("milestone", "a-milestone", serde_json::json!({})),
2542 object_with(
2543 "issue",
2544 "I0001",
2545 serde_json::json!({ "milestone": "a-milestone" }),
2546 ),
2547 object_with(
2548 "issue",
2549 "I0002",
2550 serde_json::json!({ "milestone": "a-milestone", "depends_on": ["I0001"] }),
2551 ),
2552 ];
2553 let g = compose(®istry(), &objects);
2554 assert!(g
2555 .edges
2556 .iter()
2557 .any(|e| e.source == "majordomus://issue/I0002"
2558 && e.target == "majordomus://issue/I0001"
2559 && e.kind == "depends_on"));
2560 assert_eq!(
2561 g.edges
2562 .iter()
2563 .filter(
2564 |e| e.kind == "belongs_to" && e.target == "majordomus://milestone/a-milestone"
2565 )
2566 .count(),
2567 2
2568 );
2569 assert_eq!(unresolved_relations(®istry(), &objects), vec![]);
2570 }
2571
2572 #[test]
2573 fn one_relationship_gets_one_edge_however_many_files_declare_it() {
2574 let objects = vec![
2577 object_with(
2578 "application",
2579 "an-application",
2580 serde_json::json!({ "use_cases": ["a-scenario"] }),
2581 ),
2582 object_with(
2583 "use-case",
2584 "a-scenario",
2585 serde_json::json!({ "applications": ["an-application"] }),
2586 ),
2587 ];
2588 let g = compose(®istry(), &objects);
2589 let serves: Vec<&Edge> = g.edges.iter().filter(|e| e.kind == "serves").collect();
2590 assert_eq!(serves.len(), 1, "one relationship, one edge: {serves:?}");
2591 assert_eq!(serves[0].source, "majordomus://use-case/a-scenario");
2592 assert_eq!(serves[0].target, "majordomus://application/an-application");
2593 }
2594
2595 #[test]
2596 fn a_use_case_reaches_the_capability_behind_the_tool_it_names() {
2597 let objects = vec![object_with(
2598 "use-case",
2599 "a-scenario",
2600 serde_json::json!({ "mcp_tools": ["majordomus_get"] }),
2601 )];
2602 let g = compose(®istry(), &objects);
2603 assert!(
2604 g.edges
2605 .iter()
2606 .any(|e| e.source == "majordomus://use-case/a-scenario"
2607 && e.kind == "exercises"
2608 && e.target == "capability:objects.get"),
2609 "the registry knows which capability answers to a tool name"
2610 );
2611 let named_wrongly = vec![object_with(
2612 "use-case",
2613 "a-scenario",
2614 serde_json::json!({ "mcp_tools": ["majordomus_imaginary"] }),
2615 )];
2616 let found = unresolved_relations(®istry(), &named_wrongly);
2617 assert_eq!(found.len(), 1);
2618 assert!(found[0].correction.contains("MCP tool"));
2619 }
2620
2621 #[test]
2626 fn the_runtime_projection_is_the_static_one_with_an_overlay_beside_it() {
2627 let objects = vec![object_with("use-case", "a-scenario", serde_json::json!({}))];
2628 let static_graph = compose(®istry(), &objects);
2629 let observed = RuntimeState {
2630 nodes: [(
2631 "majordomus://use-case/a-scenario".to_string(),
2632 NodeState {
2633 status: "ok".into(),
2634 detail: Some("this process answered for it".into()),
2635 },
2636 )]
2637 .into_iter()
2638 .collect(),
2639 };
2640
2641 let observed_graph = ObservedGraph::new(static_graph.clone(), &observed);
2642
2643 assert_eq!(
2644 observed_graph.graph, static_graph,
2645 "the definitions are untouched by the overlay"
2646 );
2647 assert_eq!(
2648 serde_json::to_value(&observed_graph.graph).expect("the graph serialises"),
2649 serde_json::to_value(&static_graph).expect("the graph serialises"),
2650 "stripping the runtime half yields the published document"
2651 );
2652 assert!(
2653 !serde_json::to_string(&static_graph)
2654 .expect("the graph serialises")
2655 .contains("this process answered for it"),
2656 "no observation reaches the static projection"
2657 );
2658 }
2659
2660 #[test]
2664 fn an_observation_that_names_no_node_is_dropped() {
2665 let objects = vec![object_with("use-case", "a-scenario", serde_json::json!({}))];
2666 let g = compose(®istry(), &objects);
2667 let state = |word: &str| NodeState {
2668 status: word.into(),
2669 detail: None,
2670 };
2671 let observed = RuntimeState {
2672 nodes: [
2673 ("majordomus://use-case/a-scenario".to_string(), state("ok")),
2674 ("module:not-in-this-graph".to_string(), state("degraded")),
2675 ]
2676 .into_iter()
2677 .collect(),
2678 };
2679
2680 let narrowed = observed.narrowed_to(&g);
2681
2682 assert_eq!(narrowed.nodes.len(), 1);
2683 assert_eq!(
2684 narrowed
2685 .of("majordomus://use-case/a-scenario")
2686 .map(|s| s.status.as_str()),
2687 Some("ok")
2688 );
2689 assert!(
2690 narrowed.of("module:not-in-this-graph").is_none(),
2691 "an observation about something this graph does not draw is not shown on it"
2692 );
2693 }
2694}