diff --git a/CHANGELOG.md b/CHANGELOG.md index 98bf09c8e..ec78cc471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- Improve agent-facing query correctness and recovery: callers, impact, and + affected include source-backed alias/import/export usage evidence; CompassQL + exposes live node degree and supports ordering by pre-projection bindings; + historical reads neutralize configured checkout filters; direction-only + trail misses suggest `compass path`; and full reports retain bounded hub, + suggested-query, and learned-question entries. + ## 0.3.27 - 2026-09-17 - Improve Rust call-graph recall for source-proven `Arc`, `Rc`, and `Box` diff --git a/crates/compass-cli/assets/compass-skill/SKILL.md b/crates/compass-cli/assets/compass-skill/SKILL.md index 826691be1..1b673cb53 100644 --- a/crates/compass-cli/assets/compass-skill/SKILL.md +++ b/crates/compass-cli/assets/compass-skill/SKILL.md @@ -93,7 +93,8 @@ Use the specialized navigation commands when they fit: - `compass ask ""` to require bounded, typed intent routing directly; inspect the reported operation and ambiguity. - `compass search ""` for exact or fuzzy typed-symbol lookup. -- `compass callers` or `compass callees` for one-hop call-graph evidence. +- `compass callers` for one-hop incoming usage evidence, or `compass callees` + for one-hop outgoing call evidence. - `compass call-graph` for a bounded caller/callee trace from a source position or symbol, optionally enriched with Program IR. - `compass impact` for bounded transitive impact; use `affected` for review diff --git a/crates/compass-cli/assets/compass-skill/references/command-reference.md b/crates/compass-cli/assets/compass-skill/references/command-reference.md index 5ee61fde7..325fa9368 100644 --- a/crates/compass-cli/assets/compass-skill/references/command-reference.md +++ b/crates/compass-cli/assets/compass-skill/references/command-reference.md @@ -11,7 +11,8 @@ whether a Compass capability is covered by the installed skill. Run - `compass ask`: route a direct natural-language structural question to a bounded, deterministic typed query operation. - `compass search`: find typed symbols by name using the local FTS index. -- `compass callers`: list direct typed call-graph predecessors. +- `compass callers`: list direct typed incoming usages, including calls, + routes, references, imports, exports, and aliases. - `compass callees`: list direct typed call-graph successors. - `compass impact`: compute bounded transitive change impact, excluding heuristic evidence unless explicitly requested. diff --git a/crates/compass-cli/assets/compass-skill/references/query.md b/crates/compass-cli/assets/compass-skill/references/query.md index 2b726f0f6..d98e9c84b 100644 --- a/crates/compass-cli/assets/compass-skill/references/query.md +++ b/crates/compass-cli/assets/compass-skill/references/query.md @@ -95,7 +95,9 @@ compass tree node trail. Treat the reported operation and any ambiguity as part of the result. - `search` resolves typed symbols by exact or fuzzy name. -- `callers` and `callees` walk one attributable call-graph hop. +- `callers` returns one attributable incoming usage hop across calls, routes, + references, imports, exports, and aliases. `callees` walks one outgoing call + hop. - `impact` traverses a bounded transitive radius and excludes heuristic evidence by default. - `explore` returns related source and paths together under source and response @@ -103,10 +105,11 @@ compass tree - `node` exposes the evidence trail and provenance between two symbols. - `explain` reports a matched node and connected context; follow its pagination metadata when connections or ambiguous candidates span multiple pages. -- `path` reports the shortest known directed graph route from source to target. - A `direction_mismatch` diagnostic means a route exists only by ignoring one - or more edge directions; swap the operands only when the reverse route is - the intended question. +- `node` reports a directed evidence trail. A `direction_mismatch` diagnostic + includes a `compass path SOURCE TARGET` next action when a connection exists + only by ignoring edge direction. +- `path` ranks an undirected traversal while preserving stored relationship + direction in its displayed arrows. - `affected` follows impact relations and returns a review candidate set. - `tree` combines repository structure with graph metadata. diff --git a/crates/compass-cli/src/help.rs b/crates/compass-cli/src/help.rs index bed65a6d6..48315dbaf 100644 --- a/crates/compass-cli/src/help.rs +++ b/crates/compass-cli/src/help.rs @@ -324,9 +324,9 @@ const PAGES: &[Page] = &[ ), page!( "callers", - "List direct callers of a typed symbol", + "List direct incoming usages of a typed symbol", ["compass callers [OPTIONS]"], - "Arguments:\n Symbol ID, name, or qualified name\n\nOptions:\n --graph Typed graph [default: compass-out/graph.json]\n --program Optional Program IR enrichment\n --cache Query-index cache directory\n --engine Graph storage engine [default: default]\n --max-nodes Node bound\n --max-edges Edge bound\n --include-heuristic Include heuristic evidence (default is exact-first)\n --format Output format [default: text]\n\nExamples:\n compass callers PaymentService.charge\n compass callers sym:checkout --format json" + "Arguments:\n Symbol ID, name, or qualified name\n\nOptions:\n --graph Typed graph [default: compass-out/graph.json]\n --program Optional Program IR enrichment\n --cache Query-index cache directory\n --engine Graph storage engine [default: default]\n --max-nodes Node bound\n --max-edges Edge bound\n --include-heuristic Include heuristic evidence (default is exact-first)\n --format Output format [default: text]\n\nExamples:\n compass callers PaymentService.charge\n compass callers sym:checkout --format json\n\nNotes:\n Incoming usages include calls, routes, references, imports, exports, and aliases; each result retains its exact relationship kind." ), page!( "callees", @@ -425,7 +425,7 @@ const PAGES: &[Page] = &[ "path", "Find the shortest relationship path between two graph nodes", ["compass path [OPTIONS]"], - "Arguments:\n Exact source node name, qualified name, or ID\n Exact target node name, qualified name, or ID\n\nOptions:\n --max-depth Maximum hops examined [default: 8]\n --graph Read a graph JSON file\n --at Use an immutable Git revision; conflicts with --graph\n\nExamples:\n compass path CheckoutHandler PaymentGateway\n compass path api route --max-depth 5 --at v1.2.0\n\nNotes:\n Resolution completes before traversal. The final path node is always the resolved target ID. Relations are weighted so structural chains beat weak shared-reference shortcuts; a close shorter-but-weaker alternative is reported separately." + "Arguments:\n Exact source node name, qualified name, or ID\n Exact target node name, qualified name, or ID\n\nOptions:\n --max-depth Maximum hops examined [default: 8]\n --graph Read a graph JSON file\n --at Use an immutable Git revision; conflicts with --graph\n\nExamples:\n compass path CheckoutHandler PaymentGateway\n compass path api route --max-depth 5 --at v1.2.0\n\nNotes:\n Resolution completes before traversal. Path traversal may follow relationships in either direction; displayed arrows preserve stored direction. The final path node is always the resolved target ID. Relations are weighted so structural chains beat weak shared-reference shortcuts; a close shorter-but-weaker alternative is reported separately." ), page!( "explain", diff --git a/crates/compass-cypher/src/semantic.rs b/crates/compass-cypher/src/semantic.rs index 76d57f775..5e3b69966 100644 --- a/crates/compass-cypher/src/semantic.rs +++ b/crates/compass-cypher/src/semantic.rs @@ -217,8 +217,14 @@ fn analyze_projection( if clause.distinct { operators.push(LogicalOperator::Distinct); } + let mut order_scope = projected.clone(); + if !has_aggregate && !clause.distinct { + for (name, binding) in scope { + order_scope.entry(name.clone()).or_insert(*binding); + } + } for item in &clause.order_by { - validate_expr(&item.expression, &projected, parameter_types)?; + validate_expr(&item.expression, &order_scope, parameter_types)?; } if !clause.order_by.is_empty() { operators.push(LogicalOperator::Sort); diff --git a/crates/compass-history/src/git.rs b/crates/compass-history/src/git.rs index 8d4e26ffe..a5a648a9a 100644 --- a/crates/compass-history/src/git.rs +++ b/crates/compass-history/src/git.rs @@ -391,13 +391,6 @@ impl Repository { commit: &CommitId, ) -> Result, HistoryError> { let mut limitations = Vec::new(); - match reject_unsupported_filters(&self.root) { - Ok(()) => {} - Err(HistoryError::UnsupportedGitFilter(filter)) => { - limitations.push(GitTargetLimitation::UnsupportedFilter(filter)); - } - Err(error) => return Err(error), - } let listing = git_output( &self.root, &["ls-tree", "-r", "-z", "-l", "--full-tree", commit.as_str()], @@ -440,7 +433,7 @@ impl Repository { /// Create an exact detached worktree without running hooks, prompting, fetching, or smudging /// LFS content. pub fn detached_worktree(&self, commit: &CommitId) -> Result { - reject_unsupported_filters(&self.root)?; + let checkout_filters = configured_checkout_filters(&self.root)?; let compass_root = self.common_dir.join("compass"); crate::store::create_owner_dir(&compass_root)?; let tmp_root = compass_root.join("tmp"); @@ -476,7 +469,13 @@ impl Repository { registered: false, closed: false, }; - if let Err(error) = add_worktree(&guard.repository_root, &hooks, &guard.path, commit) { + if let Err(error) = add_worktree( + &guard.repository_root, + &hooks, + &guard.path, + commit, + &checkout_filters, + ) { let _cleanup = guard.cleanup(); return Err(error); } @@ -797,8 +796,19 @@ fn add_worktree( hooks: &Path, path: &Path, commit: &CommitId, + checkout_filters: &[String], ) -> Result<(), HistoryError> { - let output = Command::new("git") + let mut command = Command::new("git"); + for driver in checkout_filters { + command + .arg("-c") + .arg(format!("filter.{driver}.process=")) + .arg("-c") + .arg(format!("filter.{driver}.smudge=")) + .arg("-c") + .arg(format!("filter.{driver}.required=false")); + } + let output = command .arg("-c") .arg(format!("core.hooksPath={}", hooks.display())) .args(["-c", "credential.helper=", "-C"]) @@ -822,17 +832,22 @@ fn add_worktree( } } -fn reject_unsupported_filters(repository_root: &Path) -> Result<(), HistoryError> { +fn configured_checkout_filters(repository_root: &Path) -> Result, HistoryError> { let output = Command::new("git") .args(["-C"]) .arg(repository_root) - .args(["config", "--get-regexp", r"^filter\..*\.(smudge|process)$"]) + .args([ + "config", + "--name-only", + "--get-regexp", + r"^filter\..*\.(smudge|process|required)$", + ]) .env("GIT_TERMINAL_PROMPT", "0") .output() .map_err(|error| HistoryError::Git(error.to_string()))?; if !output.status.success() { if output.status.code() == Some(1) && output.stderr.is_empty() { - return Ok(()); + return Ok(Vec::new()); } return Err(HistoryError::Git( String::from_utf8_lossy(&output.stderr).trim().to_owned(), @@ -840,20 +855,31 @@ fn reject_unsupported_filters(repository_root: &Path) -> Result<(), HistoryError } let text = std::str::from_utf8(&output.stdout) .map_err(|error| HistoryError::Git(format!("Git returned non-UTF-8 filters: {error}")))?; - for line in text.lines() { - let (name, command) = line.split_once(char::is_whitespace).unwrap_or((line, "")); - let command = command.trim_start(); - if !matches!(command, "git-lfs" | "git lfs") - && !command.starts_with("git-lfs ") - && !command.starts_with("git lfs ") + let mut drivers = Vec::new(); + for key in text.lines() { + let Some(body) = key.strip_prefix("filter.") else { + continue; + }; + let Some((driver, field)) = body.rsplit_once('.') else { + continue; + }; + if !matches!(field, "smudge" | "process" | "required") { + continue; + } + if driver.is_empty() + || !driver + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) { - return Err(HistoryError::UnsupportedGitFilter(format!( - "{name}={}", - command.trim() + return Err(HistoryError::Git(format!( + "Git returned an unsafe filter driver name: {driver}" ))); } + drivers.push(driver.to_owned()); } - Ok(()) + drivers.sort(); + drivers.dedup(); + Ok(drivers) } fn target_limitations(checkout: &Path) -> Result, HistoryError> { diff --git a/crates/compass-history/tests/git.rs b/crates/compass-history/tests/git.rs index c0e0e86cd..db9a629d3 100644 --- a/crates/compass-history/tests/git.rs +++ b/crates/compass-history/tests/git.rs @@ -249,18 +249,12 @@ fn detached_worktree_is_exact_offline_reports_limitations_and_cleans_up() directory.path(), &["config", "filter.unsafe.smudge", "external-smudge %f"], )?; - assert!(matches!( - repository.detached_worktree(&first), - Err(HistoryError::UnsupportedGitFilter(_)) - )); + repository.detached_worktree(&first)?.close()?; git( directory.path(), &["config", "filter.unsafe.smudge", "evil-git-lfs-wrapper %f"], )?; - assert!(matches!( - repository.detached_worktree(&first), - Err(HistoryError::UnsupportedGitFilter(_)) - )); + repository.detached_worktree(&first)?.close()?; Ok(()) } @@ -298,6 +292,61 @@ fn detached_worktree_fails_for_a_missing_object_without_fetching() Ok(()) } +#[test] +fn detached_worktree_bypasses_custom_checkout_filters_without_executing_them() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + git(directory.path(), &["init", "--quiet"])?; + git(directory.path(), &["config", "user.name", "Compass Test"])?; + git( + directory.path(), + &["config", "user.email", "compass@example.invalid"], + )?; + std::fs::write( + directory.path().join(".gitattributes"), + "filtered.txt filter=crab\n", + )?; + std::fs::write(directory.path().join("filtered.txt"), "stored bytes\n")?; + git(directory.path(), &["add", ".gitattributes", "filtered.txt"])?; + git(directory.path(), &["commit", "--quiet", "-m", "filtered"])?; + git( + directory.path(), + &[ + "config", + "filter.crab.process", + "compass-filter-must-not-run", + ], + )?; + git( + directory.path(), + &[ + "config", + "filter.crab.smudge", + "compass-filter-must-not-run", + ], + )?; + git( + directory.path(), + &["config", "filter.crab.required", "true"], + )?; + + let repository = Repository::discover(directory.path())?; + let commit = repository.resolve("HEAD")?; + assert!( + repository + .target_limitations(&commit)? + .iter() + .all(|limitation| !matches!(limitation, GitTargetLimitation::UnsupportedFilter(_))) + ); + let checkout = repository.detached_worktree(&commit)?; + assert_eq!( + std::fs::read_to_string(checkout.path().join("filtered.txt"))?, + "stored bytes\n" + ); + checkout.close()?; + Ok(()) +} + #[test] fn source_delta_reports_statuses_renames_and_zero_context_hunks() -> Result<(), Box> { diff --git a/crates/compass-output/src/agent_query.rs b/crates/compass-output/src/agent_query.rs index 64a3ecdd1..8d6bb9b6c 100644 --- a/crates/compass-output/src/agent_query.rs +++ b/crates/compass-output/src/agent_query.rs @@ -1751,7 +1751,7 @@ fn answer_for_code( _ => format!("No exact answer was proven for \"{requested}\"."), }, AgentOperation::Callers => format!( - "Found {} incoming call or route relationship(s) for {subject}.", + "Found {} incoming usage relationship(s) for {subject}.", relationships.len() ), AgentOperation::Callees => format!( @@ -1859,6 +1859,35 @@ fn next_actions_for_code( paths: &[QueryPath], ) -> Vec { let mut actions = Vec::new(); + if caveats + .iter() + .any(|caveat| caveat.code == "direction_mismatch") + { + let source = context + .operands + .iter() + .find(|operand| operand.role == AgentOperandRole::Source); + let target = context + .operands + .iter() + .find(|operand| operand.role == AgentOperandRole::Target); + if let (Some(source), Some(target)) = (source, target) { + actions.push(AgentNextAction { + kind: "inspect_undirected_path".to_owned(), + reason: "A connection exists only when relationship direction is ignored." + .to_owned(), + cli: Some(AgentActionCli { + argv: vec![ + "compass".to_owned(), + "path".to_owned(), + source.value.clone(), + target.value.clone(), + ], + }), + mcp: None, + }); + } + } if caveats .iter() .any(|caveat| caveat.code == "ambiguous_match") diff --git a/crates/compass-output/src/report.rs b/crates/compass-output/src/report.rs index 4c4f27d6c..41a12e4a0 100644 --- a/crates/compass-output/src/report.rs +++ b/crates/compass-output/src/report.rs @@ -571,7 +571,6 @@ pub fn agent_orientation( }; sanitize_orientation_model(&mut model); fit_orientation_json_budget(&mut model); - fit_orientation_budget(&mut model); fit_report_budget(&mut model, options.obsidian); model } @@ -608,7 +607,6 @@ pub fn agent_orientation_with_blind_spots( model.blind_spots = blind_spots.cloned(); sanitize_orientation_model(&mut model); fit_orientation_json_budget(&mut model); - fit_orientation_budget(&mut model); fit_report_budget(&mut model, options.obsidian); model } @@ -689,7 +687,8 @@ pub fn graph_artifact_identity(path: &Path) -> Result { pub fn render_orientation_markdown(model: &AgentOrientation) -> Result { validate_orientation_model(model)?; - let rendered = render_orientation_markdown_unchecked(model); + let compact = compact_orientation_model(model); + let rendered = render_orientation_markdown_unchecked(&compact); let rendered_chars = char_count(&rendered); if rendered_chars > ORIENTATION_MARKDOWN_MAX_CHARS { return Err(OutputError::OrientationBudgetExceeded { @@ -2067,6 +2066,12 @@ fn fit_orientation_budget(model: &mut AgentOrientation) { } } +fn compact_orientation_model(model: &AgentOrientation) -> AgentOrientation { + let mut compact = model.clone(); + fit_orientation_budget(&mut compact); + compact +} + fn fit_orientation_json_budget(model: &mut AgentOrientation) { while let Ok(rendered) = serde_json::to_vec_pretty(model) { if rendered.len() <= ORIENTATION_JSON_FIT_BYTES { diff --git a/crates/compass-output/tests/agent_query.rs b/crates/compass-output/tests/agent_query.rs index d4f77c7f3..02ee060a0 100644 --- a/crates/compass-output/tests/agent_query.rs +++ b/crates/compass-output/tests/agent_query.rs @@ -224,6 +224,13 @@ fn direction_mismatch_is_a_no_path_blocker() -> Result<(), Box> { .iter() .any(|caveat| caveat.code == "direction_mismatch") ); + assert!(view.next_actions.iter().any(|action| { + action.kind == "inspect_undirected_path" + && action + .cli + .as_ref() + .is_some_and(|cli| cli.argv == ["compass", "path", "Source", "Target"]) + })); Ok(()) } diff --git a/crates/compass-output/tests/orientation.rs b/crates/compass-output/tests/orientation.rs index 20d6bb3cb..7c9f2120c 100644 --- a/crates/compass-output/tests/orientation.rs +++ b/crates/compass-output/tests/orientation.rs @@ -189,7 +189,10 @@ fn orientation_is_bounded_deterministic_and_markdown_safe() -> Result<(), Box (bool, &'static [EdgeKind]) { match self { - Self::CallersTarget => (true, &[EdgeKind::Calls, EdgeKind::RoutesTo]), + Self::CallersTarget => (true, CALLER_KINDS), Self::CalleesSource => (false, &[EdgeKind::Calls]), Self::ImpactTarget => (true, IMPACT_KINDS), Self::TrailSource => (false, ALL_EDGE_KINDS), @@ -96,6 +96,15 @@ impl StructuralOperandRole { } } +const CALLER_KINDS: &[EdgeKind] = &[ + EdgeKind::Calls, + EdgeKind::RoutesTo, + EdgeKind::References, + EdgeKind::Imports, + EdgeKind::Exports, + EdgeKind::Aliases, +]; + pub(crate) struct CandidateAssembly { pub(crate) pool: SearchCandidatePool, pub(crate) truncated: bool, @@ -263,6 +272,7 @@ const IMPACT_KINDS: &[EdgeKind] = &[ EdgeKind::Imports, EdgeKind::Exports, EdgeKind::References, + EdgeKind::Aliases, EdgeKind::DependsOn, EdgeKind::Reads, EdgeKind::Writes, @@ -2248,7 +2258,7 @@ impl CodeQueryEngine { }; let execution_started = Instant::now(); let kinds: &[EdgeKind] = if inbound { - &[EdgeKind::Calls, EdgeKind::RoutesTo] + CALLER_KINDS } else { &[EdgeKind::Calls] }; diff --git a/crates/compass-query/src/cql/eval.rs b/crates/compass-query/src/cql/eval.rs index 3581c4191..a3cef4faf 100644 --- a/crates/compass-query/src/cql/eval.rs +++ b/crates/compass-query/src/cql/eval.rs @@ -38,6 +38,16 @@ pub(super) fn project_rows( ); } } + if !clause.distinct { + let mut order_scope = row.clone(); + order_scope.extend(next.clone()); + for (index, sort) in clause.order_by.iter().enumerate() { + next.insert( + hidden_order_key(index), + eval(&sort.expression, &order_scope, None, context)?, + ); + } + } projected.push(next); } projected @@ -62,8 +72,12 @@ pub(super) fn project_rows( let mut decorated = Vec::with_capacity(output.len()); for row in output { let mut keys = Vec::with_capacity(clause.order_by.len()); - for sort in &clause.order_by { - keys.push(eval(&sort.expression, &row, None, context)?); + for (index, sort) in clause.order_by.iter().enumerate() { + if let Some(value) = row.get(&hidden_order_key(index)) { + keys.push(value.clone()); + } else { + keys.push(eval(&sort.expression, &row, None, context)?); + } } decorated.push((keys, row)); } @@ -82,6 +96,11 @@ pub(super) fn project_rows( }); output = decorated.into_iter().map(|(_, row)| row).collect(); } + for row in &mut output { + for index in 0..clause.order_by.len() { + row.remove(&hidden_order_key(index)); + } + } let empty = BindingRow::new(); let skip = clause @@ -113,6 +132,10 @@ pub(super) fn project_rows( Ok(output) } +fn hidden_order_key(index: usize) -> String { + format!("\0compass.order.{index}") +} + fn project_aggregate( rows: Vec, clause: &ProjectionClause, @@ -793,6 +816,9 @@ pub(super) fn property_value( if property == "label" { return Ok(CompassValue::String(Arc::from(node.label()))); } + if property == "degree" { + return usize_to_integer(context.graph.degree(reference.index)); + } node.property(property) .map_or(Ok(CompassValue::Null), |value| json_value(&value)) } @@ -838,6 +864,10 @@ fn properties_function( "label".to_owned(), CompassValue::String(Arc::from(record.label())), ); + values.insert( + "degree".to_owned(), + usize_to_integer(context.graph.degree(node.index))?, + ); Ok(CompassValue::Map(Arc::new(values))) } Some(CompassValue::Relationship(relationship)) => { diff --git a/crates/compass-query/tests/code_impact.rs b/crates/compass-query/tests/code_impact.rs index 18f11814d..597756eb2 100644 --- a/crates/compass-query/tests/code_impact.rs +++ b/crates/compass-query/tests/code_impact.rs @@ -22,6 +22,13 @@ fn impact_walks_the_approved_reverse_family_and_gates_heuristics() let exact = engine.impact(request(false))?; assert!(exact.nodes.iter().any(|node| node.id == "n:route")); assert!(exact.nodes.iter().any(|node| node.id == "n:dependent")); + assert!(exact.nodes.iter().any(|node| node.id == "n:alias")); + assert!( + exact + .edges + .iter() + .any(|edge| edge.kind == EdgeKind::Aliases) + ); assert!(!exact.nodes.iter().any(|node| node.id == "n:heuristic")); let enriched = engine.impact(request(true))?; assert!(enriched.nodes.iter().any(|node| node.id == "n:heuristic")); diff --git a/crates/compass-query/tests/code_traversal.rs b/crates/compass-query/tests/code_traversal.rs index 8bdfafca7..cf17737ed 100644 --- a/crates/compass-query/tests/code_traversal.rs +++ b/crates/compass-query/tests/code_traversal.rs @@ -37,6 +37,13 @@ fn callers_include_calls_and_route_bindings_while_callees_follow_calls() .iter() .any(|edge| edge.kind == EdgeKind::RoutesTo) ); + assert!(caller_ids.contains("n:alias")); + assert!( + callers + .edges + .iter() + .any(|edge| edge.kind == EdgeKind::Aliases) + ); assert!(!callers.nodes.iter().any(|node| node.id == "n:heuristic")); let enriched = engine.callers(CallRequest { diff --git a/crates/compass-query/tests/cql_execution.rs b/crates/compass-query/tests/cql_execution.rs index 00105aee4..4df3782dc 100644 --- a/crates/compass-query/tests/cql_execution.rs +++ b/crates/compass-query/tests/cql_execution.rs @@ -96,6 +96,26 @@ fn executes_indexed_fixed_patterns_and_properties() -> Result<(), Box Ok(()) } +#[test] +fn node_degree_is_queryable_and_orders_before_projection_scope_is_discarded() +-> Result<(), Box> { + let result = run("MATCH (n) RETURN n.label AS label, n.degree AS degree \ + ORDER BY n.degree DESC, label")?; + assert_eq!(result.rows.len(), 4); + assert_eq!(result.rows[0][0], CompassValue::String("a()".into())); + assert_eq!(result.rows[0][1], CompassValue::Integer(2)); + assert_eq!(result.rows[1][0], CompassValue::String("b()".into())); + assert_eq!(result.rows[1][1], CompassValue::Integer(2)); + assert_eq!(result.rows[2][1], CompassValue::Integer(1)); + assert_eq!(result.rows[3][1], CompassValue::Integer(1)); + + let mapped = run("MATCH (n {id:'a'}) \ + RETURN n.degree AS direct, properties(n).degree AS mapped")?; + assert_eq!(mapped.rows[0][0], CompassValue::Integer(2)); + assert_eq!(mapped.rows[0][1], CompassValue::Integer(2)); + Ok(()) +} + #[test] fn typed_graph_projects_labels_anchors_and_effective_confidence() -> Result<(), Box> { let graph = typed_graph()?; diff --git a/crates/compass-query/tests/fixtures/relevance/executable-reviewed-v1.json b/crates/compass-query/tests/fixtures/relevance/executable-reviewed-v1.json index a81160e5d..57a425515 100644 --- a/crates/compass-query/tests/fixtures/relevance/executable-reviewed-v1.json +++ b/crates/compass-query/tests/fixtures/relevance/executable-reviewed-v1.json @@ -4273,6 +4273,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4322,6 +4326,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4371,6 +4379,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4420,6 +4432,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4469,6 +4485,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4518,6 +4538,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4567,6 +4591,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4616,6 +4644,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4665,6 +4697,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4714,6 +4750,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4763,6 +4803,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4812,6 +4856,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4861,6 +4909,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4910,6 +4962,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -4959,6 +5015,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5008,6 +5068,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5057,6 +5121,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5106,6 +5174,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5155,6 +5227,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5204,6 +5280,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5253,6 +5333,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5302,6 +5386,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5351,6 +5439,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5400,6 +5492,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5449,6 +5545,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5498,6 +5598,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5547,6 +5651,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5596,6 +5704,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5645,6 +5757,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5694,6 +5810,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5743,6 +5863,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5792,6 +5916,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5841,6 +5969,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5890,6 +6022,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5939,6 +6075,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -5988,6 +6128,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6037,6 +6181,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6086,6 +6234,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6135,6 +6287,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6184,6 +6340,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6233,6 +6393,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6282,6 +6446,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6331,6 +6499,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6380,6 +6552,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6429,6 +6605,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6478,6 +6658,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6527,6 +6711,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6576,6 +6764,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6625,6 +6817,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6674,6 +6870,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6723,6 +6923,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6772,6 +6976,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6821,6 +7029,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6870,6 +7082,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6919,6 +7135,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -6968,6 +7188,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -7017,6 +7241,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -7066,6 +7294,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -7115,6 +7347,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 @@ -7164,6 +7400,10 @@ "truncated": "false" }, "nodeJudgments": [ + { + "id": "n:alias", + "grade": 3 + }, { "id": "n:caller", "grade": 3 diff --git a/crates/compass-query/tests/relevance_qualification.rs b/crates/compass-query/tests/relevance_qualification.rs index 0b76bdc65..10e296d4a 100644 --- a/crates/compass-query/tests/relevance_qualification.rs +++ b/crates/compass-query/tests/relevance_qualification.rs @@ -208,11 +208,13 @@ fn executable_corpus(graph_digest: String) -> JudgmentCorpus { let callers = |id: &str, text: &str| { let mut judged = query(id, text, QueryClass::Edge, "callers"); judged.node_judgments = vec![ + node_judgment("n:alias"), node_judgment("n:caller"), node_judgment("n:list"), node_judgment("n:route"), ]; judged.edge_judgments = vec![ + edge_judgment("n:alias", "n:list", "aliases"), edge_judgment("n:caller", "n:list", "calls"), edge_judgment("n:route", "n:list", "routes_to"), ]; diff --git a/deny.toml b/deny.toml index 2e9bf958d..1c30f7897 100644 --- a/deny.toml +++ b/deny.toml @@ -32,6 +32,7 @@ allow = [ # allowing the license for every future dependency. exceptions = [ { allow = ["BSL-1.0"], crate = "clipper2-rust@1.1.0" }, + { allow = ["BSL-1.0"], crate = "clipper2-rust@1.2.0" }, { allow = ["BSL-1.0"], crate = "xxhash-rust@0.8.18" }, ] diff --git a/docs/COMPASSQL.md b/docs/COMPASSQL.md index fad02d30b..00219dddc 100644 --- a/docs/COMPASSQL.md +++ b/docs/COMPASSQL.md @@ -26,7 +26,9 @@ JSON uses the version tag `compass.cql.result/1`, explicit typed values, columns ## Graph mapping -- Each Compass node is a Cypher node. Stable `id`, display `label`, `name`, and `qualified_name` are logical properties; stored attributes retain their names. +- Each Compass node is a Cypher node. Stable `id`, display `label`, live incident + `degree`, `name`, and `qualified_name` are logical properties; stored + attributes retain their names. - The single Cypher label is derived from the typed `kind` (for example `Function`, `Class`, or `Route`). Legacy `file_type` is a compatibility alias, not the source of truth; a missing or unusable kind falls back to `:Entity`. - Each stored edge is a directed relationship. Its type is the normalized uppercase `relation`; missing values become `RELATES_TO`. - Relationship attributes retain their names. Typed `source`/`relationshipSite` fields project to `source_file`, `source_location`, `line_start`, and `line_end`. Missing `confidence` reads as `EXTRACTED`; when evidence contains multiple claims, the most conservative confidence wins (`AMBIGUOUS` > `INFERRED` > `EXTRACTED`). @@ -41,6 +43,9 @@ do not depend on the nested wire names (`source`, `relationshipSite`, or Use `n.id` for the portable stable Compass string ID. `id(n)` and `id(r)` return snapshot-local integer indexes and must not be persisted or compared across graph snapshots. +For a non-aggregate, non-`DISTINCT` projection, `ORDER BY` may reference either +a returned alias or an input binding such as `ORDER BY n.degree DESC`. + ## Supported language CompassQL supports `MATCH`, multiple patterns and repeated-variable joins, `OPTIONAL MATCH`, `WHERE`, correlated one-level `EXISTS { MATCH ... }` and openCypher's `EXISTS { (...)-->() }` shorthand, `UNWIND`, `WITH`, `RETURN`, projection wildcards, `DISTINCT`, `UNION`, `UNION ALL`, `ORDER BY`, `SKIP`, and `LIMIT`. diff --git a/docs/design/principles.md b/docs/design/principles.md index b233bb9bd..bb9b1adbb 100644 --- a/docs/design/principles.md +++ b/docs/design/principles.md @@ -88,7 +88,7 @@ The same rule applies broadly: - missing semantic credentials do not silently become a code-only historical profile; - an unreadable preferred realization is not silently replaced; -- unsafe checkout filters are rejected for historical materialization; +- checkout filters are neutralized for historical materialization; - an incomplete semantic build cannot publish as complete; - a profile mismatch is surfaced before a normal diff. diff --git a/docs/design/security-and-privacy.md b/docs/design/security-and-privacy.md index 68907beaf..9c6e639f0 100644 --- a/docs/design/security-and-privacy.md +++ b/docs/design/security-and-privacy.md @@ -203,7 +203,8 @@ Compass historical materialization: - creates a detached offline worktree; - does not run hooks; -- rejects external-code checkout filters; +- discovers configured checkout filters and overrides their process, smudge, + and required settings with inert values for the isolated checkout; - does not fetch or prompt; - does not smudge LFS; - does not recurse submodules; diff --git a/docs/design/storage-and-history.md b/docs/design/storage-and-history.md index 402615770..4fa1327e3 100644 --- a/docs/design/storage-and-history.md +++ b/docs/design/storage-and-history.md @@ -221,7 +221,7 @@ Materialization uses a protected worktree under a restrictive policy: - no user hooks; - no recursive submodules; - no LFS smudge; -- no external-code checkout filters; +- configured checkout filters are neutralized before worktree creation; - committed ignore policy only. This is both reproducibility and security design. A historical query should not diff --git a/docs/guides/versioned-history.md b/docs/guides/versioned-history.md index a1541c46b..b667d0daf 100644 --- a/docs/guides/versioned-history.md +++ b/docs/guides/versioned-history.md @@ -517,7 +517,7 @@ Failure handling: | Preferred realization fails validation | Inspect with `show`; use explicit rebuild/recovery path | | Profiles differ during diff | Build the missing side with `--profile-from`; unlike profiles are not compared | | Live lease exists | Join/wait according to command behavior; do not delete lock files | -| Historical checkout limitation | Read the reported Gitlink/LFS/filter limitation and adjust source policy | +| Historical checkout limitation | Read the reported Gitlink/LFS limitation and adjust source policy; configured checkout filters are neutralized | | Store copy is inconsistent | Restore a coherent SQLite/WAL backup; do not guess at Prolly records | ## Qualification diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 5d360d528..119fed302 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -420,6 +420,9 @@ existing discovery entry ledger and v2 cursor remain unchanged. bounded `nextActions` as argv arrays or JSON argument objects; clients should use those values instead of reconstructing shell commands from result text. +`callers` returns direct incoming usage evidence: calls, routes, references, +imports, exports, and aliases. `callees` remains the direct outgoing call view. + ### `path` ```text @@ -434,6 +437,8 @@ or documentation links. When a meaningfully weaker route is up to two hops shorter, Compass shows it separately. Output names the resolved target ID, and an unreachable target is reported as `NO PATH FOUND` with the depth bound and visited-node count. Relationship arrows always preserve their stored direction. +Traversal may follow a relationship in either direction; the arrows make that +choice visible rather than rewriting the graph. ### `explain` diff --git a/docs/reference/framework-routes.md b/docs/reference/framework-routes.md index 91961dde6..4dc49f96b 100644 --- a/docs/reference/framework-routes.md +++ b/docs/reference/framework-routes.md @@ -40,7 +40,9 @@ Configuration and file-convention routes keep the rule and source that produced ## Read a route through callers -The `callers` command follows incoming `calls` and `routes_to` edges. Use it to see code callers and URL registrations for the same handler: +The `callers` command follows incoming usage evidence, including `calls`, +`routes_to`, references, imports, exports, and aliases. Use it to see code +usages and URL registrations for the same handler: ```bash compass callers UsersController.show --graph compass-out/graph.json diff --git a/scripts/generate_query_relevance_corpus.py b/scripts/generate_query_relevance_corpus.py index 4cfe2ec37..75dc16d6d 100644 --- a/scripts/generate_query_relevance_corpus.py +++ b/scripts/generate_query_relevance_corpus.py @@ -150,7 +150,7 @@ def callers_records() -> list[dict[str, object]]: "which methods call {symbol}", "where is {symbol} called", ) - nodes = (("n:caller", 3), ("n:list", 3), ("n:route", 3)) + nodes = (("n:alias", 3), ("n:caller", 3), ("n:list", 3), ("n:route", 3)) edges = ( edge("n:caller", "n:list", "calls"), edge("n:route", "n:list", "routes_to"),