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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

- Resolve Python calls through annotated receivers and unit-of-work member chains,
injected constructor fields, and source return contracts. Preserve unknown
invocations as bounded source-call inventories and optional inferred receiver
edges. Class callee queries include their methods and report resolved versus
unresolved call sites instead of claiming a false zero.
- Preserve qualified external Rust factory return receivers without inventing
absent methods on source-local types.
- Expand impact defaults to construction, inheritance, annotations and other
dependency relationships. Add CLI `--relation` and MCP `relations` filters,
direct/transitive file grouping, and an independent direct-connection audit.

- Route natural-language usage, dependency, impact, and connection questions
into typed graph queries. Continue ambiguous questions with a disclosed,
deterministically ranked candidate and retain alternative identities.
Expand Down
44 changes: 44 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1602,3 +1602,47 @@ Compass was inspired by
[Graphify](https://github.com/Graphify-Labs/graphify). This attribution records
project lineage only; it does not create a runtime, testing, or compatibility
dependency between the products.

## Python call coverage and impact auditing

Rebuilt Python graphs contain optional `details.callSites` inventories on Python
symbols, including callable bodies and class/field initializers. Each inventory
retains sorted, unique invocation anchors,
including calls whose receiver cannot be resolved, and a `truncated` flag at the
4,096-site per-symbol bound. Missing inventories in older graphs mean unknown
coverage, not zero calls. Disposable AST cache semantics advance from 13 to 14.
Stored graphs and historical realizations are not rewritten.

Annotated parameters/locals, annotated class fields, direct constructor injection
and direct constructor assignments can establish nominal receivers. Project
resolution follows member types and callable return contracts. Duplicate types,
multiple or conditional field writes and rebinding do not establish a convenient
target. Dynamic receivers retain source observations; maximum inference builds
also retain explicitly inferred deferred receiver edges. Deferred placeholders
are excluded from terminal-name stub rewrites. These facts do not prove runtime
object identity or arbitrary dynamic Python dispatch.

Typed class callee queries aggregate contained executable owners while keeping
the actual method endpoints. `compass.query/1` adds optional `callSummary` and
`impactSummary` fields, and graph v1 adds the optional symbol inventory. Consumers
that reject unknown optional properties must update their contract types before
reading new results. Node IDs, relation direction and graph/query major schemas
remain unchanged.

Impact follows the expanded dependency default in reverse. CLI `--relation TYPE`
(repeatable) and MCP `get_impact.relations` replace that default. The direct audit
counts distinct `(direction, neighbor)` contacts, matching explain's connection
units; parallel occurrences do not inflate the count. Outgoing contacts are
review context, not claimed dependents. Incoming types/confidences outside the
chosen policy are counted as excluded. An unbounded query that omits a selected
direct dependent fails with a typed consistency error. Bounded audits mark direct
coverage incomplete. Self-references are audited as context/exclusions rather than
counting the seed as its own affected dependent. The audit covers published
adjacency; partial source graphs retain their separate
`IncompleteCoverage` diagnostic and do not claim complete repository coverage.
File/module groups separate direct from transitive retained dependents
independently of the bounded path ledger.

The resolver also preserves qualified external Rust receivers established by
source factory return contracts, subject to the existing inference policy. An
absent method on a source-local return type remains unresolved.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ layout remains visible and clearly owned.

## Graph rebuilds and query resolution

Rebuild Python graphs with `compass extract <root> --code-only --force` to obtain
receiver-chain resolution and source-call inventories. AST cache version 14
recomputes older entries automatically. Add `--inference-level max` to retain
inferred deferred receiver edges; the source inventory remains available at low
inference. Older/historical graphs remain readable but unresolved-call coverage
is explicitly unavailable until a new graph is built. Strict machine consumers
must admit optional `details.callSites`, `callSummary`, and `impactSummary`.
Impact's broader default may return more dependents; use repeatable
`compass impact <symbol> --relation calls` (or MCP `relations`) for a narrower
policy. Counts distinguish incoming dependents from outgoing review context.

Disposable graph query/impact/traversal and typed content caches are rebuilt
automatically when read by this version. No source graph rebuild or historical
rewrite is needed for cache freshness. An in-flight MCP request retains its
Expand Down
51 changes: 46 additions & 5 deletions crates/compass-cli/src/code_query_commands.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::path::PathBuf;
use std::time::{Duration, Instant};

use compass_model::code_graph::EdgeKind;
use compass_model::query_contract::{
CallRequest, CodeQueryLimits, CodeQueryResponse, ExploreRequest, ImpactRequest,
NodeTrailRequest, SearchRequest,
Expand Down Expand Up @@ -268,6 +269,7 @@ fn execute(
.with_deadline(deadline);
let limits = limits(args, page_scale)?;
let include_heuristic = args.iter().any(|arg| arg == "--include-heuristic");
let relations = impact_relations(operation, args)?;
let (response, question, operands) = match operation {
"ask" => {
let question = required(&positional, 0, "ask <QUESTION>")?.to_owned();
Expand Down Expand Up @@ -332,11 +334,18 @@ fn execute(
include_heuristic,
limits,
}),
"impact" => engine.impact(ImpactRequest {
symbol: symbol.clone(),
include_heuristic,
limits,
}),
"impact" => {
let request = ImpactRequest {
symbol: symbol.clone(),
include_heuristic,
limits,
};
if relations.is_empty() {
engine.impact(request)
} else {
engine.impact_with_relations(request, &relations)
}
}
_ => unreachable!(),
}
.map_err(query_error)?;
Expand Down Expand Up @@ -488,6 +497,7 @@ fn positional(args: &[String]) -> Vec<String> {
"--file",
"--line",
"--kind",
"--relation",
];
let mut values = Vec::new();
let mut skip = false;
Expand All @@ -503,6 +513,37 @@ fn positional(args: &[String]) -> Vec<String> {
values
}

fn impact_relations(operation: &str, args: &[String]) -> Result<Vec<EdgeKind>, String> {
let mut relations = Vec::new();
let mut values = args.iter();
while let Some(arg) = values.next() {
let value = if arg == "--relation" {
Some(
values
.next()
.ok_or("--relation requires a relationship type")?
.as_str(),
)
} else {
arg.strip_prefix("--relation=")
};
if let Some(value) = value {
if operation != "impact" {
return Err("--relation is supported only by compass impact".to_owned());
}
let relation = serde_json::from_value::<EdgeKind>(serde_json::Value::String(value.to_owned()))
.map_err(|_| format!("unknown impact relationship {value:?}; use a stored graph type such as calls, instantiates, references, extends, or imports"))?;
if relations.len() >= 32 {
return Err("--relation exceeds the 32-type bound".to_owned());
}
if !relations.contains(&relation) {
relations.push(relation);
}
}
}
Ok(relations)
}

fn required<'a>(values: &'a [String], index: usize, usage: &str) -> Result<&'a str, String> {
values
.get(index)
Expand Down
2 changes: 1 addition & 1 deletion crates/compass-cli/src/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ const PAGES: &[Page] = &[
"impact",
"Compute the bounded transitive impact of a symbol",
["compass impact <SYMBOL> [OPTIONS]"],
"Arguments:\n <SYMBOL> Changed 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-depth <N> Traversal radius\n --max-nodes <N> Node bound\n --max-edges <N> Edge bound\n --include-heuristic Traverse heuristic evidence\n --format <text|agent-json|json> Output format [default: text]\n --text-budget <N> Approximate tokens per text page [default: 2000]\n --cursor <TOKEN> Continue the same text result (text only)\n --timeout-ms <N> Query deadline in milliseconds [default: 60000; max: 600000]\n --brief Emit compass.query.agent-view.brief/1 (agent-json only)\n\nExamples:\n compass impact PaymentGateway --max-depth 3\n compass impact sym:gateway --include-heuristic --format json"
"Arguments:\n <SYMBOL> Changed 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-depth <N> Traversal radius\n --max-nodes <N> Node bound\n --max-edges <N> Edge bound\n --relation <TYPE> Follow stored relationship type; repeatable\n --include-heuristic Traverse heuristic evidence\n --format <text|agent-json|json> Output format [default: text]\n --text-budget <N> Approximate tokens per text page [default: 2000]\n --cursor <TOKEN> Continue the same text result (text only)\n --timeout-ms <N> Query deadline in milliseconds [default: 60000; max: 600000]\n --brief Emit compass.query.agent-view.brief/1 (agent-json only)\n\nExamples:\n compass impact PaymentGateway --max-depth 3\n compass impact sym:gateway --include-heuristic --format json"
),
page!(
"explore",
Expand Down
49 changes: 49 additions & 0 deletions crates/compass-cli/tests/code_query_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2767,3 +2767,52 @@ fn calls_only_cli_and_ask_reject_structural_routes_and_invalid_flags() -> Result
assert!(String::from_utf8(help.stdout)?.contains("--calls-only"));
Ok(())
}

#[test]
fn impact_relation_filter_is_typed_and_reports_excluded_contacts() -> Result<(), Box<dyn Error>> {
let directory = tempfile::tempdir()?;
let graph = support::write_typed_graph(directory.path())?;
let filtered = run(
Frontend::Compass,
[
OsString::from("impact"),
OsString::from("Target"),
OsString::from("--graph"),
graph.as_os_str().to_owned(),
OsString::from("--relation"),
OsString::from("imports"),
OsString::from("--format"),
OsString::from("json"),
],
);
assert_eq!(filtered.code, 0, "{}", filtered.stderr);
let value: Value = serde_json::from_str(&filtered.stdout)?;
assert_eq!(
value["impactSummary"]["relations"],
serde_json::json!(["imports"])
);
assert_eq!(
value["impactSummary"]["directDependents"],
serde_json::json!([])
);
assert!(
value["impactSummary"]["excludedDirectConnections"]
.as_u64()
.ok_or("missing count")?
> 0
);
let bad = run(
Frontend::Compass,
[
OsString::from("impact"),
OsString::from("Target"),
OsString::from("--graph"),
graph.as_os_str().to_owned(),
OsString::from("--relation"),
OsString::from("imagined"),
],
);
assert_ne!(bad.code, 0);
assert!(bad.stderr.contains("unknown impact relationship"));
Ok(())
}
27 changes: 27 additions & 0 deletions crates/compass-core/examples/republish_qualification.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//! Rebuild a production qualification graph using unchanged validated AST facts.
use compass_core::{BuildOptions, GraphStorage, InferenceLevel, build_local_graph};
use std::error::Error;
use std::path::PathBuf;

fn main() -> Result<(), Box<dyn Error>> {
let mut args = std::env::args_os().skip(1);
let root = PathBuf::from(args.next().ok_or("source root required")?);
let output = PathBuf::from(args.next().ok_or("output root required")?);
if args.next().is_some() {
return Err("expected source and output roots".into());
}
let mut options = BuildOptions::new(&root);
options.output_root = Some(output);
options.force = true;
options.reuse_cache_on_force = true;
options.code_only = true;
options.inference_level = InferenceLevel::Max;
options.graph_storage = GraphStorage::Json;
options.extra_excludes = vec!["tests/**".to_owned()];
options.no_cluster = true;
options.no_viz = true;
options.max_workers = Some(2);
let result = build_local_graph(&options)?;
println!("{}", result.output_dir.display());
Ok(())
}
43 changes: 43 additions & 0 deletions crates/compass-core/tests/code_graph_v1_determinism.rs
Original file line number Diff line number Diff line change
Expand Up @@ -991,3 +991,46 @@ fn force_extract_with_cache_reuse_has_no_prior_published_semantic_input()
}));
Ok(())
}

