1use 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#[derive(Parser, Debug)]
22pub struct Args {
23 #[arg(long)]
27 pub mem: Option<String>,
28
29 #[arg(long, value_delimiter = ',')]
72 pub include: Vec<String>,
73
74 #[arg(long)]
77 pub target_schema: Option<String>,
78
79 #[arg(long, default_value_t = 10)]
81 pub limit: usize,
82
83 #[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 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
172fn 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
286fn 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
296fn 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
894fn 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 let got = strict_violations(&v, &[]);
967 assert_eq!(got, vec![("signals", 2), ("mount_unbacked", 1)]);
968 }
969}