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