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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
145 changes: 140 additions & 5 deletions crates/compass-cli/src/history_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 <command>\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",
)
}

Expand Down Expand Up @@ -473,6 +478,9 @@ fn execute(frontend: Frontend, args: &[String]) -> Result<String, CommandFailure
if args[0] == "timeline" {
return execute_timeline(&repository, &args[1..]);
}
if args[0] == "blind-spots" {
return execute_blind_spots(&repository, &args[1..]);
}
if args[0] == "change-counts" {
return execute_change_counts(&repository, &args[1..]);
}
Expand Down Expand Up @@ -1389,6 +1397,132 @@ fn parse_cache_format(args: &[String]) -> Result<&str, CommandFailure> {
}
}

fn execute_blind_spots(repository: &Repository, args: &[String]) -> Result<String, CommandFailure> {
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::<usize>()
.map_err(|_| usage("history blind-spots --limit must be an integer"))?;
}
value if value.starts_with("--limit=") => {
limit = value[8..]
.parse::<usize>()
.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::<std::collections::BTreeMap<_, _>>();
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<String, CommandFailure> {
let mut revision = "HEAD".to_owned();
let mut revision_selected = false;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions crates/compass-cli/tests/viewer_export_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 10 additions & 7 deletions crates/compass-core/src/cluster_existing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -255,6 +256,7 @@ where
},
selection.token_cost,
Some(&questions),
Some(&blind_spots),
learning.as_ref(),
&report_options,
);
Expand All @@ -271,6 +273,7 @@ where
"gods": gods,
"surprises": surprises,
"questions": questions,
"blindSpots": blind_spots,
}),
true,
)?;
Expand Down
22 changes: 16 additions & 6 deletions crates/compass-core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -3944,6 +3951,7 @@ fn build_graph_inner_unscoped(
"cohesion": cohesion.iter().map(|(key, value)| (key.to_string(), value)).collect::<BTreeMap<_, _>>(),
"gods": gods,
"surprises": surprises,
"blindSpots": blind_spots,
"tokens": {"input": tokens.0, "output": tokens.1},
})
} else {
Expand All @@ -3953,6 +3961,7 @@ fn build_graph_inner_unscoped(
"gods": gods,
"surprises": surprises,
"questions": questions,
"blindSpots": blind_spots,
})
};
if options.purpose == BuildPurpose::Extract {
Expand All @@ -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,
Expand All @@ -3986,6 +3995,7 @@ fn build_graph_inner_unscoped(
&detection_summary,
TokenCost::default(),
Some(&questions),
Some(&blind_spots),
None,
&report_options,
))
Expand Down
1 change: 1 addition & 0 deletions crates/compass-core/src/task_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ fn declaration_details(source: &CodeQueryResponse) -> CodeQueryResponse {
EdgeKind::Embeds,
EdgeKind::Extends,
EdgeKind::Implements,
EdgeKind::MixesIn,
EdgeKind::TypeOf,
EdgeKind::Returns,
EdgeKind::Instantiates,
Expand Down
Loading
Loading