From 4256130207e90382d133e3a6a612ed1f56356ff2 Mon Sep 17 00:00:00 2001 From: Karn Date: Fri, 17 Jul 2026 19:57:47 +0530 Subject: [PATCH] fix: humanize search, sessions, and mutation output Reformat the text-mode output of search, sessions, and mutations show so they are scannable, and accept short id prefixes everywhere a stored id is required. JSON output is unchanged (new report fields are `#[serde(skip)]`). - search rows: abbreviated event id, compact time, project basename, plain match kind, percentage score, and a cleaned command snippet (JSON framing stripped, `[...]` match highlights preserved). - sessions: aligned columns, abbreviated session id, compact time, `~`-relative project path. - mutations show: a labeled lesson block (hypothesis, intervention, trigger selectors, evidence counts, promotion gates as percentages) before the audit trail. - percentages replace basis points in every text surface (findings support, search score, shadow precision/recall, near-miss support). - near-miss list shows only candidates with >=2 occurrences (capped at 5), prints a summary line when none recur, uses plain-language gate reasons ("needs 3+ occurrences, saw 2"), and truncates commands at word boundaries. - short id prefixes (>=6 chars past the type prefix) resolve to a unique stored id for mutations show/challenge/reject/replay/replay-draft/shadow/ shadow-draft/install/uninstall and delete session; ambiguous prefixes error with the candidates; next-step hints echo the id form the user gave. Co-Authored-By: Claude Fable 5 --- crates/autophagy-cli/src/main.rs | 858 +++++++++++++++++++++++++++--- crates/autophagy-cli/src/setup.rs | 8 +- crates/autophagy-cli/tests/cli.rs | 308 ++++++++++- 3 files changed, 1083 insertions(+), 91 deletions(-) diff --git a/crates/autophagy-cli/src/main.rs b/crates/autophagy-cli/src/main.rs index a38eadf..e6d7529 100644 --- a/crates/autophagy-cli/src/main.rs +++ b/crates/autophagy-cli/src/main.rs @@ -37,9 +37,11 @@ use autophagy_install::{ InstallError, InstallTarget, InstalledArtifact, SkillPlan, SupervisorError, materialize, plan_skill, unmaterialize, }; -use autophagy_mutations::{GenerationOutcome, equivalence_key, generate_candidates}; +use autophagy_mutations::{ + GenerationOutcome, MutationPackage, equivalence_key, generate_candidates, +}; use autophagy_patterns::{ - DetectionDiagnostics, DetectionReport, DetectorConfig, EvidencePacket, Observation, + DetectionDiagnostics, DetectionReport, DetectorConfig, EvidencePacket, Observation, UnmetGate, }; use autophagy_replay::{ CounterfactualOutcome, ExpectedAction, ReplayDraftError, ReplayEvaluationError, ReplayReport, @@ -179,6 +181,10 @@ enum Commands { }, /// Recall evidence by exact signature and/or full text with ranked explanations. + #[command( + after_help = "Text rows abbreviate event ids and clean snippets for scanning. \ + Use --output json for full event ids, raw snippets, and the complete ranking explanation." + )] Search { /// FTS5 query expression. Required unless `--signature` is supplied. #[arg(required_unless_present = "signature")] @@ -750,7 +756,7 @@ enum CommandReport { #[serde(rename = "mutations_show")] MutationShow(MutationDetails), #[serde(rename = "mutations_transition")] - MutationTransition(MutationTransitionOutcome), + MutationTransition(MutationTransitionReport), #[serde(rename = "mutations_replay")] MutationReplay(MutationReplayReport), #[serde(rename = "mutations_replay_draft")] @@ -812,12 +818,20 @@ struct DigestReport { network_used: bool, findings: Vec, observations: Vec, + // Text-only: the detector thresholds this pass ran under, used to explain in + // plain language which gate each near-threshold observation missed. Skipped + // from JSON so the machine surface stays byte-stable. + #[serde(skip)] + thresholds: DetectorConfig, } /// Build a digest report from one deterministic detection pass. Shared by the /// `digest` command and `setup`'s immediate digest so both render the same /// deterministic, model-free report through the same path. -fn digest_report(report: DetectionReport) -> Result { +fn digest_report( + report: DetectionReport, + thresholds: DetectorConfig, +) -> Result { let DetectionDiagnostics { events_scanned, sessions_scanned, @@ -834,6 +848,7 @@ fn digest_report(report: DetectionReport) -> Result { network_used: false, findings: report.findings, observations, + thresholds, }) } @@ -844,6 +859,10 @@ struct PatternsReport { candidate_signatures: usize, findings: Vec, observations: Vec, + // Text-only detector thresholds; see `DigestReport::thresholds`. Skipped from + // JSON to keep the machine surface byte-stable. + #[serde(skip)] + thresholds: DetectorConfig, } #[derive(Debug, Serialize)] @@ -881,10 +900,25 @@ struct ChallengeAssessment { note: Option, } +/// A lifecycle transition plus the id form the user supplied, so the next-step +/// hint echoes their short id rather than the resolved 64-hex identity. The +/// `requested_id` is text-only (`#[serde(skip)]`) so the JSON surface — the bare +/// [`MutationTransitionOutcome`] fields — stays byte-stable. +#[derive(Debug, Serialize)] +struct MutationTransitionReport { + #[serde(flatten)] + outcome: MutationTransitionOutcome, + #[serde(skip)] + requested_id: String, +} + #[derive(Debug, Serialize)] struct MutationReplayReport { evaluation: ReplayReport, registration: ReplayRegisterOutcome, + // Text-only: the id form the user typed, echoed by the next-step hint. + #[serde(skip)] + requested_id: String, } #[derive(Debug, Serialize)] @@ -896,6 +930,9 @@ struct MutationReplayDraftReport { no_op_scenarios: usize, unreviewed_scenarios: usize, draft: ReplaySuite, + // Text-only: the id form the user typed, echoed by the next-step hint. + #[serde(skip)] + requested_id: String, } #[derive(Debug, Serialize)] @@ -906,12 +943,18 @@ struct MutationShadowDraftReport { intervention_observations: usize, no_op_observations: usize, draft: ShadowSuite, + // Text-only: the id form the user typed, echoed by the next-step hint. + #[serde(skip)] + requested_id: String, } #[derive(Debug, Serialize)] struct MutationShadowReport { evaluation: ShadowReport, registration: ShadowRegisterOutcome, + // Text-only: the id form the user typed, echoed by the next-step hint. + #[serde(skip)] + requested_id: String, } #[derive(Debug, Serialize)] @@ -997,28 +1040,30 @@ impl CommandReport { --check equivalent-searched --check counterexamples-reviewed" )) } - Self::MutationTransition(outcome) if outcome.to_state == "challenged" => Some(format!( - "next: autophagy mutations replay-draft {} --suite replay-suite.json", - outcome.mutation_id - )), + Self::MutationTransition(report) if report.outcome.to_state == "challenged" => { + Some(format!( + "next: autophagy mutations replay-draft {} --suite replay-suite.json", + report.requested_id + )) + } Self::MutationReplayDraft(report) => Some(format!( "next: autophagy mutations replay {} --suite {} \ (after setting counterfactual_outcome for each intervention scenario)", - report.draft.mutation_id, report.path + report.requested_id, report.path )), Self::MutationReplay(report) if report.evaluation.passed => Some(format!( "next: autophagy mutations shadow-draft {} --suite shadow-suite.json", - report.evaluation.mutation_id + report.requested_id )), Self::MutationShadowDraft(report) => Some(format!( "next: autophagy mutations shadow {} --suite {} \ (after confirming intervention_would_help for each observation)", - report.draft.mutation_id, report.path + report.requested_id, report.path )), Self::MutationShadow(report) if report.evaluation.passed => Some(format!( "next: autophagy mutations install {} --repository \ --target claude-code --confirm-permissions repo-skill-write", - report.evaluation.mutation_id + report.requested_id )), _ => None, } @@ -1144,6 +1189,8 @@ enum CliError { ids: String, suite_path: String, }, + #[error("id prefix '{query}' is ambiguous; it matches {matches}")] + AmbiguousId { query: String, matches: String }, } /// The built-in reference manifest for the offline `deterministic` provider. @@ -1173,6 +1220,237 @@ fn builtin_deterministic_manifest() -> ModelManifest { } } +/// Number of digest characters kept when abbreviating a stored identifier for a +/// scannable text row. Eight hex digits is enough to stay unique in practice +/// while fitting a terminal column; the full id is always available in JSON. +const SHORT_ID_KEEP: usize = 8; + +/// Minimum characters after a type prefix (`mut_`, `ses_`) before a token is +/// treated as a resolvable short-id prefix rather than left for an exact lookup. +const MIN_SHORT_PREFIX: usize = 6; + +/// Abbreviate a prefixed identifier for a text row: keep everything up to and +/// including the final `_`, then the first [`SHORT_ID_KEEP`] characters of the +/// digest, marking the elision with a single ellipsis. `evt_claude_<64hex>` +/// becomes `evt_claude_fc1bc1d7…`; `mut_<64hex>` becomes `mut_a1b2c3d4…`. Ids +/// with a short suffix (or no `_`) are returned unchanged. +fn short_id(id: &str) -> String { + match id.rsplit_once('_') { + Some((prefix, suffix)) if suffix.chars().count() > SHORT_ID_KEEP => { + let head: String = suffix.chars().take(SHORT_ID_KEEP).collect(); + format!("{prefix}_{head}…") + } + _ => id.to_owned(), + } +} + +/// Render an RFC3339 timestamp as a compact, human-scannable time: a coarse +/// relative age within the last week (`2h ago`), otherwise an absolute +/// `YYYY-MM-DD HH:MM` in UTC. Unparseable input falls back to the raw string so +/// no information is silently dropped. +fn compact_time(timestamp: &str, now: OffsetDateTime) -> String { + let Ok(then) = OffsetDateTime::parse(timestamp, &Rfc3339) else { + return timestamp.to_owned(); + }; + let seconds = (now - then).whole_seconds(); + if (0..604_800).contains(&seconds) { + let seconds = seconds.unsigned_abs(); + return match seconds { + 0..=59 => format!("{seconds}s ago"), + 60..=3599 => format!("{}m ago", seconds / 60), + 3600..=86_399 => format!("{}h ago", seconds / 3600), + _ => format!("{}d ago", seconds / 86_400), + }; + } + let then = then.to_offset(time::UtcOffset::UTC); + format!( + "{:04}-{:02}-{:02} {:02}:{:02}", + then.year(), + u8::from(then.month()), + then.day(), + then.hour(), + then.minute() + ) +} + +/// Format a basis-points rate as a one-decimal percentage (`7411` → `74.1%`). +/// Percentages are what humans read; the underlying basis-points fields stay in +/// the spec-versioned JSON surface unchanged. +fn percent(bps: u32) -> String { + format!("{:.1}%", f64::from(bps) / 100.0) +} + +/// Rewrite an absolute project path into a `~`-relative form when it lives under +/// the user's home directory, so session rows stay short without becoming +/// ambiguous. Paths outside home (and non-UTF-8 homes) are returned unchanged. +fn shorten_project(path: &str) -> String { + if let Some(home) = directories::BaseDirs::new().and_then(|dirs| { + dirs.home_dir() + .to_str() + .map(std::string::ToString::to_string) + }) { + if let Some(rest) = path.strip_prefix(&home) { + let rest = rest.trim_start_matches('/'); + return if rest.is_empty() { + "~".to_owned() + } else { + format!("~/{rest}") + }; + } + } + path.to_owned() +} + +/// The basename (final path component) of a project path, for the compact search +/// row. Falls back to the whole string when there is no separator. +fn project_basename(path: &str) -> String { + path.rsplit(['/', '\\']) + .find(|segment| !segment.is_empty()) + .unwrap_or(path) + .to_owned() +} + +/// Human-readable label for a retrieval match kind. +fn match_kind_label(kind: autophagy_store::RetrievalMatchKind) -> &'static str { + use autophagy_store::RetrievalMatchKind::{ExactSignature, FullText, SignatureAndFullText}; + match kind { + ExactSignature => "signature", + FullText => "text", + SignatureAndFullText => "signature+text", + } +} + +/// Clean a raw FTS snippet for a scannable text row while preserving the `[…]` +/// match highlights the store inserts. +/// +/// Tool events index their input as a compact JSON object, so the raw snippet +/// reads `{"command":"mise exec -- [cargo] [test] …`. When a `command` field is +/// present its value is extracted (highlights intact); otherwise the JSON +/// framing characters are stripped so plain metadata text remains. +fn clean_snippet(snippet: &str) -> String { + if let Some(command) = extract_command_value(snippet) { + let trimmed = command.trim(); + if !trimmed.is_empty() { + return trimmed.to_owned(); + } + } + let stripped: String = snippet + .trim() + .chars() + .filter(|character| !matches!(character, '{' | '}' | '"')) + .collect(); + stripped.trim().to_owned() +} + +/// Extract the `command` field's string value from a (possibly truncated) FTS +/// snippet, keeping the `[…]` highlight markers. Returns `None` when no +/// `command` field is present. The snippet is not valid JSON — the highlight +/// brackets break parsing — so this scans structurally instead of deserializing. +fn extract_command_value(snippet: &str) -> Option { + let key = snippet.find("command")?; + let after_key = &snippet[key + "command".len()..]; + let colon = after_key.find(':')?; + let after_colon = &after_key[colon + 1..]; + let open_quote = after_colon.find('"')?; + let value_region = &after_colon[open_quote + 1..]; + let mut result = String::new(); + let mut characters = value_region.chars().peekable(); + while let Some(character) = characters.next() { + match character { + '\\' => match characters.peek() { + Some('"') => { + result.push('"'); + characters.next(); + } + Some('\\') => { + result.push('\\'); + characters.next(); + } + Some('n' | 't' | 'r') => { + result.push(' '); + characters.next(); + } + _ => result.push('\\'), + }, + // The value's closing quote ends the command; a truncated snippet + // simply runs to the end of the region. + '"' => break, + other => result.push(other), + } + } + Some(result) +} + +/// Truncate a command string at a word boundary near `limit` characters, marking +/// the elision with a single ellipsis. Never splits mid-token: it trims back to +/// the last whitespace within the budget, falling back to a hard cut only when a +/// single token already exceeds it. +fn truncate_words(text: &str, limit: usize) -> String { + if text.chars().count() <= limit { + return text.to_owned(); + } + let budget: String = text.chars().take(limit).collect(); + let cut = budget + .rfind(char::is_whitespace) + .filter(|boundary| *boundary > 0) + .unwrap_or(budget.len()); + format!("{}…", budget[..cut].trim_end()) +} + +/// Resolve a possibly-abbreviated stored identifier against the known ids. +/// +/// An exact full-id match always wins. Otherwise, for a token that starts with +/// the expected type prefix and carries at least [`MIN_SHORT_PREFIX`] characters +/// beyond it, a unique prefix match resolves to the full id; an ambiguous prefix +/// is an error listing the candidates. Anything else (too short, no match) is +/// returned unchanged so the caller's exact lookup produces its standard +/// not-found error. +fn resolve_stored_id(candidates: I, query: &str, type_prefix: &str) -> Result +where + I: IntoIterator, +{ + let ids: Vec = candidates.into_iter().collect(); + if ids.iter().any(|id| id == query) { + return Ok(query.to_owned()); + } + let specific = query + .strip_prefix(type_prefix) + .is_some_and(|rest| rest.chars().count() >= MIN_SHORT_PREFIX); + if !specific { + return Ok(query.to_owned()); + } + let mut matches: Vec = ids.into_iter().filter(|id| id.starts_with(query)).collect(); + match matches.len() { + 0 => Ok(query.to_owned()), + 1 => Ok(matches.remove(0)), + _ => { + matches.sort(); + Err(CliError::AmbiguousId { + query: query.to_owned(), + matches: truncate_ids(&matches), + }) + } + } +} + +/// Resolve a mutation id, accepting unique `mut_` short-id prefixes. +fn resolve_mutation_id(store: &EventStore, query: &str) -> Result { + let candidates = store + .list_mutations()? + .into_iter() + .map(|record| record.mutation_id); + resolve_stored_id(candidates, query, "mut_") +} + +/// Resolve a session id, accepting unique `ses_` short-id prefixes. +fn resolve_session_id(store: &EventStore, query: &str) -> Result { + let candidates = store + .list_sessions(u32::MAX)? + .into_iter() + .map(|session| session.session_id); + resolve_stored_id(candidates, query, "ses_") +} + /// Render at most the first three IDs, then summarize the remainder, keeping a /// long unreviewed list actionable without flooding the terminal. fn truncate_ids(ids: &[String]) -> String { @@ -1459,13 +1737,10 @@ fn execute( } => { let database = resolve_database_path(cli.database)?; let store = open_store(&database)?; - let report = detection::detect_cached( - &store, - project.as_deref(), - config::resolve_thresholds(leaf, thresholds, config), - recompute, - )?; - Ok(CommandReport::Digest(digest_report(report)?)) + let thresholds = config::resolve_thresholds(leaf, thresholds, config); + let report = + detection::detect_cached(&store, project.as_deref(), thresholds, recompute)?; + Ok(CommandReport::Digest(digest_report(report, thresholds)?)) } Commands::Patterns { project, @@ -1474,12 +1749,9 @@ fn execute( } => { let database = resolve_database_path(cli.database)?; let store = open_store(&database)?; - let report = detection::detect_cached( - &store, - project.as_deref(), - config::resolve_thresholds(leaf, thresholds, config), - recompute, - )?; + let thresholds = config::resolve_thresholds(leaf, thresholds, config); + let report = + detection::detect_cached(&store, project.as_deref(), thresholds, recompute)?; let DetectionDiagnostics { events_scanned, sessions_scanned, @@ -1492,6 +1764,7 @@ fn execute( candidate_signatures, findings: report.findings, observations, + thresholds, })) } Commands::Mutations { action } => { @@ -1522,9 +1795,12 @@ fn execute( let database = resolve_database_path(cli.database)?; let mut store = open_store(&database)?; match target { - DeleteTarget::Session { session_id } => Ok(CommandReport::DeleteSession( - store.delete_session(&session_id)?, - )), + DeleteTarget::Session { session_id } => { + let session_id = resolve_session_id(&store, &session_id)?; + Ok(CommandReport::DeleteSession( + store.delete_session(&session_id)?, + )) + } DeleteTarget::All { confirm } => { if confirm != "delete-all" { return Err(CliError::DeleteAllConfirmation); @@ -1861,14 +2137,19 @@ fn execute_mutation_action( })) } MutationAction::List => Ok(CommandReport::MutationList(store.list_mutations()?)), - MutationAction::Show { mutation_id } => Ok(CommandReport::MutationShow( - store.get_mutation(&mutation_id)?, - )), + MutationAction::Show { mutation_id } => { + let mutation_id = resolve_mutation_id(&store, &mutation_id)?; + Ok(CommandReport::MutationShow( + store.get_mutation(&mutation_id)?, + )) + } MutationAction::Challenge { mutation_id, checks, note, } => { + let requested_id = mutation_id.clone(); + let mutation_id = resolve_mutation_id(&store, &mutation_id)?; let completed = checks.into_iter().collect::>(); let missing = ChallengeCheck::ALL .into_iter() @@ -1894,16 +2175,29 @@ fn execute_mutation_action( note, }; Ok(CommandReport::MutationTransition( - store.challenge_mutation(&mutation_id, &serde_json::to_value(assessment)?)?, + MutationTransitionReport { + outcome: store + .challenge_mutation(&mutation_id, &serde_json::to_value(assessment)?)?, + requested_id, + }, )) } MutationAction::Reject { mutation_id, reason, - } => Ok(CommandReport::MutationTransition( - store.reject_mutation(&mutation_id, &reason)?, - )), + } => { + let requested_id = mutation_id.clone(); + let mutation_id = resolve_mutation_id(&store, &mutation_id)?; + Ok(CommandReport::MutationTransition( + MutationTransitionReport { + outcome: store.reject_mutation(&mutation_id, &reason)?, + requested_id, + }, + )) + } MutationAction::Replay { mutation_id, suite } => { + let requested_id = mutation_id.clone(); + let mutation_id = resolve_mutation_id(&store, &mutation_id)?; let suite_path = suite.to_string_lossy().into_owned(); let suite: ReplaySuite = serde_json::from_slice(&fs::read(&suite)?)?; let details = store.get_mutation(&mutation_id)?; @@ -1951,6 +2245,7 @@ fn execute_mutation_action( Ok(CommandReport::MutationReplay(MutationReplayReport { evaluation, registration, + requested_id, })) } MutationAction::ReplayDraft { @@ -1959,6 +2254,8 @@ fn execute_mutation_action( context_events, force, } => { + let requested_id = mutation_id.clone(); + let mutation_id = resolve_mutation_id(&store, &mutation_id)?; let details = store.get_mutation(&mutation_id)?; let package = serde_json::from_value(details.mutation.package)?; let events = store.list_events_for_detection(None)?; @@ -1995,6 +2292,7 @@ fn execute_mutation_action( no_op_scenarios, unreviewed_scenarios, draft, + requested_id, }, )) } @@ -2004,6 +2302,8 @@ fn execute_mutation_action( context_events, force, } => { + let requested_id = mutation_id.clone(); + let mutation_id = resolve_mutation_id(&store, &mutation_id)?; let details = store.get_mutation(&mutation_id)?; let package = serde_json::from_value(details.mutation.package)?; let events = store.list_events_for_detection(None)?; @@ -2032,10 +2332,13 @@ fn execute_mutation_action( intervention_observations, no_op_observations, draft, + requested_id, }, )) } MutationAction::Shadow { mutation_id, suite } => { + let requested_id = mutation_id.clone(); + let mutation_id = resolve_mutation_id(&store, &mutation_id)?; let suite: ShadowSuite = serde_json::from_slice(&fs::read(&suite)?)?; let details = store.get_mutation(&mutation_id)?; let package = serde_json::from_value(details.mutation.package)?; @@ -2064,6 +2367,7 @@ fn execute_mutation_action( Ok(CommandReport::MutationShadow(MutationShadowReport { evaluation, registration, + requested_id, })) } MutationAction::Install { @@ -2076,6 +2380,7 @@ fn execute_mutation_action( if confirm_permissions != "repo-skill-write" { return Err(CliError::InstallPermissionConfirmation); } + let mutation_id = resolve_mutation_id(&store, &mutation_id)?; let details = store.get_mutation(&mutation_id)?; if details.mutation.state != "shadow_passed" { return Err(StoreError::MutationStateTransition { @@ -2123,6 +2428,7 @@ fn execute_mutation_action( } } MutationAction::Uninstall { mutation_id } => { + let mutation_id = resolve_mutation_id(&store, &mutation_id)?; let audit = store.get_installation(&mutation_id)?; let details = store.get_mutation(&mutation_id)?; let package = serde_json::from_value(details.mutation.package)?; @@ -2263,18 +2569,7 @@ fn write_report( if sessions.is_empty() { writeln!(writer, "no sessions imported yet — run `autophagy setup`")?; } else { - writeln!(writer, "SESSION\tSOURCE\tEVENTS\tLAST EVENT\tPROJECT")?; - for session in sessions { - writeln!( - writer, - "{}\t{}\t{}\t{}\t{}", - session.session_id, - session.adapter, - session.event_count, - session.last_event_at, - session.project_path.as_deref().unwrap_or("-") - )?; - } + write_sessions(&mut writer, sessions)?; } } CommandReport::Search(report) => { @@ -2288,19 +2583,7 @@ fn write_report( writeln!(writer, "no retrieval matches")?; } } - for hit in &report.hits { - writeln!( - writer, - "{}\t{}\t{} bps\t{}", - hit.event_id, - hit.explanation.match_kind.as_str(), - hit.explanation.rank_score_bps, - hit.snippet - .as_deref() - .or(hit.signature.as_deref()) - .unwrap_or("-") - )?; - } + write_search_hits(&mut writer, &report.hits)?; } CommandReport::Digest(report) => write_detection( &mut writer, @@ -2310,6 +2593,7 @@ fn write_report( report.candidate_signatures, &report.findings, &report.observations, + report.thresholds, )?, CommandReport::Patterns(report) => write_detection( &mut writer, @@ -2319,6 +2603,7 @@ fn write_report( report.candidate_signatures, &report.findings, &report.observations, + report.thresholds, )?, CommandReport::MutationProposal(report) => { write_mutation_proposal(&mut writer, report)?; @@ -2341,15 +2626,16 @@ fn write_report( } } CommandReport::MutationShow(details) => { - writeln!( - writer, - "{}\t{}\t{}", - details.mutation.mutation_id, - details.mutation.state, - details.mutation.package["title"] - .as_str() - .unwrap_or("untitled") - )?; + write_mutation_lesson(&mut writer, details)?; + // The lesson block above answers "what is this?"; a blank line + // then separates it from the append-only audit trail below. + if !details.transitions.is_empty() + || !details.replays.is_empty() + || !details.shadows.is_empty() + || !details.installations.is_empty() + { + writeln!(writer)?; + } for transition in &details.transitions { writeln!( writer, @@ -2385,10 +2671,13 @@ fn write_report( )?; } } - CommandReport::MutationTransition(outcome) => writeln!( + CommandReport::MutationTransition(report) => writeln!( writer, "{}\t{} -> {}\tchanged={}", - outcome.mutation_id, outcome.from_state, outcome.to_state, outcome.changed + report.outcome.mutation_id, + report.outcome.from_state, + report.outcome.to_state, + report.outcome.changed )?, CommandReport::MutationReplay(report) => writeln!( writer, @@ -2421,11 +2710,11 @@ fn write_report( )?, CommandReport::MutationShadow(report) => writeln!( writer, - "{}\tpassed={}\tprecision={} bps · recall={} bps · {} false positives\tmutation_applied=false", + "{}\tpassed={}\tprecision={} · recall={} · {} false positives\tmutation_applied=false", report.evaluation.shadow_id, report.evaluation.passed, - report.evaluation.summary.precision_bps, - report.evaluation.summary.recall_bps, + percent(u32::from(report.evaluation.summary.precision_bps)), + percent(u32::from(report.evaluation.summary.recall_bps)), report.evaluation.summary.false_positives )?, CommandReport::MutationInstall(report) => writeln!( @@ -2492,6 +2781,7 @@ fn write_report( Ok(()) } +#[allow(clippy::too_many_arguments)] fn write_detection( writer: &mut impl Write, header: &str, @@ -2500,6 +2790,7 @@ fn write_detection( candidate_signatures: usize, findings: &[EvidencePacket], observations: &[Observation], + thresholds: DetectorConfig, ) -> io::Result<()> { writeln!( writer, @@ -2510,7 +2801,7 @@ fn write_detection( // Never a silent zero: when nothing qualified, show the strongest recurring // candidates and the exact gate each missed so the scan explains itself. if findings.is_empty() { - write_observations(writer, observations)?; + write_observations(writer, observations, candidate_signatures, thresholds)?; } Ok(()) } @@ -2545,10 +2836,10 @@ fn write_findings(writer: &mut impl Write, findings: &[EvidencePacket]) -> io::R for finding in findings { writeln!( writer, - "{}\t{}\t{} bps\t{} evidence\t{} counterexamples", + "{}\t{}\t{}\t{} evidence\t{} counterexamples", finding.finding_id, finding.title, - finding.score.score_bps, + percent(u32::from(finding.score.score_bps)), finding.evidence.len(), finding.counterexamples.len() )?; @@ -2556,29 +2847,280 @@ fn write_findings(writer: &mut impl Write, findings: &[EvidencePacket]) -> io::R Ok(()) } -fn write_observations(writer: &mut impl Write, observations: &[Observation]) -> io::Result<()> { - if observations.is_empty() { +/// Minimum occurrences a candidate must recur before it is worth surfacing as a +/// near-threshold observation. Below this a signature is effectively a one-off, +/// and listing several such giants only buried the real signal. +const NEAR_MISS_MIN_OCCURRENCES: u32 = 2; + +/// Most near-threshold observations to list, keeping the digest scannable. +const NEAR_MISS_LIMIT: usize = 5; + +/// Character budget for a near-miss command title before word-boundary +/// truncation, chosen to fit a terminal line alongside its statistics. +const NEAR_MISS_TITLE_BUDGET: usize = 72; + +fn write_observations( + writer: &mut impl Write, + observations: &[Observation], + candidate_signatures: usize, + thresholds: DetectorConfig, +) -> io::Result<()> { + // Only genuinely recurring candidates are worth a reviewer's attention. A + // one-occurrence signature is noise, however large its command; showing five + // of them buried the digest, so they are filtered out entirely. + let recurring: Vec<&Observation> = observations + .iter() + .filter(|observation| observation.score.occurrences >= NEAR_MISS_MIN_OCCURRENCES) + .take(NEAR_MISS_LIMIT) + .collect(); + if recurring.is_empty() { + writeln!( + writer, + "{candidate_signatures} candidate signatures, none recurring — nothing near threshold" + )?; return Ok(()); } writeln!( writer, "near-threshold observations (recurring candidates, not findings):" )?; - for observation in observations { + for observation in recurring { writeln!( writer, - "{}\t{} occ · {} sessions · {} counterexamples · {} bps support\tunmet: {}", - observation.title, + "{}\t{} occ · {} sessions · {} counterexamples · {} support\t{}", + truncate_words(&observation.title, NEAR_MISS_TITLE_BUDGET), observation.score.occurrences, observation.score.distinct_sessions, observation.score.counterexamples, - observation.score.support_ratio_bps, - observation.unmet_gate.as_str() + percent(u32::from(observation.score.support_ratio_bps)), + unmet_gate_reason(observation, thresholds), + )?; + } + Ok(()) +} + +/// Plain-language explanation of the gate a near-threshold candidate missed, +/// naming both the required and observed value (`needs 3+ occurrences, saw 2`) +/// in place of the internal gate identifier. +fn unmet_gate_reason(observation: &Observation, thresholds: DetectorConfig) -> String { + let score = &observation.score; + match observation.unmet_gate { + UnmetGate::MinOccurrences => format!( + "needs {}+ occurrences, saw {}", + thresholds.min_occurrences, score.occurrences + ), + UnmetGate::MinSessions => format!( + "needs {}+ sessions, saw {}", + thresholds.min_sessions, score.distinct_sessions + ), + UnmetGate::MinSupportRatio => format!( + "needs {} support, saw {}", + percent(u32::from(thresholds.min_support_ratio_bps)), + percent(u32::from(score.support_ratio_bps)) + ), + } +} + +/// Render the session list as aligned, scannable columns: abbreviated session +/// ids, compact times, event counts, and `~`-relative project paths. Full ids +/// and full timestamps remain available under `--output json`. +fn write_sessions(writer: &mut impl Write, sessions: &[SessionSummary]) -> io::Result<()> { + let now = OffsetDateTime::now_utc(); + let ids: Vec = sessions + .iter() + .map(|session| short_id(&session.session_id)) + .collect(); + let sources: Vec<&str> = sessions + .iter() + .map(|session| session.adapter.as_str()) + .collect(); + let events: Vec = sessions + .iter() + .map(|session| session.event_count.to_string()) + .collect(); + let lasts: Vec = sessions + .iter() + .map(|session| compact_time(&session.last_event_at, now)) + .collect(); + let projects: Vec = sessions + .iter() + .map(|session| { + session + .project_path + .as_deref() + .map_or_else(|| "-".to_owned(), shorten_project) + }) + .collect(); + + let width = |header: &str, column: &[String]| { + column + .iter() + .map(String::len) + .chain(std::iter::once(header.len())) + .max() + .unwrap_or(0) + }; + let id_w = width("SESSION", &ids); + let src_w = sources + .iter() + .map(|source| source.len()) + .chain(std::iter::once("SOURCE".len())) + .max() + .unwrap_or(0); + let evt_w = width("EVENTS", &events); + let last_w = width("LAST EVENT", &lasts); + + writeln!( + writer, + "{:evt_w$} {:evt_w$} {: io::Result<()> { + let now = OffsetDateTime::now_utc(); + let ids: Vec = hits.iter().map(|hit| short_id(&hit.event_id)).collect(); + let times: Vec = hits + .iter() + .map(|hit| compact_time(&hit.occurred_at, now)) + .collect(); + let projects: Vec = hits + .iter() + .map(|hit| { + hit.project + .as_deref() + .map_or_else(|| "-".to_owned(), project_basename) + }) + .collect(); + let kinds: Vec<&str> = hits + .iter() + .map(|hit| match_kind_label(hit.explanation.match_kind)) + .collect(); + let scores: Vec = hits + .iter() + .map(|hit| percent(hit.explanation.rank_score_bps)) + .collect(); + let snippets: Vec = hits + .iter() + .map(|hit| { + hit.snippet + .as_deref() + .map(clean_snippet) + .or_else(|| hit.signature.clone()) + .unwrap_or_else(|| "-".to_owned()) + }) + .collect(); + + let column_width = |column: &[String]| column.iter().map(String::len).max().unwrap_or(0); + let id_w = column_width(&ids); + let time_w = column_width(×); + let project_w = column_width(&projects); + let kind_w = kinds.iter().map(|kind| kind.len()).max().unwrap_or(0); + let score_w = column_width(&scores); + + for index in 0..hits.len() { + writeln!( + writer, + "{:score_w$} {}", + ids[index], times[index], projects[index], kinds[index], scores[index], snippets[index] + )?; + } + Ok(()) +} + +/// Width of the label column in the `mutations show` lesson block. +const LESSON_LABEL: usize = 13; + +/// Render the mutation package as a compact, labeled lesson block: the title, +/// state, falsifiable hypothesis, proposed intervention, trigger selectors, +/// evidence/counterexample counts, and promotion gates as percentages. This +/// answers the reviewer's core question — "what is this lesson?" — that the raw +/// header-plus-transitions rendering left to the JSON surface. +fn write_mutation_lesson(writer: &mut impl Write, details: &MutationDetails) -> io::Result<()> { + let record = &details.mutation; + let row = |label: &str, value: &str| format!("{label:(record.package.clone()) { + Ok(package) => { + writeln!( + writer, + "{}", + row("hypothesis", &package.hypothesis.statement) + )?; + writeln!( + writer, + "{}", + row("intervention", &package.intervention.instruction) + )?; + let selectors = package + .triggers + .iter() + .map(|trigger| trigger.selector.as_str()) + .collect::>() + .join(", "); + let selectors = if selectors.is_empty() { + "none".to_owned() + } else { + selectors + }; + writeln!(writer, "{}", row("triggers", &selectors))?; + writeln!( + writer, + "{}", + row( + "evidence", + &format!( + "{} events · {} counterexamples", + package.hypothesis.supporting_event_ids.len(), + package.hypothesis.counterexample_event_ids.len() + ) + ) + )?; + writeln!( + writer, + "{}", + row( + "gates", + &format!( + "{}+ replays · {} success · ≤{} false positives", + package.promotion.minimum_replays, + percent(u32::from(package.promotion.minimum_success_rate_bps)), + percent(u32::from(package.promotion.maximum_false_positive_rate_bps)), + ) + ) + )?; + } + Err(_) => { + // The stored package should always deserialize; if a future contract + // makes it momentarily unreadable, still show the header rather than + // failing the whole command. + writeln!( + writer, + "{}", + row("note", "package detail unavailable; see --output json") + )?; + } + } + Ok(()) +} + fn write_mutation_proposal( writer: &mut impl Write, report: &MutationProposalReport, @@ -2911,3 +3453,151 @@ fn write_import_summary(writer: &mut impl Write, summary: &ImportSummary) -> io: } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ + MIN_SHORT_PREFIX, clean_snippet, percent, project_basename, resolve_stored_id, short_id, + shorten_project, truncate_words, + }; + + const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + #[test] + fn short_id_keeps_prefix_and_eight_digest_chars() { + assert_eq!( + short_id("evt_claude_fc1bc1d7aaaaaaaaaaaaaaaa"), + "evt_claude_fc1bc1d7…" + ); + assert_eq!( + short_id("ses_claude_d19bfbd8bbbbbbbbbbbbbbbb"), + "ses_claude_d19bfbd8…" + ); + assert_eq!(short_id(&format!("mut_{HASH}")), "mut_01234567…"); + } + + #[test] + fn short_id_leaves_already_short_ids_untouched() { + // Suffix at or below the keep width, or no separator at all. + assert_eq!(short_id("evt_cli_failure"), "evt_cli_failure"); + assert_eq!(short_id("ses_demo"), "ses_demo"); + assert_eq!(short_id("bare"), "bare"); + } + + #[test] + fn percent_renders_basis_points_to_one_decimal() { + assert_eq!(percent(7411), "74.1%"); + assert_eq!(percent(5000), "50.0%"); + assert_eq!(percent(10_000), "100.0%"); + assert_eq!(percent(0), "0.0%"); + } + + #[test] + fn clean_snippet_extracts_command_and_preserves_highlights() { + let raw = r#"{"command":"mise exec -- [cargo] [test] -p autophagy-store"}"#; + assert_eq!( + clean_snippet(raw), + "mise exec -- [cargo] [test] -p autophagy-store" + ); + } + + #[test] + fn clean_snippet_handles_truncated_command_payload() { + // A snippet cut off mid-value (no closing quote) still yields the text. + let raw = r#"{"command":"mise exec -- [cargo] test -p autophagy"#; + assert_eq!(clean_snippet(raw), "mise exec -- [cargo] test -p autophagy"); + } + + #[test] + fn clean_snippet_strips_json_framing_without_a_command_field() { + let raw = r#"{"path":"/workspace/[demo]"}"#; + // No command field: JSON framing is stripped, highlights preserved. + assert_eq!(clean_snippet(raw), "path:/workspace/[demo]"); + } + + #[test] + fn clean_snippet_leaves_plain_text_alone() { + assert_eq!(clean_snippet("cargo [test] failed"), "cargo [test] failed"); + } + + #[test] + fn truncate_words_cuts_at_a_word_boundary() { + let text = "mise exec -- cargo test --workspace --all-features some-really-long-token"; + let truncated = truncate_words(text, 30); + assert!( + truncated.ends_with('…'), + "truncation marks elision: {truncated}" + ); + assert!( + !truncated.contains("--all-features"), + "must not keep a token past the budget: {truncated}" + ); + // The kept portion is a clean whole-token prefix, never mid-word. + let kept = truncated.trim_end_matches('…'); + assert!(text.starts_with(kept), "kept portion is a clean prefix"); + assert!(!kept.ends_with('-'), "no dangling partial token: {kept}"); + } + + #[test] + fn truncate_words_leaves_short_text_unchanged() { + assert_eq!(truncate_words("cargo test", 40), "cargo test"); + } + + #[test] + fn shorten_project_and_basename() { + assert_eq!(project_basename("/workspace/demo/repo"), "repo"); + assert_eq!(project_basename("repo"), "repo"); + // A non-home path is returned verbatim (unambiguous). + assert_eq!(shorten_project("/opt/things/x"), "/opt/things/x"); + } + + #[test] + fn resolve_stored_id_matches_a_unique_prefix() { + let ids = vec![format!("mut_{HASH}"), "mut_ff00ff00deadbeef".to_owned()]; + let resolved = + resolve_stored_id(ids.clone(), "mut_012345", "mut_").expect("unique prefix resolves"); + assert_eq!(resolved, format!("mut_{HASH}")); + } + + #[test] + fn resolve_stored_id_passes_through_an_exact_full_id() { + let full = format!("mut_{HASH}"); + let ids = vec![full.clone(), "mut_ff00ff00deadbeef".to_owned()]; + assert_eq!( + resolve_stored_id(ids, &full, "mut_").expect("exact id resolves"), + full + ); + } + + #[test] + fn resolve_stored_id_reports_an_ambiguous_prefix() { + let ids = vec!["mut_abcdef001111".to_owned(), "mut_abcdef002222".to_owned()]; + let error = + resolve_stored_id(ids, "mut_abcdef", "mut_").expect_err("an ambiguous prefix errors"); + let rendered = error.to_string(); + assert!( + rendered.contains("ambiguous"), + "message names ambiguity: {rendered}" + ); + assert!( + rendered.contains("mut_abcdef001111") && rendered.contains("mut_abcdef002222"), + "message lists the candidates: {rendered}" + ); + } + + #[test] + fn resolve_stored_id_passes_missing_and_too_short_prefixes_through() { + let ids = vec![format!("mut_{HASH}")]; + // No match: returned unchanged so the caller's exact lookup 404s. + assert_eq!( + resolve_stored_id(ids.clone(), "mut_ffffff", "mut_").expect("no match passes through"), + "mut_ffffff" + ); + // Below the minimum prefix length: not treated as a resolvable prefix. + let short = format!("mut_{}", &HASH[..MIN_SHORT_PREFIX - 1]); + assert_eq!( + resolve_stored_id(ids, &short, "mut_").expect("too-short passes through"), + short + ); + } +} diff --git a/crates/autophagy-cli/src/setup.rs b/crates/autophagy-cli/src/setup.rs index d4a9f8a..9d3d330 100644 --- a/crates/autophagy-cli/src/setup.rs +++ b/crates/autophagy-cli/src/setup.rs @@ -359,12 +359,10 @@ pub fn run( // still shows the scan stats and near-threshold observations rather than a // silent nothing. ui.say(""); - let digest = digest_report(crate::detection::detect_cached( - &store, - None, + let digest = digest_report( + crate::detection::detect_cached(&store, None, DetectorConfig::default(), false)?, DetectorConfig::default(), - false, - )?)?; + )?; let digest_events_scanned = digest.events_scanned; let digest_findings = digest.findings.len(); let digest_observations = digest.observations.len(); diff --git a/crates/autophagy-cli/tests/cli.rs b/crates/autophagy-cli/tests/cli.rs index 0360fd3..4ae4dda 100644 --- a/crates/autophagy-cli/tests/cli.rs +++ b/crates/autophagy-cli/tests/cli.rs @@ -1194,8 +1194,12 @@ fn setup_digest_surfaces_scan_stats_and_observations_when_nothing_qualifies() { "zero-finding digest must surface observations:\n{stdout}" ); assert!( - stdout.contains("unmet: min_occurrences"), - "each observation must name the exact gate it missed:\n{stdout}" + stdout.contains("needs 3+ occurrences, saw 2"), + "each observation must name the missed gate in plain language:\n{stdout}" + ); + assert!( + !stdout.contains("bps"), + "the digest must speak in percentages, not basis points:\n{stdout}" ); } @@ -2214,6 +2218,306 @@ fn mutation_pipeline_ergonomics_are_smooth_end_to_end() { ); } +/// One failing session whose `tool.input` is a command object with a long, +/// abbreviatable event id — exercises snippet cleaning and short-id rendering. +const COMMAND_JSONL: &str = concat!( + "{\"spec_version\":\"aep/0.1\",\"event_id\":\"evt_generic_1111aaaa2222bbbb\",", + "\"session_id\":\"ses_generic_9999cccc8888dddd\",\"timestamp\":\"2026-07-16T10:00:00Z\",", + "\"sequence\":0,\"source\":\"generic-jsonl\",\"type\":\"session.started\",", + "\"project\":\"/workspace/demo\"}\n", + "{\"spec_version\":\"aep/0.1\",\"event_id\":\"evt_generic_3333eeee4444ffff\",", + "\"session_id\":\"ses_generic_9999cccc8888dddd\",\"timestamp\":\"2026-07-16T10:01:00Z\",", + "\"sequence\":1,\"source\":\"generic-jsonl\",\"type\":\"tool.failed\",", + "\"project\":\"/workspace/demo\",\"tool\":{\"name\":\"bash\",", + "\"input\":{\"command\":\"mise exec -- cargo test -p autophagy-store\"},\"exit_code\":1}}\n" +); + +/// A single session with two distinct one-off command failures: candidate +/// signatures exist, but none recurs, so the digest must print its summary line +/// instead of listing one-occurrence giants. +const NONRECURRING_JSONL: &str = concat!( + "{\"spec_version\":\"aep/0.1\",\"event_id\":\"evt_nr_start\",", + "\"session_id\":\"ses_nr\",\"timestamp\":\"2026-07-16T10:00:00Z\",", + "\"sequence\":0,\"source\":\"generic-jsonl\",\"type\":\"session.started\",", + "\"project\":\"/workspace/demo\"}\n", + "{\"spec_version\":\"aep/0.1\",\"event_id\":\"evt_nr_a\",", + "\"session_id\":\"ses_nr\",\"timestamp\":\"2026-07-16T10:01:00Z\",", + "\"sequence\":1,\"source\":\"generic-jsonl\",\"type\":\"tool.failed\",", + "\"project\":\"/workspace/demo\",\"tool\":{\"name\":\"bash\",", + "\"input\":{\"command\":\"cargo build --release\"},\"exit_code\":1}}\n", + "{\"spec_version\":\"aep/0.1\",\"event_id\":\"evt_nr_b\",", + "\"session_id\":\"ses_nr\",\"timestamp\":\"2026-07-16T10:02:00Z\",", + "\"sequence\":2,\"source\":\"generic-jsonl\",\"type\":\"tool.failed\",", + "\"project\":\"/workspace/demo\",\"tool\":{\"name\":\"bash\",", + "\"input\":{\"command\":\"npm run lint\"},\"exit_code\":1}}\n" +); + +/// Search text rows must be scannable — abbreviated event id, percentage score, +/// and a cleaned command snippet — while `--output json` keeps the full event +/// id, the raw snippet, and the basis-points ranking fields byte-stable. +#[test] +fn search_text_humanizes_rows_while_json_keeps_full_fields() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("search.db"); + let input = directory.path().join("events.jsonl"); + fs::write(&input, COMMAND_JSONL).expect("write fixture"); + run_json( + &database, + [ + "import", + input.to_str().expect("UTF-8 path"), + "--instance-key", + "fixture:search", + "--index-tool-input", + ], + ); + + let output = command(&database) + .args(["search", "cargo"]) + .output() + .expect("run search"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout"); + assert!( + stdout.contains("evt_generic_3333eeee…"), + "search rows must abbreviate the event id:\n{stdout}" + ); + assert!( + !stdout.contains("evt_generic_3333eeee4444ffff"), + "the full event id must not appear in text rows:\n{stdout}" + ); + assert!( + stdout.contains("mise exec -- ") && stdout.contains("cargo"), + "the snippet must show the cleaned command text:\n{stdout}" + ); + assert!( + !stdout.contains("{\"command\""), + "the raw JSON framing must be stripped from the snippet:\n{stdout}" + ); + assert!( + stdout.contains('%') && !stdout.contains("bps"), + "the rank score must read as a percentage, not basis points:\n{stdout}" + ); + + let search = run_json(&database, ["search", "cargo"]); + let hit = &search["result"][0]; + assert_eq!( + hit["event_id"], "evt_generic_3333eeee4444ffff", + "JSON keeps the full event id" + ); + assert!( + hit["snippet"] + .as_str() + .expect("snippet") + .contains("\"command\""), + "JSON keeps the raw snippet unchanged: {hit}" + ); + assert!( + hit["explanation"]["rank_score_bps"].is_number(), + "JSON keeps the spec-versioned basis-points field: {hit}" + ); +} + +/// Session text rows must align without raw tabs, abbreviate the session id, and +/// print a compact timestamp; JSON keeps the full session id and RFC3339 time. +#[test] +fn sessions_text_aligns_and_compacts_while_json_stays_stable() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("sessions.db"); + let input = directory.path().join("events.jsonl"); + fs::write(&input, COMMAND_JSONL).expect("write fixture"); + run_json( + &database, + [ + "import", + input.to_str().expect("UTF-8 path"), + "--instance-key", + "fixture:sessions", + ], + ); + + let output = command(&database) + .arg("sessions") + .output() + .expect("run sessions"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout"); + assert!( + !stdout.contains('\t'), + "aligned columns must not use raw tabs:\n{stdout}" + ); + assert!( + stdout.contains("ses_generic_9999cccc…"), + "the session id must be abbreviated:\n{stdout}" + ); + assert!( + !stdout.contains("2026-07-16T10:01:00Z"), + "the timestamp must be compact (relative or short-absolute), not raw RFC3339:\n{stdout}" + ); + + let sessions = run_json(&database, ["sessions"]); + assert_eq!( + sessions["result"][0]["session_id"], "ses_generic_9999cccc8888dddd", + "JSON keeps the full session id" + ); + assert_eq!( + sessions["result"][0]["last_event_at"], "2026-07-16T10:01:00Z", + "JSON keeps the full RFC3339 timestamp" + ); +} + +/// A zero-finding digest with no recurring candidate prints a single summary +/// line rather than a wall of one-occurrence signatures. +#[test] +fn digest_summarizes_when_no_candidate_recurs() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("digest.db"); + let input = directory.path().join("events.jsonl"); + fs::write(&input, NONRECURRING_JSONL).expect("write fixture"); + run_json( + &database, + [ + "import", + input.to_str().expect("UTF-8 path"), + "--instance-key", + "fixture:digest", + "--index-tool-input", + ], + ); + + let output = command(&database) + .arg("digest") + .output() + .expect("run digest"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout"); + assert!( + stdout.contains("none recurring — nothing near threshold"), + "a non-recurring scan must print the summary line:\n{stdout}" + ); + assert!( + !stdout.contains("near-threshold observations"), + "one-occurrence giants must not be listed:\n{stdout}" + ); +} + +/// `mutations show` text must render the lesson — hypothesis statement, +/// intervention instruction, and promotion gates as percentages — not just a +/// header. JSON keeps the full package byte-stable. +#[test] +fn mutations_show_text_renders_the_lesson() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("show.db"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../evals/fixtures/findings/deterministic.jsonl"); + run_json(&database, ["import", fixture.to_str().expect("UTF-8 path")]); + run_json(&database, ["mutations", "propose"]); + + let registry = run_json(&database, ["mutations", "list"]); + let record = registry["result"] + .as_array() + .expect("registry") + .iter() + .find(|mutation| mutation["source_detector"] == "repeated_command_failure") + .expect("failure mutation"); + let mutation_id = record["mutation_id"].as_str().expect("id").to_owned(); + + // Pull the authoritative lesson text from the JSON package. + let shown = run_json(&database, ["mutations", "show", &mutation_id]); + let statement = shown["result"]["mutation"]["package"]["hypothesis"]["statement"] + .as_str() + .expect("statement") + .to_owned(); + let instruction = shown["result"]["mutation"]["package"]["intervention"]["instruction"] + .as_str() + .expect("instruction") + .to_owned(); + + let output = command(&database) + .args(["mutations", "show", &mutation_id]) + .output() + .expect("run show"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 stdout"); + assert!( + stdout.contains("hypothesis") && stdout.contains(&statement), + "show must render the hypothesis statement:\n{stdout}" + ); + assert!( + stdout.contains("intervention") && stdout.contains(&instruction), + "show must render the intervention instruction:\n{stdout}" + ); + assert!( + stdout.contains("gates") && stdout.contains('%') && !stdout.contains("bps"), + "promotion gates must read as percentages:\n{stdout}" + ); +} + +/// A unique `mut_` prefix resolves to the full id, and the next-step hint echoes +/// the short id the user typed rather than the resolved 64-hex identity. +#[test] +fn short_mutation_id_prefix_resolves_and_hint_echoes_it() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("shortid.db"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../evals/fixtures/findings/deterministic.jsonl"); + run_json(&database, ["import", fixture.to_str().expect("UTF-8 path")]); + run_json(&database, ["mutations", "propose"]); + + let registry = run_json(&database, ["mutations", "list"]); + let record = registry["result"] + .as_array() + .expect("registry") + .iter() + .find(|mutation| mutation["source_detector"] == "repeated_command_failure") + .expect("failure mutation"); + let mutation_id = record["mutation_id"].as_str().expect("id").to_owned(); + let prefix: String = mutation_id.chars().take("mut_".len() + 10).collect(); + assert_ne!(prefix, mutation_id, "prefix must be a genuine abbreviation"); + + // A unique short prefix resolves to the same package the full id shows. + let shown = run_json(&database, ["mutations", "show", &prefix]); + assert_eq!(shown["result"]["mutation"]["mutation_id"], mutation_id); + + // The next-step hint echoes the short prefix the user supplied. + let challenged = command(&database) + .args([ + "mutations", + "challenge", + &prefix, + "--check", + "coincidence-considered", + "--check", + "sessions-comparable", + "--check", + "trigger-observable", + "--check", + "legitimate-uses-bounded", + "--check", + "equivalent-searched", + "--check", + "counterexamples-reviewed", + ]) + .output() + .expect("challenge"); + assert!(challenged.status.success()); + let stderr = String::from_utf8_lossy(&challenged.stderr); + assert!( + stderr.contains(&format!("replay-draft {prefix} ")), + "the hint must echo the short id the user typed:\n{stderr}" + ); + + // An unknown prefix still surfaces the standard not-found error. + let missing = command(&database) + .args(["mutations", "show", "mut_ffffffffffff"]) + .output() + .expect("show missing"); + assert!( + !missing.status.success(), + "an unknown id must fail, not silently resolve" + ); +} + fn run_json(database: &Path, args: [&str; N]) -> Value { let output = command(database) .args(["--output", "json"])