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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion crates/compass-cli/assets/compass-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ Use the specialized navigation commands when they fit:
- `compass ask "<question>"` to require bounded, typed intent routing directly;
inspect the reported operation and ambiguity.
- `compass search "<symbol>"` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 8 additions & 5 deletions crates/compass-cli/assets/compass-skill/references/query.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,21 @@ 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
byte limits.
- `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.

Expand Down
6 changes: 3 additions & 3 deletions crates/compass-cli/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SYMBOL> [OPTIONS]"],
"Arguments:\n <SYMBOL> Symbol ID, name, or qualified name\n\nOptions:\n --graph <PATH> Typed graph [default: compass-out/graph.json]\n --program <PATH> Optional Program IR enrichment\n --cache <DIR> Query-index cache directory\n --engine <default|json|store> Graph storage engine [default: default]\n --max-nodes <N> Node bound\n --max-edges <N> Edge bound\n --include-heuristic Include heuristic evidence (default is exact-first)\n --format <text|json> Output format [default: text]\n\nExamples:\n compass callers PaymentService.charge\n compass callers sym:checkout --format json"
"Arguments:\n <SYMBOL> Symbol ID, name, or qualified name\n\nOptions:\n --graph <PATH> Typed graph [default: compass-out/graph.json]\n --program <PATH> Optional Program IR enrichment\n --cache <DIR> Query-index cache directory\n --engine <default|json|store> Graph storage engine [default: default]\n --max-nodes <N> Node bound\n --max-edges <N> Edge bound\n --include-heuristic Include heuristic evidence (default is exact-first)\n --format <text|json> 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",
Expand Down Expand Up @@ -425,7 +425,7 @@ const PAGES: &[Page] = &[
"path",
"Find the shortest relationship path between two graph nodes",
["compass path <SOURCE> <TARGET> [OPTIONS]"],
"Arguments:\n <SOURCE> Exact source node name, qualified name, or ID\n <TARGET> Exact target node name, qualified name, or ID\n\nOptions:\n --max-depth <N> Maximum hops examined [default: 8]\n --graph <PATH> Read a graph JSON file\n --at <REV> 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 <SOURCE> Exact source node name, qualified name, or ID\n <TARGET> Exact target node name, qualified name, or ID\n\nOptions:\n --max-depth <N> Maximum hops examined [default: 8]\n --graph <PATH> Read a graph JSON file\n --at <REV> 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",
Expand Down
8 changes: 7 additions & 1 deletion crates/compass-cypher/src/semantic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
72 changes: 49 additions & 23 deletions crates/compass-history/src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,13 +391,6 @@ impl Repository {
commit: &CommitId,
) -> Result<Vec<GitTargetLimitation>, 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()],
Expand Down Expand Up @@ -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<WorktreeGuard, HistoryError> {
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");
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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"])
Expand All @@ -822,38 +832,54 @@ fn add_worktree(
}
}

fn reject_unsupported_filters(repository_root: &Path) -> Result<(), HistoryError> {
fn configured_checkout_filters(repository_root: &Path) -> Result<Vec<String>, 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(),
));
}
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<Vec<GitTargetLimitation>, HistoryError> {
Expand Down
65 changes: 57 additions & 8 deletions crates/compass-history/tests/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
Expand Down
31 changes: 30 additions & 1 deletion crates/compass-output/src/agent_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -1859,6 +1859,35 @@ fn next_actions_for_code(
paths: &[QueryPath],
) -> Vec<AgentNextAction> {
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")
Expand Down
11 changes: 8 additions & 3 deletions crates/compass-output/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -689,7 +687,8 @@ pub fn graph_artifact_identity(path: &Path) -> Result<String, OutputError> {

pub fn render_orientation_markdown(model: &AgentOrientation) -> Result<String, OutputError> {
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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading