1use std::collections::{BTreeMap, BTreeSet};
25use std::path::Path;
26
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use sha2::{Digest, Sha256};
31
32use crate::capability::{Capability, CapabilityRegistry, Provenance};
33use crate::cockpit::nav;
34use crate::index::Index;
35use crate::model::{Object, Severity};
36use crate::share::ProviderOffers;
37use crate::web::Topology;
38use crate::why::Catalogue;
39
40pub const FEATURE: &str = "feature";
42pub const ROUTE: &str = "/features/";
44pub const STABLE: &str = "stable";
46pub const RESERVED: &[&str] = &["matrix", "providers", "index"];
49pub const DOMAIN: &str = "domain";
52pub const DOMAIN_ROUTE: &str = "/domains/";
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60pub struct Feature {
61 pub id: String,
63 pub title: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub short_title: Option<String>,
68 pub headline: String,
70 pub summary: String,
72 pub status: String,
74 #[serde(default)]
75 pub weight: u32,
77 #[serde(default)]
78 pub featured: bool,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub domain: Option<String>,
84 #[serde(default)]
85 pub areas: Vec<String>,
87 #[serde(default)]
88 pub audiences: Vec<String>,
90 #[serde(default)]
91 pub modules: Vec<String>,
93 #[serde(default)]
94 pub commands: Vec<String>,
96 #[serde(default)]
97 pub kinds: Vec<String>,
99 #[serde(default)]
100 pub rules: Vec<String>,
102 #[serde(default)]
103 pub docs: Vec<String>,
105 #[serde(default)]
106 pub adrs: Vec<String>,
108 #[serde(default)]
109 pub claims: Vec<String>,
111 #[serde(default)]
112 pub use_cases: Vec<String>,
114 #[serde(default)]
115 pub cockpit: Vec<String>,
117 #[serde(default)]
118 pub web: Vec<String>,
120 #[serde(default)]
121 pub related: Vec<String>,
123 #[serde(default)]
124 pub tags: Vec<String>,
126
127 #[serde(default)]
128 pub route: String,
130 #[serde(default)]
131 pub source: String,
133 #[serde(default, skip_serializing_if = "String::is_empty")]
134 pub body: String,
136}
137
138impl Feature {
139 pub fn label(&self) -> &str {
141 self.short_title.as_deref().unwrap_or(&self.title)
142 }
143}
144
145#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
155pub struct Surfaces {
156 pub cli: bool,
158 pub api: bool,
160 pub mcp: bool,
162 pub cockpit: bool,
164 pub docs: bool,
166}
167
168impl Surfaces {
169 pub fn present(&self) -> Vec<&'static str> {
171 SURFACES
172 .iter()
173 .filter(|(id, _, _)| self.has(id))
174 .map(|(id, _, _)| *id)
175 .collect()
176 }
177
178 pub fn has(&self, id: &str) -> bool {
180 match id {
181 "cli" => self.cli,
182 "api" => self.api,
183 "mcp" => self.mcp,
184 "cockpit" => self.cockpit,
185 "docs" => self.docs,
186 _ => false,
187 }
188 }
189}
190
191pub const SURFACES: &[(&str, &str, &str)] = &[
195 ("cli", "Command line", "/docs/cli/"),
196 ("api", "HTTP API", "/docs/api/"),
197 ("mcp", "MCP", "/registry/mcp/"),
198 ("cockpit", "Cockpit", "/docs/cockpit/"),
199 ("docs", "Documentation", "/docs/"),
200];
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
205pub struct CapabilityRef {
206 pub id: String,
208 pub title: String,
210 pub kind: String,
212 pub stability: String,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub tool: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub resource: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub route: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub cli: Option<String>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
230pub struct ModuleRef {
231 pub id: String,
233 pub title: String,
235 pub description: String,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub stability: Option<String>,
240 pub source_path: String,
242 pub capabilities: Vec<CapabilityRef>,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
249pub struct CommandRef {
250 pub id: String,
252 pub summary: String,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub stage: Option<String>,
257 pub read_only: bool,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
263pub struct KindRef {
264 pub name: String,
266 pub objects: usize,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
272pub struct RuleRef {
273 pub id: String,
275 pub identity: String,
277 pub title: String,
279 pub class: String,
281 pub enforced: bool,
284 pub path: String,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
290pub struct DocRef {
291 pub path: String,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub title: Option<String>,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
300pub struct AdrRef {
301 pub id: String,
303 pub title: String,
305 pub status: String,
307 pub path: String,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
313pub struct ClaimRef {
314 pub id: String,
316 pub claim: String,
318 pub status: String,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub test: Option<String>,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
330pub struct UseCaseRef {
331 pub id: String,
333 pub title: String,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub category: Option<String>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342pub struct CockpitAreaRef {
343 pub id: String,
345 pub title: String,
347 pub route: String,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
353pub struct SurfaceRef {
354 pub id: String,
356 pub title: String,
358 pub mount: String,
360 pub category: String,
362}
363
364impl crate::order::Ordered for Feature {
367 fn order_key(&self) -> crate::order::OrderKey<'_> {
368 crate::order::OrderKey::plain(&self.id, &self.id).ranked(i64::from(self.weight))
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
375pub struct MomentRef {
376 pub id: String,
378 pub title: String,
380 pub hook: String,
382 pub route: String,
384}
385
386#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
388pub struct FeatureCounts {
389 pub capabilities: usize,
391 pub mcp_tools: usize,
393 pub mcp_resources: usize,
395 pub http_routes: usize,
397 pub cli_paths: usize,
399 pub commands: usize,
401 pub objects: usize,
403 pub rules: usize,
405 pub enforced_rules: usize,
407 pub docs: usize,
409 pub adrs: usize,
411 pub claims: usize,
413 pub use_cases: usize,
415 pub moments: usize,
417}
418
419#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
422pub struct FeatureEvidence {
423 pub claims: BTreeMap<String, usize>,
425 pub modules: BTreeMap<String, usize>,
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
431pub struct ResolvedRefs {
432 #[serde(flatten)]
434 pub feature: Feature,
435 pub surfaces: Surfaces,
437 pub module_refs: Vec<ModuleRef>,
439 pub command_refs: Vec<CommandRef>,
441 pub kind_refs: Vec<KindRef>,
443 pub rule_refs: Vec<RuleRef>,
445 pub doc_refs: Vec<DocRef>,
447 pub adr_refs: Vec<AdrRef>,
449 pub claim_refs: Vec<ClaimRef>,
451 pub use_case_refs: Vec<UseCaseRef>,
453 pub cockpit_refs: Vec<CockpitAreaRef>,
455 pub web_refs: Vec<SurfaceRef>,
457 pub moments: Vec<MomentRef>,
459 pub backlinks: Vec<String>,
461 pub counts: FeatureCounts,
463 pub evidence: FeatureEvidence,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub domain_ref: Option<DomainRef>,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
487pub struct DomainRef {
488 pub id: String,
490 pub title: String,
492 pub route: String,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
515pub struct Domain {
516 pub id: String,
518 pub title: String,
520 pub headline: String,
522 pub problem: String,
524 pub status: String,
526 #[serde(default)]
527 pub weight: u32,
529 #[serde(default)]
530 pub tags: Vec<String>,
532 #[serde(default)]
533 pub route: String,
535 #[serde(default)]
536 pub source: String,
538 #[serde(default, skip_serializing_if = "String::is_empty")]
539 pub body: String,
541}
542
543impl crate::order::Ordered for Domain {
546 fn order_key(&self) -> crate::order::OrderKey<'_> {
547 crate::order::OrderKey::plain(&self.id, &self.id).ranked(i64::from(self.weight))
548 }
549}
550
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
572pub struct DomainMember {
573 pub id: String,
575 pub label: String,
577 pub title: String,
579 pub headline: String,
581 pub route: String,
583 pub featured: bool,
585 pub surfaces: Surfaces,
587 pub claims: usize,
589 pub tested: usize,
591 pub use_cases: usize,
593}
594
595#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
607pub struct DomainCounts {
608 pub features: usize,
610 pub claims: usize,
612 pub tested: usize,
616 pub use_cases: usize,
618 pub rules: usize,
620 pub enforced_rules: usize,
622 pub capabilities: usize,
624 pub moments: usize,
626}
627
628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
656pub struct ResolvedDomain {
657 #[serde(flatten)]
659 pub domain: Domain,
660 pub features: Vec<DomainMember>,
662 pub surfaces: Surfaces,
664 pub moments: Vec<MomentRef>,
666 pub claim_refs: Vec<ClaimRef>,
668 pub use_case_refs: Vec<UseCaseRef>,
670 pub counts: DomainCounts,
672 pub evidence: FeatureEvidence,
674}
675
676impl crate::order::Ordered for ProductProvider {
681 fn order_key(&self) -> crate::order::OrderKey<'_> {
682 crate::order::OrderKey::plain(&self.id, &self.id)
683 }
684}
685
686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
689pub struct ProductProvider {
690 pub id: String,
692 pub title: String,
694 pub bootstraps: Vec<ProviderBootstrap>,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub client_config: Option<String>,
701 pub hooks: Vec<String>,
703 #[serde(default)]
707 pub scratch_roots: Vec<String>,
708 #[serde(default)]
717 pub offers: ProviderOffers,
718}
719
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
722pub struct ProviderBootstrap {
723 pub target: String,
725 pub mode: String,
727 pub always_loaded: bool,
729}
730
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
736pub struct ProductFinding {
737 pub severity: Severity,
739 pub code: String,
741 pub path: String,
743 #[serde(skip_serializing_if = "Option::is_none")]
745 pub id: Option<String>,
746 #[serde(skip_serializing_if = "Option::is_none")]
748 pub field: Option<String>,
749 pub message: String,
751 #[serde(skip_serializing_if = "Option::is_none")]
753 pub did_you_mean: Option<String>,
754}
755
756#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
762pub struct ProductCoverage {
763 pub id: String,
765 pub title: String,
767 pub features: Vec<String>,
769}
770
771#[derive(Debug, Clone, Default)]
776pub struct ProductModel {
777 features: Vec<ResolvedRefs>,
778 providers: Vec<ProductProvider>,
779 findings: Vec<ProductFinding>,
780 fingerprint: String,
781 by_id: BTreeMap<String, usize>,
782 modules: Vec<ProductCoverage>,
783 commands: Vec<ProductCoverage>,
784 kinds: Vec<ProductCoverage>,
785 module_areas: BTreeMap<String, String>,
786 domains: Vec<ResolvedDomain>,
787 domain_by_id: BTreeMap<String, usize>,
788}
789
790type Claims = BTreeMap<String, Vec<(String, Vec<String>)>>;
792
793fn resolve_module_areas(
805 claims: &Claims,
806 weight: &BTreeMap<&str, u32>,
807) -> (BTreeMap<String, String>, Vec<(String, String)>) {
808 let mut areas: BTreeMap<String, String> = BTreeMap::new();
809 let mut contested: Vec<(String, String)> = Vec::new();
810
811 for (module, claimants) in claims {
812 let union: std::collections::BTreeSet<&str> = claimants
815 .iter()
816 .flat_map(|(_, areas)| areas.iter().map(String::as_str))
817 .collect();
818
819 let disjoint = claimants.len() > 1
820 && claimants.iter().any(|(_, a)| {
821 claimants
822 .iter()
823 .any(|(_, b)| !a.iter().any(|x| b.contains(x)))
824 });
825 if disjoint {
826 let who: Vec<String> = claimants
827 .iter()
828 .map(|(id, a)| format!("{id} ({})", a.join(", ")))
829 .collect();
830 contested.push((module.clone(), who.join(" / ")));
831 }
832
833 if let Some(best) = union.iter().min_by(|a, b| {
834 weight
835 .get(*a)
836 .unwrap_or(&u32::MAX)
837 .cmp(weight.get(*b).unwrap_or(&u32::MAX))
838 .then_with(|| a.cmp(b))
839 }) {
840 areas.insert(module.clone(), (*best).to_string());
841 }
842 }
843 (areas, contested)
844}
845
846fn machine_enforced(meta: &Value) -> bool {
852 meta.get("x-majordomus").is_some_and(|x| {
853 x.get("validator").is_some()
854 || x.get("tests")
855 .and_then(Value::as_array)
856 .is_some_and(|t| !t.is_empty())
857 })
858}
859
860fn distance(a: &str, b: &str) -> usize {
862 let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
863 let mut prev: Vec<usize> = (0..=b.len()).collect();
864 let mut cur = vec![0usize; b.len() + 1];
865 for (i, ca) in a.iter().enumerate() {
866 cur[0] = i + 1;
867 for (j, cb) in b.iter().enumerate() {
868 let cost = usize::from(ca != cb);
869 cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
870 }
871 std::mem::swap(&mut prev, &mut cur);
872 }
873 prev[b.len()]
874}
875
876fn nearest<'a>(value: &str, candidates: impl Iterator<Item = &'a str>) -> Option<String> {
878 let limit = (value.chars().count() / 3).max(2);
879 candidates
880 .map(|c| (distance(value, c), c))
881 .filter(|(d, _)| *d <= limit)
882 .min_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(b.1)))
883 .map(|(_, c)| c.to_string())
884}
885
886fn meta_str(o: &Object, key: &str) -> Option<String> {
887 o.metadata
888 .get(key)
889 .and_then(Value::as_str)
890 .map(str::to_string)
891}
892
893fn enum_word<T: Serialize>(v: &T) -> String {
894 serde_json::to_value(v)
895 .ok()
896 .and_then(|v| v.as_str().map(str::to_string))
897 .unwrap_or_default()
898}
899
900struct Lookups<'a> {
902 commands: BTreeMap<String, &'a Object>,
903 rules_by_stem: BTreeMap<String, &'a Object>,
904 docs: BTreeMap<String, &'a Object>,
905 adrs_by_id: BTreeMap<String, &'a Object>,
906 claims: BTreeMap<String, &'a Object>,
907 use_cases: BTreeMap<String, &'a Object>,
908 kinds: BTreeMap<String, usize>,
909 areas: BTreeSet<String>,
910 audiences: BTreeSet<String>,
911}
912
913impl<'a> Lookups<'a> {
914 fn new(index: &'a Index) -> Self {
915 let mut l = Lookups {
916 commands: BTreeMap::new(),
917 rules_by_stem: BTreeMap::new(),
918 docs: BTreeMap::new(),
919 adrs_by_id: BTreeMap::new(),
920 claims: BTreeMap::new(),
921 use_cases: BTreeMap::new(),
922 kinds: BTreeMap::new(),
923 areas: BTreeSet::new(),
924 audiences: BTreeSet::new(),
925 };
926 for o in &index.objects {
927 *l.kinds.entry(o.kind.clone()).or_default() += 1;
928 match o.kind.as_str() {
929 "command" => {
932 if meta_str(o, "visibility").as_deref() == Some("public") {
933 l.commands.insert(o.identity.clone(), o);
934 }
935 }
936 "rule" => {
937 let stem = o
938 .identity
939 .split_once('@')
940 .map(|(s, _)| s.to_string())
941 .unwrap_or_else(|| o.identity.clone());
942 l.rules_by_stem.entry(stem).or_insert(o);
943 }
944 "document" => {
945 l.docs.insert(o.provenance.path.clone(), o);
946 }
947 "adr" => {
948 if let Some(id) = meta_str(o, "id") {
949 l.adrs_by_id.insert(id, o);
950 }
951 }
952 "claim" => {
953 l.claims.insert(o.identity.clone(), o);
954 }
955 "use-case" => {
956 l.use_cases.insert(o.identity.clone(), o);
957 }
958 crate::why::AREA => {
959 l.areas.insert(o.identity.clone());
960 }
961 crate::why::AUDIENCE => {
962 l.audiences.insert(o.identity.clone());
963 }
964 _ => {}
965 }
966 }
967 l
968 }
969}
970
971impl ProductModel {
972 pub fn build(
977 index: &Index,
978 registry: &CapabilityRegistry,
979 why: &Catalogue,
980 web: &Topology,
981 ) -> Self {
982 let mut m = ProductModel::default();
983 let mut features: Vec<Feature> = Vec::new();
984 let mut seen: BTreeMap<String, String> = BTreeMap::new();
985 for o in index.objects.iter().filter(|o| o.kind == FEATURE) {
986 let path = o.provenance.path.clone();
987 if let Some(first) = seen.insert(o.identity.clone(), path.clone()) {
988 m.findings.push(ProductFinding {
989 severity: Severity::Error,
990 code: "duplicate_identity".into(),
991 path: path.clone(),
992 id: Some(o.identity.clone()),
993 field: Some("id".into()),
994 message: format!(
995 "a second feature claims the identity '{}' (first: {first})",
996 o.identity
997 ),
998 did_you_mean: None,
999 });
1000 }
1001 let stem = path
1002 .rsplit('/')
1003 .next()
1004 .and_then(|f| f.strip_suffix(".md"))
1005 .unwrap_or_default();
1006 if stem != o.identity {
1007 m.findings.push(ProductFinding {
1008 severity: Severity::Error,
1009 code: "filename_mismatch".into(),
1010 path: path.clone(),
1011 id: Some(o.identity.clone()),
1012 field: Some("id".into()),
1013 message: format!(
1014 "the file is named '{stem}.md' and the record's id is '{}'; the id is the file name and the route",
1015 o.identity
1016 ),
1017 did_you_mean: Some(format!("{}.md", o.identity)),
1018 });
1019 }
1020 if let Ok(mut f) = serde_json::from_value::<Feature>(o.metadata.clone()) {
1021 f.route = format!("{ROUTE}{}/", f.id);
1022 f.source = path;
1023 f.body = o.body.clone();
1024 features.push(f);
1025 }
1026 }
1027 crate::order::canonical(&mut features);
1028
1029 m.adopt_diagnostics(index);
1030 let lookups = Lookups::new(index);
1031 let backlinks = backlinks(&features);
1032 let areas = nav::areas();
1033
1034 let mut resolved = Vec::with_capacity(features.len());
1035 for f in &features {
1036 let (r, findings) = resolve(
1037 f, registry, index, why, web, &lookups, areas, &backlinks, &features,
1038 );
1039 m.findings.extend(findings);
1040 resolved.push(r);
1041 }
1042 m.features = resolved;
1043 for (i, r) in m.features.iter().enumerate() {
1044 m.by_id.insert(r.feature.id.clone(), i);
1045 }
1046 m.providers = providers(index);
1047 for decl in &index.providers.providers {
1048 if !decl.template {
1049 m.findings.push(ProductFinding {
1050 severity: Severity::Error,
1051 code: "provider_without_template".into(),
1052 path: "share/providers.yaml".into(),
1053 id: Some(decl.id.clone()),
1054 field: Some("providers".into()),
1055 message: format!(
1056 "provider '{}' is declared and the distribution ships no share/providers/{}.tmpl; a provider with no bootstrap is a name and nothing else",
1057 decl.id, decl.id
1058 ),
1059 did_you_mean: None,
1060 });
1061 } else if !decl.declared {
1062 m.findings.push(ProductFinding {
1063 severity: Severity::Warning,
1064 code: "provider_undeclared".into(),
1065 path: format!("share/providers/{}.tmpl", decl.id),
1066 id: Some(decl.id.clone()),
1067 field: None,
1068 message: format!(
1069 "provider '{}' has a template and no entry in share/providers.yaml; it is named by its file and declares no title, client configuration or scratch root",
1070 decl.id
1071 ),
1072 did_you_mean: None,
1073 });
1074 }
1075 }
1076 m.coverage(registry, &lookups);
1077 m.resolve_domains(index, why);
1078 let area_weights: BTreeMap<&str, u32> = why
1079 .areas()
1080 .iter()
1081 .map(|a| (a.id.as_str(), a.weight))
1082 .collect();
1083 m.derive_module_areas(&area_weights);
1084 m.findings.sort_by(|a, b| {
1085 b.severity
1086 .cmp(&a.severity)
1087 .then(a.path.cmp(&b.path))
1088 .then(a.code.cmp(&b.code))
1089 .then(a.message.cmp(&b.message))
1090 });
1091 m.fingerprint = m.compute_fingerprint();
1092 m
1093 }
1094
1095 fn derive_module_areas(&mut self, weight: &BTreeMap<&str, u32>) {
1102 let mut claims: Claims = BTreeMap::new();
1103 for r in &self.features {
1104 if r.feature.areas.is_empty() {
1105 continue;
1106 }
1107 for module in &r.feature.modules {
1108 claims
1109 .entry(module.clone())
1110 .or_default()
1111 .push((r.feature.id.clone(), r.feature.areas.clone()));
1112 }
1113 }
1114 let (areas, contested) = resolve_module_areas(&claims, weight);
1115 self.module_areas = areas;
1116 for (module, who) in contested {
1117 self.findings.push(ProductFinding {
1118 severity: Severity::Warning,
1119 code: "contested_area".into(),
1120 path: ".ai/repo/features".into(),
1121 id: Some(module.clone()),
1122 field: Some("areas".into()),
1123 message: format!(
1124 "the module '{module}' is claimed by features that share no area: {who}; \
1125 the lowest-weight area wins until a feature file settles it"
1126 ),
1127 did_you_mean: None,
1128 });
1129 }
1130 }
1131
1132 pub fn module_area(&self, module: &str) -> Option<&str> {
1140 self.module_areas.get(module).map(String::as_str)
1141 }
1142
1143 fn adopt_diagnostics(&mut self, index: &Index) {
1147 let Some(section) = index.repository.sections.get("features") else {
1148 return;
1149 };
1150 for d in index
1151 .diagnostics
1152 .iter()
1153 .filter(|d| d.severity == Severity::Error)
1154 {
1155 let Some(path) = d.path.as_deref() else {
1156 continue;
1157 };
1158 if !path.starts_with(section.as_str()) {
1159 continue;
1160 }
1161 self.findings.push(ProductFinding {
1162 severity: Severity::Error,
1163 code: d.code.clone(),
1164 path: path.to_string(),
1165 id: None,
1166 field: None,
1167 message: d.message.clone(),
1168 did_you_mean: None,
1169 });
1170 }
1171 }
1172
1173 fn coverage(&mut self, registry: &CapabilityRegistry, lookups: &Lookups<'_>) {
1177 let stable: Vec<&ResolvedRefs> = self
1178 .features
1179 .iter()
1180 .filter(|r| r.feature.status == STABLE)
1181 .collect();
1182 let naming = |pick: &dyn Fn(&Feature) -> &Vec<String>, id: &str| -> Vec<String> {
1183 stable
1184 .iter()
1185 .filter(|r| pick(&r.feature).iter().any(|x| x == id))
1186 .map(|r| r.feature.id.clone())
1187 .collect()
1188 };
1189 self.modules = registry
1190 .modules()
1191 .filter(|m| m.source != crate::capability::registry::ModuleSource::Declarative)
1192 .map(|m| ProductCoverage {
1193 id: m.id.to_string(),
1194 title: m.title.clone(),
1195 features: naming(&|f| &f.modules, m.id.as_str()),
1196 })
1197 .collect();
1198 self.commands = lookups
1199 .commands
1200 .iter()
1201 .map(|(id, o)| ProductCoverage {
1202 id: id.clone(),
1203 title: o.title.clone().unwrap_or_else(|| id.clone()),
1204 features: naming(&|f| &f.commands, id),
1205 })
1206 .collect();
1207 self.kinds = lookups
1211 .kinds
1212 .iter()
1213 .filter(|(k, _)| !matches!(k.as_str(), "implementation" | "test" | "document"))
1214 .map(|(k, n)| ProductCoverage {
1215 id: k.clone(),
1216 title: format!("{k} ({n})"),
1217 features: naming(&|f| &f.kinds, k),
1218 })
1219 .collect();
1220 let mut uncovered = Vec::new();
1221 for (what, list) in [
1222 ("module", &self.modules),
1223 ("command", &self.commands),
1224 ("kind", &self.kinds),
1225 ] {
1226 for c in list.iter().filter(|c| c.features.is_empty()) {
1227 uncovered.push(ProductFinding {
1228 severity: Severity::Warning,
1229 code: "uncovered".into(),
1230 path: match what {
1231 "module" => crate::capability::model::CRATE_DIR.to_string(),
1232 "command" => "share/commands.yaml".to_string(),
1233 _ => "share/kinds.yaml".to_string(),
1234 },
1235 id: Some(c.id.clone()),
1236 field: None,
1237 message: format!(
1238 "the {what} '{}' is named by no stable feature; the product page says nothing about it",
1239 c.id
1240 ),
1241 did_you_mean: None,
1242 });
1243 }
1244 }
1245 self.findings.extend(uncovered);
1246 }
1247
1248 fn compute_fingerprint(&self) -> String {
1252 let mut h = Sha256::new();
1253 for r in &self.features {
1254 h.update(r.feature.id.as_bytes());
1255 h.update([0]);
1256 h.update(r.feature.source.as_bytes());
1257 h.update([0]);
1258 h.update(r.feature.body.as_bytes());
1259 h.update([0]);
1260 h.update(
1261 serde_json::to_string(&r.surfaces)
1262 .unwrap_or_default()
1263 .as_bytes(),
1264 );
1265 h.update(
1266 serde_json::to_string(&r.counts)
1267 .unwrap_or_default()
1268 .as_bytes(),
1269 );
1270 h.update(b"\n");
1271 }
1272 for d in &self.domains {
1273 h.update(d.domain.id.as_bytes());
1274 h.update([0]);
1275 h.update(d.domain.source.as_bytes());
1276 h.update([0]);
1277 h.update(d.domain.body.as_bytes());
1278 h.update([0]);
1279 for f in &d.features {
1280 h.update(f.id.as_bytes());
1281 h.update([0]);
1282 }
1283 h.update(b"\n");
1284 }
1285 format!("{:x}", h.finalize())
1286 }
1287
1288 pub fn all(&self) -> &[ResolvedRefs] {
1290 &self.features
1291 }
1292 pub fn feature(&self, id: &str) -> Option<&ResolvedRefs> {
1294 self.by_id.get(id).map(|i| &self.features[*i])
1295 }
1296 pub fn public(&self) -> Vec<&ResolvedRefs> {
1298 self.features
1299 .iter()
1300 .filter(|r| r.feature.status == STABLE)
1301 .collect()
1302 }
1303 pub fn featured(&self) -> Vec<&ResolvedRefs> {
1305 self.public()
1306 .into_iter()
1307 .filter(|r| r.feature.featured)
1308 .collect()
1309 }
1310 pub fn providers(&self) -> &[ProductProvider] {
1312 &self.providers
1313 }
1314 pub fn findings(&self) -> &[ProductFinding] {
1316 &self.findings
1317 }
1318 pub fn errors(&self) -> usize {
1320 self.findings
1321 .iter()
1322 .filter(|f| f.severity == Severity::Error)
1323 .count()
1324 }
1325 pub fn fingerprint(&self) -> &str {
1327 &self.fingerprint
1328 }
1329 pub fn module_coverage(&self) -> &[ProductCoverage] {
1331 &self.modules
1332 }
1333 pub fn command_coverage(&self) -> &[ProductCoverage] {
1335 &self.commands
1336 }
1337 pub fn kind_coverage(&self) -> &[ProductCoverage] {
1339 &self.kinds
1340 }
1341 pub fn domains(&self) -> &[ResolvedDomain] {
1343 &self.domains
1344 }
1345 pub fn domain(&self, id: &str) -> Option<&ResolvedDomain> {
1354 self.domain_by_id.get(id).map(|i| &self.domains[*i])
1355 }
1356 pub fn public_domains(&self) -> Vec<&ResolvedDomain> {
1364 self.domains
1365 .iter()
1366 .filter(|d| d.domain.status == STABLE)
1367 .collect()
1368 }
1369
1370 fn resolve_domains(&mut self, index: &Index, why: &Catalogue) {
1380 let mut mismatched = Vec::new();
1383 let mut domains: Vec<Domain> = index
1384 .objects
1385 .iter()
1386 .filter(|o| o.kind == DOMAIN)
1387 .filter_map(|o| {
1388 let path = o.provenance.path.clone();
1389 let stem = path
1390 .rsplit('/')
1391 .next()
1392 .and_then(|f| f.strip_suffix(".md"))
1393 .unwrap_or_default();
1394 if stem != o.identity {
1395 mismatched.push(ProductFinding {
1396 severity: Severity::Error,
1397 code: "filename_mismatch".into(),
1398 path: path.clone(),
1399 id: Some(o.identity.clone()),
1400 field: Some("id".into()),
1401 message: format!(
1402 "the file is named '{stem}.md' and the domain's id is '{}'; the id is the file name and the route",
1403 o.identity
1404 ),
1405 did_you_mean: Some(format!("{}.md", o.identity)),
1406 });
1407 }
1408 serde_json::from_value::<Domain>(o.metadata.clone())
1410 .ok()
1411 .map(|mut d| {
1412 d.route = format!("{DOMAIN_ROUTE}{}/", d.id);
1413 d.source = path;
1414 d.body = o.body.clone();
1415 d
1416 })
1417 })
1418 .collect();
1419 self.findings.extend(mismatched);
1420 crate::order::canonical(&mut domains);
1421
1422 let ids: Vec<&str> = domains.iter().map(|d| d.id.as_str()).collect();
1424 let mut findings = Vec::new();
1425 for r in self.features.iter_mut() {
1426 let f = &r.feature;
1427 match f.domain.as_deref() {
1428 Some(id) => match domains.iter().find(|d| d.id == id) {
1429 Some(d) => {
1430 if f.status == STABLE && d.status != STABLE {
1431 findings.push(ProductFinding {
1432 severity: Severity::Error,
1433 code: "draft_domain".into(),
1434 path: f.source.clone(),
1435 id: Some(f.id.clone()),
1436 field: Some("domain".into()),
1437 message: format!(
1438 "a stable feature is filed under the {} domain '{}', which is shown nowhere; file it under a stable domain or make the domain stable",
1439 d.status, d.id
1440 ),
1441 did_you_mean: None,
1442 });
1443 }
1444 r.domain_ref = Some(DomainRef {
1445 id: d.id.clone(),
1446 title: d.title.clone(),
1447 route: d.route.clone(),
1448 });
1449 }
1450 None => findings.push(ProductFinding {
1451 severity: Severity::Error,
1452 code: "unknown_reference".into(),
1453 path: f.source.clone(),
1454 id: Some(f.id.clone()),
1455 field: Some("domain".into()),
1456 message: format!("unknown domain reference: \"{id}\""),
1457 did_you_mean: nearest(id, ids.iter().copied()),
1458 }),
1459 },
1460 None if f.status == STABLE && !domains.is_empty() => {
1461 findings.push(ProductFinding {
1462 severity: Severity::Error,
1463 code: "unassigned_domain".into(),
1464 path: f.source.clone(),
1465 id: Some(f.id.clone()),
1466 field: Some("domain".into()),
1467 message: format!(
1468 "a stable feature names no domain while the layer declares {}; every surface that presents the product by domain would leave it out",
1469 ids.join(", ")
1470 ),
1471 did_you_mean: None,
1472 })
1473 }
1474 None => {}
1475 }
1476 }
1477
1478 let mut resolved = Vec::with_capacity(domains.len());
1479 for d in domains {
1480 let members: Vec<&ResolvedRefs> = self
1481 .features
1482 .iter()
1483 .filter(|r| r.feature.status == STABLE)
1484 .filter(|r| r.feature.domain.as_deref() == Some(d.id.as_str()))
1485 .collect();
1486 if members.is_empty() && d.status == STABLE {
1487 findings.push(ProductFinding {
1488 severity: Severity::Warning,
1489 code: "empty_domain".into(),
1490 path: d.source.clone(),
1491 id: Some(d.id.clone()),
1492 field: None,
1493 message: format!(
1494 "no stable feature names the domain '{}'; it is shown nowhere until one does",
1495 d.id
1496 ),
1497 did_you_mean: None,
1498 });
1499 }
1500 resolved.push(resolve_domain(d, &members, why));
1501 }
1502 self.domains = resolved;
1503 self.domain_by_id = self
1504 .domains
1505 .iter()
1506 .enumerate()
1507 .map(|(i, d)| (d.domain.id.clone(), i))
1508 .collect();
1509 self.findings.extend(findings);
1510 }
1511}
1512
1513fn resolve_domain(d: Domain, members: &[&ResolvedRefs], why: &Catalogue) -> ResolvedDomain {
1515 let mut surfaces = Surfaces::default();
1516 let mut claims: BTreeMap<String, ClaimRef> = BTreeMap::new();
1517 let mut use_cases: BTreeMap<String, UseCaseRef> = BTreeMap::new();
1518 let mut rules: BTreeMap<String, bool> = BTreeMap::new();
1519 let mut capabilities: BTreeSet<String> = BTreeSet::new();
1520 let mut moments: BTreeMap<(u32, String), MomentRef> = BTreeMap::new();
1522 for r in members {
1523 surfaces.cli |= r.surfaces.cli;
1524 surfaces.api |= r.surfaces.api;
1525 surfaces.mcp |= r.surfaces.mcp;
1526 surfaces.cockpit |= r.surfaces.cockpit;
1527 surfaces.docs |= r.surfaces.docs;
1528 for c in &r.claim_refs {
1529 claims.entry(c.id.clone()).or_insert_with(|| c.clone());
1530 }
1531 for u in &r.use_case_refs {
1532 use_cases.entry(u.id.clone()).or_insert_with(|| u.clone());
1533 }
1534 for x in &r.rule_refs {
1535 rules.insert(x.id.clone(), x.enforced);
1536 }
1537 for m in &r.module_refs {
1538 for c in &m.capabilities {
1539 capabilities.insert(c.id.clone());
1540 }
1541 }
1542 for m in &r.moments {
1543 let weight = why.moment(&m.id).map(|x| x.weight).unwrap_or_default();
1544 moments
1545 .entry((weight, m.id.clone()))
1546 .or_insert_with(|| m.clone());
1547 }
1548 }
1549 let moments: Vec<MomentRef> = moments.into_values().collect();
1550 let tested = |c: &ClaimRef| c.status == "guaranteed" && c.test.is_some();
1551 let mut evidence = FeatureEvidence::default();
1552 for c in claims.values() {
1553 *evidence.claims.entry(c.status.clone()).or_default() += 1;
1554 }
1555 let counts = DomainCounts {
1556 features: members.len(),
1557 claims: claims.len(),
1558 tested: claims.values().filter(|c| tested(c)).count(),
1559 use_cases: use_cases.len(),
1560 rules: rules.len(),
1561 enforced_rules: rules.values().filter(|e| **e).count(),
1562 capabilities: capabilities.len(),
1563 moments: moments.len(),
1564 };
1565 let features = members
1566 .iter()
1567 .map(|r| DomainMember {
1568 id: r.feature.id.clone(),
1569 label: r.feature.label().to_string(),
1570 title: r.feature.title.clone(),
1571 headline: r.feature.headline.clone(),
1572 route: r.feature.route.clone(),
1573 featured: r.feature.featured,
1574 surfaces: r.surfaces,
1575 claims: r.claim_refs.len(),
1576 tested: r.claim_refs.iter().filter(|c| tested(c)).count(),
1577 use_cases: r.use_case_refs.len(),
1578 })
1579 .collect();
1580 ResolvedDomain {
1581 domain: d,
1582 features,
1583 surfaces,
1584 moments,
1585 claim_refs: claims.into_values().collect(),
1586 use_case_refs: use_cases.into_values().collect(),
1587 counts,
1588 evidence,
1589 }
1590}
1591
1592fn backlinks(features: &[Feature]) -> BTreeMap<String, Vec<String>> {
1594 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
1595 for f in features {
1596 for r in &f.related {
1597 out.entry(r.clone()).or_default().push(f.id.clone());
1598 }
1599 }
1600 for v in out.values_mut() {
1601 v.sort();
1602 v.dedup();
1603 }
1604 out
1605}
1606
1607fn capability_ref(c: &Capability) -> CapabilityRef {
1609 CapabilityRef {
1610 id: c.id.to_string(),
1611 title: c.title.clone(),
1612 kind: enum_word(&c.kind),
1613 stability: enum_word(&c.stability),
1614 tool: c.exposure.mcp.as_ref().and_then(|m| m.tool.clone()),
1615 resource: c
1616 .exposure
1617 .mcp
1618 .as_ref()
1619 .and_then(|m| m.resource.as_ref())
1620 .map(|r| r.uri.clone()),
1621 route: c
1622 .exposure
1623 .http
1624 .as_ref()
1625 .map(|h| format!("{} {}", h.method.as_str(), h.path)),
1626 cli: c
1627 .exposure
1628 .cli
1629 .as_ref()
1630 .map(|x| format!("majordomus {}", x.path.join(" "))),
1631 }
1632}
1633
1634#[allow(clippy::too_many_arguments)]
1637fn resolve(
1638 f: &Feature,
1639 registry: &CapabilityRegistry,
1640 index: &Index,
1641 why: &Catalogue,
1642 web: &Topology,
1643 lookups: &Lookups<'_>,
1644 areas: &[nav::AreaInfo],
1645 backlinks: &BTreeMap<String, Vec<String>>,
1646 all: &[Feature],
1647) -> (ResolvedRefs, Vec<ProductFinding>) {
1648 let mut findings = Vec::new();
1649 let mut unknowns = Vec::new();
1652 let mut unknown = |field: &str, value: &str, what: &str, candidates: Vec<&str>| {
1653 unknowns.push(ProductFinding {
1654 severity: Severity::Error,
1655 code: "unknown_reference".into(),
1656 path: f.source.clone(),
1657 id: Some(f.id.clone()),
1658 field: Some(field.into()),
1659 message: format!("unknown {what} reference: \"{value}\""),
1660 did_you_mean: nearest(value, candidates.into_iter()),
1661 });
1662 };
1663
1664 let mut module_refs = Vec::new();
1666 for id in &f.modules {
1667 match registry
1668 .module(id)
1669 .filter(|m| m.source != crate::capability::registry::ModuleSource::Declarative)
1670 {
1671 Some(m) => {
1672 let capabilities: Vec<&Capability> = registry
1673 .iter()
1674 .filter(|c| {
1675 c.module.as_str() == m.id.as_str()
1676 && matches!(c.provenance, Provenance::Builtin { .. })
1677 })
1678 .collect();
1679 module_refs.push(ModuleRef {
1680 id: m.id.to_string(),
1681 title: m.title.clone(),
1682 description: m.description.clone(),
1683 stability: m.stability.map(|s| enum_word(&s)),
1684 source_path: capabilities
1685 .first()
1686 .map(|c| c.provenance.source_path())
1687 .unwrap_or_default(),
1688 capabilities: capabilities.iter().map(|c| capability_ref(c)).collect(),
1689 });
1690 }
1691 None => unknown(
1692 "modules",
1693 id,
1694 "module",
1695 registry
1696 .modules()
1697 .filter(|m| m.source != crate::capability::registry::ModuleSource::Declarative)
1698 .map(|m| m.id.as_str())
1699 .collect(),
1700 ),
1701 }
1702 }
1703
1704 let mut command_refs = Vec::new();
1705 for id in &f.commands {
1706 match lookups.commands.get(id) {
1707 Some(o) => command_refs.push(CommandRef {
1708 id: id.clone(),
1709 summary: o.description.clone().unwrap_or_default(),
1710 stage: meta_str(o, "stage"),
1711 read_only: meta_str(o, "class").as_deref() == Some("read-only"),
1712 }),
1713 None => unknown(
1714 "commands",
1715 id,
1716 "command",
1717 lookups.commands.keys().map(String::as_str).collect(),
1718 ),
1719 }
1720 }
1721
1722 let mut kind_refs = Vec::new();
1723 for k in &f.kinds {
1724 match lookups.kinds.get(k) {
1725 Some(n) => kind_refs.push(KindRef {
1726 name: k.clone(),
1727 objects: *n,
1728 }),
1729 None => unknown(
1730 "kinds",
1731 k,
1732 "kind",
1733 lookups.kinds.keys().map(String::as_str).collect(),
1734 ),
1735 }
1736 }
1737
1738 let mut rule_refs = Vec::new();
1739 for id in &f.rules {
1740 match lookups.rules_by_stem.get(id) {
1741 Some(o) => rule_refs.push(RuleRef {
1742 id: id.clone(),
1743 identity: o.identity.clone(),
1744 title: o.title.clone().unwrap_or_else(|| id.clone()),
1745 class: meta_str(o, "class").unwrap_or_default(),
1746 enforced: machine_enforced(&o.metadata),
1747 path: o.provenance.path.clone(),
1748 }),
1749 None => unknown(
1750 "rules",
1751 id,
1752 "rule",
1753 lookups.rules_by_stem.keys().map(String::as_str).collect(),
1754 ),
1755 }
1756 }
1757
1758 let mut doc_refs = Vec::new();
1759 for p in &f.docs {
1760 match lookups.docs.get(p) {
1761 Some(o) => doc_refs.push(DocRef {
1762 path: p.clone(),
1763 title: o.title.clone(),
1764 }),
1765 None => unknown(
1766 "docs",
1767 p,
1768 "document",
1769 lookups.docs.keys().map(String::as_str).collect(),
1770 ),
1771 }
1772 }
1773
1774 let mut adr_refs = Vec::new();
1775 for id in &f.adrs {
1776 match lookups.adrs_by_id.get(id) {
1777 Some(o) => adr_refs.push(AdrRef {
1778 id: id.clone(),
1779 title: o.title.clone().unwrap_or_else(|| id.clone()),
1780 status: meta_str(o, "status").unwrap_or_default(),
1781 path: o.provenance.path.clone(),
1782 }),
1783 None => unknown(
1784 "adrs",
1785 id,
1786 "decision",
1787 lookups.adrs_by_id.keys().map(String::as_str).collect(),
1788 ),
1789 }
1790 }
1791
1792 let mut claim_refs = Vec::new();
1793 for id in &f.claims {
1794 match lookups.claims.get(id) {
1795 Some(o) => claim_refs.push(ClaimRef {
1796 id: id.clone(),
1797 claim: o.title.clone().unwrap_or_else(|| id.clone()),
1798 status: meta_str(o, "status").unwrap_or_default(),
1799 test: meta_str(o, "test").filter(|t| t != "-"),
1800 }),
1801 None => unknown(
1802 "claims",
1803 id,
1804 "claim",
1805 lookups.claims.keys().map(String::as_str).collect(),
1806 ),
1807 }
1808 }
1809
1810 let mut use_case_refs = Vec::new();
1811 for id in &f.use_cases {
1812 match lookups.use_cases.get(id) {
1813 Some(o) => use_case_refs.push(UseCaseRef {
1814 id: id.clone(),
1815 title: o.title.clone().unwrap_or_else(|| id.clone()),
1816 category: meta_str(o, "category"),
1817 }),
1818 None => unknown(
1819 "use_cases",
1820 id,
1821 "use case",
1822 lookups.use_cases.keys().map(String::as_str).collect(),
1823 ),
1824 }
1825 }
1826
1827 let mut cockpit_refs = Vec::new();
1828 for id in &f.cockpit {
1829 match areas.iter().find(|a| a.id == id) {
1830 Some(a) => cockpit_refs.push(CockpitAreaRef {
1831 id: id.clone(),
1832 title: a.label.to_string(),
1833 route: a.href.to_string(),
1834 }),
1835 None => unknown(
1836 "cockpit",
1837 id,
1838 "Cockpit area",
1839 areas.iter().map(|a| a.id).collect(),
1840 ),
1841 }
1842 }
1843
1844 let mut web_refs = Vec::new();
1845 for id in &f.web {
1846 match web.get(id) {
1847 Some(s) => web_refs.push(SurfaceRef {
1848 id: id.clone(),
1849 title: s.title.clone(),
1850 mount: s.mount.as_str().to_string(),
1851 category: s.category.to_string(),
1852 }),
1853 None => unknown("web", id, "web surface", web.ids()),
1854 }
1855 }
1856
1857 for a in &f.areas {
1858 if !lookups.areas.contains(a) {
1859 unknown(
1860 "areas",
1861 a,
1862 "area",
1863 lookups.areas.iter().map(String::as_str).collect(),
1864 );
1865 }
1866 }
1867 for a in &f.audiences {
1868 if !lookups.audiences.contains(a) {
1869 unknown(
1870 "audiences",
1871 a,
1872 "audience",
1873 lookups.audiences.iter().map(String::as_str).collect(),
1874 );
1875 }
1876 }
1877 for r in &f.related {
1878 if r == &f.id {
1879 findings.push(ProductFinding {
1880 severity: Severity::Error,
1881 code: "self_reference".into(),
1882 path: f.source.clone(),
1883 id: Some(f.id.clone()),
1884 field: Some("related".into()),
1885 message: "a feature may not be related to itself".into(),
1886 did_you_mean: None,
1887 });
1888 } else if !all.iter().any(|x| &x.id == r) {
1889 unknown(
1890 "related",
1891 r,
1892 "feature",
1893 all.iter().map(|x| x.id.as_str()).collect(),
1894 );
1895 }
1896 }
1897 findings.extend(unknowns);
1898 if RESERVED.contains(&f.id.as_str()) {
1899 findings.push(ProductFinding {
1900 severity: Severity::Error,
1901 code: "reserved_identity".into(),
1902 path: f.source.clone(),
1903 id: Some(f.id.clone()),
1904 field: Some("id".into()),
1905 message: format!(
1906 "'{}' is a route this section owns; a feature may not claim it (reserved: {})",
1907 f.id,
1908 RESERVED.join(", ")
1909 ),
1910 did_you_mean: None,
1911 });
1912 }
1913 if f.featured && f.status != STABLE {
1914 findings.push(ProductFinding {
1915 severity: Severity::Error,
1916 code: "featured_draft".into(),
1917 path: f.source.clone(),
1918 id: Some(f.id.clone()),
1919 field: Some("featured".into()),
1920 message: format!(
1921 "a {} feature may not be featured; the homepage shows stable features only",
1922 f.status
1923 ),
1924 did_you_mean: None,
1925 });
1926 }
1927
1928 if f.status == STABLE {
1930 let mut floor = |field: &str, message: &str| {
1931 findings.push(ProductFinding {
1932 severity: Severity::Warning,
1933 code: "missing_content".into(),
1934 path: f.source.clone(),
1935 id: Some(f.id.clone()),
1936 field: Some(field.into()),
1937 message: message.into(),
1938 did_you_mean: None,
1939 });
1940 };
1941 if module_refs.is_empty() && command_refs.is_empty() && kind_refs.is_empty() {
1942 floor(
1943 "modules",
1944 "a stable feature names at least one mechanism: a module, a command or a kind",
1945 );
1946 }
1947 if doc_refs.is_empty() {
1948 floor(
1949 "docs",
1950 "a stable feature names at least one document that explains it",
1951 );
1952 }
1953 for heading in ["## What it does", "## What it does not do"] {
1954 if !f.body.lines().any(|l| l.trim_end() == heading) {
1955 floor(
1956 "body",
1957 &format!("a stable feature's body carries `{heading}`"),
1958 );
1959 }
1960 }
1961 }
1962
1963 let caps: Vec<&CapabilityRef> = module_refs.iter().flat_map(|m| &m.capabilities).collect();
1965 let surfaces = Surfaces {
1966 cli: caps.iter().any(|c| c.cli.is_some()) || !command_refs.is_empty(),
1967 api: caps.iter().any(|c| c.route.is_some()),
1968 mcp: caps
1969 .iter()
1970 .any(|c| c.tool.is_some() || c.resource.is_some())
1971 || !kind_refs.is_empty(),
1972 cockpit: !cockpit_refs.is_empty() || !module_refs.is_empty(),
1973 docs: !doc_refs.is_empty(),
1974 };
1975
1976 let mut moment_ids: Vec<String> = Vec::new();
1980 let mut add = |ms: Vec<&crate::why::Moment>| {
1981 for m in ms {
1982 if m.status == crate::why::STABLE && !moment_ids.contains(&m.id) {
1983 moment_ids.push(m.id.clone());
1984 }
1985 }
1986 };
1987 for c in &command_refs {
1988 add(why.naming("command", &c.id));
1989 }
1990 for c in &caps {
1991 add(why.naming("capability", &c.id));
1992 }
1993 for c in &claim_refs {
1994 add(why.naming("claim", &c.id));
1995 }
1996 for r in &rule_refs {
1997 add(why.naming("doctrine", &r.id));
1998 }
1999 let mut moments: Vec<MomentRef> = moment_ids
2000 .iter()
2001 .filter_map(|id| why.moment(id))
2002 .map(|m| MomentRef {
2003 id: m.id.clone(),
2004 title: m.title.clone(),
2005 hook: m.hook.clone(),
2006 route: m.route.clone(),
2007 })
2008 .collect();
2009 moments.sort_by(|a, b| {
2010 let wa = why.moment(&a.id).map(|m| m.weight).unwrap_or_default();
2011 let wb = why.moment(&b.id).map(|m| m.weight).unwrap_or_default();
2012 wa.cmp(&wb).then(a.id.cmp(&b.id))
2013 });
2014
2015 let counts = FeatureCounts {
2016 capabilities: caps.len(),
2017 mcp_tools: caps.iter().filter(|c| c.tool.is_some()).count(),
2018 mcp_resources: caps.iter().filter(|c| c.resource.is_some()).count()
2019 + kind_refs.iter().map(|k| k.objects).sum::<usize>(),
2020 http_routes: caps.iter().filter(|c| c.route.is_some()).count(),
2021 cli_paths: caps.iter().filter(|c| c.cli.is_some()).count(),
2022 commands: command_refs.len(),
2023 objects: kind_refs.iter().map(|k| k.objects).sum(),
2024 rules: rule_refs.len(),
2025 enforced_rules: rule_refs.iter().filter(|r| r.enforced).count(),
2026 docs: doc_refs.len(),
2027 adrs: adr_refs.len(),
2028 claims: claim_refs.len(),
2029 use_cases: use_case_refs.len(),
2030 moments: moments.len(),
2031 };
2032 let mut evidence = FeatureEvidence::default();
2033 for c in &claim_refs {
2034 *evidence.claims.entry(c.status.clone()).or_default() += 1;
2035 }
2036 for m in &module_refs {
2037 *evidence
2038 .modules
2039 .entry(m.stability.clone().unwrap_or_else(|| "unknown".into()))
2040 .or_default() += 1;
2041 }
2042 let _ = index;
2043
2044 (
2045 ResolvedRefs {
2046 feature: f.clone(),
2047 surfaces,
2048 module_refs,
2049 command_refs,
2050 kind_refs,
2051 rule_refs,
2052 doc_refs,
2053 adr_refs,
2054 claim_refs,
2055 use_case_refs,
2056 cockpit_refs,
2057 web_refs,
2058 moments,
2059 backlinks: backlinks.get(&f.id).cloned().unwrap_or_default(),
2060 counts,
2061 evidence,
2062 domain_ref: None,
2063 },
2064 findings,
2065 )
2066}
2067
2068fn providers(index: &Index) -> Vec<ProductProvider> {
2076 let policy = index.objects.iter().find(|o| o.kind == "policy");
2077 let projections: Vec<(String, String, String, bool)> = policy
2078 .and_then(|p| p.metadata.get("projections"))
2079 .and_then(Value::as_array)
2080 .map(|a| {
2081 a.iter()
2082 .filter_map(|p| {
2083 Some((
2084 p.get("provider")?.as_str()?.to_string(),
2085 p.get("target")?.as_str()?.to_string(),
2086 p.get("mode")
2087 .and_then(Value::as_str)
2088 .unwrap_or("file")
2089 .to_string(),
2090 p.get("always_loaded")
2091 .map(|v| v == "true" || v == &Value::Bool(true))
2092 .unwrap_or(false),
2093 ))
2094 })
2095 .collect()
2096 })
2097 .unwrap_or_default();
2098 let hooks: Vec<(String, String)> = policy
2099 .and_then(|p| p.metadata.get("enforcement"))
2100 .and_then(Value::as_array)
2101 .map(|a| {
2102 a.iter()
2103 .filter_map(|e| {
2104 let wired = e.get("wired_by")?.as_str()?;
2105 let provider = wired.strip_prefix("provider-hook:")?;
2106 let provider = provider.split(':').next().unwrap_or(provider);
2107 Some((provider.to_string(), e.get("name")?.as_str()?.to_string()))
2108 })
2109 .collect()
2110 })
2111 .unwrap_or_default();
2112 let root = Path::new(&index.repository.root);
2113 let mut out: Vec<ProductProvider> = index
2114 .providers
2115 .providers
2116 .iter()
2117 .map(|decl| {
2118 let id = &decl.id;
2119 let client_config = decl
2120 .client_config
2121 .as_deref()
2122 .filter(|c| root.join(c).is_file())
2123 .map(str::to_string);
2124 ProductProvider {
2125 id: id.clone(),
2126 title: decl.title.clone(),
2127 bootstraps: projections
2128 .iter()
2129 .filter(|(p, _, _, _)| p == id)
2130 .map(|(_, target, mode, always)| ProviderBootstrap {
2131 target: target.clone(),
2132 mode: mode.clone(),
2133 always_loaded: *always,
2134 })
2135 .collect(),
2136 client_config,
2137 hooks: hooks
2138 .iter()
2139 .filter(|(p, _)| p == id)
2140 .map(|(_, n)| n.clone())
2141 .collect(),
2142 scratch_roots: decl.scratch_roots.clone(),
2143 offers: decl.offers.clone(),
2144 }
2145 })
2146 .collect();
2147 crate::order::canonical(&mut out);
2148 out
2149}
2150
2151#[cfg(test)]
2152mod tests {
2153 use super::*;
2154
2155 fn member(id: &str, use_cases: &[&str], claims: &[&str]) -> ResolvedRefs {
2158 let counts: FeatureCounts = serde_json::from_value(serde_json::json!({
2159 "capabilities": 0, "mcp_tools": 0, "mcp_resources": 0, "http_routes": 0,
2160 "cli_paths": 0, "commands": 0, "objects": 0, "rules": 0, "enforced_rules": 0,
2161 "docs": 0, "adrs": 0, "claims": 0, "use_cases": 0, "moments": 0
2162 }))
2163 .unwrap();
2164 serde_json::from_value(serde_json::json!({
2165 "id": id, "title": id, "headline": "h", "summary": "s", "status": "stable",
2166 "domain": "alpha",
2167 "surfaces": { "cli": id == "a", "api": false, "mcp": id == "b", "cockpit": false, "docs": true },
2168 "module_refs": [], "command_refs": [], "kind_refs": [],
2169 "rule_refs": [{ "id": "project.r", "identity": "project.r@1", "title": "r",
2170 "class": "blocking", "enforced": true, "path": "r.md" }],
2171 "doc_refs": [], "adr_refs": [],
2172 "claim_refs": claims.iter().map(|c| serde_json::json!({
2173 "id": c, "claim": c, "status": "guaranteed", "test": "test/cases/x.sh" })).collect::<Vec<_>>(),
2174 "use_case_refs": use_cases.iter().map(|u| serde_json::json!({
2175 "id": u, "title": u, "category": "completion" })).collect::<Vec<_>>(),
2176 "cockpit_refs": [], "web_refs": [], "moments": [], "backlinks": [],
2177 "counts": counts, "evidence": { "claims": {}, "modules": {} }
2178 }))
2179 .unwrap()
2180 }
2181
2182 #[test]
2183 fn a_rule_is_enforced_only_when_a_machine_decides_it() {
2184 let rule =
2185 |block: serde_json::Value| serde_json::json!({ "id": "r", "x-majordomus": block });
2186 assert!(
2187 machine_enforced(&rule(serde_json::json!({ "validator": "scope" }))),
2188 "dispatched"
2189 );
2190 assert!(
2191 machine_enforced(&rule(serde_json::json!({ "tests": ["test/cases/x.sh"] }))),
2192 "gated"
2193 );
2194 assert!(
2195 !machine_enforced(&rule(
2196 serde_json::json!({ "reviewed_because": "a person reads it" })
2197 )),
2198 "reviewed is a person's act, not enforcement"
2199 );
2200 assert!(
2201 !machine_enforced(&rule(serde_json::json!({ "tests": [] }))),
2202 "no test named"
2203 );
2204 assert!(
2205 !machine_enforced(&serde_json::json!({ "id": "a-principle" })),
2206 "no block is a principle"
2207 );
2208 }
2209
2210 #[test]
2211 fn a_domain_counts_what_its_members_share_once_and_unions_their_surfaces() {
2212 let d: Domain = serde_json::from_value(serde_json::json!({
2213 "id": "alpha", "title": "Alpha", "headline": "h", "problem": "p", "status": "stable"
2214 }))
2215 .unwrap();
2216 let (a, b) = (
2217 member("a", &["u1", "u2"], &["c1"]),
2218 member("b", &["u2"], &["c1", "c2"]),
2219 );
2220 let r = resolve_domain(d, &[&a, &b], &Catalogue::default());
2221 assert_eq!(r.counts.features, 2);
2222 assert_eq!(r.counts.use_cases, 2, "u2 is shared and counted once");
2223 assert_eq!(r.counts.claims, 2, "c1 is shared and counted once");
2224 assert_eq!(r.counts.tested, 2);
2225 assert_eq!(r.counts.rules, 1);
2226 assert_eq!(r.counts.enforced_rules, 1);
2227 assert!(
2228 r.surfaces.cli && r.surfaces.mcp && r.surfaces.docs,
2229 "the union of the members'"
2230 );
2231 assert!(!r.surfaces.api);
2232 assert_eq!(r.evidence.claims["guaranteed"], 2);
2233 let ids: Vec<&str> = r.use_case_refs.iter().map(|u| u.id.as_str()).collect();
2234 assert_eq!(ids, ["u1", "u2"]);
2235 assert_eq!(r.features[0].tested, 1);
2236 assert_eq!(r.features[1].use_cases, 1);
2237 }
2238
2239 fn areas() -> BTreeMap<&'static str, u32> {
2242 [
2243 ("context", 10),
2244 ("coordination", 20),
2245 ("governance", 30),
2246 ("verification", 60),
2247 ("documentation", 90),
2248 ]
2249 .into_iter()
2250 .collect()
2251 }
2252
2253 type Claimant<'a> = (&'a str, &'a [&'a str]);
2255
2256 fn claims(of: &[(&str, &[Claimant<'_>])]) -> Claims {
2259 of.iter()
2260 .map(|(module, claimants)| {
2261 (
2262 (*module).to_string(),
2263 claimants
2264 .iter()
2265 .map(|(feature, areas)| {
2266 (
2267 (*feature).to_string(),
2268 areas.iter().map(|a| (*a).to_string()).collect(),
2269 )
2270 })
2271 .collect(),
2272 )
2273 })
2274 .collect()
2275 }
2276
2277 #[test]
2278 fn a_modules_area_is_the_lowest_weight_area_of_the_features_that_name_it() {
2279 let (areas_by_module, contested) = resolve_module_areas(
2280 &claims(&[
2281 (
2282 "continuity",
2283 &[("continuity", &["context", "coordination"])],
2284 ),
2285 (
2286 "distribution",
2287 &[("install", &["verification", "documentation"])],
2288 ),
2289 ]),
2290 &areas(),
2291 );
2292 assert_eq!(
2293 areas_by_module.get("continuity").map(String::as_str),
2294 Some("context")
2295 );
2296 assert_eq!(
2297 areas_by_module.get("distribution").map(String::as_str),
2298 Some("verification")
2299 );
2300 assert_eq!(areas_by_module.get("nothing-names-me"), None);
2301 assert!(
2302 contested.is_empty(),
2303 "one claimant per module is not a disagreement"
2304 );
2305 }
2306
2307 #[test]
2308 fn a_module_two_features_place_differently_is_reported_and_still_resolved() {
2309 let (areas_by_module, contested) = resolve_module_areas(
2310 &claims(&[(
2311 "health",
2312 &[
2313 ("cockpit", &["documentation"]),
2314 ("doctrine", &["governance", "verification"]),
2315 ],
2316 )]),
2317 &areas(),
2318 );
2319 assert_eq!(
2321 areas_by_module.get("health").map(String::as_str),
2322 Some("governance")
2323 );
2324 assert_eq!(contested.len(), 1, "the disagreement is reported once");
2325 assert_eq!(contested[0].0, "health");
2326 assert!(contested[0].1.contains("cockpit"));
2327 assert!(contested[0].1.contains("doctrine"));
2328 }
2329
2330 #[test]
2331 fn features_that_overlap_in_one_area_are_not_a_disagreement() {
2332 let (areas_by_module, contested) = resolve_module_areas(
2333 &claims(&[(
2334 "worktree",
2335 &[
2336 ("coordination", &["coordination"]),
2337 ("worktrees", &["coordination", "context"]),
2338 ],
2339 )]),
2340 &areas(),
2341 );
2342 assert_eq!(
2343 areas_by_module.get("worktree").map(String::as_str),
2344 Some("context")
2345 );
2346 assert!(
2347 contested.is_empty(),
2348 "a shared area is agreement, whatever else each feature also serves"
2349 );
2350 }
2351
2352 #[test]
2353 fn an_area_the_catalogue_does_not_rank_loses_to_one_it_does() {
2354 let (areas_by_module, _) = resolve_module_areas(
2355 &claims(&[("m", &[("f", &["not-an-area", "documentation"])])]),
2356 &areas(),
2357 );
2358 assert_eq!(
2359 areas_by_module.get("m").map(String::as_str),
2360 Some("documentation")
2361 );
2362 }
2363
2364 #[test]
2365 fn the_surface_vocabulary_is_closed_and_read_back_in_order() {
2366 let all = Surfaces {
2367 cli: true,
2368 api: true,
2369 mcp: true,
2370 cockpit: true,
2371 docs: true,
2372 };
2373 assert_eq!(all.present(), ["cli", "api", "mcp", "cockpit", "docs"]);
2374 let none = Surfaces::default();
2375 assert!(none.present().is_empty());
2376 assert!(!none.has("nowhere"));
2377 assert_eq!(SURFACES.len(), 5);
2378 }
2379
2380 #[test]
2381 fn nearest_offers_a_plausible_typo_and_nothing_else() {
2382 assert_eq!(
2383 nearest("worktre", ["worktree", "why"].into_iter()),
2384 Some("worktree".into())
2385 );
2386 assert_eq!(nearest("zzzzzzzz", ["worktree", "why"].into_iter()), None);
2387 }
2388
2389 #[test]
2390 fn a_feature_labels_itself_by_its_short_title_when_it_has_one() {
2391 let f: Feature = serde_json::from_value(serde_json::json!({
2392 "id": "x", "title": "A long title", "short_title": "Short",
2393 "headline": "h", "summary": "s", "status": "stable"
2394 }))
2395 .unwrap();
2396 assert_eq!(f.label(), "Short");
2397 assert!(
2398 f.route.is_empty(),
2399 "the route is derived when the model is built"
2400 );
2401 }
2402}