Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 199 additions & 129 deletions crates/travsr-cli/src/status.rs

Large diffs are not rendered by default.

29 changes: 19 additions & 10 deletions crates/travsr-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2961,15 +2961,21 @@ fn write_phase_b_results(

// H3: stamp phase_b_warnings in the meta table so `travsr status` can surface
// actionable issues without the user having to re-read init output.
//
// #760: every class name below comes from `PhaseBWarningClass`, which
// `travsr status` and the MCP `get_index_status` tool both iterate. A class
// added here that a consumer does not handle used to be dropped silently and
// could surface as a terminal `done`; now it fails their guards instead.
use travsr_plugin_host::phase_b::PhaseBWarningClass as Warn;
let mut warnings: Vec<String> = Vec::new();
for lang in &pb_outcome.crashed {
warnings.push(format!("crashed:{lang}"));
warnings.push(Warn::Crashed.entry(lang));
}
// #712: a language whose analyzer ran but produced no nodes over its source
// files. Surfaced so a silent zero-node "success" (e.g. scip-ruby invoked
// without an input path) is visible and actionable in `travsr status`.
for lang in &pb_outcome.produced_no_nodes {
warnings.push(format!("zero_nodes:{lang}"));
warnings.push(Warn::ZeroNodes.entry(lang));
}
// #724: a sidecar that returned definitions and not one occurrence. No call
// edge can be derived from it, so the run is a success with nothing
Expand All @@ -2978,40 +2984,43 @@ fn write_phase_b_results(
// `init` path defers Phase B to the daemon, so the java user this exists for
// saw nothing anywhere (#752 review).
for lang in &pb_outcome.produced_no_references {
warnings.push(format!("no_references:{lang}"));
warnings.push(Warn::NoReferences.entry(lang));
}
for (lang, expected, got) in &pb_outcome.version_mismatch {
warnings.push(format!("version_mismatch:{lang}:{expected}:{got}"));
warnings.push(format!(
"{}:{expected}:{got}",
Warn::VersionMismatch.entry(lang)
));
}
for lang in &pb_outcome.skipped_needs_approval {
warnings.push(format!("needs_approval:{lang}"));
warnings.push(Warn::NeedsApproval.entry(lang));
}
// Windows-only: an analyzer that cannot run isolated here and has no
// permission on record. Surface the exact `travsr lang allow-unsandboxed
// <lang>` fix rather than leaving the language silently absent.
for lang in &pb_outcome.skipped_needs_consent {
warnings.push(format!("needs_consent:{lang}"));
warnings.push(Warn::NeedsConsent.entry(lang));
}
// #449: a language present in the repo whose sidecar is not installed or
// not registered used to be skipped silently, and the user saw "0 references"
// with no hint that Phase B never ran. Surface both skip classes so
// `travsr status` can print the exact `travsr lang install <lang>` fix.
for lang in &pb_outcome.skipped_unregistered {
warnings.push(format!("skipped_unregistered:{lang}"));
warnings.push(Warn::SkippedUnregistered.entry(lang));
}
// #414 (ADR-017 Rule 3): a registered language whose corpus has no trust
// grant is skipped before spawn — surface the exact `travsr lang add
// <lang> --corpus <corpus>` fix.
for lang in &pb_outcome.skipped_untrusted_corpus {
warnings.push(format!("untrusted_corpus:{lang}"));
warnings.push(Warn::UntrustedCorpus.entry(lang));
}
for lang in &pb_outcome.skipped_no_analyzer {
warnings.push(format!("skipped_no_analyzer:{lang}"));
warnings.push(Warn::SkippedNoAnalyzer.entry(lang));
}
// L5a: scip-clang (c/cpp) needs a compile_commands.json — surface it the
// same way as the other user-actionable skip classes above.
for lang in &pb_outcome.skipped_no_compdb {
warnings.push(format!("skipped_no_compdb:{lang}"));
warnings.push(Warn::SkippedNoCompdb.entry(lang));
}
// E6: surface SCIP def-unification misses (orphaned twins). Positional
// span-containment makes this near-zero; a non-zero rate means Phase A
Expand Down
84 changes: 41 additions & 43 deletions crates/travsr-mcp/src/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,13 +304,14 @@ fn error_payload(reason: &str) -> serde_json::Value {
/// classes (e.g. `scip_unification_misses`, which is not per-language) are
/// ignored, they're not a language state.
///
/// The set of per-language classes handled here must stay equal to the set
/// `travsr status` matches on, which is what the shared invariant above
/// actually requires: a class present there and absent here does not merely
/// lose its wording, it silently falls through to the availability ladder and
/// can be reported as a terminal `done` (#636 round-5 review, which is how
/// `untrusted_corpus` was missed). `phase_b_warning_classes_match_the_cli`
/// pins the set, not just one string.
/// Every class the daemon writes must be handled here: one that is not does
/// not merely lose its wording, it silently falls through to the availability
/// ladder and can be reported as a terminal `done` (#636 round-5 review, which
/// is how `untrusted_corpus` was missed). #760 made that enforceable rather
/// than aspirational: the classes are the variants of
/// `travsr_plugin_host::phase_b::PhaseBWarningClass`, the daemon formats from
/// it, and `phase_b_warning_classes_match_the_cli` iterates it, so a class
/// added there and forgotten here fails the build.
///
/// `corpus` is the store's `corpus` meta, needed only by the
/// `untrusted_corpus` arm, whose remediation names the corpus to trust.
Expand Down Expand Up @@ -2378,49 +2379,46 @@ mod tests {
assert!(!detail.contains('\u{2014}'), "em-dash: {detail}");
}

/// #636 round-5 review: pinning one string's wording was not enough. The
/// invariant that actually matters is that the *set* of per-language
/// warning classes handled here equals the set `travsr status` matches
/// on. A class present there and missing here does not just lose its
/// wording: it falls through to the availability ladder and can surface
/// as a terminal `done`, which is exactly how `untrusted_corpus` was
/// missed. Every class listed here is one the daemon writes.
/// #760: every warning class the daemon can write must be decoded here.
///
/// #636 round-5 established the invariant: a class the daemon writes and
/// this function does not handle falls through to the availability ladder
/// and can surface as a terminal `done`, telling the user the language
/// succeeded when it did not. That is how `untrusted_corpus` was missed.
///
/// The guard that followed could not enforce it. It restated the class
/// names in a third hand-written list, so a class missing from BOTH that
/// list and this function was invisible: there was nothing to disagree
/// with. `zero_nodes` and `needs_consent` sat in that hole until they were
/// found by reading the producer against the consumers.
///
/// So the list is gone. This iterates `PhaseBWarningClass::ALL`, the enum
/// the daemon formats `phase_b_warnings` from, and asserts every variant
/// decodes. Adding a class to the producer and forgetting this consumer now
/// fails here. Same treatment `is_native_phase_b` got in #752: assert
/// against the real decision, not against a copy of it.
#[test]
fn phase_b_warning_classes_match_the_cli() {
// The per-language classes `travsr status` handles (status.rs).
// `scip_unification_misses` is deliberately absent: it is a repo-wide
// rate, not a per-language state, and neither surface treats it as one.
for class in [
"crashed",
"version_mismatch",
"needs_approval",
"skipped_unregistered",
"skipped_no_analyzer",
"skipped_no_compdb",
"untrusted_corpus",
"no_references",
"zero_nodes",
"needs_consent",
] {
// `version_mismatch` carries `lang:expected:got`, the rest `lang`.
let warning = if class == "version_mismatch" {
format!("{class}:go:2:1")
} else {
format!("{class}:go")
};
let decoded = decode_phase_b_warnings(&warning, "github.com/acme/repo");
use travsr_plugin_host::phase_b::PhaseBWarningClass;
// `scip_unification_misses` is deliberately not in `ALL`: it is a
// repo-wide rate, not a per-language state, and neither surface treats
// it as one.
for class in PhaseBWarningClass::ALL {
let tag = class.tag();
let decoded =
decode_phase_b_warnings(&class.sample_entry("go"), "github.com/acme/repo");
let (state, detail) = decoded.get("go").unwrap_or_else(|| {
panic!("class {class:?} is handled by travsr status but falls through here")
panic!(
"class {tag:?} is written by the daemon but falls through here, so a \
language it describes can still be reported as a terminal `done`"
)
});
assert!(
matches!(*state, "failed" | "unavailable"),
"class {class:?} must map to a terminal state, got {state:?}"
);
assert!(!detail.is_empty(), "class {class:?} must explain itself");
assert!(
!detail.contains('\u{2014}'),
"em-dash in {class:?}: {detail}"
"class {tag:?} must map to a terminal state, got {state:?}"
);
assert!(!detail.is_empty(), "class {tag:?} must explain itself");
assert!(!detail.contains('\u{2014}'), "em-dash in {tag:?}: {detail}");
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/travsr-plugin-host/src/phase_b/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
pub mod catalog;
pub mod platform;
pub mod status;
pub mod warning;
pub use catalog::{lookup, OutputFormat, PhaseBEntry, SandboxRequirement, CATALOG};
pub use platform::{full_analysis_unavailable_here, unsupported_reason};
pub use status::{capability, os_label, Capability, LangStatus};
pub use warning::PhaseBWarningClass;
150 changes: 150 additions & 0 deletions crates/travsr-plugin-host/src/phase_b/warning.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! #760: the per-language Phase B warning classes, defined once.
//!
//! The daemon stamps a language's Phase B outcome into the `phase_b_warnings`
//! meta key as comma-separated `class:lang[:extra]` entries. Three surfaces read
//! it back: `travsr status` (travsr-cli), the MCP `get_index_status` tool
//! (travsr-mcp), and that tool's guard test. Before this, each of them carried
//! its own hand-written list of the class names and nothing derived from the
//! writer, so a class added to the daemon and forgotten in a consumer was
//! silently dropped: it fell through to the availability ladder and could
//! surface as a terminal `done`, telling the user the language SUCCEEDED when it
//! did not. `zero_nodes` and `needs_consent` sat in exactly that hole, invisible
//! to the guard because the guard was itself a third hand-written list with
//! nothing to disagree with.
//!
//! So this enum is the one source. The daemon formats its entries from
//! [`PhaseBWarningClass::entry`], and each consumer's guard iterates
//! [`PhaseBWarningClass::ALL`] and asserts that consumer handles every variant.
//! Adding a class here without teaching both consumers about it fails the build,
//! and there is no third list to keep in step.
//!
//! It lives in travsr-plugin-host because that is the crate the classes actually
//! come from: every variant below is one field of
//! [`PhaseBOutcome`](crate::PhaseBOutcome), which this crate produces, and it is
//! the only crate all three surfaces already depend on (travsr-daemon depends on
//! travsr-mcp, so the daemon itself cannot hold a definition travsr-mcp reads).
//! The shape follows [`LangStatus::tag`](super::status::LangStatus::tag): a
//! stable machine tag per variant, never reworded.

/// One per-language Phase B warning class.
///
/// Repo-wide diagnostics that are not a per-language state are deliberately not
/// here: `scip_unification_misses` is a missed/attempted rate for the whole
/// index, and neither consumer treats it as a language's status, so putting it
/// in [`ALL`](Self::ALL) would force both guards to assert something false.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhaseBWarningClass {
/// The analyzer was found and spawned but died or errored mid-invoke.
Crashed,
/// #712: the analyzer ran cleanly and produced no graph output at all, even
/// though the language is present in the repo.
ZeroNodes,
/// #724: the analyzer returned definitions and not one occurrence, so no
/// call edge can be derived from it.
NoReferences,
/// The sidecar speaks a different plugin protocol version than expected.
/// The only class that carries extra fields: `lang:expected:got`.
VersionMismatch,
/// Vestigial since elevated access became auto-granted for local use
/// (ADR-017 Amendment A5). This build never writes it, but a pre-upgrade
/// index can still hold it in stored meta, so both consumers still decode it.
NeedsApproval,
/// Windows only: the analyzer cannot run inside Travsr's isolation and the
/// user has not granted permission to run it with their own privileges.
NeedsConsent,
/// #449: the language is present in the repo but not registered in lang.toml.
SkippedUnregistered,
/// #414 (ADR-017 Rule 3): registered globally, but this repository's corpus
/// has no trust grant, so the sidecar was never spawned.
UntrustedCorpus,
/// Registered, but the analyzer binary could not be resolved.
SkippedNoAnalyzer,
/// L5a: scip-clang (c/cpp) needs a `compile_commands.json` at the repo root
/// and there is none.
SkippedNoCompdb,
}

impl PhaseBWarningClass {
/// Every class the daemon can write. The guards in travsr-cli and travsr-mcp
/// iterate this, so a variant added here must be handled by both.
pub const ALL: [PhaseBWarningClass; 10] = [
PhaseBWarningClass::Crashed,
PhaseBWarningClass::ZeroNodes,
PhaseBWarningClass::NoReferences,
PhaseBWarningClass::VersionMismatch,
PhaseBWarningClass::NeedsApproval,
PhaseBWarningClass::NeedsConsent,
PhaseBWarningClass::SkippedUnregistered,
PhaseBWarningClass::UntrustedCorpus,
PhaseBWarningClass::SkippedNoAnalyzer,
PhaseBWarningClass::SkippedNoCompdb,
];

/// The stable machine tag written to `phase_b_warnings`. Never reworded: it
/// is persisted in every existing index and read back by `travsr status` and
/// the MCP tool, so it is an API surface, not UI copy.
pub fn tag(&self) -> &'static str {
match self {
PhaseBWarningClass::Crashed => "crashed",
PhaseBWarningClass::ZeroNodes => "zero_nodes",
PhaseBWarningClass::NoReferences => "no_references",
PhaseBWarningClass::VersionMismatch => "version_mismatch",
PhaseBWarningClass::NeedsApproval => "needs_approval",
PhaseBWarningClass::NeedsConsent => "needs_consent",
PhaseBWarningClass::SkippedUnregistered => "skipped_unregistered",
PhaseBWarningClass::UntrustedCorpus => "untrusted_corpus",
PhaseBWarningClass::SkippedNoAnalyzer => "skipped_no_analyzer",
PhaseBWarningClass::SkippedNoCompdb => "skipped_no_compdb",
}
}

/// The `class:lang` entry the daemon writes for `lang`.
/// [`VersionMismatch`](Self::VersionMismatch) appends `:expected:got` to this.
pub fn entry(&self, lang: &str) -> String {
format!("{}:{lang}", self.tag())
}

/// A complete, well-formed entry for `lang`, including whatever extra fields
/// the class carries. Exists so a consumer guard can iterate
/// [`ALL`](Self::ALL) and feed each class a decodable entry without keeping a
/// second list of which classes carry what, which is the drift #760 removed.
pub fn sample_entry(&self, lang: &str) -> String {
match self {
PhaseBWarningClass::VersionMismatch => format!("{}:2:1", self.entry(lang)),
_ => self.entry(lang),
}
}
}

#[cfg(test)]
mod tests {
use super::*;

/// #760: `ALL` is what both consumer guards iterate, so a duplicated or
/// missing tag would quietly weaken them rather than fail loudly here.
#[test]
fn every_class_has_a_distinct_tag_and_a_decodable_sample() {
let tags: std::collections::HashSet<&str> =
PhaseBWarningClass::ALL.iter().map(|c| c.tag()).collect();
assert_eq!(
tags.len(),
PhaseBWarningClass::ALL.len(),
"two classes share a tag, so one of them can never be decoded"
);
for class in PhaseBWarningClass::ALL {
let sample = class.sample_entry("go");
let (tag, rest) = sample.split_once(':').expect("class:lang at minimum");
assert_eq!(tag, class.tag());
assert!(
rest.starts_with("go"),
"the language must follow the tag: {sample}"
);
}
// The one class with extra fields carries them in the sample, so a guard
// that only knows about `ALL` still hands it something decodable.
assert_eq!(
PhaseBWarningClass::VersionMismatch.sample_entry("go"),
"version_mismatch:go:2:1"
);
}
}
2 changes: 1 addition & 1 deletion plugin-hashes.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Auto-generated by update-plugin-hashes.sh — do not edit manually
# Format: crate_name = sha256_of_src_tree

travsr-plugin-host = 1e19ef90398f1e8f73d0cd3431e0c0fae2b56c5bf6debc5fd22f75ef275b6597
travsr-plugin-host = bd8b4ef18db733bf3128a6b273de8a975495c164ee7ae330031959ac6ff65496
travsr-plugin-protocol = d6f8d6949b3c684aa21d4742a8c9ba04ccec793bea9551d8264f4c7be901d427
travsr-plugin-sdk = 6f20b7313d11d8fa0dfafd5203ef5870be443ee30b2141687c4624b7cc5f9a2b
Loading