Skip to main content

memstead_cli/commands/
health.rs

1//! `memstead health` — the workspace health report, composed by the
2//! engine's shared `compose_health` (the same builder behind the MCP
3//! `memstead_health` tool) so the `--json` bytes equal the tool's
4//! `structured_content` for every include key and under a `--mem` filter
5//! (backlog-engine plan A7). This file owns only the CLI's concerns: the
6//! argument shape, the `--strict` exit policy read off the composed report,
7//! and the markdown rendering.
8
9use clap::Parser;
10use memstead_base::ops::health_compose::{
11    ComposeHealthError, HealthArgs, HealthConfig, compose_health,
12};
13use serde_json::Value;
14
15use crate::output::{ExitKind, print_json, print_markdown};
16use crate::setup::CliContext;
17
18/// Graph health summary.
19///
20/// Default: counts only. Pass `--include` to drill into details.
21#[derive(Parser, Debug)]
22pub struct Args {
23    /// Scope the report to one writable mem (the engine still loads every
24    /// mem: dangling-link adjudication and the community partition are
25    /// only truthful over the whole store).
26    #[arg(long)]
27    pub mem: Option<String>,
28
29    /// Opt heavy content into the response: orphans, stubs,
30    /// most_connected, missing_fields, stale, dangling_links, tags,
31    /// missing_required_outgoing, constraints (standing violations of
32    /// declared schema constraints), conformance, integrity, config,
33    /// anchors (per-mem counts of the standalone anchor-verification
34    /// states, with `unresolvable` meaning the artifact is GONE and
35    /// `unobserved` meaning the pass could not measure it, plus the
36    /// population those counts cover), ledger (a FOLDER mem's change
37    /// ledger set against the markdown files beside it: entities the
38    /// ledger records with no file, and files the ledger never
39    /// mentions — read-only, it never writes or tidies a ledger line;
40    /// git-branch mems are absent rather than clean, because their
41    /// change set is a real two-tree diff and the divergence cannot
42    /// arise), friction (the workspace-local
43    /// refusal ledger's summary — counts per typed refusal code and
44    /// per verb, with per-code reason breakdowns where the code
45    /// carries a closed engine-owned discriminator, whole-ledger plus
46    /// a recent 24h window; local-only, values drawn from closed
47    /// engine-defined vocabularies only), vital_signs (per-mem model-truth signals: last-resort type share per community, unclaimed and contested source files, zero-outgoing entities folded into their subject, empty declared sections; counts and capped lists, never a verdict), open_questions (per-mem
48    /// composed worklist of
49    /// what the holding does not know: stubs, anchors that are recheck,
50    /// unresolvable (artifact gone), unobserved (not measured) or
51    /// dangling (entity gone), unsatisfied constraints, dangling links,
52    /// and a paired
53    /// process mem's open entries — negative findings separated as
54    /// already-searched; capped per kind with an explicit `more`
55    /// count), stale_derivations (per-mem derivation edges whose
56    /// target changed since the recorded baseline, plus unbaselined
57    /// edges — re-assert via `memstead relate` to refresh), checks
58    /// (per-mem counts of the four derived check states plus the
59    /// author≠checker independence gate: self_checked /
60    /// confirmed_independent / unconfirmable — transport is not
61    /// identity, so until a caller-declared identity exists every
62    /// ok-checked entity reports unconfirmable; the other two
63    /// categories are explicit empties), signals (entities whose
64    /// declared aggregate signals sit above `none`, each with value,
65    /// level and contributing entity ids, plus per-level counts;
66    /// `warn`-level signals participate in `--strict`, `notice`
67    /// never does), labelling (grounded labels per declaring mem:
68    /// accepted/defeated/undecided counts, the defeated and undecided
69    /// lists with their attacker evidence, and the excluded cross-mem
70    /// attack-edge count; an observation, never a strict violation).
71    #[arg(long, value_delimiter = ',')]
72    pub include: Vec<String>,
73
74    /// Schema ref (`name@x.y.z`) the conformance/integrity includes
75    /// lint against instead of each mem's current pin.
76    #[arg(long)]
77    pub target_schema: Option<String>,
78
79    /// Max rows for `most_connected` and `tag_distribution` (default: 10).
80    #[arg(long, default_value_t = 10)]
81    pub limit: usize,
82
83    /// Exit non-zero (1) when any included Tier-2 warning kind has
84    /// present violations, or when an always-on configuration axis
85    /// reports findings. Always-on (no `--include` opt-in): the
86    /// authoring-drift axis (`SCHEMA_AUTHORING_SOURCE_MISSING` /
87    /// `SCHEMA_AUTHORING_SOURCE_DIVERGED`) and the configuration
88    /// defects `SCHEMA_PIN_MISMATCH`, `SCHEMA_UNSTAMPED_SOURCE_ROT`
89    /// and `MOUNT_UNBACKED` (a mount whose branch or folder does not
90    /// exist, or holds no entity). Include-gated participation:
91    /// `missing_required_outgoing`, `constraints`, `signals` (warn
92    /// level), and with `integrity` the consistency findings
93    /// `UNRESOLVED_STUB`, `DANGLING_LINK_TARGET_MISSING`,
94    /// `DANGLING_LINK_NOT_RELATED` and
95    /// `DANGLING_RELATION_TARGET_MISSING` and
96    /// `CROSS_MEM_EDGE_UNGRANTED`. Stale entities, drifted
97    /// anchors and `SCHEMA_GENERATIONS_BEHIND` stay advisory. The
98    /// output is rendered first, then the non-zero exit fires; new
99    /// Tier-2 codes opt in additively without breaking the flag's
100    /// semantics.
101    #[arg(long)]
102    pub strict: bool,
103}
104
105pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
106    let mut cli_engine = ctx.cli_engine()?;
107    let engine = cli_engine.base_mut();
108    // Mirror the MCP handler: the full lazy-mount load, then the drift
109    // pass whose warnings ride in the report.
110    engine.ensure_mems_loaded(None);
111    let drift_warnings = engine.reload_if_stale(args.mem.as_deref());
112    let _ = engine.take_mem_changed_notices();
113
114    let (mutations, plugin) =
115        memstead_base::ops::health::config_projection_from_settings(engine.settings());
116    let config = HealthConfig { mutations, plugin };
117    let health_args = HealthArgs {
118        mem: args.mem.as_deref(),
119        include: &args.include,
120        limit: Some(args.limit),
121        target_schema: args.target_schema.as_deref(),
122        include_config: false,
123    };
124
125    let result = match compose_health(engine, &health_args, drift_warnings, &config) {
126        Ok(v) => v,
127        Err(ComposeHealthError::MemQuarantined(name)) => {
128            return Err(crate::CliError::from_engine_op(engine.unknown_mem_error(&name)).into());
129        }
130        Err(ComposeHealthError::UnknownMem {
131            name,
132            writable_mems,
133        }) => {
134            return Err(crate::CliError {
135                code: "UNKNOWN_MEM",
136                kind: ExitKind::NotFound,
137                message: format!(
138                    "unknown mem: \"{name}\". Writable mems: [{}]",
139                    writable_mems.join(", ")
140                ),
141                details: Some(serde_json::json!({
142                    "name": name,
143                    "writable_mems": writable_mems,
144                })),
145            }
146            .into());
147        }
148        Err(ComposeHealthError::InvalidTargetSchema { raw, reason }) => {
149            return Err(crate::CliError::new(
150                ExitKind::Validation,
151                "INVALID_INPUT",
152                format!("invalid target_schema {raw:?}: {reason}"),
153            )
154            .into());
155        }
156        Err(ComposeHealthError::Engine(e)) => {
157            return Err(crate::CliError::from_engine_op(e).into());
158        }
159    };
160
161    let strict_violations = strict_violations(&result, &args.include);
162
163    if ctx.json {
164        print_json(&result)?;
165        return strict_exit(args.strict, &strict_violations);
166    }
167
168    print_markdown(&render_markdown(&result, args.mem.as_deref()));
169    strict_exit(args.strict, &strict_violations)
170}
171
172/// The Tier-2 violations `--strict` refuses on, read off the composed
173/// report. Every entry is a section the caller opted into with
174/// `--include` (or a configuration defect the engine always reports), so
175/// `--strict` without any Tier-2 include stays a no-op.
176fn strict_violations(v: &Value, include: &[String]) -> Vec<(&'static str, usize)> {
177    let has = |key: &str| include.iter().any(|s| s == key);
178    let arr_len = |key: &str| v.get(key).and_then(Value::as_array).map_or(0, Vec::len);
179    let mut out: Vec<(&'static str, usize)> = Vec::new();
180    fn push(out: &mut Vec<(&'static str, usize)>, label: &'static str, n: usize) {
181        if n > 0 {
182            out.push((label, n));
183        }
184    }
185
186    if has("missing_required_outgoing") {
187        push(
188            &mut out,
189            "missing_required_outgoing",
190            arr_len("missing_required_outgoing"),
191        );
192    }
193    if has("constraints") {
194        push(&mut out, "constraints", arr_len("constraints"));
195        push(
196            &mut out,
197            "schema_format_defects",
198            arr_len("schema_format_defects"),
199        );
200    }
201    if has("integrity") {
202        let findings = v.get("findings").and_then(Value::as_array);
203        let count_code = |pred: &dyn Fn(&str) -> bool| {
204            findings.map_or(0, |f| {
205                f.iter()
206                    .filter(|x| x["code"].as_str().is_some_and(pred))
207                    .count()
208            })
209        };
210        push(
211            &mut out,
212            "dangling_links",
213            count_code(&|c| memstead_base::ops::DanglingLinkKind::ALL_CODES.contains(&c)),
214        );
215        push(
216            &mut out,
217            "unresolved_stubs",
218            count_code(&|c| c == "UNRESOLVED_STUB"),
219        );
220        push(
221            &mut out,
222            "ungranted_cross_mem_edges",
223            count_code(&|c| c == "CROSS_MEM_EDGE_UNGRANTED"),
224        );
225        push(
226            &mut out,
227            "anchors_sidecar_unreadable",
228            count_code(&|c| c == "ANCHORS_SIDECAR_UNREADABLE"),
229        );
230    }
231    if let Some(warn) = v["signals"]["counts"]["warn"].as_u64() {
232        push(&mut out, "signals", warn as usize);
233    }
234    if let Some(mems) = v.get("anchors").and_then(Value::as_object)
235        && !out.iter().any(|(k, _)| *k == "anchors_sidecar_unreadable")
236    {
237        let unreadable = mems
238            .values()
239            .filter(|m| m.get("condition").is_some_and(|c| !c.is_null()))
240            .count();
241        push(&mut out, "anchors_sidecar_unreadable", unreadable);
242    }
243
244    let warnings = v.get("warnings").and_then(Value::as_array);
245    let count_warning = |pred: &dyn Fn(&str) -> bool| {
246        warnings.map_or(0, |w| {
247            w.iter()
248                .filter(|x| x["code"].as_str().is_some_and(pred))
249                .count()
250        })
251    };
252    push(
253        &mut out,
254        "schema_authoring_drift",
255        count_warning(&|c| {
256            matches!(
257                c,
258                "SCHEMA_AUTHORING_SOURCE_MISSING" | "SCHEMA_AUTHORING_SOURCE_DIVERGED"
259            )
260        }),
261    );
262    for (label, code) in [
263        ("schema_pin_mismatch", "SCHEMA_PIN_MISMATCH"),
264        ("schema_unstamped_source_rot", "SCHEMA_UNSTAMPED_SOURCE_ROT"),
265        ("mount_unbacked", "MOUNT_UNBACKED"),
266    ] {
267        push(&mut out, label, count_warning(&|c| c == code));
268    }
269    out
270}
271
272fn s<'a>(v: &'a Value, key: &str) -> &'a str {
273    v[key].as_str().unwrap_or("")
274}
275
276fn n(v: &Value, key: &str) -> u64 {
277    v[key].as_u64().unwrap_or(0)
278}
279
280fn strs(v: &Value) -> Vec<&str> {
281    v.as_array()
282        .map(|a| a.iter().filter_map(Value::as_str).collect())
283        .unwrap_or_default()
284}
285
286/// Descending-count, then name: the order the markdown lists keyed maps in.
287fn counts_desc(map: &serde_json::Map<String, Value>) -> Vec<(&String, u64)> {
288    let mut entries: Vec<(&String, u64)> = map
289        .iter()
290        .map(|(k, v)| (k, v.as_u64().unwrap_or(0)))
291        .collect();
292    entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
293    entries
294}
295
296/// The CLI's markdown rendering of the composed report — every section
297/// reads the same keys the JSON carries.
298fn render_markdown(v: &Value, mem: Option<&str>) -> String {
299    let mut lines: Vec<String> = Vec::new();
300    lines.push("# Graph health".to_string());
301    lines.push(String::new());
302    if let Some(cov) = crate::coverage::HEALTH.axis_coverage() {
303        lines.push(format!("**Verdict coverage:** {}", cov.wire_line()));
304        lines.push(String::new());
305    }
306    if let Some(m) = mem {
307        lines.push(format!("**Mem filter:** `{m}`"));
308        lines.push(String::new());
309    }
310    let summary = &v["summary"];
311    lines.push(format!("- Entities: {}", n(summary, "total_entities")));
312    match summary["orphans_by_schema"].as_object() {
313        Some(by) if by.len() > 1 => {
314            let listed: Vec<String> = by
315                .iter()
316                .map(|(schema, count)| {
317                    format!(
318                        "{}: {}",
319                        if schema.is_empty() {
320                            "(unpinned)"
321                        } else {
322                            schema
323                        },
324                        count.as_u64().unwrap_or(0)
325                    )
326                })
327                .collect();
328            lines.push(format!(
329                "- Orphans: {} ({})",
330                n(summary, "total_orphans"),
331                listed.join(", ")
332            ));
333        }
334        _ => lines.push(format!("- Orphans: {}", n(summary, "total_orphans"))),
335    }
336    lines.push(format!("- Stubs: {}", n(summary, "total_stubs")));
337    lines.push(format!("- Stale: {}", n(summary, "total_stale")));
338    lines.push(format!(
339        "- Missing fields: {}",
340        n(summary, "total_missing_fields")
341    ));
342    lines.push(format!(
343        "- Communities: {}",
344        n(summary, "total_communities")
345    ));
346    lines.push(String::new());
347
348    if let Some(items) = v.get("orphans").and_then(Value::as_array) {
349        lines.push("## Orphans".to_string());
350        for item in items {
351            lines.push(format!("- {} — {}", s(item, "id"), s(item, "title")));
352        }
353        lines.push(String::new());
354    }
355    if let Some(items) = v.get("stubs").and_then(Value::as_array) {
356        lines.push("## Stubs".to_string());
357        for item in items {
358            lines.push(format!("- {}", s(item, "id")));
359        }
360        lines.push(String::new());
361    }
362    if let Some(items) = v.get("most_connected").and_then(Value::as_array) {
363        lines.push("## Most connected".to_string());
364        lines.push("(ranked by typed dependency degree; total keeps mention edges)".to_string());
365        for item in items {
366            lines.push(format!(
367                "- {} — {} (typed {}, total {}, in {}, out {})",
368                s(item, "id"),
369                s(item, "title"),
370                n(item, "typed_total"),
371                n(item, "total"),
372                n(item, "incoming"),
373                n(item, "outgoing"),
374            ));
375        }
376        lines.push(String::new());
377    }
378    if let Some(items) = v.get("missing_fields").and_then(Value::as_array) {
379        lines.push("## Missing fields".to_string());
380        for item in items {
381            let labels: Vec<String> = match item["issues"].as_array() {
382                Some(issues) if !issues.is_empty() => issues
383                    .iter()
384                    .map(|i| {
385                        format!(
386                            "{} ({})",
387                            s(i, "field"),
388                            i["code"].as_str().unwrap_or("MISSING")
389                        )
390                    })
391                    .collect(),
392                _ => strs(&item["missing"])
393                    .into_iter()
394                    .map(str::to_string)
395                    .collect(),
396            };
397            lines.push(format!(
398                "- {} — {} (issues: {})",
399                s(item, "id"),
400                s(item, "title"),
401                labels.join(", ")
402            ));
403        }
404        lines.push(String::new());
405    }
406    if let Some(items) = v.get("stale").and_then(Value::as_array) {
407        lines.push("## Stale entities".to_string());
408        for item in items {
409            lines.push(format!(
410                "- {} — {} ({} days)",
411                s(item, "id"),
412                s(item, "title"),
413                n(item, "days_since_modified")
414            ));
415        }
416        lines.push(String::new());
417    }
418    if let Some(items) = v.get("missing_required_outgoing").and_then(Value::as_array) {
419        lines.push("## Missing required outgoing".to_string());
420        for item in items {
421            let blocks: Vec<String> = item["missing"]
422                .as_array()
423                .map(|arr| {
424                    arr.iter()
425                        .map(|b| {
426                            format!(
427                                "[{}] {}",
428                                strs(&b["relationships"]).join(", "),
429                                s(b, "cardinality")
430                            )
431                        })
432                        .collect()
433                })
434                .unwrap_or_default();
435            lines.push(format!(
436                "- {} — {} (missing: {})",
437                s(item, "id"),
438                s(item, "title"),
439                blocks.join("; ")
440            ));
441        }
442        lines.push(String::new());
443    }
444    if let Some(items) = v.get("findings").and_then(Value::as_array) {
445        lines.push(format!("## Conformance findings ({})", items.len()));
446        if items.is_empty() {
447            lines.push("- none".to_string());
448        }
449        for item in items {
450            let mut line = format!(
451                "- [{}] {} (axis {})",
452                item["code"].as_str().unwrap_or("?"),
453                s(item, "id"),
454                item["axis"].as_str().unwrap_or("?"),
455            );
456            for key in ["field", "heading", "section"] {
457                if let Some(val) = item["detail"][key].as_str() {
458                    line.push_str(&format!(" — {key} `{val}`"));
459                }
460            }
461            lines.push(line);
462        }
463        lines.push(String::new());
464    }
465    if let Some(items) = v.get("body_observations").and_then(Value::as_array)
466        && !items.is_empty()
467    {
468        lines.push(format!("## Body observations ({})", items.len()));
469        for item in items {
470            let mut line = format!(
471                "- [{}] {} — {}",
472                item["code"].as_str().unwrap_or("?"),
473                s(item, "id"),
474                item["fate"].as_str().unwrap_or("?"),
475            );
476            for key in ["heading", "key"] {
477                if let Some(val) = item["detail"][key].as_str() {
478                    line.push_str(&format!(", {key} `{val}`"));
479                }
480            }
481            lines.push(line);
482        }
483        lines.push(String::new());
484    }
485    if let Some(items) = v.get("constraints").and_then(Value::as_array) {
486        lines.push(format!("## Constraint violations ({})", items.len()));
487        if items.is_empty() {
488            lines.push("- none".to_string());
489        }
490        for item in items {
491            let mut kinds: Vec<String> = item["violations"]
492                .as_array()
493                .map(|a| {
494                    a.iter()
495                        .filter_map(|x| x["kind"].as_str())
496                        .map(str::to_string)
497                        .collect()
498                })
499                .unwrap_or_default();
500            if item["format_violations"]
501                .as_array()
502                .is_some_and(|a| !a.is_empty())
503            {
504                kinds.push("section_format".to_string());
505            }
506            lines.push(format!(
507                "- {} — {} ({})",
508                s(item, "id"),
509                s(item, "title"),
510                kinds.join(", "),
511            ));
512        }
513        lines.push(String::new());
514    }
515    if let Some(items) = v.get("schema_format_defects").and_then(Value::as_array) {
516        lines.push(format!("## Schema format defects ({})", items.len()));
517        for item in items {
518            lines.push(format!("- {item}"));
519        }
520        lines.push(String::new());
521    }
522    if let Some(items) = v.get("dangling_links").and_then(Value::as_array) {
523        lines.push("## Dangling links".to_string());
524        for item in items {
525            lines.push(format!(
526                "- [{}] {} → {}{}",
527                item["kind"].as_str().unwrap_or("?"),
528                s(item, "from"),
529                s(item, "target_id"),
530                item["section"]
531                    .as_str()
532                    .map(|sec| format!(" (in `{sec}`)"))
533                    .unwrap_or_default(),
534            ));
535        }
536        lines.push(String::new());
537    }
538    if let Some(items) = v.get("tag_distribution").and_then(Value::as_array) {
539        lines.push("## Tags".to_string());
540        for item in items {
541            lines.push(format!("- {} ({})", s(item, "tag"), n(item, "count")));
542        }
543        lines.push(String::new());
544    }
545    if let Some(items) = v.get("warnings").and_then(Value::as_array) {
546        lines.push("## Warnings".to_string());
547        for w in items {
548            lines.push(format!("- {} — {}", s(w, "code"), s(w, "message")));
549        }
550        lines.push(String::new());
551    }
552    if let Some(u) = v.get("untagged_entities") {
553        lines.push("## Untagged".to_string());
554        lines.push(format!("- Total: {}", n(u, "total")));
555        if let Some(by_type) = u["by_entity_type"].as_object() {
556            for (kind, count) in counts_desc(by_type) {
557                lines.push(format!("  - {kind}: {count}"));
558            }
559        }
560        lines.push(String::new());
561    }
562
563    if let Some(axis) = v.get("ledger").and_then(Value::as_object) {
564        lines.push(format!("## Ledger vs files ({} folder mem(s))", axis.len()));
565        if axis.is_empty() {
566            lines.push(
567                "- no folder mems: the check does not apply to git-branch storage, whose \
568                 change set is a real two-tree diff"
569                    .to_string(),
570            );
571        }
572        for (mem, r) in axis {
573            let ghosts = r["ledger_without_file"]
574                .as_array()
575                .map(Vec::len)
576                .unwrap_or(0);
577            let unlogged = r["file_without_ledger"]
578                .as_array()
579                .map(Vec::len)
580                .unwrap_or(0);
581            if ghosts == 0 && unlogged == 0 {
582                lines.push(format!("- `{mem}`: ledger and files agree"));
583                continue;
584            }
585            lines.push(format!(
586                "- `{mem}`: {ghosts} recorded with no file, {unlogged} file(s) the ledger \
587                 never mentions"
588            ));
589            for id in r["ledger_without_file"].as_array().into_iter().flatten() {
590                lines.push(format!(
591                    "  - recorded, no file: `{}`",
592                    id.as_str().unwrap_or("")
593                ));
594            }
595            for id in r["file_without_ledger"].as_array().into_iter().flatten() {
596                lines.push(format!(
597                    "  - file, never recorded: `{}`",
598                    id.as_str().unwrap_or("")
599                ));
600            }
601        }
602        lines.push(String::new());
603    }
604
605    if let Some(axis) = v.get("anchors").and_then(Value::as_object) {
606        lines.push(format!("## Anchors ({} mems)", axis.len()));
607        for (mem, counts) in axis {
608            if let Some(c) = counts.get("condition").filter(|c| !c.is_null()) {
609                lines.push(format!(
610                    "- `{mem}`: ANCHORS_SIDECAR_UNREADABLE — {} — {}",
611                    c["reason"].as_str().unwrap_or("reason not stated"),
612                    counts["population"]
613                        .as_str()
614                        .unwrap_or("population not stated"),
615                ));
616                continue;
617            }
618            lines.push(format!(
619                "- `{mem}`: resolves {}, drifted {}, recheck {}, unresolvable (artifact gone) \
620                 {}, unobserved (not measured) {}, dangling (entity gone) {} — {}",
621                n(counts, "resolves"),
622                n(counts, "drifted"),
623                n(counts, "recheck"),
624                n(counts, "unresolvable"),
625                n(counts, "unobserved"),
626                n(counts, "dangling"),
627                counts["population"]
628                    .as_str()
629                    .unwrap_or("population not stated"),
630            ));
631        }
632        lines.push(String::new());
633    }
634
635    if let Some(axis) = v.get("vital_signs").and_then(Value::as_object) {
636        let mems: Vec<(&String, &Value)> = axis.iter().filter(|(k, _)| *k != "_item_cap").collect();
637        lines.push(format!("## Vital signs ({} mems)", mems.len()));
638        for (mem, sig) in mems {
639            let count = |k: &str| sig[k]["count"].as_u64().unwrap_or(0);
640            let share = match sig["type_share_by_community"]["status"].as_str() {
641                Some("declared") => format!(
642                    "last-resort type `{}` over {} community(ies)",
643                    sig["type_share_by_community"]["last_resort_type"]
644                        .as_str()
645                        .unwrap_or("?"),
646                    count("type_share_by_community")
647                ),
648                _ => "last-resort type not declared".to_string(),
649            };
650            let unclaimed = match sig["unclaimed_source_files"]["status"].as_str() {
651                Some("enumerated") => {
652                    format!(
653                        "{} unclaimed source file(s)",
654                        count("unclaimed_source_files")
655                    )
656                }
657                _ => "no bound source".to_string(),
658            };
659            lines.push(format!(
660                "- `{mem}`: {share}; {unclaimed}; {} contested unowned file(s); {} zero-outgoing \
661                 entity(ies) in {} community(ies); {} empty declared section(s)",
662                count("contested_unowned_files"),
663                sig["zero_outgoing_entities"]["entities"]
664                    .as_u64()
665                    .unwrap_or(0),
666                count("zero_outgoing_entities"),
667                count("empty_declared_sections"),
668            ));
669        }
670        lines.push(String::new());
671    }
672
673    if let Some(axis) = v.get("open_questions").and_then(Value::as_object) {
674        let cap = axis
675            .get("_item_cap")
676            .and_then(Value::as_u64)
677            .unwrap_or_default();
678        lines.push(format!("## Open questions (item cap {cap} per kind)"));
679        for (mem, entry) in axis.iter().filter(|(k, _)| *k != "_item_cap") {
680            lines.push(format!("- `{mem}`: {} open", n(entry, "total_open")));
681            for kind in [
682                "stubs",
683                "anchors_recheck",
684                "anchors_unresolvable",
685                "anchors_unobserved",
686                "anchors_dangling",
687                "unsatisfied_constraints",
688                "dangling_links",
689            ] {
690                let count = entry[kind]["count"].as_u64().unwrap_or(0);
691                if count > 0 {
692                    let more = entry[kind]["more"].as_u64().unwrap_or(0);
693                    let suffix = if more > 0 {
694                        format!(" ({more} more not shown)")
695                    } else {
696                        String::new()
697                    };
698                    lines.push(format!("  - {kind}: {count}{suffix}"));
699                }
700            }
701            for p in entry["process"].as_array().into_iter().flatten() {
702                if p["resolvable"] == Value::Bool(true) {
703                    lines.push(format!(
704                        "  - process `{}`: {} open entries; {} already searched (do not redo)",
705                        p["binding"].as_str().unwrap_or("?"),
706                        p["open_entries"]["count"].as_u64().unwrap_or(0),
707                        p["already_searched"]["count"].as_u64().unwrap_or(0),
708                    ));
709                } else {
710                    lines.push(format!(
711                        "  - process `{}`: not resolvable (mem not mounted)",
712                        p["binding"].as_str().unwrap_or("?"),
713                    ));
714                }
715            }
716        }
717        lines.push(String::new());
718    }
719
720    if let Some(axis) = v.get("checks").and_then(Value::as_object) {
721        lines.push(format!("## Checks ({} mems)", axis.len()));
722        for (mem, c) in axis {
723            let conf = |key: &str| c["conformance"][key].as_u64().unwrap_or(0);
724            let gate = |key: &str| c["independence"][key]["count"].as_u64().unwrap_or(0);
725            lines.push(format!(
726                "- `{mem}`: never_checked {}, checked_ok {}, check_failed {}, \
727                 check_stale {}; conformance: never_checked {}, \
728                 checked_ok {}, check_failed {}, check_stale {}; \
729                 independence: self_checked {}, \
730                 confirmed_independent {}, unconfirmable {}",
731                n(c, "never_checked"),
732                n(c, "checked_ok"),
733                n(c, "check_failed"),
734                n(c, "check_stale"),
735                conf("never_checked"),
736                conf("checked_ok"),
737                conf("check_failed"),
738                conf("check_stale"),
739                gate("self_checked"),
740                gate("confirmed_independent"),
741                gate("unconfirmable"),
742            ));
743            if let Some(foreign) = c.get("foreign_kinds").and_then(Value::as_object)
744                && !foreign.is_empty()
745            {
746                let listed: Vec<String> = foreign
747                    .iter()
748                    .map(|(k, count)| format!("{k} {}", count.as_u64().unwrap_or(0)))
749                    .collect();
750                lines.push(format!("  - foreign kinds: {}", listed.join(", ")));
751            }
752            if let Some(findings) = c.get("findings").and_then(Value::as_object) {
753                for (entity, f) in findings {
754                    let code = f["finding"]["code"].as_str().unwrap_or("?");
755                    let section = f["finding"]["section"]
756                        .as_str()
757                        .map(|sec| format!(" [{sec}]"))
758                        .unwrap_or_default();
759                    let message = f["finding"]["message"].as_str().unwrap_or("");
760                    lines.push(format!(
761                        "  - finding on `{entity}` ({} {}): {code}{section} — {message}",
762                        f["kind"].as_str().unwrap_or("verification"),
763                        f["verdict"].as_str().unwrap_or("?"),
764                    ));
765                }
766            }
767        }
768        lines.push(String::new());
769    }
770
771    if let Some(axis) = v.get("signals") {
772        lines.push(format!(
773            "## Signals (notice {}, warn {})",
774            axis["counts"]["notice"].as_u64().unwrap_or(0),
775            axis["counts"]["warn"].as_u64().unwrap_or(0),
776        ));
777        for e in axis["entities"].as_array().into_iter().flatten() {
778            for sig in e["signals"].as_array().into_iter().flatten() {
779                lines.push(format!(
780                    "- {} — {}: {} ({}) [{}]",
781                    s(e, "id"),
782                    s(sig, "name"),
783                    n(sig, "value"),
784                    s(sig, "level"),
785                    strs(&sig["contributors"]).join(", "),
786                ));
787            }
788        }
789        lines.push(String::new());
790    }
791
792    if let Some(axis) = v.get("labelling").and_then(Value::as_object) {
793        lines.push(format!("## Labelling ({} mems)", axis.len()));
794        for (mem, m) in axis {
795            let c = &m["counts"];
796            lines.push(format!(
797                "- `{mem}`: accepted {}, defeated {}, undecided {}; cross-mem attack edges excluded {}",
798                n(c, "accepted"),
799                n(c, "defeated"),
800                n(c, "undecided"),
801                n(m, "cross_mem_edges_excluded"),
802            ));
803            for d in m["defeated"].as_array().into_iter().flatten() {
804                lines.push(format!(
805                    "  - defeated: {} (by {})",
806                    s(d, "id"),
807                    strs(&d["defeated_by"]).join(", ")
808                ));
809            }
810            for u in m["undecided"].as_array().into_iter().flatten() {
811                lines.push(format!(
812                    "  - undecided: {} (open attackers {})",
813                    s(u, "id"),
814                    strs(&u["undecided_by"]).join(", ")
815                ));
816            }
817        }
818        lines.push(String::new());
819    }
820
821    if let Some(axis) = v.get("stale_derivations").and_then(Value::as_object) {
822        let total: usize = axis
823            .values()
824            .filter_map(|a| a.as_array().map(Vec::len))
825            .sum();
826        lines.push(format!("## Stale derivations ({total} findings)"));
827        for (mem, findings) in axis {
828            for f in findings.as_array().into_iter().flatten() {
829                lines.push(format!(
830                    "- `{mem}`: {} -[{}]-> {} ({})",
831                    s(f, "source"),
832                    s(f, "rel_type"),
833                    s(f, "target"),
834                    s(f, "state"),
835                ));
836            }
837        }
838        lines.push(String::new());
839    }
840
841    if let Some(items) = v.get("quarantined").and_then(Value::as_array) {
842        lines.push(format!("## Quarantined mems ({})", items.len()));
843        for q in items {
844            lines.push(format!(
845                "- `{}` [{}] {}",
846                s(q, "mem"),
847                s(q, "reason_code"),
848                s(q, "reason_message"),
849            ));
850        }
851        lines.push(String::new());
852    }
853
854    if let Some(items) = v.get("load_errors").and_then(Value::as_array) {
855        lines.push(format!("## Load errors ({})", items.len()));
856        for e in items {
857            lines.push(format!("- `{}` — {}", s(e, "file"), s(e, "error")));
858        }
859        lines.push(String::new());
860    }
861
862    if let Some(f) = v.get("friction") {
863        lines.push(format!(
864            "## Friction ({} refusals recorded, {} in the last 24h)",
865            n(f, "total"),
866            f["recent_24h"]["total"].as_u64().unwrap_or(0),
867        ));
868        if let Some(by_code) = f["by_code"].as_object().filter(|m| !m.is_empty()) {
869            lines.push("- by code:".to_string());
870            for (code, count) in counts_desc(by_code) {
871                lines.push(format!("  - {code}: {count}"));
872                if let Some(reasons) = f["by_reason"][code.as_str()]
873                    .as_object()
874                    .filter(|m| !m.is_empty())
875                {
876                    for (reason, count) in counts_desc(reasons) {
877                        lines.push(format!("    - {reason}: {count}"));
878                    }
879                }
880            }
881        }
882        if let Some(by_verb) = f["by_verb"].as_object().filter(|m| !m.is_empty()) {
883            lines.push("- by verb:".to_string());
884            for (verb, count) in counts_desc(by_verb) {
885                lines.push(format!("  - {verb}: {count}"));
886            }
887        }
888        lines.push(String::new());
889    }
890
891    lines.join("\n")
892}
893
894/// `--strict` set and any Tier-2 violations recorded, return a
895/// `CliError(Generic)` so `main` exits 1 after the report has been
896/// written to stdout. When `--strict` is unset, or when no Tier-2
897/// `--include` token was supplied, this is a no-op.
898fn strict_exit(strict: bool, violations: &[(&'static str, usize)]) -> anyhow::Result<()> {
899    if !strict || violations.is_empty() {
900        return Ok(());
901    }
902    let summary = violations
903        .iter()
904        .map(|(code, n)| format!("{code}: {n}"))
905        .collect::<Vec<_>>()
906        .join(", ");
907    Err(crate::CliError::new(
908        ExitKind::Generic,
909        "HEALTH_STRICT_VIOLATIONS",
910        format!("strict mode: tier-2 violations present ({summary})"),
911    )
912    .into())
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918    use clap::CommandFactory;
919    use memstead_base::ops::health::HEALTH_INCLUDE_KEYS;
920
921    #[test]
922    fn help_lists_every_include_key() {
923        let cmd = Args::command();
924        let arg = cmd
925            .get_arguments()
926            .find(|a| a.get_id() == "include")
927            .expect("--include arg must exist");
928        let help = arg
929            .get_help()
930            .expect("--include must have help text")
931            .to_string();
932        for key in HEALTH_INCLUDE_KEYS {
933            assert!(
934                help.contains(key),
935                "`memstead health --help` must name include key `{key}` (got: {help})"
936            );
937        }
938    }
939
940    #[test]
941    fn strict_reads_the_tier_two_sections_off_the_report() {
942        let v = serde_json::json!({
943            "findings": [
944                {"code": "UNRESOLVED_STUB"},
945                {"code": "DANGLING_LINK_TARGET_MISSING"},
946                {"code": "CROSS_MEM_EDGE_UNGRANTED"},
947            ],
948            "constraints": [{"id": "a"}],
949            "signals": {"counts": {"warn": 2}},
950            "warnings": [{"code": "MOUNT_UNBACKED"}],
951        });
952        let include = vec!["integrity".to_string(), "constraints".to_string()];
953        let got = strict_violations(&v, &include);
954        assert_eq!(
955            got,
956            vec![
957                ("constraints", 1),
958                ("dangling_links", 1),
959                ("unresolved_stubs", 1),
960                ("ungranted_cross_mem_edges", 1),
961                ("signals", 2),
962                ("mount_unbacked", 1),
963            ]
964        );
965        // Sections not opted into do not count.
966        let got = strict_violations(&v, &[]);
967        assert_eq!(got, vec![("signals", 2), ("mount_unbacked", 1)]);
968    }
969}