#[test]
fn python_call_inventory_survives_publication_and_class_queries() -> Result<(), Box<dyn Error>> {
use compass_model::query_contract::{CallRequest, CodeQueryLimits};
let directory = tempfile::tempdir()?;
let root = directory.path();
fs::write(
root.join("service.py"),
"class Service:\n initial = missing.repository.get()\n def execute(self, uow):\n uow.repositories.get()\n callback = uow.callback\n callback()\n",
)?;
let (first, _) = build(root)?;
let graph = GraphDocument::load(&root.join("compass-out/graph.json"))?;
let method = graph
.nodes
.iter()
.find(|node| node.qualified_name.ends_with("Service::execute"))
.ok_or("missing service method")?;
let Some(NodeDetails::Symbol(details)) = &method.details else {
return Err("missing symbol details".into());
};
let inventory = details.call_sites.as_ref().ok_or("missing inventory")?;
assert_eq!(inventory.sites.len(), 2);
assert!(!inventory.truncated);
assert!(inventory.sites.iter().all(|site| site.file == "service.py"));
let engine = compass_query::open(
&root.join("compass-out/graph.json"),
None,
&root.join("query-cache"),
)?;
let result = engine.callees(CallRequest {
symbol: "Service".to_owned(),
include_heuristic: false,
limits: CodeQueryLimits::default(),
})?;
let summary = result.call_summary.ok_or("missing summary")?;
assert_eq!(summary.resolved_calls, 0);
assert_eq!(summary.unresolved_calls, Some(3));
assert_eq!(summary.observed_calls, Some(3));
assert!(summary.inventory_complete);
let (second, _) = build(root)?;
assert_eq!(first, second);
Ok(())
}
1 change: 1 addition & 0 deletions crates/compass-core/tests/task_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ fn qualification_composes_verified_priority_sections_and_memory_deterministicall
signature_digest: None,
implementation_digest: Some("sha256:implementation".to_owned()),
source_digest: None,
call_sites: None,
}));
graph.nodes = vec![
target,
Expand Down
2 changes: 1 addition & 1 deletion crates/compass-files/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use sha2::{Digest, Sha256};
use crate::{FileError, StatHashIndex, file_hash, io_error, write_bytes_atomic, write_json_atomic};

/// Changes whenever cached extraction semantics change, even if the wire encoding does not.
pub const AST_CACHE_VERSION: &str = "13";
pub const AST_CACHE_VERSION: &str = "14";
/// Portable cache encoding version used in the on-disk namespace.
pub const CACHE_ENCODING_VERSION: u32 = 1;
const MESSAGEPACK_EXTENSION: &str = "msgpack";
Expand Down
23 changes: 23 additions & 0 deletions crates/compass-graph/src/v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1982,6 +1982,7 @@ fn ensure_external_placeholder_details(nodes: &mut [NodeRecord]) {
signature_digest: None,
implementation_digest: None,
source_digest: None,
call_sites: None,
}));
}
}
Expand Down Expand Up @@ -3699,6 +3700,9 @@ fn insert_raw_node_details(attributes: &mut Map<String, Value>, details: &NodeDe
NodeDetails::File(_) | NodeDetails::Resource(_) => {}
NodeDetails::Document(details) => insert_raw_document_details(attributes, details),
NodeDetails::Symbol(details) => {
if let Some(inventory) = &details.call_sites {
attributes.insert("call_sites".to_owned(), serde_json::json!(inventory));
}
insert_optional_string(attributes, "signature", details.signature.as_ref());
insert_optional_string(
attributes,
Expand Down Expand Up @@ -5266,6 +5270,24 @@ fn node_details(
record: &str,
root: &Path,
) -> Result<Option<NodeDetails>, GraphError> {
let call_sites = attributes
.get("call_sites")
.map(|value| {
let mut inventory: compass_model::code_graph::CallSiteInventory =
serde_json::from_value(value.clone()).map_err(|error| {
raw_error(record, &format!("invalid call-site inventory: {error}"))
})?;
if inventory.sites.len() > compass_model::code_graph::MAX_SYMBOL_CALL_SITES {
return Err(raw_error(record, "call-site inventory exceeds its bound"));
}
for site in &mut inventory.sites {
site.file = portable_path(&site.file, root)?;
}
inventory.sites.sort();
inventory.sites.dedup();
Ok(inventory)
})
.transpose()?;
let details = match kind {
NodeKind::File => {
let file = file_facts.get(source_path).ok_or_else(|| {
Expand Down Expand Up @@ -5434,6 +5456,7 @@ fn node_details(
signature_digest: optional_string(attributes, "signature_hash"),
implementation_digest: optional_string(attributes, "implementation_hash"),
source_digest: optional_string(attributes, "source_hash"),
call_sites,
})),
};
Ok(details)
Expand Down
Loading
Loading