diff --git a/CHANGELOG.md b/CHANGELOG.md index cd5b8027..ebab6c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## Unreleased +- Preserve anonymous PHP functions and arrow functions as typed callable + `closure` nodes, and publish exact PHP trait composition as `mixes_in` + instead of collapsing it into `implements`. These additions eliminate + closure-induced partial graphs while retaining exact UTF-8 occurrence + anchors, deterministic identities, and strict endpoint validation. Also + canonicalize file-node names from their portable source paths before + coalescing, preventing empty ECMAScript module evidence from colliding with + the same detected file identity. + +- Publish bounded, typed blind-spot evidence in `analysis.json`, + `orientation.json` (`compass.orientation/2`), and MCP + `compass://graph-insights`, including stable IDs, witnesses, multiplicity, + exact omissions, and explicit limits. Preserve the existing graph edges; + structural gaps remain investigative projections rather than inferred + relationships. + +- Add `compass history blind-spots` to compare persisted graph-insights + observations across immutable realizations and report active/resolved + structural gaps and disconnected components without treating missing older + sidecars as empty graphs. + - Make `compass update` use the fact-neutral incremental path for metadata-only source edits, refresh full-file source envelopes without moving exact symbol anchors, point-update immutable node values, reuse unchanged graph JSON @@ -180,7 +201,6 @@ become noisy graph hubs. Source-proven same-file, same-package, and imported declarations retain precedence. Advance extraction semantics to v2 so cached graphs rebuild with the denser call topology. - - Stop Markdown pipe-table containers from overwhelming community names and `GRAPH_REPORT.md`. Mixed communities now prefer meaningful headings or symbols over higher-degree table parser nodes; table-only communities use diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index dfb9d5cc..b5962010 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -157,9 +157,11 @@ result plus its query-owned `semanticResultDigest`. The digest is computed from canonical v1 semantic response bytes; the digest field is outside that result, so the v1 payload and its byte/shape contract remain unchanged. -Clustered updates publish `orientation.json` (`compass.orientation/1`) from the +Clustered updates publish `orientation.json` (`compass.orientation/2`) from the same fitted model as `GRAPH_REPORT.md` and include it in the coherent snapshot -and build state. `compass export orientation-json` and +and build state. The additive `blindSpots` field carries the versioned, +bounded graph-insights report with witnesses, exact omission counts, and +limits. `compass export orientation-json` and `compass://orientation` validate that its generation, source/configuration identity, commit, graph summary, and exact streamed `graph.json` artifact digest match the selected guarded graph. A direct or historical graph without diff --git a/MIGRATION.md b/MIGRATION.md index b9a99f20..cad3d1f2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -86,6 +86,11 @@ the exact `graph.json` digest. Agent-facing orientation/report exports fail explicitly for older, missing, detached, or stale sidecars instead of pairing evidence by filename alone. +The orientation contract is now `compass.orientation/2`. Consumers that parse +`orientation.json` must accept the new schema and may read its optional typed +`blindSpots` projection; older orientation files should be regenerated with +`compass update .` rather than edited in place. + ## Select inference breadth explicitly when upgrading Structural `init`, `update`, `extract`, and `watch` builds now default to diff --git a/crates/compass-cli/src/history_commands.rs b/crates/compass-cli/src/history_commands.rs index 8a0c307c..108b928d 100644 --- a/crates/compass-cli/src/history_commands.rs +++ b/crates/compass-cli/src/history_commands.rs @@ -7,10 +7,11 @@ use compass_core::{ materialize_history_with_observer, }; use compass_history::{ - ArtifactClass, BuildProfile, ChangeKind, ChangeSink, ClaimedJob, CommitId, - DerivedCacheNamespace, ExtractionFingerprint, GitTargetLimitation, GraphChange, HistoryConfig, - HistoryError, HistoryQueue, HistoryStore, JobRequest, JobState, PublishedVersion, - RealizationId, RecordKind, Repository, canonical_json_bytes, + ArtifactClass, BlindSpotObservation, BuildProfile, ChangeKind, ChangeSink, ClaimedJob, + CommitId, DerivedCacheNamespace, ExtractionFingerprint, GitTargetLimitation, GraphChange, + HistoryConfig, HistoryError, HistoryQueue, HistoryStore, JobRequest, JobState, + PublishedVersion, RealizationId, RecordKind, Repository, canonical_json_bytes, + summarize_blind_spots, }; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -20,8 +21,12 @@ use crate::{Frontend, Outcome}; pub(crate) fn help(_frontend: Frontend) -> String { let prefix = "compass"; - format!( + let rendered = format!( "Usage: {prefix} history \n\nCommands:\n enable [build-profile options]\n disable\n timeline [--rev REV] [--limit N [--after CURSOR]] --format json\n change-counts REV [--parent REV] --format json\n diff OLD NEW [--root NAME] [--output PATH] --format jsonl\n status [REV] [--format text|json]\n verify REV|REALIZATION [--format text|json]\n build REV [--all [--first-parent]] [build-profile options|--profile-from REV|REALIZATION] [--format text|json]\n rebuild REV [build-profile options] [--replace-corrupt] [--format text|json]\n list [REV] [--format text|json]\n show REALIZATION [--format text|json]\n prefer REV REALIZATION [--format text|json]\n export REV --format graph-json|json|compass-out [--community ID] [--node-limit N] --output PATH\n cache status [--format text|json]\n cache gc [--max-bytes N] [--max-age-days N] [--yes] [--format text|json]\n gc [--prune-non-preferred] [--yes] [--format text|json]\n\nExact diff roots:\n nodes, edges, hyperedges, analysis, metadata, program-facts, program-summaries\n\nBuild options:\n --all Build every commit reachable from REV\n --first-parent With --all, build only the first-parent lineage\n\nBuild-profile options:\n --code-only Build a complete local AST/inferred realization without model credentials\n --backend NAME Build a semantic realization with the selected provider\n --model NAME Select the provider model\n --exclude PATTERN Exclude a committed path pattern (repeatable)\n --cargo Include Cargo package metadata" + ); + rendered.replace( + " timeline [--rev REV] [--limit N [--after CURSOR]] --format json\n", + " timeline [--rev REV] [--limit N [--after CURSOR]] --format json\n blind-spots [--rev REV] [--limit N] [--format text|json]\n", ) } @@ -473,6 +478,9 @@ fn execute(frontend: Frontend, args: &[String]) -> Result Result<&str, CommandFailure> { } } +fn execute_blind_spots(repository: &Repository, args: &[String]) -> Result { + let mut revision = "HEAD".to_owned(); + let mut limit = 200_usize; + let mut format = "text"; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--rev" => { + index += 1; + revision = args + .get(index) + .ok_or_else(|| usage("history blind-spots --rev requires a revision"))? + .clone(); + } + value if value.starts_with("--rev=") => revision = value[6..].to_owned(), + "--limit" => { + index += 1; + limit = args + .get(index) + .ok_or_else(|| usage("history blind-spots --limit requires a value"))? + .parse::() + .map_err(|_| usage("history blind-spots --limit must be an integer"))?; + } + value if value.starts_with("--limit=") => { + limit = value[8..] + .parse::() + .map_err(|_| usage("history blind-spots --limit must be an integer"))?; + } + "--format" => { + index += 1; + format = args + .get(index) + .ok_or_else(|| usage("history blind-spots --format requires a value"))?; + } + value if value.starts_with("--format=") => format = &value[9..], + value => return Err(usage(format!("unknown history blind-spots option {value}"))), + } + index += 1; + } + if !(1..=1_000).contains(&limit) { + return Err(usage( + "history blind-spots --limit must be between 1 and 1000", + )); + } + if !matches!(format, "text" | "json") { + return Err(usage("history blind-spots --format must be text or json")); + } + let head = repository.resolve(&revision).map_err(runtime)?; + let mut commits = repository + .reachable_commits(&head, false) + .map_err(runtime)?; + // Repository::reachable_commits is already parent-before-child order; + // trend aggregation needs that chronological direction to classify an ID + // as active or resolved correctly. + if commits.len() > limit { + commits = commits.split_off(commits.len() - limit); + } + let metadata = repository.timeline_commits(&commits).map_err(runtime)?; + let history = HistoryStore::open_existing(repository) + .map_err(runtime)? + .ok_or_else(|| runtime("history blind-spots requires an existing history store"))?; + let versions = history.preferred_many(&commits).map_err(runtime)?; + let versions = versions + .into_iter() + .map(|version| (version.version.git_commit.clone(), version)) + .collect::>(); + let mut observations = Vec::new(); + for (commit, metadata) in commits.iter().zip(metadata) { + let report = if let Some(version) = versions.get(commit.as_str()) { + let reader = history.reader(&version.id).map_err(runtime)?; + reader + .analysis_json() + .map_err(runtime)? + .and_then(|analysis| analysis.get("blindSpots").cloned()) + } else { + None + }; + observations.push(BlindSpotObservation { + commit: commit.to_string(), + authored_at_seconds: metadata.authored_at_seconds, + report, + }); + } + let trend = summarize_blind_spots(&observations).map_err(runtime)?; + if format == "json" { + serde_json::to_string(&trend).map_err(runtime) + } else { + Ok(format_blind_spot_trend(&trend)) + } +} + +fn format_blind_spot_trend(trend: &compass_history::BlindSpotTrend) -> String { + let mut lines = vec![ + format!( + "blind spots: {} observations · {} with graph insights", + trend.observation_count, trend.observations_with_graph_insights + ), + format!( + "range: {} -> {}", + trend.first_commit.as_deref().unwrap_or("none"), + trend.last_commit.as_deref().unwrap_or("none") + ), + format!("active: {}", trend.active.len()), + ]; + lines.extend(trend.active.iter().map(|item| { + format!( + " active {} [{}] · seen {} times · first {}", + item.id, item.kind, item.observation_count, item.first_commit + ) + })); + lines.push(format!("resolved: {}", trend.resolved.len())); + lines.extend(trend.resolved.iter().map(|item| { + format!( + " resolved {} [{}] · last {} · seen {} times", + item.id, item.kind, item.last_commit, item.observation_count + ) + })); + if trend.omissions.items > 0 || trend.omissions.observations_without_graph_insights > 0 { + lines.push(format!( + "omitted: {} trend items · {} observations without graph insights", + trend.omissions.items, trend.omissions.observations_without_graph_insights + )); + } + lines.join("\n") +} + fn execute_timeline(repository: &Repository, args: &[String]) -> Result { let mut revision = "HEAD".to_owned(); let mut revision_selected = false; @@ -2234,6 +2368,7 @@ mod tests { #[test] fn help_failures_and_common_argument_boundaries_are_total() { assert!(help(Frontend::Compass).starts_with("Usage: compass history")); + assert!(help(Frontend::Compass).contains("blind-spots [--rev REV]")); assert_eq!(command(Frontend::Compass, &[]).code, 0); assert_eq!( command_worker(Frontend::Compass, &["extra".to_owned()]).code, diff --git a/crates/compass-cli/tests/viewer_export_cli.rs b/crates/compass-cli/tests/viewer_export_cli.rs index a11de9de..7c5fb270 100644 --- a/crates/compass-cli/tests/viewer_export_cli.rs +++ b/crates/compass-cli/tests/viewer_export_cli.rs @@ -66,7 +66,7 @@ fn cluster_only_preserves_the_typed_graph_used_by_orientation_export() -> Result String::from_utf8_lossy(&exported.stderr) ); let orientation: Value = serde_json::from_slice(&exported.stdout)?; - assert_eq!(orientation["schema"], "compass.orientation/1"); + assert_eq!(orientation["schema"], "compass.orientation/2"); assert_eq!(orientation["graphSummary"]["edges"], typed.links.len()); Ok(()) } @@ -102,7 +102,7 @@ fn orientation_json_export_is_bound_to_the_selected_graph_generation() -> Result String::from_utf8_lossy(&exported.stderr) ); let orientation: Value = serde_json::from_slice(&exported.stdout)?; - assert_eq!(orientation["schema"], "compass.orientation/1"); + assert_eq!(orientation["schema"], "compass.orientation/2"); assert!(orientation["evidenceStatus"]["generationId"].is_string()); let active = compass_files::BuildGuard::resolve_current_snapshot_directory( diff --git a/crates/compass-core/src/cluster_existing.rs b/crates/compass-core/src/cluster_existing.rs index 36ea8de6..9ac37bb7 100644 --- a/crates/compass-core/src/cluster_existing.rs +++ b/crates/compass-core/src/cluster_existing.rs @@ -5,18 +5,18 @@ use std::time::{Duration, Instant}; use compass_files::{BuildGuard, write_atomic_with_digest, write_json_atomic, write_text_atomic}; use compass_graph::{ - ClusterOptions, Communities, GodNode, cluster, community_member_signatures, god_nodes, - label_communities_by_hub, remap_communities_to_previous, score_communities, suggest_questions, - surprising_connections, write_canonical_graph_json, + ClusterOptions, Communities, GodNode, blind_spot_report, cluster, community_member_signatures, + god_nodes, label_communities_by_hub, remap_communities_to_previous, score_communities, + suggest_questions, surprising_connections, write_canonical_graph_json, }; use compass_model::GraphDocument; use compass_model::GraphError; use compass_model::code_graph::{CommunityMetadata, GraphDocument as V1GraphDocument}; use compass_output::{ DetectionSummary, FreshnessBasis, FreshnessStatus, HtmlOptions, JsonExportOptions, - OrientationHealth, ReportOptions, TokenCost, agent_orientation, backup_if_protected_to, - graph_artifact_identity, render_agent_report_markdown, render_orientation_json, write_html, - write_json, + OrientationHealth, ReportOptions, TokenCost, agent_orientation_with_blind_spots, + backup_if_protected_to, graph_artifact_identity, render_agent_report_markdown, + render_orientation_json, write_html, write_json, }; use serde_json::{Value, json}; @@ -229,6 +229,7 @@ where let gods = god_nodes(published_document, 10); let surprises = surprising_connections(published_document, &communities, 5); let questions = suggest_questions(published_document, &communities, &labels, 10); + let blind_spots = blind_spot_report(published_document, &communities, &labels); let commit_root = std::env::current_dir().unwrap_or_else(|_| options.root.clone()); let commit = git_commit(&commit_root); let report_root = options.root.to_string_lossy(); @@ -242,7 +243,7 @@ where report_commit.as_deref(), ); let learning = load_learning_for_report(&options.output_dir.join("graph.json")); - let mut orientation = agent_orientation( + let mut orientation = agent_orientation_with_blind_spots( published_document, &communities, &cohesion, @@ -255,6 +256,7 @@ where }, selection.token_cost, Some(&questions), + Some(&blind_spots), learning.as_ref(), &report_options, ); @@ -271,6 +273,7 @@ where "gods": gods, "surprises": surprises, "questions": questions, + "blindSpots": blind_spots, }), true, )?; diff --git a/crates/compass-core/src/pipeline.rs b/crates/compass-core/src/pipeline.rs index a02e82fa..695acbcf 100644 --- a/crates/compass-core/src/pipeline.rs +++ b/crates/compass-core/src/pipeline.rs @@ -21,7 +21,7 @@ use compass_graph::{ SnapshotSelector, SourceDigest, apply_inference_level, build_owned_with_tiebreaker_at_inference as build_document, canonical_edge_kind, canonical_raw_edge_sites, cluster_incremental, deduped_node_count, extraction_from_v1, - garbage_collect_graph_snapshots, graph_insights, graph_snapshot_needs_gc, + garbage_collect_graph_snapshots, graph_insights_with_blind_spots, graph_snapshot_needs_gc, label_communities_by_hub, normalize_document_v1_with_evidence_best_effort_owned_at_inference, normalize_document_v1_with_inventory_and_source_digests_best_effort_owned_at_inference, normalize_document_v1_with_inventory_best_effort_at_inference, score_communities, @@ -47,8 +47,9 @@ use compass_model::provenance::{ use compass_model::{EdgeRecord, GraphDocument, NodeRecord}; use compass_output::{ DetectionSummary, FreshnessBasis, FreshnessStatus, GraphViewModel, HtmlOptions, - OrientationHealth, OutputError, PublicationStatus, ReportOptions, TokenCost, agent_orientation, - graph_view_model_document, render_agent_report_markdown, render_orientation_json, write_html, + OrientationHealth, OutputError, PublicationStatus, ReportOptions, TokenCost, + agent_orientation_with_blind_spots, graph_view_model_document, render_agent_report_markdown, + render_orientation_json, write_html, }; use compass_resolve::{ ResolutionAdmission, apply_program_projection, collect_program_projection_sites, @@ -3928,10 +3929,16 @@ fn build_graph_inner_unscoped( > { let started = Instant::now(); let analysis_compute_started = Instant::now(); - let (cohesion, (gods, surprises, questions)) = rayon::join( + let (cohesion, insights) = rayon::join( || score_communities(&document, &communities), - || graph_insights(&document, &communities, &labels, 10, 5, 10), + || graph_insights_with_blind_spots(&document, &communities, &labels, 10, 5, 10), ); + let compass_graph::GraphInsights { + gods, + surprises, + questions, + blind_spots, + } = insights; profile_internal_duration( "graph analyses computation", analysis_compute_started.elapsed(), @@ -3944,6 +3951,7 @@ fn build_graph_inner_unscoped( "cohesion": cohesion.iter().map(|(key, value)| (key.to_string(), value)).collect::>(), "gods": gods, "surprises": surprises, + "blindSpots": blind_spots, "tokens": {"input": tokens.0, "output": tokens.1}, }) } else { @@ -3953,6 +3961,7 @@ fn build_graph_inner_unscoped( "gods": gods, "surprises": surprises, "questions": questions, + "blindSpots": blind_spots, }) }; if options.purpose == BuildPurpose::Extract { @@ -3976,7 +3985,7 @@ fn build_graph_inner_unscoped( let mut report_options = ReportOptions::new(&report_root); report_options.built_at_commit = commit.as_deref(); report_options.health = report_health.clone(); - Some(agent_orientation( + Some(agent_orientation_with_blind_spots( &document, &communities, &cohesion, @@ -3986,6 +3995,7 @@ fn build_graph_inner_unscoped( &detection_summary, TokenCost::default(), Some(&questions), + Some(&blind_spots), None, &report_options, )) diff --git a/crates/compass-core/src/task_context.rs b/crates/compass-core/src/task_context.rs index e5e737f7..ba900aae 100644 --- a/crates/compass-core/src/task_context.rs +++ b/crates/compass-core/src/task_context.rs @@ -547,6 +547,7 @@ fn declaration_details(source: &CodeQueryResponse) -> CodeQueryResponse { EdgeKind::Embeds, EdgeKind::Extends, EdgeKind::Implements, + EdgeKind::MixesIn, EdgeKind::TypeOf, EdgeKind::Returns, EdgeKind::Instantiates, diff --git a/crates/compass-core/tests/code_graph_v1_publication_resilience.rs b/crates/compass-core/tests/code_graph_v1_publication_resilience.rs index 663d8821..a470ecf9 100644 --- a/crates/compass-core/tests/code_graph_v1_publication_resilience.rs +++ b/crates/compass-core/tests/code_graph_v1_publication_resilience.rs @@ -30,6 +30,106 @@ fn write(root: &Path, relative: &str, source: &str) -> Result<(), Box Ok(()) } +#[test] +fn php_closures_and_trait_uses_publish_without_quarantine() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + write( + directory.path(), + "src/Worker.php", + r#"write($value); + return trim($value); + }; + return array_map(fn (string $value): string => $normalize($value), $values); + } +} +"#, + )?; + + let mut options = BuildOptions::new(directory.path()); + options.no_cluster = true; + options.no_viz = true; + options.max_workers = Some(2); + let result = build_local_graph(&options)?; + assert!(!result.partial_graph, "result={result:#?}"); + assert_eq!(result.omitted_nodes, 0); + assert_eq!(result.omitted_edges, 0); + + let graph = GraphDocument::load(&result.output_dir.join("graph.json"))?; + validate_code_graph(&graph)?; + assert_eq!( + graph + .nodes + .iter() + .filter(|node| node.kind.as_str() == "closure") + .count(), + 2, + "nodes={:#?}", + graph.nodes + ); + assert!(graph.links.iter().any(|edge| { + edge.kind.as_str() == "mixes_in" + && graph.nodes.iter().any(|node| { + node.id == edge.source && node.qualified_name.eq_ignore_ascii_case("app\\worker") + }) + && graph.nodes.iter().any(|node| { + node.id == edge.target && node.qualified_name.eq_ignore_ascii_case("app\\logs") + }) + })); + assert!(graph.graph.diagnostics.iter().all(|diagnostic| { + !matches!( + diagnostic.code.as_str(), + "publication_omitted_node" + | "publication_omitted_edge" + | "publication_omission_summary" + ) + })); + Ok(()) +} + +#[test] +fn empty_javascript_file_has_one_canonical_file_identity() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + write(directory.path(), "src/random.js", "")?; + + let mut options = BuildOptions::new(directory.path()); + options.no_cluster = true; + options.no_viz = true; + options.max_workers = Some(2); + let result = build_local_graph(&options)?; + assert!(!result.partial_graph, "result={result:#?}"); + assert_eq!(result.identity_collisions, 0); + assert_eq!(result.omitted_nodes, 0); + assert_eq!(result.omitted_edges, 0); + + let graph = GraphDocument::load(&result.output_dir.join("graph.json"))?; + validate_code_graph(&graph)?; + let file_nodes = graph + .nodes + .iter() + .filter(|node| node.source_file() == Some("src/random.js")) + .collect::>(); + assert_eq!(file_nodes.len(), 1, "nodes={:#?}", graph.nodes); + assert_eq!(file_nodes[0].kind, NodeKind::File); + assert_eq!(file_nodes[0].name, "random.js"); + assert_eq!(file_nodes[0].qualified_name, "src/random.js"); + Ok(()) +} + #[test] fn invalid_topology_is_quarantined_and_the_valid_graph_is_published() -> Result<(), Box> { diff --git a/crates/compass-graph/src/analyze.rs b/crates/compass-graph/src/analyze.rs index 7032362f..68be0050 100644 --- a/crates/compass-graph/src/analyze.rs +++ b/crates/compass-graph/src/analyze.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use ahash::{AHashMap as HashMap, AHashSet as HashSet}; use std::path::Path; @@ -6,6 +6,7 @@ use std::path::Path; use compass_model::{EdgeRecord, GraphDocument, NodeRecord}; use rayon::prelude::*; use serde::Serialize; +use sha2::{Digest, Sha256}; use crate::cluster::{Communities, PythonRandom}; @@ -78,6 +79,27 @@ const JSON_NOISE_LABELS: &[&str] = &[ "bundledependencies", ]; +const COMMUNITY_GAP_MIN_REAL_NODES: usize = 4; +const COMMUNITY_GAP_MIN_CONNECTANCE: f64 = 1.2; +const COMMUNITY_GAP_MAX_NEIGHBOR_COMMUNITIES: usize = 32; +const COMMUNITY_GAP_MAX_PAIRS: usize = 200_000; +const COMMUNITY_GAP_MAX_QUESTIONS: usize = 3; +const ANALYSIS_LABEL_MAX_CHARS: usize = 160; + +// These relations describe graph wiring rather than a topical or semantic +// connection. They may make two communities adjacent without making them a +// useful structural-gap candidate. +const COMMUNITY_GAP_WIRING_RELATIONS: &[&str] = &[ + "contains", + "declares", + "defines", + "imports", + "imports_from", + "member_of", + "method", + "re_exports", +]; + #[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] pub struct GodNode { pub id: String, @@ -106,6 +128,102 @@ pub struct SuggestedQuestion { pub why: String, } +/// Versioned, bounded evidence for topology that deserves investigation. +/// +/// These are observations about the published graph. They are deliberately +/// separate from graph edges and from the prose questions derived from them. +pub const GRAPH_INSIGHTS_SCHEMA: &str = "compass.graph-insights/1"; + +const BLIND_SPOT_MAX_COMPONENTS: usize = 32; +const BLIND_SPOT_MAX_COMPONENT_MEMBERS: usize = 64; +const BLIND_SPOT_MAX_WITNESSES: usize = 8; + +#[derive(Clone, Debug, PartialEq, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlindSpotReport { + pub schema: String, + pub community_gaps: Vec, + pub disconnected_components: Vec, + pub disconnected_component_count: usize, + pub largest_component_size: usize, + pub omissions: BlindSpotOmissions, + pub limits: BlindSpotLimits, +} + +#[derive(Clone, Debug, PartialEq, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CommunityGap { + pub id: String, + pub left_community: usize, + pub right_community: usize, + pub left_anchor: String, + pub right_anchor: String, + pub left_label: String, + pub right_label: String, + pub score: f64, + pub shared_intermediary_count: usize, + pub shared_intermediaries: Vec, + pub direct_topical_edge_count: usize, + pub direct_topical_edges: Vec, + pub omitted_shared_intermediaries: usize, + pub omitted_direct_topical_edges: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DisconnectedComponent { + pub id: String, + pub real_node_count: usize, + pub members: Vec, + pub omitted_members: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlindSpotNode { + pub id: String, + pub label: String, + pub source_file: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlindSpotEdge { + pub source: String, + pub target: String, + pub relation: String, + pub confidence: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlindSpotOmissions { + pub candidate_pair_limit_reached: bool, + pub community_gaps: usize, + pub disconnected_components: usize, + pub component_members: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlindSpotLimits { + pub max_candidate_pairs: usize, + pub max_community_gaps: usize, + pub max_shared_intermediaries: usize, + pub max_direct_topical_edges: usize, + pub max_disconnected_components: usize, + pub max_component_members: usize, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphInsights { + pub gods: Vec, + pub surprises: Vec, + pub questions: Vec, + pub blind_spots: BlindSpotReport, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct DiffNode { pub id: String, @@ -206,7 +324,7 @@ pub fn suggest_questions( top_n: usize, ) -> Vec { let graph = AnalysisGraph::new(document); - suggest_questions_in(&graph, communities, community_labels, top_n) + suggest_questions_in(&graph, communities, community_labels, top_n).0 } fn suggest_questions_in( @@ -214,7 +332,7 @@ fn suggest_questions_in( communities: &Communities, community_labels: &BTreeMap, top_n: usize, -) -> Vec { +) -> (Vec, BlindSpotReport) { let node_community = invert_communities(communities); let cohesion = community_cohesion_scores(graph, communities, &node_community); let mut questions = Vec::new(); @@ -378,6 +496,8 @@ fn suggest_questions_in( }); } } + let blind_spots = blind_spots_in(graph, communities, community_labels); + questions.extend(blind_spot_questions(&blind_spots)); if questions.is_empty() { questions.push(SuggestedQuestion { kind: "no_signal".to_owned(), @@ -385,10 +505,542 @@ fn suggest_questions_in( why: "Not enough signal to generate questions. This usually means the corpus has no AMBIGUOUS edges, no bridge nodes, no INFERRED relationships, and all communities are tightly cohesive. Add more files or run with --mode deep to extract richer edges.".to_owned(), }); } - questions.truncate(top_n); + (prioritize_questions(questions, top_n), blind_spots) +} + +fn blind_spot_questions(report: &BlindSpotReport) -> Vec { + let mut questions = report + .community_gaps + .iter() + .map(|gap| SuggestedQuestion { + kind: "community_gap".to_owned(), + question: Some(format!( + "What evidence would directly connect `{}` and `{}` (for example, through a shared intermediary)?", + gap.left_label, gap.right_label + )), + why: format!( + "Structural gap score {:.4}: {} shared two-hop intermediaries and {} direct topical edges; wiring-only relations are excluded.", + gap.score, gap.shared_intermediary_count, gap.direct_topical_edge_count + ), + }) + .collect::>(); + if report.omissions.candidate_pair_limit_reached || report.omissions.community_gaps > 0 { + questions.push(SuggestedQuestion { + kind: "community_gap_limit".to_owned(), + question: None, + why: format!( + "Structural-gap analysis is bounded at {} candidate pairs and {} displayed gaps; some eligible evidence was omitted.", + report.limits.max_candidate_pairs, report.limits.max_community_gaps + ), + }); + } + if report.disconnected_component_count > 1 { + questions.push(SuggestedQuestion { + kind: "disconnected_components".to_owned(), + question: Some(format!( + "Which relationships are missing between the {} disconnected source-backed components?", + report.disconnected_component_count + )), + why: format!( + "The graph contains {} weakly connected source-backed components; the largest has {} real nodes. File, concept, and JSON-key-only components are not counted.", + report.disconnected_component_count, report.largest_component_size + ), + }); + } questions } +fn prioritize_questions(questions: Vec, top_n: usize) -> Vec { + if top_n == 0 { + return Vec::new(); + } + let mut diagnostics: [Vec; 3] = [Vec::new(), Vec::new(), Vec::new()]; + let mut ordinary = Vec::new(); + for question in questions { + let priority = match question.kind.as_str() { + "community_gap" => Some(0), + "disconnected_components" => Some(1), + "community_gap_limit" => Some(2), + _ => None, + }; + if let Some(priority) = priority { + diagnostics[priority].push(question); + } else { + ordinary.push(question); + } + } + // Keep the structural diagnostics category-aware: one large set of gaps + // must not hide the fact that the graph is also disconnected (or that the + // analysis itself was bounded). Fill one slot per category first, then + // use the normal priority order for the remaining slots. + let mut selected = Vec::new(); + for bucket in &mut diagnostics { + if selected.len() >= top_n { + break; + } + if !bucket.is_empty() { + selected.push(bucket.remove(0)); + } + } + for bucket in diagnostics { + selected.extend(bucket); + } + selected.extend(ordinary); + selected.truncate(top_n); + selected +} + +struct CommunityPairScore { + proximity: f64, + intermediaries: Vec, + omitted_intermediaries: usize, + direct_edges: usize, + direct_witnesses: Vec, +} + +fn blind_spots_in( + graph: &AnalysisGraph<'_>, + communities: &Communities, + community_labels: &BTreeMap, +) -> BlindSpotReport { + let node_community = invert_communities(communities); + let relation_edges = sorted_relation_edges(graph); + + let mut real_members = BTreeMap::>::new(); + for (community, members) in communities { + let mut positions = members + .iter() + .filter_map(|member| graph.positions.get(member.as_str()).copied()) + .filter(|position| is_community_gap_real_node(graph, *position)) + .collect::>(); + positions.sort_unstable(); + positions.dedup(); + real_members.insert(*community, positions); + } + + let mut internal_edges = BTreeMap::>::new(); + for edge in &relation_edges { + let left = node_community.get(&graph.nodes[edge.left].id); + let right = node_community.get(&graph.nodes[edge.right].id); + if let (Some(left), Some(right)) = (left, right) + && left == right + && !is_community_gap_wiring_relation(edge.record) + && is_community_gap_real_node(graph, edge.left) + && is_community_gap_real_node(graph, edge.right) + { + let endpoints = if edge.left <= edge.right { + (edge.left, edge.right) + } else { + (edge.right, edge.left) + }; + internal_edges.entry(*left).or_default().insert(endpoints); + } + } + + let eligible = real_members + .into_iter() + .filter(|(community, members)| { + let internal = internal_edges.get(community).map_or(0, BTreeSet::len); + let connectance = if members.is_empty() { + 0.0 + } else { + (internal as f64 * 2.0) / members.len() as f64 + }; + members.len() >= COMMUNITY_GAP_MIN_REAL_NODES + && connectance >= COMMUNITY_GAP_MIN_CONNECTANCE + }) + .collect::>(); + + let mut topical_adjacency = vec![Vec::::new(); graph.len()]; + for edge in &relation_edges { + if is_community_gap_wiring_relation(edge.record) { + continue; + } + topical_adjacency[edge.left].push(edge.right); + topical_adjacency[edge.right].push(edge.left); + } + for neighbors in &mut topical_adjacency { + neighbors.sort_by(|left, right| graph.nodes[*left].id.cmp(&graph.nodes[*right].id)); + neighbors.dedup(); + } + + let mut pair_scores = BTreeMap::<(usize, usize), CommunityPairScore>::new(); + let mut pair_budget_exhausted = false; + let mut middles = (0..graph.len()).collect::>(); + middles.sort_by(|left, right| graph.nodes[*left].id.cmp(&graph.nodes[*right].id)); + for middle in middles { + if !is_community_gap_real_node(graph, middle) { + continue; + } + let neighbors = &topical_adjacency[middle]; + let mut neighbor_communities = BTreeSet::new(); + for neighbor in neighbors { + if is_community_gap_real_node(graph, *neighbor) + && let Some(community) = node_community.get(&graph.nodes[*neighbor].id) + && eligible.contains_key(community) + { + neighbor_communities.insert(*community); + } + } + if neighbor_communities.len() < 2 + || neighbor_communities.len() > COMMUNITY_GAP_MAX_NEIGHBOR_COMMUNITIES + { + continue; + } + let neighbor_communities = neighbor_communities.into_iter().collect::>(); + let weight = 1.0 / (graph.degree(middle).max(1) as f64).sqrt(); + for left_index in 0..neighbor_communities.len().saturating_sub(1) { + for right_index in (left_index + 1)..neighbor_communities.len() { + let pair = ( + neighbor_communities[left_index], + neighbor_communities[right_index], + ); + if !pair_scores.contains_key(&pair) && pair_scores.len() >= COMMUNITY_GAP_MAX_PAIRS + { + pair_budget_exhausted = true; + continue; + } + let score = pair_scores.entry(pair).or_insert(CommunityPairScore { + proximity: 0.0, + intermediaries: Vec::new(), + omitted_intermediaries: 0, + direct_edges: 0, + direct_witnesses: Vec::new(), + }); + score.proximity += weight; + if score.intermediaries.len() < BLIND_SPOT_MAX_WITNESSES { + score.intermediaries.push(middle); + } else { + score.omitted_intermediaries = score.omitted_intermediaries.saturating_add(1); + } + } + } + } + + for edge in &relation_edges { + if is_community_gap_wiring_relation(edge.record) { + continue; + } + if !is_community_gap_real_node(graph, edge.left) + || !is_community_gap_real_node(graph, edge.right) + { + continue; + } + let Some(left) = node_community.get(&graph.nodes[edge.left].id) else { + continue; + }; + let Some(right) = node_community.get(&graph.nodes[edge.right].id) else { + continue; + }; + if left == right || !eligible.contains_key(left) || !eligible.contains_key(right) { + continue; + } + let pair = if left < right { + (*left, *right) + } else { + (*right, *left) + }; + if let Some(score) = pair_scores.get_mut(&pair) { + score.direct_edges = score.direct_edges.saturating_add(1); + let witness = blind_spot_edge(graph, edge.record); + if !score + .direct_witnesses + .iter() + .any(|candidate| candidate == &witness) + && score.direct_witnesses.len() < BLIND_SPOT_MAX_WITNESSES + { + score.direct_witnesses.push(witness); + } + } + } + + let mut ranked = pair_scores + .into_iter() + .map(|((left, right), evidence)| { + let score = evidence.proximity / (1.0 + evidence.direct_edges as f64); + (score, left, right, evidence) + }) + .filter(|(score, _, _, _)| *score > 0.0) + .collect::>(); + ranked.sort_by(|left, right| { + right + .0 + .partial_cmp(&left.0) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| left.1.cmp(&right.1)) + .then_with(|| left.2.cmp(&right.2)) + }); + + let total_ranked = ranked.len(); + let community_gaps = ranked + .into_iter() + .take(COMMUNITY_GAP_MAX_QUESTIONS) + .filter_map(|(score, left, right, evidence)| { + let left_members = eligible.get(&left)?; + let right_members = eligible.get(&right)?; + let left_anchor = community_gap_anchor(graph, left_members); + let right_anchor = community_gap_anchor(graph, right_members); + let left_label = community_gap_label(left, community_labels, graph, left_members); + let right_label = community_gap_label(right, community_labels, graph, right_members); + let shared_intermediaries = evidence + .intermediaries + .iter() + .map(|position| blind_spot_node(graph.nodes[*position])) + .collect::>(); + let direct_witness_count = evidence.direct_witnesses.len(); + Some(CommunityGap { + id: community_gap_id(&left_anchor, &right_anchor), + left_community: left, + right_community: right, + left_anchor, + right_anchor, + left_label, + right_label, + score, + shared_intermediary_count: evidence + .intermediaries + .len() + .saturating_add(evidence.omitted_intermediaries), + shared_intermediaries, + direct_topical_edge_count: evidence.direct_edges, + direct_topical_edges: evidence.direct_witnesses, + omitted_shared_intermediaries: evidence.omitted_intermediaries, + omitted_direct_topical_edges: evidence + .direct_edges + .saturating_sub(direct_witness_count), + }) + }) + .collect::>(); + let ( + disconnected_components, + disconnected_component_count, + largest_component_size, + omitted_disconnected_components, + omitted_component_members, + ) = disconnected_components_in(graph); + let (disconnected_components, omitted_disconnected_components, omitted_component_members) = + if disconnected_component_count > 1 { + ( + disconnected_components, + omitted_disconnected_components, + omitted_component_members, + ) + } else { + (Vec::new(), 0, 0) + }; + BlindSpotReport { + schema: GRAPH_INSIGHTS_SCHEMA.to_owned(), + community_gaps, + disconnected_components, + disconnected_component_count, + largest_component_size, + omissions: BlindSpotOmissions { + candidate_pair_limit_reached: pair_budget_exhausted, + community_gaps: total_ranked.saturating_sub(COMMUNITY_GAP_MAX_QUESTIONS), + disconnected_components: omitted_disconnected_components, + component_members: omitted_component_members, + }, + limits: BlindSpotLimits { + max_candidate_pairs: COMMUNITY_GAP_MAX_PAIRS, + max_community_gaps: COMMUNITY_GAP_MAX_QUESTIONS, + max_shared_intermediaries: BLIND_SPOT_MAX_WITNESSES, + max_direct_topical_edges: BLIND_SPOT_MAX_WITNESSES, + max_disconnected_components: BLIND_SPOT_MAX_COMPONENTS, + max_component_members: BLIND_SPOT_MAX_COMPONENT_MEMBERS, + }, + } +} + +#[must_use] +pub fn blind_spot_report( + document: &GraphDocument, + communities: &Communities, + community_labels: &BTreeMap, +) -> BlindSpotReport { + let graph = AnalysisGraph::new(document); + blind_spots_in(&graph, communities, community_labels) +} + +fn blind_spot_node(node: &NodeRecord) -> BlindSpotNode { + BlindSpotNode { + id: node.id.clone(), + label: bounded_analysis_label(node.label()), + source_file: attribute(node, "source_file") + .filter(|source| !source.is_empty()) + .map(str::to_owned), + } +} + +fn community_gap_anchor(graph: &AnalysisGraph<'_>, members: &[usize]) -> String { + members + .iter() + .copied() + .min_by(|left, right| graph.nodes[*left].id.cmp(&graph.nodes[*right].id)) + .map_or_else(String::new, |position| graph.nodes[position].id.clone()) +} + +fn community_gap_id(left_anchor: &str, right_anchor: &str) -> String { + let (left, right) = if left_anchor <= right_anchor { + (left_anchor, right_anchor) + } else { + (right_anchor, left_anchor) + }; + let mut input = Vec::with_capacity(left.len() + right.len() + 1); + input.extend_from_slice(left.as_bytes()); + input.push(0); + input.extend_from_slice(right.as_bytes()); + format!("community-gap-{:x}", Sha256::digest(input)) +} + +fn blind_spot_edge(graph: &AnalysisGraph<'_>, edge: &EdgeRecord) -> BlindSpotEdge { + let (source, target) = if graph.directed || edge.source <= edge.target { + (edge.source.clone(), edge.target.clone()) + } else { + (edge.target.clone(), edge.source.clone()) + }; + BlindSpotEdge { + source, + target, + relation: edge_string(edge, "relation"), + confidence: edge_string(edge, "confidence"), + } +} + +fn disconnected_components_in( + graph: &AnalysisGraph<'_>, +) -> (Vec, usize, usize, usize, usize) { + if graph.len() == 0 { + return (Vec::new(), 0, 0, 0, 0); + } + let adjacency = undirected_adjacency(graph); + let mut visited = vec![false; graph.len()]; + let mut component_count = 0_usize; + let mut largest_component = 0_usize; + let mut candidates = Vec::::new(); + for start in 0..graph.len() { + if visited[start] { + continue; + } + visited[start] = true; + let mut queue = VecDeque::from([start]); + let mut real_nodes = Vec::new(); + while let Some(node) = queue.pop_front() { + if is_community_gap_real_node(graph, node) { + real_nodes.push(node); + } + for neighbor in &adjacency[node] { + if !visited[*neighbor] { + visited[*neighbor] = true; + queue.push_back(*neighbor); + } + } + } + if real_nodes.len() < 2 { + continue; + } + real_nodes.sort_by(|left, right| graph.nodes[*left].id.cmp(&graph.nodes[*right].id)); + component_count = component_count.saturating_add(1); + largest_component = largest_component.max(real_nodes.len()); + let id = format!("component:{}", graph.nodes[real_nodes[0]].id); + let members = real_nodes + .iter() + .take(BLIND_SPOT_MAX_COMPONENT_MEMBERS) + .map(|position| blind_spot_node(graph.nodes[*position])) + .collect::>(); + let omitted = real_nodes.len().saturating_sub(members.len()); + let candidate = DisconnectedComponent { + id: id.clone(), + real_node_count: real_nodes.len(), + members, + omitted_members: omitted, + }; + candidates.push(candidate); + } + candidates.sort_by(|left, right| { + right + .real_node_count + .cmp(&left.real_node_count) + .then_with(|| left.id.cmp(&right.id)) + }); + let omitted_components = candidates.len().saturating_sub(BLIND_SPOT_MAX_COMPONENTS); + let retained_count = candidates.len().min(BLIND_SPOT_MAX_COMPONENTS); + let omitted_members = candidates + .iter() + .skip(retained_count) + .fold(0_usize, |total, component| { + total.saturating_add(component.real_node_count) + }) + .saturating_add( + candidates + .iter() + .take(retained_count) + .fold(0_usize, |total, component| { + total.saturating_add(component.omitted_members) + }), + ); + let components = candidates + .into_iter() + .take(BLIND_SPOT_MAX_COMPONENTS) + .collect::>(); + ( + components, + component_count, + largest_component, + omitted_components, + omitted_members, + ) +} + +fn community_gap_label( + community: usize, + community_labels: &BTreeMap, + graph: &AnalysisGraph<'_>, + members: &[usize], +) -> String { + if let Some(label) = community_labels.get(&community) + && !label.is_empty() + { + return bounded_analysis_label(label); + } + let representative = members.iter().copied().min_by(|left, right| { + graph + .degree(*right) + .cmp(&graph.degree(*left)) + .then_with(|| graph.nodes[*left].id.cmp(&graph.nodes[*right].id)) + }); + representative.map_or_else( + || format!("Community {community}"), + |position| bounded_analysis_label(graph.nodes[position].label()), + ) +} + +fn bounded_analysis_label(value: &str) -> String { + value.chars().take(ANALYSIS_LABEL_MAX_CHARS).collect() +} + +fn is_community_gap_real_node(graph: &AnalysisGraph<'_>, position: usize) -> bool { + !graph.is_file_node(position) + && !is_concept_node(graph.nodes[position]) + && !is_json_key_node(graph.nodes[position]) +} + +fn is_community_gap_wiring_relation(edge: &EdgeRecord) -> bool { + COMMUNITY_GAP_WIRING_RELATIONS.contains(&edge_string(edge, "relation").as_str()) +} + +fn undirected_adjacency(graph: &AnalysisGraph<'_>) -> Vec> { + let mut adjacency = vec![Vec::::new(); graph.len()]; + for edge in &graph.edges { + adjacency[edge.left].push(edge.right); + adjacency[edge.right].push(edge.left); + } + for neighbors in &mut adjacency { + neighbors.sort_unstable(); + neighbors.dedup(); + } + adjacency +} + #[must_use] pub fn graph_insights( document: &GraphDocument, @@ -402,8 +1054,28 @@ pub fn graph_insights( Vec, Vec, ) { + let insights = graph_insights_with_blind_spots( + document, + communities, + community_labels, + god_limit, + surprise_limit, + question_limit, + ); + (insights.gods, insights.surprises, insights.questions) +} + +#[must_use] +pub fn graph_insights_with_blind_spots( + document: &GraphDocument, + communities: &Communities, + community_labels: &BTreeMap, + god_limit: usize, + surprise_limit: usize, + question_limit: usize, +) -> GraphInsights { let graph = AnalysisGraph::new(document); - let (gods, (surprises, questions)) = rayon::join( + let (gods, (surprises, (questions, blind_spots))) = rayon::join( || god_nodes_in(&graph, god_limit), || { rayon::join( @@ -412,7 +1084,12 @@ pub fn graph_insights( ) }, ); - (gods, surprises, questions) + GraphInsights { + gods, + surprises, + questions, + blind_spots, + } } #[must_use] @@ -986,10 +1663,39 @@ struct AnalysisEdge<'a> { right: usize, record: &'a EdgeRecord, } + +fn sorted_relation_edges<'a>(graph: &'a AnalysisGraph<'a>) -> Vec<&'a AnalysisEdge<'a>> { + let mut edges = graph.relation_edges.iter().collect::>(); + edges.sort_by(|left, right| { + relation_edge_sort_key(graph, left).cmp(&relation_edge_sort_key(graph, right)) + }); + edges +} + +fn relation_edge_sort_key( + graph: &AnalysisGraph<'_>, + edge: &AnalysisEdge<'_>, +) -> (String, String, String, String) { + let source = edge.record.source.clone(); + let target = edge.record.target.clone(); + let (source, target) = if graph.directed || source <= target { + (source, target) + } else { + (target, source) + }; + ( + source, + target, + edge_string(edge.record, "relation"), + edge_string(edge.record, "confidence"), + ) +} + struct AnalysisGraph<'a> { nodes: Vec<&'a NodeRecord>, positions: HashMap<&'a str, usize>, edges: Vec>, + relation_edges: Vec>, adjacency: Vec>, degrees: Vec, directed: bool, @@ -1004,6 +1710,7 @@ impl<'a> AnalysisGraph<'a> { .map(|(index, node)| (node.id.as_str(), index)) .collect::>(); let mut edges = Vec::>::new(); + let mut relation_edges = Vec::>::new(); let mut edge_positions = HashMap::<(usize, usize), usize>::new(); let mut adjacency = vec![Vec::new(); nodes.len()]; let mut degrees = vec![0; nodes.len()]; @@ -1014,6 +1721,11 @@ impl<'a> AnalysisGraph<'a> { ) else { continue; }; + relation_edges.push(AnalysisEdge { + left: *left, + right: *right, + record, + }); let key = if document.directed || left <= right { (*left, *right) } else { @@ -1040,6 +1752,7 @@ impl<'a> AnalysisGraph<'a> { nodes, positions, edges, + relation_edges, adjacency, degrees, directed: document.directed, diff --git a/crates/compass-graph/src/lib.rs b/crates/compass-graph/src/lib.rs index d564e5d2..72b5fcb4 100644 --- a/crates/compass-graph/src/lib.rs +++ b/crates/compass-graph/src/lib.rs @@ -9,9 +9,11 @@ mod snapshot; mod v1; pub use analyze::{ - DiffEdge, DiffNode, GodNode, GraphDiff, ImportCycle, SuggestedQuestion, SurpriseConnection, - find_import_cycles, god_nodes, graph_diff, graph_insights, suggest_questions, - surprising_connections, + BlindSpotEdge, BlindSpotLimits, BlindSpotNode, BlindSpotOmissions, BlindSpotReport, + CommunityGap, DiffEdge, DiffNode, DisconnectedComponent, GRAPH_INSIGHTS_SCHEMA, GodNode, + GraphDiff, GraphInsights, ImportCycle, SuggestedQuestion, SurpriseConnection, + blind_spot_report, find_import_cycles, god_nodes, graph_diff, graph_insights, + graph_insights_with_blind_spots, suggest_questions, surprising_connections, }; pub use cluster::{ ClusterOptions, Communities, IncrementalClusterLimits, IncrementalClusterResult, cluster, diff --git a/crates/compass-graph/src/v1.rs b/crates/compass-graph/src/v1.rs index cce70712..b3ca2d2b 100644 --- a/crates/compass-graph/src/v1.rs +++ b/crates/compass-graph/src/v1.rs @@ -979,7 +979,7 @@ fn finalize_prepared_edge( } if matches!( edge.kind, - EdgeKind::Embeds | EdgeKind::Extends | EdgeKind::Implements + EdgeKind::Embeds | EdgeKind::Extends | EdgeKind::Implements | EdgeKind::MixesIn ) { let valid = match edge.kind { EdgeKind::Embeds | EdgeKind::Extends => source_kind.is_type() && target_kind.is_type(), @@ -996,6 +996,7 @@ fn finalize_prepared_edge( | NodeKind::TypeAlias ) } + EdgeKind::MixesIn => source_kind.is_type() && target_kind.is_type(), _ => false, }; if !valid { @@ -2265,7 +2266,8 @@ fn placeholder_scope_key( fn inferred_external_target_kind(attributes: &Map) -> Option<&'static str> { match optional_string(attributes, "relation").as_deref() { - Some("implements" | "scip_impl" | "mixes_in") => Some("interface"), + Some("implements" | "scip_impl") => Some("interface"), + Some("mixes_in") => Some("trait"), Some("extends" | "inherits") => Some("class"), Some("calls" | "indirect_call") => Some("function"), Some("type_of" | "returns" | "scip_typed") => Some("type_alias"), @@ -3506,9 +3508,10 @@ fn normalize_node( .unwrap_or("code"); let (kind, resource_kind) = map_node_kind(raw_kind, file_type) .ok_or_else(|| raw_error(&raw.id, "unknown raw node kind or file_type"))?; - let name = required_any_string(&raw.attributes, &["name", "label"], &raw.id)?; - let qualified_name = optional_any_string(&raw.attributes, &["qualified_name", "qualifiedName"]) - .unwrap_or_else(|| name.clone()); + let mut name = required_any_string(&raw.attributes, &["name", "label"], &raw.id)?; + let mut qualified_name = + optional_any_string(&raw.attributes, &["qualified_name", "qualifiedName"]) + .unwrap_or_else(|| name.clone()); let language = optional_any_string(&raw.attributes, &["language", "lang"]); let framework = optional_string(&raw.attributes, "framework"); let source = raw_anchor(&raw.attributes, root, file_facts)?; @@ -3538,6 +3541,15 @@ fn normalize_node( .transpose()? .unwrap_or_default(), }; + if kind == NodeKind::File && !source_path.is_empty() { + name = source_path + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(source_path.as_str()) + .to_owned(); + qualified_name.clone_from(&source_path); + } let roles = raw .attributes .get("roles") @@ -4141,6 +4153,7 @@ fn map_node_kind( "enum_member" | "enum_constant" => NodeKind::EnumMember, "type_alias" | "alias" | "type" => NodeKind::TypeAlias, "function" => NodeKind::Function, + "closure" => NodeKind::Closure, "method" | "destructor" => NodeKind::Method, "constructor" => NodeKind::Constructor, "property" => NodeKind::Property, @@ -4259,7 +4272,7 @@ fn map_edge_kind(raw: &str) -> Option<(EdgeKind, Option<&'static str>, bool)> { "configures" => (EdgeKind::DependsOn, None, false), "case_of" | "defines" | "method" => (EdgeKind::Contains, None, false), "embeds" => (EdgeKind::Embeds, Some("embedded-member"), false), - "mixes_in" => (EdgeKind::Implements, Some("mixin-contract"), false), + "mixes_in" => (EdgeKind::MixesIn, None, false), _ => return None, }; Some(mapped) @@ -4379,6 +4392,7 @@ fn node_details( | NodeKind::EnumMember | NodeKind::TypeAlias | NodeKind::Function + | NodeKind::Closure | NodeKind::Method | NodeKind::Constructor | NodeKind::Property diff --git a/crates/compass-graph/tests/analyze_coverage.rs b/crates/compass-graph/tests/analyze_coverage.rs index 3b9eb576..943358cb 100644 --- a/crates/compass-graph/tests/analyze_coverage.rs +++ b/crates/compass-graph/tests/analyze_coverage.rs @@ -2,16 +2,25 @@ use std::collections::BTreeMap; use std::error::Error; use compass_graph::{ - Communities, find_import_cycles, god_nodes, graph_diff, suggest_questions, + Communities, blind_spot_report, find_import_cycles, god_nodes, graph_diff, suggest_questions, surprising_connections, }; use compass_model::GraphDocument; use serde_json::{Value, json}; fn document(nodes: Vec, links: Vec, directed: bool) -> GraphDocument { + document_with_multigraph(nodes, links, directed, false) +} + +fn document_with_multigraph( + nodes: Vec, + links: Vec, + directed: bool, + multigraph: bool, +) -> GraphDocument { let parsed = serde_json::from_value(json!({ "directed":directed, - "multigraph":false, + "multigraph":multigraph, "graph":{}, "nodes":nodes, "links":links @@ -94,6 +103,232 @@ fn questions_cover_no_signal_isolation_inference_ambiguity_bridge_and_low_cohesi Ok(()) } +#[test] +fn questions_surface_structural_gaps_without_wiring_or_json_noise() -> Result<(), Box> { + let mut nodes = Vec::new(); + for (prefix, file_prefix) in [("a", "docs/a"), ("b", "docs/b")] { + for index in 0..4 { + nodes.push(node( + &format!("{prefix}{index}"), + &format!("{prefix}{index}"), + &format!("{file_prefix}{index}.md"), + )); + } + } + nodes.push(node("bridge", "Bridge", "docs/bridge.md")); + nodes.push(node("json-key", "dependencies", "config.json")); + let mut links = Vec::new(); + for prefix in ["a", "b"] { + for (left, right) in [(0, 1), (1, 2), (2, 3), (3, 0)] { + links.push(edge( + &format!("{prefix}{left}"), + &format!("{prefix}{right}"), + "calls", + "EXTRACTED", + )); + } + } + links.extend([ + edge("a0", "bridge", "references", "EXTRACTED"), + edge("b0", "bridge", "references", "EXTRACTED"), + // Structural wiring must not turn a gap into a topical connection. + edge("a1", "b1", "contains", "EXTRACTED"), + ]); + let graph = document(nodes, links, true); + let communities = BTreeMap::from([ + (0, (0..4).map(|index| format!("a{index}")).collect()), + (1, (0..4).map(|index| format!("b{index}")).collect()), + ]); + let labels = BTreeMap::from([(0, "Alpha".to_owned()), (1, "Beta".to_owned())]); + let first = suggest_questions(&graph, &communities, &labels, 20); + let second = suggest_questions(&graph, &communities, &labels, 20); + assert_eq!(first, second); + let gap = first + .iter() + .find(|question| question.kind == "community_gap") + .ok_or("missing structural gap question")?; + assert!( + gap.question + .as_deref() + .is_some_and(|question| { question.contains("Alpha") && question.contains("Beta") }) + ); + assert!(gap.why.contains("shared two-hop intermediaries")); + assert!(gap.why.contains("0 direct topical edges")); + + let mut noisy_nodes = Vec::new(); + for (prefix, file_prefix) in [("a", "docs/a"), ("b", "docs/b")] { + for index in 0..4 { + noisy_nodes.push(node( + &format!("{prefix}{index}"), + &format!("{prefix}{index}"), + &format!("{file_prefix}{index}.md"), + )); + } + } + noisy_nodes.push(node("json-key", "dependencies", "config.json")); + noisy_nodes.push(node("concept", "Shared Concept", "Concept")); + let mut noisy_links = Vec::new(); + for prefix in ["a", "b"] { + for (left, right) in [(0, 1), (1, 2), (2, 3), (3, 0)] { + noisy_links.push(edge( + &format!("{prefix}{left}"), + &format!("{prefix}{right}"), + "calls", + "EXTRACTED", + )); + } + } + noisy_links.extend([ + edge("a0", "json-key", "references", "EXTRACTED"), + edge("b0", "json-key", "references", "EXTRACTED"), + edge("a1", "concept", "references", "EXTRACTED"), + edge("b1", "concept", "references", "EXTRACTED"), + ]); + let noisy_graph = document(noisy_nodes, noisy_links, true); + assert!( + !suggest_questions(&noisy_graph, &communities, &labels, 20) + .iter() + .any(|question| question.kind == "community_gap") + ); + Ok(()) +} + +#[test] +fn typed_blind_spots_keep_witnesses_and_parallel_topical_relations() -> Result<(), Box> { + let mut nodes = Vec::new(); + for (prefix, file_prefix) in [("a", "docs/a"), ("b", "docs/b")] { + for index in 0..4 { + nodes.push(node( + &format!("{prefix}{index}"), + &format!("{prefix}{index}"), + &format!("{file_prefix}{index}.md"), + )); + } + } + nodes.push(node("bridge", "Bridge", "docs/bridge.md")); + let communities = BTreeMap::from([ + (0, (0..4).map(|index| format!("a{index}")).collect()), + (1, (0..4).map(|index| format!("b{index}")).collect()), + ]); + let labels = BTreeMap::from([(0, "Alpha".to_owned()), (1, "Beta".to_owned())]); + let mut links = Vec::new(); + for prefix in ["a", "b"] { + for (left, right) in [(0, 1), (1, 2), (2, 3), (3, 0)] { + links.push(edge( + &format!("{prefix}{left}"), + &format!("{prefix}{right}"), + "calls", + "EXTRACTED", + )); + } + } + links.extend([ + edge("a0", "bridge", "references", "EXTRACTED"), + edge("b0", "bridge", "references", "EXTRACTED"), + edge("a1", "b1", "contains", "EXTRACTED"), + edge("a1", "b1", "references", "EXTRACTED"), + ]); + let graph = document_with_multigraph(nodes.clone(), links.clone(), true, true); + let report = blind_spot_report(&graph, &communities, &labels); + assert_eq!(report.schema, "compass.graph-insights/1"); + let gap = report.community_gaps.first().ok_or("missing typed gap")?; + assert_eq!(gap.left_label, "Alpha"); + assert_eq!(gap.right_label, "Beta"); + assert!(gap.shared_intermediary_count >= 1); + assert!( + gap.shared_intermediaries + .iter() + .any(|witness| witness.id == "bridge") + ); + assert_eq!(gap.direct_topical_edge_count, 1); + assert_eq!(gap.direct_topical_edges[0].relation, "references"); + assert!(!gap.left_anchor.is_empty()); + assert!(!gap.right_anchor.is_empty()); + + links.reverse(); + let reversed = document_with_multigraph(nodes, links, true, true); + assert_eq!(report, blind_spot_report(&reversed, &communities, &labels)); + Ok(()) +} + +#[test] +fn blind_spot_questions_are_not_starved_by_older_question_categories() { + let mut nodes = Vec::new(); + for (prefix, file_prefix) in [("a", "docs/a"), ("b", "docs/b")] { + for index in 0..4 { + nodes.push(node( + &format!("{prefix}{index}"), + &format!("{prefix}{index}"), + &format!("{file_prefix}{index}.md"), + )); + } + } + nodes.push(node("bridge", "Bridge", "docs/bridge.md")); + nodes.push(node("orphan-a", "Orphan A", "docs/orphan-a.md")); + nodes.push(node("orphan-b", "Orphan B", "docs/orphan-b.md")); + let mut links = vec![edge("a0", "a1", "references", "AMBIGUOUS")]; + for prefix in ["a", "b"] { + for (left, right) in [(0, 1), (1, 2), (2, 3), (3, 0)] { + links.push(edge( + &format!("{prefix}{left}"), + &format!("{prefix}{right}"), + "calls", + "EXTRACTED", + )); + } + } + links.extend([ + edge("a0", "bridge", "references", "EXTRACTED"), + edge("b0", "bridge", "references", "EXTRACTED"), + edge("orphan-a", "orphan-b", "references", "EXTRACTED"), + ]); + let graph = document(nodes, links, true); + let communities = BTreeMap::from([ + (0, (0..4).map(|index| format!("a{index}")).collect()), + (1, (0..4).map(|index| format!("b{index}")).collect()), + ]); + let labels = BTreeMap::from([(0, "Alpha".to_owned()), (1, "Beta".to_owned())]); + let questions = suggest_questions(&graph, &communities, &labels, 1); + assert_eq!(questions[0].kind, "community_gap"); + let questions = suggest_questions(&graph, &communities, &labels, 2); + assert_eq!( + questions + .iter() + .map(|question| question.kind.as_str()) + .collect::>(), + ["community_gap", "disconnected_components"] + ); +} + +#[test] +fn questions_surface_disconnected_source_backed_components() -> Result<(), Box> { + let nodes = (0..6) + .map(|index| node(&format!("n{index}"), &format!("N{index}"), "src/module.rs")) + .collect(); + let links = [ + edge("n0", "n1", "calls", "EXTRACTED"), + edge("n1", "n2", "calls", "EXTRACTED"), + edge("n2", "n0", "calls", "EXTRACTED"), + edge("n3", "n4", "calls", "EXTRACTED"), + edge("n4", "n5", "calls", "EXTRACTED"), + edge("n5", "n3", "calls", "EXTRACTED"), + ]; + let graph = document(nodes, links.to_vec(), true); + let questions = suggest_questions(&graph, &Communities::new(), &BTreeMap::new(), 20); + let disconnected = questions + .iter() + .find(|question| question.kind == "disconnected_components") + .ok_or("missing disconnected component question")?; + assert!( + disconnected + .question + .as_deref() + .is_some_and(|question| question.contains("2 disconnected")) + ); + assert!(disconnected.why.contains("largest has 3 real nodes")); + Ok(()) +} + #[test] fn surprises_cover_cross_file_scoring_cross_community_and_structural_fallbacks() { let mut nodes = vec![ diff --git a/crates/compass-graph/tests/graph_v1_normalization.rs b/crates/compass-graph/tests/graph_v1_normalization.rs index 5e5ee437..3628bb8a 100644 --- a/crates/compass-graph/tests/graph_v1_normalization.rs +++ b/crates/compass-graph/tests/graph_v1_normalization.rs @@ -115,6 +115,37 @@ fn raw_file_node(root: &Path, id: &str, relative: &str) -> RawNodeRecord { } } +#[test] +fn file_nodes_canonicalize_extension_preserving_identity_before_coalescing() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + let root = directory.path(); + let detected = raw_file_node(root, "raw:detected", "src/lib.rs"); + let mut universal = raw_file_node(root, "raw:universal", "src/lib.rs"); + universal + .attributes + .insert("label".to_owned(), json!("lib")); + universal + .attributes + .insert("qualified_name".to_owned(), json!("lib")); + + let outcome = normalize_v1_best_effort( + Extraction { + nodes: vec![detected, universal], + ..Extraction::default() + }, + build_evidence(root)?, + )?; + + assert_eq!(outcome.omissions.identity_collisions, 0); + assert_eq!(outcome.omissions.nodes, 0); + assert_eq!(outcome.document.nodes.len(), 1); + assert_eq!(outcome.document.nodes[0].kind, NodeKind::File); + assert_eq!(outcome.document.nodes[0].name, "lib.rs"); + assert_eq!(outcome.document.nodes[0].qualified_name, "src/lib.rs"); + Ok(()) +} + #[test] fn node_navigation_extent_preserves_and_contains_exact_provenance() -> Result<(), Box> { diff --git a/crates/compass-history/src/artifacts.rs b/crates/compass-history/src/artifacts.rs index 920e2ade..df9dbeba 100644 --- a/crates/compass-history/src/artifacts.rs +++ b/crates/compass-history/src/artifacts.rs @@ -1618,7 +1618,7 @@ fn sort_unique(entries: &mut [(Vec, Vec)], kind: &str) -> Result<(), His } } -fn analysis_key(parts: &[&[u8]]) -> Vec { +pub(crate) fn analysis_key(parts: &[&[u8]]) -> Vec { parts .iter() .fold( diff --git a/crates/compass-history/src/blind_spots.rs b/crates/compass-history/src/blind_spots.rs new file mode 100644 index 00000000..0d28524c --- /dev/null +++ b/crates/compass-history/src/blind_spots.rs @@ -0,0 +1,320 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::HistoryError; + +pub const BLIND_SPOT_HISTORY_SCHEMA: &str = "compass.graph-blind-spot-history/1"; +const MAX_TREND_ITEMS: usize = 4_096; +const MAX_REPORT_ID_BYTES: usize = 4_096; +const MAX_CANDIDATE_PAIRS: usize = 200_000; +const MAX_COMMUNITY_GAPS: usize = 3; +const MAX_WITNESSES: usize = 8; +const MAX_COMPONENTS: usize = 32; +const MAX_COMPONENT_MEMBERS: usize = 64; + +#[derive(Clone, Debug)] +pub struct BlindSpotObservation { + pub commit: String, + pub authored_at_seconds: i64, + pub report: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlindSpotTrend { + pub schema: String, + pub observation_count: usize, + pub observations_with_graph_insights: usize, + pub first_commit: Option, + pub last_commit: Option, + pub active: Vec, + pub resolved: Vec, + pub omissions: BlindSpotTrendOmissions, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlindSpotTrendItem { + pub id: String, + pub kind: String, + pub first_commit: String, + pub last_commit: String, + pub first_authored_at_seconds: i64, + pub last_authored_at_seconds: i64, + pub observation_count: usize, + pub active: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlindSpotTrendOmissions { + pub items: usize, + pub observations_without_graph_insights: usize, +} + +#[derive(Clone, Debug)] +struct TrendAccumulator { + kind: String, + first_commit: String, + last_commit: String, + first_authored_at_seconds: i64, + last_authored_at_seconds: i64, + observation_count: usize, +} + +/// Summarize exact blind-spot IDs across an ordered, bounded history slice. +/// Missing graph-insights sidecars are expected for older realizations and are +/// counted explicitly rather than being treated as an empty observation. +pub fn summarize_blind_spots( + observations: &[BlindSpotObservation], +) -> Result { + let mut items = BTreeMap::::new(); + let mut observations_with_graph_insights = 0_usize; + let mut observations_without_graph_insights = 0_usize; + for observation in observations { + let Some(report) = observation.report.as_ref() else { + observations_without_graph_insights = + observations_without_graph_insights.saturating_add(1); + continue; + }; + let ids = parse_report_ids(report)?; + observations_with_graph_insights = observations_with_graph_insights.saturating_add(1); + for (id, kind) in ids { + if let Some(existing) = items.get(&id) + && existing.kind != kind + { + return Err(HistoryError::InvalidArtifacts(format!( + "blind-spot ID {id} changed kind from {} to {kind}", + existing.kind + ))); + } + let entry = items.entry(id).or_insert_with(|| TrendAccumulator { + kind: kind.clone(), + first_commit: observation.commit.clone(), + last_commit: observation.commit.clone(), + first_authored_at_seconds: observation.authored_at_seconds, + last_authored_at_seconds: observation.authored_at_seconds, + observation_count: 0, + }); + entry.last_commit.clone_from(&observation.commit); + entry.last_authored_at_seconds = observation.authored_at_seconds; + entry.observation_count = entry.observation_count.saturating_add(1); + } + } + // A realization may predate the graph-insights sidecar. In that case the + // latest observation with a valid sidecar is the newest graph we can + // compare; an older realization must not be marked resolved merely + // because a newer observation has no sidecar. + let latest_graph_insights_commit = observations.iter().rev().find_map(|observation| { + observation + .report + .as_ref() + .map(|_| observation.commit.as_str()) + }); + let total_items = items.len(); + let mut active = Vec::new(); + let mut resolved = Vec::new(); + for (id, item) in items.into_iter().take(MAX_TREND_ITEMS) { + let is_active = latest_graph_insights_commit == Some(item.last_commit.as_str()); + let trend = BlindSpotTrendItem { + id, + kind: item.kind, + first_commit: item.first_commit, + last_commit: item.last_commit, + first_authored_at_seconds: item.first_authored_at_seconds, + last_authored_at_seconds: item.last_authored_at_seconds, + observation_count: item.observation_count, + active: is_active, + }; + if is_active { + active.push(trend); + } else { + resolved.push(trend); + } + } + active.sort_by(|left, right| left.id.cmp(&right.id)); + resolved.sort_by(|left, right| { + right + .last_authored_at_seconds + .cmp(&left.last_authored_at_seconds) + .then_with(|| left.id.cmp(&right.id)) + }); + let omitted_items = total_items.saturating_sub(MAX_TREND_ITEMS); + Ok(BlindSpotTrend { + schema: BLIND_SPOT_HISTORY_SCHEMA.to_owned(), + observation_count: observations.len(), + observations_with_graph_insights, + first_commit: observations + .first() + .map(|observation| observation.commit.clone()), + last_commit: observations + .last() + .map(|observation| observation.commit.clone()), + active, + resolved, + omissions: BlindSpotTrendOmissions { + items: omitted_items, + observations_without_graph_insights, + }, + }) +} + +fn parse_report_ids(report: &Value) -> Result, HistoryError> { + let schema = report + .get("schema") + .and_then(Value::as_str) + .ok_or_else(|| { + HistoryError::InvalidArtifacts("blind-spot report has no schema".to_owned()) + })?; + if schema != "compass.graph-insights/1" { + return Err(HistoryError::InvalidArtifacts(format!( + "unsupported blind-spot report schema {schema}" + ))); + } + let limits = report + .get("limits") + .and_then(Value::as_object) + .ok_or_else(|| { + HistoryError::InvalidArtifacts("blind-spot report has no limits".to_owned()) + })?; + for (field, expected) in [ + ("maxCandidatePairs", MAX_CANDIDATE_PAIRS), + ("maxCommunityGaps", MAX_COMMUNITY_GAPS), + ("maxSharedIntermediaries", MAX_WITNESSES), + ("maxDirectTopicalEdges", MAX_WITNESSES), + ("maxDisconnectedComponents", MAX_COMPONENTS), + ("maxComponentMembers", MAX_COMPONENT_MEMBERS), + ] { + let actual = limits.get(field).and_then(Value::as_u64).ok_or_else(|| { + HistoryError::InvalidArtifacts(format!("blind-spot limits has no {field}")) + })?; + if actual != expected as u64 { + return Err(HistoryError::InvalidArtifacts(format!( + "blind-spot limit {field} is {actual}, expected {expected}" + ))); + } + } + let mut ids = BTreeSet::new(); + for (field, kind) in [ + ("communityGaps", "community_gap"), + ("disconnectedComponents", "disconnected_component"), + ] { + let values = report.get(field).and_then(Value::as_array).ok_or_else(|| { + HistoryError::InvalidArtifacts(format!("blind-spot report has no {field} array")) + })?; + let maximum = if field == "communityGaps" { + MAX_COMMUNITY_GAPS + } else { + MAX_COMPONENTS + }; + if values.len() > maximum { + return Err(HistoryError::InvalidArtifacts(format!( + "blind-spot {field} exceeds limit {maximum}" + ))); + } + for value in values { + let id = value + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + HistoryError::InvalidArtifacts(format!("blind-spot {kind} has no ID")) + })?; + if id.len() > MAX_REPORT_ID_BYTES { + return Err(HistoryError::InvalidArtifacts(format!( + "blind-spot {kind} ID exceeds byte limit" + ))); + } + ids.insert((id.to_owned(), kind.to_owned())); + } + } + Ok(ids) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn observation(commit: &str, report: Option) -> BlindSpotObservation { + BlindSpotObservation { + commit: commit.to_owned(), + authored_at_seconds: commit.as_bytes()[0] as i64, + report, + } + } + + fn report(gaps: &[&str], components: &[&str]) -> Value { + json!({ + "schema": "compass.graph-insights/1", + "communityGaps": gaps.iter().map(|id| json!({"id": id})).collect::>(), + "disconnectedComponents": components.iter().map(|id| json!({"id": id})).collect::>(), + "limits": { + "maxCandidatePairs": 200000, + "maxCommunityGaps": 3, + "maxSharedIntermediaries": 8, + "maxDirectTopicalEdges": 8, + "maxDisconnectedComponents": 32, + "maxComponentMembers": 64, + }, + }) + } + + #[test] + fn summarizes_active_and_resolved_ids_deterministically() { + let result = summarize_blind_spots(&[ + observation("a", Some(report(&["gap-a"], &[]))), + observation("b", Some(report(&["gap-a"], &["component:b"]))), + observation("c", Some(report(&[], &["component:b"]))), + ]); + assert!(result.is_ok(), "valid trend: {:?}", result.as_ref().err()); + let Some(trend) = result.ok() else { + return; + }; + + assert_eq!(trend.observation_count, 3); + assert_eq!(trend.observations_with_graph_insights, 3); + assert_eq!(trend.active.len(), 1); + assert_eq!(trend.active[0].id, "component:b"); + assert_eq!(trend.resolved.len(), 1); + assert_eq!(trend.resolved[0].id, "gap-a"); + assert!(!trend.resolved[0].active); + assert_eq!(trend.resolved[0].observation_count, 2); + } + + #[test] + fn missing_latest_sidecar_does_not_fake_resolution() { + let result = summarize_blind_spots(&[ + observation("a", Some(report(&["gap-a"], &[]))), + observation("b", None), + ]); + assert!(result.is_ok(), "valid trend: {:?}", result.as_ref().err()); + let Some(trend) = result.ok() else { + return; + }; + + assert_eq!(trend.omissions.observations_without_graph_insights, 1); + assert_eq!( + trend + .active + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["gap-a"] + ); + assert!(trend.resolved.is_empty()); + } + + #[test] + fn rejects_unsupported_schema_and_kind_changes() { + let mut unsupported = report(&["gap-a"], &[]); + unsupported["schema"] = json!("compass.graph-insights/2"); + assert!(summarize_blind_spots(&[observation("a", Some(unsupported))]).is_err()); + + let mut mismatch = report(&["same"], &[]); + mismatch["disconnectedComponents"] = json!([{"id": "same"}]); + assert!(summarize_blind_spots(&[observation("a", Some(mismatch))]).is_err()); + } +} diff --git a/crates/compass-history/src/lib.rs b/crates/compass-history/src/lib.rs index e18dc6c1..b264435d 100644 --- a/crates/compass-history/src/lib.rs +++ b/crates/compass-history/src/lib.rs @@ -1,6 +1,7 @@ //! Immutable, SQLite-backed version history for complete Compass graphs. mod artifacts; +mod blind_spots; mod cache; mod canonical; mod config; @@ -22,6 +23,10 @@ mod timeline; mod validate; pub use artifacts::{CompletedGraphArtifacts, GraphArtifacts, PartitionedGraph}; +pub use blind_spots::{ + BLIND_SPOT_HISTORY_SCHEMA, BlindSpotObservation, BlindSpotTrend, BlindSpotTrendItem, + BlindSpotTrendOmissions, summarize_blind_spots, +}; pub use cache::{ CacheGcPlan, CacheNamespaceStatus, CacheStatus, DerivedCacheNamespace, HISTORY_CACHE_VERSION, HistoryCache, diff --git a/crates/compass-history/src/reader.rs b/crates/compass-history/src/reader.rs index d90b5e9b..fe98aefb 100644 --- a/crates/compass-history/src/reader.rs +++ b/crates/compass-history/src/reader.rs @@ -91,6 +91,24 @@ impl RealizationReader<'_> { }) } + /// Read the immutable analysis sidecar retained by this realization. + pub fn analysis_json(&self) -> Result, HistoryError> { + let tree = self.tree(&self.published.version.analysis_root); + let key = crate::artifacts::analysis_key(&[b"sidecar", b"analysis.json"]); + let Some(bytes) = self.prolly.get(&tree, &key)? else { + return Ok(None); + }; + if bytes.len() > crate::MAX_RECORD_VALUE_BYTES { + return Err(HistoryError::CorruptHistory( + "historical analysis sidecar exceeds byte limit".to_owned(), + )); + } + Ok(Some(crate::artifacts::decode_typed( + &bytes, + "compass.analysis.sidecar", + )?)) + } + pub fn read(&self, key: HistoryRecordKey<'_>) -> Result, HistoryError> { let owned = OwnedHistoryRecordKey::from(key); if let Some(value) = self.records.borrow().get(&owned) { diff --git a/crates/compass-mcp/src/lib.rs b/crates/compass-mcp/src/lib.rs index a68c3d1a..b69a1617 100644 --- a/crates/compass-mcp/src/lib.rs +++ b/crates/compass-mcp/src/lib.rs @@ -15,7 +15,9 @@ use std::time::SystemTime; use std::time::{Duration, Instant}; use compass_core::LoadedGraph; -use compass_graph::{Communities, god_nodes, suggest_questions, surprising_connections}; +use compass_graph::{ + Communities, blind_spot_report, god_nodes, suggest_questions, surprising_connections, +}; use compass_model::code_graph::GraphDocument as CodeGraphDocument; use compass_model::query_contract::{ MAX_DISCOVERY_CANDIDATES, MAX_DISCOVERY_DEPTH, MAX_DISCOVERY_EDGES, @@ -395,6 +397,7 @@ impl ServerHandler for CompassMcp { let mime = match request.uri.as_str() { "compass://report" => "text/markdown", "compass://orientation" => "application/json", + "compass://graph-insights" => "application/json", _ => "text/plain", }; let required_bytes = text.len(); @@ -1206,6 +1209,12 @@ fn resource_specs() -> Vec { "Suggested questions for this codebase", "text/plain", ), + ( + "compass://graph-insights", + "Graph Insights", + "Typed structural gaps, disconnected components, witnesses, and limits", + "application/json", + ), ] .into_iter() .map(|(uri, name, description, mime)| { @@ -2002,13 +2011,36 @@ fn read_resource_text(uri: &str, context: &GraphContext) -> Result { + let document = context.document().map_err(InvocationError::InvalidParams)?; + let labels_path = context + .path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("labels.json"); + let labels = fs::read(&labels_path) + .ok() + .and_then(|bytes| serde_json::from_slice::>(&bytes).ok()) + .unwrap_or_else(|| { + context + .communities + .keys() + .map(|community| (*community, format!("Community {community}"))) + .collect() + }); + let report = blind_spot_report(&document, &context.community_ids(), &labels); + serde_json::to_string_pretty(&report) + .map_err(|error| InvocationError::Internal(error.to_string())) + } _ => Err(InvocationError::InvalidParams(format!( "Unknown resource: {uri}" ))), @@ -2187,7 +2219,7 @@ mod tests { assert_eq!(spec.input_schema["additionalProperties"], false); assert_eq!(spec.input_schema["required"], required); } - assert_eq!(CompassMcp::resources().len(), 7); + assert_eq!(CompassMcp::resources().len(), 8); let text = server.invoke("graph_stats", Map::new()); assert_eq!( text, @@ -2499,6 +2531,7 @@ mod tests { "compass://surprises", "compass://audit", "compass://questions", + "compass://graph-insights", ] { assert!(!server.read(uri)?.is_empty(), "{uri}"); } @@ -2590,7 +2623,7 @@ mod tests { ); assert_eq!( empty.read("compass://questions")?, - "Suggested questions:\n - " + "Suggested questions:\n - [no_signal] Not enough signal to generate questions. This usually means the corpus has no AMBIGUOUS edges, no bridge nodes, no INFERRED relationships, and all communities are tightly cohesive. Add more files or run with --mode deep to extract richer edges." ); assert_eq!( empty.invoke("god_nodes", Map::new()), diff --git a/crates/compass-mcp/tests/code_query_tools.rs b/crates/compass-mcp/tests/code_query_tools.rs index 2dc79ada..117a29a2 100644 --- a/crates/compass-mcp/tests/code_query_tools.rs +++ b/crates/compass-mcp/tests/code_query_tools.rs @@ -187,7 +187,7 @@ fn code_query_tools_share_the_bounded_versioned_contract() -> Result<(), Box Resul assert_eq!(typed.links.len(), 2); let server = CompassMcp::new(output.join("graph.json")); let orientation: Value = serde_json::from_str(&server.read("compass://orientation")?)?; - assert_eq!(orientation["schema"], "compass.orientation/1"); + assert_eq!(orientation["schema"], "compass.orientation/2"); assert!(orientation["evidenceStatus"]["buildCommit"].is_null()); assert_eq!(orientation["graphSummary"]["edges"], 2); let report = server.read("compass://report")?; diff --git a/crates/compass-mcp/tests/coverage_paths.rs b/crates/compass-mcp/tests/coverage_paths.rs index 22f1134a..2428049c 100644 --- a/crates/compass-mcp/tests/coverage_paths.rs +++ b/crates/compass-mcp/tests/coverage_paths.rs @@ -50,14 +50,14 @@ fn tool_contract_and_all_local_tools_cover_success_and_validation_paths() let info = server.get_info(); assert_eq!(info.server_info.name, "compass"); - assert_eq!(CompassMcp::tools().len(), 16); + assert_eq!(CompassMcp::tools().len(), 18); assert!(CompassMcp::tools().iter().all(|tool| { tool.input_schema .get("properties") .and_then(Value::as_object) .is_some_and(|properties| properties.contains_key("project_path")) })); - assert_eq!(CompassMcp::resources().len(), 7); + assert_eq!(CompassMcp::resources().len(), 8); assert!( server @@ -218,6 +218,8 @@ fn resources_and_hot_reload_cover_reports_analysis_and_cache_refresh() -> Result assert!(server.read("compass://audit")?.contains("Total edges: 3")); assert!(!server.read("compass://surprises")?.is_empty()); assert!(!server.read("compass://questions")?.is_empty()); + let insights: Value = serde_json::from_str(&server.read("compass://graph-insights")?)?; + assert_eq!(insights["schema"], "compass.graph-insights/1"); assert!(server.read("compass://unknown").is_err()); fs::write( @@ -296,9 +298,9 @@ async fn in_memory_protocol_exercises_tool_and_resource_server_handlers() let client = ().serve(client_transport).await?; let tools = client.list_tools(None).await?; - assert_eq!(tools.tools.len(), 16); + assert_eq!(tools.tools.len(), 18); let resources = client.list_resources(None).await?; - assert_eq!(resources.resources.len(), 7); + assert_eq!(resources.resources.len(), 8); let call = client .call_tool(CallToolRequestParams::new("graph_stats")) diff --git a/crates/compass-model/src/code_graph.rs b/crates/compass-model/src/code_graph.rs index 1103b152..0ee9e025 100644 --- a/crates/compass-model/src/code_graph.rs +++ b/crates/compass-model/src/code_graph.rs @@ -31,6 +31,7 @@ pub enum NodeKind { EnumMember, TypeAlias, Function, + Closure, Method, Constructor, Property, @@ -82,6 +83,7 @@ impl NodeKind { Self::EnumMember => "enum_member", Self::TypeAlias => "type_alias", Self::Function => "function", + Self::Closure => "closure", Self::Method => "method", Self::Constructor => "constructor", Self::Property => "property", @@ -121,7 +123,11 @@ impl NodeKind { pub const fn is_callable(self) -> bool { matches!( self, - Self::Function | Self::Method | Self::Constructor | Self::DatabaseProcedure + Self::Function + | Self::Closure + | Self::Method + | Self::Constructor + | Self::DatabaseProcedure ) } @@ -207,6 +213,7 @@ pub enum EdgeKind { Exports, Extends, Implements, + MixesIn, References, TypeOf, Returns, @@ -242,6 +249,7 @@ impl EdgeKind { Self::Exports => "exports", Self::Extends => "extends", Self::Implements => "implements", + Self::MixesIn => "mixes_in", Self::References => "references", Self::TypeOf => "type_of", Self::Returns => "returns", diff --git a/crates/compass-model/src/validation.rs b/crates/compass-model/src/validation.rs index cea6e0d3..48d90ac1 100644 --- a/crates/compass-model/src/validation.rs +++ b/crates/compass-model/src/validation.rs @@ -531,6 +531,7 @@ fn details_match_kind(kind: NodeKind, details: Option<&NodeDetails>) -> bool { | NodeKind::EnumMember | NodeKind::TypeAlias | NodeKind::Function + | NodeKind::Closure | NodeKind::Method | NodeKind::Constructor | NodeKind::Property @@ -623,6 +624,7 @@ fn endpoint_kinds_are_valid( | NodeKind::TypeAlias ) } + EdgeKind::MixesIn => source.kind.is_type() && target.kind.is_type(), EdgeKind::TypeOf => { is_typed_value(source.kind) && (target.kind.is_type() || target.kind == NodeKind::Parameter) @@ -698,6 +700,7 @@ fn endpoint_kinds_are_valid( matches!( source.kind, NodeKind::Function + | NodeKind::Closure | NodeKind::Method | NodeKind::Component | NodeKind::Job @@ -712,6 +715,7 @@ fn endpoint_kinds_are_valid( && matches!( target.kind, NodeKind::Function + | NodeKind::Closure | NodeKind::Method | NodeKind::Job | NodeKind::Event @@ -724,7 +728,11 @@ fn endpoint_kinds_are_valid( EdgeKind::Tests => { matches!( source.kind, - NodeKind::File | NodeKind::Function | NodeKind::Method | NodeKind::Class + NodeKind::File + | NodeKind::Function + | NodeKind::Closure + | NodeKind::Method + | NodeKind::Class ) && source.roles.contains(&NodeRole::Test) && is_test_target(target.kind) } @@ -767,6 +775,7 @@ const fn contains_endpoint_pair(source: NodeKind, target: NodeKind) -> bool { | NodeKind::Enum | NodeKind::TypeAlias | NodeKind::Function + | NodeKind::Closure | NodeKind::Method | NodeKind::Constructor | NodeKind::Property @@ -805,6 +814,7 @@ const fn contains_endpoint_pair(source: NodeKind, target: NodeKind) -> bool { | NodeKind::Enum | NodeKind::TypeAlias | NodeKind::Function + | NodeKind::Closure | NodeKind::Method | NodeKind::Constructor | NodeKind::Property @@ -846,6 +856,7 @@ const fn contains_endpoint_pair(source: NodeKind, target: NodeKind) -> bool { | NodeKind::Enum | NodeKind::TypeAlias | NodeKind::Function + | NodeKind::Closure | NodeKind::Method | NodeKind::Constructor | NodeKind::Property @@ -857,7 +868,11 @@ const fn contains_endpoint_pair(source: NodeKind, target: NodeKind) -> bool { | NodeKind::Annotation | NodeKind::Component ) | ( - NodeKind::Function | NodeKind::Method | NodeKind::Constructor | NodeKind::TypeAlias, + NodeKind::Function + | NodeKind::Closure + | NodeKind::Method + | NodeKind::Constructor + | NodeKind::TypeAlias, NodeKind::Class | NodeKind::Struct | NodeKind::Interface @@ -866,6 +881,7 @@ const fn contains_endpoint_pair(source: NodeKind, target: NodeKind) -> bool { | NodeKind::Enum | NodeKind::TypeAlias | NodeKind::Function + | NodeKind::Closure | NodeKind::Method | NodeKind::Constructor | NodeKind::Property diff --git a/crates/compass-model/tests/code_graph_v1.rs b/crates/compass-model/tests/code_graph_v1.rs index 75b16b29..2e32dc75 100644 --- a/crates/compass-model/tests/code_graph_v1.rs +++ b/crates/compass-model/tests/code_graph_v1.rs @@ -26,6 +26,7 @@ fn v1_vocabularies_serialize_to_the_closed_contract() -> Result<(), Box Result<(), Box) -> Result<(), Outpu total_words: 0, warning: None, }; - let report = generate_report( + let blind_spots = + if let Some(value) = input.analysis.and_then(|value| value.get("blindSpots")) { + let report = serde_json::from_value::(value.clone())?; + validate_blind_spot_report(&report)?; + report + } else { + blind_spot_report(input.document, &communities, &labels) + }; + let report = generate_report_with_blind_spots( input.document, &communities, &BTreeMap::new(), @@ -151,6 +160,7 @@ fn render_v1(staging: &Path, input: &HistoryBundleInput<'_>) -> Result<(), Outpu &labels, 10, )), + Some(&blind_spots), None, &options, ); diff --git a/crates/compass-output/src/lib.rs b/crates/compass-output/src/lib.rs index cc836c8b..8271fbaf 100644 --- a/crates/compass-output/src/lib.rs +++ b/crates/compass-output/src/lib.rs @@ -59,9 +59,10 @@ pub use report::{ OrientationLearnedQuestion, OrientationNodeReference, OrientationOmissions, OrientationPublicationDiagnostic, OrientationQuery, OrientationRisk, OrientationSourceAnchor, OrientationWorkMemory, PublicationStatus, REPORT_MARKDOWN_MAX_CHARS, ReportOptions, - SectionOmission, TokenCost, WorkingTreeState, agent_orientation, generate_report, + SectionOmission, TokenCost, WorkingTreeState, agent_orientation, + agent_orientation_with_blind_spots, generate_report, generate_report_with_blind_spots, graph_artifact_identity, render_agent_report_markdown, render_orientation_json, - render_orientation_markdown, validate_orientation_graph_identity, + render_orientation_markdown, validate_blind_spot_report, validate_orientation_graph_identity, }; pub use review::{ MAX_REVIEW_RENDER_BYTES, RenderedReview, render_readiness_json, render_readiness_markdown, diff --git a/crates/compass-output/src/report.rs b/crates/compass-output/src/report.rs index 1a76ac87..fd1de200 100644 --- a/crates/compass-output/src/report.rs +++ b/crates/compass-output/src/report.rs @@ -4,7 +4,8 @@ use std::io::Read; use std::path::Path; use compass_graph::{ - Communities, GodNode, SuggestedQuestion, SurpriseConnection, find_import_cycles, + BlindSpotEdge, BlindSpotNode, BlindSpotReport, Communities, GRAPH_INSIGHTS_SCHEMA, GodNode, + SuggestedQuestion, SurpriseConnection, find_import_cycles, }; use compass_model::{EdgeRecord, GraphDocument, NodeRecord}; use serde::{Deserialize, Serialize}; @@ -13,7 +14,7 @@ use sha2::{Digest, Sha256}; use crate::OutputError; -pub const ORIENTATION_SCHEMA: &str = "compass.orientation/1"; +pub const ORIENTATION_SCHEMA: &str = "compass.orientation/2"; pub const ORIENTATION_MARKDOWN_MAX_CHARS: usize = 16_000; pub const REPORT_MARKDOWN_MAX_CHARS: usize = 256_000; pub const ORIENTATION_JSON_MAX_BYTES: usize = 4 * 1024 * 1024; @@ -153,6 +154,8 @@ pub struct AgentOrientation { pub risks: Vec, pub suggested_queries: Vec, pub learned_questions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blind_spots: Option, pub details: OrientationDetails, pub omissions: OrientationOmissions, } @@ -558,6 +561,7 @@ pub fn agent_orientation( risks, suggested_queries: queries, learned_questions, + blind_spots: None, details, }; sanitize_orientation_model(&mut model); @@ -567,6 +571,43 @@ pub fn agent_orientation( model } +#[allow(clippy::too_many_arguments)] +#[must_use] +pub fn agent_orientation_with_blind_spots( + document: &GraphDocument, + communities: &Communities, + cohesion_scores: &BTreeMap, + community_labels: &BTreeMap, + god_node_list: &[GodNode], + surprise_list: &[SurpriseConnection], + detection: &DetectionSummary, + token_cost: TokenCost, + suggested_questions: Option<&[SuggestedQuestion]>, + blind_spots: Option<&BlindSpotReport>, + learning: Option<&Value>, + options: &ReportOptions<'_>, +) -> AgentOrientation { + let mut model = agent_orientation( + document, + communities, + cohesion_scores, + community_labels, + god_node_list, + surprise_list, + detection, + token_cost, + suggested_questions, + learning, + options, + ); + model.blind_spots = blind_spots.cloned(); + sanitize_orientation_model(&mut model); + fit_orientation_json_budget(&mut model); + fit_orientation_budget(&mut model); + fit_report_budget(&mut model, options.obsidian); + model +} + pub fn render_orientation_json(model: &AgentOrientation) -> Result { validate_orientation_model(model)?; Ok(serde_json::to_string_pretty(model)?) @@ -675,6 +716,19 @@ pub fn render_agent_report_markdown( Ok(rendered) } +/// Validate a persisted structural-blind-spot projection before rendering it. +/// Unknown schema versions and malformed bounded evidence must fail explicitly; +/// they must not silently turn into an empty historical report. +pub fn validate_blind_spot_report(report: &BlindSpotReport) -> Result<(), OutputError> { + if blind_spot_report_is_safe(report) { + Ok(()) + } else { + Err(OutputError::InvalidOrientationModel { + reason: "invalid graph-insights report", + }) + } +} + fn validate_orientation_model(model: &AgentOrientation) -> Result<(), OutputError> { if model.schema != ORIENTATION_SCHEMA { return Err(OutputError::InvalidOrientationModel { @@ -688,6 +742,10 @@ fn validate_orientation_model(model: &AgentOrientation) -> Result<(), OutputErro && model.risks.len() <= RISK_LIMIT && model.suggested_queries.len() <= QUERY_LIMIT && model.learned_questions.len() <= QUERY_LIMIT + && model + .blind_spots + .as_ref() + .is_none_or(blind_spot_report_is_safe) && model.details.surprising_connections.len() <= DETAIL_LIMIT && model.details.import_cycles.len() <= DETAIL_LIMIT && model.details.hyperedges.len() <= DETAIL_LIMIT @@ -896,6 +954,39 @@ pub fn generate_report( render_report_markdown(&model, options.obsidian) } +#[allow(clippy::too_many_arguments)] +#[must_use] +pub fn generate_report_with_blind_spots( + document: &GraphDocument, + communities: &Communities, + cohesion_scores: &BTreeMap, + community_labels: &BTreeMap, + god_node_list: &[GodNode], + surprise_list: &[SurpriseConnection], + detection: &DetectionSummary, + token_cost: TokenCost, + suggested_questions: Option<&[SuggestedQuestion]>, + blind_spots: Option<&BlindSpotReport>, + learning: Option<&Value>, + options: &ReportOptions<'_>, +) -> String { + let model = agent_orientation_with_blind_spots( + document, + communities, + cohesion_scores, + community_labels, + god_node_list, + surprise_list, + detection, + token_cost, + suggested_questions, + blind_spots, + learning, + options, + ); + render_report_markdown(&model, options.obsidian) +} + fn build_communities( graph: &ReportGraph<'_>, communities: &Communities, @@ -1611,6 +1702,13 @@ fn sanitize_orientation_model(model: &mut AgentOrientation) { .omissions .learned_questions .set_shown(model.learned_questions.len()); + if model + .blind_spots + .as_ref() + .is_some_and(|report| !blind_spot_report_is_safe(report)) + { + model.blind_spots = None; + } model .details @@ -1748,6 +1846,82 @@ fn learned_question_is_safe(value: &OrientationLearnedQuestion) -> bool { raw_string_fits(&value.question) && raw_string_fits(&value.why) } +fn blind_spot_node_is_safe(value: &BlindSpotNode) -> bool { + !value.id.is_empty() + && raw_string_fits(&value.id) + && raw_string_fits(&value.label) + && optional_raw_string_fits(value.source_file.as_deref()) +} + +fn blind_spot_edge_is_safe(value: &BlindSpotEdge) -> bool { + !value.source.is_empty() + && !value.target.is_empty() + && raw_string_fits(&value.source) + && raw_string_fits(&value.target) + && raw_string_fits(&value.relation) + && raw_string_fits(&value.confidence) +} + +fn blind_spot_report_is_safe(value: &BlindSpotReport) -> bool { + const MAX_CANDIDATE_PAIRS: usize = 200_000; + const MAX_COMMUNITY_GAPS: usize = 3; + const MAX_WITNESSES: usize = 8; + const MAX_COMPONENTS: usize = 32; + const MAX_COMPONENT_MEMBERS: usize = 64; + + value.schema == GRAPH_INSIGHTS_SCHEMA + && value.limits.max_candidate_pairs == MAX_CANDIDATE_PAIRS + && value.limits.max_community_gaps == MAX_COMMUNITY_GAPS + && value.limits.max_shared_intermediaries == MAX_WITNESSES + && value.limits.max_direct_topical_edges == MAX_WITNESSES + && value.limits.max_disconnected_components == MAX_COMPONENTS + && value.limits.max_component_members == MAX_COMPONENT_MEMBERS + && value.community_gaps.len() <= 3 + && value.disconnected_components.len() <= 32 + && value.disconnected_components.len() <= value.disconnected_component_count + && (value.disconnected_component_count > 1 || value.disconnected_components.is_empty()) + && value.community_gaps.iter().all(|gap| { + !gap.id.is_empty() + && !gap.left_anchor.is_empty() + && !gap.right_anchor.is_empty() + && raw_string_fits(&gap.id) + && raw_string_fits(&gap.left_anchor) + && raw_string_fits(&gap.right_anchor) + && raw_string_fits(&gap.left_label) + && raw_string_fits(&gap.right_label) + && gap.score.is_finite() + && gap.score >= 0.0 + && gap.shared_intermediaries.len() <= 8 + && gap.shared_intermediary_count + == gap + .shared_intermediaries + .len() + .saturating_add(gap.omitted_shared_intermediaries) + && gap + .shared_intermediaries + .iter() + .all(blind_spot_node_is_safe) + && gap.direct_topical_edges.len() <= 8 + && gap + .direct_topical_edges + .len() + .saturating_add(gap.omitted_direct_topical_edges) + == gap.direct_topical_edge_count + && gap.direct_topical_edges.iter().all(blind_spot_edge_is_safe) + }) + && value.disconnected_components.iter().all(|component| { + !component.id.is_empty() + && raw_string_fits(&component.id) + && component.members.len() <= 64 + && component + .members + .len() + .saturating_add(component.omitted_members) + == component.real_node_count + && component.members.iter().all(blind_spot_node_is_safe) + }) +} + fn connection_is_safe(value: &OrientationConnection) -> bool { raw_string_fits(&value.endpoint_a) && raw_string_fits(&value.endpoint_b) @@ -1829,6 +2003,10 @@ fn orientation_strings_are_bounded(model: &AgentOrientation) -> bool { && model.risks.iter().all(risk_is_safe) && model.suggested_queries.iter().all(query_is_safe) && model.learned_questions.iter().all(learned_question_is_safe) + && model + .blind_spots + .as_ref() + .is_none_or(blind_spot_report_is_safe) && model .details .surprising_connections @@ -1869,6 +2047,9 @@ fn fit_orientation_budget(model: &mut AgentOrientation) { model.omissions.risks.set_shown(model.risks.len()); } else if model.hubs.pop().is_some() { model.omissions.hubs.set_shown(model.hubs.len()); + } else if trim_blind_spots_for_budget(model) { + // Keep the bounded structural evidence section within the same + // publication budget as the rest of the orientation. } else { break; } @@ -1877,7 +2058,13 @@ fn fit_orientation_budget(model: &mut AgentOrientation) { fn fit_orientation_json_budget(model: &mut AgentOrientation) { while let Ok(rendered) = serde_json::to_vec_pretty(model) { - if rendered.len() <= ORIENTATION_JSON_FIT_BYTES || model.communities.is_empty() { + if rendered.len() <= ORIENTATION_JSON_FIT_BYTES { + break; + } + if trim_blind_spots_for_budget(model) { + continue; + } + if model.communities.is_empty() { break; } let scaled = model @@ -1942,12 +2129,90 @@ fn fit_report_budget(model: &mut AgentOrientation, obsidian: bool) { .omissions .communities .set_shown(model.communities.len()); + } else if trim_blind_spots_for_budget(model) { + // Keep the bounded structural evidence section within the report + // publication budget. } else { break; } } } +fn trim_blind_spots_for_budget(model: &mut AgentOrientation) -> bool { + let Some(report) = model.blind_spots.as_mut() else { + return false; + }; + + if let Some(index) = report + .disconnected_components + .iter() + .enumerate() + .filter(|(_, component)| component.members.len() > 1) + .max_by_key(|(_, component)| component.members.len()) + .map(|(index, _)| index) + { + let component = &mut report.disconnected_components[index]; + let retained = component.members.len() / 2; + let removed = component.members.len().saturating_sub(retained); + component.members.truncate(retained); + component.omitted_members = component.omitted_members.saturating_add(removed); + report.omissions.component_members = + report.omissions.component_members.saturating_add(removed); + return true; + } + + if let Some(index) = report + .community_gaps + .iter() + .enumerate() + .filter(|(_, gap)| gap.shared_intermediaries.len() > 1) + .max_by_key(|(_, gap)| gap.shared_intermediaries.len()) + .map(|(index, _)| index) + { + let gap = &mut report.community_gaps[index]; + let retained = gap.shared_intermediaries.len() / 2; + let removed = gap.shared_intermediaries.len().saturating_sub(retained); + gap.shared_intermediaries.truncate(retained); + gap.omitted_shared_intermediaries = + gap.omitted_shared_intermediaries.saturating_add(removed); + return true; + } + + if let Some(index) = report + .community_gaps + .iter() + .enumerate() + .filter(|(_, gap)| gap.direct_topical_edges.len() > 1) + .max_by_key(|(_, gap)| gap.direct_topical_edges.len()) + .map(|(index, _)| index) + { + let gap = &mut report.community_gaps[index]; + let retained = gap.direct_topical_edges.len() / 2; + let removed = gap.direct_topical_edges.len().saturating_sub(retained); + gap.direct_topical_edges.truncate(retained); + gap.omitted_direct_topical_edges = gap.omitted_direct_topical_edges.saturating_add(removed); + return true; + } + + if let Some(component) = report.disconnected_components.pop() { + report.omissions.disconnected_components = + report.omissions.disconnected_components.saturating_add(1); + report.omissions.component_members = report.omissions.component_members.saturating_add( + component + .real_node_count + .saturating_sub(component.omitted_members), + ); + return true; + } + + if report.community_gaps.pop().is_some() { + report.omissions.community_gaps = report.omissions.community_gaps.saturating_add(1); + return true; + } + + false +} + fn render_orientation_markdown_unchecked(model: &AgentOrientation) -> String { let mut community_limit = ORIENTATION_COMMUNITY_LIMIT.min(model.communities.len()); loop { @@ -2083,6 +2348,7 @@ fn render_orientation_markdown_with_community_limit( )); } } + lines.extend(blind_spot_lines(model)); lines.extend([ String::new(), "## High-Connectivity Hubs".to_owned(), @@ -2180,6 +2446,81 @@ fn render_orientation_markdown_with_community_limit( lines.join("\n") } +fn blind_spot_lines(model: &AgentOrientation) -> Vec { + let Some(report) = model.blind_spots.as_ref() else { + return vec![ + String::new(), + "## Structural Blind Spots".to_owned(), + "- No typed structural-gap or disconnected-component evidence was retained.".to_owned(), + ]; + }; + let mut lines = vec![ + String::new(), + "## Structural Blind Spots".to_owned(), + format!( + "- Schema: {} · community gaps: {} · disconnected components: {} · largest component: {}", + report.schema, + report.community_gaps.len(), + report.disconnected_component_count, + report.largest_component_size, + ), + format!( + "- Omitted evidence: candidate-pair limit={} · gaps={} · components={} · component members={}", + report.omissions.candidate_pair_limit_reached, + report.omissions.community_gaps, + report.omissions.disconnected_components, + report.omissions.component_members, + ), + ]; + for gap in &report.community_gaps { + let witnesses = gap + .shared_intermediaries + .iter() + .map(blind_spot_node_text) + .collect::>() + .join(", "); + lines.push(format!( + "- Gap {}: `{}` ↔ `{}` · score {:.4} · shared intermediaries {}/{} · direct topical edges {} · witnesses: {}", + compact_identifier(&gap.id), + markdown_value(&gap.left_label, MARKDOWN_VALUE_MAX_CHARS), + markdown_value(&gap.right_label, MARKDOWN_VALUE_MAX_CHARS), + gap.score, + gap.shared_intermediaries.len(), + gap.shared_intermediary_count, + gap.direct_topical_edge_count, + if witnesses.is_empty() { "none" } else { &witnesses }, + )); + } + for component in &report.disconnected_components { + let members = component + .members + .iter() + .map(blind_spot_node_text) + .collect::>() + .join(", "); + lines.push(format!( + "- Component {}: {} real nodes · members {}/{}: {}", + compact_identifier(&component.id), + component.real_node_count, + component.members.len(), + component.real_node_count, + if members.is_empty() { "none" } else { &members }, + )); + } + lines +} + +fn blind_spot_node_text(node: &BlindSpotNode) -> String { + let label = markdown_value(&node.label, MARKDOWN_VALUE_MAX_CHARS); + match node.source_file.as_deref() { + Some(source) => format!( + "`{label}` ({})", + markdown_value(source, MARKDOWN_VALUE_MAX_CHARS) + ), + None => format!("`{label}`"), + } +} + fn render_report_markdown(model: &AgentOrientation, obsidian: bool) -> String { let community_labels = community_label_index(model); let mut lines = vec![ diff --git a/crates/compass-output/tests/coverage_paths.rs b/crates/compass-output/tests/coverage_paths.rs index b77bf365..0d260167 100644 --- a/crates/compass-output/tests/coverage_paths.rs +++ b/crates/compass-output/tests/coverage_paths.rs @@ -2,10 +2,15 @@ use std::collections::BTreeMap; use std::error::Error; use std::fs; -use compass_graph::{Communities, GodNode, SuggestedQuestion, SurpriseConnection}; +use compass_graph::{ + Communities, GodNode, SuggestedQuestion, SurpriseConnection, blind_spot_report, +}; use compass_model::GraphDocument; use compass_output::{ - DetectionSummary, ReportOptions, TokenCost, generate_report, graphml_document, write_graphml, + DetectionSummary, ORIENTATION_JSON_MAX_BYTES, REPORT_MARKDOWN_MAX_CHARS, ReportOptions, + TokenCost, agent_orientation_with_blind_spots, generate_report, + generate_report_with_blind_spots, graphml_document, render_agent_report_markdown, + render_orientation_json, write_graphml, }; use serde_json::json; @@ -157,6 +162,11 @@ fn reports_cover_navigation_quality_learning_hyperedges_and_questions() -> Resul question: None, why: "omitted text".to_owned(), }, + SuggestedQuestion { + kind: "community_gap".to_owned(), + question: Some("What evidence would directly connect Alpha and Beta?".to_owned()), + why: "Structural gap score 0.5000: 2 shared two-hop intermediaries and 0 direct topical edges; wiring-only relations are excluded.".to_owned(), + }, ]; let learning = json!({ "overlay":{ @@ -222,10 +232,29 @@ fn reports_cover_navigation_quality_learning_hyperedges_and_questions() -> Resul "known_dead_end", "Suggested Compass Queries", "How does runtime flow?", + "Structural gap score", ] { assert!(report.contains(expected), "missing {expected:?}\n{report}"); } + let blind_spots = blind_spot_report(&graph, &communities, &labels); + let typed_report = generate_report_with_blind_spots( + &graph, + &communities, + &cohesion, + &labels, + &gods, + &surprises, + &detection, + TokenCost::default(), + Some(&questions), + Some(&blind_spots), + Some(&learning), + &options, + ); + assert!(typed_report.contains("Structural Blind Spots")); + assert!(typed_report.contains("compass.graph-insights/1")); + let warning = DetectionSummary { warning: Some("Corpus warning".to_owned()), ..DetectionSummary::default() @@ -254,3 +283,62 @@ fn reports_cover_navigation_quality_learning_hyperedges_and_questions() -> Resul assert!(minimal.contains("Work-Memory Observations")); Ok(()) } + +#[test] +fn typed_blind_spots_remain_within_orientation_and_report_budgets() -> Result<(), Box> { + let mut nodes = Vec::new(); + let mut links = Vec::new(); + let long_label = "x".repeat(160); + for component in 0..33 { + for member in 0..100 { + let id = format!("component-{component}-member-{member}"); + nodes.push(json!({ + "id": id, + "label": format!("{long_label}-{component}-{member}"), + "source_file": format!("src/{component}/member-{member}.rs"), + "file_type": "code" + })); + if member > 0 { + links.push(json!({ + "source": format!("component-{component}-member-{}", member - 1), + "target": format!("component-{component}-member-{member}"), + "relation": "calls", + "confidence": "EXTRACTED" + })); + } + } + } + let graph = document(json!({ + "directed": true, + "multigraph": false, + "graph": {}, + "nodes": nodes, + "links": links + }))?; + let communities = Communities::new(); + let labels = BTreeMap::new(); + let blind_spots = blind_spot_report(&graph, &communities, &labels); + assert_eq!(blind_spots.disconnected_component_count, 33); + assert_eq!(blind_spots.disconnected_components.len(), 32); + + let options = ReportOptions::new("bounded"); + let model = agent_orientation_with_blind_spots( + &graph, + &communities, + &BTreeMap::new(), + &labels, + &[], + &[], + &DetectionSummary::default(), + TokenCost::default(), + None, + Some(&blind_spots), + None, + &options, + ); + let orientation = render_orientation_json(&model)?; + assert!(orientation.len() <= ORIENTATION_JSON_MAX_BYTES); + let report = render_agent_report_markdown(&model, false)?; + assert!(report.chars().count() <= REPORT_MARKDOWN_MAX_CHARS); + Ok(()) +} diff --git a/crates/compass-output/tests/orientation.rs b/crates/compass-output/tests/orientation.rs index 9a5ad11a..f1439081 100644 --- a/crates/compass-output/tests/orientation.rs +++ b/crates/compass-output/tests/orientation.rs @@ -213,6 +213,7 @@ fn orientation_is_bounded_deterministic_and_markdown_safe() -> Result<(), Box u8 { match node.kind { - NodeKind::Function | NodeKind::Method | NodeKind::Constructor => 4, + NodeKind::Function | NodeKind::Closure | NodeKind::Method | NodeKind::Constructor => 4, NodeKind::Class | NodeKind::Interface | NodeKind::Struct diff --git a/docs/concepts/graph-model.md b/docs/concepts/graph-model.md index 471ed404..9896efad 100644 --- a/docs/concepts/graph-model.md +++ b/docs/concepts/graph-model.md @@ -116,6 +116,11 @@ Relation names depend on the extractor and input type. Common families include: Do not assume every relation implies runtime execution. `imports_from` and `references` express different kinds of dependency from `calls`. +In the canonical `compass.graph/1` vocabulary, anonymous functions are typed +callable `closure` nodes. Trait or mixin composition is `mixes_in`; interface +contract satisfaction remains `implements`. Both retain their exact source +occurrence rather than being inferred from terminal-name similarity. + Configuration keys preserve their source hierarchy: root keys are contained by the file or schema node, and nested keys are contained by their immediate parent key. Nested keys may also retain a `references` edge with diff --git a/docs/guides/versioned-history.md b/docs/guides/versioned-history.md index dd5592e7..89768457 100644 --- a/docs/guides/versioned-history.md +++ b/docs/guides/versioned-history.md @@ -161,6 +161,17 @@ compass history status HEAD compass history list HEAD ``` +Compare structural blind spots across the retained preferred realizations: + +```bash +compass history blind-spots --rev HEAD --limit 200 +compass history blind-spots --rev HEAD --format json +``` + +The command tracks stable typed gap/component IDs, separates active findings +from resolved findings, and counts historical realizations that predate the +graph-insights sidecar instead of treating them as empty graphs. + For automation: ```bash diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 0473331b..253860b0 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -374,6 +374,7 @@ Runs the native graph-query benchmark surface. ```text compass history enable [build-profile options] compass history disable +compass history blind-spots [--rev REV] [--limit N] [--format text|json] compass history status [REV] [--format text|json] compass history build REV [--all [--first-parent]] [build-profile options|--profile-from REV|REALIZATION] [--format text|json] compass history rebuild REV [build-profile options] [--replace-corrupt] [--format text|json] @@ -397,6 +398,12 @@ compass history build main --all --code-only compass history build main --all --first-parent ``` +`history blind-spots` reads the preferred immutable realization for each +reachable commit and compares typed graph-insights IDs. It reports active and +resolved gaps/components, preserves explicit omission counts, and treats +missing sidecars from older realizations as unavailable evidence rather than +as empty reports. + Build-profile options include: ```text @@ -846,6 +853,7 @@ compass export callflow-json --output PATH compass program call-graph (--symbol SYMBOL | --source FILE --byte BYTE) [--direction callers|callees|both] [--depth N] --format json compass history timeline [--rev REV] [--limit N [--after CURSOR]] --format json +compass history blind-spots [--rev REV] [--limit N] [--format text|json] compass history change-counts REV [--parent REV] --format json compass history diff OLD NEW [--root NAME] [--output PATH] --format jsonl compass history export REV --format json [--community ID] [--node-limit N] --output PATH diff --git a/docs/reference/outputs.md b/docs/reference/outputs.md index 9471a11a..b9b473d0 100644 --- a/docs/reference/outputs.md +++ b/docs/reference/outputs.md @@ -13,6 +13,7 @@ compass-out/ ├── graph.json ├── graph.html # unless omitted by size or --no-viz ├── GRAPH_REPORT.md +├── orientation.json # clustered Agent Orientation ├── manifest.json ├── program.json # only with --program or --program-artifact ├── graph-overview.json # clustered builds @@ -234,11 +235,37 @@ The report can include: - communities; - surprising connections; - cycles/diagnostics; -- suggested questions. +- suggested questions, including bounded structural-gap questions when two + well-formed communities share topical two-hop evidence but lack a direct + topical relationship, and disconnected-component questions for multiple + source-backed graph islands. It is intended for people and can evolve in prose/format. Do not parse it when structured data or command JSON exists. +Structural-gap questions are investigative evidence, not newly inferred graph +edges. Compass dampens shared intermediaries by their degree, excludes +containment/import/wiring relations from topical linkage, and ignores +file/concept/JSON-key-only noise. The report may therefore ask what would +connect two communities without asserting that a connection exists. + +## Typed graph-insights projection + +Clustered `analysis.json` includes a bounded `blindSpots` value with schema +`compass.graph-insights/1`. It contains ranked `communityGaps` and, when more +than one source-backed component exists, `disconnectedComponents`. Each gap +retains stable anchors, shared-intermediary witnesses, direct topical-edge +witnesses, and exact counts; each component retains bounded member witnesses. +`omissions` and `limits` are part of the contract, so a missing item is never +silently interpreted as evidence that no item existed. + +The same projection is included as optional `blindSpots` in +`compass.orientation/2`, rendered in `GRAPH_REPORT.md`, and exposed through +the read-only MCP resource `compass://graph-insights`. The projection does not +add, remove, or rewrite graph edges. `compass history blind-spots --format +json` compares these exact IDs across immutable realizations; realizations +without the sidecar are counted as observations without graph insights. + Community evidence labels prefer a meaningful symbol or document heading over Markdown pipe-table parser blocks, even when a table container has more structural edges. A community containing only pipe-table blocks receives a @@ -652,6 +679,12 @@ First-party editor and offline-viewer contracts are versioned independently: - `compass.history.timeline/1` — commit and materialization states; - `compass.history.change_counts/1` — lazy structural counts between existing realizations; +- `compass.graph-insights/1` — bounded structural-gap and disconnected-component + evidence with witnesses and omission limits; +- `compass.graph-blind-spot-history/1` — active/resolved blind-spot trends over + immutable history observations; +- `compass.orientation/2` — fitted Agent Orientation with optional typed + `blindSpots` evidence; - `compass.history.viewer_graph/1` — exact historical graph envelope; - `compass.semantic_diff.report/1` — exhaustive semantic findings, source changes, and exact added, removed, and changed node/edge records consumed by diff --git a/fixtures/code-graph/qualification/rich.php b/fixtures/code-graph/qualification/rich.php new file mode 100644 index 00000000..a3e45a88 --- /dev/null +++ b/fixtures/code-graph/qualification/rich.php @@ -0,0 +1,19 @@ +log('working'); + }; + $callback(); + } +} diff --git a/scripts/code_graph_v1_oracle.py b/scripts/code_graph_v1_oracle.py index c4bae100..b4a35ffb 100755 --- a/scripts/code_graph_v1_oracle.py +++ b/scripts/code_graph_v1_oracle.py @@ -15,7 +15,7 @@ NODE_KINDS = ( "file", "module", "package", "namespace", "class", "struct", "interface", "trait", "protocol", "enum", "enum_member", "type_alias", "function", - "method", "constructor", "property", "field", "variable", "constant", + "method", "constructor", "closure", "property", "field", "variable", "constant", "parameter", "import", "export", "macro", "annotation", "route", "component", "event", "message", "topic", "queue", "job", "resource", "schema", "query", "migration", "config_key", "database", @@ -24,7 +24,7 @@ "database_trigger", ) EDGE_KINDS = ( - "contains", "calls", "imports", "exports", "extends", "implements", + "contains", "calls", "imports", "exports", "extends", "implements", "mixes_in", "references", "type_of", "returns", "instantiates", "overrides", "decorates", "routes_to", "reads", "writes", "aliases", "registers", "handles", "publishes", "subscribes", "produces", "consumes", "schedules", @@ -32,7 +32,7 @@ ) DETAIL_TYPES = { "file": {"file"}, - "symbol": set(NODE_KINDS[1:24]) | {"migration"}, + "symbol": set(NODE_KINDS[1:25]) | {"migration"}, "import_export": {"import", "export"}, "route": {"route"}, "component": {"component"}, @@ -42,7 +42,7 @@ "schema": {"schema"}, "query": {"query"}, "config": {"config_key"}, - "database": set(NODE_KINDS[36:]), + "database": set(NODE_KINDS[37:]), } TRUSTED_ORIGINS = {"ast", "config", "convention", "artifact"} ALL_ORIGINS = TRUSTED_ORIGINS | {"heuristic"} @@ -60,22 +60,22 @@ ) TYPE_KINDS = {"class", "struct", "interface", "trait", "protocol", "enum", "type_alias"} -CALLABLE = {"function", "method", "constructor", "database_procedure"} +CALLABLE = {"function", "method", "constructor", "closure", "database_procedure"} CONTAINER = { "file", "module", "package", "namespace", "class", "struct", "interface", "trait", "protocol", "enum", "component", "resource", "schema", "database", "database_schema", "database_table", "database_view", } -CONTAINS_FILE_TARGETS = set(NODE_KINDS[1:36]) | {"database"} -CONTAINS_SCOPE_TARGETS = set(NODE_KINDS[:36]) +CONTAINS_FILE_TARGETS = set(NODE_KINDS[1:37]) | {"database"} +CONTAINS_SCOPE_TARGETS = set(NODE_KINDS[:37]) CONTAINS_TYPE_TARGETS = { "class", "struct", "interface", "trait", "protocol", "enum", "enum_member", - "type_alias", "function", "method", "constructor", "property", "field", + "type_alias", "function", "method", "constructor", "closure", "property", "field", "variable", "constant", "parameter", "macro", "annotation", "component", } CONTAINS_CALLABLE_TARGETS = { "class", "struct", "interface", "trait", "protocol", "enum", "type_alias", - "function", "method", "constructor", "property", "field", "variable", + "function", "method", "constructor", "closure", "property", "field", "variable", "constant", "parameter", } EXECUTABLE = CALLABLE | {"component", "job", "query", "database_trigger"} @@ -379,6 +379,8 @@ def endpoint_allowed(source: dict[str, Any], edge: dict[str, Any], target: dict[ return s in TYPE_KINDS and t in TYPE_KINDS if kind == "implements": return s in TYPE_KINDS and t in {"interface", "trait", "protocol"} + if kind == "mixes_in": + return s in TYPE_KINDS and t in TYPE_KINDS if kind == "type_of": return s in VALUE_KINDS and t in TYPE_KINDS | {"parameter"} if kind == "returns": diff --git a/tests/qualification/code-graph-v1-semantic.json b/tests/qualification/code-graph-v1-semantic.json index 08e10d05..00a82aab 100644 --- a/tests/qualification/code-graph-v1-semantic.json +++ b/tests/qualification/code-graph-v1-semantic.json @@ -909,7 +909,7 @@ "id": "node-file", "kind": "file", "source": "fixtures/code-graph/qualification/MissingReference.csproj", - "qualifiedName": "MissingReference.csproj", + "qualifiedName": "fixtures/code-graph/qualification/MissingReference.csproj", "producer": "compass.languages.project-xml", "origins": [ "ast" @@ -1389,6 +1389,17 @@ ], "detailType": "database" }, + { + "id": "node-closure", + "kind": "closure", + "source": "fixtures/code-graph/qualification/rich.php", + "qualifiedName": "*", + "producer": "compass.languages.php.universal", + "origins": [ + "ast" + ], + "detailType": "symbol" + }, { "id": "node-database_trigger", "kind": "database_trigger", @@ -1468,6 +1479,17 @@ ], "detailType": null }, + { + "id": "edge-mixes-in", + "kind": "mixes_in", + "source": "fixtures/code-graph/qualification/rich.php", + "qualifiedName": "*", + "producer": "compass.resolve.php.universal", + "origins": [ + "ast" + ], + "detailType": null + }, { "id": "edge-references", "kind": "references",