From 2bc7b95a0af82e156a6880314c24f59db159c2a5 Mon Sep 17 00:00:00 2001 From: forhappy Date: Fri, 2 Oct 2026 14:41:21 -0700 Subject: [PATCH 1/4] Add scoped overviews, hotspots, and graph freshness warnings --- CHANGELOG.md | 8 + COMPATIBILITY.md | 17 +- MIGRATION.md | 10 + SECURITY.md | 13 + .../compass-skill/references/operations.md | 8 + crates/compass-cli/src/call_graph_commands.rs | 14 +- crates/compass-cli/src/code_query_commands.rs | 8 +- crates/compass-cli/src/freshness.rs | 92 +++ crates/compass-cli/src/help.rs | 18 +- crates/compass-cli/src/lib.rs | 91 ++- crates/compass-cli/src/overview_commands.rs | 273 +++++++++ crates/compass-cli/src/program_commands.rs | 46 +- .../compass-cli/src/task_context_commands.rs | 1 + crates/compass-cli/tests/usability_cli.rs | 343 +++++++++++ crates/compass-core/src/freshness.rs | 383 ++++++++++++ crates/compass-core/src/lib.rs | 2 + crates/compass-mcp/src/lib.rs | 375 +++++++++-- crates/compass-mcp/src/transport.rs | 2 +- crates/compass-mcp/tests/coverage_paths.rs | 4 +- crates/compass-model/src/document.rs | 66 +- crates/compass-model/src/graph.rs | 39 ++ .../tests/traversal_confidence.rs | 2 +- crates/compass-output/src/lib.rs | 2 + crates/compass-output/src/overview.rs | 84 +++ crates/compass-query/Cargo.toml | 3 +- crates/compass-query/src/code_query.rs | 7 + crates/compass-query/src/discovery.rs | 1 + crates/compass-query/src/graph_engine.rs | 13 +- crates/compass-query/src/index.rs | 8 +- crates/compass-query/src/lib.rs | 5 + crates/compass-query/src/overview.rs | 580 ++++++++++++++++++ docs/reference/commands.md | 22 + docs/reference/outputs.md | 55 ++ 33 files changed, 2503 insertions(+), 92 deletions(-) create mode 100644 crates/compass-cli/src/freshness.rs create mode 100644 crates/compass-cli/src/overview_commands.rs create mode 100644 crates/compass-cli/tests/usability_cli.rs create mode 100644 crates/compass-core/src/freshness.rs create mode 100644 crates/compass-output/src/overview.rs create mode 100644 crates/compass-query/src/overview.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5517e3d9a..95ed686ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Add source-scoped architecture and community views, compact hotspot rankings, + and optional labelled inferred/document connection layers. +- Warn about old graph revisions and working-tree changes under the recorded + source root, including cached answers. Keep machine stdout intact and expose + versioned freshness metadata in MCP envelopes. +- Return recovery commands for missing graph/Program artifacts and function + selectors, and reject unknown Program commands before loading artifacts. + - Default typed query and MCP text to compact, source-located answers. Keep uncertainty visible and expose full paged audit detail with `--verbose` or `--evidence`; raw machine contracts are unchanged. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 3cbd2de1b..d8f17878b 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -55,7 +55,7 @@ results for the known nested-anchor limitation in that stable release. Graph query, impact and traversal caches now bind to the SHA-256 of the bounded JSON bytes read, including when file size and modification time are unchanged. Their disposable header versions advance to `TRAILG02`, `TRAILA03` and -`TRAILT07`. Typed content caches use `.content-v2.cache` and `CGRPHV02`; their +`TRAILT08`. Typed content caches use `.content-v2.cache` and `CGRPHV02`; their cache key, schema admission and decoded document come from one byte snapshot. Older cache formats are ignored and rebuilt from the graph. @@ -645,7 +645,7 @@ typed responses report truncation and an ambiguity diagnostic. Graph schemas, stored identities, and extraction are unchanged. Existing graphs can receive this lookup correction without re-extraction. -Traversal cache format `TRAILT07` retains deferred relationship flags and the +Traversal cache format `TRAILT08` retains deferred relationship flags and the weakest confidence across all evidence (introduced in `TRAILT06`), including explicit compatibility confidence. Missing or unknown confidence values in an evidence item cannot establish an exact fact. Older disposable traversal @@ -1727,3 +1727,16 @@ are bound to graph/Program IR, executable version, planner/ranker profiles, complete request and semantic mode. A changed response-cache meaning requires a new cache filename/version; missing/corrupt caches do not change native results. Neither cache rewrites published graphs or historical realizations. + +## Scoped usability and freshness + +Unscoped architecture output and existing MCP community membership defaults are +unchanged. Opt-in scoped architecture output uses the new +`compass.architecture.scoped-view/1` wrapper. New community/hotspot commands and +`get_hotspots` have explicit schemas described in `docs/reference/outputs.md`. +MCP consumers with closed outer envelopes must admit optional `freshness`. +CLI machine stdout remains unchanged; warnings use stderr and are budgeted. +Graph, Program IR and immutable historical schemas are unchanged. Disposable +traversal cache header `TRAILT08` retains the pinned build commit; older caches +are ignored and rebuilt. Missing Program IR exits with code 3 and recovery +commands; unknown Program subcommands exit with code 2 before artifact access. diff --git a/MIGRATION.md b/MIGRATION.md index 1dee4a311..55aac2364 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -5,6 +5,16 @@ sidecars. Its output root now preserves the familiar flat artifact shape so file-based workflows can transition while Compass's snapshot and store layout remains visible and clearly owned. +## Scoped views and freshness + +No rebuild is required for new scoped views or hotspot ranking. Explicitly +scoped architecture JSON has a new wrapper; read the original projection from +`result`. Unscoped JSON retains its existing shape. Strict MCP envelope decoders +must allow optional `freshness`; resource transport metadata can also contain it. +CLI JSON remains on stdout, with advisory warnings on stderr. Older traversal +caches rebuild automatically. Program signatures require `compass update --program`; +use `--program PATH` when selecting an already published artifact. + ## Compact output and budgets Typed query text is compact by default. Select `--verbose` or `--evidence` to diff --git a/SECURITY.md b/SECURITY.md index d1ffaeb55..1f5423abf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -156,3 +156,16 @@ ceiling. Lookup requires the verified graph identity and Program IR/profile/ request/mode identity, validates payload length, checksum and typed schema, and checks deadlines. Source excerpts and partial responses are excluded. Cache errors use native execution. Neither cache adds network or credential access. + +Graph freshness checks read the bounded `source-root.txt` marker beside a selected +artifact, canonicalize its absolute root, and invoke Git with separate arguments. +They do not infer the root from the caller's current directory. Each invocation +has a 500 ms deadline and bounded captured output (admitted up to 1 MiB). +Lazy object fetching, filesystem monitors, external diff programs, text +conversions and rename heuristics are disabled. Git versions without the +no-lazy-fetch option report unknown freshness. These are read-only local observations; no checkout, +hook, network fetch or graph rewrite is performed. Failed checks remain unknown. +The marked root can be outside the current project; its path appears in the +advisory recovery command and MCP freshness metadata. Shell quoting is display +only. Source-root markers are local build metadata, not a trust assertion that +arbitrary imported graph bytes describe that checkout. diff --git a/crates/compass-cli/assets/compass-skill/references/operations.md b/crates/compass-cli/assets/compass-skill/references/operations.md index 8aebf8c72..f4d25936e 100644 --- a/crates/compass-cli/assets/compass-skill/references/operations.md +++ b/crates/compass-cli/assets/compass-skill/references/operations.md @@ -64,3 +64,11 @@ commands print `compass output ID --offset N --budget N` to read the saved output without repeating the command. Saved output is worktree-local and bounded to sixteen records. Streaming `watch`, `serve`, and JSONL sessions reject this presentation control; noninteractive `init --yes` supports it. + +### hotspots + +`compass hotspots --scope app/services --include-inferred --include-documents` ranks published symbol connectivity and incoming dependents. Optional evidence layers stay labelled. + +### community + +`compass community [ID] --scope app/services` lists a scoped community window. Use `--format json` for typed results. diff --git a/crates/compass-cli/src/call_graph_commands.rs b/crates/compass-cli/src/call_graph_commands.rs index 446814430..f608f089f 100644 --- a/crates/compass-cli/src/call_graph_commands.rs +++ b/crates/compass-cli/src/call_graph_commands.rs @@ -22,7 +22,7 @@ pub(crate) fn command(frontend: Frontend, args: &[String]) -> Outcome { Err(error) => { return Outcome::failure_with_code( format!( - "error: could not resolve graph {}: {error}", + "error: could not resolve graph {}: {error}; run compass ensure or select an existing graph with --graph PATH", options.graph.display() ), 3, @@ -34,17 +34,25 @@ pub(crate) fn command(frontend: Frontend, args: &[String]) -> Outcome { Err(error) => { return Outcome::failure_with_code( format!( - "error: could not load graph {}: {error}", + "error: could not load graph {}: {error}; run compass ensure or select an existing graph with --graph PATH", options.graph.display() ), 3, ); } }; + crate::freshness::record(&resolved_graph, crate::graph_source_commit(&graph)); let analysis = match options.program { Some(path) => match crate::program_commands::load_program(&path) { Ok(analysis) => Some(analysis), - Err(error) => return Outcome::failure_with_code(format!("error: {error}"), 3), + Err(error) => { + return Outcome::failure_with_code( + format!( + "error: {error}; run compass update --program, or select an existing artifact with --program PATH" + ), + 3, + ); + } }, None => None, }; diff --git a/crates/compass-cli/src/code_query_commands.rs b/crates/compass-cli/src/code_query_commands.rs index c7b877a4a..d5be6f042 100644 --- a/crates/compass-cli/src/code_query_commands.rs +++ b/crates/compass-cli/src/code_query_commands.rs @@ -275,8 +275,7 @@ fn execute( compass_files::BuildGuard::resolve_artifact(&output, "graph.json") .map_err(|error| error.to_string())? }; - open_with_engine(&graph, program.as_deref(), &cache, engine) - .map_err(|error| error.to_string())? + open_with_engine(&graph, program.as_deref(), &cache, engine).map_err(query_error)? } .with_deadline(deadline) .with_semantic_search(args.iter().any(|arg| arg == "--semantic-search")); @@ -413,6 +412,7 @@ fn execute( } _ => unreachable!(), }; + super::freshness::record(engine.graph_path(), engine.source_commit()); let mut context = AgentQueryContext::new( response.operation.into(), engine.graph_identity().to_owned(), @@ -467,6 +467,10 @@ fn timeout(args: &[String]) -> Result { fn query_error(error: QueryError) -> String { if error.kind() == QueryErrorKind::Timeout { format!("{error}; raise --timeout-ms or lower --max-nodes/--max-edges") + } else if error.kind() == QueryErrorKind::UnsupportedSchema { + format!( + "{error}; run compass update to build artifacts with this version, or use --graph PATH for a supported graph" + ) } else { error.to_string() } diff --git a/crates/compass-cli/src/freshness.rs b/crates/compass-cli/src/freshness.rs new file mode 100644 index 000000000..70edf17e3 --- /dev/null +++ b/crates/compass-cli/src/freshness.rs @@ -0,0 +1,92 @@ +//! Request-local advisory messages collected while readers pin their graphs. +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use crate::Outcome; +use compass_core::{GraphFreshness, graph_freshness}; + +type Frame = BTreeMap>; +thread_local! { static FRAMES: RefCell> = const { RefCell::new(Vec::new()) }; } + +pub(super) struct Scope { + active: bool, +} +impl Scope { + pub(super) fn begin() -> Self { + let active = FRAMES.with(|frames| { + let mut frames = frames.borrow_mut(); + if frames.len() >= 16 { + false + } else { + frames.push(BTreeMap::new()); + true + } + }); + Self { active } + } + pub(super) fn finish(mut self, mut output: Outcome) -> Outcome { + if self.active { + let frame = FRAMES.with(|frames| frames.borrow_mut().pop()); + self.active = false; + for warning in frame + .into_iter() + .flat_map(BTreeMap::into_values) + .flatten() + .filter_map(|value| value.warning()) + { + if !output.stderr.is_empty() { + output.stderr.push('\n'); + } + output.stderr.push_str(&warning); + output.stderr_trailing_newline = true; + } + } + output + } +} +impl Drop for Scope { + fn drop(&mut self) { + if self.active { + FRAMES.with(|frames| { + frames.borrow_mut().pop(); + }); + } + } +} + +pub(super) fn record(path: &Path, commit: Option<&str>) { + record_with_mode(path, commit, false); +} + +pub(super) fn record_program(path: &Path) { + record_with_mode(path, None, true); +} + +fn record_with_mode(path: &Path, commit: Option<&str>, program: bool) { + let capture = FRAMES.with(|frames| { + let mut frames = frames.borrow_mut(); + let Some(frame) = frames.last_mut() else { + return false; + }; + if frame.len() >= 8 || frame.contains_key(path) { + return false; + } + frame.insert(path.to_owned(), None); + true + }); + if capture { + let mut report = graph_freshness(path, commit); + if program && let Some(report) = report.as_mut() { + report.update_command.push_str(" --program"); + if report.reason.as_deref() == Some("graph has no valid build commit") { + report.reason = Some("Program IR has no recorded build commit".into()); + } + } + FRAMES.with(|frames| { + if let Some(frame) = frames.borrow_mut().last_mut() { + frame.insert(path.to_owned(), report); + } + }); + } +} diff --git a/crates/compass-cli/src/help.rs b/crates/compass-cli/src/help.rs index 2dd165606..36ce1e873 100644 --- a/crates/compass-cli/src/help.rs +++ b/crates/compass-cli/src/help.rs @@ -108,6 +108,8 @@ const GROUPS: &[Group] = &[ "affected", "benchmark", "output", + "hotspots", + "community", ], }, Group { @@ -149,6 +151,18 @@ const GROUPS: &[Group] = &[ ]; const PAGES: &[Page] = &[ + page!( + "hotspots", + "Rank connected symbols and incoming dependents", + ["compass hotspots [OPTIONS]"], + "Options:\n --scope Repeatable source scope\n --limit Rows per ranking, 1-100 [default: 10]\n --include-inferred Include labelled inferred contacts\n --include-documents Include labelled document contacts\n --graph Selected graph\n --format Output format\n\nExamples:\n compass hotspots --scope app/services\n compass hotspots --include-inferred --include-documents --format json" + ), + page!( + "community", + "List scoped communities or inspect one community", + ["compass community [ID] [OPTIONS]"], + "Options:\n --scope Repeatable source scope\n --limit Community/member window, 1-100 [default: 20]\n --graph Selected graph\n --format Output format\n\nExamples:\n compass community --scope app/services\n compass community 7 --scope module:app.services --format json" + ), page!( "output", "Read the rest of an immutable saved command output", @@ -444,7 +458,7 @@ const PAGES: &[Page] = &[ "architecture", "Summarize the bounded architecture projection for agent inspection", ["compass architecture [OPTIONS]"], - "Options:\n --graph Graph JSON [default: compass-out/graph.json]\n --labels Community-label JSON\n --format Output format [default: text]\n\nExamples:\n compass architecture\n compass architecture --format agent-json\n compass architecture --graph compass-out/graph.json --format json\n\nNotes:\n Detailed JSON is capped at 5,000 nodes and 20,000 relationships. Above either cap, Compass returns exact totals, bounded kind counts, and samples from the 12 largest communities (3 nodes each), ordered deterministically by community size, community ID, and node ID. Oversized sample fields are bounded or counted as omitted in the versioned summary." + "Options:\n --scope Repeatable path/module filter (OR)\n --graph Graph JSON [default: compass-out/graph.json]\n --labels Community-label JSON\n --format Output format [default: text]\n\nExamples:\n compass architecture\n compass architecture --format agent-json\n compass architecture --graph compass-out/graph.json --format json\n\nNotes:\n Detailed JSON is capped at 5,000 nodes and 20,000 relationships. Above either cap, Compass returns exact totals, bounded kind counts, and samples from the 12 largest communities (3 nodes each), ordered deterministically by community size, community ID, and node ID. Oversized sample fields are bounded or counted as omitted in the versioned summary." ), page!( "affected", @@ -1341,7 +1355,7 @@ mod tests { #[test] fn catalog_has_unique_complete_public_roots() { let roots = root_commands(); - assert_eq!(roots.len(), 55); + assert_eq!(roots.len(), 57); for root in roots { let matches = PAGES.iter().filter(|page| page.path == root).count(); assert_eq!(matches, 1, "{root}"); diff --git a/crates/compass-cli/src/lib.rs b/crates/compass-cli/src/lib.rs index a8c83bf29..23d4b1074 100644 --- a/crates/compass-cli/src/lib.rs +++ b/crates/compass-cli/src/lib.rs @@ -6,6 +6,7 @@ mod capability_commands; mod code_query_commands; mod dedup_commands; mod document_commands; +mod freshness; mod help; mod history_batch; mod history_build; @@ -19,6 +20,7 @@ mod integration_commands; mod label_commands; mod models_commands; mod output_budget; +mod overview_commands; mod program_commands; mod provider_commands; mod prs_commands; @@ -427,6 +429,7 @@ fn write_output( #[must_use] pub fn run(frontend: Frontend, arguments: impl IntoIterator) -> Outcome { + let freshness = freshness::Scope::begin(); let mut args = arguments .into_iter() .map(|argument| argument.to_string_lossy().into_owned()) @@ -474,7 +477,7 @@ pub fn run(frontend: Frontend, arguments: impl IntoIterator) -> } } let outcome = run_unbudgeted(frontend, args.into_iter().map(OsString::from)); - output_budget::finish(outcome, &controls) + output_budget::finish(freshness.finish(outcome), &controls) } fn run_unbudgeted(frontend: Frontend, arguments: impl IntoIterator) -> Outcome { @@ -534,6 +537,8 @@ fn run_unbudgeted(frontend: Frontend, arguments: impl IntoIterator command_path(frontend, &args), "explain" => command_explain(frontend, &args), "architecture" => command_architecture(frontend, &args), + "community" => overview_commands::command("community", &args), + "hotspots" => overview_commands::command("hotspots", &args), "affected" => command_affected(&args), "export" => command_export(frontend, &args), "benchmark" => command_benchmark(&args), @@ -4113,7 +4118,7 @@ fn command_export(frontend: Frontend, args: &[String]) -> Outcome { { return Outcome::failure("error: --node-limit must be a positive integer".to_owned()); } - let mut inputs = match ExportInputs::load(&graph_path) { + let mut inputs = match load_export_inputs(&graph_path) { Ok(inputs) => inputs, Err(GraphError::NotFound(_)) => { return Outcome::failure(format!( @@ -6221,6 +6226,7 @@ fn discovery_query( .join("cache"); let engine = open_code_query(&graph, None, &cache).map_err(|error| error.to_string())?; + freshness::record(engine.graph_path(), engine.source_commit()); let graph_identity = engine.build_generation_identity().to_owned(); let graph_digest = engine.graph_identity().to_owned(); let engine = engine @@ -6255,6 +6261,7 @@ fn discovery_query( &cache, ) .map_err(|error| error.to_string())?; + freshness::record(engine.graph_path(), engine.source_commit()); let graph_identity = engine.build_generation_identity().to_owned(); let graph_digest = engine.graph_identity().to_owned(); let engine = engine @@ -6344,7 +6351,7 @@ fn command_architecture(_frontend: Frontend, args: &[String]) -> Outcome { .iter() .any(|argument| matches!(argument.as_str(), "-h" | "--help")) { - return Outcome::success("Usage: compass architecture [--graph PATH] [--labels PATH] [--format text|json|agent-json]".to_owned()); + return Outcome::success("Usage: compass architecture [--graph PATH] [--labels PATH] [--scope PATH|module:NAME] [--format text|json|agent-json]".to_owned()); } let (format, args) = match parse_shared_output_format(args, "architecture") { Ok(parsed) => parsed, @@ -6352,6 +6359,7 @@ fn command_architecture(_frontend: Frontend, args: &[String]) -> Outcome { }; let mut graph_path = default_graph_path(); let mut labels_path = None; + let mut scopes = Vec::new(); let mut index = 0; while index < args.len() { match args[index].as_str() { @@ -6366,6 +6374,19 @@ fn command_architecture(_frontend: Frontend, args: &[String]) -> Outcome { graph_path = PathBuf::from(&value[8..]); index += 1; } + "--scope" => { + let Some(value) = args.get(index + 1) else { + return Outcome::failure( + "error: --scope requires a relative path or module:NAME".to_owned(), + ); + }; + scopes.push(value.clone()); + index += 2; + } + value if value.starts_with("--scope=") => { + scopes.push(value[8..].to_owned()); + index += 1; + } "--labels" => { let Some(value) = args.get(index + 1) else { return Outcome::failure("error: --labels requires a path".to_owned()); @@ -6388,11 +6409,15 @@ fn command_architecture(_frontend: Frontend, args: &[String]) -> Outcome { } } } + let scopes = match compass_query::OverviewScope::new(scopes) { + Ok(scope) => scope.filters, + Err(error) => return Outcome::failure_with_code(format!("error: {error}"), 2), + }; let graph_path = match compass_files::BuildGuard::resolve_requested_artifact(&graph_path) { Ok(path) => path, Err(error) => return Outcome::failure(format!("error: could not resolve graph: {error}")), }; - let mut inputs = match ExportInputs::load(&graph_path) { + let mut inputs = match load_export_inputs(&graph_path) { Ok(inputs) => inputs, Err(GraphError::NotFound(_)) => { return Outcome::failure(format!( @@ -6408,6 +6433,14 @@ fn command_architecture(_frontend: Frontend, args: &[String]) -> Outcome { Err(error) => return Outcome::failure(error), } } + let selection = if scopes.is_empty() { + None + } else { + match overview_commands::apply_scope(&mut inputs, scopes) { + Ok(selection) => Some(selection), + Err(error) => return Outcome::failure(format!("error: {error}")), + } + }; let title = export_project_title(&inputs, &graph_path); let overlay = match load_architecture_overlay(None, &graph_path) { Ok(overlay) => overlay, @@ -6433,11 +6466,15 @@ fn command_architecture(_frontend: Frontend, args: &[String]) -> Outcome { )); } }; - match projection { + let output = match projection { ArchitectureProjectionOutput::Detailed(model) => render_architecture_output(format, &model), ArchitectureProjectionOutput::Summary(summary) => { render_architecture_summary_output(format, &summary) } + }; + match selection { + Some(selection) => overview_commands::scoped_output(output, &selection, format), + None => output, } } @@ -7117,6 +7154,7 @@ fn command_affected_typed( Ok(engine) => engine, Err(error) => return Some(Outcome::failure(format!("error: {error}"))), }; + freshness::record(engine.graph_path(), engine.source_commit()); let relation_kinds = relations .iter() .filter_map(|relation| affected_relation_kind(relation)) @@ -7302,7 +7340,11 @@ pub(crate) fn load_selection_full( compass_files::BuildGuard::resolve_requested_artifact(path).map_err(|error| { Outcome::failure(format!("error: could not resolve graph: {error}")) })?; - LoadedGraph::load_full_directed(&path).map_err(graph_load_outcome) + LoadedGraph::load_full_directed(&path) + .inspect(|loaded| { + freshness::record(&path, loaded.graph.source_commit()); + }) + .map_err(graph_load_outcome) } GraphSelection::Commit(revision) => { history_commands::load_graph_at(frontend, revision, true) @@ -7322,6 +7364,7 @@ pub(crate) fn load_indexed_selection( Outcome::failure(format!("error: could not resolve graph: {error}")) })?; let graph = compass_model::Graph::load_directed(&path).map_err(graph_load_outcome)?; + freshness::record(&path, graph.source_commit()); Ok(LoadedGraph { graph, overlay: HashMap::new(), @@ -7386,24 +7429,36 @@ fn load(path: &Path, force_directed: bool) -> Result { } else { LoadedGraph::load(&path) }; - result.map_err(graph_load_outcome) + result + .inspect(|loaded| { + freshness::record(&path, loaded.graph.source_commit()); + }) + .map_err(graph_load_outcome) } fn load_affected(path: &Path) -> Result { let path = compass_files::BuildGuard::resolve_requested_artifact(path) .map_err(|error| Outcome::failure(format!("error: could not resolve graph: {error}")))?; - LoadedGraph::load_for_affected(&path).map_err(graph_load_outcome) + LoadedGraph::load_for_affected(&path) + .inspect(|loaded| { + freshness::record(&path, loaded.graph.source_commit()); + }) + .map_err(graph_load_outcome) } fn graph_load_outcome(error: GraphError) -> Outcome { match error { - GraphError::NotFound(path) => { - Outcome::failure(format!("error: graph file not found: {}", path.display())) - } - GraphError::InvalidExtension(_) => { - Outcome::failure("error: graph file must be a .json file".to_owned()) - } - other => Outcome::failure(format!("error: could not load graph: {other}")), + GraphError::NotFound(path) => Outcome::failure(format!( + "error: graph file not found: {}. Run compass ensure to publish a graph, or select an existing graph with --graph PATH.", + path.display() + )), + GraphError::InvalidExtension(_) => Outcome::failure( + "error: graph file must be a .json file; select an existing graph with --graph PATH" + .to_owned(), + ), + other => Outcome::failure(format!( + "error: could not load graph: {other}. Run compass ensure to publish a fresh graph, or select a supported existing graph with --graph PATH." + )), } } @@ -8096,3 +8151,9 @@ mod mcp_option_tests { assert!(extract_help().contains("COMPASS_BACKEND/COMPASS_MODEL")); } } + +fn load_export_inputs(path: &Path) -> Result { + let inputs = ExportInputs::load(path)?; + freshness::record(path, graph_source_commit(&inputs.document)); + Ok(inputs) +} diff --git a/crates/compass-cli/src/overview_commands.rs b/crates/compass-cli/src/overview_commands.rs new file mode 100644 index 000000000..c67441d67 --- /dev/null +++ b/crates/compass-cli/src/overview_commands.rs @@ -0,0 +1,273 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use compass_core::ExportInputs; +use compass_query::{ConnectionLayers, OverviewScope, ScopeSelection, hotspots, scoped_document}; + +use crate::{Outcome, SharedOutputFormat, default_graph_path, parse_shared_output_format}; + +pub(super) fn apply_scope( + inputs: &mut ExportInputs, + filters: Vec, +) -> Result { + let scope = OverviewScope::new(filters).map_err(|error| error.to_string())?; + let (document, selection) = + scoped_document(&inputs.document, &scope).map_err(|error| error.to_string())?; + let ids = document + .nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + for members in inputs.communities.values_mut() { + members.retain(|id| ids.contains(id.as_str())); + members.sort(); + members.dedup(); + } + inputs.communities.retain(|_, members| !members.is_empty()); + inputs + .labels + .retain(|id, _| inputs.communities.contains_key(id)); + inputs.document = document; + Ok(selection) +} + +pub(super) fn scope_text(selection: &ScopeSelection) -> String { + format!( + "Scope {}: {}/{} nodes; {} outside relationships omitted ({} cross the boundary).", + if selection.scope.filters.is_empty() { + "all".into() + } else { + selection.scope.filters.join(", ") + }, + selection.selected_nodes, + selection.input_nodes, + selection.omitted_relationships, + selection.boundary_relationships + ) +} + +pub(super) fn scoped_output( + mut output: Outcome, + selection: &ScopeSelection, + format: SharedOutputFormat, +) -> Outcome { + if output.code != 0 { + return output; + } + if matches!(format, SharedOutputFormat::Text) { + output.stdout = format!("{}\n{}", scope_text(selection), output.stdout); + } else { + let result = match serde_json::from_str::(&output.stdout) { + Ok(value) => value, + Err(error) => { + return Outcome::failure(format!( + "error: scoped architecture encoding failed: {error}" + )); + } + }; + match serde_json::to_string_pretty( + &serde_json::json!({"schema":"compass.architecture.scoped-view/1", "selection":selection, "result":result}), + ) { + Ok(text) => output.stdout = text, + Err(error) => { + return Outcome::failure(format!( + "error: scoped architecture encoding failed: {error}" + )); + } + } + } + output +} + +pub(super) fn command(command: &str, args: &[String]) -> Outcome { + if args + .iter() + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) + { + let id = if command == "community" { " [ID]" } else { "" }; + let layers = if command == "hotspots" { + " [--include-inferred] [--include-documents]" + } else { + "" + }; + return Outcome::success(format!( + "Usage: compass {command}{id} [--graph PATH] [--scope PATH|module:NAME] [--limit N]{layers} [--format text|json] [--budget N]" + )); + } + let (format, args) = match parse_shared_output_format(args, command) { + Ok(value) => value, + Err(error) => return Outcome::failure_with_code(format!("error: {error}"), 2), + }; + if matches!(format, SharedOutputFormat::AgentJson) { + return Outcome::failure_with_code( + "error: --format must be text or json; run compass help community or compass help hotspots".into(), + 2, + ); + } + let mut graph = default_graph_path(); + let mut filters = Vec::new(); + let mut layers = ConnectionLayers::default(); + let mut limit = if command == "hotspots" { 10 } else { 20 }; + let mut community = None; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + let (flag, inline) = arg + .split_once('=') + .map_or((arg.as_str(), None), |(a, b)| (a, Some(b))); + match flag { + "--include-inferred" if inline.is_none() && command == "hotspots" => layers.inferred = true, + "--include-documents" if inline.is_none() && command == "hotspots" => layers.documents = true, + "--graph" | "--scope" | "--limit" => { + let value = match inline.or_else(|| { index += 1; args.get(index).map(String::as_str) }) { + Some(value) if !value.is_empty() && !value.starts_with("--") => value, + _ => return Outcome::failure_with_code(format!("error: {flag} requires a value; run compass help {command}"), 2), + }; + match flag { + "--graph" => graph = PathBuf::from(value), + "--scope" => filters.push(value.to_owned()), + _ => match value.parse::() { + Ok(value) if (1..=100).contains(&value) => limit = value, + _ => return Outcome::failure_with_code("error: --limit must be between 1 and 100".into(), 2), + }, + } + } + value if command == "community" && !value.starts_with('-') && community.is_none() => { + match value.parse::() { + Ok(value) => community = Some(value), + Err(_) => return Outcome::failure_with_code("error: community ID must be a nonnegative integer; run compass community to list IDs".into(), 2), + } + } + _ => return Outcome::failure_with_code(format!("error: unknown {command} argument {arg}; run compass help {command}"), 2), + } + index += 1; + } + let scope = match OverviewScope::new(filters) { + Ok(value) => value, + Err(error) => return Outcome::failure_with_code(format!("error: {error}"), 2), + }; + let graph = match compass_files::BuildGuard::resolve_requested_artifact(&graph) { + Ok(path) => path, + Err(error) => { + return Outcome::failure(format!( + "error: cannot select graph: {error}; run compass ensure" + )); + } + }; + if command == "hotspots" { + let document = match compass_model::GraphDocument::load(&graph) { + Ok(value) => value, + Err(error) => return crate::graph_load_outcome(error), + }; + crate::freshness::record(&graph, crate::graph_source_commit(&document)); + let report = match hotspots(&document, &scope, layers, limit) { + Ok(report) => report, + Err(error) => return Outcome::failure(format!("error: {error}")), + }; + if !matches!(format, SharedOutputFormat::Text) { + return encode(&report); + } + return Outcome::success(compass_output::render_hotspots_text(&report)); + } + let document = match compass_model::GraphDocument::load(&graph) { + Ok(value) => value, + Err(error) => return crate::graph_load_outcome(error), + }; + crate::freshness::record(&graph, crate::graph_source_commit(&document)); + let mut inputs = ExportInputs { + document, + communities: BTreeMap::new(), + labels: BTreeMap::new(), + cohesion: BTreeMap::new(), + gods: Vec::new(), + report: String::new(), + }; + // Use membership from the pinned graph, matching MCP, rather than mixing + // it with a possibly unrelated analysis sidecar. + for node in &inputs.document.nodes { + if let Some(id) = node + .unsigned("community") + .and_then(|id| usize::try_from(id).ok()) + { + inputs + .communities + .entry(id) + .or_default() + .push(node.id.clone()); + let label = node.string("community_name"); + if !label.is_empty() { + inputs + .labels + .entry(id) + .and_modify(|existing| { + if label < *existing { + *existing = label.clone(); + } + }) + .or_insert(label); + } + } + } + let selection = match apply_scope(&mut inputs, scope.filters.clone()) { + Ok(value) => value, + Err(error) => return Outcome::failure(format!("error: {error}")), + }; + let all_count = inputs.communities.len(); + let groups = inputs.communities.iter().filter(|(id,_)| community.is_none_or(|wanted| wanted == **id)) + .take(limit).map(|(id,members)| serde_json::json!({"id":id,"label":inputs.labels.get(id),"memberCount":members.len(), + "members":members.iter().take(limit).collect::>(), "omittedMembers":members.len().saturating_sub(limit)})).collect::>(); + let omitted = if community.is_some() { + 0 + } else { + all_count.saturating_sub(groups.len()) + }; + let result = serde_json::json!({"schema":"compass.communities/1", "selection":selection, "communities":groups, "omittedCommunities":omitted}); + if !matches!(format, SharedOutputFormat::Text) { + return encode(&result); + } + let nodes = inputs + .document + .nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + let mut lines = vec![scope_text(&selection)]; + if groups.is_empty() { + lines.push("No communities matched. Run compass community without --scope to list IDs, or compass cluster-only to publish community membership.".into()); + } + for group in groups { + lines.push(format!( + "Community {}: {} ({} nodes; {} members omitted)", + group["id"], + compass_query::sanitize_label(group["label"].as_str().unwrap_or("unlabelled")), + group["memberCount"], + group["omittedMembers"] + )); + if let Some(members) = group["members"].as_array() { + for id in members { + let id = id.as_str().unwrap_or_default(); + if let Some(node) = nodes.get(id) { + lines.push(format!( + " {} {}:{}", + compass_query::sanitize_label(&node.display_label()), + compass_query::sanitize_label(node.source_file().unwrap_or("?")), + compass_query::sanitize_label(&node.string("source_location")) + )); + } else { + lines.push(format!(" {}", compass_query::sanitize_label(id))); + } + } + } + } + lines.push(format!( + "{omitted} communities omitted; use --limit 100 or compass community ID." + )); + Outcome::success(lines.join("\n")) +} + +fn encode(value: &impl serde::Serialize) -> Outcome { + match serde_json::to_string_pretty(value) { + Ok(text) => Outcome::success(text), + Err(error) => Outcome::failure(format!("error: overview encoding failed: {error}")), + } +} diff --git a/crates/compass-cli/src/program_commands.rs b/crates/compass-cli/src/program_commands.rs index d4fc89c57..1b21cbf6a 100644 --- a/crates/compass-cli/src/program_commands.rs +++ b/crates/compass-cli/src/program_commands.rs @@ -21,14 +21,41 @@ pub(super) fn command(_frontend: Frontend, args: &[String]) -> Outcome { if matches!(subcommand, "-h" | "--help" | "help") { return Outcome::success(help()); } + if !matches!( + subcommand, + "summary" + | "coverage" + | "functions" + | "show" + | "callers" + | "explain-call" + | "call-graph" + | "query" + ) { + return usage_error(&format!( + "unknown program command '{subcommand}'; run compass help program" + )); + } let (options, remaining) = match parse_common(&args[1..], subcommand != "query") { Ok(parsed) => parsed, Err(error) => return Outcome::failure_with_code(format!("error: {error}"), 2), }; let analysis = match load_program(&options.program) { Ok(analysis) => analysis, - Err(error) => return Outcome::failure_with_code(format!("error: {error}"), 3), + Err(error) => { + return Outcome::failure_with_code( + format!( + "error: Program IR unavailable: {error}.\nRun compass update --program to build function signatures and program evidence. For an existing artifact, use --program PATH." + ), + 3, + ); + } }; + if let Ok(path) = compass_files::BuildGuard::resolve_requested_artifact(&options.program) { + // Program IR has no build commit in its current schema. Report unknown + // freshness under its own recorded root rather than borrowing a revision. + crate::freshness::record_program(&path); + } match subcommand { "summary" => render(summary(&analysis), options.format), "coverage" => coverage(&analysis, &remaining, options.format), @@ -125,7 +152,7 @@ pub(crate) fn load_program(path: &Path) -> Result { "Program IR exceeds the {MAX_PROGRAM_BYTES}-byte safety limit" )); } - let bytes = fs::read(&resolved) + let bytes = compass_files::read_bytes_bounded(&resolved, MAX_PROGRAM_BYTES) .map_err(|error| format!("could not read {}: {error}", resolved.display()))?; let analysis: AnalysisBundle = serde_json::from_slice(&bytes) .map_err(|error| format!("invalid Program IR JSON at {}: {error}", resolved.display()))?; @@ -346,7 +373,14 @@ fn show(analysis: &AnalysisBundle, args: &[String], format: Format) -> Outcome { } let function = match resolve_function(analysis, &args[0]) { Ok(function) => function, - Err(error) => return Outcome::failure_with_code(format!("error: {error}"), 4), + Err(error) => { + return Outcome::failure_with_code( + format!( + "error: {error}. Run compass program functions to list recorded function IDs." + ), + 4, + ); + } }; let Some(module) = analysis.program.modules.iter().find(|module| { module @@ -859,10 +893,12 @@ fn resolve_function<'a>( candidates.sort_by(|left, right| left.symbol_id.cmp(&right.symbol_id)); candidates.dedup_by(|left, right| left.symbol_id == right.symbol_id); match candidates.as_slice() { - [] => Err(format!("no Program IR function matches '{query}'")), + [] => Err(format!( + "no Program IR function matches '{query}'; run compass program functions to list recorded function IDs" + )), [function] => Ok(function), functions => Err(format!( - "function selector '{query}' is ambiguous: {}", + "function selector '{query}' is ambiguous: {}; retry compass program show ID with an exact function ID", functions .iter() .take(8) diff --git a/crates/compass-cli/src/task_context_commands.rs b/crates/compass-cli/src/task_context_commands.rs index 1e13a6813..2078b9f7d 100644 --- a/crates/compass-cli/src/task_context_commands.rs +++ b/crates/compass-cli/src/task_context_commands.rs @@ -129,6 +129,7 @@ fn execute(args: &[String]) -> Result { (None, None) => { let engine = open_with_engine(&graph, program.as_deref(), &cache, engine_selection) .map_err(|error| error.to_string())?; + super::freshness::record(engine.graph_path(), engine.source_commit()); build_task_context(&engine, &request, &memory).map_err(|error| error.to_string()) } (Some(overlay), Some(revision)) => { diff --git a/crates/compass-cli/tests/usability_cli.rs b/crates/compass-cli/tests/usability_cli.rs new file mode 100644 index 000000000..4acf1bd35 --- /dev/null +++ b/crates/compass-cli/tests/usability_cli.rs @@ -0,0 +1,343 @@ +mod support; + +use serde_json::{Value, json}; +use std::{ + error::Error, + fs, + path::Path, + process::{Command, Output}, +}; + +fn token_count(text: &str) -> usize { + text.len().div_ceil(4) +} + +fn execute(root: &Path, args: &[&str]) -> Result> { + Ok(support::compass_command() + .current_dir(root) + .args(args) + .output()?) +} +fn parsed(output: Output) -> Result> { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(serde_json::from_slice(&output.stdout)?) +} +fn fixture(root: &Path) -> Result<(), Box> { + fs::write( + root.join("graph.json"), + serde_json::to_vec(&json!({ + "directed":true,"multigraph":true,"nodes":[ + {"id":"a","label":"Service","kind":"class","source_file":"app/services/a.py","source_location":"L2","qualified_name":"app.services.Service","community":0}, + {"id":"b","label":"Client","kind":"function","source_file":"other/b.py","community":0}, + {"id":"c","label":"Repository","kind":"class","source_file":"app/services_extra/c.py","community":1}, + {"id":"d","label":"Design","kind":"document_section","source_file":"README.md","community":1} + ], "links":[ + {"source":"b","target":"a","relation":"calls","confidence":"EXTRACTED"}, + {"source":"b","target":"a","relation":"calls","confidence":"EXTRACTED"}, + {"source":"c","target":"a","relation":"uses","confidence":"INFERRED"}, + {"source":"d","target":"a","relation":"mentions","confidence":"EXTRACTED"} + ] + }))?, + )?; + Ok(()) +} + +#[test] +fn scoped_views_use_path_and_module_boundaries() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + fixture(directory.path())?; + for scope in ["app/services", "module:app.services"] { + let architecture = parsed(execute( + directory.path(), + &[ + "architecture", + "--graph", + "graph.json", + "--scope", + scope, + "--format", + "json", + ], + )?)?; + assert_eq!(architecture["schema"], "compass.architecture.scoped-view/1"); + assert_eq!(architecture["selection"]["selectedNodes"], 1); + assert_eq!(architecture["selection"]["boundaryRelationships"], 4); + let community = parsed(execute( + directory.path(), + &[ + "community", + "0", + "--graph", + "graph.json", + "--scope", + scope, + "--format", + "json", + ], + )?)?; + assert_eq!(community["communities"][0]["members"], json!(["a"])); + assert_eq!(community["selection"]["omittedNodes"], 3); + } + fs::write( + directory.path().join("analysis.json"), + serde_json::to_vec(&json!({"communities":{"0":["b"],"1":["a"]}}))?, + )?; + let pinned = parsed(execute( + directory.path(), + &[ + "community", + "0", + "--graph", + "graph.json", + "--scope", + "app/services", + "--format", + "json", + ], + )?)?; + assert_eq!(pinned["communities"][0]["members"], json!(["a"])); + let empty = parsed(execute( + directory.path(), + &[ + "architecture", + "--graph", + "graph.json", + "--scope", + "absent", + "--format", + "json", + ], + )?)?; + assert_eq!(empty["selection"]["selectedNodes"], 0); + let invalid_architecture = execute( + directory.path(), + &[ + "architecture", + "--graph", + "missing.json", + "--scope", + "../outside", + ], + )?; + assert_eq!(invalid_architecture.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&invalid_architecture.stderr).contains("invalid scope")); + let invalid = execute(directory.path(), &["hotspots", "--scope", "../outside"])?; + assert_eq!(invalid.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&invalid.stderr).contains("relative path")); + Ok(()) +} + +#[test] +fn hotspot_counts_keep_optional_evidence_and_global_contacts_explicit() -> Result<(), Box> +{ + let directory = tempfile::tempdir()?; + fixture(directory.path())?; + let args = [ + "hotspots", + "--graph", + "graph.json", + "--scope", + "app/services", + "--format", + "json", + ]; + let base = parsed(execute(directory.path(), &args)?)?; + assert_eq!(base["schema"], "compass.hotspots/1"); + assert_eq!(base["mostConnected"][0]["connections"], 1); + assert_eq!(base["mostConnected"][0]["relationshipRecords"], 2); + assert_eq!(base["excludedRelationshipRecords"], 2); + assert_eq!(base, parsed(execute(directory.path(), &args)?)?); + let all = parsed(execute( + directory.path(), + &[ + "hotspots", + "--graph", + "graph.json", + "--scope", + "app/services", + "--include-inferred", + "--include-documents", + "--format", + "json", + ], + )?)?; + assert_eq!(all["mostConnected"][0]["connections"], 3); + assert_eq!(all["mostDependedOn"][0]["dependents"], 3); + assert_eq!(all["mostConnected"][0]["layers"]["inferred"], 1); + assert_eq!(all["mostConnected"][0]["layers"]["document"], 1); + assert_eq!(all["mostConnected"][0]["sourceLocation"], "L2"); + let capped = execute( + directory.path(), + &["hotspots", "--graph", "graph.json", "--budget", "200"], + )?; + assert!(capped.status.success()); + assert!( + token_count(&String::from_utf8(capped.stdout)?) + .saturating_add(token_count(&String::from_utf8(capped.stderr)?)) + <= 200 + ); + Ok(()) +} + +fn git(root: &Path, args: &[&str]) -> Result> { + let output = Command::new("git") + .args(["-c", "commit.gpgsign=false", "-c", "core.fsmonitor=false"]) + .arg("-c") + .arg(format!( + "core.hooksPath={}", + root.join(".no-hooks").display() + )) + .arg("-C") + .arg(root) + .args(args) + .output()?; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8(output.stdout)?.trim().into()) +} + +#[test] +fn freshness_follows_the_recorded_root_and_is_rechecked_on_cached_queries() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + let root = directory.path(); + let graph_path = support::write_typed_graph(root)?; + fs::write( + root.join(".gitignore"), + "graph.json\nsource-root.txt\ncache/\n.compass/\n", + )?; + git(root, &["init"])?; + git(root, &["config", "user.name", "Fixture"])?; + git(root, &["config", "user.email", "fixture@example.invalid"])?; + git(root, &["add", "."])?; + git(root, &["commit", "-m", "fixture"])?; + let built = git(root, &["rev-parse", "HEAD"])?; + let mut graph: Value = serde_json::from_slice(&fs::read(&graph_path)?)?; + graph["graph"]["build"]["sourceCommit"] = built.clone().into(); + fs::write(&graph_path, serde_json::to_vec(&graph)?)?; + fs::write( + root.join("source-root.txt"), + fs::canonicalize(root)?.to_string_lossy().as_bytes(), + )?; + let elsewhere = tempfile::tempdir()?; + let path = graph_path.to_str().ok_or("path encoding")?; + let args = ["callers", "Target", "--graph", path, "--format", "json"]; + let current = execute(elsewhere.path(), &args)?; + assert!( + current.status.success(), + "{}", + String::from_utf8_lossy(¤t.stderr) + ); + assert!(!String::from_utf8_lossy(¤t.stderr).contains("Graph built")); + fs::write(root.join("src/lib.rs"), "changed")?; + fs::write(root.join("new.py"), "pass")?; + for _ in 0..2 { + let dirty = execute(elsewhere.path(), &args)?; + assert!( + dirty.status.success(), + "{}", + String::from_utf8_lossy(&dirty.stderr) + ); + let stderr = String::from_utf8(dirty.stderr)?; + assert!( + stderr.contains("2 files differ; 2 uncommitted files"), + "{stderr}" + ); + assert!(stderr.contains(root.to_str().ok_or("root")?), "{stderr}"); + let _: Value = serde_json::from_slice(&dirty.stdout)?; + } + for _ in 0..2 { + let path_query = execute( + elsewhere.path(), + &["path", "Caller", "Target", "--graph", path], + )?; + assert!( + path_query.status.success(), + "{}", + String::from_utf8_lossy(&path_query.stderr) + ); + assert!( + String::from_utf8_lossy(&path_query.stderr) + .contains("2 files differ; 2 uncommitted files") + ); + } + git(root, &["add", "."])?; + git(root, &["commit", "-m", "change"])?; + let stale = execute(elsewhere.path(), &args)?; + assert!(String::from_utf8_lossy(&stale.stderr).contains(&built[..12])); + let capped = execute( + elsewhere.path(), + &["callers", "Target", "--graph", path, "--budget", "200"], + )?; + assert!(capped.status.success()); + assert!( + token_count(&String::from_utf8(capped.stdout)?) + .saturating_add(token_count(&String::from_utf8(capped.stderr)?)) + <= 200 + ); + fs::remove_file(root.join("source-root.txt"))?; + assert!( + !String::from_utf8_lossy(&execute(elsewhere.path(), &args)?.stderr).contains("Graph built") + ); + Ok(()) +} + +#[test] +fn unavailable_program_and_graph_return_recovery_commands() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let missing = execute(directory.path(), &["program", "show", "run"])?; + assert_eq!(missing.status.code(), Some(3)); + let stderr = String::from_utf8(missing.stderr)?; + assert!(stderr.contains("Program IR unavailable")); + assert!(stderr.contains("compass update --program")); + assert!(stderr.contains("--program PATH")); + fs::write( + directory.path().join("lib.rs"), + "pub fn run(value: usize) -> usize { value }\n", + )?; + let repair = execute( + directory.path(), + &["update", "--program", "--no-cluster", "--no-viz"], + )?; + assert!( + repair.status.success(), + "{}", + String::from_utf8_lossy(&repair.stderr) + ); + let functions = execute( + directory.path(), + &["program", "functions", "--format", "json"], + )?; + assert!( + functions.status.success(), + "{}", + String::from_utf8_lossy(&functions.stderr) + ); + assert!(String::from_utf8_lossy(&functions.stdout).contains("run")); + let signature = execute( + directory.path(), + &["program", "show", "run", "--format", "json"], + )?; + assert!( + signature.status.success(), + "{}", + String::from_utf8_lossy(&signature.stderr) + ); + let unknown = execute(directory.path(), &["program", "unknown"])?; + assert_eq!(unknown.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&unknown.stderr).contains("compass help program")); + let missing_graph = execute( + directory.path(), + &["callers", "Target", "--graph", "missing.json"], + )?; + assert!(!missing_graph.status.success()); + assert!(String::from_utf8_lossy(&missing_graph.stderr).contains("compass ensure")); + Ok(()) +} diff --git a/crates/compass-core/src/freshness.rs b/crates/compass-core/src/freshness.rs new file mode 100644 index 000000000..8c0813bba --- /dev/null +++ b/crates/compass-core/src/freshness.rs @@ -0,0 +1,383 @@ +//! Advisory freshness over the selected graph's recorded source root. +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use compass_files::read_bytes_bounded; +use compass_prs::{ProcessRunner, SystemRunner}; +use serde::Serialize; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphFreshness { + pub schema: &'static str, + pub source_commit: Option, + pub head_commit: Option, + pub changed_files: Option, + pub uncommitted_files: Option, + pub status: FreshnessStatus, + pub reason: Option, + pub update_command: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FreshnessStatus { + Current, + RevisionChanged, + WorkingTreeChanges, + Unknown, +} + +impl GraphFreshness { + #[must_use] + pub fn warning(&self) -> Option { + if self.status == FreshnessStatus::Current { + return None; + } + let built = self.source_commit.as_deref().unwrap_or("unknown revision"); + let built = built.chars().take(12).collect::(); + Some(match self.status { + FreshnessStatus::Unknown => format!( + "Graph freshness unknown: {}. Run {}.", + self.reason + .as_deref() + .unwrap_or("source revision cannot be checked"), + self.update_command + ), + _ => format!( + "Graph built at {built}; {} files differ; {} uncommitted files. Run {}.", + self.changed_files.unwrap_or_default(), + self.uncommitted_files.unwrap_or_default(), + self.update_command + ), + }) + } +} + +fn commit_id(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// No source root is guessed from the caller's current directory. Graphs +/// without a recorded root (including historical realizations) return None. +/// Git failures and limits are unknown freshness, never a claim of currency. +#[must_use] +pub fn graph_freshness(graph_path: &Path, source_commit: Option<&str>) -> Option { + graph_freshness_with_runner(graph_path, source_commit, &SystemRunner) +} + +fn graph_freshness_with_runner( + graph_path: &Path, + source_commit: Option<&str>, + runner: &impl ProcessRunner, +) -> Option { + let marker = graph_path.parent()?.join("source-root.txt"); + if !marker.exists() { + return None; + } + let mut result = GraphFreshness { + schema: "compass.graph-freshness/1", + source_commit: source_commit + .filter(|value| commit_id(value)) + .map(str::to_owned), + head_commit: None, + changed_files: None, + uncommitted_files: None, + status: FreshnessStatus::Unknown, + reason: None, + update_command: "compass update".into(), + }; + let root = (|| -> Result { + let bytes = read_bytes_bounded(&marker, 4096) + .map_err(|_| "source-root marker is unavailable or exceeds 4096 bytes")?; + let text = std::str::from_utf8(&bytes) + .map_err(|_| "source-root marker is not UTF-8")? + .trim(); + let root = PathBuf::from(text); + if !root.is_absolute() || text.chars().any(char::is_control) { + return Err("source-root marker is not a valid absolute path".into()); + } + std::fs::canonicalize(root).map_err(|_| "recorded source root is unavailable".into()) + })(); + let root = match root { + Ok(root) => root, + Err(reason) => { + result.reason = Some(reason); + return Some(result); + } + }; + let root_text = match root.to_str() { + Some(text) => text, + None => { + result.reason = Some("recorded source root is not UTF-8".into()); + return Some(result); + } + }; + // Shell quoting is display only; this string is never executed. + // The root may be outside the caller's current project. + let mut output = compass_files::BuildGuard::output_container_for_artifact(graph_path); + // A selected older managed generation must never be rebuilt in place. + if let Some(snapshots) = graph_path.parent().and_then(Path::parent) + && snapshots + .file_name() + .is_some_and(|name| name == "snapshots") + && let Some(container) = snapshots.parent() + && compass_files::BuildGuard::resolve_current_snapshot_directory(container).is_ok() + { + output = container.to_owned(); + } + if !output.is_absolute() { + output = std::env::current_dir() + .unwrap_or_else(|_| root.clone()) + .join(output); + } + output = std::fs::canonicalize(&output).unwrap_or(output); + if output == root { + output = root.join("compass-out"); + } + let output_text = match output.to_str() { + Some(text) if !text.chars().any(char::is_control) => text, + _ => { + result.reason = + Some("artifact output root cannot be represented in a recovery command".into()); + return Some(result); + } + }; + result.update_command = format!( + "compass update {} --out {}", + shell_quote(root_text), + shell_quote(output_text) + ); + let git = |args: &[&str]| -> Result { + let mut arguments = vec![ + "--no-optional-locks".to_owned(), + "--no-lazy-fetch".into(), + "-c".into(), + "core.fsmonitor=false".into(), + "-c".into(), + "core.quotePath=true".into(), + "-c".into(), + "status.renames=false".into(), + "-C".into(), + root_text.to_owned(), + ]; + arguments.extend(args.iter().map(|arg| (*arg).to_owned())); + let output = runner + .run("git", &arguments, Duration::from_millis(500)) + .map_err(|_| "bounded Git freshness check unavailable")?; + if output.code != 0 { + return Err( + "Git cannot verify the graph's recorded revision in this source root".into(), + ); + } + if output.stdout.len() > 1024 * 1024 { + return Err("Git freshness output exceeded 1 MiB".into()); + } + Ok(output.stdout) + }; + let checked = (|| -> Result<(), String> { + let built = result + .source_commit + .as_deref() + .ok_or("graph has no valid build commit")?; + let head = git(&["rev-parse", "--verify", "HEAD"])?; + let head = head.trim(); + if !commit_id(head) { + return Err("Git returned an invalid HEAD revision".into()); + } + result.head_commit = Some(head.to_owned()); + let diff = git(&[ + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--name-only", + "-z", + built, + "--", + ])?; + let status = git(&["status", "--porcelain=v1", "-z", "--untracked-files=all"])?; + if diff.contains('\u{fffd}') || status.contains('\u{fffd}') { + return Err("Git paths cannot be verified losslessly as UTF-8".into()); + } + if !diff.is_empty() && !diff.ends_with('\0') { + return Err("Git returned malformed changed-path output".into()); + } + if !status.is_empty() && !status.ends_with('\0') { + return Err("Git returned malformed working-tree status".into()); + } + let mut changed = diff.split_terminator('\0').collect::>(); + let mut dirty = 0; + for row in status.split_terminator('\0') { + if row.len() < 4 + || row.as_bytes()[2] != b' ' + || !row.as_bytes()[..2].iter().all(u8::is_ascii) + { + return Err("Git returned malformed working-tree status".into()); + } + dirty += 1; + // Dirty tracked files already appear in diff unless their working + // bytes equal the built commit; staged-only states remain visible. + changed.insert(&row[3..]); + } + result.changed_files = Some(changed.len()); + result.uncommitted_files = Some(dirty); + result.status = if built != head { + FreshnessStatus::RevisionChanged + } else if dirty > 0 || !changed.is_empty() { + FreshnessStatus::WorkingTreeChanges + } else { + FreshnessStatus::Current + }; + Ok(()) + })(); + if let Err(reason) = checked { + result.reason = Some(reason); + result.status = FreshnessStatus::Unknown; + } + Some(result) +} + +fn shell_quote(text: &str) -> String { + #[cfg(windows)] + { + format!("'{}'", text.replace('\'', "''")) + } + #[cfg(not(windows))] + { + format!("'{}'", text.replace('\'', "'\\''")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use compass_prs::{ProcessOutput, PrsError}; + struct Fake { + head: String, + status: &'static str, + fail: bool, + } + impl ProcessRunner for Fake { + fn run( + &self, + _: &str, + args: &[String], + timeout: Duration, + ) -> Result { + assert_eq!(timeout, Duration::from_millis(500)); + assert!(args.iter().any(|arg| arg == "--no-lazy-fetch")); + assert!(args.iter().any(|arg| arg == "core.fsmonitor=false")); + assert!(args.iter().any(|arg| arg == "status.renames=false")); + if args.iter().any(|arg| arg == "diff") { + for flag in ["--no-ext-diff", "--no-textconv", "--no-renames"] { + assert!(args.iter().any(|arg| arg == flag)); + } + } + let stdout = if args.iter().any(|arg| arg == "rev-parse") { + self.head.clone() + } else if args.iter().any(|arg| arg == "status") { + self.status.into() + } else if self.status.is_empty() { + String::new() + } else { + "app/a.py\0".into() + }; + Ok(ProcessOutput { + code: i32::from(self.fail), + stdout, + stderr: String::new(), + }) + } + } + #[test] + fn unknown_revision_and_malformed_status_never_report_current() + -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let graph = directory.path().join("graph.json"); + let marker = directory.path().join("source-root.txt"); + std::fs::write(&marker, "relative/root")?; + let commit = "a".repeat(40); + let mut fake = Fake { + head: commit.clone(), + status: "", + fail: false, + }; + let invalid = + graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; + assert_eq!(invalid.status, FreshnessStatus::Unknown); + assert!( + invalid + .reason + .is_some_and(|reason| reason.contains("absolute path")) + ); + std::fs::write(&marker, directory.path().to_string_lossy().as_bytes())?; + fake.head = "invalid".into(); + let invalid = + graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; + assert_eq!(invalid.status, FreshnessStatus::Unknown); + fake.head = commit.clone(); + fake.status = "bad"; + let invalid = + graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; + assert_eq!(invalid.status, FreshnessStatus::Unknown); + assert!(invalid.changed_files.is_none()); + assert!( + invalid + .reason + .is_some_and(|reason| reason.contains("malformed")) + ); + fake.status = " M app/a.py\0 D old.py\0?? new.py\0"; + let renamed = + graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; + assert_eq!(renamed.status, FreshnessStatus::WorkingTreeChanges); + assert_eq!(renamed.changed_files, Some(3)); + fake.status = " M app/a.py\0?? file with space.py\0?? name\nwith newline.py\0"; + let unusual = + graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; + assert_eq!(unusual.changed_files, Some(3)); + assert_eq!(unusual.uncommitted_files, Some(3)); + assert!(shell_quote("has $dollars and 'quotes'").contains("$dollars")); + Ok(()) + } + + #[test] + fn freshness_is_bound_to_recorded_root_and_git_failures_stay_unknown() + -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let graph = directory.path().join("graph.json"); + let commit = "a".repeat(40); + let mut fake = Fake { + head: "b".repeat(40), + status: " M app/a.py\0?? new.py\0", + fail: false, + }; + assert!(graph_freshness_with_runner(&graph, Some(&commit), &fake).is_none()); + std::fs::write( + directory.path().join("source-root.txt"), + directory.path().to_string_lossy().as_bytes(), + )?; + let stale = graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; + assert_eq!(stale.status, FreshnessStatus::RevisionChanged); + assert_eq!(stale.changed_files, Some(2)); + assert_eq!(stale.uncommitted_files, Some(2)); + assert!( + stale + .warning() + .is_some_and(|text| text.contains("compass update")) + ); + fake.head = commit.clone(); + fake.status = ""; + let current = + graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; + assert_eq!(current.status, FreshnessStatus::Current); + assert!(current.warning().is_none()); + fake.fail = true; + let unknown = + graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; + assert_eq!(unknown.status, FreshnessStatus::Unknown); + assert!(unknown.changed_files.is_none()); + Ok(()) + } +} diff --git a/crates/compass-core/src/lib.rs b/crates/compass-core/src/lib.rs index d536264fa..4d0609091 100644 --- a/crates/compass-core/src/lib.rs +++ b/crates/compass-core/src/lib.rs @@ -5,6 +5,7 @@ mod build_state; mod cluster_existing; mod diagnostics; mod document_processing; +mod freshness; mod history; mod merge; mod pipeline; @@ -31,6 +32,7 @@ pub use diagnostics::{ pub use document_processing::{ CoreDocumentProcessingOptions, PreparedDocument, PreparedDocumentSet, prepare_document_set, }; +pub use freshness::{FreshnessStatus, GraphFreshness, graph_freshness}; pub use history::{ CompleteGraphBuilder, MaterializeError, MaterializeObserver, MaterializeRequest, MaterializeStage, history_provider_manifest, materialize_history, diff --git a/crates/compass-mcp/src/lib.rs b/crates/compass-mcp/src/lib.rs index d8593ba16..b2307528f 100644 --- a/crates/compass-mcp/src/lib.rs +++ b/crates/compass-mcp/src/lib.rs @@ -385,8 +385,23 @@ impl CompassMcp { } fn read_result(&self, uri: &str) -> Result { + self.read_result_with_freshness(uri).map(|(text, _)| text) + } + + fn read_result_with_freshness( + &self, + uri: &str, + ) -> Result<(String, Option), InvocationError> { let context = self.store.load(None).map_err(InvocationError::Internal)?; - let text = read_resource_text(uri, &context)?; + let mut text = read_resource_text(uri, &context)?; + let freshness = compass_core::graph_freshness(&context.path, context.graph.source_commit()); + if !matches!(uri, "compass://orientation" | "compass://graph-insights") + && let Some(warning) = freshness + .as_ref() + .and_then(compass_core::GraphFreshness::warning) + { + text = format!("{warning}\n{text}"); + } if text.len() > MAX_MCP_RESOURCE_BYTES { return Err(InvocationError::TransportLimit { required_bytes: text.len(), @@ -394,7 +409,7 @@ impl CompassMcp { omitted_bytes: text.len().saturating_sub(MAX_MCP_RESOURCE_BYTES), }); } - Ok(text) + Ok((text, freshness)) } } @@ -454,8 +469,8 @@ impl ServerHandler for CompassMcp { request: ReadResourceRequestParams, _context: RequestContext, ) -> Result { - let text = self - .read_result(&request.uri) + let (text, freshness) = self + .read_result_with_freshness(&request.uri) .map_err(InvocationError::protocol_error)?; let mime = match request.uri.as_str() { "compass://report" => "text/markdown", @@ -464,7 +479,7 @@ impl ServerHandler for CompassMcp { _ => "text/plain", }; let required_bytes = text.len(); - let transport = Meta(Map::from_iter([( + let mut transport = Meta(Map::from_iter([( "transportTruncation".to_owned(), json!({ "schema": MCP_TRANSPORT_TRUNCATION_SCHEMA, @@ -474,6 +489,13 @@ impl ServerHandler for CompassMcp { "omittedBytes": 0, }), )])); + if let Some(freshness) = freshness { + transport.0.insert( + "freshness".into(), + serde_json::to_value(freshness) + .map_err(|error| ErrorData::internal_error(error.to_string(), None))?, + ); + } Ok(ReadResourceResult::new(vec![ ResourceContents::text(text, request.uri) .with_mime_type(mime) @@ -487,6 +509,90 @@ struct ToolInvocation { structured_content: Option, } +fn decorate_freshness( + mut result: ToolInvocation, + path: &Path, + commit: Option<&str>, +) -> Result { + if let Some(freshness) = compass_core::graph_freshness(path, commit) { + if let Some(warning) = freshness.warning() { + result.text = format!("{warning}\n{}", result.text); + } + let value = serde_json::to_value(freshness) + .map_err(|error| InvocationError::Internal(error.to_string()))?; + if result.structured_content.is_none() { + result.structured_content = Some(transport_envelope(json!({"text":result.text}))?); + } + let structured = result + .structured_content + .as_mut() + .ok_or_else(|| InvocationError::Internal("missing tool envelope".into()))?; + if let Some(object) = structured.as_object_mut() { + object.insert("freshness".into(), value); + } + result.structured_content = Some(validate_transport_envelope( + result + .structured_content + .take() + .ok_or_else(|| InvocationError::Internal("missing tool envelope".into()))?, + )?); + } + if result.text.len() > MAX_MCP_STRUCTURED_RESPONSE_BYTES { + return Err(InvocationError::TransportLimit { + required_bytes: result.text.len(), + limit_bytes: MAX_MCP_STRUCTURED_RESPONSE_BYTES, + omitted_bytes: result.text.len() - MAX_MCP_STRUCTURED_RESPONSE_BYTES, + }); + } + Ok(result) +} + +fn overview_scope(arguments: &Map) -> Result { + let filters = match arguments.get("scope") { + None => Vec::new(), + Some(Value::String(value)) => vec![value.clone()], + _ => return Err("scope must be a relative path or module:NAME".into()), + }; + compass_query::OverviewScope::new(filters).map_err(|error| error.to_string()) +} + +fn invoke_hotspots( + arguments: &Map, + context: &GraphContext, +) -> Result { + reject_unknown_arguments( + arguments, + &["scope", "limit", "include_inferred", "include_documents"], + "get_hotspots", + )?; + let scope = overview_scope(arguments).map_err(InvocationError::InvalidParams)?; + let boolean = |key| { + arguments.get(key).map_or(Ok(false), |value| { + value + .as_bool() + .ok_or_else(|| InvocationError::InvalidParams(format!("{key} must be a boolean"))) + }) + }; + let layers = compass_query::ConnectionLayers { + inferred: boolean("include_inferred")?, + documents: boolean("include_documents")?, + }; + let limit = arguments + .get("limit") + .map_or(Some(10), Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| InvocationError::InvalidParams("limit must be between 1 and 100".into()))?; + let report = compass_query::hotspots(context.document(), &scope, layers, limit) + .map_err(|error| InvocationError::InvalidParams(error.to_string()))?; + Ok(ToolInvocation { + text: compass_output::render_hotspots_text(&report), + structured_content: Some(transport_envelope( + serde_json::to_value(report) + .map_err(|error| InvocationError::Internal(error.to_string()))?, + )?), + }) +} + impl CompassMcp { fn invoke_result( &self, @@ -585,18 +691,42 @@ impl CompassMcp { return invoke_typed_tool(&self.store, name, arguments, &context.path, Some(&context)); } if name == "get_neighbors" { - return invoke_neighbor_tool(arguments, &context); + return decorate_freshness( + invoke_neighbor_tool(arguments, &context)?, + &context.path, + context.graph.source_commit(), + ); } if name == "god_nodes" { - return invoke_hub_tool(arguments, &context); + return decorate_freshness( + invoke_hub_tool(arguments, &context)?, + &context.path, + context.graph.source_commit(), + ); + } + if name == "get_hotspots" { + return decorate_freshness( + invoke_hotspots(arguments, &context)?, + &context.path, + context.graph.source_commit(), + ); } if name == "shortest_path" { - return invoke_path_tool(arguments, &context); + return decorate_freshness( + invoke_path_tool(arguments, &context)?, + &context.path, + context.graph.source_commit(), + ); } - Ok(ToolInvocation { - text: invoke_tool(name, arguments, &context).map_err(InvocationError::InvalidParams)?, - structured_content: None, - }) + decorate_freshness( + ToolInvocation { + text: invoke_tool(name, arguments, &context) + .map_err(InvocationError::InvalidParams)?, + structured_content: None, + }, + &context.path, + context.graph.source_commit(), + ) } fn invoke_agent_graph( @@ -1082,15 +1212,19 @@ fn invoke_typed_tool( .map_err(|error| InvocationError::Internal(error.to_string()))?; let view = serde_json::to_value(view).map_err(|error| InvocationError::Internal(error.to_string()))?; - Ok(ToolInvocation { - text, - structured_content: Some(transport_envelope_with_view( - serde_json::to_value(response) - .map_err(|error| InvocationError::Internal(error.to_string()))?, - Some(&semantic_result_digest), - Some(view), - )?), - }) + decorate_freshness( + ToolInvocation { + text, + structured_content: Some(transport_envelope_with_view( + serde_json::to_value(response) + .map_err(|error| InvocationError::Internal(error.to_string()))?, + Some(&semantic_result_digest), + Some(view), + )?), + }, + engine.graph_path(), + engine.source_commit(), + ) } fn typed_agent_query_context( @@ -1201,15 +1335,19 @@ fn invoke_discovery_tool( .map_err(|error| InvocationError::Internal(error.to_string()))?; let view = serde_json::to_value(view).map_err(|error| InvocationError::Internal(error.to_string()))?; - Ok(ToolInvocation { - text, - structured_content: Some(transport_envelope_with_view( - serde_json::to_value(response) - .map_err(|error| InvocationError::Internal(error.to_string()))?, - Some(&semantic_result_digest), - Some(view), - )?), - }) + decorate_freshness( + ToolInvocation { + text, + structured_content: Some(transport_envelope_with_view( + serde_json::to_value(response) + .map_err(|error| InvocationError::Internal(error.to_string()))?, + Some(&semantic_result_digest), + Some(view), + )?), + }, + engine.graph_path(), + engine.source_commit(), + ) } fn cached_typed_engine( @@ -1307,6 +1445,10 @@ fn transport_envelope_with_view( if let Some(view) = agent_view { envelope["agentView"] = view; } + validate_transport_envelope(envelope) +} + +fn validate_transport_envelope(mut envelope: Value) -> Result { for _ in 0..8 { let required_bytes = serde_json::to_vec(&envelope) .map_err(|error| InvocationError::Internal(error.to_string()))? @@ -1399,8 +1541,13 @@ fn tool_specs() -> Vec { ), tool( "get_community", - "Get all nodes in a community by community ID.", - json!({"type":"object","properties":{"community_id":{"type":"integer","description":"Community ID (0-indexed by size)"}},"required":["community_id"]}), + "Get a source-scoped bounded community window with omission counts.", + json!({"type":"object","properties":{"scope":{"type":"string","minLength":1,"maxLength":4096,"description":"Relative path or module:NAME"},"limit":{"type":"integer","minimum":1,"maximum":1000000},"community_id":{"type":"integer","description":"Community ID (0-indexed by size)"}},"required":["community_id"]}), + ), + tool( + "get_hotspots", + "Rank source symbols by distinct connections and incoming dependents. Inferred and document contacts are optional labelled layers; scope filters symbols but retains their outside contacts.", + json!({"type":"object","additionalProperties":false,"properties":{"scope":{"type":"string","minLength":1,"maxLength":4096},"limit":{"type":"integer","minimum":1,"maximum":100,"default":10},"include_inferred":{"type":"boolean","default":false},"include_documents":{"type":"boolean","default":false}}}), ), tool( "god_nodes", @@ -1891,14 +2038,22 @@ fn invoke_task_context( if result.truncated { " (truncated)" } else { "" } ); let digest = result.result_digest.clone(); - Ok(ToolInvocation { - text, - structured_content: Some(transport_envelope_with_digest( - serde_json::to_value(result) - .map_err(|error| InvocationError::Internal(error.to_string()))?, - Some(&digest), - )?), - }) + let engine = cached_typed_engine(store, graph_path, graph_context)?; + let engine = engine + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + decorate_freshness( + ToolInvocation { + text, + structured_content: Some(transport_envelope_with_digest( + serde_json::to_value(result) + .map_err(|error| InvocationError::Internal(error.to_string()))?, + Some(&digest), + )?), + }, + graph_path, + engine.source_commit(), + ) } fn task_context_invocation_error(error: compass_core::TaskContextError) -> InvocationError { @@ -2358,16 +2513,28 @@ fn tool_get_community( arguments: &Map, context: &GraphContext, ) -> Result { + let scope = overview_scope(arguments)?; + if context.graph.node_count() > compass_query::MAX_OVERVIEW_NODES { + return Err( + "community exceeds its graph work limit; run compass ensure PATH for a smaller graph" + .into(), + ); + } + let limit = arguments.get("limit").map_or(Some(compass_query::MAX_OVERVIEW_NODES as u64), Value::as_u64).filter(|value| (1..=compass_query::MAX_OVERVIEW_NODES as u64).contains(value)).ok_or("limit must be between 1 and 1000000; call get_community with a positive limit and scope")? as usize; let raw = integer_argument(arguments, "community_id", -1); let Ok(community) = usize::try_from(raw) else { - return Ok(format!("Community {raw} not found.")); + return Ok(format!( + "Community {raw} not found. Call graph_stats to inspect the graph, or run compass community to list IDs." + )); }; let Some(nodes) = context .communities .get(&community) .filter(|nodes| !nodes.is_empty()) else { - return Ok(format!("Community {community} not found.")); + return Ok(format!( + "Community {community} not found. Run compass community to list IDs, or compass cluster-only to publish community membership." + )); }; let name = context.graph.node(nodes[0]).string("community_name"); let base = format!("Community {community}"); @@ -2377,14 +2544,39 @@ fn tool_get_community( } else { format!("{base} — {clean}") }; - let mut lines = vec![format!("{header} ({} nodes):", nodes.len())]; - for index in nodes { - let node = context.graph.node(*index); + let mut selected = nodes + .iter() + .filter(|index| scope.matches(context.graph.node(**index))) + .collect::>(); + selected.sort_by_key(|index| context.graph.node(**index).id.as_str()); + let mut lines = vec![format!("{header} ({} nodes):", selected.len())]; + if !scope.filters.is_empty() { lines.push(format!( + "Scope {}: {} of {} community members matched.", + scope.filters.join(", "), + selected.len(), + nodes.len() + )); + } + if selected.len() > limit { + lines.push(format!( + "{} members omitted; call get_community with a larger limit or narrow scope.", + selected.len() - limit + )); + } + let mut response_bytes = lines.iter().map(String::len).sum::(); + for index in selected.into_iter().take(limit) { + let node = context.graph.node(*index); + let row = format!( " {} [{}]", sanitize_label(node.label()), sanitize_label(&node.string("source_file")) - )); + ); + response_bytes = response_bytes.saturating_add(row.len() + 1); + if response_bytes > MAX_MCP_STRUCTURED_RESPONSE_BYTES { + return Err("community response exceeds 16 MiB; call get_community with limit=100 and a narrower scope".into()); + } + lines.push(row); } Ok(lines.join("\n")) } @@ -3045,6 +3237,95 @@ fn read_bounded_resource(path: &Path) -> Result { #[cfg(test)] mod tests { + #[test] + fn scoped_hotspots_and_communities_share_labelled_evidence() + -> Result<(), Box> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("graph.json"); + fs::write( + &path, + serde_json::to_vec(&json!({ + "directed":true,"nodes":[ + {"id":"a","label":"Service","kind":"class","source_file":"app/a.py","qualified_name":"app.Service","community":0}, + {"id":"b","label":"Client","kind":"function","source_file":"other/b.py","community":0}, + {"id":"d","kind":"document_section","source_file":"README.md"}], + "links":[{"source":"b","target":"a","relation":"uses","confidence":"INFERRED"}, + {"source":"d","target":"a","relation":"mentions"}] + }))?, + )?; + let server = CompassMcp::new(&path); + let mut args = json!({"scope":"module:app","include_inferred":true}) + .as_object() + .ok_or("args")? + .clone(); + let result = server + .invoke_result("get_hotspots", &mut args) + .map_err(|error| error.to_string())?; + let content = result.structured_content.ok_or("content")?; + assert_eq!(content["result"]["schema"], "compass.hotspots/1"); + assert_eq!(content["result"]["mostConnected"][0]["id"], "a"); + assert_eq!(content["result"]["mostConnected"][0]["connections"], 1); + assert_eq!( + content["result"]["mostConnected"][0]["layers"]["inferred"], + 1 + ); + assert!(result.text.contains("inferred:1")); + let community = server.invoke( + "get_community", + json!({"community_id":0,"scope":"app"}) + .as_object() + .ok_or("args")? + .clone(), + ); + assert!(community.contains("1 of 2 community members matched")); + assert!(community.contains("Service")); + assert!(!community.contains("Client")); + for invalid in [ + json!({"limit":0}), + json!({"include_inferred":"yes"}), + json!({"scope":"../outside"}), + json!({"unknown":true}), + ] { + assert!(matches!( + server.invoke_result( + "get_hotspots", + &mut invalid.as_object().ok_or("args")?.clone() + ), + Err(InvocationError::InvalidParams(_)) + )); + } + fs::write( + temp.path().join("source-root.txt"), + temp.path().to_string_lossy().as_bytes(), + )?; + // The graph has no source commit: never call this current or borrow the caller's repository. + let unknown = server + .invoke_result("get_hotspots", &mut Map::new()) + .map_err(|error| error.to_string())?; + assert!(unknown.text.contains("Graph freshness unknown")); + assert_eq!( + unknown.structured_content.ok_or("content")?["freshness"]["status"], + "unknown" + ); + let envelope = server + .invoke_result("get_hotspots", &mut Map::new()) + .map_err(|error| error.to_string())? + .structured_content + .ok_or("content")?; + assert_eq!( + envelope["transportTruncation"]["requiredBytes"].as_u64(), + Some(serde_json::to_vec(&envelope)?.len() as u64) + ); + let (_, freshness) = server + .read_result_with_freshness("compass://stats") + .map_err(|error| error.to_string())?; + assert_eq!( + freshness.ok_or("freshness")?.status, + compass_core::FreshnessStatus::Unknown + ); + Ok(()) + } + use super::*; #[test] @@ -3686,7 +3967,7 @@ mod tests { sample(&graph)?; let server = CompassMcp::new(graph); let tools = CompassMcp::tools(); - assert_eq!(tools.len(), 18); + assert_eq!(tools.len(), 19); for (name, required) in [ ("task_context", json!(["intent", "target"])), ("pr_readiness", json!(["base", "head"])), diff --git a/crates/compass-mcp/src/transport.rs b/crates/compass-mcp/src/transport.rs index f32058e83..a2f9009e0 100644 --- a/crates/compass-mcp/src/transport.rs +++ b/crates/compass-mcp/src/transport.rs @@ -621,7 +621,7 @@ mod tests { let payload: Value = serde_json::from_str(response_body(&listed))?; assert_eq!( payload["result"]["tools"].as_array().map(Vec::len), - Some(18) + Some(19) ); cancellation.cancel(); diff --git a/crates/compass-mcp/tests/coverage_paths.rs b/crates/compass-mcp/tests/coverage_paths.rs index 7b1e4010d..219f3143b 100644 --- a/crates/compass-mcp/tests/coverage_paths.rs +++ b/crates/compass-mcp/tests/coverage_paths.rs @@ -50,7 +50,7 @@ fn tool_contract_and_all_local_tools_cover_success_and_validation_paths() let info = server.get_info(); assert_eq!(info.server_info.name, "compass"); - assert_eq!(CompassMcp::tools().len(), 18); + assert_eq!(CompassMcp::tools().len(), 19); assert!(CompassMcp::tools().iter().all(|tool| { tool.input_schema .get("properties") @@ -298,7 +298,7 @@ async fn in_memory_protocol_exercises_tool_and_resource_server_handlers() let client = ().serve(client_transport).await?; let tools = client.list_tools(None).await?; - assert_eq!(tools.tools.len(), 18); + assert_eq!(tools.tools.len(), 19); let resources = client.list_resources(None).await?; assert_eq!(resources.resources.len(), 8); diff --git a/crates/compass-model/src/document.rs b/crates/compass-model/src/document.rs index 45a5f196c..fc5465dbb 100644 --- a/crates/compass-model/src/document.rs +++ b/crates/compass-model/src/document.rs @@ -831,7 +831,7 @@ impl GraphDocument { const QUERY_CACHE_MAGIC: &[u8; 8] = b"TRAILG02"; const AFFECTED_CACHE_MAGIC: &[u8; 8] = b"TRAILA03"; -const TRAVERSAL_CACHE_MAGIC: &[u8; 8] = b"TRAILT07"; +const TRAVERSAL_CACHE_MAGIC: &[u8; 8] = b"TRAILT08"; const QUERY_CACHE_HEADER_LEN: usize = 48; static QUERY_CACHE_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -841,6 +841,8 @@ static QUERY_CACHE_SEQUENCE: AtomicU64 = AtomicU64::new(0); /// traversal engine cannot consume. #[derive(Deserialize)] struct TraversalRawGraphDocument { + #[serde(default)] + graph: Option, #[serde(default)] directed: bool, #[serde(default = "networkx_default_multigraph")] @@ -851,6 +853,28 @@ struct TraversalRawGraphDocument { edges: Option>, } +#[derive(Default, Deserialize)] +struct TraversalFreshnessMetadata { + #[serde(default)] + build: Option, + #[serde(default)] + built_at_commit: Option, +} + +impl TraversalFreshnessMetadata { + fn source_commit(&self) -> Option { + self.build + .as_ref() + .and_then(|build| build.get("sourceCommit")) + .or(self.built_at_commit.as_ref()) + .and_then(Value::as_str) + .filter(|value| { + matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + .map(str::to_owned) + } +} + #[derive(Default)] struct TraversalRawNode { id: String, @@ -1159,7 +1183,13 @@ impl<'de> Deserialize<'de> for TraversalEvidenceItem { /// lookup for every retained attribute while leaving the published JSON graph /// authoritative. #[derive(Deserialize, Serialize)] -struct TraversalCacheDocument(bool, bool, Vec, Vec); +struct TraversalCacheDocument( + bool, + bool, + Vec, + Vec, + Option, +); #[derive(Deserialize, Serialize)] struct TraversalCacheNode( @@ -1193,6 +1223,10 @@ struct TraversalCacheEdge( impl TraversalRawGraphDocument { fn into_cache(self) -> TraversalCacheDocument { + let source_commit = self + .graph + .as_ref() + .and_then(TraversalFreshnessMetadata::source_commit); let links = self.links.or(self.edges).unwrap_or_default(); TraversalCacheDocument( self.directed, @@ -1205,6 +1239,7 @@ impl TraversalRawGraphDocument { .into_iter() .map(TraversalRawEdge::into_cache) .collect(), + source_commit, ) } } @@ -1370,7 +1405,7 @@ fn source_anchor_field(anchor: Option<&Value>, field: &str) -> Option { impl TraversalCacheDocument { fn into_document(self) -> GraphDocument { - let Self(directed, multigraph, nodes, links) = self; + let Self(directed, multigraph, nodes, links, source_commit) = self; let nodes = nodes .into_iter() .map(|node| { @@ -1437,7 +1472,9 @@ impl TraversalCacheDocument { GraphDocument { directed, multigraph, - graph: Map::new(), + graph: source_commit.map_or_else(Map::new, |commit| { + Map::from_iter([("build".into(), serde_json::json!({"sourceCommit":commit}))]) + }), nodes, links, extras: BTreeMap::new(), @@ -1772,6 +1809,27 @@ fn insert_optional_value(attributes: &mut Map, key: &str, value: #[cfg(test)] mod tests { + #[test] + fn traversal_cache_retains_only_the_pinned_build_revision() + -> Result<(), Box> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("graph.json"); + let commit = "a".repeat(40); + fs::write( + &path, + serde_json::to_vec(&serde_json::json!({ + "graph":{"build":{"sourceCommit":commit},"files":[{"large":"discard"}]}, + "nodes":[{"id":"a"}],"links":[] + }))?, + )?; + for _ in 0..2 { + let document = GraphDocument::load_for_traversal(&path)?; + assert_eq!(document.graph["build"]["sourceCommit"], commit); + assert!(!document.graph.contains_key("files")); + } + Ok(()) + } + use std::fs; use super::{ diff --git a/crates/compass-model/src/graph.rs b/crates/compass-model/src/graph.rs index 5ba445491..1862ce2af 100644 --- a/crates/compass-model/src/graph.rs +++ b/crates/compass-model/src/graph.rs @@ -14,6 +14,7 @@ pub type EdgeIndex = usize; /// Query-oriented directed graph preserving document insertion order. #[derive(Clone, Debug)] pub struct Graph { + source_commit: Option, directed: bool, multigraph: bool, nodes: Arc>, @@ -108,6 +109,13 @@ impl Graph { document: GraphDocument, build_query_index: bool, ) -> Result { + let source_commit = document + .graph + .get("build") + .and_then(|build| build.get("sourceCommit")) + .or_else(|| document.graph.get("built_at_commit")) + .and_then(Value::as_str) + .map(str::to_owned); let GraphDocument { directed, multigraph, @@ -154,6 +162,7 @@ impl Graph { Arc::new(edges), Arc::new(ids), build_query_index, + source_commit, )?; Ok(graph) } @@ -165,6 +174,7 @@ impl Graph { edges: Arc>, ids: Arc>, build_query_index: bool, + source_commit: Option, ) -> Result { let mut outgoing = vec![Vec::new(); nodes.len()]; let mut incoming = vec![Vec::new(); nodes.len()]; @@ -189,6 +199,7 @@ impl Graph { QueryIndex::empty() }; Ok(Self { + source_commit, directed, multigraph, nodes, @@ -201,6 +212,11 @@ impl Graph { }) } + #[must_use] + pub fn source_commit(&self) -> Option<&str> { + self.source_commit.as_deref() + } + #[must_use] pub fn node_count(&self) -> usize { self.nodes.len() @@ -358,6 +374,7 @@ impl Graph { Arc::new(edges), Arc::clone(&self.ids), self.query_index_enabled, + self.source_commit.clone(), ) { Ok(graph) => graph, Err(_) => self.clone(), @@ -418,6 +435,28 @@ pub(crate) fn absolute_path(path: &Path) -> PathBuf { #[cfg(test)] mod tests { + #[test] + fn pinned_build_commit_survives_graph_projections() -> Result<(), Box> { + let commit = "a".repeat(40); + let document: GraphDocument = serde_json::from_value(serde_json::json!({ + "graph":{"build":{"sourceCommit":commit}}, + "nodes":[{"id":"a"},{"id":"b"}], + "links":[{"source":"a","target":"b","context":"call"}] + }))?; + for graph in [ + Graph::from_document(document.clone())?, + Graph::from_traversal_document(document)?, + ] { + assert_eq!(graph.source_commit(), Some(commit.as_str())); + assert_eq!(graph.clone().source_commit(), Some(commit.as_str())); + assert_eq!( + graph.with_edge_contexts(&["other".into()]).source_commit(), + Some(commit.as_str()) + ); + } + Ok(()) + } + use super::*; #[test] diff --git a/crates/compass-model/tests/traversal_confidence.rs b/crates/compass-model/tests/traversal_confidence.rs index 28ad5c8c0..b4cf268b8 100644 --- a/crates/compass-model/tests/traversal_confidence.rs +++ b/crates/compass-model/tests/traversal_confidence.rs @@ -36,6 +36,6 @@ fn traversal_projection_preserves_weakest_confidence_and_deferred_state() let graph = GraphDocument::load_for_traversal(&path)?; assert_eq!(graph.links[0].string("confidence"), "INFERRED"); assert_eq!(graph.links[2].boolean("deferred"), Some(true)); - assert_eq!(&fs::read(cache)?[..8], b"TRAILT07"); + assert_eq!(&fs::read(cache)?[..8], b"TRAILT08"); Ok(()) } diff --git a/crates/compass-output/src/lib.rs b/crates/compass-output/src/lib.rs index 4c857ec09..e97aaaf9c 100644 --- a/crates/compass-output/src/lib.rs +++ b/crates/compass-output/src/lib.rs @@ -15,6 +15,7 @@ mod html; mod json; mod lenses; mod obsidian; +mod overview; mod palette; mod report; mod review; @@ -25,6 +26,7 @@ mod viewer_model; mod wiki; mod workbench; +pub use overview::render_hotspots_text; pub use text_budget::{BudgetedText, render_budgeted_text}; pub use agent_query::{ diff --git a/crates/compass-output/src/overview.rs b/crates/compass-output/src/overview.rs new file mode 100644 index 000000000..7ad0e7444 --- /dev/null +++ b/crates/compass-output/src/overview.rs @@ -0,0 +1,84 @@ +use compass_query::{ConnectionLayer, HotspotReport, sanitize_label}; + +#[must_use] +pub fn render_hotspots_text(report: &HotspotReport) -> String { + let mut lines = vec![format!( + "Hotspots: {} scoped symbols; structural{}{} evidence; distinct directional contacts.", + report.selected_symbols, + if report.included_layers.inferred { + "+inferred" + } else { + "" + }, + if report.included_layers.documents { + "+documents" + } else { + "" + } + )]; + for (title, rows) in [ + ("Most connected", &report.most_connected), + ("Most depended on", &report.most_depended_on), + ] { + if title == "Most depended on" && !report.directed { + lines.push("Dependents unavailable: graph is undirected.".into()); + continue; + } + lines.push(format!("{title}:")); + for row in rows { + let layers = row + .layers + .iter() + .map(|(layer, count)| { + let name = match layer { + ConnectionLayer::Structural => "structural", + ConnectionLayer::Inferred => "inferred", + ConnectionLayer::Document => "document", + }; + format!("{name}:{count}") + }) + .collect::>() + .join(","); + let layers = if report.included_layers.inferred || report.included_layers.documents { + format!(" layers={layers}") + } else { + String::new() + }; + lines.push(format!( + " {} {}:{} connections={} dependents={}{layers}", + sanitize_label(&row.name), + sanitize_label(row.source_file.as_deref().unwrap_or("?")), + sanitize_label(&row.source_location), + row.connections, + row.dependents + )); + } + } + lines.push(format!("{} symbols omitted; {} relationship records excluded. Counts cover published graph evidence. Use --limit 100 or --scope PATH for more detail.", report.omitted_symbols, report.excluded_relationship_records)); + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn compact_hotspots_retain_locations_and_do_not_claim_undirected_dependents() + -> Result<(), Box> { + let document = serde_json::from_value(serde_json::json!({"directed":false,"nodes":[ + {"id":"a","label":"Service\nspoof","kind":"class","source_file":"a.rs","source_location":"L2"}, + {"id":"b","label":"Client","kind":"function","source_file":"b.rs","source_location":"L3"}], + "links":[{"source":"a","target":"b","relation":"calls"}]}))?; + let report = compass_query::hotspots( + &document, + &compass_query::OverviewScope::new(Vec::new())?, + Default::default(), + 10, + )?; + let text = render_hotspots_text(&report); + assert!(text.contains("a.rs:L2")); + assert!(text.contains("Dependents unavailable: graph is undirected")); + assert!(!text.contains("\nspoof")); + assert!(text.len().div_ceil(4) < 200); + Ok(()) + } +} diff --git a/crates/compass-query/Cargo.toml b/crates/compass-query/Cargo.toml index 74f2c6544..504c55447 100644 --- a/crates/compass-query/Cargo.toml +++ b/crates/compass-query/Cargo.toml @@ -13,6 +13,7 @@ categories.workspace = true [dependencies] compass-agent-graph = { path = "../compass-agent-graph", version = "0.4.1" } +compass-files = { path = "../compass-files", version = "0.4.1" } regex.workspace = true rusqlite.workspace = true serde.workspace = true @@ -32,8 +33,6 @@ libc = "0.2" rustix.workspace = true [dev-dependencies] -# Reuse bounded atomic report publication in offline qualification examples. -compass-files = { path = "../compass-files", version = "0.4.1" } compass-graphdb = { path = "../compass-graphdb", version = "0.4.1" } compass-languages = { path = "../compass-languages", version = "0.4.1" } compass-store-redb = { path = "../compass-store-redb", version = "0.4.1" } diff --git a/crates/compass-query/src/code_query.rs b/crates/compass-query/src/code_query.rs index 79afbd335..3a5cf532c 100644 --- a/crates/compass-query/src/code_query.rs +++ b/crates/compass-query/src/code_query.rs @@ -354,6 +354,7 @@ pub struct CodeQueryEngine { pub(crate) engine_kind: QueryEngineKind, pub(crate) graph_identity: String, pub(crate) build_generation_identity: String, + pub(crate) source_commit: Option, pub(crate) search_query_cache: Mutex, pub(crate) fuzzy_lookup_cache: Mutex, pub(crate) deadline: Option, @@ -3595,6 +3596,12 @@ impl CodeQueryEngine { &self.build_generation_identity } + /// Source revision of this pinned graph, independent of response caching. + #[must_use] + pub fn source_commit(&self) -> Option<&str> { + self.source_commit.as_deref() + } + pub(crate) fn owner_qualified_candidates( &self, normalized: &str, diff --git a/crates/compass-query/src/discovery.rs b/crates/compass-query/src/discovery.rs index 382fa6bb9..2eda420f3 100644 --- a/crates/compass-query/src/discovery.rs +++ b/crates/compass-query/src/discovery.rs @@ -2946,6 +2946,7 @@ mod tests { .unwrap_or_else(|_| std::process::abort()); } CodeQueryEngine { + source_commit: None, backend: CodeGraphBackend::Materialized { graph: Box::new(graph), adjacency: Box::new(adjacency), diff --git a/crates/compass-query/src/graph_engine.rs b/crates/compass-query/src/graph_engine.rs index c6cef50ac..03a6d5019 100644 --- a/crates/compass-query/src/graph_engine.rs +++ b/crates/compass-query/src/graph_engine.rs @@ -165,7 +165,7 @@ impl JsonGraphEngine { QueryError::new( QueryErrorKind::CorruptArtifact, "graph_load_failed", - error.to_string(), + format!("{error}; run compass ensure to publish a graph, or select an existing graph with --graph PATH"), ) })?; validate_graph_schema(&graph)?; @@ -202,6 +202,7 @@ pub(crate) struct LocalStoreSnapshot { pub(crate) store_path: PathBuf, pub(crate) graph_identity: String, pub(crate) build_generation_identity: String, + pub(crate) source_commit: Option, pub(crate) partial_graph_message: Option, } @@ -389,7 +390,7 @@ pub(crate) fn open_local_store_snapshot( )); } let graph_identity = reader.manifest().graph_digest.clone(); - let build_generation_identity = reader + let build = reader .metadata_summary() .map_err(|error| { QueryError::new( @@ -399,8 +400,9 @@ pub(crate) fn open_local_store_snapshot( ) })? .graph - .build - .generation_id; + .build; + let source_commit = build.source_commit; + let build_generation_identity = build.generation_id; let partial_graph_message = reader .graph_diagnostic_by_code("publication_omission_summary") .map_err(|error| { @@ -423,6 +425,7 @@ pub(crate) fn open_local_store_snapshot( store_path, graph_identity, build_generation_identity, + source_commit, partial_graph_message, }) } @@ -479,7 +482,7 @@ pub(crate) fn read_store_ref(graph_path: &Path) -> Result return Err(QueryError::new( QueryErrorKind::CorruptArtifact, "store_ref_missing", - "store.ref is required for an immutable graph snapshot", + "store.ref is required for an immutable graph snapshot; run compass update --store sqlite to publish store data, or use --engine json --graph PATH for an existing JSON graph", )); } let size = fs::metadata(&reference_path) diff --git a/crates/compass-query/src/index.rs b/crates/compass-query/src/index.rs index e993958de..415532fce 100644 --- a/crates/compass-query/src/index.rs +++ b/crates/compass-query/src/index.rs @@ -339,6 +339,7 @@ fn open_from_graph_engine( let graph = graph_engine.graph().clone(); let graph_identity = graph_engine.graph_identity().to_owned(); let build_generation_identity = graph.graph.build.generation_id.clone(); + let source_commit = graph.graph.build.source_commit.clone(); let engine_kind = graph_engine.kind(); let (program, program_digest) = load_program(program_path)?; let key = index_key( @@ -390,6 +391,7 @@ fn open_from_graph_engine( engine_kind, graph_identity, build_generation_identity, + source_commit, search_query_cache: std::sync::Mutex::new(Default::default()), fuzzy_lookup_cache: std::sync::Mutex::new(Default::default()), deadline: None, @@ -407,6 +409,7 @@ fn open_from_local_store( let snapshot = open_local_store_snapshot(graph_path)?; let graph_identity = snapshot.graph_identity.clone(); let build_generation_identity = snapshot.build_generation_identity.clone(); + let source_commit = snapshot.source_commit.clone(); let partial_graph_message = snapshot.partial_graph_message.clone(); let (program, _) = load_program(program_path)?; let index_path = snapshot.store_path.clone(); @@ -422,6 +425,7 @@ fn open_from_local_store( engine_kind: QueryEngineKind::Store, graph_identity, build_generation_identity, + source_commit, search_query_cache: std::sync::Mutex::new(Default::default()), fuzzy_lookup_cache: std::sync::Mutex::new(Default::default()), deadline: None, @@ -437,7 +441,9 @@ fn load_program( let Some(path) = path else { return Ok((None, None)); }; - let bytes = fs::read(path).map_err(|error| io_error("read_program", error))?; + let bytes = compass_files::read_bytes_bounded(path, 2 * 1024 * 1024 * 1024).map_err(|error| { + QueryError::new(QueryErrorKind::Internal, "read_program", format!("Program IR unavailable at {}: {error}; run compass update --program, or select an existing artifact with --program PATH", path.display())) + })?; let program = serde_json::from_slice::(&bytes).map_err(|error| { QueryError::new( QueryErrorKind::CorruptArtifact, diff --git a/crates/compass-query/src/lib.rs b/crates/compass-query/src/lib.rs index 246f86f13..28d502766 100644 --- a/crates/compass-query/src/lib.rs +++ b/crates/compass-query/src/lib.rs @@ -17,6 +17,7 @@ mod index; mod intent; mod natural_answers; mod neighbors; +mod overview; mod program_join; mod ranking; mod recall; @@ -67,6 +68,10 @@ pub use neighbors::{ MAX_NEIGHBOR_ADJACENCY_ENTRIES, MAX_NEIGHBOR_RECORDS, MAX_NEIGHBOR_RESPONSE_BYTES, NeighborDirection, NeighborError, NeighborGroup, NeighborReport, direct_neighbors, }; +pub use overview::{ + ConnectionLayer, ConnectionLayers, Hotspot, HotspotReport, MAX_HOTSPOTS, MAX_OVERVIEW_EDGES, + MAX_OVERVIEW_NODES, OverviewError, OverviewScope, ScopeSelection, hotspots, scoped_document, +}; pub use program_join::join_program_evidence; pub use ranking::QUERY_RANKER_PROFILE_V1; pub use relevance::{ diff --git a/crates/compass-query/src/overview.rs b/crates/compass-query/src/overview.rs new file mode 100644 index 000000000..9ed588226 --- /dev/null +++ b/crates/compass-query/src/overview.rs @@ -0,0 +1,580 @@ +//! Bounded source scopes and evidence-labelled topology summaries. +use std::collections::{BTreeMap, BTreeSet}; + +use compass_model::{EdgeRecord, GraphDocument, NodeRecord}; +use serde::Serialize; +use thiserror::Error; + +pub const MAX_OVERVIEW_NODES: usize = 1_000_000; +pub const MAX_OVERVIEW_EDGES: usize = 2_000_000; +pub const MAX_HOTSPOTS: usize = 100; + +#[derive(Debug, Error)] +pub enum OverviewError { + #[error("invalid scope: {0}; use a relative path or module:NAME")] + Scope(String), + #[error( + "overview exceeds its graph work limit; build a smaller graph with compass ensure PATH" + )] + WorkLimit, + #[error("hotspot limit must be between 1 and 100")] + Limit, + #[error("overview label exceeds 4096 bytes; use a graph with bounded symbol metadata")] + MetadataLimit, + #[error( + "overview graph has duplicate identities or dangling relationships; run compass ensure to publish a valid graph" + )] + InvalidGraph, + #[error( + "unsupported overview graph schema {0}; run compass update to publish a supported graph" + )] + UnsupportedSchema(String), +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OverviewScope { + pub filters: Vec, +} + +impl OverviewScope { + pub fn new(filters: Vec) -> Result { + if filters.len() > 16 { + return Err(OverviewError::Scope( + "at most 16 filters are allowed".into(), + )); + } + let mut normalized = BTreeSet::new(); + for filter in filters { + let filter = filter.trim().replace('\\', "/"); + let value = filter + .strip_prefix("module:") + .or_else(|| filter.strip_prefix("path:")) + .unwrap_or(&filter); + if value.is_empty() + || filter.len() > 4096 + || value.starts_with('/') + || value.split('/').any(|part| part == "..") + || value.chars().any(char::is_control) + || (!filter.starts_with("module:") && value.contains(':')) + { + return Err(OverviewError::Scope(filter)); + } + let normalized_filter = if filter.starts_with("module:") { + format!("module:{}", value.trim_end_matches('/')) + } else { + let path = value.trim_start_matches("./").trim_end_matches('/'); + if path.is_empty() || path == "." { + return Err(OverviewError::Scope(filter)); + } + path.to_owned() + }; + normalized.insert(normalized_filter); + } + Ok(Self { + filters: normalized.into_iter().collect(), + }) + } + + pub fn matches(&self, node: &NodeRecord) -> bool { + self.filters.is_empty() + || self.filters.iter().any(|filter| { + if let Some(module) = filter.strip_prefix("module:") { + ["module", "package", "qualified_name", "qualifiedName"] + .iter() + .any(|key| { + let value = node.string(key); + value == module + || value.strip_prefix(module).is_some_and(|suffix| { + suffix.starts_with("::") + || suffix.starts_with('.') + || suffix.starts_with('/') + }) + }) + } else { + let prefix = filter.strip_prefix("path:").unwrap_or(filter); + let path = node.source_file().unwrap_or_default().replace('\\', "/"); + let path = path.trim_start_matches("./"); + path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|suffix| suffix.starts_with('/')) + } + }) + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScopeSelection { + pub scope: OverviewScope, + pub input_nodes: usize, + pub selected_nodes: usize, + pub omitted_nodes: usize, + pub omitted_relationships: usize, + pub boundary_relationships: usize, +} + +pub fn scoped_document( + document: &GraphDocument, + scope: &OverviewScope, +) -> Result<(GraphDocument, ScopeSelection), OverviewError> { + check_work(document)?; + let ids = document + .nodes + .iter() + .filter(|node| scope.matches(node)) + .map(|node| node.id.as_str()) + .collect::>(); + let nodes = document + .nodes + .iter() + .filter(|node| ids.contains(node.id.as_str())) + .cloned() + .collect::>(); + let links = document + .links + .iter() + .filter(|edge| ids.contains(edge.source.as_str()) && ids.contains(edge.target.as_str())) + .cloned() + .collect::>(); + let selection = ScopeSelection { + scope: scope.clone(), + input_nodes: document.nodes.len(), + selected_nodes: nodes.len(), + omitted_nodes: document.nodes.len() - nodes.len(), + omitted_relationships: document.links.len() - links.len(), + boundary_relationships: document + .links + .iter() + .filter(|edge| ids.contains(edge.source.as_str()) != ids.contains(edge.target.as_str())) + .count(), + }; + let scoped = GraphDocument { + directed: document.directed, + multigraph: document.multigraph, + graph: document.graph.clone(), + extras: document.extras.clone(), + nodes, + links, + }; + Ok((scoped, selection)) +} + +fn check_work(document: &GraphDocument) -> Result<(), OverviewError> { + if let Some(schema) = document + .graph + .get("schema") + .and_then(serde_json::Value::as_str) + && schema.starts_with("compass.graph/") + && schema != compass_model::code_graph::CODE_GRAPH_SCHEMA_V1 + { + return Err(OverviewError::UnsupportedSchema(crate::sanitize_label( + &schema.chars().take(64).collect::(), + ))); + } + if document.nodes.len() > MAX_OVERVIEW_NODES || document.links.len() > MAX_OVERVIEW_EDGES { + return Err(OverviewError::WorkLimit); + } + let mut ids = BTreeSet::new(); + if document + .nodes + .iter() + .any(|node| !ids.insert(node.id.as_str())) + || document + .links + .iter() + .any(|edge| !ids.contains(edge.source.as_str()) || !ids.contains(edge.target.as_str())) + { + return Err(OverviewError::InvalidGraph); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectionLayers { + pub inferred: bool, + pub documents: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectionLayer { + Structural, + Inferred, + Document, +} + +fn document_node(node: &NodeRecord) -> bool { + node.string("file_type") == "document" + || matches!( + node.kind_name(), + "document" | "document_section" | "doc_section" | "rationale" + ) + || node + .source_file() + .is_some_and(|path| path.ends_with(".md") || path.ends_with(".mdx")) +} + +fn layer( + edge: &EdgeRecord, + source: &NodeRecord, + target: &NodeRecord, +) -> Option<(ConnectionLayer, bool)> { + let confidence = edge.string("confidence").to_ascii_uppercase(); + if matches!(confidence.as_str(), "AMBIGUOUS" | "UNRESOLVED") { + return None; + } + let inferred = matches!(confidence.as_str(), "INFERRED" | "HEURISTIC"); + if document_node(source) + || document_node(target) + || matches!( + edge.relation(), + "documents" | "mentions" | "document_mentions" + ) + { + Some((ConnectionLayer::Document, inferred)) + } else if inferred { + Some((ConnectionLayer::Inferred, true)) + } else { + Some((ConnectionLayer::Structural, false)) + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Hotspot { + pub id: String, + pub name: String, + pub kind: String, + pub source_file: Option, + pub source_location: String, + pub connections: usize, + pub dependents: usize, + pub relationship_records: usize, + /// Per-layer distinct directional contacts. A contact present in two layers + /// appears in both layer counts but only once in the total. + pub layers: BTreeMap, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HotspotReport { + pub schema: &'static str, + pub scope: OverviewScope, + pub included_layers: ConnectionLayers, + pub count_policy: &'static str, + pub directed: bool, + pub selected_symbols: usize, + pub omitted_symbols: usize, + pub excluded_relationship_records: usize, + pub most_connected: Vec, + pub most_depended_on: Vec, +} + +#[derive(Default)] +struct Contacts<'a> { + total: BTreeSet<(u8, &'a str)>, + dependents: BTreeSet<&'a str>, + layers: BTreeMap>, + records: usize, +} + +/// Rank source symbols within the scope, retaining contacts from outside it. +/// Self-loops and parallel occurrences do not inflate distinct contacts. +/// Counts describe only published evidence; no missing relationships are invented. +pub fn hotspots( + document: &GraphDocument, + scope: &OverviewScope, + layers: ConnectionLayers, + limit: usize, +) -> Result { + check_work(document)?; + if !(1..=MAX_HOTSPOTS).contains(&limit) { + return Err(OverviewError::Limit); + } + let nodes = document + .nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + let mut contacts = document + .nodes + .iter() + .filter(|node| { + scope.matches(node) + && !document_node(node) + && !matches!( + node.kind_name(), + "file" | "module" | "package" | "directory" | "external" + ) + && node.source_file().is_some_and(|path| !path.is_empty()) + }) + .map(|node| (node.id.as_str(), Contacts::default())) + .collect::>(); + let mut excluded = 0; + for edge in &document.links { + let (Some(source), Some(target)) = ( + nodes.get(edge.source.as_str()), + nodes.get(edge.target.as_str()), + ) else { + continue; + }; + let Some((layer, inferred)) = layer(edge, source, target) else { + excluded += usize::from( + contacts.contains_key(edge.source.as_str()) + || contacts.contains_key(edge.target.as_str()), + ); + continue; + }; + if (inferred && !layers.inferred) + || (layer == ConnectionLayer::Document && !layers.documents) + { + excluded += usize::from( + contacts.contains_key(edge.source.as_str()) + || contacts.contains_key(edge.target.as_str()), + ); + continue; + } + if edge.source == edge.target { + continue; + } + for (id, other, direction) in [ + (edge.source.as_str(), edge.target.as_str(), 0), + (edge.target.as_str(), edge.source.as_str(), 1), + ] { + if let Some(contact) = contacts.get_mut(id) { + let contact_key = (if document.directed { direction } else { 0 }, other); + contact.total.insert(contact_key); + contact.layers.entry(layer).or_default().insert(contact_key); + if inferred && layer == ConnectionLayer::Document { + contact + .layers + .entry(ConnectionLayer::Inferred) + .or_default() + .insert(contact_key); + } + contact.records += 1; + if document.directed + && direction == 1 + && !matches!(edge.relation(), "contains" | "declares" | "member_of") + { + contact.dependents.insert(other); + } + } + } + } + for id in contacts.keys() { + let node = nodes[id]; + if [ + node.id.as_str(), + &node.display_label(), + node.kind_name(), + node.source_file().unwrap_or_default(), + &node.string("source_location"), + ] + .iter() + .any(|value| value.len() > 4096) + { + return Err(OverviewError::MetadataLimit); + } + } + let mut connected = contacts + .into_iter() + .map(|(id, counts)| { + let node = nodes[id]; + Hotspot { + id: id.to_owned(), + name: node.display_label(), + kind: node.kind_name().to_owned(), + source_file: node.source_file().map(str::to_owned), + source_location: node.string("source_location"), + connections: counts.total.len(), + dependents: counts.dependents.len(), + relationship_records: counts.records, + layers: counts + .layers + .into_iter() + .map(|(layer, values)| (layer, values.len())) + .collect(), + } + }) + .collect::>(); + let selected_symbols = connected.len(); + let mut depended = connected.clone(); + connected.sort_by(|a, b| { + b.connections + .cmp(&a.connections) + .then_with(|| a.id.cmp(&b.id)) + }); + depended.sort_by(|a, b| { + b.dependents + .cmp(&a.dependents) + .then_with(|| b.connections.cmp(&a.connections)) + .then_with(|| a.id.cmp(&b.id)) + }); + connected.truncate(limit); + if document.directed { + depended.truncate(limit); + } else { + depended.clear(); + } + Ok(HotspotReport { + schema: "compass.hotspots/1", + scope: scope.clone(), + included_layers: layers, + count_policy: "distinct-direction-neighbor; dependents=distinct-incoming-noncontainment; scoped-symbols-global-contacts", + directed: document.directed, + selected_symbols, + omitted_symbols: selected_symbols.saturating_sub(limit), + excluded_relationship_records: excluded, + most_connected: connected, + most_depended_on: depended, + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn scopes_layers_limits_and_direction_do_not_invent_contacts() + -> Result<(), Box> { + for invalid in [ + ".", + "./", + "path:./", + "module:", + "/absolute", + "C:\\absolute", + "a/../b", + ] { + assert!( + OverviewScope::new(vec![invalid.into()]).is_err(), + "{invalid}" + ); + } + let scope = OverviewScope::new(vec!["path:./src/".into()])?; + assert_eq!(scope.filters, vec!["src"]); + let mut document: GraphDocument = serde_json::from_value(serde_json::json!({ + "directed":false,"nodes":[{"id":"a","kind":"function","source_file":"src/a.rs"}, + {"id":"b","kind":"function","source_file":"src/b.rs"}], + "links":[{"source":"a","target":"b","relation":"uses","confidence":"AMBIGUOUS"}] + }))?; + let layers = ConnectionLayers { + inferred: true, + documents: true, + }; + let ambiguous = hotspots(&document, &scope, layers, 10)?; + assert_eq!(ambiguous.most_connected[0].connections, 0); + assert_eq!(ambiguous.excluded_relationship_records, 1); + document.links[0].attributes.remove("confidence"); + let undirected = hotspots(&document, &scope, layers, 1)?; + assert_eq!(undirected.most_connected[0].id, "a"); + assert_eq!(undirected.most_connected[0].connections, 1); + assert!(undirected.most_depended_on.is_empty()); + assert_eq!(undirected.omitted_symbols, 1); + document.nodes[0].id = "x".repeat(4097); + document.links[0].source = document.nodes[0].id.clone(); + assert!(matches!( + hotspots(&document, &scope, layers, 10), + Err(OverviewError::MetadataLimit) + )); + document + .graph + .insert("schema".into(), serde_json::json!("compass.graph/99")); + assert!(matches!( + hotspots(&document, &scope, layers, 10), + Err(OverviewError::UnsupportedSchema(_)) + )); + Ok(()) + } + + use super::*; + + #[test] + fn inferred_document_contacts_require_both_layers_and_keep_both_labels() + -> Result<(), Box> { + let document = serde_json::from_value(serde_json::json!({"directed":true,"nodes":[ + {"id":"a","kind":"class","source_file":"app/a.py"}, + {"id":"doc","kind":"document_section","source_file":"README.md"}], + "links":[{"source":"doc","target":"a","relation":"mentions","confidence":"INFERRED"}]}))?; + let scope = OverviewScope::new(Vec::new())?; + let only_docs = hotspots( + &document, + &scope, + ConnectionLayers { + inferred: false, + documents: true, + }, + 10, + )?; + assert_eq!(only_docs.most_connected[0].connections, 0); + assert_eq!(only_docs.excluded_relationship_records, 1); + let both = hotspots( + &document, + &scope, + ConnectionLayers { + inferred: true, + documents: true, + }, + 10, + )?; + assert_eq!(both.most_connected[0].connections, 1); + assert_eq!(both.most_connected[0].layers[&ConnectionLayer::Inferred], 1); + assert_eq!(both.most_connected[0].layers[&ConnectionLayer::Document], 1); + Ok(()) + } + + #[test] + fn scopes_use_component_boundaries_and_preserve_only_internal_edges() + -> Result<(), Box> { + let document: GraphDocument = serde_json::from_value(serde_json::json!({"nodes":[ + {"id":"a","source_file":"app/services/a.py","qualified_name":"app.services.A"}, + {"id":"b","source_file":"app/services_extra/b.py","qualified_name":"app.services_extra.B"}], + "links":[{"source":"b","target":"a","relation":"calls"}]}))?; + for filter in ["app/services", "module:app.services"] { + let (scoped, selection) = + scoped_document(&document, &OverviewScope::new(vec![filter.into()])?)?; + assert_eq!(scoped.nodes.len(), 1); + assert_eq!(scoped.nodes[0].id, "a"); + assert!(scoped.links.is_empty()); + assert_eq!(selection.boundary_relationships, 1); + } + assert!(OverviewScope::new(vec!["../app".into()]).is_err()); + Ok(()) + } + + #[test] + fn hotspot_layers_keep_occurrences_distinct_from_contacts_and_scope_from_contacts() + -> Result<(), Box> { + let document: GraphDocument = serde_json::from_value( + serde_json::json!({"directed":true,"multigraph":true,"nodes":[ + {"id":"a","kind":"class","source_file":"app/a.py"}, {"id":"b","kind":"function","source_file":"other/b.py"}, + {"id":"c","kind":"function","source_file":"other/c.py"}, {"id":"d","source_file":"README.md"}], + "links":[{"source":"b","target":"a","relation":"calls","confidence":"EXTRACTED"}, + {"source":"b","target":"a","relation":"calls","confidence":"EXTRACTED"}, + {"source":"c","target":"a","relation":"uses","confidence":"INFERRED"}, + {"source":"d","target":"a","relation":"mentions","confidence":"EXTRACTED"}, + {"source":"a","target":"a","relation":"calls","confidence":"EXTRACTED"}]}), + )?; + let scope = OverviewScope::new(vec!["app".into()])?; + let base = hotspots(&document, &scope, ConnectionLayers::default(), 10)?; + assert_eq!(base.most_connected[0].connections, 1); + assert_eq!(base.most_connected[0].relationship_records, 2); + assert_eq!(base.excluded_relationship_records, 2); + let all = hotspots( + &document, + &scope, + ConnectionLayers { + inferred: true, + documents: true, + }, + 10, + )?; + assert_eq!(all.most_connected[0].connections, 3); + assert_eq!(all.most_connected[0].dependents, 3); + assert_eq!(all.most_connected[0].layers.len(), 3); + assert!(hotspots(&document, &scope, ConnectionLayers::default(), 0).is_err()); + Ok(()) + } +} diff --git a/docs/reference/commands.md b/docs/reference/commands.md index fbd9925fa..d4dd0c080 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -648,6 +648,7 @@ are recomputed, and cache failures fall back to native execution. compass architecture [--graph PATH] [--labels PATH] + [--scope PATH|module:NAME] [--format text|json|agent-json] ``` @@ -673,6 +674,27 @@ escape control and bidirectional-text characters; `boundedFields` records which sample fields were changed, and `omittedSampleNodes` counts selected IDs that could not be included. +Use repeatable `--scope app/services/lookup_tables` or `--scope module:app.services` +to select one area. Scope counts disclose omitted nodes and boundary links. +Scoped machine output wraps the projection in `compass.architecture.scoped-view/1`. + +### `community` and `hotspots` + +```bash +compass community --scope app/services --limit 20 +compass community 0 --scope module:app.services --format json +compass hotspots --scope app/services --limit 10 +compass hotspots --include-inferred --include-documents --format json +``` + +Community views list bounded membership and explicit omissions. Hotspots rank +most connected and most depended-on source symbols. Optional inferred and +document layers stay labelled; ambiguous targets stay excluded. Scoped hotspot +symbols retain their outside contacts. Text is compact and source-located; JSON +retains exact IDs, contact counts, record counts and layer policy. Both commands +accept `--graph PATH`, `--format text|json`, `--limit 1..100` and `--budget N`. +See [output contracts](outputs.md#scoped-overviews-and-hotspots) for count semantics. + ### `path` ```text diff --git a/docs/reference/outputs.md b/docs/reference/outputs.md index 34ba5af1d..97d1611f0 100644 --- a/docs/reference/outputs.md +++ b/docs/reference/outputs.md @@ -1433,3 +1433,58 @@ Excerpts use the graph's recorded callable spans. An annotation or decorator outside those spans, such as Python's `@property`, may be absent even when a member is fully returned. Use the declaration excerpt when that surrounding context is needed; member mode does not guarantee more evidence for every fact. + +### Scoped overviews and hotspots + +`architecture --scope PATH|module:NAME` uses a source path prefix on component +boundaries or a qualified module prefix. Up to 16 repeatable scopes form a union. +The view retains only relationships between selected nodes. Text discloses +selected totals, omitted records and boundary records. Scoped JSON and agent +JSON wrap the existing projection in `compass.architecture.scoped-view/1` with +`selection` and `result`; unscoped schemas retain their meaning. Empty scope +matches produce an explicit empty selection. Absolute paths and parent traversal +are rejected. Graph work is capped at one million nodes and two million edges. + +`community [ID]` uses the same scope and emits `compass.communities/1`. Its +`--limit` (1–100, default 20) caps both communities and members per community, +with separate omission counts. IDs and membership are ordered deterministically. +MCP `get_community` adds optional `scope` and `limit` (1–1000000); omitting limit +preserves the existing full-membership behavior within the transport bound. + +`hotspots` and MCP `get_hotspots` return `compass.hotspots/1`. They rank source +symbols by distinct `(direction, neighbor)` contacts and by distinct incoming +non-containment dependents, breaking ties by stable node ID. A scope selects +candidate symbols; their contacts outside it remain counted. Self-loops do not +count, and parallel call occurrences do not inflate contacts. Each row retains +`relationshipRecords` separately. Undirected graphs have no dependent ranking. +The default structural layer excludes inferred and document contacts. Enable +`--include-inferred` / `include_inferred` and `--include-documents` / +`include_documents` to include them. An inferred document contact requires both +flags and retains both labels. Per-layer contacts can overlap; the total +still deduplicates them. Ambiguous and unresolved targets never become contacts. +Counts cover published evidence, not unseen runtime dependencies. Limits are +1–100 rows per ranking, default 10; labels over 4096 bytes fail explicitly. + +### Advisory graph freshness + +CLI graph answers emit freshness warnings on stderr, preserving JSON stdout. +The finite output budget includes these warnings. MCP tool envelopes add an +optional `freshness` object with schema `compass.graph-freshness/1`; resource +transport metadata carries the same object. Human-readable resources also show +the warning. JSON resource bodies preserve their existing schema. + +Checks use the source root recorded beside the selected artifact, never the +caller's working directory. The pinned graph build commit is compared to HEAD, +tracked working bytes and staged/untracked files. `changedFiles` counts distinct +repository paths different from the build or present in status; renames are two +paths. `uncommittedFiles` counts status entries. Files outside an index scope can +be counted: these are repository-state observations, not proof every indexed +symbol is stale. The statuses are `current`, `revision_changed`, +`working_tree_changes` and `unknown`. Missing commits, roots, Git, timeout or +output limits yield `unknown`, never `current`. Warnings show the recorded root's +update command, including the selected output container. Rebuilds publish a new +generation; select the output container’s public `graph.json` alias afterward +when the original request selected an immutable snapshot path. Freshness is checked again for each request, including cached +answers. Standalone and historical artifacts without a source-root marker have +no advisory check. Program IR currently has no build commit; its own marked +artifact reports unknown freshness rather than borrowing a graph's revision. From 57afb3fd793f1370b56c85d64809a0eaea713eba Mon Sep 17 00:00:00 2001 From: forhappy Date: Fri, 2 Oct 2026 14:46:44 -0700 Subject: [PATCH 2/4] Retain build revisions in legacy impact caches --- COMPATIBILITY.md | 6 +++--- MIGRATION.md | 3 ++- crates/compass-model/src/document.rs | 28 ++++++++++++++++++++++------ 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index d8f17878b..6043437be 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -54,7 +54,7 @@ results for the known nested-anchor limitation in that stable release. Graph query, impact and traversal caches now bind to the SHA-256 of the bounded JSON bytes read, including when file size and modification time are unchanged. -Their disposable header versions advance to `TRAILG02`, `TRAILA03` and +Their disposable header versions advance to `TRAILG02`, `TRAILA04` and `TRAILT08`. Typed content caches use `.content-v2.cache` and `CGRPHV02`; their cache key, schema admission and decoded document come from one byte snapshot. Older cache formats are ignored and rebuilt from the graph. @@ -1737,6 +1737,6 @@ unchanged. Opt-in scoped architecture output uses the new MCP consumers with closed outer envelopes must admit optional `freshness`. CLI machine stdout remains unchanged; warnings use stderr and are budgeted. Graph, Program IR and immutable historical schemas are unchanged. Disposable -traversal cache header `TRAILT08` retains the pinned build commit; older caches -are ignored and rebuilt. Missing Program IR exits with code 3 and recovery +impact/traversal cache headers `TRAILA04` and `TRAILT08` retain the pinned build +commit; older caches are ignored and rebuilt. Missing Program IR exits with code 3 and recovery commands; unknown Program subcommands exit with code 2 before artifact access. diff --git a/MIGRATION.md b/MIGRATION.md index 55aac2364..d0de600fd 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,7 +12,8 @@ scoped architecture JSON has a new wrapper; read the original projection from `result`. Unscoped JSON retains its existing shape. Strict MCP envelope decoders must allow optional `freshness`; resource transport metadata can also contain it. CLI JSON remains on stdout, with advisory warnings on stderr. Older traversal -caches rebuild automatically. Program signatures require `compass update --program`; +and impact caches rebuild automatically. Program signatures require +`compass update --program`; use `--program PATH` when selecting an already published artifact. ## Compact output and budgets diff --git a/crates/compass-model/src/document.rs b/crates/compass-model/src/document.rs index fc5465dbb..efbe00d5d 100644 --- a/crates/compass-model/src/document.rs +++ b/crates/compass-model/src/document.rs @@ -821,7 +821,19 @@ impl GraphDocument { Self { directed: self.directed, multigraph: self.multigraph, - graph: Map::new(), + graph: self + .graph + .get("build") + .and_then(|build| build.get("sourceCommit")) + .or_else(|| self.graph.get("built_at_commit")) + .and_then(Value::as_str) + .filter(|value| { + matches!(value.len(), 40 | 64) + && value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + .map_or_else(Map::new, |commit| { + Map::from_iter([("build".into(), serde_json::json!({"sourceCommit":commit}))]) + }), nodes, links, extras: BTreeMap::new(), @@ -830,7 +842,7 @@ impl GraphDocument { } const QUERY_CACHE_MAGIC: &[u8; 8] = b"TRAILG02"; -const AFFECTED_CACHE_MAGIC: &[u8; 8] = b"TRAILA03"; +const AFFECTED_CACHE_MAGIC: &[u8; 8] = b"TRAILA04"; const TRAVERSAL_CACHE_MAGIC: &[u8; 8] = b"TRAILT08"; const QUERY_CACHE_HEADER_LEN: usize = 48; static QUERY_CACHE_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -1810,7 +1822,7 @@ fn insert_optional_value(attributes: &mut Map, key: &str, value: #[cfg(test)] mod tests { #[test] - fn traversal_cache_retains_only_the_pinned_build_revision() + fn query_projection_caches_retain_only_the_pinned_build_revision() -> Result<(), Box> { let temp = tempfile::tempdir()?; let path = temp.path().join("graph.json"); @@ -1823,9 +1835,13 @@ mod tests { }))?, )?; for _ in 0..2 { - let document = GraphDocument::load_for_traversal(&path)?; - assert_eq!(document.graph["build"]["sourceCommit"], commit); - assert!(!document.graph.contains_key("files")); + for document in [ + GraphDocument::load_for_traversal(&path)?, + GraphDocument::load_for_affected(&path)?, + ] { + assert_eq!(document.graph["build"]["sourceCommit"], commit); + assert!(!document.graph.contains_key("files")); + } } Ok(()) } From 207244699a7cea4e2354b8b75997f8f337d2aa3b Mon Sep 17 00:00:00 2001 From: forhappy Date: Fri, 2 Oct 2026 15:24:41 -0700 Subject: [PATCH 3/4] Avoid Git filter and submodule processes during freshness checks --- SECURITY.md | 5 +- crates/compass-cli/tests/usability_cli.rs | 19 +++++++ crates/compass-core/src/freshness.rs | 68 ++++++++++++++++++++++- docs/reference/outputs.md | 4 +- 4 files changed, 93 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 1f5423abf..1f0ce2529 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -162,7 +162,10 @@ artifact, canonicalize its absolute root, and invoke Git with separate arguments They do not infer the root from the caller's current directory. Each invocation has a 500 ms deadline and bounded captured output (admitted up to 1 MiB). Lazy object fetching, filesystem monitors, external diff programs, text -conversions and rename heuristics are disabled. Git versions without the +conversions and rename heuristics are disabled. Configured clean/process +conversion filters and indexed submodules cause unknown freshness before +working-file checks, avoiding filter execution and child repository processes. +Git versions without the no-lazy-fetch option report unknown freshness. These are read-only local observations; no checkout, hook, network fetch or graph rewrite is performed. Failed checks remain unknown. The marked root can be outside the current project; its path appears in the diff --git a/crates/compass-cli/tests/usability_cli.rs b/crates/compass-cli/tests/usability_cli.rs index 4acf1bd35..a2250bb8f 100644 --- a/crates/compass-cli/tests/usability_cli.rs +++ b/crates/compass-cli/tests/usability_cli.rs @@ -15,6 +15,8 @@ fn token_count(text: &str) -> usize { fn execute(root: &Path, args: &[&str]) -> Result> { Ok(support::compass_command() .current_dir(root) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", root.join(".unused-global-config")) .args(args) .output()?) } @@ -185,6 +187,8 @@ fn hotspot_counts_keep_optional_evidence_and_global_contacts_explicit() -> Resul fn git(root: &Path, args: &[&str]) -> Result> { let output = Command::new("git") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", root.join(".unused-global-config")) .args(["-c", "commit.gpgsign=false", "-c", "core.fsmonitor=false"]) .arg("-c") .arg(format!( @@ -282,6 +286,21 @@ fn freshness_follows_the_recorded_root_and_is_rechecked_on_cached_queries() .saturating_add(token_count(&String::from_utf8(capped.stderr)?)) <= 200 ); + git( + root, + &[ + "config", + "filter.spy.clean", + "echo invoked > freshness-filter-ran", + ], + )?; + fs::write(root.join(".gitattributes"), "src/lib.rs filter=spy\n")?; + fs::write(root.join("src/lib.rs"), "filtered change")?; + let filtered = execute(elsewhere.path(), &args)?; + assert!(filtered.status.success()); + assert!(String::from_utf8_lossy(&filtered.stderr).contains("conversion filters")); + assert!(!root.join("freshness-filter-ran").exists()); + let _: Value = serde_json::from_slice(&filtered.stdout)?; fs::remove_file(root.join("source-root.txt"))?; assert!( !String::from_utf8_lossy(&execute(elsewhere.path(), &args)?.stderr).contains("Graph built") diff --git a/crates/compass-core/src/freshness.rs b/crates/compass-core/src/freshness.rs index 8c0813bba..e9d1a5bbd 100644 --- a/crates/compass-core/src/freshness.rs +++ b/crates/compass-core/src/freshness.rs @@ -7,6 +7,13 @@ use compass_files::read_bytes_bounded; use compass_prs::{ProcessRunner, SystemRunner}; use serde::Serialize; +const FILTER_CONFIG_ARGUMENTS: &[&str] = &[ + "config", + "--name-only", + "--get-regexp", + r"^filter\..*\.(clean|process)$", +]; + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct GraphFreshness { @@ -166,6 +173,10 @@ fn graph_freshness_with_runner( let output = runner .run("git", &arguments, Duration::from_millis(500)) .map_err(|_| "bounded Git freshness check unavailable")?; + // Exit 1 means this exact config lookup found no matching keys. + if output.code == 1 && args == FILTER_CONFIG_ARGUMENTS && output.stdout.is_empty() { + return Ok(String::new()); + } if output.code != 0 { return Err( "Git cannot verify the graph's recorded revision in this source root".into(), @@ -187,6 +198,18 @@ fn graph_freshness_with_runner( return Err("Git returned an invalid HEAD revision".into()); } result.head_commit = Some(head.to_owned()); + // Diff/status may invoke clean/process filters or child Git commands + // in submodules. Do not inspect working bytes across those boundaries. + if !git(FILTER_CONFIG_ARGUMENTS)?.is_empty() { + return Err("Git conversion filters prevent an offline working-tree check".into()); + } + let modes = git(&["ls-files", "--format=%(objectmode)", "-z"])?; + if !modes.is_empty() && !modes.ends_with('\0') { + return Err("Git returned malformed index modes".into()); + } + if modes.split_terminator('\0').any(|mode| mode == "160000") { + return Err("submodule state is outside the bounded freshness check".into()); + } let diff = git(&[ "diff", "--no-ext-diff", @@ -254,10 +277,13 @@ fn shell_quote(text: &str) -> String { mod tests { use super::*; use compass_prs::{ProcessOutput, PrsError}; + #[derive(Default)] struct Fake { head: String, status: &'static str, fail: bool, + filters: bool, + submodules: bool, } impl ProcessRunner for Fake { fn run( @@ -277,6 +303,18 @@ mod tests { } let stdout = if args.iter().any(|arg| arg == "rev-parse") { self.head.clone() + } else if args.iter().any(|arg| arg == "config") { + if self.filters { + "filter.spy.clean\n".into() + } else { + String::new() + } + } else if args.iter().any(|arg| arg == "ls-files") { + if self.submodules { + "160000\0".into() + } else { + "100644\0".into() + } } else if args.iter().any(|arg| arg == "status") { self.status.into() } else if self.status.is_empty() { @@ -285,7 +323,13 @@ mod tests { "app/a.py\0".into() }; Ok(ProcessOutput { - code: i32::from(self.fail), + code: if self.fail { + 7 + } else if args.iter().any(|arg| arg == "config") && !self.filters { + 1 + } else { + 0 + }, stdout, stderr: String::new(), }) @@ -303,6 +347,7 @@ mod tests { head: commit.clone(), status: "", fail: false, + ..Fake::default() }; let invalid = graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; @@ -352,6 +397,7 @@ mod tests { head: "b".repeat(40), status: " M app/a.py\0?? new.py\0", fail: false, + ..Fake::default() }; assert!(graph_freshness_with_runner(&graph, Some(&commit), &fake).is_none()); std::fs::write( @@ -373,6 +419,26 @@ mod tests { graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; assert_eq!(current.status, FreshnessStatus::Current); assert!(current.warning().is_none()); + fake.filters = true; + let filtered = + graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; + assert_eq!(filtered.status, FreshnessStatus::Unknown); + assert!( + filtered + .reason + .is_some_and(|reason| reason.contains("conversion filters")) + ); + fake.filters = false; + fake.submodules = true; + let submodules = + graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; + assert_eq!(submodules.status, FreshnessStatus::Unknown); + assert!( + submodules + .reason + .is_some_and(|reason| reason.contains("submodule")) + ); + fake.submodules = false; fake.fail = true; let unknown = graph_freshness_with_runner(&graph, Some(&commit), &fake).ok_or("freshness")?; diff --git a/docs/reference/outputs.md b/docs/reference/outputs.md index 97d1611f0..dfb1b5073 100644 --- a/docs/reference/outputs.md +++ b/docs/reference/outputs.md @@ -1481,7 +1481,9 @@ paths. `uncommittedFiles` counts status entries. Files outside an index scope ca be counted: these are repository-state observations, not proof every indexed symbol is stale. The statuses are `current`, `revision_changed`, `working_tree_changes` and `unknown`. Missing commits, roots, Git, timeout or -output limits yield `unknown`, never `current`. Warnings show the recorded root's +output limits yield `unknown`, never `current`. Configured Git conversion filters +and submodules also yield `unknown`; their working state cannot be checked +without crossing optional process boundaries. Warnings show the recorded root's update command, including the selected output container. Rebuilds publish a new generation; select the output container’s public `graph.json` alias afterward when the original request selected an immutable snapshot path. Freshness is checked again for each request, including cached From e3ae229f5c65a1296d1fd7114b827bc7789b9669 Mon Sep 17 00:00:00 2001 From: forhappy Date: Fri, 2 Oct 2026 15:33:50 -0700 Subject: [PATCH 4/4] Bound and sanitize invalid scope diagnostics --- crates/compass-cli/tests/usability_cli.rs | 7 +++++++ crates/compass-query/src/overview.rs | 20 +++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/compass-cli/tests/usability_cli.rs b/crates/compass-cli/tests/usability_cli.rs index a2250bb8f..793a84a70 100644 --- a/crates/compass-cli/tests/usability_cli.rs +++ b/crates/compass-cli/tests/usability_cli.rs @@ -130,6 +130,13 @@ fn scoped_views_use_path_and_module_boundaries() -> Result<(), Box> { let invalid = execute(directory.path(), &["hotspots", "--scope", "../outside"])?; assert_eq!(invalid.status.code(), Some(2)); assert!(String::from_utf8_lossy(&invalid.stderr).contains("relative path")); + for scope in ["a".repeat(5000), "src/\u{1b}[31m".into()] { + let invalid = execute(directory.path(), &["hotspots", "--scope", &scope])?; + assert_eq!(invalid.status.code(), Some(2)); + let error = String::from_utf8(invalid.stderr)?; + assert!(error.len() < 200); + assert!(!error.contains('\u{1b}')); + } Ok(()) } diff --git a/crates/compass-query/src/overview.rs b/crates/compass-query/src/overview.rs index 9ed588226..6e31dfeda 100644 --- a/crates/compass-query/src/overview.rs +++ b/crates/compass-query/src/overview.rs @@ -51,14 +51,20 @@ impl OverviewScope { .strip_prefix("module:") .or_else(|| filter.strip_prefix("path:")) .unwrap_or(&filter); + if filter.len() > 4096 { + return Err(OverviewError::Scope("filter exceeds 4096 bytes".into())); + } + if value.chars().any(char::is_control) { + return Err(OverviewError::Scope( + "control characters are not allowed".into(), + )); + } if value.is_empty() - || filter.len() > 4096 || value.starts_with('/') || value.split('/').any(|part| part == "..") - || value.chars().any(char::is_control) || (!filter.starts_with("module:") && value.contains(':')) { - return Err(OverviewError::Scope(filter)); + return Err(OverviewError::Scope(filter.chars().take(64).collect())); } let normalized_filter = if filter.starts_with("module:") { format!("module:{}", value.trim_end_matches('/')) @@ -453,6 +459,14 @@ mod tests { "{invalid}" ); } + for invalid in ["a".repeat(5000), "src/\u{1b}[31m".into()] { + let error = OverviewScope::new(vec![invalid]) + .err() + .ok_or("invalid scope accepted")? + .to_string(); + assert!(error.len() < 200); + assert!(!error.chars().any(char::is_control)); + } let scope = OverviewScope::new(vec!["path:./src/".into()])?; assert_eq!(scope.filters, vec!["src"]); let mut document: GraphDocument = serde_json::from_value(serde_json::json!({