Skip to main content

majordomus_cli/
product.rs

1//! The product model: what this repository's product does for a person, as the features
2//! under the layer's features section declare it, with every fact a surface may say about
3//! a feature derived from the registries that own those facts.
4//!
5//! Nothing here discovers or parses a file. A feature is an ordinary declarative object of
6//! the layer — kind `feature` in `share/kinds.yaml`, discovered by the source class in the
7//! repository's `sources.yaml`, validated against its JSON Schema by the time the index
8//! holds it. This module projects that validated metadata into typed records, resolves
9//! every reference the records make against the registry that owns it, and derives what
10//! nobody authored: which interfaces expose a feature, the tools, routes and command-line
11//! paths behind it, how many objects of its kinds the layer holds, which operational
12//! moments it answers, and what is guaranteed about it.
13//!
14//! Built once, when a [`crate::capability::Context`] is composed, and shared by every
15//! projection: the command line, the HTTP routes, the MCP tools, the derived graph and the
16//! site's dataset are readers of this one value.
17//!
18//! Two directions are kept apart on purpose, exactly as the Why catalogue keeps them.
19//! **Authored** is what a file says: the headline, the summary, the order, whether the
20//! homepage features it, and the typed references to what the feature is made of.
21//! **Derived** is everything else, and none of it may be written into a source file: the
22//! schema refuses a `surfaces` or a `route` key, and this module is where those come from.
23
24use 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
40/// The kind of a product feature.
41pub const FEATURE: &str = "feature";
42/// The section the features are published under.
43pub const ROUTE: &str = "/features/";
44/// A feature that is complete and public.
45pub const STABLE: &str = "stable";
46/// The identities the section's own routes use; a feature called one of these would claim
47/// a route the section already owns.
48pub const RESERVED: &[&str] = &["matrix", "providers", "index"];
49/// The kind of a product domain: one of the few things the product controls, under which
50/// every feature is filed exactly once (ADR 0104).
51pub const DOMAIN: &str = "domain";
52/// The section the domains are published under.
53pub const DOMAIN_ROUTE: &str = "/domains/";
54
55// ---------------------------------------------------------------- the authored record
56
57/// One product feature, as its file declares it plus the facts the file cannot hold: where
58/// it came from and where it is published.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60pub struct Feature {
61    /// The identity, the slug and the file name.
62    pub id: String,
63    /// The feature as a heading.
64    pub title: String,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    /// Two or three words for a card or a matrix row.
67    pub short_title: Option<String>,
68    /// The promise a visitor reads first.
69    pub headline: String,
70    /// One line: what it does.
71    pub summary: String,
72    /// `stable`, `draft` or `deprecated`.
73    pub status: String,
74    #[serde(default)]
75    /// Presentation order, lowest first.
76    pub weight: u32,
77    #[serde(default)]
78    /// Whether the homepage shows it as a chapter.
79    pub featured: bool,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    /// The product domain it belongs to, by id: exactly one. Editorial; the domain's
82    /// members are derived from this field and listed nowhere else.
83    pub domain: Option<String>,
84    #[serde(default)]
85    /// The operational areas of the why catalogue it serves.
86    pub areas: Vec<String>,
87    #[serde(default)]
88    /// The audiences of the why catalogue it is written for.
89    pub audiences: Vec<String>,
90    #[serde(default)]
91    /// Capability modules of the executable it is made of.
92    pub modules: Vec<String>,
93    #[serde(default)]
94    /// Public commands of the shell tool it is made of.
95    pub commands: Vec<String>,
96    #[serde(default)]
97    /// Object kinds of the layer it is made of.
98    pub kinds: Vec<String>,
99    #[serde(default)]
100    /// Rules of the effective set that govern it, by id without the version.
101    pub rules: Vec<String>,
102    #[serde(default)]
103    /// The documents that explain it, by repository-relative path.
104    pub docs: Vec<String>,
105    #[serde(default)]
106    /// The decisions behind it, by declared id.
107    pub adrs: Vec<String>,
108    #[serde(default)]
109    /// Claims that say what is guaranteed here.
110    pub claims: Vec<String>,
111    #[serde(default)]
112    /// Use cases that show it in use.
113    pub use_cases: Vec<String>,
114    #[serde(default)]
115    /// Areas of the Cockpit that show it.
116    pub cockpit: Vec<String>,
117    #[serde(default)]
118    /// Web surfaces of the topology it is offered through.
119    pub web: Vec<String>,
120    #[serde(default)]
121    /// Features explicitly related to this one. The reverse is derived.
122    pub related: Vec<String>,
123    #[serde(default)]
124    /// Free tags.
125    pub tags: Vec<String>,
126
127    #[serde(default)]
128    /// Derived: `/features/<id>/`. Never authored; the schema refuses a `route` key.
129    pub route: String,
130    #[serde(default)]
131    /// Derived: the repository-relative file the record came from.
132    pub source: String,
133    #[serde(default, skip_serializing_if = "String::is_empty")]
134    /// The Markdown body, without its front matter.
135    pub body: String,
136}
137
138impl Feature {
139    /// The name a narrow column shows.
140    pub fn label(&self) -> &str {
141        self.short_title.as_deref().unwrap_or(&self.title)
142    }
143}
144
145// ---------------------------------------------------------------- the derived views
146
147/// The interfaces a feature is exposed through, decided from what its references project
148/// and from nothing a file says. Each is a fact with a reason a reader can check: `cli` is
149/// true when a module of the feature has a capability with a command-line path or the
150/// feature names a shell command; `api` when a module has an HTTP route; `mcp` when a module
151/// has an MCP tool or resource, or the feature names a kind, since every object of the layer
152/// is an MCP resource; `cockpit` when the feature names a Cockpit area or a module, since
153/// every capability has a Cockpit page; `docs` when it names a document.
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
155pub struct Surfaces {
156    /// The command line: the executable's own, or the shell tool's.
157    pub cli: bool,
158    /// The HTTP routes under `/api/v1/`, and the OpenAPI document over them.
159    pub api: bool,
160    /// MCP: a tool, or a resource.
161    pub mcp: bool,
162    /// The Cockpit.
163    pub cockpit: bool,
164    /// The documentation.
165    pub docs: bool,
166}
167
168impl Surfaces {
169    /// The surface ids that are true, in the order the vocabulary lists them.
170    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    /// Is this surface exposed?
179    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
191/// The vocabulary of surfaces a feature can be exposed through: id, title, and the route
192/// on the website where that surface is documented. Five, because the executable has five
193/// ways to be reached, and nothing else is a surface.
194pub 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/// One capability of a module the feature names, with the projections the registry
203/// declares for it.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
205pub struct CapabilityRef {
206    /// The canonical id.
207    pub id: String,
208    /// The title.
209    pub title: String,
210    /// `query`, `command` or `resource`.
211    pub kind: String,
212    /// Where it stands.
213    pub stability: String,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    /// The MCP tool name, when exposed as one.
216    pub tool: Option<String>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    /// The MCP resource URI, when exposed as one.
219    pub resource: Option<String>,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    /// `METHOD /path`, when exposed over HTTP.
222    pub route: Option<String>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    /// The words after `majordomus`, when exposed on the command line.
225    pub cli: Option<String>,
226}
227
228/// One capability module the feature names.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
230pub struct ModuleRef {
231    /// The module id.
232    pub id: String,
233    /// The short name.
234    pub title: String,
235    /// One paragraph.
236    pub description: String,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    /// Where the module stands, when it declared it.
239    pub stability: Option<String>,
240    /// Repository-relative path of the file its descriptors were composed in.
241    pub source_path: String,
242    /// The builtin capabilities it composes, in id order.
243    pub capabilities: Vec<CapabilityRef>,
244}
245
246/// One public command of the shell tool the feature names, as `share/commands.yaml`
247/// declares it.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
249pub struct CommandRef {
250    /// The command.
251    pub id: String,
252    /// One line.
253    pub summary: String,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    /// The lifecycle stage it belongs to.
256    pub stage: Option<String>,
257    /// Whether it writes nothing.
258    pub read_only: bool,
259}
260
261/// One object kind the feature names, with how many objects of it the layer holds.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
263pub struct KindRef {
264    /// The kind.
265    pub name: String,
266    /// Objects of this kind in the index.
267    pub objects: usize,
268}
269
270/// One rule the feature names.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
272pub struct RuleRef {
273    /// The rule id, without the version.
274    pub id: String,
275    /// The identity the index holds, with the version.
276    pub identity: String,
277    /// The title.
278    pub title: String,
279    /// `blocking` or `advisory`.
280    pub class: String,
281    /// Whether a machine decides it: the block names a validator or tests, not only a reason
282    /// a person reviews it.
283    pub enforced: bool,
284    /// Repository-relative path.
285    pub path: String,
286}
287
288/// One document the feature names.
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
290pub struct DocRef {
291    /// Repository-relative path.
292    pub path: String,
293    /// The title, when the document has one.
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub title: Option<String>,
296}
297
298/// One architecture decision the feature names.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
300pub struct AdrRef {
301    /// The declared id.
302    pub id: String,
303    /// The title.
304    pub title: String,
305    /// `proposed`, `accepted`, `superseded` or `rejected`.
306    pub status: String,
307    /// Repository-relative path.
308    pub path: String,
309}
310
311/// One claim the feature names, with the status the matrix gives it.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
313pub struct ClaimRef {
314    /// The claim id.
315    pub id: String,
316    /// The sentence.
317    pub claim: String,
318    /// `guaranteed`, `advisory`, `planned` or `rejected`.
319    pub status: String,
320    /// The test that settles it, as `docs/CLAIMS.yaml` records it — the one field that makes a
321    /// claim checkable rather than stated. Absent where the claim declares none, and where it
322    /// declares `-`: a planned or rejected claim has nothing to run yet, and rendering a dash
323    /// as a test would be a projection inventing evidence.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub test: Option<String>,
326}
327
328/// One use case the feature names.
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
330pub struct UseCaseRef {
331    /// The use case id.
332    pub id: String,
333    /// The title.
334    pub title: String,
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    /// The category it is filed under.
337    pub category: Option<String>,
338}
339
340/// One area of the Cockpit the feature names.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
342pub struct CockpitAreaRef {
343    /// The area id.
344    pub id: String,
345    /// The label the Cockpit shows.
346    pub title: String,
347    /// The route under the running server.
348    pub route: String,
349}
350
351/// One web surface of the topology the feature names.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
353pub struct SurfaceRef {
354    /// The surface id.
355    pub id: String,
356    /// The title.
357    pub title: String,
358    /// Where it is mounted.
359    pub mount: String,
360    /// What it is for.
361    pub category: String,
362}
363
364// `weight` is the ranking a feature declares; the canonical order takes it as the rank and
365// ends on the identity, so two features of equal weight keep one order everywhere.
366impl 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/// One operational moment the feature answers: derived from the moments that name any of
373/// the feature's commands, capabilities, claims or rules.
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
375pub struct MomentRef {
376    /// The moment id.
377    pub id: String,
378    /// The title.
379    pub title: String,
380    /// The first-person line an index shows.
381    pub hook: String,
382    /// `/why/<id>/`.
383    pub route: String,
384}
385
386/// How much stands behind a feature, every number derived.
387#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
388pub struct FeatureCounts {
389    /// Builtin capabilities of the modules it names.
390    pub capabilities: usize,
391    /// MCP tools among them.
392    pub mcp_tools: usize,
393    /// MCP resources among them, plus one per object of the kinds it names.
394    pub mcp_resources: usize,
395    /// HTTP routes among them.
396    pub http_routes: usize,
397    /// Command-line paths of the executable among them.
398    pub cli_paths: usize,
399    /// Public commands of the shell tool it names.
400    pub commands: usize,
401    /// Objects of the kinds it names.
402    pub objects: usize,
403    /// Rules it names.
404    pub rules: usize,
405    /// Rules the tool enforces among them.
406    pub enforced_rules: usize,
407    /// Documents it names.
408    pub docs: usize,
409    /// Decisions it names.
410    pub adrs: usize,
411    /// Claims it names.
412    pub claims: usize,
413    /// Use cases it names.
414    pub use_cases: usize,
415    /// Moments it answers.
416    pub moments: usize,
417}
418
419/// What is guaranteed about a feature, counted from the claims it names: never a word a
420/// person wrote about the feature's maturity.
421#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
422pub struct FeatureEvidence {
423    /// Claims by status: `guaranteed`, `advisory`, `planned`, `rejected`.
424    pub claims: BTreeMap<String, usize>,
425    /// The modules it names by stability.
426    pub modules: BTreeMap<String, usize>,
427}
428
429/// One feature, resolved: the record as its file declares it, and everything derived.
430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
431pub struct ResolvedRefs {
432    /// The record as its file declares it, plus its route and source.
433    #[serde(flatten)]
434    pub feature: Feature,
435    /// Derived: the interfaces it is exposed through.
436    pub surfaces: Surfaces,
437    /// Derived: the modules it names, each with its capabilities and their projections.
438    pub module_refs: Vec<ModuleRef>,
439    /// Derived: the shell commands it names, as the command registry declares them.
440    pub command_refs: Vec<CommandRef>,
441    /// Derived: the kinds it names, each with its object count.
442    pub kind_refs: Vec<KindRef>,
443    /// Derived: the rules it names, each with its class and whether it is enforced.
444    pub rule_refs: Vec<RuleRef>,
445    /// Derived: the documents it names, with their titles.
446    pub doc_refs: Vec<DocRef>,
447    /// Derived: the decisions it names, with their status.
448    pub adr_refs: Vec<AdrRef>,
449    /// Derived: the claims it names, with their status.
450    pub claim_refs: Vec<ClaimRef>,
451    /// Derived: the use cases it names.
452    pub use_case_refs: Vec<UseCaseRef>,
453    /// Derived: the Cockpit areas it names, with their routes.
454    pub cockpit_refs: Vec<CockpitAreaRef>,
455    /// Derived: the web surfaces it names, with their mounts.
456    pub web_refs: Vec<SurfaceRef>,
457    /// Derived: the moments that name any command, capability, claim or rule of this feature.
458    pub moments: Vec<MomentRef>,
459    /// Derived: the features that name this one in their `related`.
460    pub backlinks: Vec<String>,
461    /// Derived: how much stands behind it.
462    pub counts: FeatureCounts,
463    /// Derived: what is guaranteed.
464    pub evidence: FeatureEvidence,
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    /// Derived: the domain its `domain` names, with its title and route, when that
467    /// resolves. Absent for a feature that names none or names one that does not exist.
468    pub domain_ref: Option<DomainRef>,
469}
470
471// ---------------------------------------------------------------- domains
472
473/// A domain as a feature points at it: enough for a breadcrumb.
474///
475/// ```
476/// use majordomus_cli::product::DomainRef;
477/// let crumb = DomainRef {
478///     id: "context".into(),
479///     title: "Context".into(),
480///     route: "/domains/context/".into(),
481/// };
482/// let v = serde_json::to_value(&crumb).unwrap();
483/// assert_eq!(v["route"], "/domains/context/");
484/// assert_eq!(serde_json::from_value::<DomainRef>(v).unwrap(), crumb);
485/// ```
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
487pub struct DomainRef {
488    /// The domain id.
489    pub id: String,
490    /// The domain as a heading.
491    pub title: String,
492    /// `/domains/<id>/`.
493    pub route: String,
494}
495
496/// One product domain, as its file declares it plus where it came from and where it is
497/// published. A domain never lists its features; [`ResolvedDomain`] derives them.
498///
499/// The front matter is read as it stands; the route, the source and the body are filled in by
500/// the model, so a record read alone carries none of them.
501///
502/// ```
503/// use majordomus_cli::product::Domain;
504/// let d: Domain = serde_json::from_value(serde_json::json!({
505///     "id": "context", "title": "Context", "status": "stable", "weight": 10,
506///     "headline": "Every worker starts from what the repository knows.",
507///     "problem": "Agents forget.",
508/// }))
509/// .unwrap();
510/// assert_eq!(d.weight, 10);
511/// assert!(d.route.is_empty(), "the route is derived by the model, never read");
512/// assert!(d.tags.is_empty());
513/// ```
514#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
515pub struct Domain {
516    /// The identity, the slug and the file name.
517    pub id: String,
518    /// The domain as a heading: one word or two.
519    pub title: String,
520    /// What the product does about this domain, in one sentence.
521    pub headline: String,
522    /// The operational failure this domain answers, in one sentence.
523    pub problem: String,
524    /// `stable`, `draft` or `deprecated`.
525    pub status: String,
526    #[serde(default)]
527    /// Presentation order, lowest first.
528    pub weight: u32,
529    #[serde(default)]
530    /// Free tags.
531    pub tags: Vec<String>,
532    #[serde(default)]
533    /// Derived: `/domains/<id>/`. Never authored; the schema refuses a `route` key.
534    pub route: String,
535    #[serde(default)]
536    /// Derived: the repository-relative file the record came from.
537    pub source: String,
538    #[serde(default, skip_serializing_if = "String::is_empty")]
539    /// The Markdown body, without its front matter.
540    pub body: String,
541}
542
543/// A domain is presented by its weight, then its id: the order the homepage map, the
544/// domain index and every listing of domains share.
545impl 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/// One stable feature of a domain, as a card shows it.
552///
553/// ```
554/// use majordomus_cli::product::{DomainMember, Surfaces};
555/// let m = DomainMember {
556///     id: "finish-contract".into(),
557///     label: "Finish contract".into(),
558///     title: "Done is a contract".into(),
559///     headline: "A worker does not define its own completion.".into(),
560///     route: "/features/finish-contract/".into(),
561///     featured: true,
562///     surfaces: Surfaces::default(),
563///     claims: 3,
564///     tested: 2,
565///     use_cases: 1,
566/// };
567/// let v = serde_json::to_value(&m).unwrap();
568/// assert_eq!(v["tested"], 2);
569/// assert_eq!(v["surfaces"]["cli"], false);
570/// ```
571#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
572pub struct DomainMember {
573    /// The feature id.
574    pub id: String,
575    /// The name a narrow column shows: the short title, or the title.
576    pub label: String,
577    /// The feature as a heading.
578    pub title: String,
579    /// The feature's promise.
580    pub headline: String,
581    /// `/features/<id>/`.
582    pub route: String,
583    /// Whether the homepage shows it as a chapter.
584    pub featured: bool,
585    /// The interfaces it is exposed through.
586    pub surfaces: Surfaces,
587    /// How many claims it names.
588    pub claims: usize,
589    /// How many of them are guaranteed and name the test that settles them.
590    pub tested: usize,
591    /// How many use cases show it in use.
592    pub use_cases: usize,
593}
594
595/// How much stands behind a domain: sums and distinct counts over its stable features.
596///
597/// ```
598/// use majordomus_cli::product::DomainCounts;
599/// let empty = DomainCounts::default();
600/// assert_eq!(empty.features, 0);
601/// let v = serde_json::to_value(&empty).unwrap();
602/// for key in ["features", "claims", "tested", "use_cases", "rules", "enforced_rules", "capabilities", "moments"] {
603///     assert_eq!(v[key], 0, "{key} is published, and zero for a domain nothing names");
604/// }
605/// ```
606#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
607pub struct DomainCounts {
608    /// Stable features filed under it.
609    pub features: usize,
610    /// Distinct claims its features name.
611    pub claims: usize,
612    /// Distinct claims that are guaranteed and name the test that settles them: what the
613    /// claims declare, not whether a recorded run supports them (that verdict is the
614    /// evidence ledger's).
615    pub tested: usize,
616    /// Distinct use cases its features name.
617    pub use_cases: usize,
618    /// Distinct rules its features name.
619    pub rules: usize,
620    /// Distinct rules among them the tool enforces.
621    pub enforced_rules: usize,
622    /// Distinct capabilities behind its features.
623    pub capabilities: usize,
624    /// Distinct operational moments its features answer.
625    pub moments: usize,
626}
627
628/// One domain, resolved: the record as its file declares it, and everything derived from
629/// the stable features that name it.
630///
631/// The declared record is flattened, so a reader sees one object: the domain's own fields
632/// beside its derived members and counts.
633///
634/// ```
635/// use majordomus_cli::product::{Domain, DomainCounts, FeatureEvidence, ResolvedDomain, Surfaces};
636/// let domain: Domain = serde_json::from_value(serde_json::json!({
637///     "id": "context", "title": "Context", "status": "stable",
638///     "headline": "h", "problem": "p",
639/// }))
640/// .unwrap();
641/// let resolved = ResolvedDomain {
642///     domain,
643///     features: vec![],
644///     surfaces: Surfaces::default(),
645///     moments: vec![],
646///     claim_refs: vec![],
647///     use_case_refs: vec![],
648///     counts: DomainCounts::default(),
649///     evidence: FeatureEvidence::default(),
650/// };
651/// let v = serde_json::to_value(&resolved).unwrap();
652/// assert_eq!(v["id"], "context", "the record is flattened into the resolved view");
653/// assert_eq!(v["counts"]["features"], 0);
654/// ```
655#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
656pub struct ResolvedDomain {
657    /// The record as its file declares it, plus its route and source.
658    #[serde(flatten)]
659    pub domain: Domain,
660    /// Derived: the stable features that name it, in presentation order.
661    pub features: Vec<DomainMember>,
662    /// Derived: the interfaces any of its features is exposed through.
663    pub surfaces: Surfaces,
664    /// Derived: the operational moments its features answer, in the catalogue's order.
665    pub moments: Vec<MomentRef>,
666    /// Derived: the distinct claims its features name, with their status and test.
667    pub claim_refs: Vec<ClaimRef>,
668    /// Derived: the distinct use cases its features name.
669    pub use_case_refs: Vec<UseCaseRef>,
670    /// Derived: how much stands behind it.
671    pub counts: DomainCounts,
672    /// Derived: what is guaranteed, by claim status.
673    pub evidence: FeatureEvidence,
674}
675
676// ---------------------------------------------------------------- providers
677
678/// A provider is presented by its id — the directory its bootstrap is written under and the
679/// name every other projection of the product matrix refers to it by.
680impl 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/// One provider the tool has an adapter for, discovered from the templates the distribution
687/// ships and decorated with what this repository does with it.
688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
689pub struct ProductProvider {
690    /// The provider id: the template's file stem.
691    pub id: String,
692    /// The name a person knows it by.
693    pub title: String,
694    /// The bootstraps this repository's policy renders through it: target, mode, whether
695    /// every worker loads it.
696    pub bootstraps: Vec<ProviderBootstrap>,
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    /// The client configuration at the repository root that starts the shared MCP server
699    /// for this provider, when the repository carries it.
700    pub client_config: Option<String>,
701    /// The enforcement entries of the policy wired by this provider's hooks.
702    pub hooks: Vec<String>,
703    /// The scratch roots this provider creates checkouts of its own under, as declared:
704    /// the worktree topology reports a checkout there as a session's scratch checkout and
705    /// never moves it (ADR 0024).
706    #[serde(default)]
707    pub scratch_roots: Vec<String>,
708    /// What it can do about the session lifecycle, and where each answer was verified
709    /// (`share/providers.yaml`, ADR 0103). The capability, never the wiring: `hooks` says
710    /// the vendor fires session events, not that this repository has installed anything to
711    /// receive them. The other half — what is wired here, and whether driving it produced
712    /// anything — is `majordomus capture status`, which reads this same declaration and
713    /// joins it with the tree. It is not restated here because only the shell tool holds
714    /// the adapter table and can run a shim, and a second account it could not compute
715    /// correctly is worse than one reader more.
716    #[serde(default)]
717    pub offers: ProviderOffers,
718}
719
720/// One bootstrap a provider renders.
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
722pub struct ProviderBootstrap {
723    /// The target file, repository-relative.
724    pub target: String,
725    /// `file` or `region`.
726    pub mode: String,
727    /// Loaded by every worker without asking.
728    pub always_loaded: bool,
729}
730
731// ---------------------------------------------------------------- findings
732
733/// One thing wrong with the product model, named where it is, with the nearest candidate
734/// when there is one.
735#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
736pub struct ProductFinding {
737    /// `error` or `warning`.
738    pub severity: Severity,
739    /// `unknown_reference`, `duplicate_identity`, `missing_content`, `uncovered`, ...
740    pub code: String,
741    /// The repository-relative file the finding is in, or the registry it is about.
742    pub path: String,
743    /// The record's identity, when one record owns the finding.
744    #[serde(skip_serializing_if = "Option::is_none")]
745    pub id: Option<String>,
746    /// The front-matter key the finding is about, when one key owns it.
747    #[serde(skip_serializing_if = "Option::is_none")]
748    pub field: Option<String>,
749    /// What is wrong, in one sentence.
750    pub message: String,
751    /// The nearest existing name, when the value looks like a typo of one.
752    #[serde(skip_serializing_if = "Option::is_none")]
753    pub did_you_mean: Option<String>,
754}
755
756// ---------------------------------------------------------------- coverage
757
758/// One thing of the product — a module, a public command, a kind — and the features that
759/// name it. Empty means the product page says nothing about it, which is a gap the
760/// validation reports and the matrix shows rather than hides.
761#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
762pub struct ProductCoverage {
763    /// The id.
764    pub id: String,
765    /// The title.
766    pub title: String,
767    /// The stable features that name it.
768    pub features: Vec<String>,
769}
770
771// ---------------------------------------------------------------- the model
772
773/// The validated product model and every index derived from it. Immutable: built once from
774/// an index and a registry, read many times.
775#[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
790/// What claims a module: the features that name it, each with the areas it serves.
791type Claims = BTreeMap<String, Vec<(String, Vec<String>)>>;
792
793/// Resolve one area per module, and name the modules whose claimants disagree.
794///
795/// The rule is the catalogue's own ranking: among the areas the claiming features serve,
796/// the one with the lowest `weight`, ties broken by id. The areas are already ordered by
797/// what the operator cares about most, and a second ranking invented here would be a second
798/// opinion about a question the layer has already answered.
799///
800/// Two features that name one module and share no area disagree about what the module is
801/// for. The resolution stays deterministic and the disagreement is still returned, so that
802/// it is settled in the feature files where the semantics live rather than by a tiebreak in
803/// this function.
804fn 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        // A set, not a sort and a dedup: unique and ordered is what a BTreeSet is, and the
813        // canonical-order gate is right to ask why a comparator appeared here.
814        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
846/// Whether a machine decides a rule: its enforcement block names a validator a command runs,
847/// or tests a gate runs (ADR 0048's dispatched and gated modes). A block naming only
848/// `reviewed_because` is enforced by a person and a rule with no block states a principle;
849/// neither is counted as enforced, because "enforced" on a page is read as "a machine refuses
850/// a violation".
851fn 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
860/// Levenshtein distance, for the nearest-candidate hint on an unresolved reference.
861fn 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
876/// The nearest candidate to `value`, when one is close enough to be a plausible typo.
877fn 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
900/// What the layer holds, read once into the lookups every reference needs.
901struct 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                // a public command only: an internal one is dispatched and absent from the
930                // help text, and a feature may not present it
931                "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    /// Build the model from an index, the registry its module references resolve against,
973    /// the catalogue whose moments it answers, and the topology whose surfaces it names.
974    /// Deterministic: the same objects produce the same value, and no derivation reads a
975    /// clock.
976    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    /// The area each capability module serves, derived rather than declared.
1096    ///
1097    /// A module has no area of its own and is given none: the features that name it in
1098    /// `modules:` already say which areas they serve, so the module's area is theirs,
1099    /// resolved by [`resolve_module_areas`]. A feature that declares no area claims
1100    /// nothing; a module no feature claims is shown under no heading and ordered last.
1101    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    /// The area a capability module serves, or `None` for a module no feature with an area
1133    /// names.
1134    ///
1135    /// Derived when the model is built, from the features that name the module, resolved by
1136    /// the areas' own weight; the Cockpit's sidebar groups by it. The derivation is private
1137    /// and this text does not link to it: rustdoc refuses a public link to a private item,
1138    /// and the public fact is the answer rather than the route to it.
1139    pub fn module_area(&self, module: &str) -> Option<&str> {
1140        self.module_areas.get(module).map(String::as_str)
1141    }
1142
1143    /// Every diagnostic the index raised about a file of the features section: an object the
1144    /// index refused never reaches the model, and without this the model would report
1145    /// itself valid over the objects excluded from under it.
1146    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    /// Every module of the executable, every public command and every kind of the layer,
1174    /// each with the stable features that name it. A thing no feature names is a warning:
1175    /// the product page says nothing about something the product does.
1176    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        // the kinds a feature can present: those holding an object of the layer under the
1208        // manifest's sections; the implementation and test classes index source files so
1209        // that a claim's chain resolves, and are not things the product does
1210        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    /// A hash of the model's own sources, in identity order: the feature files, and the
1249    /// facts derived from the registries, so a registry change that moves a feature's
1250    /// surfaces moves the fingerprint too.
1251    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    /// Every feature, resolved, in presentation order, whatever its status.
1289    pub fn all(&self) -> &[ResolvedRefs] {
1290        &self.features
1291    }
1292    /// One feature by id.
1293    pub fn feature(&self, id: &str) -> Option<&ResolvedRefs> {
1294        self.by_id.get(id).map(|i| &self.features[*i])
1295    }
1296    /// The stable features, in presentation order.
1297    pub fn public(&self) -> Vec<&ResolvedRefs> {
1298        self.features
1299            .iter()
1300            .filter(|r| r.feature.status == STABLE)
1301            .collect()
1302    }
1303    /// The stable features the homepage shows, in presentation order.
1304    pub fn featured(&self) -> Vec<&ResolvedRefs> {
1305        self.public()
1306            .into_iter()
1307            .filter(|r| r.feature.featured)
1308            .collect()
1309    }
1310    /// The providers the tool has an adapter for.
1311    pub fn providers(&self) -> &[ProductProvider] {
1312        &self.providers
1313    }
1314    /// Every finding.
1315    pub fn findings(&self) -> &[ProductFinding] {
1316        &self.findings
1317    }
1318    /// How many findings are errors.
1319    pub fn errors(&self) -> usize {
1320        self.findings
1321            .iter()
1322            .filter(|f| f.severity == Severity::Error)
1323            .count()
1324    }
1325    /// The hash of the model's sources and derived facts.
1326    pub fn fingerprint(&self) -> &str {
1327        &self.fingerprint
1328    }
1329    /// Every module of the executable with the stable features that name it.
1330    pub fn module_coverage(&self) -> &[ProductCoverage] {
1331        &self.modules
1332    }
1333    /// Every public shell command with the stable features that name it.
1334    pub fn command_coverage(&self) -> &[ProductCoverage] {
1335        &self.commands
1336    }
1337    /// Every kind of the layer with the stable features that name it.
1338    pub fn kind_coverage(&self) -> &[ProductCoverage] {
1339        &self.kinds
1340    }
1341    /// Every domain, resolved, in presentation order, whatever its status.
1342    pub fn domains(&self) -> &[ResolvedDomain] {
1343        &self.domains
1344    }
1345    /// One domain by its id, whatever its status, or `None` when the layer declares no domain
1346    /// of that id.
1347    ///
1348    /// ```
1349    /// use majordomus_cli::product::ProductModel;
1350    /// let empty = ProductModel::default();
1351    /// assert!(empty.domain("context").is_none());
1352    /// ```
1353    pub fn domain(&self, id: &str) -> Option<&ResolvedDomain> {
1354        self.domain_by_id.get(id).map(|i| &self.domains[*i])
1355    }
1356    /// The stable domains, in presentation order: the set every public surface shows, before
1357    /// it drops a domain no stable feature names.
1358    ///
1359    /// ```
1360    /// use majordomus_cli::product::ProductModel;
1361    /// assert!(ProductModel::default().public_domains().is_empty());
1362    /// ```
1363    pub fn public_domains(&self) -> Vec<&ResolvedDomain> {
1364        self.domains
1365            .iter()
1366            .filter(|d| d.domain.status == STABLE)
1367            .collect()
1368    }
1369
1370    /// Read the domains the layer declares, file each feature under the one it names, and
1371    /// derive what every domain holds from its stable members.
1372    ///
1373    /// The references run one way: a feature names its domain, and a domain lists nothing.
1374    /// A stable feature that names no domain while the layer declares some is an error,
1375    /// because the homepage map and the domain pages would then leave it out in silence; a
1376    /// stable feature filed under a domain that is not stable is an error for the same
1377    /// reason. A domain no stable feature names is a warning: it renders empty, and so it
1378    /// is not rendered.
1379    fn resolve_domains(&mut self, index: &Index, why: &Catalogue) {
1380        // A second file claiming an identity is the index's to refuse: it drops both and
1381        // reports them, and adopt_diagnostics carries that report into the findings.
1382        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                // the schema validated the record before the index held it
1409                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        // every feature's reference, resolved or reported
1423        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
1513/// One domain's derived view, from its stable members alone.
1514fn 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    // keyed by the catalogue's weight, then the id: the order every moment list shares
1521    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
1592/// The features that name each feature in their `related`.
1593fn 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
1607/// One capability as the feature's view shows it.
1608fn 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/// Resolve one feature: every reference against the thing it names, with a finding for
1635/// each that resolves to nothing, then everything derived.
1636#[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    // the unresolved references, collected apart so the closure holds nothing the other
1650    // findings need, and merged once every reference has been looked at
1651    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    // modules: the builtin ones of the registry, with every capability they compose
1665    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    // the floors a stable feature is held to
1929    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    // the surfaces, decided from what the references project
1964    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    // the moments this feature answers: the moments that name any of its mechanisms.
1977    // Read through the catalogue's own reverse index, so the relation is the one the why
1978    // pages already show from the other side.
1979    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
2068/// The providers, derived: one per template the distribution ships or declaration it
2069/// carries, decorated with what this repository's policy renders through it, the client
2070/// configuration it carries and the hooks the policy wires. The title, the client
2071/// configuration's name and the scratch roots are the declaration's (`share/providers.yaml`);
2072/// whether the configuration is present is a fact of the tree. Reads the policy as the index holds it — an object of kind
2073/// `policy` whose metadata is the whole file — and the repository root for the client
2074/// configurations, which are tracked files and therefore facts of the tree.
2075fn 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    /// One stable member, built from the resolved view's own JSON: the members of a domain are
2156    /// whatever the features say, and the derivation counts what they share once.
2157    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    /// Areas as weights: the ranking the resolution reads, and the whole of what it reads.
2240    /// The catalogue's own numbers, five of the nine.
2241    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    /// One module's claimants in a fixture: the feature's id and the areas it serves.
2254    type Claimant<'a> = (&'a str, &'a [&'a str]);
2255
2256    /// Claims as the model builds them: module -> the features that name it, with the areas
2257    /// each of those features serves.
2258    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        // Deterministic despite the disagreement: the catalogue's own ranking decides.
2320        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}