Skip to main content

memstead_cli/commands/
health.rs

1use clap::Parser;
2use serde_json::json;
3
4use memstead_base::EntityId;
5use memstead_base::Store;
6use memstead_base::ops::{
7    DanglingLink, HealthSummary, health::ConstraintFindingReport, health::HEALTH_INCLUDE_KEYS,
8    health::MissingRequiredOutgoingReport,
9};
10
11use crate::output::{ExitKind, print_json, print_markdown};
12use crate::setup::{CliContext, CliEngine};
13
14/// Graph health summary.
15///
16/// Default: counts only. Pass `--include` to drill into details.
17#[derive(Parser, Debug)]
18pub struct Args {
19    /// Opt heavy content into the response: orphans, stubs,
20    /// most_connected, missing_fields, stale, dangling_links, tags,
21    /// missing_required_outgoing, constraints (standing violations of
22    /// declared schema constraints), conformance, integrity, config,
23    /// anchors (per-mem counts of the four standalone
24    /// anchor-verification states), friction (the workspace-local
25    /// refusal ledger's summary — counts per typed refusal code and
26    /// per verb, with per-code reason breakdowns where the code
27    /// carries a closed engine-owned discriminator, whole-ledger plus
28    /// a recent 24h window; local-only, values drawn from closed
29    /// engine-defined vocabularies only), open_questions (per-mem
30    /// composed worklist of
31    /// what the holding does not know: stubs, recheck/unresolvable
32    /// anchors, unsatisfied constraints, dangling links, and a paired
33    /// process mem's open entries — negative findings separated as
34    /// already-searched; capped per kind with an explicit `more`
35    /// count), stale_derivations (per-mem derivation edges whose
36    /// target changed since the recorded baseline, plus unbaselined
37    /// edges — re-assert via `memstead relate` to refresh), checks
38    /// (per-mem counts of the four derived check states plus the
39    /// author≠checker independence gate: self_checked /
40    /// confirmed_independent / unconfirmable — transport is not
41    /// identity, so until a caller-declared identity exists every
42    /// ok-checked entity reports unconfirmable; the other two
43    /// categories are explicit empties).
44    /// `conformance` lints every entity against the effective schema
45    /// into a `findings` array (write-time typed codes); `integrity`
46    /// adds the consistency axis (dangling links, stubs) to the same
47    /// list. `config` renders the workspace-config projection (per-mem
48    /// origin/storage/vcs detail, `mutations`, `plugin`) — the same
49    /// block MCP's `include_config: true` serves.
50    /// Repeatable (`--include K --include K`)
51    /// AND comma-string (`--include K1,K2`) forms both parse — uniform
52    /// with `memstead overview --include`.
53    #[arg(long, value_delimiter = ',')]
54    pub include: Vec<String>,
55
56    /// Schema ref (`name@x.y.z`) the conformance/integrity includes
57    /// lint against instead of each mem's current pin.
58    #[arg(long)]
59    pub target_schema: Option<String>,
60
61    /// Max rows for `most_connected` and `tag_distribution` (default: 10).
62    #[arg(long, default_value_t = 10)]
63    pub limit: usize,
64
65    /// Exit non-zero (1) when any included Tier-2 warning kind has
66    /// present violations, or when the always-on authoring-drift axis
67    /// reports findings (`SCHEMA_AUTHORING_SOURCE_MISSING` /
68    /// `SCHEMA_AUTHORING_SOURCE_DIVERGED` — no `--include` opt-in).
69    /// The output is rendered first, then the non-zero exit fires.
70    /// Include-gated participation today: `missing_required_outgoing`, `constraints`;
71    /// new Tier-2 codes opt in additively without breaking the flag's
72    /// semantics.
73    #[arg(long)]
74    pub strict: bool,
75}
76
77pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
78    let include = &args.include;
79    // Tier-2 violation tally, populated as the corresponding `--include`
80    // tokens are processed. Consulted at the end when `--strict` is set
81    // to decide between exit 0 and exit 1. Per-code so a future
82    // expansion (e.g. `cardinality_violations`) can list which codes
83    // tripped without re-walking the report JSON.
84    let mut strict_violations: Vec<(&'static str, usize)> = Vec::new();
85
86    // Validate include-keys against the shared catalogue. Unknown keys
87    // emit `UNKNOWN_INCLUDE_KEY` warnings the operator sees in both
88    // markdown and JSON output — matches the MCP sibling's behaviour
89    // and gives a typo zero-feedback path a typed signal instead.
90    let mut include_warnings: Vec<(String, Vec<String>)> = Vec::new();
91    for key in include {
92        if !HEALTH_INCLUDE_KEYS.contains(&key.as_str()) {
93            include_warnings.push((
94                key.clone(),
95                HEALTH_INCLUDE_KEYS.iter().map(|s| s.to_string()).collect(),
96            ));
97        }
98    }
99
100    let GatheredHealth {
101        health,
102        real_count,
103        orphan_ids,
104        stub_pairs,
105        community_count,
106        orphans_by_schema,
107        communities_by_schema,
108        most_connected_with_titles,
109        missing_required_outgoing,
110        constraint_findings,
111        schema_format_defects,
112        tag_distribution,
113        dangling_links,
114        findings,
115        config_entries,
116        anchors_axis,
117        open_questions_axis,
118        stale_derivations_axis,
119        checks_axis,
120    } = match ctx.cli_engine()? {
121        #[cfg(feature = "mem-repo")]
122        CliEngine::MemRepo(mut engine) => {
123            let mut g = gather_mem_repo(&mut engine, args.limit, include);
124            g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
125            g
126        }
127        CliEngine::Filesystem(mut engine) => {
128            let mut g = gather_filesystem(&mut engine, args.limit, include);
129            g.findings = gather_findings(&engine, include, args.target_schema.as_deref())?;
130            g
131        }
132    };
133
134    let mut result = json!({
135        "summary": {
136            "total_entities": real_count,
137            "total_orphans": orphan_ids.len(),
138            "total_stubs": stub_pairs.len(),
139            "total_stale": health.stale_entities.len(),
140            "total_missing_fields": health.missing_fields.len(),
141            "total_communities": community_count,
142            "orphans_by_schema": orphans_by_schema,
143            "communities_by_schema": communities_by_schema,
144        },
145    });
146    let obj = result.as_object_mut().unwrap();
147
148    if include.iter().any(|s| s == "orphans") {
149        let list: Vec<_> = orphan_ids
150            .iter()
151            .map(|(id, title)| json!({ "id": id.to_string(), "title": title }))
152            .collect();
153        obj.insert("orphans".into(), json!(list));
154    }
155    if include.iter().any(|s| s == "stubs") {
156        let list: Vec<_> = stub_pairs
157            .iter()
158            .map(|(id, refs)| {
159                json!({
160                    "id": id.to_string(),
161                    "referenced_by": refs.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
162                })
163            })
164            .collect();
165        obj.insert("stubs".into(), json!(list));
166    }
167    if include.iter().any(|s| s == "most_connected") {
168        let connected: Vec<_> = most_connected_with_titles
169            .iter()
170            .map(
171                |(
172                    id,
173                    title,
174                    total,
175                    incoming,
176                    outgoing,
177                    typed_total,
178                    typed_incoming,
179                    typed_outgoing,
180                )| {
181                    json!({
182                        "id": id.to_string(),
183                        "title": title,
184                        "total": total,
185                        "incoming": incoming,
186                        "outgoing": outgoing,
187                        "typed_total": typed_total,
188                        "typed_incoming": typed_incoming,
189                        "typed_outgoing": typed_outgoing,
190                    })
191                },
192            )
193            .collect();
194        obj.insert("most_connected".into(), json!(connected));
195    }
196    if include.iter().any(|s| s == "missing_fields") {
197        let list: Vec<_> = health
198            .missing_fields
199            .iter()
200            .map(|h| {
201                // `missing` (bare field names) stays byte-identical for
202                // existing consumers; the per-issue detail rides next to
203                // it so the CLI projection carries WHICH condition each
204                // issue reports — same additive shape as the MCP
205                // composer's.
206                let missing: Vec<&str> = h.issues.iter().map(|i| i.field.as_str()).collect();
207                let issues: Vec<_> = h
208                    .issues
209                    .iter()
210                    .map(|i| json!({ "field": i.field, "code": i.code, "message": i.message }))
211                    .collect();
212                json!({
213                    "id": h.id.to_string(),
214                    "title": h.title,
215                    "missing": missing,
216                    "issues": issues,
217                })
218            })
219            .collect();
220        obj.insert("missing_fields".into(), json!(list));
221    }
222    if include.iter().any(|s| s == "stale") {
223        let list: Vec<_> = health
224            .stale_entities
225            .iter()
226            .map(|e| {
227                json!({
228                    "id": e.id.to_string(),
229                    "title": e.title,
230                    "days_since_modified": e.days_since_modified,
231                })
232            })
233            .collect();
234        obj.insert("stale".into(), json!(list));
235    }
236    if include.iter().any(|s| s == "missing_required_outgoing") {
237        if !missing_required_outgoing.is_empty() {
238            strict_violations.push(("missing_required_outgoing", missing_required_outgoing.len()));
239        }
240        obj.insert(
241            "missing_required_outgoing".into(),
242            serde_json::to_value(&missing_required_outgoing)?,
243        );
244    }
245    if include.iter().any(|s| s == "constraints") {
246        if !constraint_findings.is_empty() {
247            strict_violations.push(("constraints", constraint_findings.len()));
248        }
249        obj.insert(
250            "constraints".into(),
251            serde_json::to_value(&constraint_findings)?,
252        );
253        // Defective section-format declarations (lenient boot):
254        // additive key, present only when defects exist.
255        if !schema_format_defects.is_empty() {
256            strict_violations.push(("schema_format_defects", schema_format_defects.len()));
257            obj.insert(
258                "schema_format_defects".into(),
259                serde_json::to_value(&schema_format_defects)?,
260            );
261        }
262    }
263    if include.iter().any(|s| s == "dangling_links") {
264        let arr: Vec<serde_json::Value> = dangling_links
265            .iter()
266            .map(|dl| serde_json::to_value(dl).unwrap_or(serde_json::Value::Null))
267            .collect();
268        obj.insert("dangling_links".into(), json!(arr));
269    }
270    if include
271        .iter()
272        .any(|s| s == "conformance" || s == "integrity")
273    {
274        obj.insert("findings".into(), serde_json::to_value(&findings)?);
275    }
276    if include.iter().any(|s| s == "tags")
277        && let Some((distribution, folded, untagged)) = tag_distribution
278    {
279        obj.insert("tag_distribution".into(), distribution);
280        obj.insert("tag_distribution_folded".into(), folded);
281        obj.insert("untagged_entities".into(), untagged);
282    }
283    // `--include config`: the shared workspace-config projection
284    // (`mems` / `mutations` / `plugin`), rendered by the same
285    // implementation MCP's `include_config: true` uses.
286    if let Some(entries) = config_entries {
287        for (k, v) in entries {
288            obj.insert(k, v);
289        }
290    }
291    if let Some(axis) = &anchors_axis {
292        obj.insert("anchors".to_string(), axis.clone());
293    }
294    if let Some(axis) = &open_questions_axis {
295        obj.insert("open_questions".to_string(), axis.clone());
296    }
297    if let Some(axis) = &stale_derivations_axis {
298        obj.insert("stale_derivations".to_string(), axis.clone());
299    }
300    if let Some(axis) = &checks_axis {
301        obj.insert("checks".to_string(), axis.clone());
302    }
303    // `--include friction`: the friction ledger's read surface
304    // (agent-trust plan 08) — counts per refusal code / per verb,
305    // whole ledger plus a recent 24h window. Same summarizer MCP's
306    // axis serves; no workspace resolvable → empty summary.
307    let friction_axis = if include.iter().any(|s| s == "friction") {
308        let summary = std::env::current_dir()
309            .ok()
310            .and_then(|cwd| crate::setup::find_workspace_root(&cwd))
311            .map(|root| memstead_base::friction::FrictionLedger::for_workspace(&root).summarize())
312            .unwrap_or_else(|| {
313                json!({
314                    "total": 0,
315                    "by_code": {},
316                    "by_verb": {},
317                    "recent_24h": { "total": 0, "by_code": {} },
318                    "ledger_bytes": 0,
319                })
320            });
321        obj.insert("friction".to_string(), summary.clone());
322        Some(summary)
323    } else {
324        None
325    };
326
327    // Typed warnings array — engine-level health warnings (load-time
328    // drift, the authoring-drift axis, …) in the same `{code, message,
329    // details}` shape MCP emits on `warnings[]`, plus any
330    // `UNKNOWN_INCLUDE_KEY` request warnings. Previously the CLI
331    // rendered only the include-key warnings, leaving engine warnings
332    // MCP-only — the blindness the authoring-drift axis exists to fix
333    // was measured through exactly this gap.
334    let mut warning_payload: Vec<serde_json::Value> = health
335        .warnings
336        .iter()
337        .filter_map(|w| serde_json::to_value(w).ok())
338        .collect();
339    warning_payload.extend(include_warnings.iter().map(|(key, allowed)| {
340        json!({
341            "code": "UNKNOWN_INCLUDE_KEY",
342            "message": format!(
343                "unknown include key: \"{key}\". Allowed: {}",
344                allowed.join(", ")
345            ),
346            "details": { "key": key, "allowed": allowed },
347        })
348    }));
349    if !warning_payload.is_empty() {
350        obj.insert("warnings".into(), json!(warning_payload));
351    }
352    // Leaf populations — the counts the orphan axis exempts because
353    // those types are terminal by construction (agent-trust plan 06).
354    if !health.leaf_entities_by_type.is_empty() {
355        obj.insert(
356            "leaf_entities_by_type".into(),
357            serde_json::to_value(&health.leaf_entities_by_type).unwrap_or_default(),
358        );
359    }
360    // Quarantine roster — a boot-honesty fact, present whenever
361    // non-empty, never behind an include gate (agent-trust plan 04).
362    if !health.quarantined.is_empty() {
363        obj.insert(
364            "quarantined".into(),
365            serde_json::to_value(&health.quarantined).unwrap_or_default(),
366        );
367    }
368    if let Some(diag) = &health.boot_diagnosis {
369        obj.insert("boot_diagnosis".into(), diag.clone());
370    }
371
372    // Authoring-drift findings participate in `--strict`
373    // unconditionally (no `--include` opt-in): they are
374    // default-visible warnings, and the axis exists because a
375    // `health --strict` run stayed silent on a vanished authoring
376    // source.
377    let authoring_drift = health
378        .warnings
379        .iter()
380        .filter(|w| {
381            matches!(
382                w.code(),
383                "SCHEMA_AUTHORING_SOURCE_MISSING" | "SCHEMA_AUTHORING_SOURCE_DIVERGED"
384            )
385        })
386        .count();
387    if authoring_drift > 0 {
388        strict_violations.push(("schema_authoring_drift", authoring_drift));
389    }
390
391    if ctx.json {
392        print_json(&result)?;
393        return strict_exit(args.strict, &strict_violations);
394    }
395
396    // Markdown rendering
397    let mut lines = Vec::new();
398    lines.push("# Graph health".to_string());
399    lines.push(String::new());
400    lines.push(format!("- Entities: {real_count}"));
401    if orphans_by_schema.len() > 1 {
402        // Attribute the orphan headline per schema so by-design isolates
403        // (ingest mems) aren't read as uniform debt.
404        let by: Vec<String> = orphans_by_schema
405            .iter()
406            .map(|(s, n)| format!("{}: {n}", if s.is_empty() { "(unpinned)" } else { s }))
407            .collect();
408        lines.push(format!(
409            "- Orphans: {} ({})",
410            orphan_ids.len(),
411            by.join(", ")
412        ));
413    } else {
414        lines.push(format!("- Orphans: {}", orphan_ids.len()));
415    }
416    lines.push(format!("- Stubs: {}", stub_pairs.len()));
417    lines.push(format!("- Stale: {}", health.stale_entities.len()));
418    lines.push(format!("- Missing fields: {}", health.missing_fields.len()));
419    lines.push(format!("- Communities: {community_count}"));
420    lines.push(String::new());
421
422    if let Some(v) = obj.get("orphans").and_then(|v| v.as_array()) {
423        lines.push("## Orphans".to_string());
424        for item in v {
425            lines.push(format!(
426                "- {} — {}",
427                item["id"].as_str().unwrap_or(""),
428                item["title"].as_str().unwrap_or("")
429            ));
430        }
431        lines.push(String::new());
432    }
433    if let Some(v) = obj.get("stubs").and_then(|v| v.as_array()) {
434        lines.push("## Stubs".to_string());
435        for item in v {
436            lines.push(format!("- {}", item["id"].as_str().unwrap_or("")));
437        }
438        lines.push(String::new());
439    }
440    if let Some(v) = obj.get("most_connected").and_then(|v| v.as_array()) {
441        lines.push("## Most connected".to_string());
442        lines.push("(ranked by typed dependency degree; total keeps mention edges)".to_string());
443        for item in v {
444            lines.push(format!(
445                "- {} — {} (typed {}, total {}, in {}, out {})",
446                item["id"].as_str().unwrap_or(""),
447                item["title"].as_str().unwrap_or(""),
448                item["typed_total"].as_u64().unwrap_or(0),
449                item["total"].as_u64().unwrap_or(0),
450                item["incoming"].as_u64().unwrap_or(0),
451                item["outgoing"].as_u64().unwrap_or(0),
452            ));
453        }
454        lines.push(String::new());
455    }
456    if let Some(v) = obj.get("missing_fields").and_then(|v| v.as_array()) {
457        lines.push("## Missing fields".to_string());
458        for item in v {
459            // Render per-issue `field (CODE)` so a heading mismatch never
460            // reads as "missing" to a human either — content under a
461            // non-deriving heading EXISTS; the label must say which
462            // condition fired. Falls back to the legacy field-name list
463            // for payloads without `issues` (older JSON piped back in).
464            let labels: Vec<String> = match item["issues"].as_array() {
465                Some(issues) if !issues.is_empty() => issues
466                    .iter()
467                    .map(|i| {
468                        format!(
469                            "{} ({})",
470                            i["field"].as_str().unwrap_or(""),
471                            i["code"].as_str().unwrap_or("MISSING"),
472                        )
473                    })
474                    .collect(),
475                _ => item["missing"]
476                    .as_array()
477                    .map(|a| {
478                        a.iter()
479                            .filter_map(|s| s.as_str())
480                            .map(str::to_string)
481                            .collect()
482                    })
483                    .unwrap_or_default(),
484            };
485            lines.push(format!(
486                "- {} — {} (issues: {})",
487                item["id"].as_str().unwrap_or(""),
488                item["title"].as_str().unwrap_or(""),
489                labels.join(", ")
490            ));
491        }
492        lines.push(String::new());
493    }
494    if let Some(v) = obj.get("stale").and_then(|v| v.as_array()) {
495        lines.push("## Stale entities".to_string());
496        for item in v {
497            lines.push(format!(
498                "- {} — {} ({} days)",
499                item["id"].as_str().unwrap_or(""),
500                item["title"].as_str().unwrap_or(""),
501                item["days_since_modified"].as_u64().unwrap_or(0)
502            ));
503        }
504        lines.push(String::new());
505    }
506    if let Some(v) = obj
507        .get("missing_required_outgoing")
508        .and_then(|v| v.as_array())
509    {
510        lines.push("## Missing required outgoing".to_string());
511        for item in v {
512            let blocks: Vec<String> = item["missing"]
513                .as_array()
514                .map(|arr| {
515                    arr.iter()
516                        .map(|b| {
517                            let rels: Vec<&str> = b["relationships"]
518                                .as_array()
519                                .map(|a| a.iter().filter_map(|s| s.as_str()).collect())
520                                .unwrap_or_default();
521                            format!(
522                                "[{}] {}",
523                                rels.join(", "),
524                                b["cardinality"].as_str().unwrap_or("")
525                            )
526                        })
527                        .collect()
528                })
529                .unwrap_or_default();
530            lines.push(format!(
531                "- {} — {} (missing: {})",
532                item["id"].as_str().unwrap_or(""),
533                item["title"].as_str().unwrap_or(""),
534                blocks.join("; ")
535            ));
536        }
537        lines.push(String::new());
538    }
539    if let Some(v) = obj.get("dangling_links").and_then(|v| v.as_array()) {
540        lines.push("## Dangling links".to_string());
541        for item in v {
542            lines.push(format!(
543                "- {} → {} (section: {})",
544                item["from"].as_str().unwrap_or(""),
545                item["target_id"].as_str().unwrap_or(""),
546                item["section"].as_str().unwrap_or("(none)")
547            ));
548        }
549        lines.push(String::new());
550    }
551    if let Some(v) = obj.get("tag_distribution").and_then(|v| v.as_array()) {
552        lines.push("## Tags".to_string());
553        for item in v {
554            lines.push(format!(
555                "- {} ({})",
556                item["tag"].as_str().unwrap_or(""),
557                item["count"].as_u64().unwrap_or(0)
558            ));
559        }
560        lines.push(String::new());
561    }
562    if let Some(v) = obj.get("warnings").and_then(|v| v.as_array()) {
563        lines.push("## Warnings".to_string());
564        for w in v {
565            lines.push(format!(
566                "- {} — {}",
567                w["code"].as_str().unwrap_or(""),
568                w["message"].as_str().unwrap_or("")
569            ));
570        }
571        lines.push(String::new());
572    }
573    if let Some(u) = obj.get("untagged_entities") {
574        lines.push("## Untagged".to_string());
575        lines.push(format!("- Total: {}", u["total"].as_u64().unwrap_or(0)));
576        if let Some(by_type) = u["by_entity_type"].as_object() {
577            let mut entries: Vec<(&String, u64)> = by_type
578                .iter()
579                .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
580                .collect();
581            entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
582            for (kind, count) in entries {
583                lines.push(format!("  - {kind}: {count}"));
584            }
585        }
586        lines.push(String::new());
587    }
588
589    if let Some(axis) = anchors_axis.as_ref().and_then(|a| a.as_object()) {
590        lines.push(format!("## Anchors ({} mems)", axis.len()));
591        for (mem, counts) in axis {
592            lines.push(format!(
593                "- `{mem}`: resolved {}, drifted {}, recheck {}, unresolvable {}",
594                counts["resolved"].as_u64().unwrap_or(0),
595                counts["drifted"].as_u64().unwrap_or(0),
596                counts["recheck"].as_u64().unwrap_or(0),
597                counts["unresolvable"].as_u64().unwrap_or(0),
598            ));
599        }
600        lines.push(String::new());
601    }
602
603    if let Some(axis) = open_questions_axis.as_ref().and_then(|a| a.as_object()) {
604        let cap = axis
605            .get("_item_cap")
606            .and_then(|v| v.as_u64())
607            .unwrap_or_default();
608        lines.push(format!("## Open questions (item cap {cap} per kind)"));
609        for (mem, entry) in axis.iter().filter(|(k, _)| *k != "_item_cap") {
610            let total = entry["total_open"].as_u64().unwrap_or(0);
611            lines.push(format!("- `{mem}`: {total} open"));
612            for kind in [
613                "stubs",
614                "anchors_recheck",
615                "anchors_unresolvable",
616                "unsatisfied_constraints",
617                "dangling_links",
618            ] {
619                let count = entry[kind]["count"].as_u64().unwrap_or(0);
620                if count > 0 {
621                    let more = entry[kind]["more"].as_u64().unwrap_or(0);
622                    let suffix = if more > 0 {
623                        format!(" ({more} more not shown)")
624                    } else {
625                        String::new()
626                    };
627                    lines.push(format!("  - {kind}: {count}{suffix}"));
628                }
629            }
630            if let Some(process) = entry.get("process").and_then(|p| p.as_array()) {
631                for p in process {
632                    if p["resolvable"] == serde_json::json!(true) {
633                        lines.push(format!(
634                            "  - process `{}`: {} open entries; {} already searched (do not redo)",
635                            p["binding"].as_str().unwrap_or("?"),
636                            p["open_entries"]["count"].as_u64().unwrap_or(0),
637                            p["already_searched"]["count"].as_u64().unwrap_or(0),
638                        ));
639                    } else {
640                        lines.push(format!(
641                            "  - process `{}`: not resolvable (mem not mounted)",
642                            p["binding"].as_str().unwrap_or("?"),
643                        ));
644                    }
645                }
646            }
647        }
648        lines.push(String::new());
649    }
650
651    // Checks axis — same wording as the MCP text renderer
652    // (`render_health_markdown`). Null-is-a-statement: requested with
653    // no mems renders the explicit zero heading; not requested
654    // renders nothing.
655    if let Some(axis) = checks_axis.as_ref().and_then(|a| a.as_object()) {
656        lines.push(format!("## Checks ({} mems)", axis.len()));
657        for (mem, c) in axis {
658            let count = |key: &str| c.get(key).and_then(|x| x.as_u64()).unwrap_or(0);
659            let gate = |key: &str| {
660                c.get("independence")
661                    .and_then(|g| g.get(key))
662                    .and_then(|e| e.get("count"))
663                    .and_then(|x| x.as_u64())
664                    .unwrap_or(0)
665            };
666            lines.push(format!(
667                "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
668                 check_stale {}; independence: self_checked {}, \
669                 confirmed_independent {}, unconfirmable {}",
670                count("never_checked"),
671                count("checked_ok"),
672                count("check_failed"),
673                count("check_stale"),
674                gate("self_checked"),
675                gate("confirmed_independent"),
676                gate("unconfirmable"),
677            ));
678        }
679        lines.push(String::new());
680    }
681
682    // Stale-derivations axis — same requested-vs-absent contract and
683    // wording as the MCP text renderer.
684    if let Some(axis) = stale_derivations_axis.as_ref().and_then(|a| a.as_object()) {
685        let total: usize = axis
686            .values()
687            .filter_map(|a| a.as_array().map(|a| a.len()))
688            .sum();
689        lines.push(format!("## Stale derivations ({total} findings)"));
690        for (mem, findings) in axis {
691            for f in findings.as_array().into_iter().flatten() {
692                lines.push(format!(
693                    "- `{mem}`: {} -[{}]-> {} ({})",
694                    f.get("source").and_then(|x| x.as_str()).unwrap_or(""),
695                    f.get("rel_type").and_then(|x| x.as_str()).unwrap_or(""),
696                    f.get("target").and_then(|x| x.as_str()).unwrap_or(""),
697                    f.get("state").and_then(|x| x.as_str()).unwrap_or(""),
698                ));
699            }
700        }
701        lines.push(String::new());
702    }
703
704    // Quarantine roster — ungated (present in the JSON whenever
705    // non-empty), so the markdown renders it whenever present: per
706    // mem the reason code plus the message, which carries the repair
707    // command.
708    if let Some(arr) = obj.get("quarantined").and_then(|v| v.as_array()) {
709        lines.push(format!("## Quarantined mems ({})", arr.len()));
710        for q in arr {
711            lines.push(format!(
712                "- `{}` [{}] {}",
713                q.get("mem").and_then(|x| x.as_str()).unwrap_or(""),
714                q.get("reason_code").and_then(|x| x.as_str()).unwrap_or(""),
715                q.get("reason_message")
716                    .and_then(|x| x.as_str())
717                    .unwrap_or(""),
718            ));
719        }
720        lines.push(String::new());
721    }
722
723    if let Some(f) = &friction_axis {
724        lines.push(format!(
725            "## Friction ({} refusals recorded, {} in the last 24h)",
726            f["total"].as_u64().unwrap_or(0),
727            f["recent_24h"]["total"].as_u64().unwrap_or(0),
728        ));
729        if let Some(by_code) = f["by_code"].as_object().filter(|m| !m.is_empty()) {
730            lines.push("- by code:".to_string());
731            let mut entries: Vec<(&String, u64)> = by_code
732                .iter()
733                .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
734                .collect();
735            entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
736            for (code, count) in entries {
737                lines.push(format!("  - {code}: {count}"));
738                // Reason breakdown where recorded — a code without
739                // recorded reasons renders exactly as before.
740                if let Some(reasons) = f["by_reason"][code.as_str()]
741                    .as_object()
742                    .filter(|m| !m.is_empty())
743                {
744                    let mut rs: Vec<(&String, u64)> = reasons
745                        .iter()
746                        .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
747                        .collect();
748                    rs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
749                    for (reason, count) in rs {
750                        lines.push(format!("    - {reason}: {count}"));
751                    }
752                }
753            }
754        }
755        if let Some(by_verb) = f["by_verb"].as_object().filter(|m| !m.is_empty()) {
756            lines.push("- by verb:".to_string());
757            let mut entries: Vec<(&String, u64)> = by_verb
758                .iter()
759                .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
760                .collect();
761            entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
762            for (verb, count) in entries {
763                lines.push(format!("  - {verb}: {count}"));
764            }
765        }
766        lines.push(String::new());
767    }
768
769    print_markdown(&lines.join("\n"));
770    strict_exit(args.strict, &strict_violations)
771}
772
773/// Aggregated health data, engine-flavour-agnostic. Both
774/// One `most_connected` row resolved at gather time:
775/// `(id, title, total, incoming, outgoing, typed_total, typed_incoming,
776/// typed_outgoing)`. `typed_*` excludes auto-emitted mention edges so the
777/// ranking reflects dependency, not co-mention.
778type MostConnectedRow = (EntityId, String, usize, usize, usize, usize, usize, usize);
779
780/// mem-repo and filesystem gather paths populate this struct
781/// with the same shape so the rendering / JSON-envelope code below
782/// runs once.
783struct GatheredHealth {
784    health: HealthSummary,
785    /// Integrity findings (`{id, axis, code, detail}`) — populated by
786    /// the caller (engine-shaped, so outside `gather_from_store`) when
787    /// `--include conformance` / `--include integrity` is requested.
788    findings: Vec<memstead_base::ops::integrity::IntegrityFinding>,
789    real_count: usize,
790    /// `(id, title)` pairs — title resolved at gather time so the
791    /// rendering layer doesn't need to keep the engine alive.
792    orphan_ids: Vec<(EntityId, String)>,
793    stub_pairs: Vec<(EntityId, Vec<EntityId>)>,
794    community_count: usize,
795    /// #49: orphan/community counts attributed per pinned schema, so a
796    /// blended headline isn't read as uniform debt (ingest-mem isolates
797    /// are orphans by design; code-mem orphans are debt). Filled by the
798    /// engine-aware gather wrappers — `gather_from_store` leaves them empty.
799    orphans_by_schema: std::collections::BTreeMap<String, usize>,
800    communities_by_schema: std::collections::BTreeMap<String, usize>,
801    /// [`MostConnectedRow`] tuples — same reasoning as `orphan_ids`.
802    most_connected_with_titles: Vec<MostConnectedRow>,
803    missing_required_outgoing: Vec<MissingRequiredOutgoingReport>,
804    /// Standing violations of declared schema `constraints`
805    /// (`--include constraints`), empty otherwise.
806    constraint_findings: Vec<ConstraintFindingReport>,
807    /// Defective section-format declarations the loaded schemas carry
808    /// (rides the `constraints` include), empty otherwise.
809    schema_format_defects: Vec<memstead_base::ops::health::SchemaFormatDefect>,
810    /// `Some(...)` when the caller asked for `--include tags`,
811    /// `None` otherwise. The triple is `(distribution, folded,
812    /// untagged)` mirroring `collect_tag_distribution`'s return
813    /// shape.
814    /// Pre-serialised tag triple: `(distribution, folded, untagged)`
815    /// already converted to `serde_json::Value`. Keeps the gather
816    /// step engine-flavour-agnostic without exposing the
817    /// `memstead_base::ops::health` private tag types through this
818    /// crate's public surface.
819    tag_distribution: Option<(serde_json::Value, serde_json::Value, serde_json::Value)>,
820    /// Populated when `--include dangling_links` is set; empty
821    /// otherwise. Matches the MCP `memstead_health` tool's response
822    /// shape — `{from, target_id, target_path, section}` per entry.
823    dangling_links: Vec<DanglingLink>,
824    /// `Some(...)` when the caller asked for `--include config`: the
825    /// same top-level entries (`mems`, `mutations`, `plugin`) the MCP
826    /// composer renders for `include_config: true`, produced by the
827    /// shared `memstead_base::ops::health::config_projection` with the
828    /// policy values derived from `Engine::settings()`. `None`
829    /// otherwise — absence of the key means "not requested".
830    config_entries: Option<serde_json::Map<String, serde_json::Value>>,
831    /// `Some(...)` when the caller asked for `--include anchors`: the
832    /// per-mem four-state counts from the shared
833    /// `health_anchors_axis` helper (same axis MCP renders). `None`
834    /// otherwise — absence of the key means "not requested".
835    anchors_axis: Option<serde_json::Value>,
836    /// `Some(...)` when the caller asked for `--include
837    /// open_questions`: the composed per-mem worklist from the shared
838    /// `health_open_questions_axis` helper (same axis MCP renders).
839    open_questions_axis: Option<serde_json::Value>,
840    /// `Some(...)` when the caller asked for `--include
841    /// stale_derivations`: per-mem derivation-staleness findings from
842    /// the shared `health_stale_derivations_axis` helper.
843    stale_derivations_axis: Option<serde_json::Value>,
844    /// `--include checks` — per-mem check-state counts + the
845    /// author≠checker independence gate, via the shared
846    /// `health_checks_axis` helper.
847    checks_axis: Option<serde_json::Value>,
848}
849
850/// Conformance/integrity findings across every mounted mem, in
851/// sorted mem order. Engine-shaped (needs schema resolution), so it
852/// runs beside `gather_from_store`, not inside it. `target_schema`
853/// parse and resolution failures surface as typed CLI errors — the
854/// same codes the MCP surface refuses with.
855fn gather_findings(
856    engine: &memstead_base::Engine,
857    include: &[String],
858    target_schema: Option<&str>,
859) -> anyhow::Result<Vec<memstead_base::ops::integrity::IntegrityFinding>> {
860    let wants_conformance = include
861        .iter()
862        .any(|s| s == "conformance" || s == "integrity");
863    if !wants_conformance {
864        return Ok(Vec::new());
865    }
866    let target: Option<memstead_schema::SchemaRef> = match target_schema {
867        None => None,
868        Some(raw) => Some(
869            raw.parse::<memstead_schema::SchemaRef>()
870                .map_err(|reason| anyhow::anyhow!("invalid --target-schema {raw:?}: {reason}"))?,
871        ),
872    };
873    let mut mems: Vec<String> = engine.schemas().keys().cloned().collect();
874    mems.sort();
875    let mut findings = Vec::new();
876    for v in &mems {
877        findings.extend(
878            engine
879                .conformance_findings(v, target.as_ref())
880                .map_err(crate::CliError::from_engine_op)?,
881        );
882        if include.iter().any(|s| s == "integrity") {
883            findings.extend(
884                engine
885                    .consistency_findings(v)
886                    .map_err(crate::CliError::from_engine_op)?,
887            );
888        }
889    }
890    Ok(findings)
891}
892
893#[cfg(feature = "mem-repo")]
894fn gather_mem_repo(
895    engine: &mut memstead_base::Engine,
896    limit: usize,
897    include: &[String],
898) -> GatheredHealth {
899    let mut g = gather_from_store(
900        engine.health(),
901        engine.store(),
902        engine.communities().count,
903        limit,
904        include,
905        || engine.orphans(),
906        |limit| engine_most_connected_mem_repo(engine, limit),
907        || engine.missing_required_outgoing(None),
908        || engine.constraint_findings(None),
909        || engine.schema_format_defects(),
910    );
911    fill_schema_breakdowns(engine, &mut g);
912    fill_config_projection(engine, include, &mut g);
913    fill_anchors_axis(engine, include, &mut g);
914    fill_open_questions_axis(engine, include, &mut g);
915    fill_stale_derivations_axis(engine, include, &mut g);
916    fill_checks_axis(engine, include, &mut g);
917    g
918}
919
920fn gather_filesystem(
921    engine: &mut memstead_base::Engine,
922    limit: usize,
923    include: &[String],
924) -> GatheredHealth {
925    let mut g = gather_from_store(
926        engine.health(),
927        engine.store(),
928        engine.communities().count,
929        limit,
930        include,
931        || engine.orphans(),
932        |limit| engine_most_connected_filesystem(engine, limit),
933        || engine.missing_required_outgoing(None),
934        || engine.constraint_findings(None),
935        || engine.schema_format_defects(),
936    );
937    fill_schema_breakdowns(engine, &mut g);
938    fill_config_projection(engine, include, &mut g);
939    fill_anchors_axis(engine, include, &mut g);
940    fill_open_questions_axis(engine, include, &mut g);
941    fill_stale_derivations_axis(engine, include, &mut g);
942    fill_checks_axis(engine, include, &mut g);
943    g
944}
945
946/// #49: attribute the orphan / community headlines per pinned schema (the
947/// engine-aware step `gather_from_store` can't do off a bare `&Store`).
948/// Engine-aware step for `--include config` — renders the shared
949/// workspace-config projection (one implementation with the MCP
950/// composer) off the engine's own settings.
951fn fill_config_projection(
952    engine: &memstead_base::Engine,
953    include: &[String],
954    g: &mut GatheredHealth,
955) {
956    if include.iter().any(|s| s == "config") {
957        let mut mems: Vec<String> = engine
958            .mem_router()
959            .writable_mems()
960            .iter()
961            .cloned()
962            .collect();
963        mems.sort();
964        let (mutations, plugin) =
965            memstead_base::ops::health::config_projection_from_settings(engine.settings());
966        g.config_entries = Some(memstead_base::ops::health::config_projection(
967            engine, &mems, mutations, plugin,
968        ));
969    }
970}
971
972/// Engine-aware step for `--include anchors` — the per-mem four-state
973/// counts from the shared axis helper.
974/// Engine-aware step for `--include open_questions` — the composed
975/// what-don't-we-know worklist (agent-trust plan 11), one shared
976/// implementation with the MCP composer.
977fn fill_open_questions_axis(
978    engine: &memstead_base::Engine,
979    include: &[String],
980    g: &mut GatheredHealth,
981) {
982    if include.iter().any(|s| s == "open_questions") {
983        g.open_questions_axis = Some(memstead_base::ops::health::health_open_questions_axis(
984            engine, None,
985        ));
986    }
987}
988
989/// Engine-aware step for `--include stale_derivations` — per-mem
990/// derivation-staleness findings (agent-trust plan 12), one shared
991/// implementation with the MCP composer.
992fn fill_stale_derivations_axis(
993    engine: &memstead_base::Engine,
994    include: &[String],
995    g: &mut GatheredHealth,
996) {
997    if include.iter().any(|s| s == "stale_derivations") {
998        g.stale_derivations_axis = Some(memstead_base::ops::health::health_stale_derivations_axis(
999            engine, None,
1000        ));
1001    }
1002}
1003
1004fn fill_checks_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1005    if include.iter().any(|s| s == "checks") {
1006        g.checks_axis = Some(memstead_base::ops::health::health_checks_axis(engine, None));
1007    }
1008}
1009
1010fn fill_anchors_axis(engine: &memstead_base::Engine, include: &[String], g: &mut GatheredHealth) {
1011    if include.iter().any(|s| s == "anchors") {
1012        g.anchors_axis = Some(memstead_base::ops::health::health_anchors_axis(engine));
1013    }
1014}
1015
1016fn fill_schema_breakdowns(engine: &memstead_base::Engine, g: &mut GatheredHealth) {
1017    let mems: Vec<String> = engine.mounts().iter().map(|m| m.mem.clone()).collect();
1018    g.orphans_by_schema = engine.orphans_by_schema(&engine.orphans());
1019    g.communities_by_schema = engine.communities_by_schema(&mems);
1020}
1021
1022/// Engine-agnostic gather pipeline. The two engine-shaped callbacks
1023/// (`most_connected_fn`, `missing_required_outgoing_fn`) handle the
1024/// surfaces that are not available off the bare `&Store`.
1025///
1026/// Ten parameters is deliberate: five of them are the engine-shaped callbacks
1027/// that keep this function engine-agnostic. Bundling them into a struct would
1028/// move the same arity behind a type that exists for one call site.
1029#[allow(clippy::too_many_arguments)]
1030fn gather_from_store(
1031    health: HealthSummary,
1032    store: &Store,
1033    community_count: usize,
1034    limit: usize,
1035    include: &[String],
1036    orphans_fn: impl FnOnce() -> Vec<EntityId>,
1037    most_connected_fn: impl FnOnce(usize) -> Vec<MostConnectedRow>,
1038    missing_required_outgoing_fn: impl FnOnce() -> Vec<MissingRequiredOutgoingReport>,
1039    constraint_findings_fn: impl FnOnce() -> Vec<ConstraintFindingReport>,
1040    schema_format_defects_fn: impl FnOnce() -> Vec<memstead_base::ops::health::SchemaFormatDefect>,
1041) -> GatheredHealth {
1042    let real_count = store.all_entities().filter(|e| !e.stub).count();
1043    let orphan_ids: Vec<(EntityId, String)> = orphans_fn()
1044        .into_iter()
1045        .map(|id| {
1046            let title = store.get(&id).map(|e| e.title.clone()).unwrap_or_default();
1047            (id, title)
1048        })
1049        .collect();
1050    let stub_pairs = memstead_base::graph::query::find_stubs(store);
1051    let most_connected_with_titles = if include.iter().any(|s| s == "most_connected") {
1052        most_connected_fn(limit)
1053    } else {
1054        Vec::new()
1055    };
1056    let missing_required_outgoing = if include.iter().any(|s| s == "missing_required_outgoing") {
1057        missing_required_outgoing_fn()
1058    } else {
1059        Vec::new()
1060    };
1061    let constraint_findings = if include.iter().any(|s| s == "constraints") {
1062        constraint_findings_fn()
1063    } else {
1064        Vec::new()
1065    };
1066    let schema_format_defects = if include.iter().any(|s| s == "constraints") {
1067        schema_format_defects_fn()
1068    } else {
1069        Vec::new()
1070    };
1071    let tag_distribution = if include.iter().any(|s| s == "tags") {
1072        let (distribution, folded, untagged) =
1073            memstead_base::ops::health::collect_tag_distribution(store, None, limit);
1074        Some((
1075            serde_json::to_value(&distribution).unwrap_or(serde_json::Value::Null),
1076            serde_json::to_value(&folded).unwrap_or(serde_json::Value::Null),
1077            serde_json::to_value(&untagged).unwrap_or(serde_json::Value::Null),
1078        ))
1079    } else {
1080        None
1081    };
1082    let dangling_links = if include.iter().any(|s| s == "dangling_links") {
1083        memstead_base::ops::health::collect_dangling_links(store, None)
1084    } else {
1085        Vec::new()
1086    };
1087    GatheredHealth {
1088        health,
1089        findings: Vec::new(),
1090        real_count,
1091        orphan_ids,
1092        stub_pairs,
1093        community_count,
1094        // Engine-agnostic path can't resolve schema pins; the engine-aware
1095        // wrappers (`gather_mem_repo` / `gather_filesystem`) fill these.
1096        orphans_by_schema: std::collections::BTreeMap::new(),
1097        communities_by_schema: std::collections::BTreeMap::new(),
1098        most_connected_with_titles,
1099        missing_required_outgoing,
1100        constraint_findings,
1101        schema_format_defects,
1102        tag_distribution,
1103        dangling_links,
1104        config_entries: None,
1105        anchors_axis: None,
1106        open_questions_axis: None,
1107        stale_derivations_axis: None,
1108        checks_axis: None,
1109    }
1110}
1111
1112#[cfg(feature = "mem-repo")]
1113fn engine_most_connected_mem_repo(
1114    engine: &memstead_base::Engine,
1115    limit: usize,
1116) -> Vec<MostConnectedRow> {
1117    engine
1118        .most_connected(limit)
1119        .into_iter()
1120        .map(|c| {
1121            let title = engine
1122                .get_entity(&c.id)
1123                .map(|e| e.title.clone())
1124                .unwrap_or_default();
1125            (
1126                c.id,
1127                title,
1128                c.total,
1129                c.incoming,
1130                c.outgoing,
1131                c.typed_total,
1132                c.typed_incoming,
1133                c.typed_outgoing,
1134            )
1135        })
1136        .collect()
1137}
1138
1139fn engine_most_connected_filesystem(
1140    engine: &memstead_base::Engine,
1141    limit: usize,
1142) -> Vec<MostConnectedRow> {
1143    engine
1144        .most_connected(limit)
1145        .into_iter()
1146        .map(|c| {
1147            let title = engine
1148                .get_entity(&c.id)
1149                .map(|e| e.title.clone())
1150                .unwrap_or_default();
1151            (
1152                c.id,
1153                title,
1154                c.total,
1155                c.incoming,
1156                c.outgoing,
1157                c.typed_total,
1158                c.typed_incoming,
1159                c.typed_outgoing,
1160            )
1161        })
1162        .collect()
1163}
1164
1165/// Translate the strict-violation tally into an exit code. With
1166/// `--strict` set and any Tier-2 violations recorded, return a
1167/// `CliError(Generic)` so `main` exits 1 after the report has been
1168/// written to stdout. When `--strict` is unset, or when no Tier-2
1169/// `--include` token was supplied, this is a no-op.
1170fn strict_exit(strict: bool, violations: &[(&'static str, usize)]) -> anyhow::Result<()> {
1171    if !strict || violations.is_empty() {
1172        return Ok(());
1173    }
1174    let summary = violations
1175        .iter()
1176        .map(|(code, n)| format!("{code}: {n}"))
1177        .collect::<Vec<_>>()
1178        .join(", ");
1179    Err(crate::CliError::new(
1180        ExitKind::Generic,
1181        "HEALTH_STRICT_VIOLATIONS",
1182        format!("strict mode: tier-2 violations present ({summary})"),
1183    )
1184    .into())
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190    use clap::CommandFactory;
1191
1192    #[test]
1193    fn help_lists_every_include_key() {
1194        let cmd = Args::command();
1195        let arg = cmd
1196            .get_arguments()
1197            .find(|a| a.get_id() == "include")
1198            .expect("--include arg must exist");
1199        let help = arg
1200            .get_help()
1201            .expect("--include must have help text")
1202            .to_string();
1203        for key in HEALTH_INCLUDE_KEYS {
1204            assert!(
1205                help.contains(key),
1206                "`memstead health --help` must name include key `{key}` (got: {help})"
1207            );
1208        }
1209    }
1210}