From 80c6c522bdeac458a65d22a572182c2d6026bab9 Mon Sep 17 00:00:00 2001 From: forhappy Date: Wed, 30 Sep 2026 19:38:00 -0700 Subject: [PATCH] Resolve Python receiver calls and audit impact coverage --- CHANGELOG.md | 11 + COMPATIBILITY.md | 44 +++ Cargo.lock | 1 + MIGRATION.md | 11 + crates/compass-cli/src/code_query_commands.rs | 51 ++- crates/compass-cli/src/help.rs | 2 +- crates/compass-cli/tests/code_query_cli.rs | 49 +++ .../examples/republish_qualification.rs | 27 ++ .../tests/code_graph_v1_determinism.rs | 43 +++ crates/compass-core/tests/task_context.rs | 1 + crates/compass-files/src/cache.rs | 2 +- crates/compass-graph/src/v1.rs | 23 ++ .../compass-languages/src/evidence/build.rs | 88 ++++- .../src/evidence/build/python_receivers.rs | 363 ++++++++++++++++++ .../compass-languages/src/evidence/model.rs | 3 +- .../src/evidence/validate.rs | 11 +- .../tests/universal_evidence.rs | 39 ++ crates/compass-mcp/src/code_query.rs | 42 +- crates/compass-mcp/src/lib.rs | 4 +- crates/compass-mcp/tests/code_query_tools.rs | 37 ++ crates/compass-model/src/code_graph.rs | 13 + crates/compass-model/src/provenance.rs | 2 +- crates/compass-model/src/query_contract.rs | 43 +++ crates/compass-model/src/validation.rs | 29 ++ .../tests/code_graph_validation.rs | 34 ++ .../tests/typed_property_projection.rs | 1 + crates/compass-output/src/agent_query.rs | 93 ++++- crates/compass-output/tests/agent_query.rs | 59 +++ crates/compass-query/Cargo.toml | 2 + crates/compass-query/examples/query_batch.rs | 66 ++++ crates/compass-query/src/code_query.rs | 213 +++++++++- .../src/code_query/impact_summary.rs | 127 ++++++ crates/compass-query/tests/code_impact.rs | 132 +++++++ crates/compass-query/tests/code_traversal.rs | 96 +++++ crates/compass-resolve/src/evidence/facts.rs | 8 + .../src/evidence/projection/mod.rs | 28 +- .../src/evidence/projection/nodes.rs | 54 +++ .../src/evidence/resolve/bindings.rs | 33 +- .../src/evidence/resolve/members.rs | 83 +++- crates/compass-resolve/src/lib.rs | 20 +- .../tests/universal_resolution/python.rs | 173 ++++++++- docs/README.md | 1 + .../python-call-impact-qualification.md | 124 ++++++ docs/reference/commands.md | 25 +- docs/reference/universal-semantic-evidence.md | 6 +- scripts/qualify_python_service_calls.py | 211 ++++++++++ .../qualification/code-graph-v1-topology.json | 10 +- 47 files changed, 2468 insertions(+), 70 deletions(-) create mode 100644 crates/compass-core/examples/republish_qualification.rs create mode 100644 crates/compass-languages/src/evidence/build/python_receivers.rs create mode 100644 crates/compass-query/examples/query_batch.rs create mode 100644 crates/compass-query/src/code_query/impact_summary.rs create mode 100644 docs/implementation/python-call-impact-qualification.md create mode 100644 scripts/qualify_python_service_calls.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a37f60e..fe57f7fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 92875aae6..a0243b075 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 24aeca41b..bdc54ac48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1597,6 +1597,7 @@ dependencies = [ "compass-agent-graph", "compass-analysis", "compass-cypher", + "compass-files", "compass-graph", "compass-graphdb", "compass-ir", diff --git a/MIGRATION.md b/MIGRATION.md index f48620b50..bbfcd3fad 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -7,6 +7,17 @@ layout remains visible and clearly owned. ## Graph rebuilds and query resolution +Rebuild Python graphs with `compass extract --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 --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 diff --git a/crates/compass-cli/src/code_query_commands.rs b/crates/compass-cli/src/code_query_commands.rs index 41d359ee9..e87c9db88 100644 --- a/crates/compass-cli/src/code_query_commands.rs +++ b/crates/compass-cli/src/code_query_commands.rs @@ -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, @@ -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 ")?.to_owned(); @@ -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)?; @@ -488,6 +497,7 @@ fn positional(args: &[String]) -> Vec { "--file", "--line", "--kind", + "--relation", ]; let mut values = Vec::new(); let mut skip = false; @@ -503,6 +513,37 @@ fn positional(args: &[String]) -> Vec { values } +fn impact_relations(operation: &str, args: &[String]) -> Result, 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::(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) diff --git a/crates/compass-cli/src/help.rs b/crates/compass-cli/src/help.rs index b6c475622..fe935cbc0 100644 --- a/crates/compass-cli/src/help.rs +++ b/crates/compass-cli/src/help.rs @@ -338,7 +338,7 @@ const PAGES: &[Page] = &[ "impact", "Compute the bounded transitive impact of a symbol", ["compass impact [OPTIONS]"], - "Arguments:\n Changed 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-depth Traversal radius\n --max-nodes Node bound\n --max-edges Edge bound\n --include-heuristic Traverse heuristic evidence\n --format Output format [default: text]\n --text-budget Approximate tokens per text page [default: 2000]\n --cursor Continue the same text result (text only)\n --timeout-ms 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 Changed 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-depth Traversal radius\n --max-nodes Node bound\n --max-edges Edge bound\n --relation Follow stored relationship type; repeatable\n --include-heuristic Traverse heuristic evidence\n --format Output format [default: text]\n --text-budget Approximate tokens per text page [default: 2000]\n --cursor Continue the same text result (text only)\n --timeout-ms 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", diff --git a/crates/compass-cli/tests/code_query_cli.rs b/crates/compass-cli/tests/code_query_cli.rs index c160dc6d3..653a9e0e1 100644 --- a/crates/compass-cli/tests/code_query_cli.rs +++ b/crates/compass-cli/tests/code_query_cli.rs @@ -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> { + 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(()) +} diff --git a/crates/compass-core/examples/republish_qualification.rs b/crates/compass-core/examples/republish_qualification.rs new file mode 100644 index 000000000..65e992ef7 --- /dev/null +++ b/crates/compass-core/examples/republish_qualification.rs @@ -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> { + 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(()) +} diff --git a/crates/compass-core/tests/code_graph_v1_determinism.rs b/crates/compass-core/tests/code_graph_v1_determinism.rs index b2cc5589d..a01aa8699 100644 --- a/crates/compass-core/tests/code_graph_v1_determinism.rs +++ b/crates/compass-core/tests/code_graph_v1_determinism.rs @@ -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> { + 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(()) +} diff --git a/crates/compass-core/tests/task_context.rs b/crates/compass-core/tests/task_context.rs index dbe020777..aa3639b7b 100644 --- a/crates/compass-core/tests/task_context.rs +++ b/crates/compass-core/tests/task_context.rs @@ -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, diff --git a/crates/compass-files/src/cache.rs b/crates/compass-files/src/cache.rs index 6e77f636a..85b18a419 100644 --- a/crates/compass-files/src/cache.rs +++ b/crates/compass-files/src/cache.rs @@ -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"; diff --git a/crates/compass-graph/src/v1.rs b/crates/compass-graph/src/v1.rs index 9d672aee1..d9b60108c 100644 --- a/crates/compass-graph/src/v1.rs +++ b/crates/compass-graph/src/v1.rs @@ -1982,6 +1982,7 @@ fn ensure_external_placeholder_details(nodes: &mut [NodeRecord]) { signature_digest: None, implementation_digest: None, source_digest: None, + call_sites: None, })); } } @@ -3699,6 +3700,9 @@ fn insert_raw_node_details(attributes: &mut Map, 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, @@ -5266,6 +5270,24 @@ fn node_details( record: &str, root: &Path, ) -> Result, 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(|| { @@ -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) diff --git a/crates/compass-languages/src/evidence/build.rs b/crates/compass-languages/src/evidence/build.rs index 6e5a2fedb..92d4e4383 100644 --- a/crates/compass-languages/src/evidence/build.rs +++ b/crates/compass-languages/src/evidence/build.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; @@ -16,6 +16,7 @@ use super::model::{ use super::validate::{EvidenceError, EvidenceErrorCode, EvidenceLimits, validate_evidence}; mod java_fields; +mod python_receivers; // Go selector attribution can cross a closure, a multi-return call, and a // range expression before reaching the receiver type. Keep that traversal @@ -1017,6 +1018,7 @@ struct DirectEvidenceState<'source> { python_parameters: HashMap, python_callable_return_types: HashMap, python_ambiguous_callable_returns: HashSet, + python_mutated_receiver_members: HashSet<(String, String)>, python_call_result_binding_ids: HashMap<(String, String, usize), String>, rust_containers: HashMap, rust_impls: HashMap, @@ -1112,6 +1114,7 @@ impl<'source> DirectEvidenceState<'source> { python_parameters: HashMap::new(), python_callable_return_types: HashMap::new(), python_ambiguous_callable_returns: HashSet::new(), + python_mutated_receiver_members: HashSet::new(), python_call_result_binding_ids: HashMap::new(), rust_containers: HashMap::new(), rust_impls: HashMap::new(), @@ -1269,6 +1272,8 @@ impl<'source> DirectEvidenceState<'source> { })?; self.collect_python_declarations(root, &file)?; self.collect_python_imports(root, &file)?; + self.index_python_mutated_receiver_members(root); + self.collect_python_receiver_members(root, &file, None)?; self.collect_python_partial_aliases(root, &file)?; self.collect_python_module_variables(root, &file)?; let module_bound = crate::engine::python_bound_names(root, self.source, true); @@ -1880,6 +1885,26 @@ impl<'source> DirectEvidenceState<'source> { CandidateRelation::TypeOf, &canonical, )?; + if variable.kind == "field" + && !self.python_mutated_receiver_members.contains(&( + variable + .enclosing_type_qualified_name + .clone() + .unwrap_or_default(), + variable.name.clone(), + )) + { + for target in &canonical.runtime_targets { + self.builder.bind( + BindingKind::Member, + &variable.name, + target, + None, + Some(&variable.scope_id), + range_for_node(self.source_file, annotation), + )?; + } + } } } _ => {} @@ -2015,6 +2040,19 @@ impl<'source> DirectEvidenceState<'source> { } } } + if matches!(node.kind(), "except_clause" | "raise_statement") { + let mut cursor = node.walk(); + for value in node + .named_children(&mut cursor) + .filter(|child| child.kind() != "block") + { + let mut identifiers = Vec::new(); + crate::engine::collect_python_reference_values(value, &mut identifiers); + for identifier in identifiers { + self.add_python_value_reference(owner, Some(identifier), "exception", bound)?; + } + } + } if matches!(node.kind(), "dictionary" | "list" | "set" | "tuple") { let mut identifiers = Vec::new(); crate::engine::collect_python_collection_values(node, &mut identifiers); @@ -8243,6 +8281,20 @@ impl<'source> DirectEvidenceState<'source> { if spelling.is_empty() { return Ok(()); } + let observed_occurrence = if self.language == "python" { + // Retain invocation evidence even when conservative target lookup + // rejects a shadowed, dynamic, or otherwise unresolved receiver. + Some(self.builder.occur( + SemanticRole::Call, + &owner.fact_id, + spelling, + qualifier, + Some(&owner.scope_id), + range_for_node(self.source_file, function), + )?) + } else { + None + }; let python_super_receiver = (self.language == "python") .then(|| python_super_receiver(function, self.source)) .flatten(); @@ -8363,10 +8415,22 @@ impl<'source> DirectEvidenceState<'source> { }) .transpose()? .flatten() + } else if self.language == "python" { + self.python_chained_receiver_binding(owner, function, call, 0)? } else { None }; - let binding = call_result_binding.or_else(|| { + let typed_python_binding = if self.language == "python" && receiver_dispatch.is_none() { + qualifier + .map(|qualifier| { + self.python_typed_receiver_binding(owner, function, call, qualifier) + }) + .transpose()? + .flatten() + } else { + None + }; + let binding = call_result_binding.or(typed_python_binding).or_else(|| { if self.language == "go" && qualifier.is_some() && let Some((value, _)) = @@ -8433,14 +8497,18 @@ impl<'source> DirectEvidenceState<'source> { })? }) }; - let occurrence_id = self.builder.occur( - role, - &owner.fact_id, - spelling, - qualifier, - Some(&owner.scope_id), - range_for_node(self.source_file, function), - )?; + let occurrence_id = if let Some(id) = observed_occurrence { + id + } else { + self.builder.occur( + role, + &owner.fact_id, + spelling, + qualifier, + Some(&owner.scope_id), + range_for_node(self.source_file, function), + )? + }; self.builder.relate( relation, &owner.fact_id, diff --git a/crates/compass-languages/src/evidence/build/python_receivers.rs b/crates/compass-languages/src/evidence/build/python_receivers.rs new file mode 100644 index 000000000..811ca71d0 --- /dev/null +++ b/crates/compass-languages/src/evidence/build/python_receivers.rs @@ -0,0 +1,363 @@ +//! Source-backed Python receiver bindings. Project-wide member selection stays +//! in compass-resolve; this producer records only the nominal root type. + +use super::*; + +impl DirectEvidenceState<'_> { + pub(super) fn index_python_mutated_receiver_members(&mut self, root: Node<'_>) { + let mut writes = BTreeMap::<(String, String), (usize, bool)>::new(); + let mut pending = vec![(root, String::new(), String::new())]; + while let Some((node, mut class, mut method)) = pending.pop() { + if node.kind() == "class_definition" { + class = self + .declarations + .get(&node.id()) + .map(|context| context.qualified_name.clone()) + .unwrap_or_default(); + method.clear(); + } else if node.kind() == "function_definition" { + method = node + .child_by_field_name("name") + .map(|name| self.text(name)) + .unwrap_or_default(); + } + if matches!(node.kind(), "assignment" | "augmented_assignment") + && !class.is_empty() + && let Some(lhs) = node + .child_by_field_name("left") + .filter(|node| node.kind() == "attribute") + && let (Some(object), Some(member)) = ( + lhs.child_by_field_name("object"), + lhs.child_by_field_name("attribute"), + ) + && self.text(object) == "self" + { + let entry = writes + .entry((class.clone(), self.text(member))) + .or_default(); + entry.0 += 1; + entry.1 |= method != "__init__" + || node.kind() != "assignment" + || !python_direct_function_assignment(node); + } + let mut cursor = node.walk(); + pending.extend( + node.named_children(&mut cursor) + .map(|child| (child, class.clone(), method.clone())), + ); + } + self.python_mutated_receiver_members.extend( + writes + .into_iter() + .filter(|(_, (count, unsafe_write))| *count > 1 || *unsafe_write) + .map(|(key, _)| key), + ); + } + + pub(super) fn collect_python_receiver_members( + &mut self, + node: Node<'_>, + owner: &DeclarationContext, + class: Option<&DeclarationContext>, + ) -> Result<(), EvidenceError> { + let active = self + .declarations + .get(&node.id()) + .cloned() + .unwrap_or_else(|| owner.clone()); + let class = if node.kind() == "class_definition" { + Some(&active) + } else { + class + }; + if node.kind() == "assignment" && active.name == "__init__" { + let lhs = node.child_by_field_name("left"); + let direct = python_direct_function_assignment(node); + if direct + && let (Some(lhs), Some(class)) = + (lhs.filter(|lhs| lhs.kind() == "attribute"), class) + && let (Some(object), Some(member)) = ( + lhs.child_by_field_name("object"), + lhs.child_by_field_name("attribute"), + ) + && self.text(object) == "self" + && !self + .python_mutated_receiver_members + .contains(&(class.qualified_name.clone(), self.text(member))) + && self + .python_bound_method_receiver(node, "self", node.start_byte())? + .as_deref() + == Some(class.qualified_name.as_str()) + { + let nominal = if let Some(annotation) = node.child_by_field_name("type") { + self.python_canonical_annotation(&active, annotation) + .and_then(|annotation| match annotation.runtime_targets.as_slice() { + [target] => Some(target.clone()), + _ => None, + }) + } else if let Some(rhs) = node.child_by_field_name("right") { + self.python_nominal_value(&active, rhs, node) + } else { + None + }; + if let Some(nominal) = nominal { + self.builder.bind( + BindingKind::Member, + &self.text(member), + &nominal, + None, + Some(&class.scope_id), + range_for_node(self.source_file, node), + )?; + } + } + } + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + self.collect_python_receiver_members(child, &active, class)?; + } + Ok(()) + } + + /// Connect a chained invocation to the source callable's return contract. + /// Unknown callable/return types remain deferred in project resolution. + pub(super) fn python_chained_receiver_binding( + &mut self, + owner: &DeclarationContext, + function: Node<'_>, + call: Node<'_>, + depth: usize, + ) -> Result, EvidenceError> { + if depth >= 16 || function.kind() != "attribute" { + return Ok(None); + } + let Some(receiver) = function + .child_by_field_name("object") + .filter(|node| node.kind() == "call") + else { + return Ok(None); + }; + self.python_call_result_binding(owner, receiver, call, depth + 1) + } + + fn python_call_result_binding( + &mut self, + owner: &DeclarationContext, + receiver: Node<'_>, + site: Node<'_>, + depth: usize, + ) -> Result, EvidenceError> { + if depth >= 16 { + return Ok(None); + } + let Some(called) = receiver.child_by_field_name("function") else { + return Ok(None); + }; + let raw = self.text(called); + let (qualifier, spelling) = split_qualified(&raw); + let (target, receiver_binding) = if let Some(qualifier) = qualifier { + if let Some(binding) = + self.python_chained_receiver_binding(owner, called, site, depth + 1)? + { + (spelling.to_owned(), Some(binding)) + } else if let Some(binding) = + self.python_typed_receiver_binding(owner, called, site, qualifier)? + { + // Preserve the field path; the resolver follows nominal member + // bindings before selecting the factory method's return type. + let suffix = qualifier + .split_once('.') + .map(|(_, tail)| format!("{tail}.{spelling}")) + .unwrap_or_else(|| spelling.to_owned()); + (suffix, Some(binding)) + } else if let Some(target) = + self.imported_qualified_target_for(owner, qualifier, called.start_byte(), true) + { + (format!("{target}.{spelling}"), None) + } else { + return Ok(None); + } + } else { + if self.python_name_is_statically_local(owner, spelling) { + return Ok(None); + } + let target = self + .python_unique_visible_declaration( + owner, + spelling, + called.start_byte(), + &["class", "function"], + ) + .map(|declaration| declaration.qualified_name) + .or_else(|| { + self.imported_target_for_occurrence(owner, spelling, called.start_byte(), true) + .cloned() + }); + let Some(target) = target else { + return Ok(None); + }; + (target, None) + }; + Ok(Some(self.builder.bind_chained_call_result( + &raw, + &target, + None, + receiver_binding.as_deref(), + None, + Some(&owner.scope_id), + range_for_node(self.source_file, called), + )?)) + } + + fn python_nominal_value( + &self, + owner: &DeclarationContext, + value: Node<'_>, + site: Node<'_>, + ) -> Option { + if value.kind() == "call" { + let function = value.child_by_field_name("function")?; + return self.python_nominal_annotation( + owner, + &self.text(function), + function.start_byte(), + ); + } + if value.kind() != "identifier" { + return None; + } + let name = self.text(value); + let mut ancestor = Some(site); + while let Some(node) = ancestor { + if node.kind() == "function_definition" { + let parameter = python_parameter_nodes(node) + .into_iter() + .find(|parameter| self.text(parameter.name) == name)?; + let body = node.child_by_field_name("body").unwrap_or(node); + if self.python_name_rebound_between( + body, + parameter.name.end_byte(), + value.start_byte(), + &name, + ) { + return None; + } + let annotation = self.python_canonical_annotation(owner, parameter.annotation?)?; + return match annotation.runtime_targets.as_slice() { + [target] => Some(target.clone()), + _ => None, + }; + } + ancestor = node.parent(); + } + None + } + + pub(super) fn python_typed_receiver_binding( + &mut self, + owner: &DeclarationContext, + function: Node<'_>, + call: Node<'_>, + qualifier: &str, + ) -> Result, EvidenceError> { + if !qualifier.split('.').all(valid_python_identifier) { + return Ok(None); + } + let root = qualified_binding_head(qualifier); + let mut nominal = if matches!(root, "self" | "cls") { + self.python_bound_method_receiver(call, root, function.start_byte())? + } else { + None + }; + let mut ancestor = Some(call); + while nominal.is_none() { + let Some(node) = ancestor else { break }; + if node.kind() == "function_definition" { + let body = node.child_by_field_name("body").unwrap_or(node); + if let Some(parameter) = python_parameter_nodes(node) + .into_iter() + .find(|parameter| self.text(parameter.name) == root) + { + if !self.python_name_rebound_between( + body, + parameter.name.end_byte(), + function.start_byte(), + root, + ) { + nominal = parameter + .annotation + .and_then(|annotation| { + self.python_canonical_annotation(owner, annotation) + }) + .and_then(|annotation| match annotation.runtime_targets.as_slice() { + [target] => Some(target.clone()), + _ => None, + }); + } + break; + } + if crate::engine::python_bound_names(node, self.source, false).contains(root) { + // Only a single unconditional local declaration can + // establish a nominal type; branching/rebinding stays unknown. + let mut cursor = body.walk(); + let mut assignments = body.named_children(&mut cursor).filter(|statement| { + statement.start_byte() < function.start_byte() + && crate::engine::python_bound_names(*statement, self.source, true) + .contains(root) + }); + if let Some(assignment) = assignments.next() + && assignments.next().is_none() + && assignment.kind() == "assignment" + && let Some(annotation) = assignment.child_by_field_name("type") + { + nominal = self + .python_canonical_annotation(owner, annotation) + .and_then(|annotation| match annotation.runtime_targets.as_slice() { + [target] => Some(target.clone()), + _ => None, + }); + } + if nominal.is_none() { + nominal = self.python_local_initializer_receiver( + owner, + root, + function.start_byte(), + call, + )?; + } + break; + } + } + if node.kind() == "class_definition" { + break; + } + ancestor = node.parent(); + } + let Some(nominal) = nominal else { + return Ok(None); + }; + let binding = self.builder.bind( + BindingKind::LocalAlias, + root, + &nominal, + None, + Some(&owner.scope_id), + range_for_node(self.source_file, function), + )?; + Ok(Some(binding)) + } +} + +fn python_direct_function_assignment(node: Node<'_>) -> bool { + node.parent() + .and_then(|parent| { + if parent.kind() == "expression_statement" { + parent.parent() + } else { + Some(parent) + } + }) + .filter(|body| body.kind() == "block") + .and_then(|body| body.parent()) + .is_some_and(|function| function.kind() == "function_definition") +} diff --git a/crates/compass-languages/src/evidence/model.rs b/crates/compass-languages/src/evidence/model.rs index e192d938f..7bb9dffdb 100644 --- a/crates/compass-languages/src/evidence/model.rs +++ b/crates/compass-languages/src/evidence/model.rs @@ -292,7 +292,8 @@ pub struct BindingFact { /// Exact nominal result type proven by the producer for this call result. #[serde(default, skip_serializing_if = "Option::is_none")] pub result_type_qualified_name: Option, - /// Earlier call result whose nominal type receives this method call. + /// Earlier call result, or a source-backed Python nominal local alias, + /// whose type receives this method call. #[serde(default, skip_serializing_if = "Option::is_none")] pub receiver_binding_id: Option, /// Prior binding retained when project-wide call-result evidence is absent. diff --git a/crates/compass-languages/src/evidence/validate.rs b/crates/compass-languages/src/evidence/validate.rs index cb774a614..215d47956 100644 --- a/crates/compass-languages/src/evidence/validate.rs +++ b/crates/compass-languages/src/evidence/validate.rs @@ -443,13 +443,16 @@ fn validate_fact( bindings, )?; if let Some(receiver_id) = fact.receiver_binding_id.as_deref() - && bindings - .get(receiver_id) - .is_some_and(|receiver| receiver.kind != crate::BindingKind::CallResult) + && bindings.get(receiver_id).is_some_and(|receiver| { + receiver.kind != crate::BindingKind::CallResult + && !(fact.language == "python" + && receiver.language == fact.language + && receiver.kind == crate::BindingKind::LocalAlias) + }) { return Err(invalid_fact( &fact.id, - "call-result receiver must reference another call-result binding", + "call-result receiver must reference a call-result binding or a Python nominal local alias", )); } if let Some(fallback_id) = fact.fallback_binding_id.as_deref() diff --git a/crates/compass-languages/tests/universal_evidence.rs b/crates/compass-languages/tests/universal_evidence.rs index 8031d3e86..66f6c5130 100644 --- a/crates/compass-languages/tests/universal_evidence.rs +++ b/crates/compass-languages/tests/universal_evidence.rs @@ -2116,3 +2116,42 @@ func direct(current *Bad, values []*Good) { 10 ); } + +#[test] +fn python_call_result_can_receive_a_nominal_alias_but_other_binding_kinds_cannot() { + let mut batch = valid_batch(); + batch + .pipeline + .capabilities + .push(LanguageCapability::TypeReferences); + let mut receiver = batch.bindings[0].clone(); + receiver.id = "binding:receiver".to_owned(); + receiver.kind = BindingKind::LocalAlias; + batch.bindings.push(receiver); + batch.bindings[0].kind = BindingKind::CallResult; + batch.bindings[0].receiver_binding_id = Some("binding:receiver".to_owned()); + validate_evidence(&batch, EvidenceLimits::default()).expect("Python nominal receiver"); + batch.bindings[1].kind = BindingKind::Import; + assert_code(&batch, EvidenceErrorCode::InvalidFact); + batch.bindings[1].kind = BindingKind::LocalAlias; + batch.pipeline.language = "rust".to_owned(); + batch.pipeline.id = "compass.rust".to_owned(); + batch.pipeline.emitter = "tree-sitter-rust".to_owned(); + for fact in &mut batch.declarations { + fact.language = "rust".to_owned(); + } + for fact in &mut batch.scopes { + fact.language = "rust".to_owned(); + } + for fact in &mut batch.bindings { + fact.language = "rust".to_owned(); + } + for fact in &mut batch.occurrences { + fact.language = "rust".to_owned(); + } + for fact in &mut batch.candidates { + fact.language = "rust".to_owned(); + fact.constraints.exact_language = Some("rust".to_owned()); + } + assert_code(&batch, EvidenceErrorCode::InvalidFact); +} diff --git a/crates/compass-mcp/src/code_query.rs b/crates/compass-mcp/src/code_query.rs index 4e091c060..0d8e17415 100644 --- a/crates/compass-mcp/src/code_query.rs +++ b/crates/compass-mcp/src/code_query.rs @@ -54,6 +54,12 @@ pub(super) fn search_schema() -> Value { result } +pub(super) fn impact_schema() -> Value { + let mut result = schema(&["symbol"]); + result["properties"]["relations"] = json!({"type":"array","minItems":1,"maxItems":32,"items":{"type":"string"},"description":"Stored relationship types to follow in reverse, such as calls, instantiates, references, extends, type_of, returns, or imports. Omit for the full dependency default."}); + result +} + pub(super) fn trail_schema() -> Value { let mut result = schema(&["source", "target"]); result["properties"]["calls_only"] = json!({"type":"boolean","default":false,"description":"Only directed calls edges; excludes structural shortcuts and applies to direction diagnostics too."}); @@ -79,6 +85,11 @@ pub(super) fn invoke_with_engine( "exact symbol filters require search_symbols".to_owned(), )); } + if name != "get_impact" && arguments.contains_key("relations") { + return Err(super::InvocationError::InvalidParams( + "relations requires get_impact".to_owned(), + )); + } let limits = limits(arguments)?; match name { "query_graph" => engine.query_natural(NaturalQueryRequest { @@ -141,11 +152,32 @@ pub(super) fn invoke_with_engine( include_heuristic: boolean(arguments, "include_heuristic")?, limits, }), - "get_impact" => engine.impact(ImpactRequest { - symbol: required_string(arguments, "symbol")?, - include_heuristic: boolean(arguments, "include_heuristic")?, - limits, - }), + "get_impact" => { + let request = ImpactRequest { + symbol: required_string(arguments, "symbol")?, + include_heuristic: boolean(arguments, "include_heuristic")?, + limits, + }; + if let Some(value) = arguments.get("relations") { + let values = value + .as_array() + .filter(|values| !values.is_empty() && values.len() <= 32) + .ok_or_else(|| { + "relations must contain 1 to 32 stored relationship types".to_owned() + })?; + let relations = values + .iter() + .map(|value| { + serde_json::from_value(value.clone()).map_err(|_| { + "relations contains an unknown stored relationship type".to_owned() + }) + }) + .collect::, _>>()?; + engine.impact_with_relations(request, &relations) + } else { + engine.impact(request) + } + } "explore_code" => engine.explore(ExploreRequest { symbols: arguments .get("symbols") diff --git a/crates/compass-mcp/src/lib.rs b/crates/compass-mcp/src/lib.rs index 33c8fedda..b2b93880e 100644 --- a/crates/compass-mcp/src/lib.rs +++ b/crates/compass-mcp/src/lib.rs @@ -1338,8 +1338,8 @@ fn tool_specs() -> Vec { ), tool( "get_impact", - "Return the bounded transitive impact radius for a symbol.", - code_query::schema(&["symbol"]), + "Return direct and transitive dependents grouped by file/module, with a direct-connection coverage audit.", + code_query::impact_schema(), ), tool( "explore_code", diff --git a/crates/compass-mcp/tests/code_query_tools.rs b/crates/compass-mcp/tests/code_query_tools.rs index de644f5b1..c868b26a5 100644 --- a/crates/compass-mcp/tests/code_query_tools.rs +++ b/crates/compass-mcp/tests/code_query_tools.rs @@ -928,3 +928,40 @@ fn typed_queries_and_legacy_views_reload_equal_metadata_together() -> Result<(), } Ok(()) } + +#[test] +fn impact_tools_filter_typed_relations_and_audit_omissions() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let graph = write_typed_graph(directory.path())?; + let server = CompassMcp::new(graph); + let filtered = invoke( + &server, + "get_impact", + json!({"symbol":"Target","relations":["imports"]}), + )?; + assert_eq!(filtered["impactSummary"]["relations"], json!(["imports"])); + assert_eq!(filtered["impactSummary"]["directDependents"], json!([])); + assert!( + filtered["impactSummary"]["excludedDirectConnections"] + .as_u64() + .ok_or("missing excluded count")? + > 0 + ); + assert!( + invoke( + &server, + "get_impact", + json!({"symbol":"Target","relations":["imagined"]}) + ) + .is_err() + ); + assert!( + invoke( + &server, + "get_impact", + json!({"symbol":"Target","relations":[]}) + ) + .is_err() + ); + Ok(()) +} diff --git a/crates/compass-model/src/code_graph.rs b/crates/compass-model/src/code_graph.rs index 29cea6a2c..52bccb0de 100644 --- a/crates/compass-model/src/code_graph.rs +++ b/crates/compass-model/src/code_graph.rs @@ -466,6 +466,19 @@ pub struct SymbolNodeDetails { pub implementation_digest: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub source_digest: Option, + /// Source-observed invocations, including calls whose target could not be + /// resolved. Absent on older graphs and producers without this coverage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub call_sites: Option, +} + +pub const MAX_SYMBOL_CALL_SITES: usize = 4_096; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CallSiteInventory { + pub sites: Vec, + pub truncated: bool, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] diff --git a/crates/compass-model/src/provenance.rs b/crates/compass-model/src/provenance.rs index 0b89e5162..47ed9a7ed 100644 --- a/crates/compass-model/src/provenance.rs +++ b/crates/compass-model/src/provenance.rs @@ -153,7 +153,7 @@ pub fn append_endpoint_rewrite_evidence( } /// A repository-relative, half-open source range. -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SourceAnchor { pub file: String, diff --git a/crates/compass-model/src/query_contract.rs b/crates/compass-model/src/query_contract.rs index efba10200..530c1d787 100644 --- a/crates/compass-model/src/query_contract.rs +++ b/crates/compass-model/src/query_contract.rs @@ -552,6 +552,47 @@ pub struct CodeQueryResponse { pub diagnostics: Vec, pub limits: CodeQueryLimits, pub truncated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub call_summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub impact_summary: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ImpactSummary { + pub relations: Vec, + /// All observed neighbors, distinguished by stored direction, as explain + /// does. Parallel relationship records do not inflate this count. + pub observed_direct_connections: u64, + pub direct_dependents: Vec, + pub transitive_dependents: Vec, + pub outgoing_context: Vec, + pub excluded_direct_connections: u64, + /// All selected direct neighbors in the published graph were retained. + /// Repository/source completeness remains in response diagnostics. + pub direct_coverage_complete: bool, + pub groups: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ImpactFileGroup { + pub file: Option, + pub module: String, + pub direct: Vec, + pub transitive: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CallSummary { + pub resolved_calls: u64, + /// Source-observed calls lacking an exact graph target. None means the + /// producer or historical graph did not retain a call-site inventory. + pub unresolved_calls: Option, + pub observed_calls: Option, + pub inventory_complete: bool, } impl CodeQueryResponse { @@ -568,6 +609,8 @@ impl CodeQueryResponse { diagnostics: Vec::new(), limits, truncated: false, + call_summary: None, + impact_summary: None, } } diff --git a/crates/compass-model/src/validation.rs b/crates/compass-model/src/validation.rs index 208067285..728571a47 100644 --- a/crates/compass-model/src/validation.rs +++ b/crates/compass-model/src/validation.rs @@ -295,6 +295,35 @@ pub fn validate_code_graph_records(document: &CodeGraphDocument) -> CodeGraphVal if let Some(anchor) = &node.source { validate_anchor(&node.id, anchor, &files, &mut errors); } + if let Some(NodeDetails::Symbol(details)) = &node.details + && let Some(inventory) = &details.call_sites + { + if inventory.sites.len() > crate::code_graph::MAX_SYMBOL_CALL_SITES { + errors.push(format!( + "node {} call-site inventory exceeds its bound", + node.id + )); + } + for site in &inventory.sites { + validate_anchor(&node.id, site, &files, &mut errors); + if node + .source + .as_ref() + .is_some_and(|source| source.file != site.file) + { + errors.push(format!( + "node {} call site belongs to a different file", + node.id + )); + } + } + if inventory.sites.windows(2).any(|pair| pair[0] >= pair[1]) { + errors.push(format!( + "node {} call sites must be unique and sorted", + node.id + )); + } + } if !details_match_kind(node.kind, node.details.as_ref()) { errors.push(format!( "node {} has details incompatible with kind {}", diff --git a/crates/compass-model/tests/code_graph_validation.rs b/crates/compass-model/tests/code_graph_validation.rs index 6f140f960..dceed4b83 100644 --- a/crates/compass-model/tests/code_graph_validation.rs +++ b/crates/compass-model/tests/code_graph_validation.rs @@ -853,3 +853,37 @@ fn unknown_rewrite_like_names_remain_valid_open_ended_producer_rules() { assert!(validate_code_graph(&graph).is_ok()); } use std::error::Error; + +#[test] +fn symbol_call_inventories_validate_bounds_files_and_ordering() { + use compass_model::code_graph::{CallSiteInventory, NodeDetails, SymbolNodeDetails}; + let mut graph = document(); + let details = SymbolNodeDetails { + signature: None, + modifiers: Vec::new(), + overload_discriminator: None, + declaring_type: None, + signature_digest: None, + implementation_digest: None, + source_digest: None, + call_sites: Some(CallSiteInventory { + sites: vec![anchor()], + truncated: false, + }), + }; + graph.nodes[1].details = Some(NodeDetails::Symbol(details.clone())); + assert!(validate_code_graph(&graph).is_ok()); + let mut duplicate = details.clone(); + duplicate + .call_sites + .as_mut() + .expect("inventory") + .sites + .push(anchor()); + graph.nodes[1].details = Some(NodeDetails::Symbol(duplicate)); + assert!(validate_code_graph(&graph).is_err()); + let mut outside = details; + outside.call_sites.as_mut().expect("inventory").sites[0].file = "other.py".to_owned(); + graph.nodes[1].details = Some(NodeDetails::Symbol(outside)); + assert!(validate_code_graph(&graph).is_err()); +} diff --git a/crates/compass-model/tests/typed_property_projection.rs b/crates/compass-model/tests/typed_property_projection.rs index a7fc56b66..dc5f5b928 100644 --- a/crates/compass-model/tests/typed_property_projection.rs +++ b/crates/compass-model/tests/typed_property_projection.rs @@ -49,6 +49,7 @@ fn projections_expose_only_registered_derived_properties() { signature_digest: Some("sha256:signature".to_owned()), implementation_digest: Some("sha256:implementation".to_owned()), source_digest: Some("sha256:source".to_owned()), + call_sites: None, }, )), evidence: vec![evidence()], diff --git a/crates/compass-output/src/agent_query.rs b/crates/compass-output/src/agent_query.rs index e1b331dba..bedef38b7 100644 --- a/crates/compass-output/src/agent_query.rs +++ b/crates/compass-output/src/agent_query.rs @@ -1675,6 +1675,31 @@ fn text_page_entries( ), ) })); + if operation == AgentOperation::Impact + && let Some(summary) = &response.impact_summary + { + // Group the full retained ledger, so pagination does not silently cap + // direct dependents to the primary-view limit. + entries.clear(); + for group in &summary.groups { + let header = format!( + "module={} file={}", + escape_scalar(&group.module), + escape_scalar(group.file.as_deref().unwrap_or("")) + ); + for (layer, ids) in [("direct", &group.direct), ("transitive", &group.transitive)] { + for id in ids { + if let Some(node) = nodes.get(id) { + let entity = agent_entity(node); + entries.push(( + TextPageSection::PrimaryResults, + format!("{header} {layer}: {}", render_entity(&entity, false)), + )); + } + } + } + } + } let mut paths = response .paths .iter() @@ -2170,7 +2195,7 @@ fn primary_node_ids( ordered_edges .iter() .map(|(_, edge)| *edge) - .filter(|edge| edge.source == *source) + .filter(|edge| edge.source == *source || response.call_summary.is_some()) .map(|edge| edge.target.clone()), ); } else { @@ -2180,6 +2205,10 @@ fn primary_node_ids( } AgentOperation::Impact => { ordered.extend(requested); + if let Some(summary) = &response.impact_summary { + ordered.extend(summary.direct_dependents.iter().cloned()); + ordered.extend(summary.transitive_dependents.iter().cloned()); + } // A reverse walk reaches far more owner-level dependents than // direct ones, and the raw trail ledger is ordered by identity. // Order the impacted nodes by how close and how direct their @@ -2760,6 +2789,8 @@ fn answer_for_code( }; } if match_state == AgentMatch::Ambiguous + && response.call_summary.is_none() + && response.impact_summary.is_none() && let Some(selected) = response.diagnostics.iter().find_map(|diagnostic| { (diagnostic.code == QueryDiagnosticCode::AmbiguousMatch) .then_some(diagnostic.node_id.as_ref()) @@ -2783,7 +2814,7 @@ fn answer_for_code( .first() .map(|entity| entity.label.clone()) .unwrap_or_else(|| requested.clone()); - let headline = match context.operation { + let mut headline = match context.operation { AgentOperation::Search => match result_state { AgentResultState::Candidates if match_state == AgentMatch::Unknown && response.results.is_empty() => @@ -2811,6 +2842,33 @@ fn answer_for_code( response.edges.len(), "incoming usage relationship(s)", ), + AgentOperation::Callees + if result_state == AgentResultState::Answered && response.call_summary.is_some() => + { + let summary = response.call_summary.as_ref(); + match summary { + Some(summary) => match summary.unresolved_calls { + Some(unresolved) => format!( + "{} resolved calls, {unresolved} unresolved calls found for {subject}.{}", + summary.resolved_calls, + if summary.inventory_complete { + "" + } else { + " Counts are bounded; source-call coverage is incomplete." + } + ), + None if summary.observed_calls.is_some() => format!( + "{} retained resolved calls for {subject}; the unresolved count is unavailable at this query/inventory bound. Increase --max-nodes/--max-edges or rebuild to inspect source-call coverage.", + summary.resolved_calls + ), + None => format!( + "{} resolved calls for {subject}; source-call coverage is unavailable. Rebuild the graph to inventory unresolved calls.", + summary.resolved_calls + ), + }, + None => String::new(), + } + } AgentOperation::Callees => relationship_headline( result_state, &requested, @@ -2819,7 +2877,23 @@ fn answer_for_code( "direct callee relationship(s)", ), AgentOperation::Impact => { - if result_state == AgentResultState::Answered { + if result_state == AgentResultState::Answered + && let Some(summary) = &response.impact_summary + { + format!( + "{} direct and {} transitive dependents. {} direct connections audited: {} outgoing context, {} excluded by direction/type/confidence. Direct coverage {}.", + summary.direct_dependents.len(), + summary.transitive_dependents.len(), + summary.observed_direct_connections, + summary.outgoing_context.len(), + summary.excluded_direct_connections, + if summary.direct_coverage_complete { + "complete" + } else { + "bounded or incomplete" + } + ) + } else if result_state == AgentResultState::Answered { format!( "Found {} potentially affected node(s) within depth {}.", primary_results.len(), @@ -2865,6 +2939,19 @@ fn answer_for_code( } AgentOperation::Discovery => "Discovery returned candidate anchors.".to_owned(), }; + if match_state == AgentMatch::Ambiguous + && let Some(selected) = response.diagnostics.iter().find_map(|diagnostic| { + (diagnostic.code == QueryDiagnosticCode::AmbiguousMatch) + .then_some(diagnostic.node_id.as_ref()) + .flatten() + .and_then(|id| response.nodes.iter().find(|node| &node.id == id)) + }) + { + headline = format!( + "Auto-picked {} from multiple candidates. {headline}", + display_label(selected) + ); + } let mut basis = Vec::new(); if let Some(entity) = primary_results.first() { basis.push(AgentBasis { diff --git a/crates/compass-output/tests/agent_query.rs b/crates/compass-output/tests/agent_query.rs index 45e8bc7ce..058429757 100644 --- a/crates/compass-output/tests/agent_query.rs +++ b/crates/compass-output/tests/agent_query.rs @@ -1061,3 +1061,62 @@ fn search_exact_match_display_uses_typed_name_normalization() -> Result<(), Box< assert_eq!(view.status.result_state, AgentResultState::Answered); Ok(()) } + +#[test] +fn unresolved_call_inventory_never_becomes_a_zero_call_claim() -> Result<(), Box> { + use compass_model::query_contract::CallSummary; + let mut response = response(CodeQueryOperation::Callees); + response + .nodes + .push(node("n:service", "Service", &anchor("service.py", 1))); + response.call_summary = Some(CallSummary { + resolved_calls: 0, + unresolved_calls: Some(3), + observed_calls: Some(3), + inventory_complete: true, + }); + let view = build_code_query_view( + &response, + context(AgentOperation::Callees) + .with_operand(compass_output::AgentOperandRole::Symbol, "n:service"), + )?; + assert!( + view.answer + .headline + .contains("0 resolved calls, 3 unresolved calls found"), + "{}", + view.answer.headline + ); + response + .call_summary + .as_mut() + .ok_or("summary")? + .unresolved_calls = None; + response + .call_summary + .as_mut() + .ok_or("summary")? + .inventory_complete = false; + let view = build_code_query_view( + &response, + context(AgentOperation::Callees) + .with_operand(compass_output::AgentOperandRole::Symbol, "n:service"), + )?; + assert!( + view.answer + .headline + .contains("unresolved count is unavailable") + ); + response + .call_summary + .as_mut() + .ok_or("summary")? + .observed_calls = None; + let view = build_code_query_view( + &response, + context(AgentOperation::Callees) + .with_operand(compass_output::AgentOperandRole::Symbol, "n:service"), + )?; + assert!(view.answer.headline.contains("coverage is unavailable")); + Ok(()) +} diff --git a/crates/compass-query/Cargo.toml b/crates/compass-query/Cargo.toml index 3f7ed6788..a5efedb93 100644 --- a/crates/compass-query/Cargo.toml +++ b/crates/compass-query/Cargo.toml @@ -32,6 +32,8 @@ 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.0" } compass-graphdb = { path = "../compass-graphdb", version = "0.4.0" } compass-languages = { path = "../compass-languages", version = "0.4.0" } compass-store-redb = { path = "../compass-store-redb", version = "0.4.0" } diff --git a/crates/compass-query/examples/query_batch.rs b/crates/compass-query/examples/query_batch.rs new file mode 100644 index 000000000..c75c43982 --- /dev/null +++ b/crates/compass-query/examples/query_batch.rs @@ -0,0 +1,66 @@ +//! Bounded offline qualification helper: reuse one native engine for a batch. +use compass_model::query_contract::{CallRequest, CodeQueryLimits, ImpactRequest}; +use serde::Deserialize; +use std::error::Error; +use std::path::PathBuf; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Request { + operation: String, + symbol: String, +} + +fn main() -> Result<(), Box> { + let mut arguments = std::env::args_os().skip(1); + let graph = PathBuf::from(arguments.next().ok_or("graph path required")?); + let requests_path = PathBuf::from(arguments.next().ok_or("requests path required")?); + let output = PathBuf::from(arguments.next().ok_or("output path required")?); + if arguments.next().is_some() { + return Err("expected graph, requests, output paths".into()); + } + let requests: Vec = serde_json::from_slice(&compass_files::read_bytes_bounded( + &requests_path, + 1024 * 1024, + )?)?; + if requests.is_empty() || requests.len() > 32 { + return Err("request count must be 1 to 32".into()); + } + let cache = output.with_extension("query-cache"); + let (document, identity) = + compass_model::code_graph::GraphDocument::load_with_artifact_digest(&graph)?; + let engine = + compass_query::open_with_verified_document(document, identity, &graph, None, &cache)?; + let mut responses = Vec::new(); + let mut bytes = 0_usize; + for request in requests { + let limits = CodeQueryLimits { + max_nodes: 2000, + max_edges: 10000, + max_depth: if request.operation == "callees" { 8 } else { 1 }, + max_response_bytes: 32 * 1024 * 1024, + ..CodeQueryLimits::default() + }; + let response = match request.operation.as_str() { + "callees" => engine.callees(CallRequest { + symbol: request.symbol, + include_heuristic: false, + limits, + })?, + "impact" => engine.impact(ImpactRequest { + symbol: request.symbol, + include_heuristic: false, + limits, + })?, + _ => return Err("only callees and impact are supported".into()), + }; + bytes = bytes.saturating_add(serde_json::to_vec(&response)?.len()); + if bytes > 64 * 1024 * 1024 { + return Err("batch output exceeds 64 MiB".into()); + } + responses.push(response); + } + // A qualification report is written only after every native query succeeds. + compass_files::write_bytes_atomic(&output, &serde_json::to_vec(&responses)?)?; + Ok(()) +} diff --git a/crates/compass-query/src/code_query.rs b/crates/compass-query/src/code_query.rs index 1c6a1fcbb..5eda30fa7 100644 --- a/crates/compass-query/src/code_query.rs +++ b/crates/compass-query/src/code_query.rs @@ -1,3 +1,5 @@ +mod impact_summary; + use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; @@ -14,10 +16,10 @@ use compass_model::code_graph::{ }; use compass_model::provenance::{EvidenceConfidence, ResolutionState}; use compass_model::query_contract::{ - CallRequest, CodeQueryOperation, CodeQueryResponse, DiscoveryScopeKind, ExploreRequest, - ImpactRequest, NodeTrailRequest, QueryDiagnostic, QueryDiagnosticCode, QueryEdge, - QueryEvidence, QueryEvidenceLayer, QueryFile, QueryNode, QueryPath, SearchHit, SearchRequest, - discovery_scope_postings, + CallRequest, CodeQueryLimits, CodeQueryOperation, CodeQueryResponse, DiscoveryScopeKind, + ExploreRequest, ImpactRequest, NodeTrailRequest, QueryDiagnostic, QueryDiagnosticCode, + QueryEdge, QueryEvidence, QueryEvidenceLayer, QueryFile, QueryNode, QueryPath, SearchHit, + SearchRequest, discovery_scope_postings, }; use compass_store::SqliteStore; use rusqlite::{Connection, OptionalExtension, params}; @@ -319,6 +321,18 @@ const IMPACT_KINDS: &[EdgeKind] = &[ EdgeKind::Triggers, EdgeKind::MapsTo, EdgeKind::Renders, + EdgeKind::Extends, + EdgeKind::Implements, + EdgeKind::MixesIn, + EdgeKind::TypeOf, + EdgeKind::Returns, + EdgeKind::Instantiates, + EdgeKind::Overrides, + EdgeKind::Decorates, + EdgeKind::Embeds, + EdgeKind::Registers, + EdgeKind::Handles, + EdgeKind::Tests, ]; /// Optional conjunctive filters for exact symbol lookup. Paths are compared to @@ -2868,12 +2882,8 @@ impl CodeQueryEngine { return response; }; let execution_started = Instant::now(); - let kinds: &[EdgeKind] = if inbound { - CALLER_KINDS - } else { - &[EdgeKind::Calls] - }; let max_edges = usize::try_from(request.limits.max_edges).unwrap_or(usize::MAX); + let mut call_owners = BTreeSet::from([seed.clone()]); let (selected_edges, truncated, observed_importers, observed_importers_truncated) = if inbound { self.resolved_incoming_relationships( @@ -2883,13 +2893,12 @@ impl CodeQueryEngine { max_edges, )? } else { - let (edges, truncated) = self.backend.matching_bounded( + let (edges, truncated, owners) = self.owned_callee_relationships( &seed, - inbound, - kinds, request.include_heuristic, - max_edges, + &request.limits, )?; + call_owners = owners; (edges, truncated, 0, false) }; instrumentation.work.nodes_expanded = instrumentation.work.nodes_expanded.saturating_add(1); @@ -2914,16 +2923,158 @@ impl CodeQueryEngine { Self::owner_level_relationship_diagnostic(&mut response, &seed); } } - let mut ids = HashSet::from([seed.clone()]); + let mut ids = call_owners.iter().cloned().collect::>(); for edge in &selected_edges { ids.insert(edge.source.clone()); ids.insert(edge.target.clone()); response.edges.push(query_edge(edge)); } self.add_nodes(&ids, &mut response)?; - let response = self.finish_response(&mut response); + let mut response = self.finish_response(&mut response)?; + let has_call_inventory = response.nodes.iter().any(|node| { + call_owners.contains(&node.id) + && matches!(&node.details, + Some(compass_model::code_graph::NodeDetails::Symbol(details)) + if details.call_sites.is_some()) + }); + let python_seed = response + .nodes + .iter() + .any(|node| node.id == seed && node.language.as_deref() == Some("python")); + if !inbound && (python_seed || has_call_inventory) { + Self::summarize_calls(&mut response, &call_owners); + if self.partial_graph_message.is_some() + && let Some(summary) = &mut response.call_summary + { + summary.inventory_complete = false; + } + enforce_response_size(&mut response)?; + } instrumentation.execution += execution_started.elapsed(); - response + Ok(response) + } + + /// A class's executable calls belong to its contained methods. Retain the + /// actual method endpoint instead of inventing class-to-callee edges. + fn owned_callee_relationships( + &self, + seed: &str, + include_heuristic: bool, + limits: &CodeQueryLimits, + ) -> Result<(Vec, bool, BTreeSet), QueryError> { + let max_edges = usize::try_from(limits.max_edges).unwrap_or(usize::MAX); + let max_nodes = usize::try_from(limits.max_nodes).unwrap_or(usize::MAX); + let aggregate = self.backend.node_by_id(seed)?.is_some_and(|node| { + matches!( + node.kind, + NodeKind::Class | NodeKind::Struct | NodeKind::Interface | NodeKind::Trait + ) || (node.language.as_deref() == Some("python") && node.kind.is_callable()) + }); + let mut pending = VecDeque::from([(seed.to_owned(), 0_u32)]); + let mut owners = BTreeSet::new(); + let mut selected = BTreeMap::new(); + let mut truncated = false; + while let Some((owner, depth)) = pending.pop_front() { + self.check_deadline()?; + if !owners.insert(owner.clone()) { + continue; + } + let (calls, limited) = self.backend.matching_bounded( + &owner, + false, + &[EdgeKind::Calls, EdgeKind::Instantiates], + include_heuristic, + max_edges.saturating_sub(selected.len()), + )?; + truncated |= limited; + for edge in calls { + selected.insert(edge.id.clone(), edge); + } + if !aggregate { + continue; + } + let (members, limited) = self.backend.matching_bounded( + &owner, + false, + &[EdgeKind::Contains], + false, + max_nodes, + )?; + truncated |= limited; + for member in members { + let Some(node) = self.backend.node_by_id(&member.target)? else { + continue; + }; + // Initializers belong to their enclosing callable. A nested + // function's body does not execute merely because its owner does. + let owner_is_callable = self + .backend + .node_by_id(&owner)? + .is_some_and(|owner| owner.kind.is_callable()); + let is_initializer = matches!( + node.kind, + NodeKind::Field | NodeKind::Variable | NodeKind::Constant | NodeKind::Parameter + ); + let is_executable_member = matches!( + node.kind, + NodeKind::Method | NodeKind::Function | NodeKind::Constructor | NodeKind::Class + ); + if !is_initializer && (owner_is_callable || !is_executable_member) { + continue; + } + if depth >= limits.max_depth + || owners.len().saturating_add(pending.len()) >= max_nodes + { + truncated = true; + continue; + } + pending.push_back((node.id, depth + 1)); + } + } + Ok((selected.into_values().collect(), truncated, owners)) + } + + fn summarize_calls(response: &mut CodeQueryResponse, owners: &BTreeSet) { + use compass_model::code_graph::NodeDetails; + use compass_model::query_contract::CallSummary; + let mut observed = BTreeSet::new(); + let mut missing = owners + .iter() + .any(|id| !response.nodes.iter().any(|node| &node.id == id)); + let mut complete = !response.truncated; + for node in &response.nodes { + if !owners.contains(&node.id) { + continue; + } + if let Some(NodeDetails::Symbol(details)) = &node.details + && let Some(inventory) = &details.call_sites + { + complete &= !inventory.truncated; + observed.extend(inventory.sites.iter().cloned()); + } else { + missing = true; + } + } + let resolved = response + .edges + .iter() + .filter(|edge| { + matches!(edge.kind, EdgeKind::Calls | EdgeKind::Instantiates) + && !edge.evidence.is_empty() + && edge.evidence.iter().all(|evidence| { + evidence.resolution == ResolutionState::Exact + && evidence.confidence == EvidenceConfidence::Exact + }) + }) + .filter_map(|edge| edge.relationship_site.clone()) + .collect::>(); + response.call_summary = Some(CallSummary { + resolved_calls: u64::try_from(resolved.len()).unwrap_or(u64::MAX), + unresolved_calls: (complete && !missing) + .then(|| u64::try_from(observed.difference(&resolved).count()).unwrap_or(u64::MAX)), + observed_calls: (!missing).then(|| u64::try_from(observed.len()).unwrap_or(u64::MAX)), + inventory_complete: complete && !missing, + }); } pub fn impact(&self, request: ImpactRequest) -> Result { @@ -2931,6 +3082,26 @@ impl CodeQueryEngine { self.impact_instrumented(request, &mut QueryInstrumentation::default()) } + /// Restrict dependent traversal to an explicit relationship family. Direct + /// coverage is checked against that same family, never an unrelated count. + pub fn impact_with_relations( + &self, + request: ImpactRequest, + relations: &[EdgeKind], + ) -> Result { + if relations.is_empty() || relations.len() > ALL_EDGE_KINDS.len() { + return Err(QueryError::new( + QueryErrorKind::InvalidParameter, + "invalid_impact_relations", + "provide one or more known impact relationship types", + )); + } + let mut kinds = relations.to_vec(); + kinds.sort_by_key(|kind| kind.as_str()); + kinds.dedup(); + self.impact_instrumented_with_kinds(request, &kinds, &mut QueryInstrumentation::default()) + } + /// Resolve inbound dependency relationships for the compatibility /// `affected` command. It deliberately shares the same relationship /// postings and consistency probe as `impact`, while allowing the legacy @@ -3119,9 +3290,15 @@ impl CodeQueryEngine { .edges .extend(selected_edges.values().map(query_edge)); self.apply_path_bound(&mut response); - let response = self.finish_response(&mut response); + let mut response = self.finish_response(&mut response)?; + self.summarize_impact( + &mut response, + &seed, + relationship_kinds, + request.include_heuristic, + )?; instrumentation.execution += execution_started.elapsed(); - response + Ok(response) } pub fn explore(&self, request: ExploreRequest) -> Result { diff --git a/crates/compass-query/src/code_query/impact_summary.rs b/crates/compass-query/src/code_query/impact_summary.rs new file mode 100644 index 000000000..ec59afa00 --- /dev/null +++ b/crates/compass-query/src/code_query/impact_summary.rs @@ -0,0 +1,127 @@ +//! Independent direct-adjacency audit and deterministic impact grouping. +use super::*; +use compass_model::query_contract::{ImpactFileGroup, ImpactSummary}; + +impl CodeQueryEngine { + pub(super) fn summarize_impact( + &self, + response: &mut CodeQueryResponse, + seed: &str, + relations: &[EdgeKind], + include_heuristic: bool, + ) -> Result<(), QueryError> { + let limit = usize::try_from(response.limits.max_edges).unwrap_or(usize::MAX); + let (incident, limited) = self.backend.incident_bounded(seed, true, limit)?; + let mut contacts = BTreeSet::new(); + let mut selected = BTreeSet::new(); + let mut outgoing = BTreeSet::new(); + let mut excluded = BTreeSet::new(); + for edge in incident { + self.check_deadline()?; + if edge.target == seed { + let contact = (false, edge.source.clone()); + contacts.insert(contact.clone()); + if relations.contains(&edge.kind) && (include_heuristic || !is_heuristic(&edge)) { + selected.insert(edge.source.clone()); + } else { + excluded.insert(contact); + } + } + if edge.source == seed { + contacts.insert((true, edge.target.clone())); + outgoing.insert(edge.target); + } + } + // A neighbor with several relation kinds is covered if any selected + // incoming relation reaches it. Do not count parallel edges as nodes. + excluded.retain(|(outbound, id)| *outbound || !selected.contains(id)); + // A self-reference is a recorded contact, but the seed is not its + // own affected dependent. Account for it explicitly as an exclusion. + if selected.remove(seed) { + excluded.insert((false, seed.to_owned())); + } + let returned = response + .nodes + .iter() + .map(|node| node.id.as_str()) + .collect::>(); + let missing = selected + .iter() + .filter(|id| !returned.contains(id.as_str())) + .count(); + if missing > 0 && !response.truncated && !limited { + return Err(QueryError::new( + QueryErrorKind::GraphInvariant, + "impact_direct_coverage_mismatch", + format!( + "impact omitted {missing} selected direct dependents; retry compass impact {seed} --format json and report impact_direct_coverage_mismatch" + ), + )); + } + let direct = selected + .into_iter() + .filter(|id| id != seed && returned.contains(id.as_str())) + .collect::>(); + let affected = response + .edges + .iter() + .filter(|edge| relations.contains(&edge.kind)) + .map(|edge| edge.source.clone()) + .filter(|id| id != seed && returned.contains(id.as_str())) + .collect::>(); + let transitive = affected + .difference(&direct) + .cloned() + .collect::>(); + let mut groups = BTreeMap::<(Option, String), ImpactFileGroup>::new(); + for node in &response.nodes { + if !direct.contains(&node.id) && !transitive.contains(&node.id) { + continue; + } + let file = node.source.as_ref().map(|source| source.file.clone()); + let module = file + .as_deref() + .and_then(|file| file.rsplit_once('/').map(|(parent, _)| parent)) + .unwrap_or("") + .to_owned(); + let group = groups + .entry((file.clone(), module.clone())) + .or_insert_with(|| ImpactFileGroup { + file, + module, + direct: Vec::new(), + transitive: Vec::new(), + }); + if direct.contains(&node.id) { + group.direct.push(node.id.clone()); + } else { + group.transitive.push(node.id.clone()); + } + } + let covered = direct.len() + outgoing.len() + excluded.len(); + if missing == 0 && covered != contacts.len() { + return Err(QueryError::new( + QueryErrorKind::GraphInvariant, + "impact_connection_accounting_mismatch", + "impact direct-connection accounting is inconsistent", + )); + } + let mut relations = relations.to_vec(); + relations.sort_by_key(|kind| kind.as_str()); + relations.dedup(); + response.impact_summary = Some(ImpactSummary { + relations, + observed_direct_connections: u64::try_from(contacts.len()).unwrap_or(u64::MAX), + direct_dependents: direct.into_iter().collect(), + transitive_dependents: transitive.into_iter().collect(), + outgoing_context: outgoing.into_iter().collect(), + excluded_direct_connections: u64::try_from(excluded.len()).unwrap_or(u64::MAX), + // This audits published adjacency. Repository-wide omissions stay + // in IncompleteCoverage diagnostics and never become a completeness + // claim about the source tree. + direct_coverage_complete: !limited && missing == 0, + groups: groups.into_values().collect(), + }); + enforce_response_size(response) + } +} diff --git a/crates/compass-query/tests/code_impact.rs b/crates/compass-query/tests/code_impact.rs index 2b0c5633d..2c189c577 100644 --- a/crates/compass-query/tests/code_impact.rs +++ b/crates/compass-query/tests/code_impact.rs @@ -526,3 +526,135 @@ fn partial_owner_coverage_does_not_stop_the_witnessed_direct_frontier() ); Ok(()) } +#[test] +fn impact_includes_inheritance_construction_and_type_contracts() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("graph.json"); + support::write_graph(&path)?; + let mut graph = GraphDocument::load(&path)?; + for (index, (kind, source_kind, target_kind)) in [ + (EdgeKind::Extends, NodeKind::Class, NodeKind::Class), + (EdgeKind::Implements, NodeKind::Class, NodeKind::Trait), + (EdgeKind::MixesIn, NodeKind::Class, NodeKind::Trait), + (EdgeKind::Instantiates, NodeKind::Function, NodeKind::Class), + (EdgeKind::TypeOf, NodeKind::Parameter, NodeKind::Class), + (EdgeKind::Returns, NodeKind::Function, NodeKind::Class), + (EdgeKind::Overrides, NodeKind::Method, NodeKind::Method), + (EdgeKind::Decorates, NodeKind::Annotation, NodeKind::Method), + ] + .into_iter() + .enumerate() + { + let id = format!("n:dependent-type-{index}"); + let target = format!("n:target-type-{index}"); + graph.nodes.push(support::node( + &id, + source_kind, + &format!("Dependent{index}"), + &format!("Dependent{index}"), + )); + graph.nodes.push(support::node( + &target, + target_kind, + &format!("Target{index}"), + &format!("Target{index}"), + )); + graph.links.push(edge(&id, kind, &target)); + } + fs::write(&path, serde_json::to_vec(&graph)?)?; + let engine = open(&path, None, &directory.path().join("cache"))?; + for index in 0..8 { + let response = engine.impact(ImpactRequest { + symbol: format!("n:target-type-{index}"), + include_heuristic: false, + limits: CodeQueryLimits::default(), + })?; + assert!( + response + .nodes + .iter() + .any(|node| node.id == format!("n:dependent-type-{index}")), + "missing dependent {index}: {response:#?}" + ); + } + Ok(()) +} + +#[test] +fn impact_audits_direct_contacts_and_groups_layers_even_when_paths_are_capped() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + let graph_path = directory.path().join("graph.json"); + support::write_graph(&graph_path)?; + let engine = open(&graph_path, None, &directory.path().join("cache"))?; + let request = ImpactRequest { + symbol: "n:callee".to_owned(), + include_heuristic: false, + limits: CodeQueryLimits { + max_paths: 1, + ..CodeQueryLimits::default() + }, + }; + let result = engine.impact(request.clone())?; + let summary = result + .impact_summary + .as_ref() + .ok_or("missing impact summary")?; + assert!(summary.direct_coverage_complete); + assert!(summary.direct_dependents.iter().any(|id| id == "n:list")); + assert_eq!( + summary + .groups + .iter() + .map(|group| group.direct.len()) + .sum::(), + summary.direct_dependents.len() + ); + assert_eq!( + summary + .groups + .iter() + .map(|group| group.transitive.len()) + .sum::(), + summary.transitive_dependents.len() + ); + let filtered = engine.impact_with_relations(request, &[EdgeKind::Imports])?; + let filtered_summary = filtered.impact_summary.ok_or("missing filtered summary")?; + assert!(filtered_summary.direct_dependents.is_empty()); + assert!(filtered_summary.excluded_direct_connections > 0); + assert_eq!( + summary.observed_direct_connections, + filtered_summary.observed_direct_connections + ); + Ok(()) +} + +#[test] +fn impact_accounts_for_self_references_without_counting_the_seed_as_affected() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("graph.json"); + support::write_graph(&path)?; + let mut graph = GraphDocument::load(&path)?; + graph + .links + .push(edge("n:callee", EdgeKind::Calls, "n:callee")); + fs::write(&path, serde_json::to_vec(&graph)?)?; + let engine = open(&path, None, &directory.path().join("cache"))?; + let result = engine.impact(ImpactRequest { + symbol: "n:callee".to_owned(), + include_heuristic: false, + limits: CodeQueryLimits::default(), + })?; + let summary = result.impact_summary.ok_or("impact summary")?; + assert!(summary.direct_coverage_complete); + assert!(!summary.direct_dependents.iter().any(|id| id == "n:callee")); + assert_eq!( + summary.observed_direct_connections as usize, + summary.direct_dependents.len() + + summary.outgoing_context.len() + + summary.excluded_direct_connections as usize + ); + Ok(()) +} diff --git a/crates/compass-query/tests/code_traversal.rs b/crates/compass-query/tests/code_traversal.rs index 7797db909..82866dd8a 100644 --- a/crates/compass-query/tests/code_traversal.rs +++ b/crates/compass-query/tests/code_traversal.rs @@ -55,6 +55,10 @@ fn callers_include_calls_and_route_bindings_while_callees_follow_calls() assert!(enriched.nodes.iter().any(|node| node.id == "n:heuristic")); let callees = engine.callees(request)?; + assert!( + callees.call_summary.is_none(), + "producers without call inventories retain relationship counts" + ); assert!(callees.nodes.iter().any(|node| node.id == "n:callee")); assert!( callees @@ -1044,3 +1048,95 @@ fn calls_only_retains_heuristic_opt_in_and_exact_name_ambiguity() ); Ok(()) } +#[test] +fn class_callees_include_calls_owned_by_its_methods() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("graph.json"); + support::write_graph(&path)?; + let mut graph = GraphDocument::load(&path)?; + graph.nodes.push(support::node( + "n:service", + compass_model::code_graph::NodeKind::Class, + "UserService", + "UserService", + )); + let mut contains = graph.links.first().cloned().ok_or("edge template")?; + contains.source = "n:service".to_owned(); + contains.target = "n:list".to_owned(); + contains.kind = EdgeKind::Contains; + contains.id = edge_id( + &contains.source, + contains.kind, + &contains.target, + contains.relationship_site.as_ref(), + None, + ); + contains.key.clone_from(&contains.id); + graph.links.push(contains); + fs::write(&path, serde_json::to_vec(&graph)?)?; + let engine = open(&path, None, &directory.path().join("cache"))?; + let answer = engine.callees(CallRequest { + symbol: "n:service".to_owned(), + include_heuristic: false, + limits: CodeQueryLimits::default(), + })?; + assert!( + answer.edges.iter().any(|edge| edge.kind == EdgeKind::Calls + && edge.source == "n:list" + && edge.target == "n:callee"), + "class must not claim zero outgoing calls: {answer:#?}" + ); + Ok(()) +} + +#[test] +fn python_callable_includes_initializer_calls_but_not_nested_callable_bodies() +-> Result<(), Box> { + use compass_model::code_graph::NodeKind; + let directory = tempfile::tempdir()?; + let path = directory.path().join("graph.json"); + support::write_graph(&path)?; + let mut graph = GraphDocument::load(&path)?; + let owner = graph + .nodes + .iter_mut() + .find(|node| node.id == "n:list") + .ok_or("owner")?; + owner.language = Some("python".to_owned()); + let template = graph.links.first().cloned().ok_or("edge template")?; + for (id, kind) in [ + ("n:initializer", NodeKind::Variable), + ("n:nested", NodeKind::Function), + ] { + let mut node = support::node(id, kind, id, id); + node.language = Some("python".to_owned()); + graph.nodes.push(node); + for (source, target, kind) in [ + ("n:list", id, EdgeKind::Contains), + (id, "n:callee", EdgeKind::Calls), + ] { + let mut edge = template.clone(); + edge.source = source.to_owned(); + edge.target = target.to_owned(); + edge.kind = kind; + edge.id = edge_id(source, kind, target, edge.relationship_site.as_ref(), None); + edge.key.clone_from(&edge.id); + graph.links.push(edge); + } + } + fs::write(&path, serde_json::to_vec(&graph)?)?; + let engine = open(&path, None, &directory.path().join("cache"))?; + let answer = engine.callees(CallRequest { + symbol: "n:list".to_owned(), + include_heuristic: false, + limits: CodeQueryLimits::default(), + })?; + assert!( + answer + .edges + .iter() + .any(|edge| edge.source == "n:initializer") + ); + assert!(!answer.edges.iter().any(|edge| edge.source == "n:nested")); + Ok(()) +} diff --git a/crates/compass-resolve/src/evidence/facts.rs b/crates/compass-resolve/src/evidence/facts.rs index 75fcc0212..961bbcac1 100644 --- a/crates/compass-resolve/src/evidence/facts.rs +++ b/crates/compass-resolve/src/evidence/facts.rs @@ -69,6 +69,7 @@ type StringSlot = u32; struct CompactOccurrence { id: StringSlot, role: compass_languages::SemanticRole, + owner: StringSlot, spelling: StringSlot, qualifier: Option, context: Option, @@ -87,6 +88,12 @@ pub(in crate::evidence) struct OccurrenceTable { } impl OccurrenceTable { + pub(in crate::evidence) fn call_sites(&self) -> impl Iterator { + self.values + .iter() + .filter(|occurrence| occurrence.role == compass_languages::SemanticRole::Call) + .map(|occurrence| (self.string(occurrence.owner), &occurrence.range)) + } pub(in crate::evidence) fn from_values(values: Vec) -> Result { let mut strings = StringPoolBuilder::default(); let mut compact = Vec::with_capacity(values.len()); @@ -138,6 +145,7 @@ impl CompactOccurrence { Ok(Self { id: strings.intern(occurrence.id)?, role: occurrence.role, + owner: strings.intern(occurrence.owner_declaration_id)?, spelling: strings.intern(occurrence.spelling)?, qualifier: strings.intern_option(occurrence.qualifier)?, context: strings.intern_option(occurrence.context)?, diff --git a/crates/compass-resolve/src/evidence/projection/mod.rs b/crates/compass-resolve/src/evidence/projection/mod.rs index 2dcb20374..c7aee3673 100644 --- a/crates/compass-resolve/src/evidence/projection/mod.rs +++ b/crates/compass-resolve/src/evidence/projection/mod.rs @@ -127,6 +127,7 @@ impl UniversalResolutionIndex { } } profile_internal("universal declaration projection", &mut profile_started); + self.materialize_python_call_sites(nodes, &graph_ids); let inventory_kinds = nodes .iter() .map(|node| (node.id.clone(), node.string("symbol_kind"))) @@ -151,7 +152,32 @@ impl UniversalResolutionIndex { .filter_map(|candidate_slot| { let candidate = self.facts.candidates.at(candidate_slot)?; let candidate_id = candidate.id.as_str(); - let decision = db.resolve_candidate(&candidate, admission); + let mut decision = db.resolve_candidate(&candidate, admission); + if admission.admits_deferred_receiver() + && decision == ResolutionDecision::Unresolved + && candidate.language == "python" + && candidate.relation == CandidateRelation::Calls + && let Some(qualifier) = + db.occurrence(&candidate).and_then(OccurrenceRef::qualifier) + && let Some(owner) = self + .facts + .declarations + .get(&candidate.source_declaration_id) + { + // This endpoint names an observed invocation, not a guessed + // repository method. Scope unknown receivers to their + // source owner so unrelated dynamic calls cannot collapse. + decision = ResolutionDecision::DeferredReceiver { + qualified_name: format!( + "{}::<{}>.{}", + owner.qualified_name, qualifier, candidate.target_spelling + ), + evidence: ResolutionEvidence { + rule: ResolutionRule::DeferredReceiver, + candidate_count: 0, + }, + }; + } let exact_declaration_id = match &decision { ResolutionDecision::Resolved { declaration_id, .. } => { Some(declaration_id.clone()) diff --git a/crates/compass-resolve/src/evidence/projection/nodes.rs b/crates/compass-resolve/src/evidence/projection/nodes.rs index 96544f423..ef03497ea 100644 --- a/crates/compass-resolve/src/evidence/projection/nodes.rs +++ b/crates/compass-resolve/src/evidence/projection/nodes.rs @@ -2,6 +2,60 @@ use super::*; +impl UniversalResolutionIndex { + pub(super) fn materialize_python_call_sites( + &self, + nodes: &mut [NodeRecord], + graph_ids: &AHashMap, + ) { + use compass_model::code_graph::{CallSiteInventory, MAX_SYMBOL_CALL_SITES}; + use compass_model::provenance::SourceAnchor; + let mut inventories = BTreeMap::new(); + for declaration in + self.facts.declarations.values().filter(|declaration| { + declaration.language == "python" && declaration.kind != "file" + }) + { + if let Some(id) = graph_ids.get(&declaration.id) { + inventories.insert( + id.clone(), + CallSiteInventory { + sites: Vec::new(), + truncated: false, + }, + ); + } + } + for (owner, range) in self.facts.occurrences.call_sites() { + let Some(inventory) = graph_ids.get(owner).and_then(|id| inventories.get_mut(id)) + else { + continue; + }; + if inventory.sites.len() == MAX_SYMBOL_CALL_SITES { + inventory.truncated = true; + continue; + } + inventory.sites.push(SourceAnchor { + file: range.source_file.clone(), + start_byte: range.start_byte, + end_byte: range.end_byte, + start_line: range.start_line, + end_line: range.end_line, + start_column: range.start_column, + end_column: range.end_column, + }); + } + for node in nodes { + if let Some(mut inventory) = inventories.remove(&node.id) { + inventory.sites.sort(); + inventory.sites.dedup(); + node.attributes + .insert("call_sites".to_owned(), serde_json::json!(inventory)); + } + } + } +} + pub(super) fn declaration_node( declaration: &DeclarationFact, definition_range: Option<&EvidenceRange>, diff --git a/crates/compass-resolve/src/evidence/resolve/bindings.rs b/crates/compass-resolve/src/evidence/resolve/bindings.rs index e10710561..8922ea7cf 100644 --- a/crates/compass-resolve/src/evidence/resolve/bindings.rs +++ b/crates/compass-resolve/src/evidence/resolve/bindings.rs @@ -53,6 +53,17 @@ impl ResolutionDb<'_> { } match self.bound_member_target(language, binding, candidate) { Ok(Some(qualified)) => { + if language == "python" + && candidate.relation == CandidateRelation::Calls + && let Some((receiver, _)) = qualified.rsplit_once("::") + { + return Some(self.resolve_c3_receiver_dispatch( + language, + receiver, + compass_languages::ReceiverDispatchStrategy::C3FromReceiver, + candidate, + )); + } let key = (language.to_owned(), qualified.clone()); if let Some(decision) = self.unique_decision( self.indexes.names.by_qualified.get(&key), @@ -88,7 +99,20 @@ impl ResolutionDb<'_> { { return Some(decision); } - if !candidate.constraints.allow_external { + // A Rust factory's source return contract may establish a + // qualified external receiver even when the original call only + // named a local builder. Keep that proven nominal boundary; + // absent methods on source-local return types stay unresolved. + let external_return_receiver = language == "rust" + && binding.kind == compass_languages::BindingKind::CallResult + && qualified.rsplit_once("::").is_some_and(|(owner, _)| { + !self + .indexes + .names + .by_qualified + .contains_key(&(language.to_owned(), owner.to_owned())) + }); + if !candidate.constraints.allow_external && !external_return_receiver { return Some(ResolutionDecision::Unresolved); } return Some(ResolutionDecision::QualifiedExternal { @@ -115,6 +139,13 @@ impl ResolutionDb<'_> { // of suppressing source-valid fallback evidence. return None; } + Ok(None) + if language == "python" + && qualified_occurrence + && binding.kind == compass_languages::BindingKind::LocalAlias => + { + return Some(ResolutionDecision::Unresolved); + } Ok(None) => {} Err(candidate_count) => { return Some(ResolutionDecision::Ambiguous { candidate_count }); diff --git a/crates/compass-resolve/src/evidence/resolve/members.rs b/crates/compass-resolve/src/evidence/resolve/members.rs index f7feefca4..467604491 100644 --- a/crates/compass-resolve/src/evidence/resolve/members.rs +++ b/crates/compass-resolve/src/evidence/resolve/members.rs @@ -239,7 +239,7 @@ impl ResolutionDb<'_> { let Some(qualifier) = self.occurrence(candidate).and_then(|occurrence| { occurrence .qualifier() - .filter(|qualifier| qualifier.contains('.')) + .filter(|qualifier| language == "python" || qualifier.contains('.')) }) else { return Ok(None); }; @@ -248,6 +248,9 @@ impl ResolutionDb<'_> { return Ok(None); } let mut target = binding.qualified_target.clone(); + if language == "python" && !self.python_unique_nominal_receiver(&target)? { + return Ok(None); + } for member in parts { let Some(targets) = self.indexes.members.members.get(&( language.to_owned(), @@ -260,10 +263,35 @@ impl ResolutionDb<'_> { return Err(targets.len()); }; target.clone_from(next); + if language == "python" && !self.python_unique_nominal_receiver(&target)? { + return Ok(None); + } } Ok(Some(format!("{target}::{}", candidate.target_spelling))) } + fn python_unique_nominal_receiver(&self, qualified_name: &str) -> Result { + let owners = self + .indexes + .names + .by_qualified + .get(&("python".to_owned(), qualified_name.to_owned())); + let owners = owners + .into_iter() + .flatten() + .filter(|slot| { + self.declaration(**slot) + .is_some_and(|declaration| declaration.kind == "class") + }) + .take(2) + .count(); + match owners { + 0 => Ok(false), + 1 => Ok(true), + count => Err(count), + } + } + pub(in crate::evidence) fn call_result_return_type( &self, language: &str, @@ -294,6 +322,11 @@ impl ResolutionDb<'_> { if let Some(return_type) = binding.result_type_qualified_name.as_ref() { return Ok(Some(return_type.clone())); } + if language == "python" && binding.kind == compass_languages::BindingKind::LocalAlias { + return self + .python_unique_nominal_receiver(&binding.qualified_target) + .map(|unique| unique.then(|| binding.qualified_target.clone())); + } let qualified_callable = if let Some(receiver_binding_id) = binding.receiver_binding_id.as_ref() { let Some(receiver_binding) = self.facts.bindings.get(receiver_binding_id) else { @@ -309,11 +342,54 @@ impl ResolutionDb<'_> { else { return Ok(None); }; - format!("{receiver_type}::{}", binding.qualified_target) + if language == "python" { + let mut owner = receiver_type; + let mut members = binding.qualified_target.split('.').peekable(); + while let Some(member) = members.next() { + if members.peek().is_none() { + break; + } + let Some(targets) = self.indexes.members.members.get(&( + language.to_owned(), + owner.clone(), + member.to_owned(), + )) else { + return Ok(None); + }; + let [next] = targets.as_slice() else { + return Err(targets.len()); + }; + owner.clone_from(next); + if !self.python_unique_nominal_receiver(&owner)? { + return Ok(None); + } + } + format!( + "{owner}::{}", + binding + .qualified_target + .rsplit('.') + .next() + .unwrap_or_default() + ) + } else { + format!("{receiver_type}::{}", binding.qualified_target) + } } else { binding.qualified_target.clone() }; + // This lookup identifies the inner factory, not the outer method + // invocation. Its argument shape must not be borrowed from the latter. + let mut factory_candidate; + let candidate = if language == "python" { + factory_candidate = candidate.clone(); + factory_candidate.constraints.argument_count = None; + factory_candidate.constraints.argument_types.clear(); + &factory_candidate + } else { + candidate + }; let mut callable_ids = BTreeSet::new(); callable_ids.extend( self.callable_declarations(language, &qualified_callable) @@ -355,6 +431,9 @@ impl ResolutionDb<'_> { let Some(callable) = self.declaration(*callable_id) else { return Ok(None); }; + if language == "python" && callable.kind == "class" { + return Ok(Some(callable.qualified_name.clone())); + } let key = (language.to_owned(), callable.qualified_name.clone()); let return_candidates = if language == "rust" { self.indexes diff --git a/crates/compass-resolve/src/lib.rs b/crates/compass-resolve/src/lib.rs index 471f9b127..a22096ee7 100644 --- a/crates/compass-resolve/src/lib.rs +++ b/crates/compass-resolve/src/lib.rs @@ -4051,7 +4051,10 @@ fn rewire_unique_family_stubs(extraction: &mut Extraction) { if label.is_empty() { continue; } - if source.is_empty() && !is_canonical_external_symbol(node) { + if source.is_empty() + && !is_canonical_external_symbol(node) + && !is_deferred_receiver_symbol(node) + { stubs.insert(node.id.clone(), label); } else if is_type_like_definition(node) && let Some(family @ "jvm") = language_family(&source) @@ -4687,7 +4690,9 @@ fn rewire_unique_stub_nodes(extraction: &mut Extraction) { .nodes .iter() .filter(|node| { - string_attribute(node, "source_file").is_empty() && !is_canonical_external_symbol(node) + string_attribute(node, "source_file").is_empty() + && !is_canonical_external_symbol(node) + && !is_deferred_receiver_symbol(node) }) .filter_map(|node| { let label = normalized_label(node); @@ -4923,6 +4928,17 @@ fn is_type_like_definition(node: &NodeRecord) -> bool { !label.is_empty() && !label.ends_with(')') && !label.starts_with('.') && !label.contains('.') } +// A deferred receiver records an observed invocation, not a nominal target. +// A unique terminal name is never enough to resolve its runtime receiver. +fn is_deferred_receiver_symbol(node: &NodeRecord) -> bool { + node.string("language") == "python" + && node + .attributes + .get("deferred_receiver") + .and_then(Value::as_bool) + == Some(true) +} + fn is_canonical_external_symbol(node: &NodeRecord) -> bool { node.attributes .get("_canonical_external_symbol") diff --git a/crates/compass-resolve/tests/universal_resolution/python.rs b/crates/compass-resolve/tests/universal_resolution/python.rs index 8b461eaae..715be940b 100644 --- a/crates/compass-resolve/tests/universal_resolution/python.rs +++ b/crates/compass-resolve/tests/universal_resolution/python.rs @@ -119,7 +119,9 @@ fn python_rebound_local_class_receiver_does_not_invent_inherited_dispatch() { ); assert!(resolved.edges.iter().all(|edge| { - edge.string("relation") != "calls" || edge.string("source_location") != "L9" + edge.string("relation") != "calls" + || edge.string("source_location") != "L9" + || edge.string("resolution_rule") == "deferred-receiver" })); } @@ -217,7 +219,9 @@ fn python_super_call_with_multiple_bases_cannot_terminal_match_an_unrelated_meth ); assert!(resolved.edges.iter().all(|edge| { - edge.string("relation") != "calls" || edge.string("source_location") != "L9" + edge.string("relation") != "calls" + || edge.string("source_location") != "L9" + || edge.string("resolution_rule") == "deferred-receiver" })); } @@ -451,7 +455,9 @@ fn python_mixin_receiver_does_not_match_an_unrelated_same_name_member() { ); assert!(!resolved.edges.iter().any(|edge| { - edge.string("relation") == "calls" && edge.string("source_location") == "L3" + edge.string("relation") == "calls" + && edge.string("source_location") == "L3" + && edge.string("resolution_rule") != "deferred-receiver" })); } @@ -468,7 +474,9 @@ fn python_mixin_receiver_rejects_an_inconsistent_descendant_c3() { ); assert!(!resolved.edges.iter().any(|edge| { - edge.string("relation") == "calls" && edge.string("source_location") == "L12" + edge.string("relation") == "calls" + && edge.string("source_location") == "L12" + && edge.string("resolution_rule") != "deferred-receiver" })); } @@ -723,7 +731,13 @@ fn python_shadowed_or_unknown_receivers_never_invent_recursive_calls() { .filter(|edge| { edge.string("relation") == "calls" && declarations.contains(edge.target.as_str()) }) - .map(|edge| edge.string("source_location")) + .map(|edge| { + ( + edge.string("source_location"), + edge.string("resolution_rule"), + edge.target.clone(), + ) + }) .collect::>(); assert!( false_shadow_edges.is_empty(), @@ -755,3 +769,152 @@ fn python_global_directive_preserves_module_recursive_call() { && edge.string("source_location") == "L3" })); } +#[test] +fn python_unit_of_work_attribute_calls_resolve_through_typed_fields() { + let repositories = extract( + "app/repositories.py", + b"class Repository:\n def get(self, key):\n return key\n", + ); + let uow = extract("app/uow.py", b"from app.repositories import Repository\nclass UnitOfWork:\n field_lookup_tables: Repository\n"); + let service = extract("app/services.py", b"from app.uow import UnitOfWork\nclass MutationService:\n def update(self, uow: UnitOfWork):\n uow.field_lookup_tables.get(1)\n uow.field_lookup_tables.get(2)\n"); + let resolved = compass_resolve::resolve(&[repositories, uow, service], &HashMap::new()); + let target = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == "app.repositories.Repository::get") + .expect("repository method"); + let calls = resolved + .edges + .iter() + .filter(|edge| edge.string("relation") == "calls" && edge.target == target.id) + .collect::>(); + assert_eq!( + calls.len(), + 2, + "both source occurrences must resolve: {:#?}", + resolved.edges + ); + assert_eq!(calls[0].string("source_file"), "app/services.py"); + assert_ne!( + calls[0].attributes.get("start_byte"), + calls[1].attributes.get("start_byte") + ); +} + +#[test] +fn python_injected_dependencies_and_constructor_assignments_keep_member_targets() { + let source = b"class Repository:\n def get(self):\n return None\nclass UnitOfWork:\n def __init__(self, repository: Repository):\n self.repository = repository\nclass Service:\n def __init__(self, uow: UnitOfWork):\n self.uow = uow\n def run(self):\n self.uow.repository.get()\nclass ConstructedService:\n def __init__(self):\n self.repository = Repository()\n def run(self):\n self.repository.get()\n"; + let extracted = extract("app/services.py", source); + let resolved = compass_resolve::resolve(&[extracted], &HashMap::new()); + let target = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == "app.services.Repository::get") + .expect("repository method"); + let calls = resolved + .edges + .iter() + .filter(|edge| edge.string("relation") == "calls" && edge.target == target.id) + .collect::>(); + assert_eq!( + calls.len(), + 2, + "injected and constructed receiver calls: {:#?}", + resolved.edges + ); +} + +#[test] +fn python_chained_calls_follow_cross_file_return_contracts() { + let provider = extract("app/repositories.py", b"class Repository:\n def get(self):\n return None\ndef factory() -> Repository:\n return Repository()\n"); + let source = b"from app.repositories import factory, Repository\nclass Service:\n def execute(self):\n factory().get()\n Repository().get()\n"; + let resolved = compass_resolve::resolve( + &[provider, extract("app/services.py", source)], + &HashMap::from([( + "app/services.py".to_owned(), + String::from_utf8(source.to_vec()).expect("source"), + )]), + ); + let target = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == "app.repositories.Repository::get") + .expect("repository method"); + let calls = resolved + .edges + .iter() + .filter(|edge| edge.string("relation") == "calls" && edge.target == target.id) + .collect::>(); + assert_eq!(calls.len(), 2, "{:#?}", resolved.edges); + assert!( + calls + .iter() + .all(|edge| edge.string("confidence") == "EXTRACTED") + ); +} + +#[test] +fn python_receiver_field_reassignments_do_not_reuse_stale_constructor_types() { + let source = b"class Repository:\n def get(self):\n return None\nclass Service:\n def __init__(self, repo: Repository):\n self.repo = repo\n def replace(self, other):\n self.repo = other\n def execute(self):\n return self.repo.get()\n"; + let resolved = compass_resolve::resolve( + &[extract("app/service.py", source)], + &HashMap::from([( + "app/service.py".to_owned(), + String::from_utf8(source.to_vec()).expect("source"), + )]), + ); + let calls = resolved + .edges + .iter() + .filter(|edge| { + edge.string("relation") == "calls" && edge.string("source_location") == "L10" + }) + .collect::>(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].string("resolution_rule"), "deferred-receiver"); + assert_eq!(calls[0].string("confidence"), "INFERRED"); + assert!(resolved.nodes.iter().any(|node| node.id == calls[0].target + && node.attributes.get("deferred_receiver") == Some(&serde_json::Value::Bool(true)))); +} + +#[test] +fn python_exception_construction_and_catch_types_retain_dependencies() { + let source = b"class ValidationError(Exception):\n pass\ndef validate():\n raise ValidationError()\ndef handle():\n try:\n validate()\n except ValidationError:\n return None\n"; + let resolved = + compass_resolve::resolve(&[extract("app/validation.py", source)], &HashMap::new()); + let error = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == "app.validation.ValidationError") + .expect("error class"); + for line in ["L4", "L8"] { + assert!( + resolved.edges.iter().any(|edge| edge.target == error.id + && edge.string("source_location") == line + && matches!(edge.string("relation").as_str(), "calls" | "references")), + "missing exception dependency at {line}" + ); + } +} + +#[test] +fn python_typed_factory_method_chains_validate_and_resolve() { + let source = b"class Repository:\n def get(self):\n return None\nclass UnitOfWork:\n def repository(self) -> Repository:\n return Repository()\nclass Service:\n def factory(self) -> UnitOfWork:\n return UnitOfWork()\n def run(self, uow: UnitOfWork):\n self.factory().repository().get()\n uow.repository().get()\n"; + let resolved = compass_resolve::resolve(&[extract("app/service.py", source)], &HashMap::new()); + let repository = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == "app.service.Repository::get") + .expect("repository"); + let calls = resolved + .edges + .iter() + .filter(|edge| edge.string("relation") == "calls" && edge.target == repository.id) + .collect::>(); + assert_eq!(calls.len(), 2, "{:#?}", resolved.edges); + assert!( + calls + .iter() + .all(|edge| edge.string("confidence") == "EXTRACTED") + ); +} diff --git a/docs/README.md b/docs/README.md index 199bdfb25..c3542c50d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -114,6 +114,7 @@ They are not evidence that an uncompleted design has shipped. | [Architecture graph hardening qualification](implementation/architecture-graph-hardening-qualification.md) | Real-repository metrics, screenshots, acceptance gates, and residual diagnostics | | [Community detection quality technical design](implementation/community-detection-quality-technical-design.md) | Versioned topology, shared quality evidence, native Leiden, bounded selection, and incremental rollout | | [Community detection quality qualification](implementation/community-detection-quality-qualification.md) | Fixture results, performance decision, and remaining corpus evidence | +| [Python call and impact qualification](implementation/python-call-impact-qualification.md) | Pinned Dify source-call capture and direct-connection audits | | [Agent query evaluation](implementation/agent-query-evaluation-2026-09-23.md) | Five-repository Compass/Graphify question suite, graph-anchor results, token cost, and remaining gaps | | [Query recall and accuracy design](implementation/query-recall-accuracy/query-performance-accuracy-recall-phased-technical-design.md) | Phased query-quality architecture, evidence, and rollout boundaries | | [Query implementation plans](plans/README.md) | Ordered, independently executable query-quality work plans | diff --git a/docs/implementation/python-call-impact-qualification.md b/docs/implementation/python-call-impact-qualification.md new file mode 100644 index 000000000..1f180493e --- /dev/null +++ b/docs/implementation/python-call-impact-qualification.md @@ -0,0 +1,124 @@ +# Python calls and impact qualification + +Compass captured **2,550 / 2,550 explicit source calls (100%)** in ten Dify +production service classes and accounted for all published direct connections +of five widely used classes. This measures source-call capture, not runtime +dispatch or exact target resolution. + +Input: [Dify at `1a918ea407990ecd2156af2614932db8c9c43091`](https://github.com/langgenius/dify/tree/1a918ea407990ecd2156af2614932db8c9c43091/api). +The checkout was read-only. Extraction used native code-only mode, maximum +inference, and excluded `tests/**`. The random seed was `20260930`; selection +was from all 175 source service classes with at least five method-body calls. +Missing or ambiguous graph classes fail the gate rather than being replaced. + +## Call capture + +| Service | Source calls | Captured | All-exact edge sites | Inferred edge sites | +| --- | ---: | ---: | ---: | ---: | +| MigrationPackageService | 24 | 24 | 8 | 15 | +| InstalledAppConversationService | 17 | 17 | 16 | 1 | +| ModelLoadBalancingService | 143 | 143 | 32 | 82 | +| OAuthServerService | 30 | 30 | 30 | 0 | +| RagPipelineService | 479 | 479 | 77 | 342 | +| SkillManagementService | 1488 | 1488 | 443 | 846 | +| WorkflowAppLogQueryService | 15 | 15 | 5 | 8 | +| WorkflowCommentService | 169 | 169 | 33 | 121 | +| WorkflowDraftVariableService | 159 | 159 | 28 | 122 | +| WorkspaceProvisioningService | 26 | 26 | 26 | 0 | + +There were 698 source sites with all-exact target evidence and 1,537 with +inferred edge evidence. These are separate measurements; neither is a claim +of 100% resolved-target accuracy. Remaining invocations stay in the source +inventory. Default callee queries omit inferred edges while reporting unresolved +counts; `--include-heuristic` exposes the optional inferred layer. + +The source oracle uses Python AST invocation ranges, including nested call +expressions, while excluding nested named function/class bodies from their +enclosing method. Native class queries aggregate owned methods and initializers, +retain actual method endpoints, and disclose their source-call inventories. +All ten responses retained their query bounds without truncation. + +The source-first sample caught a rejected typed factory receiver in +`ModelLoadBalancingService`. The native evidence validator now accepts +source-backed Python nominal aliases as factory receivers, while rejecting +other binding kinds and non-Python aliases. The rebuilt graph had zero +extractor failures. + +## Direct impact audit + +| Symbol | Known directional contacts | Direct dependents | Outgoing context | Excluded | +| --- | ---: | ---: | ---: | ---: | +| machinery.context.RequestContext | 1389 | 1381 | 6 | 2 | +| models.model.App | 1289 | 1243 | 45 | 1 | +| models.account.Account | 1265 | 1221 | 43 | 1 | +| fields.base.ResponseModel | 663 | 660 | 2 | 1 | +| models.dataset.Dataset | 583 | 556 | 26 | 1 | + +For every symbol, the retained direct dependents, outgoing context and +exclusions account for every known `(direction, neighbor)` contact. Parallel +occurrences remain graph relationships but do not inflate affected-node counts. +Outgoing context and containment are not invented affected dependents. +The audit uses depth one, at most 2,000 nodes and 10,000 edges, and independently +checks published adjacency. A capped path ledger does not cap the summary. + +Five source locations per symbol were inspected, covering imports, parameter +annotations, return contracts, inheritance and query/model references. Examples: + +- RequestContext: [controller import](https://github.com/langgenius/dify/blob/1a918ea407990ecd2156af2614932db8c9c43091/api/controllers/console/flask_admission.py#L27). +- App: [typed tool arguments](https://github.com/langgenius/dify/blob/1a918ea407990ecd2156af2614932db8c9c43091/api/core/mcp/server/streamable_http.py#L264). +- Account: [workspace controller import](https://github.com/langgenius/dify/blob/1a918ea407990ecd2156af2614932db8c9c43091/api/controllers/console/workspace/models.py#L35). +- ResponseModel: [inherited response](https://github.com/langgenius/dify/blob/1a918ea407990ecd2156af2614932db8c9c43091/api/controllers/web/human_input_form.py#L70). +- Dataset: [query reference](https://github.com/langgenius/dify/blob/1a918ea407990ecd2156af2614932db8c9c43091/api/controllers/service_api/dataset/document.py#L410). + +Native fixtures separately verify exception construction and bare catch +references, typed unit-of-work fields, injection, factory chains, mutation, +shadowing, ambiguous receivers and incorrect same-name recursive rewrites. + +The publisher quarantined 15 invalid edge records, with zero omitted nodes +and zero identity collisions. Responses retain `IncompleteCoverage`; a complete +direct audit refers to the published graph, not to every possible source or +runtime dependency. Transitive completeness is bounded separately. + +## Reproduce + +Set `CARGO_TARGET_DIR` to the per-checkout external target required by +`AGENTS.md`, `DIFY_API` to the pinned checkout's `api/` directory, and +`QUALIFICATION_OUT` to an artifact directory outside the source checkout. + +```bash +CARGO_TARGET_DIR="$CARGO_TARGET_DIR" cargo build --locked \ + -p compass-core --example republish_qualification \ + -p compass-query --example query_batch +"$CARGO_TARGET_DIR/debug/examples/republish_qualification" \ + "$DIFY_API" "$QUALIFICATION_OUT" +python3 scripts/qualify_python_service_calls.py \ + --repository "$DIFY_API" \ + --graph "$QUALIFICATION_OUT/compass-out/graph.json" \ + --compass "$CARGO_TARGET_DIR/debug/compass" \ + --batch-runner "$CARGO_TARGET_DIR/debug/examples/query_batch" \ + --report "$QUALIFICATION_OUT/services-report.json" +``` + +The rebuild helper reuses only validated compatible AST facts. The batch runner +executes the owning native query APIs on one validated graph; Python is solely +an offline qualification oracle and never supplies product graph facts. +Inputs, queries and outputs are bounded. Batch setup plus execution has a +twenty-minute bound because a cold large-graph search index can be expensive. +This qualification makes no latency, token-cost or competitor-performance claim. + +## Fixture topology baseline + +Source-backed deferred receiver nodes add 17 nodes to the fixture graph +(1,292 to 1,309). They remain explicitly inferred and attach to their callers. +The exact layer retains 56 cross-file edges and gains six exact edges +(956 to 962), six exact typed endpoint pairs (948 to 954), and two exact +edge-bearing nodes (918 to 920). Overall connected components fall to 236; +there are no self-loops. + +The three exact-density floors are rebased to the larger node denominator +(42, 702 and 728 per thousand). Exact-only isolated nodes and components each +grow by 15, matching the added nodes without exact incident evidence. Their +ceilings become 389 and 528. Absolute exact-edge, cross-file-edge and endpoint +floors remain intact, as do all relationship-specific and semantic assertions. +This changes the expected inferred layer rather than claiming those contacts +as exact evidence. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 50bf7f117..2a668dcbf 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -537,7 +537,30 @@ other format. On the evaluation corpus the same caller answers cost 1.7k-2.0k tokens in brief form instead of 6.3k-6.8k. `callers` returns incoming relationship evidence: calls, routes, references, -imports, exports, and aliases. `callees` remains the direct outgoing call view. +imports, exports, and aliases. `callees` returns outgoing calls and constructions. For a class/type, it includes +calls owned by contained methods while preserving their actual source endpoints. +Python graphs rebuilt with AST cache version 14 retain source-call inventories; +`callSummary` distinguishes resolved call sites, unresolved invocations and +inventory completeness. Older graphs report unavailable coverage instead of +assuming their absent inventory means zero calls. + +`impact --relation TYPE` accepts repeatable stored relationship names and replaces +the default dependency policy. The default includes calls, constructions, +inheritance/conformance, type and return contracts, references, imports/exports, +aliases, overrides/decorators and stored effect/routing relations. Containment and +document mentions are not traversed by default. Heuristic edges still require +`--include-heuristic`. MCP `get_impact` accepts the same policy as a `relations` +array. `impactSummary` groups retained direct and transitive dependents by file +and parent module. Paged text prints these groups before paths and relationships. + +Impact independently audits distinct incoming and outgoing neighbors, matching +`explain` connection units. It reports outgoing review context and incoming +policy/confidence exclusions rather than presenting them as affected nodes. +Missing selected direct dependents fail a complete query with a typed consistency +error; budgets instead mark direct coverage incomplete. Partial source graphs retain a +separate incomplete-source-coverage caveat; the direct audit covers published +adjacency. Use +larger `--max-nodes`/`--max-edges` bounds to inspect a bounded audit. When an import or reference ends at a containing module rather than the selected declaration, `callers`, `impact`, and `affected` retain the real owner-targeted edge and emit an `incomplete_coverage` precision warning. diff --git a/docs/reference/universal-semantic-evidence.md b/docs/reference/universal-semantic-evidence.md index b807352a9..47df287bd 100644 --- a/docs/reference/universal-semantic-evidence.md +++ b/docs/reference/universal-semantic-evidence.md @@ -86,8 +86,10 @@ collections: exact callable that initialized a receiver and may record the zero-based output selected by a destructuring assignment. It may preserve an exact nominal result type proven in the same file, reference an earlier - call-result binding to represent a bounded receiver chain, and retain one - non-call-result fallback binding for incomplete project-wide evidence. Chain + call-result binding to represent a bounded receiver chain, or a source-backed + Python local-alias binding establishing a nominal receiver for a factory + method. Other binding kinds and non-Python local aliases are rejected as + call-result receivers. It may retain one non-call-result fallback binding for incomplete project-wide evidence. Chain references must exist, remain acyclic, and stay within the evidence depth limit. Resolution otherwise requires a unique callable and either one published return type or an in-range exact output diff --git a/scripts/qualify_python_service_calls.py b/scripts/qualify_python_service_calls.py new file mode 100644 index 000000000..8c7fa341b --- /dev/null +++ b/scripts/qualify_python_service_calls.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Offline source-oracle qualification. Python is a qualification-only dependency. + +The supplied native graph remains the authority; this script never creates graph +facts or supplies a runtime fallback. Source observations and resolved targets +are measured separately. Checkouts are read-only, and output is explicit. +""" +import argparse +import ast +from collections import defaultdict +import json +from pathlib import Path +import random +import subprocess + +MAX_GRAPH_BYTES = 1024 * 1024 * 1024 +MAX_SOURCE_BYTES = 2 * 1024 * 1024 +SEED = 20260930 +MAX_SOURCE_FILES = 20_000 + + +def source_path(root, relative): + candidate = (root / relative).resolve() + candidate.relative_to(root.resolve()) + if not candidate.is_file() or candidate.stat().st_size > MAX_SOURCE_BYTES: + raise ValueError("source file is missing or exceeds its qualification bound") + return candidate + + +def bounded_read(path, limit): + if not path.is_file() or path.stat().st_size > limit: + raise ValueError("qualification input is missing or exceeds its bound") + with path.open("rb") as source: + data = source.read(limit + 1) + if len(data) > limit: + raise ValueError("qualification input grew past its bound") + return data + + +def load_graph(path): + return json.loads(bounded_read(path, MAX_GRAPH_BYTES)) + + +def anchor_key(anchor): + return (anchor["file"], anchor["startByte"], anchor["endByte"]) + + +def query(binary, graph, operation, symbol, batch): + if batch is not None: + return batch[(operation, symbol)] + result = subprocess.run([str(binary), operation, symbol, "--graph", str(graph), + "--format", "json", "--max-nodes", "2000", "--max-edges", "10000", + "--max-depth", "8" if operation == "callees" else "1", "--max-response-bytes", "33554432"], capture_output=True, timeout=120, check=True) + if len(result.stdout) > 32 * 1024 * 1024: + raise ValueError("qualification response exceeds 32 MiB") + return json.loads(result.stdout) + + +def owned_calls(function): + pending = list(function.body) + while pending: + node = pending.pop() + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + if isinstance(node, ast.Call): + yield node.func + pending.extend(ast.iter_child_nodes(node)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", type=Path, required=True, help="graph's source root") + parser.add_argument("--graph", type=Path, required=True) + parser.add_argument("--compass", type=Path, required=True) + parser.add_argument("--report", type=Path, required=True) + parser.add_argument("--batch-runner", type=Path, help="native query_batch example; reuses one engine") + args = parser.parse_args() + graph = load_graph(args.graph) + nodes = {node["id"]: node for node in graph["nodes"]} + by_class = defaultdict(list) + for node in nodes.values(): + if node["kind"] == "class" and node.get("source"): + by_class[(node["source"]["file"], node["name"])].append(node) + inventory = defaultdict(set) + resolved = defaultdict(set) + inferred = defaultdict(set) + for node in nodes.values(): + for site in node.get("details", {}).get("data", {}).get("callSites", {}).get("sites", []): + inventory[node["id"]].add(anchor_key(site)) + for edge in graph["links"]: + if edge["kind"] not in ("calls", "instantiates") or not edge.get("relationshipSite"): + continue + site = anchor_key(edge["relationshipSite"]) + exact = bool(edge["evidence"]) and all(item["confidence"] == "exact" and len(item.get("candidates", [])) <= 1 for item in edge["evidence"]) + (resolved if exact else inferred)[edge["source"]].add(site) + classes = [] + paths = [] + for path in (args.repository / "services").rglob("*.py"): + paths.append(path) + if len(paths) > MAX_SOURCE_FILES: + raise ValueError("service-source file bound exceeded") + for path in sorted(paths): + if "tests" in path.parts or path.stat().st_size > MAX_SOURCE_BYTES: + continue + source = bounded_read(source_path(args.repository, path.relative_to(args.repository)), MAX_SOURCE_BYTES) + tree = ast.parse(source, filename=str(path)) + relative = path.relative_to(args.repository).as_posix() + starts = [0] + for line in source.splitlines(keepends=True): + starts.append(starts[-1] + len(line)) + for definition in tree.body: + if not isinstance(definition, ast.ClassDef) or not definition.name.endswith("Service"): + continue + matches = by_class[(relative, definition.name)] + expected = set() + for function in definition.body: + if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for call in owned_calls(function): + expected.add((relative, starts[call.lineno - 1] + call.col_offset, + starts[call.end_lineno - 1] + call.end_col_offset)) + if len(expected) >= 5: + classes.append((relative, definition.name, matches[0] if len(matches) == 1 else None, expected)) + if len(classes) < 10: + raise ValueError("qualification requires at least ten nontrivial service classes") + chosen = sorted(random.Random(SEED).sample(classes, 10), key=lambda item: item[:2]) + for file, name, node, _ in chosen: + if node is None: + raise ValueError(f"{file}:{name}: source-sampled service has no unique graph class") + incoming = defaultdict(set) + incident = defaultdict(set) + for edge in graph["links"]: + incoming[edge["target"]].add(edge["source"]) + incident[edge["target"]].add(("incoming", edge["source"])) + incident[edge["source"]].add(("outgoing", edge["target"])) + symbols = sorted((node for node in nodes.values() if node["kind"] == "class" + and node.get("source") and len(incoming[node["id"]]) >= 5), + key=lambda node: (-len(incoming[node["id"]]), node["id"]))[:5] + if len(symbols) != 5: + raise ValueError("qualification requires five widely used source symbols") + batch = None + if args.batch_runner: + requests = [{"operation": "callees", "symbol": node["id"]} for _, _, node, _ in chosen] + requests.extend({"operation": "impact", "symbol": node["id"]} for node in symbols) + request_path = args.report.with_suffix(".requests.json") + response_path = args.report.with_suffix(".responses.json") + request_path.write_text(json.dumps(requests)) + subprocess.run([str(args.batch_runner), str(args.graph), str(request_path), str(response_path)], + check=True, timeout=1200) + if response_path.stat().st_size > 64 * 1024 * 1024: + raise ValueError("batch response exceeds 64 MiB") + responses = json.loads(bounded_read(response_path, 64 * 1024 * 1024)) + if len(responses) != len(requests): + raise ValueError("batch response count mismatch") + batch = {(request["operation"], request["symbol"]): response + for request, response in zip(requests, responses)} + calls = [] + for file, name, node, expected in chosen: + # Independently ask the public class query; it must aggregate methods. + response = query(args.compass, args.graph, "callees", node["id"], batch) + observed = set() + exact = set() + possible = set() + for result_node in response["nodes"]: + observed.update(inventory[result_node["id"]]) + exact.update(resolved[result_node["id"]]) + possible.update(inferred[result_node["id"]]) + summary = response.get("callSummary") + if not summary or summary["observedCalls"] == 0 or summary["unresolvedCalls"] is None: + raise ValueError(f"{name}: missing honest source-call coverage") + calls.append({"file": file, "class": name, "source_calls": len(expected), + "observed_calls": len(expected & observed), "resolved_calls": len(expected & exact), + "inferred_edges": len(expected & possible), "query_summary": summary, + "missing_source_sites": sorted(expected - observed)}) + impacts = [] + for node in symbols: + response = query(args.compass, args.graph, "impact", node["id"], batch) + summary = response["impactSummary"] + known = len(incident[node["id"]]) + if summary["observedDirectConnections"] != known or not summary["directCoverageComplete"]: + raise ValueError(f'{node["qualifiedName"]}: incomplete direct-connection audit') + if len(summary["directDependents"]) + len(summary["outgoingContext"]) + summary["excludedDirectConnections"] != known: + raise ValueError("direct-connection audit has unaccounted neighbors") + samples = [] + for edge in graph["links"]: + if edge["target"] != node["id"] or edge["source"] not in summary["directDependents"]: + continue + site = edge.get("relationshipSite") + if not site or len(samples) >= 5: + continue + lines = bounded_read(source_path(args.repository, site["file"]), MAX_SOURCE_BYTES).decode().splitlines() + samples.append({"relation": edge["kind"], "source": nodes[edge["source"]]["qualifiedName"], + "file": site["file"], "line": site["startLine"], + "source_line": lines[site["startLine"] - 1].strip()}) + impacts.append({"symbol": node["qualifiedName"], "source": node["source"], + "known_direct_connections": known, "summary": summary, "hand_check_samples": samples}) + total = sum(item["source_calls"] for item in calls) + observed = sum(item["observed_calls"] for item in calls) + report = {"schema": "compass.qualification.python-services/1", "random_seed": SEED, + "source_commit": graph["graph"]["build"].get("sourceCommit"), + "eligible_source_services": len(classes), + "service_count": len(calls), "source_calls": total, "observed_calls": observed, + "source_capture_ratio": observed / total, "services": calls, "impact_symbols": impacts} + args.report.write_text(json.dumps(report, indent=2) + "\n") + if observed / total < 0.9: + raise SystemExit("source-call capture below 90%; see report") + print(f"{observed}/{total} source calls captured across ten services; five direct-impact audits complete") + + +if __name__ == "__main__": + main() diff --git a/tests/qualification/code-graph-v1-topology.json b/tests/qualification/code-graph-v1-topology.json index 3e03e2bc6..1a4d9642b 100644 --- a/tests/qualification/code-graph-v1-topology.json +++ b/tests/qualification/code-graph-v1-topology.json @@ -6,21 +6,21 @@ "edges": 1277, "exactCrossCommunityEdges": 2, "exactCrossFileEdges": 56, - "exactCrossFileEdgesPerThousandNodes": 43, - "exactEdgeBearingNodePermille": 710, + "exactCrossFileEdgesPerThousandNodes": 42, + "exactEdgeBearingNodePermille": 702, "exactEdgeBearingNodes": 918, "exactEdges": 956, "exactLargestComponentNodes": 54, "exactUniqueTypedEndpointPairs": 948, - "exactUniqueTypedEndpointPairsPerThousandNodes": 733, + "exactUniqueTypedEndpointPairsPerThousandNodes": 728, "nodes": 1292, "uniqueTypedEndpointPairs": 1255 }, "maximums": { "communities": 240, "connectedComponents": 237, - "exactConnectedComponents": 513, - "exactIsolatedNodes": 374, + "exactConnectedComponents": 528, + "exactIsolatedNodes": 389, "exactSelfLoops": 0, "isolatedNodes": 100, "selfLoops": 0,