1use kranz_engine::cost;
10use serde::Serialize;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15#[derive(Debug, Clone, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub struct ReadyReport {
18 pub score: u8,
19 pub level: ReadyLevel,
20 pub highest_leverage_fix: String,
21 pub dimensions: Vec<ReadyDimension>,
22 pub amm: crate::amm::AmmProjection,
25 #[serde(skip_serializing_if = "Option::is_none")]
28 pub contract_health: Option<kranz_engine::contract_health::ContractHealth>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum ReadyLevel {
34 Ready,
35 Warmup,
36 ColdStart,
37}
38
39#[derive(Debug, Clone, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ReadyDimension {
42 pub name: &'static str,
43 pub score: u8,
44 pub weight: u8,
45 pub status: ReadyStatus,
46 pub evidence: String,
47 pub remedy: String,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum ReadyStatus {
53 Pass,
54 Warn,
55 Fail,
56}
57
58#[derive(Debug, Clone)]
59struct ValidationCommand {
60 command: String,
61 program: String,
62 available: bool,
63}
64
65pub fn assess(repo: &Path) -> ReadyReport {
66 let validation_commands = detect_validation_commands(repo);
67 let dimensions = vec![
68 planner_instructions(repo),
69 readme_docs(repo),
70 test_runner(repo, &validation_commands),
71 ci_config(repo),
72 merge_gates(repo),
73 gitignore_hygiene(repo),
74 backend_lanes(repo),
75 contract_prerequisites(&validation_commands),
76 clean_git_state(repo),
77 calibration_corpus(repo),
78 context_not_credentials(repo),
79 ];
80 let total: u16 = dimensions.iter().map(|d| d.score as u16).sum();
81 let score = total.min(100) as u8;
82 let level = if score >= 80 {
83 ReadyLevel::Ready
84 } else if score >= 50 {
85 ReadyLevel::Warmup
86 } else {
87 ReadyLevel::ColdStart
88 };
89 let highest_leverage_fix = dimensions
90 .iter()
91 .filter(|d| !d.remedy.is_empty())
92 .max_by_key(|d| d.weight.saturating_sub(d.score))
93 .map(|d| d.remedy.clone())
94 .unwrap_or_else(|| {
95 "No obvious fix: this repo exposes the main signals kranz needs.".into()
96 });
97 let contract_health = kranz_engine::contract_health::compute_contract_health(repo)
100 .ok()
101 .flatten();
102 let amm = crate::amm::project(&dimensions, contract_health.as_ref());
103 ReadyReport {
104 score,
105 level,
106 highest_leverage_fix,
107 dimensions,
108 amm,
109 contract_health,
110 }
111}
112
113pub fn render(report: &ReadyReport) -> String {
114 let mut out = String::new();
115 out.push_str(&format!(
116 "kranz ready: {}/100 ({})\n",
117 report.score,
118 level_label(report.level)
119 ));
120 out.push_str(&format!(
121 "highest-leverage fix: {}\n\n",
122 report.highest_leverage_fix
123 ));
124 out.push_str("scorecard:\n");
125 for dim in &report.dimensions {
126 out.push_str(&format!(
127 " [{:>2}/{:<2}] {} — {}\n",
128 dim.score, dim.weight, dim.name, dim.evidence
129 ));
130 if !dim.remedy.is_empty() && dim.status != ReadyStatus::Pass {
131 out.push_str(&format!(" remedy: {}\n", dim.remedy));
132 }
133 }
134 out.push_str(&format!(
135 "\namm: {} (projection v{})",
136 crate::amm::level_label(report.amm.level),
137 report.amm.mapping_version
138 ));
139 if !report.amm.missing_signals.is_empty() {
140 out.push_str(&format!(
141 " — missing for next level: {}",
142 report.amm.missing_signals.join(", ")
143 ));
144 }
145 out.push('\n');
146 if let Some(health) = &report.contract_health {
147 let lint = health
148 .lint_pass_rate
149 .map(|r| {
150 format!(
151 "{:.0}% ({} of {} linted)",
152 r * 100.0,
153 health.lint_clean,
154 health.lint_linted
155 )
156 })
157 .unwrap_or_else(|| "n/a (no linted missions)".to_string());
158 out.push_str(&format!(
159 "contract health ({} missions): lint pass {lint}, waivers/mission {:.2}, blocked [{}]\n",
160 health.missions,
161 health.waivers_per_mission.unwrap_or(0.0),
162 [
163 ("grant", health.blocked.grant),
164 ("scan", health.blocked.secret_scan),
165 ("contract-bug", health.blocked.contract_bug),
166 ("cap", health.blocked.fix_cycle_cap),
167 ("untrusted", health.blocked.untrusted_validator),
168 ("other", health.blocked.other),
169 ]
170 .into_iter()
171 .filter(|(_, n)| *n > 0)
172 .map(|(k, n)| format!("{k}:{n}"))
173 .collect::<Vec<_>>()
174 .join(" "),
175 ));
176 }
177 out
178}
179
180#[derive(Debug, Clone, Serialize)]
187#[serde(rename_all = "camelCase")]
188pub struct OrgRepoReport {
189 pub id: String,
190 pub name: String,
191 pub root: PathBuf,
192 pub score: Option<u8>,
193 pub level: Option<crate::amm::AmmLevel>,
194 pub missing_for_next_level: Vec<String>,
195 pub unavailable: Option<String>,
196}
197
198#[derive(Debug, Clone, Serialize)]
199#[serde(rename_all = "camelCase")]
200pub struct OrgReport {
201 pub repos: Vec<OrgRepoReport>,
202 pub at_l3_plus: usize,
204 pub total: usize,
205 pub note: Option<String>,
208}
209
210pub fn assess_all(config_path: &Path) -> OrgReport {
213 let host = match kranz_server::load_host_config(config_path) {
214 Ok(host) => host,
215 Err(error) => {
216 return OrgReport {
217 repos: Vec::new(),
218 at_l3_plus: 0,
219 total: 0,
220 note: Some(format!(
221 "cannot read host catalog {}: {error:#}",
222 config_path.display()
223 )),
224 }
225 }
226 };
227 if host.repos.is_empty() {
228 return OrgReport {
229 repos: Vec::new(),
230 at_l3_plus: 0,
231 total: 0,
232 note: Some(format!(
233 "no host catalog at {} — register repos with `kranz init --register`",
234 config_path.display()
235 )),
236 };
237 }
238
239 let mut repos = Vec::new();
240 for repo in &host.repos {
241 let name = repo.display_name.clone().unwrap_or_else(|| repo.id.clone());
242 let unavailable = if !repo.root.is_dir() {
243 Some("root missing".to_string())
244 } else if !repo.root.join(".git").exists() {
245 Some("not a git repository".to_string())
246 } else {
247 None
248 };
249 if let Some(reason) = unavailable {
250 repos.push(OrgRepoReport {
251 id: repo.id.clone(),
252 name,
253 root: repo.root.clone(),
254 score: None,
255 level: None,
256 missing_for_next_level: Vec::new(),
257 unavailable: Some(reason),
258 });
259 continue;
260 }
261 let report = assess(&repo.root);
262 repos.push(OrgRepoReport {
263 id: repo.id.clone(),
264 name,
265 root: repo.root.clone(),
266 score: Some(report.score),
267 level: Some(report.amm.level),
268 missing_for_next_level: report.amm.missing_signals.clone(),
269 unavailable: None,
270 });
271 }
272 let at_l3_plus = repos
273 .iter()
274 .filter(|r| {
275 r.level
276 .map(|l| l >= crate::amm::AmmLevel::L3)
277 .unwrap_or(false)
278 })
279 .count();
280 OrgReport {
281 total: repos.len(),
282 repos,
283 at_l3_plus,
284 note: None,
285 }
286}
287
288pub fn render_org(report: &OrgReport) -> String {
289 let mut out = String::new();
290 if let Some(note) = &report.note {
291 out.push_str(&format!("kranz ready --all: {note}\n"));
292 return out;
293 }
294 out.push_str(&format!(
295 "kranz ready --all: {} of {} repos at L3+\n",
296 report.at_l3_plus, report.total
297 ));
298 for repo in &report.repos {
299 if let Some(reason) = &repo.unavailable {
300 out.push_str(&format!(
301 " {:<20} — unavailable: {reason} ({})\n",
302 repo.name,
303 repo.root.display()
304 ));
305 } else {
306 out.push_str(&format!(
307 " {:<20} {:<3} {:>3}/100 ({})\n",
308 repo.name,
309 crate::amm::level_label(repo.level.expect("available repos have a level")),
310 repo.score.unwrap_or(0),
311 repo.root.display()
312 ));
313 if !repo.missing_for_next_level.is_empty() {
314 out.push_str(&format!(
315 " {:<20} missing for next level: {}\n",
316 "",
317 repo.missing_for_next_level.join(", ")
318 ));
319 }
320 }
321 }
322 out
323}
324
325fn level_label(level: ReadyLevel) -> &'static str {
326 match level {
327 ReadyLevel::Ready => "ready",
328 ReadyLevel::Warmup => "warmup",
329 ReadyLevel::ColdStart => "cold-start",
330 }
331}
332
333fn planner_instructions(repo: &Path) -> ReadyDimension {
334 let agents = repo.join("AGENTS.md").exists();
335 let claude = repo.join("CLAUDE.md").exists();
336 match (agents, claude) {
337 (true, true) => dim(
338 "planner instructions",
339 20,
340 20,
341 ReadyStatus::Pass,
342 "AGENTS.md and CLAUDE.md present",
343 "",
344 ),
345 (true, false) => dim(
346 "planner instructions",
347 20,
348 20,
349 ReadyStatus::Pass,
350 "AGENTS.md present",
351 "",
352 ),
353 (false, true) => dim(
354 "planner instructions",
355 16,
356 20,
357 ReadyStatus::Warn,
358 "CLAUDE.md present; AGENTS.md absent",
359 "add AGENTS.md with repo-specific build/test/change rules",
360 ),
361 (false, false) => dim(
362 "planner instructions",
363 0,
364 20,
365 ReadyStatus::Fail,
366 "no AGENTS.md or CLAUDE.md",
367 "add AGENTS.md so planners inherit repo-specific constraints",
368 ),
369 }
370}
371
372fn readme_docs(repo: &Path) -> ReadyDimension {
373 if repo.join("README.md").exists() || repo.join("readme.md").exists() {
374 dim("repo docs", 10, 10, ReadyStatus::Pass, "README present", "")
375 } else {
376 dim(
377 "repo docs",
378 0,
379 10,
380 ReadyStatus::Fail,
381 "README missing",
382 "add a README with setup, architecture, and common commands",
383 )
384 }
385}
386
387fn test_runner(repo: &Path, commands: &[ValidationCommand]) -> ReadyDimension {
388 if commands.is_empty() {
389 return dim(
390 "test runner",
391 0,
392 20,
393 ReadyStatus::Fail,
394 "no common runnable test/build command detected",
395 "add a single documented runnable test command kranz can bind to",
396 );
397 }
398 let evidence = commands
399 .iter()
400 .map(|c| c.command.as_str())
401 .collect::<Vec<_>>()
402 .join(", ");
403 let has_test_signal = has_tests_dir(repo)
404 || repo.join("Cargo.toml").exists()
405 || package_json_has_script(repo, "test");
406 if has_test_signal {
407 dim("test runner", 20, 20, ReadyStatus::Pass, evidence, "")
408 } else {
409 dim(
410 "test runner",
411 10,
412 20,
413 ReadyStatus::Warn,
414 evidence,
415 "add committed tests or a clearly named test target",
416 )
417 }
418}
419
420fn ci_config(repo: &Path) -> ReadyDimension {
421 let has_ci = repo.join(".github").join("workflows").exists()
422 || repo.join(".gitlab-ci.yml").exists()
423 || repo.join(".circleci").join("config.yml").exists();
424 if has_ci {
425 dim(
426 "CI config",
427 5,
428 5,
429 ReadyStatus::Pass,
430 "CI config detected",
431 "",
432 )
433 } else {
434 dim(
435 "CI config",
436 0,
437 5,
438 ReadyStatus::Fail,
439 "no common CI config detected",
440 "add CI that runs the same validation commands kranz will gate on",
441 )
442 }
443}
444
445fn merge_gates(repo: &Path) -> ReadyDimension {
450 use kranz_engine::merge_gate::MERGE_GATES_PATH;
451 let committed = kranz_engine::git_ops::GitRepo::open(repo)
452 .and_then(|git| git.show_file("HEAD", MERGE_GATES_PATH));
453 let bytes = match committed {
454 Ok(Some(bytes)) => bytes,
455 Ok(None) => {
456 return dim(
457 "merge gates",
458 0,
459 5,
460 ReadyStatus::Fail,
461 format!("no tracked {MERGE_GATES_PATH} on the current branch"),
462 format!(
463 "commit a {MERGE_GATES_PATH} gate suite so the first merge does not fail closed"
464 ),
465 )
466 }
467 Err(error) => {
468 return dim(
469 "merge gates",
470 0,
471 5,
472 ReadyStatus::Fail,
473 format!("cannot read committed {MERGE_GATES_PATH}: {error}"),
474 format!("commit a parseable {MERGE_GATES_PATH} on a committed branch"),
475 )
476 }
477 };
478 match kranz_engine::merge_gate::parse_gate_suite(&bytes) {
479 Ok(suite) => dim(
480 "merge gates",
481 5,
482 5,
483 ReadyStatus::Pass,
484 format!(
485 "committed {MERGE_GATES_PATH} defines {} gate(s)",
486 suite.gates.len()
487 ),
488 "",
489 ),
490 Err(detail) => dim(
491 "merge gates",
492 0,
493 5,
494 ReadyStatus::Fail,
495 detail,
496 format!("fix {MERGE_GATES_PATH} so merges can run real gates"),
497 ),
498 }
499}
500
501fn gitignore_hygiene(repo: &Path) -> ReadyDimension {
502 let sentinels = [
507 ".kranz/missions/m-ready/events.jsonl",
508 ".kranz/missions/m-ready/events.jsonl.lock",
509 ".kranz/missions/m-ready/state.json",
510 ".kranz/missions/m-ready/runs/run.jsonl",
511 ".kranz/missions/m-ready/control/0001.json",
512 ".kranz/config.json",
513 ".kranz/serve.token",
514 ".kranz/serve.read.token",
515 ".kranz/tickets/ready.status",
516 ];
517 let present = sentinels
518 .iter()
519 .filter(|path| git_ignores(repo, path))
520 .count();
521 if present == sentinels.len() {
522 dim(
523 "kranz runtime gitignore",
524 10,
525 10,
526 ReadyStatus::Pass,
527 "runtime files ignored",
528 "",
529 )
530 } else if present > 0 {
531 dim(
532 "kranz runtime gitignore",
533 ((present * 10) / sentinels.len()) as u8,
534 10,
535 ReadyStatus::Warn,
536 format!("{present}/{} runtime paths ignored", sentinels.len()),
537 "add missing .kranz runtime patterns to .gitignore",
538 )
539 } else {
540 dim(
541 "kranz runtime gitignore",
542 0,
543 10,
544 ReadyStatus::Fail,
545 ".kranz runtime files not ignored",
546 "ignore .kranz runtime logs, snapshots, runs, control files, tokens, and status files",
547 )
548 }
549}
550
551fn git_ignores(repo: &Path, relative_path: &str) -> bool {
552 let ignored = Command::new("git")
558 .arg("-C")
559 .arg(repo)
560 .args(["-c", "core.excludesFile=", "check-ignore", "--quiet", "--"])
565 .arg(relative_path)
566 .output()
567 .map(|output| output.status.success())
568 .unwrap_or(false);
569 if !ignored {
570 return false;
571 }
572 let output = Command::new("git")
573 .arg("-C")
574 .arg(repo)
575 .args([
576 "-c",
577 "core.excludesFile=",
578 "check-ignore",
579 "--verbose",
580 "--",
581 ])
582 .arg(relative_path)
583 .output();
584 let Ok(output) = output else { return false };
585 if !output.status.success() {
586 return false;
587 }
588 let Ok(verbose) = std::str::from_utf8(&output.stdout) else {
589 return false;
590 };
591 let Some(metadata) = verbose.split_once('\t').map(|(metadata, _)| metadata) else {
592 return false;
593 };
594 let source_end = metadata.char_indices().find_map(|(index, character)| {
598 if character != ':' {
599 return None;
600 }
601 let rest = &metadata[index + 1..];
602 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
603 (digits > 0 && rest.as_bytes().get(digits) == Some(&b':')).then_some(index)
604 });
605 let Some(source_end) = source_end else {
606 return false;
607 };
608 let source = Path::new(&metadata[..source_end]);
609 let relative_source = if source.is_absolute() {
610 match source.strip_prefix(repo) {
611 Ok(relative) => relative,
612 Err(_) => return false,
613 }
614 } else {
615 source
616 };
617 if relative_source.file_name().and_then(|name| name.to_str()) != Some(".gitignore") {
618 return false;
619 }
620 if relative_source == Path::new(".kranz/.gitignore") {
629 let canonical = std::fs::read_to_string(repo.join(relative_source))
630 .map(|text| {
631 kranz_engine::paths::KRANZ_GITIGNORE_RULES
632 .iter()
633 .all(|rule| text.lines().any(|line| line.trim() == *rule))
634 })
635 .unwrap_or(false);
636 if canonical {
637 return true;
638 }
639 }
640 let source_spec = relative_source
643 .components()
644 .map(|component| component.as_os_str().to_string_lossy())
645 .collect::<Vec<_>>()
646 .join("/");
647 let Ok(git) = kranz_engine::git_ops::GitRepo::open(repo) else {
648 return false;
649 };
650 let Ok(Some(_)) = git.show_file("HEAD", &source_spec) else {
651 return false;
652 };
653 if !git.has_normal_index_entry(&source_spec).unwrap_or(false) {
654 return false;
655 }
656 Command::new("git")
660 .arg("-C")
661 .arg(repo)
662 .args(["diff", "--quiet", "HEAD", "--"])
663 .arg(&source_spec)
664 .status()
665 .map(|status| status.success())
666 .unwrap_or(false)
667}
668
669fn contract_prerequisites(commands: &[ValidationCommand]) -> ReadyDimension {
670 if commands.is_empty() {
671 return dim(
672 "contract prerequisites",
673 0,
674 5,
675 ReadyStatus::Fail,
676 "no validation command to preflight",
677 "add a runnable validation command before the first mission",
678 );
679 }
680 let missing: Vec<&ValidationCommand> = commands.iter().filter(|c| !c.available).collect();
681 if missing.is_empty() {
682 dim(
683 "contract prerequisites",
684 5,
685 5,
686 ReadyStatus::Pass,
687 "validation command programs are on PATH",
688 "",
689 )
690 } else {
691 let names = missing
692 .iter()
693 .map(|c| c.program.as_str())
694 .collect::<Vec<_>>()
695 .join(", ");
696 dim(
697 "contract prerequisites",
698 2,
699 5,
700 ReadyStatus::Warn,
701 format!("missing program(s): {names}"),
702 "install the missing validation-command programs or document the setup",
703 )
704 }
705}
706
707fn backend_lanes(repo: &Path) -> ReadyDimension {
708 let cfg = match kranz_engine::config::load(repo) {
709 Ok(cfg) => cfg,
710 Err(error) => {
711 return dim(
712 "agent backend lanes",
713 0,
714 5,
715 ReadyStatus::Fail,
716 format!("cannot resolve mission config: {error}"),
717 "fix mission config before probing agent backends",
718 )
719 }
720 };
721 backend_lanes_for_config(&cfg)
722}
723
724fn backend_lanes_for_config(cfg: &kranz_engine::types::MissionConfig) -> ReadyDimension {
725 let required = [
726 cfg.orchestrator.backend.as_deref().unwrap_or("claude"),
727 cfg.worker.backend.as_deref().unwrap_or("claude"),
728 cfg.validator_scrutiny
729 .backend
730 .as_deref()
731 .unwrap_or("claude"),
732 cfg.validator_functional
733 .backend
734 .as_deref()
735 .unwrap_or("claude"),
736 ];
737 let probes: Vec<(&str, Option<bool>)> = ["claude", "codex", "droid", "kimi", "cursor"]
742 .iter()
743 .map(|&backend| {
744 if !required.contains(&backend) {
745 return (backend, None);
746 }
747 let available = match backend {
748 "claude" => kranz_engine::backend_claude::discover_claude_binary(
749 cfg.claude_binary.as_deref(),
750 )
751 .is_ok(),
752 "codex" => kranz_engine::backend_codex::discover_codex_binary(None).is_ok(),
753 "droid" => kranz_engine::backend_droid::discover_droid_binary(None).is_ok(),
754 "kimi" => kranz_engine::backend_kimi::discover_kimi_binary(None).is_ok(),
755 _ => kranz_engine::backend_cursor::discover_cursor_binary(None).is_ok(),
756 };
757 (backend, Some(available))
758 })
759 .collect();
760 let missing_required: Vec<&str> = required
761 .iter()
762 .copied()
763 .filter(|required| {
764 !probes
765 .iter()
766 .any(|(backend, available)| backend == required && *available == Some(true))
767 })
768 .collect();
769 let evidence = format!(
770 "{}; executable/version probes only (authentication is proven by the first live mission)",
771 probes
772 .iter()
773 .map(|(backend, available)| format!(
774 "{backend}={}",
775 match available {
776 Some(true) => "ready",
777 Some(false) => "unavailable",
778 None => "skipped (no role selects it)",
779 }
780 ))
781 .collect::<Vec<_>>()
782 .join(", ")
783 );
784 if missing_required.is_empty() {
785 dim("agent backend lanes", 5, 5, ReadyStatus::Pass, evidence, "")
786 } else {
787 let mut missing = missing_required;
788 missing.sort_unstable();
789 missing.dedup();
790 dim(
791 "agent backend lanes",
792 0,
793 5,
794 ReadyStatus::Fail,
795 evidence,
796 format!(
797 "install or configure the selected backend CLI(s): {}",
798 missing.join(", ")
799 ),
800 )
801 }
802}
803
804fn clean_git_state(repo: &Path) -> ReadyDimension {
805 let output = Command::new("git")
806 .arg("-C")
807 .arg(repo)
808 .arg("status")
809 .arg("--porcelain")
810 .output();
811 match output {
812 Ok(out) if out.status.success() && out.stdout.is_empty() => dim(
813 "clean git state",
814 10,
815 10,
816 ReadyStatus::Pass,
817 "worktree clean",
818 "",
819 ),
820 Ok(out) if out.status.success() => dim(
821 "clean git state",
822 0,
823 10,
824 ReadyStatus::Fail,
825 "worktree has uncommitted changes",
826 "commit or shelve local changes before autonomous missions",
827 ),
828 _ => dim(
829 "clean git state",
830 0,
831 10,
832 ReadyStatus::Fail,
833 "git status unavailable",
834 "initialize a git repo and make sure git is available",
835 ),
836 }
837}
838
839fn context_not_credentials(repo: &Path) -> ReadyDimension {
846 let mut signals: Vec<(&str, bool, &str)> = Vec::new();
847
848 let vault = repo.join("docs/knowledge").is_dir();
849 signals.push((
850 "knowledge vault",
851 vault,
852 "create docs/knowledge/ (or an equivalent committed vault) so agent context lives in git",
853 ));
854
855 let gate = repo.join(".kranz/merge-gates.json").is_file();
856 signals.push((
857 "secret-scan gate",
858 gate,
859 "commit a .kranz/merge-gates.json suite so the secret-scan gate runs before every merge",
860 ));
861
862 let tracked_env: Vec<String> = Command::new("git")
866 .arg("-C")
867 .arg(repo)
868 .args(["ls-files"])
869 .output()
870 .map(|out| {
871 String::from_utf8_lossy(&out.stdout)
872 .lines()
873 .filter(|line| {
874 let name = line.rsplit('/').next().unwrap_or(line);
875 name == ".env" || name.starts_with(".env.") || name.ends_with(".env")
876 })
877 .map(str::to_string)
878 .collect()
879 })
880 .unwrap_or_default();
881 let no_env = tracked_env.is_empty();
882 signals.push((
883 "no tracked .env",
884 no_env,
885 "untrack env files and move secrets to the credential store — tracked .env: see evidence",
886 ));
887
888 let docs = repo.join("README.md").is_file();
889 signals.push((
890 "worker-readable docs",
891 docs,
892 "document setup/build/test in the README so workers don't need tribal (or credential-gated) knowledge",
893 ));
894
895 let passed = signals.iter().filter(|(_, ok, _)| *ok).count();
896 let score = ((passed * 10) / signals.len()) as u8;
897 let status = match passed {
898 n if n == signals.len() => ReadyStatus::Pass,
899 0 | 1 => ReadyStatus::Fail,
900 _ => ReadyStatus::Warn,
901 };
902 let evidence = if passed == signals.len() {
903 "context lives in git, not credentials".to_string()
904 } else {
905 let mut parts: Vec<String> = signals
906 .iter()
907 .filter(|(_, ok, _)| !ok)
908 .map(|(name, _, _)| format!("{name} missing"))
909 .collect();
910 if !tracked_env.is_empty() {
911 parts.push(format!("tracked env files: {}", tracked_env.join(", ")));
912 }
913 parts.join("; ")
914 };
915 let remedy = signals
916 .iter()
917 .filter(|(_, ok, _)| !ok)
918 .map(|(_, _, hint)| *hint)
919 .collect::<Vec<_>>()
920 .join("; ");
921 dim(
922 "context over credentials",
923 score,
924 10,
925 status,
926 evidence,
927 remedy,
928 )
929}
930
931fn calibration_corpus(repo: &Path) -> ReadyDimension {
932 let missions = cost::calibrate(repo).missions_used;
933 match missions {
934 0 => dim(
935 "calibration corpus",
936 0,
937 10,
938 ReadyStatus::Warn,
939 "0 completed missions; estimates use built-in defaults",
940 "run a small first mission to seed cost calibration",
941 ),
942 1 | 2 => dim(
943 "calibration corpus",
944 7,
945 10,
946 ReadyStatus::Warn,
947 format!("{missions} completed mission(s)"),
948 "complete a few more representative missions to tighten estimates",
949 ),
950 _ => dim(
951 "calibration corpus",
952 10,
953 10,
954 ReadyStatus::Pass,
955 format!("{missions} completed mission(s)"),
956 "",
957 ),
958 }
959}
960
961fn detect_validation_commands(repo: &Path) -> Vec<ValidationCommand> {
962 let mut commands = Vec::new();
963 if repo.join("Cargo.toml").exists() {
964 commands.push(validation_command("cargo test --workspace"));
965 }
966 if package_json_has_script(repo, "test") {
967 commands.push(validation_command("npm test"));
968 }
969 if repo.join("pytest.ini").exists()
970 || repo.join("pyproject.toml").exists()
971 || repo.join("tests").exists()
972 {
973 commands.push(validation_command("pytest"));
974 }
975 commands
976}
977
978fn validation_command(command: &str) -> ValidationCommand {
979 let program = command
980 .split_whitespace()
981 .next()
982 .unwrap_or(command)
983 .to_string();
984 let available = program_available(&program);
985 ValidationCommand {
986 command: command.to_string(),
987 program,
988 available,
989 }
990}
991
992fn package_json_has_script(repo: &Path, script: &str) -> bool {
993 let Ok(text) = fs::read_to_string(repo.join("package.json")) else {
994 return false;
995 };
996 let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
997 return false;
998 };
999 value
1000 .get("scripts")
1001 .and_then(|scripts| scripts.get(script))
1002 .and_then(|script| script.as_str())
1003 .is_some_and(|script| !script.trim().is_empty())
1004}
1005
1006fn has_tests_dir(repo: &Path) -> bool {
1007 ["tests", "test", "__tests__"]
1008 .iter()
1009 .any(|name| repo.join(name).exists())
1010}
1011
1012fn program_available(program: &str) -> bool {
1013 let path = Path::new(program);
1014 if path.components().count() > 1 {
1015 return path.exists();
1016 }
1017 let names = executable_names(program);
1018 std::env::var_os("PATH")
1019 .map(|paths| {
1020 std::env::split_paths(&paths).any(|dir| {
1021 names.iter().any(|name| {
1022 let candidate: PathBuf = dir.join(name);
1023 candidate.is_file()
1024 })
1025 })
1026 })
1027 .unwrap_or(false)
1028}
1029
1030fn executable_names(program: &str) -> Vec<String> {
1035 if !cfg!(windows) {
1036 return vec![program.to_string()];
1037 }
1038 let mut names = vec![program.to_string()];
1039 let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
1040 for ext in pathext.split(';').filter(|e| !e.is_empty()) {
1041 names.push(format!("{program}{ext}"));
1042 names.push(format!("{program}{}", ext.to_ascii_lowercase()));
1045 }
1046 names
1047}
1048
1049fn dim(
1050 name: &'static str,
1051 score: u8,
1052 weight: u8,
1053 status: ReadyStatus,
1054 evidence: impl Into<String>,
1055 remedy: impl Into<String>,
1056) -> ReadyDimension {
1057 ReadyDimension {
1058 name,
1059 score,
1060 weight,
1061 status,
1062 evidence: evidence.into(),
1063 remedy: remedy.into(),
1064 }
1065}
1066
1067#[cfg(test)]
1068mod tests {
1069 use super::*;
1070 use tempfile::TempDir;
1071
1072 fn write(path: &Path, text: &str) {
1073 if let Some(parent) = path.parent() {
1074 fs::create_dir_all(parent).unwrap();
1075 }
1076 fs::write(path, text).unwrap();
1077 }
1078
1079 fn git(dir: &Path, args: &[&str]) {
1080 let output = Command::new("git")
1081 .arg("-C")
1082 .arg(dir)
1083 .args(args)
1084 .output()
1085 .unwrap();
1086 assert!(
1087 output.status.success(),
1088 "git {args:?}: {}",
1089 String::from_utf8_lossy(&output.stderr)
1090 );
1091 }
1092
1093 fn commit_all(dir: &Path) {
1094 git(dir, &["config", "user.name", "ready-test"]);
1095 git(dir, &["config", "user.email", "ready@example.com"]);
1096 git(dir, &["add", "-A"]);
1097 git(dir, &["commit", "-m", "fixture"]);
1098 }
1099
1100 #[test]
1101 fn bare_repo_scores_low_and_names_test_runner_first() {
1102 let dir = TempDir::new().unwrap();
1103 let report = assess(dir.path());
1104
1105 assert!(report.score < 50, "{report:?}");
1106 assert!(
1107 report.highest_leverage_fix.contains("test command"),
1108 "{report:?}"
1109 );
1110 assert!(
1111 report
1112 .dimensions
1113 .iter()
1114 .any(|d| d.evidence.contains("0 completed missions")),
1115 "cold-start calibration warning present: {report:?}"
1116 );
1117 }
1118
1119 #[test]
1120 fn this_shape_scores_high_with_core_signals() {
1121 let dir = TempDir::new().unwrap();
1122 write(&dir.path().join("AGENTS.md"), "rules");
1123 write(&dir.path().join("README.md"), "readme");
1124 write(&dir.path().join("Cargo.toml"), "[workspace]\n");
1125 write(&dir.path().join(".github/workflows/ci.yml"), "name: ci\n");
1126 Command::new("git")
1127 .arg("-C")
1128 .arg(dir.path())
1129 .arg("init")
1130 .output()
1131 .unwrap();
1132 write(
1133 &dir.path().join(".kranz/.gitignore"),
1134 "missions/*/events.jsonl\n\
1135 missions/*/events.jsonl.lock\n\
1136 missions/*/state.json\n\
1137 missions/*/runs/\n\
1138 missions/*/control/\n\
1139 config.json\n\
1140 serve.token\n\
1141 serve.read.token\n\
1142 tickets/*.status\n",
1143 );
1144 write(
1145 &dir.path().join(".kranz/merge-gates.json"),
1146 r#"{"gates":[{"command":"cargo test --workspace"}]}"#,
1147 );
1148 commit_all(dir.path());
1149
1150 let report = assess(dir.path());
1151
1152 assert!(report.score >= 80, "{report:?}");
1153 assert_eq!(report.level, ReadyLevel::Ready);
1154 let hygiene = report
1155 .dimensions
1156 .iter()
1157 .find(|dimension| dimension.name == "kranz runtime gitignore")
1158 .unwrap();
1159 assert_eq!(hygiene.status, ReadyStatus::Pass, "{hygiene:?}");
1160 let gates = report
1161 .dimensions
1162 .iter()
1163 .find(|dimension| dimension.name == "merge gates")
1164 .unwrap();
1165 assert_eq!(gates.status, ReadyStatus::Pass, "{gates:?}");
1166 }
1167
1168 #[test]
1169 fn gitignore_hygiene_rejects_tracked_runtime_files() {
1170 let dir = TempDir::new().unwrap();
1171 git(dir.path(), &["init"]);
1172 write(
1173 &dir.path().join(".kranz/.gitignore"),
1174 "missions/\nconfig.json\nserve.token\nserve.read.token\ntickets/*.status\n",
1175 );
1176 write(
1177 &dir.path().join(".kranz/serve.token"),
1178 "must-not-be-tracked",
1179 );
1180 git(dir.path(), &["config", "user.name", "ready-test"]);
1181 git(dir.path(), &["config", "user.email", "ready@example.com"]);
1182 git(dir.path(), &["add", ".kranz/.gitignore"]);
1183 git(dir.path(), &["add", "-f", ".kranz/serve.token"]);
1184 git(dir.path(), &["commit", "-m", "tracked token fixture"]);
1185
1186 let hygiene = gitignore_hygiene(dir.path());
1187 assert_ne!(hygiene.status, ReadyStatus::Pass, "{hygiene:?}");
1188 assert!(hygiene.evidence.contains("8/9"), "{hygiene:?}");
1189 }
1190
1191 #[test]
1192 fn gitignore_hygiene_ignores_global_and_git_info_excludes() {
1193 let dir = TempDir::new().unwrap();
1194 git(dir.path(), &["init"]);
1195 let global = dir.path().join("global-excludes");
1196 write(&global, ".kranz/\n");
1197 git(
1198 dir.path(),
1199 &["config", "core.excludesFile", global.to_str().unwrap()],
1200 );
1201 write(&dir.path().join(".git/info/exclude"), ".kranz/\n");
1202
1203 let hygiene = gitignore_hygiene(dir.path());
1204 assert_eq!(hygiene.status, ReadyStatus::Fail, "{hygiene:?}");
1205 }
1206
1207 #[test]
1208 fn gitignore_hygiene_rejects_negated_ignore_rules() {
1209 let dir = TempDir::new().unwrap();
1210 git(dir.path(), &["init"]);
1211 write(
1215 &dir.path().join(".kranz/.gitignore"),
1216 "*\n!.gitignore\n!config.json\n",
1217 );
1218 commit_all(dir.path());
1219
1220 assert!(!git_ignores(dir.path(), ".kranz/config.json"));
1221 let hygiene = gitignore_hygiene(dir.path());
1222 assert_ne!(hygiene.status, ReadyStatus::Pass, "{hygiene:?}");
1223 assert!(hygiene.evidence.contains("8/9"), "{hygiene:?}");
1224 }
1225
1226 #[test]
1227 fn gitignore_hygiene_rejects_staged_but_uncommitted_gitignore() {
1228 let dir = TempDir::new().unwrap();
1229 git(dir.path(), &["init"]);
1230 write(&dir.path().join("README.md"), "readme");
1231 commit_all(dir.path());
1232 write(
1235 &dir.path().join(".kranz/.gitignore"),
1236 "missions/*/events.jsonl\n\
1237 missions/*/events.jsonl.lock\n\
1238 missions/*/state.json\n\
1239 missions/*/runs/\n\
1240 missions/*/control/\n\
1241 config.json\n\
1242 serve.token\n\
1243 tickets/*.status\n",
1244 );
1245 git(dir.path(), &["add", ".kranz/.gitignore"]);
1246
1247 assert!(!git_ignores(dir.path(), ".kranz/config.json"));
1248 let hygiene = gitignore_hygiene(dir.path());
1249 assert_eq!(hygiene.status, ReadyStatus::Fail, "{hygiene:?}");
1250 }
1251
1252 #[test]
1253 fn gitignore_hygiene_rejects_uncommitted_rules_in_a_tracked_gitignore() {
1254 let dir = TempDir::new().unwrap();
1255 git(dir.path(), &["init"]);
1256 write(&dir.path().join(".kranz/.gitignore"), "missions/\n");
1257 commit_all(dir.path());
1258 write(
1259 &dir.path().join(".kranz/.gitignore"),
1260 "missions/\nconfig.json\n",
1261 );
1262
1263 let git_verdict = Command::new("git")
1264 .arg("-C")
1265 .arg(dir.path())
1266 .args(["check-ignore", "--quiet", "--", ".kranz/config.json"])
1267 .status()
1268 .unwrap();
1269 assert!(
1270 git_verdict.success(),
1271 "fixture's working-tree rule must ignore config.json"
1272 );
1273 assert!(
1274 !git_ignores(dir.path(), ".kranz/config.json"),
1275 "readiness must evaluate the committed .gitignore bytes"
1276 );
1277 }
1278
1279 #[test]
1280 fn gitignore_hygiene_accepts_engine_materialized_kranz_gitignore() {
1281 let dir = TempDir::new().unwrap();
1282 git(dir.path(), &["init"]);
1283 write(&dir.path().join("README.md"), "readme");
1284 commit_all(dir.path());
1285 let mut text = "# kranz engine bookkeeping — never part of mission commits\n".to_string();
1290 for rule in kranz_engine::paths::KRANZ_GITIGNORE_RULES {
1291 text.push_str(rule);
1292 text.push('\n');
1293 }
1294 write(&dir.path().join(".kranz/.gitignore"), &text);
1295
1296 assert!(git_ignores(dir.path(), ".kranz/config.json"));
1297 let hygiene = gitignore_hygiene(dir.path());
1298 assert_eq!(hygiene.status, ReadyStatus::Pass, "{hygiene:?}");
1299 }
1300
1301 #[test]
1302 fn gitignore_hygiene_rejects_gutted_kranz_gitignore_when_uncommitted() {
1303 let dir = TempDir::new().unwrap();
1307 git(dir.path(), &["init"]);
1308 write(&dir.path().join("README.md"), "readme");
1309 commit_all(dir.path());
1310 write(
1311 &dir.path().join(".kranz/.gitignore"),
1312 "missions/\nconfig.json\nserve.token\ntickets/*.status\n",
1313 );
1314
1315 assert!(!git_ignores(dir.path(), ".kranz/config.json"));
1316 }
1317
1318 #[test]
1319 fn gitignore_hygiene_rejects_rules_hidden_by_index_flags() {
1320 for flag in ["--assume-unchanged", "--skip-worktree"] {
1321 let dir = TempDir::new().unwrap();
1322 git(dir.path(), &["init"]);
1323 write(&dir.path().join(".kranz/.gitignore"), "missions/\n");
1324 commit_all(dir.path());
1325 git(dir.path(), &["update-index", flag, ".kranz/.gitignore"]);
1326 write(
1327 &dir.path().join(".kranz/.gitignore"),
1328 "missions/\nconfig.json\n",
1329 );
1330
1331 let git_verdict = Command::new("git")
1332 .arg("-C")
1333 .arg(dir.path())
1334 .args(["check-ignore", "--quiet", "--", ".kranz/config.json"])
1335 .status()
1336 .unwrap();
1337 assert!(git_verdict.success(), "fixture failed for {flag}");
1338 assert!(
1339 !git_ignores(dir.path(), ".kranz/config.json"),
1340 "readiness trusted a .gitignore hidden by {flag}"
1341 );
1342 }
1343 }
1344
1345 #[test]
1346 fn gitignore_hygiene_rejects_rules_hidden_by_fsmonitor_valid() {
1347 let dir = TempDir::new().unwrap();
1348 git(dir.path(), &["init"]);
1349 write(&dir.path().join(".kranz/.gitignore"), "missions/\n");
1350 commit_all(dir.path());
1351 git(dir.path(), &["config", "core.fsmonitor", "true"]);
1352 write(
1353 &dir.path().join(".kranz/.gitignore"),
1354 "missions/\nconfig.json\n",
1355 );
1356 git(
1357 dir.path(),
1358 &["update-index", "--fsmonitor-valid", ".kranz/.gitignore"],
1359 );
1360
1361 let index_tag = Command::new("git")
1367 .arg("-C")
1368 .arg(dir.path())
1369 .args(["ls-files", "-f", "--", ".kranz/.gitignore"])
1370 .output()
1371 .unwrap();
1372 if String::from_utf8_lossy(&index_tag.stdout) != "h .kranz/.gitignore\n" {
1373 eprintln!("this git does not honor --fsmonitor-valid; skipping");
1374 return;
1375 }
1376 let hidden_diff = Command::new("git")
1377 .arg("-C")
1378 .arg(dir.path())
1379 .args(["diff", "--quiet", "HEAD", "--", ".kranz/.gitignore"])
1380 .status()
1381 .unwrap();
1382 if !hidden_diff.success() {
1383 eprintln!("this git does not hide fsmonitor-valid worktree bytes; skipping");
1384 return;
1385 }
1386 assert!(
1387 !git_ignores(dir.path(), ".kranz/config.json"),
1388 "readiness trusted a .gitignore hidden by fsmonitor-valid"
1389 );
1390 }
1391
1392 #[test]
1393 fn merge_gates_dimension_requires_a_tracked_suite() {
1394 let dir = TempDir::new().unwrap();
1395 git(dir.path(), &["init"]);
1396 write(&dir.path().join("README.md"), "readme");
1397 commit_all(dir.path());
1398 write(
1401 &dir.path().join(".kranz/merge-gates.json"),
1402 r#"{"gates":[{"command":"cargo test --workspace"}]}"#,
1403 );
1404
1405 let gates = merge_gates(dir.path());
1406 assert_eq!(gates.status, ReadyStatus::Fail, "{gates:?}");
1407 assert!(gates.evidence.contains("no tracked"), "{gates:?}");
1408 assert!(
1409 gates.remedy.contains(".kranz/merge-gates.json"),
1410 "{gates:?}"
1411 );
1412 }
1413
1414 #[test]
1415 fn merge_gates_dimension_rejects_unparseable_suites() {
1416 let dir = TempDir::new().unwrap();
1417 git(dir.path(), &["init"]);
1418 write(&dir.path().join(".kranz/merge-gates.json"), "not json");
1419 commit_all(dir.path());
1420
1421 let gates = merge_gates(dir.path());
1422 assert_eq!(gates.status, ReadyStatus::Fail, "{gates:?}");
1423 assert!(
1424 gates.evidence.contains("invalid .kranz/merge-gates.json"),
1425 "{gates:?}"
1426 );
1427 assert!(!gates.remedy.is_empty(), "{gates:?}");
1428 }
1429
1430 #[test]
1431 fn merge_gates_dimension_passes_on_a_committed_valid_suite() {
1432 let dir = TempDir::new().unwrap();
1433 git(dir.path(), &["init"]);
1434 write(
1435 &dir.path().join(".kranz/merge-gates.json"),
1436 r#"{"gates":[{"command":"cargo test --workspace"}]}"#,
1437 );
1438 commit_all(dir.path());
1439
1440 let gates = merge_gates(dir.path());
1441 assert_eq!(gates.status, ReadyStatus::Pass, "{gates:?}");
1442 assert!(gates.evidence.contains("1 gate"), "{gates:?}");
1443 }
1444
1445 #[test]
1446 fn backend_lanes_probe_only_backends_some_role_selects() {
1447 let cfg = kranz_engine::types::MissionConfig::default();
1450 let lanes = backend_lanes_for_config(&cfg);
1451 assert!(!lanes.evidence.contains("claude=skipped"), "{lanes:?}");
1452 assert!(lanes.evidence.contains("codex=skipped"), "{lanes:?}");
1453 assert!(lanes.evidence.contains("droid=skipped"), "{lanes:?}");
1454 assert!(lanes.evidence.contains("kimi=skipped"), "{lanes:?}");
1455 }
1456
1457 fn host_config(dir: &Path, repos: serde_json::Value) -> PathBuf {
1462 let path = dir.join("config.json");
1463 write(
1466 &path,
1467 &serde_json::json!({ "host": { "repos": repos } }).to_string(),
1468 );
1469 path
1470 }
1471
1472 fn repo_entry(id: &str, root: &Path) -> serde_json::Value {
1473 serde_json::json!({ "id": id, "root": root })
1474 }
1475
1476 fn git_repo(dir: &Path) {
1477 git(dir, &["init"]);
1478 write(&dir.join("README.md"), "readme");
1479 commit_all(dir);
1480 }
1481
1482 #[test]
1483 fn context_dimension_passes_when_context_lives_in_git() {
1484 let dir = TempDir::new().unwrap();
1485 git(dir.path(), &["init"]);
1486 write(&dir.path().join("README.md"), "readme");
1487 write(&dir.path().join("docs/knowledge/index.md"), "# vault");
1488 write(
1489 &dir.path().join(".kranz/merge-gates.json"),
1490 r#"{"gates":[{"command":"cargo test"}]}"#,
1491 );
1492 commit_all(dir.path());
1493
1494 let dimension = context_not_credentials(dir.path());
1495 assert_eq!(dimension.status, ReadyStatus::Pass, "{dimension:?}");
1496 assert_eq!(dimension.score, 10);
1497 }
1498
1499 #[test]
1500 fn context_dimension_flags_tracked_env_and_missing_vault() {
1501 let dir = TempDir::new().unwrap();
1502 git(dir.path(), &["init"]);
1503 write(&dir.path().join("README.md"), "readme");
1504 write(&dir.path().join(".env.production"), "SECRET=hunter2");
1505 write(
1506 &dir.path().join(".kranz/merge-gates.json"),
1507 r#"{"gates":[{"command":"cargo test"}]}"#,
1508 );
1509 commit_all(dir.path());
1510
1511 let dimension = context_not_credentials(dir.path());
1512 assert_eq!(dimension.status, ReadyStatus::Warn, "{dimension:?}");
1513 assert!(
1514 dimension.evidence.contains("knowledge vault missing"),
1515 "vault gap named: {dimension:?}"
1516 );
1517 assert!(
1518 dimension.evidence.contains(".env.production"),
1519 "the tracked env file is named in evidence: {dimension:?}"
1520 );
1521 assert!(dimension.remedy.contains("untrack"), "{dimension:?}");
1522 }
1523
1524 #[test]
1525 fn context_dimension_fails_when_nothing_is_in_place() {
1526 let dir = TempDir::new().unwrap();
1527 git(dir.path(), &["init"]);
1528 write(&dir.path().join("x.txt"), "x");
1529 commit_all(dir.path());
1530
1531 let dimension = context_not_credentials(dir.path());
1532 assert_eq!(dimension.status, ReadyStatus::Fail, "{dimension:?}");
1533 assert_eq!(dimension.score, 2, "only the env-absence signal scores");
1534 }
1535
1536 #[test]
1537 fn org_view_scores_catalog_and_counts_l3_plus() {
1538 let dir = TempDir::new().unwrap();
1539 let strong = dir.path().join("strong");
1540 let weak = dir.path().join("weak");
1541 fs::create_dir_all(&strong).unwrap();
1542 fs::create_dir_all(&weak).unwrap();
1543 write(&strong.join("AGENTS.md"), "rules");
1545 write(&strong.join("README.md"), "readme");
1546 write(&strong.join("Cargo.toml"), "[workspace]\n");
1547 write(&strong.join(".github/workflows/ci.yml"), "name: ci\n");
1548 git(&strong, &["init"]);
1549 write(
1550 &strong.join(".kranz/.gitignore"),
1551 "missions/*/events.jsonl\nmissions/*/events.jsonl.lock\nmissions/*/state.json\nmissions/*/runs/\nmissions/*/control/\nconfig.json\nserve.token\nserve.read.token\ntickets/*.status\n",
1552 );
1553 write(
1554 &strong.join(".kranz/merge-gates.json"),
1555 r#"{"gates":[{"command":"cargo test --workspace"}]}"#,
1556 );
1557 commit_all(&strong);
1558 git_repo(&weak);
1559
1560 let config = host_config(
1561 dir.path(),
1562 serde_json::json!([repo_entry("strong", &strong), repo_entry("weak", &weak)]),
1563 );
1564 let report = assess_all(&config);
1565
1566 assert_eq!(report.total, 2);
1567 assert!(report.note.is_none());
1568 let strong_row = report.repos.iter().find(|r| r.id == "strong").unwrap();
1569 let weak_row = report.repos.iter().find(|r| r.id == "weak").unwrap();
1570 assert!(
1571 strong_row.level.unwrap() >= crate::amm::AmmLevel::L3,
1572 "{strong_row:?}"
1573 );
1574 assert_eq!(
1575 weak_row.level,
1576 Some(crate::amm::AmmLevel::L1),
1577 "{weak_row:?}"
1578 );
1579 assert_eq!(report.at_l3_plus, 1, "{report:?}");
1580 let text = render_org(&report);
1582 assert!(text.contains("1 of 2 repos at L3+"), "{text}");
1583 }
1584
1585 #[test]
1586 fn org_view_degrades_unavailable_repos_with_a_reason() {
1587 let dir = TempDir::new().unwrap();
1588 let present = dir.path().join("present");
1589 fs::create_dir_all(&present).unwrap();
1590 git_repo(&present);
1591 let missing = dir.path().join("missing");
1592 let not_git = dir.path().join("not-git");
1593 fs::create_dir_all(¬_git).unwrap();
1594
1595 let mut notgit = repo_entry("notgit", ¬_git);
1596 notgit["displayName"] = serde_json::Value::String("Not Git".to_string());
1597 let config = host_config(
1598 dir.path(),
1599 serde_json::json!([
1600 repo_entry("present", &present),
1601 repo_entry("missing", &missing),
1602 notgit,
1603 ]),
1604 );
1605 let report = assess_all(&config);
1606
1607 assert_eq!(report.total, 3);
1608 let missing_row = report.repos.iter().find(|r| r.id == "missing").unwrap();
1609 assert_eq!(missing_row.unavailable.as_deref(), Some("root missing"));
1610 assert!(missing_row.level.is_none());
1611 let notgit_row = report.repos.iter().find(|r| r.id == "notgit").unwrap();
1612 assert_eq!(
1613 notgit_row.unavailable.as_deref(),
1614 Some("not a git repository")
1615 );
1616 assert_eq!(notgit_row.name, "Not Git");
1617 assert_eq!(report.at_l3_plus, 0, "{report:?}");
1619 let text = render_org(&report);
1620 assert!(text.contains("unavailable: root missing"), "{text}");
1621 }
1622
1623 #[test]
1624 fn org_view_explains_empty_and_malformed_catalogs() {
1625 let dir = TempDir::new().unwrap();
1626 let missing = assess_all(&dir.path().join("nope.json"));
1628 assert_eq!(missing.total, 0);
1629 assert!(missing.note.as_deref().unwrap().contains("no host catalog"));
1630 assert!(render_org(&missing).contains("kranz init --register"));
1631
1632 let bad = dir.path().join("bad.json");
1634 write(&bad, "{not json");
1635 let malformed = assess_all(&bad);
1636 assert_eq!(malformed.total, 0);
1637 assert!(malformed
1638 .note
1639 .as_deref()
1640 .unwrap()
1641 .contains("cannot read host catalog"));
1642 }
1643}