diff --git a/crates/compass-semantic-diff/src/engine.rs b/crates/compass-semantic-diff/src/engine.rs index 554eba4b..7b566fc4 100644 --- a/crates/compass-semantic-diff/src/engine.rs +++ b/crates/compass-semantic-diff/src/engine.rs @@ -8,6 +8,7 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use crate::SemanticDiffError; +use crate::logic::LogicDeltaIndex; use crate::model::{ AffectedConsumer, CLASSIFIER_VERSION, ChangeDirection, ChangedEntity, CollapsedGroup, Comparison, Compatibility, Completeness, Confidence, DependencyDelta, EntitySnapshot, @@ -645,6 +646,7 @@ fn classify_graph_fallbacks( findings: &mut Vec, limitations: &mut Vec, ) -> Result<(), SemanticDiffError> { + let logic_deltas = LogicDeltaIndex::new(input.source_deltas); for node_id in input.changed_node_ids { if matched.contains(node_id) { continue; @@ -694,19 +696,45 @@ fn classify_graph_fallbacks( "Inspect the source-level signature change.", )); } else if old_body != new_body && (old_body.is_some() || new_body.is_some()) { - findings.push(base_finding( - FindingType::BehaviorChange, - node_id, - FindingOrigin::Direct, - format!("{node_id} implementation changed"), - "An implementation digest changed without supported semantic evidence.", - Compatibility::Indeterminate, - Confidence::Unknown, - old_body.map(|value| json!({"body_digest": value})), - new_body.map(|value| json!({"body_digest": value})), - node_evidence(old.as_ref().or(new.as_ref()), node_id, "implementation"), - "Inspect the body-only change.", - )); + if let Some(logic) = logic_deltas.for_nodes(old.as_ref(), new.as_ref()) { + let mut finding = base_finding( + FindingType::BehaviorChange, + node_id, + FindingOrigin::Direct, + format!("{node_id} control flow changed"), + logic.explanation(), + Compatibility::Behavioral, + Confidence::Exact, + Some(json!({ + "body_digest": old_body, + "conditions": logic.removed_conditions, + })), + Some(json!({ + "body_digest": new_body, + "conditions": logic.added_conditions, + })), + node_evidence(old.as_ref().or(new.as_ref()), node_id, "control_flow"), + "Review the changed branch condition and its boundary behavior.", + ); + finding + .completeness + .insert("control_flow".to_owned(), Completeness::Partial); + findings.push(finding); + } else { + findings.push(base_finding( + FindingType::BehaviorChange, + node_id, + FindingOrigin::Direct, + format!("{node_id} implementation changed"), + "An implementation digest changed without supported semantic evidence.", + Compatibility::Indeterminate, + Confidence::Unknown, + old_body.map(|value| json!({"body_digest": value})), + new_body.map(|value| json!({"body_digest": value})), + node_evidence(old.as_ref().or(new.as_ref()), node_id, "implementation"), + "Inspect the body-only change.", + )); + } } } Ok(()) diff --git a/crates/compass-semantic-diff/src/lib.rs b/crates/compass-semantic-diff/src/lib.rs index b02770aa..9b6d3d38 100644 --- a/crates/compass-semantic-diff/src/lib.rs +++ b/crates/compass-semantic-diff/src/lib.rs @@ -2,6 +2,7 @@ mod engine; mod error; +mod logic; mod model; mod verification; diff --git a/crates/compass-semantic-diff/src/logic.rs b/crates/compass-semantic-diff/src/logic.rs new file mode 100644 index 00000000..27da70fb --- /dev/null +++ b/crates/compass-semantic-diff/src/logic.rs @@ -0,0 +1,253 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use compass_history::{SourceFileDelta, SourceHunk}; +use compass_model::NodeRecord; +use serde_json::Value; + +const MAX_CONDITIONS_PER_FINDING: usize = 20; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct LogicDelta { + pub added_conditions: Vec, + pub removed_conditions: Vec, +} + +impl LogicDelta { + pub(crate) fn explanation(&self) -> String { + let mut changes = Vec::new(); + if !self.added_conditions.is_empty() { + changes.push(format!( + "added {}", + describe_conditions(&self.added_conditions) + )); + } + if !self.removed_conditions.is_empty() { + changes.push(format!( + "removed {}", + describe_conditions(&self.removed_conditions) + )); + } + format!("Control flow changed: {}.", changes.join("; ")) + } +} + +#[derive(Clone, Debug)] +struct HunkLogic { + hunk: SourceHunk, + added_conditions: Vec, + removed_conditions: Vec, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct LogicDeltaIndex { + by_path: BTreeMap>, +} + +impl LogicDeltaIndex { + pub(crate) fn new(source_deltas: &[SourceFileDelta]) -> Self { + let mut by_path = BTreeMap::new(); + for delta in source_deltas { + let parsed = parse_hunks(delta); + if parsed.is_empty() { + continue; + } + for path in [delta.old_path.as_ref(), delta.new_path.as_ref()] + .into_iter() + .flatten() + { + by_path.insert(path.clone(), parsed.clone()); + } + } + Self { by_path } + } + + pub(crate) fn for_nodes( + &self, + old: Option<&NodeRecord>, + new: Option<&NodeRecord>, + ) -> Option { + let path = source_file(new).or_else(|| source_file(old))?; + let hunks = self.by_path.get(path)?; + let old_span = line_span(old); + let new_span = line_span(new); + let mut added = BTreeSet::new(); + let mut removed = BTreeSet::new(); + for hunk in hunks { + if !old_span + .is_some_and(|span| overlaps(span, hunk.hunk.old_start, hunk.hunk.old_lines)) + && !new_span + .is_some_and(|span| overlaps(span, hunk.hunk.new_start, hunk.hunk.new_lines)) + { + continue; + } + added.extend(hunk.added_conditions.iter().cloned()); + removed.extend(hunk.removed_conditions.iter().cloned()); + } + let unchanged = added.intersection(&removed).cloned().collect::>(); + for condition in unchanged { + added.remove(&condition); + removed.remove(&condition); + } + let added_conditions = added + .into_iter() + .take(MAX_CONDITIONS_PER_FINDING) + .collect::>(); + let removed_conditions = removed + .into_iter() + .take(MAX_CONDITIONS_PER_FINDING) + .collect::>(); + (!added_conditions.is_empty() || !removed_conditions.is_empty()).then_some(LogicDelta { + added_conditions, + removed_conditions, + }) + } +} + +fn parse_hunks(delta: &SourceFileDelta) -> Vec { + let mut changes = Vec::<(Vec, Vec)>::new(); + let mut current = None; + for line in delta.patch.lines() { + if line.starts_with("@@") { + changes.push((Vec::new(), Vec::new())); + current = changes.len().checked_sub(1); + continue; + } + let Some(index) = current else { + continue; + }; + let mut characters = line.chars(); + let Some(prefix) = characters.next() else { + continue; + }; + if !matches!(prefix, '+' | '-') { + continue; + } + let Some(condition) = condition(characters.as_str()) else { + continue; + }; + match prefix { + '+' => changes[index].0.push(condition), + '-' => changes[index].1.push(condition), + _ => {} + } + } + delta + .hunks + .iter() + .copied() + .zip(changes) + .map(|(hunk, (added_conditions, removed_conditions))| HunkLogic { + hunk, + added_conditions, + removed_conditions, + }) + .collect() +} + +fn condition(line: &str) -> Option { + let line = line + .trim() + .trim_start_matches('}') + .trim() + .trim_end_matches('{') + .trim_end_matches(':') + .trim(); + let branch = [ + "if ", "if(", "if let ", "else if ", "elif ", "guard ", "when ", "switch ", "switch(", + "match ", + ] + .iter() + .any(|prefix| line.starts_with(prefix)); + branch.then(|| bounded_condition(line)) +} + +fn bounded_condition(line: &str) -> String { + let mut characters = line.chars(); + let mut output = characters.by_ref().take(240).collect::(); + if characters.next().is_some() { + output.pop(); + output.push('…'); + } + output +} + +fn source_file(node: Option<&NodeRecord>) -> Option<&str> { + node?.attributes.get("source_file")?.as_str() +} + +fn line_span(node: Option<&NodeRecord>) -> Option<(u32, u32)> { + let node = node?; + let start = line_number(node.attributes.get("line_start")?)?; + let end = node + .attributes + .get("line_end") + .and_then(line_number) + .unwrap_or(start); + Some((start, end.max(start))) +} + +fn line_number(value: &Value) -> Option { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .or_else(|| value.as_str()?.parse().ok()) +} + +fn overlaps(span: (u32, u32), start: u32, lines: u32) -> bool { + let hunk_end = if lines == 0 { + start + } else { + start.saturating_add(lines.saturating_sub(1)) + }; + span.0 <= hunk_end && start <= span.1 +} + +fn describe_conditions(conditions: &[String]) -> String { + let label = if conditions.len() == 1 { + "condition" + } else { + "conditions" + }; + format!( + "{label} {}", + conditions + .iter() + .map(|condition| format!("`{condition}`")) + .collect::>() + .join(", ") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn condition_evidence_is_bounded_without_splitting_unicode() { + let source = format!("if {} {{", "界".repeat(300)); + let parsed = condition(&source); + assert!(parsed.is_some()); + let parsed = parsed.unwrap_or_default(); + assert_eq!(parsed.chars().count(), 240); + assert!(parsed.ends_with('…')); + } + + #[test] + fn empty_patch_lines_are_ignored() { + let delta = SourceFileDelta { + old_path: Some("src/example.go".to_owned()), + new_path: Some("src/example.go".to_owned()), + status: compass_history::SourceFileStatus::Modified, + hunks: vec![SourceHunk { + old_start: 10, + old_lines: 2, + new_start: 10, + new_lines: 1, + }], + patch: "@@ -10,2 +10,1 @@\n\n-if stale {\n".to_owned(), + }; + let hunks = parse_hunks(&delta); + assert_eq!(hunks.len(), 1); + assert_eq!(hunks[0].removed_conditions, ["if stale"]); + } +} diff --git a/crates/compass-semantic-diff/tests/semantic_diff.rs b/crates/compass-semantic-diff/tests/semantic_diff.rs index 12feae27..2e9755ff 100644 --- a/crates/compass-semantic-diff/tests/semantic_diff.rs +++ b/crates/compass-semantic-diff/tests/semantic_diff.rs @@ -8,8 +8,9 @@ use compass_languages::TreeSitterSyntaxProvider; use compass_model::NodeRecord; use compass_program::{FileInput, SyntaxProvider, merge_evidence}; use compass_semantic_diff::{ - ChangeDirection, Compatibility, DependencyDelta, GraphDelta, NoTestEvidence, REPORT_SCHEMA, - SemanticDiffError, SemanticDiffInput, SnapshotIdentity, SnapshotReader, SnapshotSide, compare, + ChangeDirection, Compatibility, Confidence, DependencyDelta, FindingType, GraphDelta, + NoTestEvidence, REPORT_SCHEMA, SemanticDiffError, SemanticDiffInput, SnapshotIdentity, + SnapshotReader, SnapshotSide, compare, }; struct Fixtures { @@ -389,3 +390,99 @@ fn graph_only_additions_and_removals_are_classified_before_digest_changes() ); Ok(()) } + +#[test] +fn graph_fallback_explains_control_flow_patch_for_changed_function() -> Result<(), Box> { + let node_id = "libpod_container_getcontainerinspectdata".to_owned(); + let node = |implementation_hash: &str| NodeRecord { + id: node_id.clone(), + attributes: serde_json::Map::from_iter([ + ( + "label".to_owned(), + serde_json::json!(".getContainerInspectData()"), + ), + ("symbol_kind".to_owned(), serde_json::json!("method")), + ( + "source_file".to_owned(), + serde_json::json!("libpod/container_inspect.go"), + ), + ("line_start".to_owned(), serde_json::json!(66)), + ("line_end".to_owned(), serde_json::json!(160)), + ( + "implementation_hash".to_owned(), + serde_json::json!(implementation_hash), + ), + ]), + }; + let fixtures = Fixtures { + old: analyze_typescript(b"")?, + new: analyze_typescript(b"")?, + old_nodes: BTreeMap::from([(node_id.clone(), node("old-body"))]), + new_nodes: BTreeMap::from([(node_id.clone(), node("new-body"))]), + }; + let source_deltas = [SourceFileDelta { + old_path: Some("libpod/container_inspect.go".to_owned()), + new_path: Some("libpod/container_inspect.go".to_owned()), + status: SourceFileStatus::Modified, + hunks: vec![SourceHunk { + old_start: 80, + old_lines: 2, + new_start: 79, + new_lines: 0, + }], + patch: concat!( + "diff --git a/libpod/container_inspect.go b/libpod/container_inspect.go\n", + "--- a/libpod/container_inspect.go\n", + "+++ b/libpod/container_inspect.go\n", + "@@ -80,2 +79,0 @@ func (c *Container) getContainerInspectData(size bool, driverData *define.DriverData) (*define.InspectContainerData, error) {\n", + "-\t}\n", + "-\tif len(args) > 1 {\n", + ) + .to_owned(), + }]; + let report = compare(SemanticDiffInput { + old: SnapshotIdentity { + commit: "a".repeat(40), + realization: "old".to_owned(), + fingerprint: "f".repeat(64), + }, + new: SnapshotIdentity { + commit: "b".repeat(40), + realization: "new".to_owned(), + fingerprint: "f".repeat(64), + }, + source_deltas: &source_deltas, + changed_node_ids: std::slice::from_ref(&node_id), + dependency_deltas: &[], + graph_delta: &GraphDelta::default(), + snapshots: &fixtures, + test_evidence: &NoTestEvidence, + })?; + let finding = report + .findings + .iter() + .find(|finding| finding.subject == node_id) + .ok_or("missing control-flow finding")?; + assert_eq!(finding.finding_type, FindingType::BehaviorChange); + assert_eq!(finding.compatibility, Compatibility::Behavioral); + assert_eq!(finding.confidence, Confidence::Exact); + assert!( + finding + .explanation + .contains("removed condition `if len(args) > 1`") + ); + assert_eq!( + finding + .before + .as_ref() + .and_then(|value| value["conditions"].as_array()) + .and_then(|values| values.first()) + .and_then(serde_json::Value::as_str), + Some("if len(args) > 1") + ); + assert_eq!( + finding.completeness.get("control_flow").copied(), + Some(compass_semantic_diff::Completeness::Partial) + ); + Ok(()) +} diff --git a/docs/assets/screenshots/codebase-evolution-changed-graph-dark.png b/docs/assets/screenshots/codebase-evolution-changed-graph-dark.png new file mode 100644 index 00000000..922aec24 Binary files /dev/null and b/docs/assets/screenshots/codebase-evolution-changed-graph-dark.png differ diff --git a/docs/assets/screenshots/codebase-evolution-semantic-findings-light.png b/docs/assets/screenshots/codebase-evolution-semantic-findings-light.png new file mode 100644 index 00000000..25e95fac Binary files /dev/null and b/docs/assets/screenshots/codebase-evolution-semantic-findings-light.png differ diff --git a/docs/assets/screenshots/codebase-evolution-source-diff-dark.png b/docs/assets/screenshots/codebase-evolution-source-diff-dark.png new file mode 100644 index 00000000..b4e0c8e4 Binary files /dev/null and b/docs/assets/screenshots/codebase-evolution-source-diff-dark.png differ diff --git a/docs/guides/versioned-history.md b/docs/guides/versioned-history.md index 4f6551c9..9ab33f5c 100644 --- a/docs/guides/versioned-history.md +++ b/docs/guides/versioned-history.md @@ -267,11 +267,15 @@ exact source patch and the meaningful code-graph delta in the same report. The graph visualization focuses on the changed subgraph; its node/edge lists and embedded JSON remain exhaustive. -Semantic diff requires Program IR v1 evidence. Rebuild older realizations with -the current Compass binary before comparing them. Static test mapping may -recommend resolved test callers, but `partial` or `unknown` evidence never -claims safety or a test gap. AI-generated summaries and hosted PR delivery are -outside this deterministic MVP. +Program IR v1 provides the richest behavior evidence. For graph-only languages, +Compass can also report changed branch conditions when an exact zero-context +source hunk overlaps the changed function's recorded line span. That evidence +is exact but deliberately marked as partial control-flow coverage; unrelated +body-hash changes remain indeterminate. Rebuild older realizations with the +current Compass binary before comparing them. Static test mapping may recommend +resolved test callers, but `partial` or `unknown` evidence never claims safety +or a test gap. AI-generated summaries and hosted PR delivery are outside this +deterministic MVP. ### Profile compatibility diff --git a/docs/superpowers/reviews/2026-07-26-actionable-semantic-diff-real-repository-qualification.md b/docs/superpowers/reviews/2026-07-26-actionable-semantic-diff-real-repository-qualification.md new file mode 100644 index 00000000..5e6220fb --- /dev/null +++ b/docs/superpowers/reviews/2026-07-26-actionable-semantic-diff-real-repository-qualification.md @@ -0,0 +1,124 @@ +# Actionable Semantic Diff Real-Repository Qualification + +**Date:** 2026-07-26 +**Binary:** local debug build from `codex/improve-vscode-diff-experience` + +## Outcome + +Versioned history and semantic diff completed successfully for arbitrary, +non-tip revisions in Podman, CocoIndex, and LevelDB. Reports contained semantic +contract, behavior, dependency, affected-consumer, source, and graph-structure +evidence where supported. Graph-only body changes now use exact, line-scoped +branch-condition evidence when available instead of always degrading to an +opaque implementation digest. + +The qualification used immutable history realizations; none of the source +checkouts were switched to the historical commits. + +## Podman + +Repository: `/Volumes/workspace/Github/podman` + +Compared: + +- old: `d8380c9c80d9c4acf5afd59b65c4c779aaacbbf5` +- new: `7ac3e837075460ecdea5ce59e607cdaa6b6709fc` + +Each realization contained 119,293 nodes and 262,724 edges. The source change +removes `if len(args) > 1` from +`libpod/container_inspect.go::getContainerInspectData`. + +The final report produced an exact behavioral finding: + +```text +libpod_container_getcontainerinspectdata control flow changed +Control flow changed: removed condition `if len(args) > 1`. +``` + +The finding records the before/after body digests, the removed condition, +`control_flow: partial`, and source evidence. Two JSON runs produced the same +SHA-256 digest: + +```text +1a3434e316744ede6c9ef44a55bc586d2c139ff4a0ffbc7e56d33ff6bdb74ee5 +``` + +`--explain` resolved the stable finding ID, and the checkout status before and +after materialization was byte-identical. + +## CocoIndex + +Repository: `/Volumes/workspace/Github/cocoindex-compass-audit-20260726` + +Compared: + +- old: `90571539fa291fc6e6b248095bd2c8a2ff68bab4` +- new: `71f9cc9dc693080310181a2d011fb737420f7907` + +The old realization contained 17,729 nodes, 40,237 edges, 91,163 Program IR +facts, and 10,931 summaries. The new realization contained 17,808 nodes, +40,412 edges, 91,537 facts, and 10,981 summaries. + +The final canonical JSON report contained: + +- 333 findings in 12 feature groups; +- 9 source-file changes; +- 149 added, 70 removed, and 9 changed graph nodes; +- 247 added and 72 removed graph edges; +- public API, behavior, dependency, affected-consumer, effect, error, and test + evidence; +- three exact, partial-control-flow explanations for added branch conditions. + +`HEAD~1` and `HEAD` resolved to the expected full commit IDs. `--all` preserved +the same exhaustive 333-finding JSON contract. The removed `--detailed` flag +failed closed with exit status 2. No temporary-worktree path leaked into JSON +or HTML output. + +## LevelDB + +Repository: `/Volumes/workspace/Github/leveldb` + +Compared: + +- deadlock body edit: `78a352f47ed6c1e9d750545e9b242289185b87e1` + to `4a0c572440c7df2f56a6f5fb5aec9e366d522edb` +- Zstd addition: `bfae97ff7d9fd0ceccb49b90a1e4c19fe7b57652` + to `1d6e8d64ee8489a85ce939b819d106d2b54abb15` + +The deadlock edit produced one explicit indeterminate implementation finding, +never location churn or an invented behavioral claim. The Zstd comparison +produced 67 findings, including exact added branch conditions and retained +call, reference, and import dependencies. Its graph delta contained 14 added, +2 removed, and 6 changed nodes plus 45 added and 12 removed edges. + +All four revisions were materialized with one comparable, deterministic +code-only extraction profile. The LevelDB checkout remained clean. + +## Verification + +Passed: + +```text +cargo fmt --all -- --check +cargo test -p compass-semantic-diff +cargo test -p compass-cli --test history_cli semantic_diff_end_to_end_languages -- --exact --nocapture +cargo test --workspace +cargo clippy -p compass-semantic-diff --all-targets --all-features --no-deps -- -D warnings +git diff --check +graphify update . +``` + +Strict workspace Clippy is not green because five unchanged +`clippy::needless_borrow` findings remain in +`crates/compass-graph/src/analyze.rs`. The semantic-diff crate is clean with +warnings denied, and the complete workspace test suite passes. + +## Scale observation + +The Podman debug-build qualification completed, but history publication peaked +at approximately 4.7–5.2 GB RSS. Initial materialization took roughly six +minutes; profile-based materialization of the adjacent revision took roughly +eleven minutes, including source-realization validation. These are debug-binary +observations rather than a release benchmark, so they do not establish a +production performance regression. Release-mode history publication should be +measured separately before adopting a memory or latency SLO. diff --git a/docs/superpowers/specs/2026-07-24-actionable-semantic-diff-design.md b/docs/superpowers/specs/2026-07-24-actionable-semantic-diff-design.md index 1248e6f2..e8b28c64 100644 --- a/docs/superpowers/specs/2026-07-24-actionable-semantic-diff-design.md +++ b/docs/superpowers/specs/2026-07-24-actionable-semantic-diff-design.md @@ -376,9 +376,11 @@ adapters cover Python, Rust, and TypeScript/JavaScript. Other languages report the exact before/after signature when available but use `indeterminate` compatibility until an adapter can prove more. -An implementation hash is only a change detector. If the hash changes without -sufficient Program IR, Compass reports an implementation change with incomplete -semantic coverage; it does not narrate the behavior. +An implementation hash is only a change detector. Without sufficient Program +IR, Compass may narrate an exact changed branch condition only when a +zero-context source hunk overlaps the changed function's recorded line span. +That finding remains partial control-flow coverage. Other implementation-hash +changes report incomplete semantic coverage without narrating the behavior. ## Relation semantics and bounded impact diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 092f8cac..74ea3630 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -57,7 +57,7 @@ { "command": "compass.update", "title": "Compass: Update Graph", - "icon": "$(refresh)" + "icon": "$(sync)" }, { "command": "compass.refreshWorkspace", @@ -69,6 +69,21 @@ "title": "Compass: Start or Stop Watch", "icon": "$(eye)" }, + { + "command": "compass.startWatch", + "title": "Compass: Watch for Changes", + "icon": "$(eye)" + }, + { + "command": "compass.stopWatch", + "title": "Compass: Stop Watching", + "icon": "$(debug-stop)" + }, + { + "command": "compass.openSettings", + "title": "Compass: Settings", + "icon": "$(settings-gear)" + }, { "command": "compass.openGraph", "title": "Compass: Open Code Graph", @@ -114,6 +129,20 @@ "default": 5000, "minimum": 1, "description": "Maximum detailed nodes before Compass uses a community overview." + }, + "compass.watch.debounceSeconds": { + "type": "number", + "default": 0.4, + "minimum": 0.05, + "maximum": 5, + "scope": "resource", + "description": "Seconds Compass waits for file changes to settle before updating the graph." + }, + "compass.watch.poll": { + "type": "boolean", + "default": false, + "scope": "resource", + "description": "Force polling when watching for repository changes." } } }, @@ -136,10 +165,42 @@ }, "menus": { "view/title": [ + { + "command": "compass.openSettings", + "when": "view == compass.status", + "group": "navigation@1" + }, { "command": "compass.refreshWorkspace", "when": "view == compass.status", - "group": "navigation" + "group": "navigation@2" + } + ], + "view/item/context": [ + { + "command": "compass.update", + "when": "view == compass.status && viewItem == compass.repository.ready", + "group": "inline@1" + }, + { + "command": "compass.update", + "when": "view == compass.status && viewItem == compass.repository.failed", + "group": "inline@1" + }, + { + "command": "compass.update", + "when": "view == compass.status && viewItem == compass.repository.watching", + "group": "inline@1" + }, + { + "command": "compass.startWatch", + "when": "view == compass.status && viewItem == compass.repository.ready", + "group": "inline@2" + }, + { + "command": "compass.stopWatch", + "when": "view == compass.status && viewItem == compass.repository.watching", + "group": "inline@2" } ], "editor/context": [ diff --git a/editors/vscode/src/commands/buildCommands.ts b/editors/vscode/src/commands/buildCommands.ts index 57b8b93a..8e643c26 100644 --- a/editors/vscode/src/commands/buildCommands.ts +++ b/editors/vscode/src/commands/buildCommands.ts @@ -29,7 +29,8 @@ export function registerBuildCommands( if (!await ensureCompatible(session, COMPASS_REQUIREMENTS.initialize)) return; await openInitializationPanel(context, session, output, refresh); }), - vscode.commands.registerCommand("compass.update", async (repositoryId?: string) => { + vscode.commands.registerCommand("compass.update", async (target?: unknown) => { + const repositoryId = repositoryIdFromTarget(target); const session = await pick( repositoryId, (candidate) => candidate.graphState === "available" || candidate.graphState === "failed" @@ -53,41 +54,75 @@ export function registerBuildCommands( refresh ); }), - vscode.commands.registerCommand("compass.toggleWatch", async (repositoryId?: string) => { - const session = await pick( - repositoryId, - (candidate) => candidate.graphState === "available" || candidate.watch !== undefined - ); - if (!session) return; - if (!await ensureCompatible(session, COMPASS_REQUIREMENTS.watch)) return; - if (session.watch) { - session.watch.cancel(); + vscode.commands.registerCommand( + "compass.toggleWatch", + (target?: unknown) => setWatch(target) + ), + vscode.commands.registerCommand( + "compass.startWatch", + (target?: unknown) => setWatch(target, true) + ), + vscode.commands.registerCommand( + "compass.stopWatch", + (target?: unknown) => setWatch(target, false) + ) + ); + + async function setWatch(target: unknown, requestedState?: boolean): Promise { + const repositoryId = repositoryIdFromTarget(target); + const session = await pick( + repositoryId, + (candidate) => candidate.graphState === "available" || candidate.watch !== undefined + ); + if (!session) return; + if (!await ensureCompatible(session, COMPASS_REQUIREMENTS.watch)) return; + if (requestedState === Boolean(session.watch)) return; + if (session.watch) { + session.watch.cancel(); + session.watch = undefined; + void vscode.window.showInformationMessage("Compass watch stopped."); + await refresh(); + return; + } + const configuration = vscode.workspace.getConfiguration( + "compass", + vscode.Uri.file(session.root) + ); + const debounceSeconds = configuration.get("watch.debounceSeconds", 0.4); + const poll = configuration.get("watch.poll", false); + output.appendLine(`> compass ${buildWatchArgs({ + root: session.root, + debounceSeconds, + poll + }).join(" ")}`); + const command = session.processes.startJsonl( + session.root, + buildWatchArgs({ root: session.root, debounceSeconds, poll }), + (event) => output.appendLine(`[${event.phase}] ${event.message}`) + ); + session.watch = command; + void command.completed.finally(async () => { + if (session.watch?.operationId === command.operationId) { session.watch = undefined; - void vscode.window.showInformationMessage("Compass watch stopped."); - await refresh(); - return; } - output.appendLine(`> compass ${buildWatchArgs({ - root: session.root, - debounceSeconds: 0.4, - poll: false - }).join(" ")}`); - const command = session.processes.startJsonl( - session.root, - buildWatchArgs({ root: session.root, debounceSeconds: 0.4, poll: false }), - (event) => output.appendLine(`[${event.phase}] ${event.message}`) - ); - session.watch = command; - void command.completed.finally(async () => { - if (session.watch?.operationId === command.operationId) { - session.watch = undefined; - } - await refresh(); - }); - void vscode.window.showInformationMessage("Compass watch started."); await refresh(); - }) - ); + }); + void vscode.window.showInformationMessage("Compass watch started."); + await refresh(); + } +} + +function repositoryIdFromTarget(target: unknown): string | undefined { + if (typeof target === "string") return target; + if ( + typeof target === "object" + && target !== null + && "repositoryId" in target + && typeof target.repositoryId === "string" + ) { + return target.repositoryId; + } + return undefined; } async function runGuided( diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 62893854..d5994073 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -120,11 +120,83 @@ export async function activate(context: vscode.ExtensionContext): Promise ); return picked?.session; }; + const openCompassSettings = async () => { + const selected = await vscode.window.showQuickPick([ + { + label: "$(settings-gear) Extension and watch settings", + description: "CLI path, graph limits, debounce, and polling", + action: "extension" + }, + { + label: "$(folder-library) Repository scope", + description: "Review included and excluded paths", + action: "scope" + }, + { + label: "$(file-code) Repository configuration", + description: "Open .compass/config.toml", + action: "configuration" + }, + { + label: "$(history) History profile", + description: "Configure versioned graph profiles", + action: "history" + }, + { + label: "$(terminal) Select Compass CLI", + description: "Choose a Compass executable", + action: "cli" + } + ], { + placeHolder: "Choose which Compass settings to configure" + }); + if (!selected) return; + if (selected.action === "extension") { + await vscode.commands.executeCommand( + "workbench.action.openSettings", + "@ext:crabbuild.compass-vscode" + ); + return; + } + if (selected.action === "cli") { + await vscode.commands.executeCommand("compass.selectCli"); + return; + } + const session = await selectRepository(); + if (!session) return; + if (selected.action === "scope") { + await vscode.commands.executeCommand("compass.initialize", session.id); + return; + } + if (selected.action === "history") { + await vscode.commands.executeCommand("compass.openHistory", session.id); + return; + } + const configurationUri = vscode.Uri.joinPath( + vscode.Uri.file(session.root), + ".compass", + "config.toml" + ); + try { + await vscode.workspace.fs.stat(configurationUri); + const document = await vscode.workspace.openTextDocument(configurationUri); + await vscode.window.showTextDocument(document); + } catch { + const action = await vscode.window.showInformationMessage( + "This repository does not have a Compass configuration yet.", + "Configure scope" + ); + if (action === "Configure scope") { + await vscode.commands.executeCommand("compass.initialize", session.id); + } + } + }; context.subscriptions.push( vscode.window.registerTreeDataProvider("compass.status", workspaceTree), vscode.window.onDidChangeActiveTextEditor(() => statusBar.refresh()), vscode.commands.registerCommand("compass.refreshWorkspace", refresh), vscode.commands.registerCommand("compass.selectCli", selectCompassBinary), + vscode.commands.registerCommand("compass.openSettings", openCompassSettings), vscode.commands.registerCommand("compass.openGraph", async (repositoryId?: string) => { if (!vscode.workspace.isTrusted) { void vscode.window.showWarningMessage("Trust this workspace to run Compass."); diff --git a/editors/vscode/src/test/suite/extension.integration.ts b/editors/vscode/src/test/suite/extension.integration.ts index 3bb49965..46d4f23b 100644 --- a/editors/vscode/src/test/suite/extension.integration.ts +++ b/editors/vscode/src/test/suite/extension.integration.ts @@ -12,6 +12,9 @@ suite("Compass extension", () => { "compass.update", "compass.refreshWorkspace", "compass.toggleWatch", + "compass.startWatch", + "compass.stopWatch", + "compass.openSettings", "compass.openGraph", "compass.openCallGraph", "compass.openArchitecture", @@ -33,8 +36,8 @@ suite("Compass extension", () => { assert.deepEqual( viewTitle.filter((item) => item.when === "view == compass.status") .map((item) => item.command), - ["compass.refreshWorkspace"], - "Workspace exposes one read-only title action" + ["compass.openSettings", "compass.refreshWorkspace"], + "Workspace exposes settings and refresh title actions" ); await vscode.commands.executeCommand("compass.refreshWorkspace"); }); diff --git a/editors/vscode/src/views/historyPanel.ts b/editors/vscode/src/views/historyPanel.ts index 46ebc70b..7c954a0e 100644 --- a/editors/vscode/src/views/historyPanel.ts +++ b/editors/vscode/src/views/historyPanel.ts @@ -526,7 +526,7 @@ function html(context: vscode.ExtensionContext, webview: vscode.Webview): string const nonce = randomUUID().replaceAll("-", ""); return ` - + Compass Evolution
`; diff --git a/editors/vscode/src/views/treeItem.ts b/editors/vscode/src/views/treeItem.ts index 55af032a..c287f729 100644 --- a/editors/vscode/src/views/treeItem.ts +++ b/editors/vscode/src/views/treeItem.ts @@ -9,6 +9,7 @@ export function treeItemFromNode(node: TreeNode): vscode.TreeItem { : vscode.TreeItemCollapsibleState.None; const item = new vscode.TreeItem(node.label, collapsibleState); item.id = node.id; + if (node.contextValue !== undefined) item.contextValue = node.contextValue; if (node.description !== undefined) item.description = node.description; if (node.tooltip !== undefined) item.tooltip = node.tooltip; item.iconPath = new vscode.ThemeIcon(node.icon); diff --git a/editors/vscode/src/views/treeModel.test.ts b/editors/vscode/src/views/treeModel.test.ts index 174f12ef..c519c84c 100644 --- a/editors/vscode/src/views/treeModel.test.ts +++ b/editors/vscode/src/views/treeModel.test.ts @@ -23,12 +23,13 @@ describe("buildWorkspaceTree", () => { expect(nodes.map((node) => node.label)).toEqual([ "repo", - "Explore", - "Maintain" + "Explore" ]); expect(nodes[0]).toMatchObject({ description: "Graph ready", - tooltip: "/work/repo" + tooltip: "/work/repo", + contextValue: "compass.repository.ready", + repositoryId: "repository-1" }); expect(nodes[0]?.children).toBeUndefined(); expect(nodes[1]?.expanded).toBe(true); @@ -39,11 +40,6 @@ describe("buildWorkspaceTree", () => { ["Ask codebase", "compass.openQuery"], ["Codebase evolution", "compass.openHistory"] ]); - expect(nodes[2]?.expanded).toBeUndefined(); - expect(nodes[2]?.children?.map((node) => [node.label, node.command])).toEqual([ - ["Update graph", "compass.update"], - ["Watch for changes", "compass.toggleWatch"] - ]); expect(new Set(commands(nodes)).size).toBe(commands(nodes).length); }); @@ -57,10 +53,10 @@ describe("buildWorkspaceTree", () => { expect(nodes.map((node) => node.label)).toEqual([ "repo", "Active operations", - "Explore", - "Maintain" + "Explore" ]); expect(nodes[1]).toMatchObject({ description: "2", expanded: true }); + expect(nodes[0]?.contextValue).toBe("compass.repository.watching"); expect(nodes[1]?.children?.map((node) => ({ label: node.label, description: node.description, @@ -69,7 +65,6 @@ describe("buildWorkspaceTree", () => { { label: "Building graph", description: "repo", command: undefined }, { label: "Watching for changes", description: "repo", command: undefined } ]); - expect(nodes[3]?.children?.at(-1)?.label).toBe("Stop watching"); expect(new Set(commands(nodes)).size).toBe(commands(nodes).length); }); @@ -160,8 +155,6 @@ describe("buildWorkspaceTree", () => { expect(nodes.filter((node) => node.id.startsWith("repository:")).map((node) => node.label)) .toEqual(["repo", "other"]); expect(nodes.filter((node) => node.label === "Explore")).toHaveLength(1); - expect(nodes.filter((node) => node.label === "Maintain")).toHaveLength(1); - expect(commands(nodes).filter((command) => command === "compass.update")).toHaveLength(1); expect(new Set(commands(nodes)).size).toBe(commands(nodes).length); }); }); diff --git a/editors/vscode/src/views/treeModel.ts b/editors/vscode/src/views/treeModel.ts index 348c823e..9da86390 100644 --- a/editors/vscode/src/views/treeModel.ts +++ b/editors/vscode/src/views/treeModel.ts @@ -10,6 +10,8 @@ export type TreeNode = { icon: string; command?: string; commandArguments?: unknown[]; + contextValue?: string; + repositoryId?: string; expanded?: boolean; children?: TreeNode[]; }; @@ -81,8 +83,6 @@ export function buildWorkspaceTree( const hasMissing = missing.length > 0; const hasGraph = sessions.some((session) => session.graphState === "available"); const hasFailed = failed.length > 0; - const hasWatch = sessions.some((session) => Boolean(session.watch)); - if (hasMissing) { nodes.push(actionNode( "workspace:initialize", @@ -152,37 +152,6 @@ export function buildWorkspaceTree( children: explore }); - const maintain: TreeNode[] = []; - if (hasGraph && !hasFailed) { - maintain.push(actionNode( - "workspace:update", - "Update graph", - "refresh", - "compass.update", - "Refresh changed code relationships" - )); - } - if (hasGraph || hasWatch) { - maintain.push(actionNode( - "workspace:watch", - sessions.length === 1 && hasWatch ? "Stop watching" : "Watch for changes", - sessions.length === 1 && hasWatch ? "debug-stop" : "eye", - "compass.toggleWatch", - sessions.length === 1 - ? hasWatch - ? "Stop watching this repository" - : "Keep the graph current as files change" - : "Choose a repository to start or stop watching" - )); - } - if (maintain.length > 0) { - nodes.push({ - id: "workspace:maintain", - label: "Maintain", - icon: "tools", - children: maintain - }); - } return nodes; } @@ -219,13 +188,22 @@ function repositoryStatusNodes( ): TreeNode[] { return sessions.map((session) => ({ id: `repository:${session.id}`, + repositoryId: session.id, label: path.basename(session.root) || session.root, description: graphStateLabel(session.graphState), tooltip: session.root, - icon: graphStateIcon(session.graphState) + icon: graphStateIcon(session.graphState), + contextValue: repositoryContextValue(session) })); } +function repositoryContextValue(session: SessionTreeSnapshot): string { + if (session.graphState === "available") { + return session.watch ? "compass.repository.watching" : "compass.repository.ready"; + } + return `compass.repository.${session.graphState}`; +} + function activeOperationGroup( sessions: readonly SessionTreeSnapshot[] ): TreeNode[] { diff --git a/packages/compass-viewer/src/history/HistoryWorkspace.test.tsx b/packages/compass-viewer/src/history/HistoryWorkspace.test.tsx index 4a8bf180..4cf43156 100644 --- a/packages/compass-viewer/src/history/HistoryWorkspace.test.tsx +++ b/packages/compass-viewer/src/history/HistoryWorkspace.test.tsx @@ -34,7 +34,7 @@ function host(): HistoryHost { } describe("HistoryWorkspace", () => { - it("puts a zero-topology comparison explanation before the graph canvas", () => { + it("opens comparisons on source changes and keeps the graph in the second tab", () => { const container = document.createElement("div"); const root = createRoot(container); const parent = "b".repeat(40); @@ -116,12 +116,31 @@ describe("HistoryWorkspace", () => { const explanation = Array.from(container.querySelectorAll("*")) .find((element) => element.textContent === "No structural graph changes"); - const graphFrame = container.querySelector(".history-graph-frame"); + const comparisonWorkspace = container.querySelector(".history-comparison-workspace"); expect(explanation).toBeDefined(); - expect(graphFrame).not.toBeNull(); - if (!explanation || !graphFrame) throw new Error("comparison layout did not render"); - expect(explanation.compareDocumentPosition(graphFrame) & Node.DOCUMENT_POSITION_FOLLOWING) + expect(comparisonWorkspace).not.toBeNull(); + if (!explanation || !comparisonWorkspace) { + throw new Error("comparison layout did not render"); + } + expect( + explanation.compareDocumentPosition(comparisonWorkspace) + & Node.DOCUMENT_POSITION_FOLLOWING + ) .toBeTruthy(); + const tabs = Array.from(container.querySelectorAll('[role="tab"]')); + expect(tabs.map((tab) => tab.textContent)).toEqual([ + "Source changes0", + "Changed graph0", + "Semantic findings0" + ]); + expect(tabs[0]?.getAttribute("aria-selected")).toBe("true"); + expect(container.querySelector(".history-graph-frame")).toBeNull(); + + flushSync(() => tabs[1]?.click()); + + expect(tabs[1]?.getAttribute("aria-selected")).toBe("true"); + expect(container.querySelector(".history-graph-frame")).not.toBeNull(); + expect(container.textContent).toContain("No graph delta to draw"); expect(container.textContent).toContain(`Comparing ${commit.slice(0, 9)} to ${parent.slice(0, 9)}`); expect(container.textContent).toContain( "No structural changes from the first parent. Source or configuration changes may still exist" diff --git a/packages/compass-viewer/src/history/HistoryWorkspace.tsx b/packages/compass-viewer/src/history/HistoryWorkspace.tsx index bfa964a1..0a8b7c33 100644 --- a/packages/compass-viewer/src/history/HistoryWorkspace.tsx +++ b/packages/compass-viewer/src/history/HistoryWorkspace.tsx @@ -1,5 +1,16 @@ -import { useMemo, useState } from "react"; -import { HistoryIcon, SearchIcon } from "lucide-react"; +import { + useEffect, + useMemo, + useState, + type KeyboardEvent as ReactKeyboardEvent +} from "react"; +import { + FileDiffIcon, + HistoryIcon, + NetworkIcon, + SearchIcon, + SparklesIcon +} from "lucide-react"; import { WorkspaceState } from "../components/workbench/WorkspaceState"; import { CompassGraph } from "../graph/CompassGraph"; import type { GraphViewModel, SourceLocation } from "../contracts/graph"; @@ -12,7 +23,13 @@ import type { import { CommitDetails } from "./CommitDetails"; import { CommitRail } from "./CommitRail"; import { ComparisonOverlay, type GraphComparison } from "./ComparisonOverlay"; -import { SemanticFindings } from "./SemanticFindings"; +import { + SemanticFindings, + SourceChangeEvidence, + semanticEvidence +} from "./SemanticFindings"; + +type ComparisonTab = "source" | "graph" | "semantic"; export type HistoryHost = { enableHistory(): void; @@ -70,6 +87,10 @@ export function HistoryWorkspace({ host: HistoryHost; }) { const [query, setQuery] = useState(""); + const [comparisonTab, setComparisonTab] = useState("source"); + useEffect(() => { + setComparisonTab("source"); + }, [comparison?.parent, selectedCommit]); const entries = useMemo(() => { const normalizedQuery = query.trim().toLocaleLowerCase(); return timeline.entries.filter((entry) => !normalizedQuery @@ -91,6 +112,7 @@ export function HistoryWorkspace({ const countLabel = timeline.hasMore ? `${loadedEntries.toLocaleString()} loaded commits` : `${(timeline.totalEntries ?? loadedEntries).toLocaleString()} reachable commits`; + const evidence = useMemo(() => semanticEvidence(semanticDiff), [semanticDiff]); return (
@@ -213,103 +235,244 @@ export function HistoryWorkspace({ onExit={() => onExitComparison?.()} /> )} - {comparison && semanticDiff !== undefined && } -
- {comparison && comparison.graph.nodes.length === 0 - && comparison.graph.edges.length === 0 ? ( - - ) : visibleGraph ? ( -
-
- {comparison ? "Viewing changed subgraph for " : "Viewing graph for "} - {selected.commit.slice(0, 9)} + {comparison ? ( +
+
+ + + +
+ + {comparisonTab === "source" && ( +
+ +
+ )} + + {comparisonTab === "graph" && ( +
+
+ {comparison.graph.nodes.length === 0 + && comparison.graph.edges.length === 0 ? ( + + ) : ( +
+
+ Viewing changed subgraph for{" "} + {selected.commit.slice(0, 9)} +
+
+ +
+
+ )} +
+
+ )} + + {comparisonTab === "semantic" && ( +
+
-
- + ) : ( + <> +
+ {visibleGraph ? ( +
+
+ Viewing graph for {selected.commit.slice(0, 9)} +
+
+ +
+
+ ) : buildState?.status === "requesting" ? ( + + ) : buildState?.status === "running" ? ( + + ) : buildState?.status === "failed" ? ( + host.buildRevision(selected.commit) }} /> -
+ ) : operationError?.operation === "Load graph" ? ( + host.loadRevision(selected.commit) + }} + /> + ) : !selected.presentationAvailable ? ( + host.buildRevision(selected.commit) + }} + /> + ) : revisionLoadState === "loading" ? ( + + ) : ( + host.loadRevision(selected.commit) + }} + /> + )}
- ) : buildState?.status === "requesting" ? ( - - ) : buildState?.status === "running" ? ( - - ) : buildState?.status === "failed" ? ( - host.buildRevision(selected.commit) - }} - /> - ) : operationError?.operation === "Load graph" ? ( - host.loadRevision(selected.commit) - }} - /> - ) : !selected.presentationAvailable ? ( - host.buildRevision(selected.commit) - }} - /> - ) : revisionLoadState === "loading" ? ( - - ) : ( - host.loadRevision(selected.commit) - }} - /> - )} -
- {!comparison && semanticDiff !== undefined && } + {semanticDiff !== undefined && ( +
+ + +
+ )} + + )} )}
); } + +function handleComparisonTabKeyDown(event: ReactKeyboardEvent) { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + const tabs = Array.from( + event.currentTarget.querySelectorAll('[role="tab"]') + ); + const current = tabs.indexOf(document.activeElement as HTMLButtonElement); + if (current < 0) return; + let next = current; + if (event.key === "ArrowLeft") next = (current - 1 + tabs.length) % tabs.length; + if (event.key === "ArrowRight") next = (current + 1) % tabs.length; + if (event.key === "Home") next = 0; + if (event.key === "End") next = tabs.length - 1; + event.preventDefault(); + tabs[next]?.focus(); + tabs[next]?.click(); +} diff --git a/packages/compass-viewer/src/history/SemanticFindings.test.tsx b/packages/compass-viewer/src/history/SemanticFindings.test.tsx index cd7b2d8f..9c0da541 100644 --- a/packages/compass-viewer/src/history/SemanticFindings.test.tsx +++ b/packages/compass-viewer/src/history/SemanticFindings.test.tsx @@ -1,9 +1,8 @@ -import { parsePatchFiles } from "@pierre/diffs"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { describe, expect, it } from "vitest"; -import { SemanticFindings } from "./SemanticFindings"; -import { minimumDiffHeight, normalizeSourcePatch } from "./SourceChanges"; +import { SemanticFindings, SourceChangeEvidence } from "./SemanticFindings"; +import { normalizeSourcePatch } from "./SourceChanges"; globalThis.ResizeObserver = class { observe() {} @@ -26,31 +25,11 @@ describe("SemanticFindings", () => { ); }); - it("reserves enough height for every compact diff hunk", () => { - const patch = [ - "diff --git a/example.ts b/example.ts", - "--- a/example.ts", - "+++ b/example.ts", - "@@ -1,0 +1,2 @@", - "+const first = true;", - "+const second = true;", - "@@ -4 +6 @@", - "-const value = 1;", - "+const value = 2;", - "" - ].join("\n"); - const fileDiff = parsePatchFiles(patch, "height-test", true)[0]?.files?.[0]; - - expect(fileDiff).toBeDefined(); - expect(minimumDiffHeight(fileDiff!, "split")).toBe(137); - expect(minimumDiffHeight(fileDiff!, "unified")).toBe(156); - }); - it("renders source-only changes as readable evidence instead of a raw report dump", () => { const container = document.createElement("div"); const root = createRoot(container); flushSync(() => root.render( - { expect(container.querySelector("h2")?.textContent).toBe("Source changes"); expect(container.textContent).toContain("Cargo.toml"); expect(container.textContent).toContain("+1−1"); - expect(container.textContent).toContain("No semantic graph findings for this comparison."); expect(container.textContent).not.toContain("\"source_changes\""); root.unmount(); }); + + it("renders semantic evidence as comfortable expandable fields", () => { + const container = document.createElement("div"); + const root = createRoot(container); + flushSync(() => root.render( + + )); + + const cards = container.querySelectorAll( + ".history-finding-list details" + ); + expect(cards).toHaveLength(2); + expect(cards[0]?.open).toBe(true); + expect(cards[1]?.open).toBe(false); + expect(container.textContent).toContain("2 evidence fields"); + expect(container.textContent).toContain("Affected symbols"); + expect(container.textContent).toContain("resolveOrgRef"); + expect(container.textContent).not.toContain("\"affected_symbols\""); + root.unmount(); + }); }); diff --git a/packages/compass-viewer/src/history/SemanticFindings.tsx b/packages/compass-viewer/src/history/SemanticFindings.tsx index 8187cce1..b1513561 100644 --- a/packages/compass-viewer/src/history/SemanticFindings.tsx +++ b/packages/compass-viewer/src/history/SemanticFindings.tsx @@ -1,48 +1,141 @@ -import { FileDiffIcon, SparklesIcon } from "lucide-react"; +import { ChevronRightIcon, FileDiffIcon, SparklesIcon } from "lucide-react"; +import { useEffect, useState } from "react"; import { Badge } from "../components/ui/badge"; import { SourceChanges, type SourceChange } from "./SourceChanges"; -export function SemanticFindings({ report }: { report: unknown }) { +export type SemanticEvidence = { + sourceChanges: SourceChange[]; + findings: unknown[]; +}; + +export function semanticEvidence(report: unknown): SemanticEvidence { const value = report && typeof report === "object" ? report as Record : {}; - const findings = Array.isArray(value.findings) ? value.findings : []; - const sourceChanges = Array.isArray(value.source_changes) - ? value.source_changes.filter(isSourceChange) - : []; + return { + findings: Array.isArray(value.findings) ? value.findings : [], + sourceChanges: Array.isArray(value.source_changes) + ? value.source_changes.filter(isSourceChange) + : [] + }; +} + +export function SourceChangeEvidence({ report }: { report: unknown }) { + const { sourceChanges } = semanticEvidence(report); return ( -
-
-
+
+
+
+
+

Review the exact files and lines changed between these revisions.

+
{sourceChanges.length > 0 ? ( ) : (

No source patch was reported for this comparison.

)} -
-
+
+ ); +} + +export function SemanticFindings({ report }: { report: unknown }) { + const { findings } = semanticEvidence(report); + const [openFindings, setOpenFindings] = useState>( + () => new Set(findings.length ? [0] : []) + ); + + useEffect(() => { + setOpenFindings((current) => new Set( + [...current].filter((index) => index < findings.length) + )); + }, [findings.length]); + + const allOpen = findings.length > 0 && openFindings.size === findings.length; + + return ( +
+
+
+
+
+

Inspect behavior and relationship changes inferred from the graph comparison.

+
+ {findings.length > 1 && ( + + )} +
{findings.length > 0 ? (
- {findings.map((finding, index) => ( -
- {findingSummary(finding, index)} - {findingDetails(finding) && ( -
- Finding details -
{JSON.stringify(findingDetails(finding), null, 2)}
-
- )} -
- ))} + {findings.map((finding, index) => { + const details = findingDetails(finding); + return ( +
{ + const nextOpen = event.currentTarget.open; + setOpenFindings((current) => { + const next = new Set(current); + if (nextOpen) next.add(index); + else next.delete(index); + return next; + }); + }} + > + + + + {findingSummary(finding, index)} + + {details + ? `${Object.keys(details).length} evidence ${Object.keys(details).length === 1 ? "field" : "fields"}` + : "Summary only"} + + + +
+ {details ? ( +
+ {Object.entries(details).map(([key, value]) => ( +
+
{humanize(key)}
+
{renderFindingValue(value)}
+
+ ))} +
+ ) : ( +

No additional structured evidence was reported for this finding.

+ )} +
+
+ ); + })}
) : ( -

No semantic graph findings for this comparison.

+
+
)}
); @@ -74,3 +167,33 @@ function findingDetails(finding: unknown): Record | undefined { ); return Object.keys(details).length > 0 ? details : undefined; } + +function humanize(value: string): string { + const words = value + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replaceAll("_", " ") + .replaceAll("-", " "); + return words.charAt(0).toLocaleUpperCase() + words.slice(1); +} + +function renderFindingValue(value: unknown) { + if (value === null || value === undefined) { + return Not reported; + } + if (Array.isArray(value)) { + if (value.length === 0) return None; + return ( +
    + {value.map((item, index) => ( +
  • {renderFindingValue(item)}
  • + ))} +
+ ); + } + if (typeof value === "object") { + return
{JSON.stringify(value, null, 2)}
; + } + if (typeof value === "boolean") return {value ? "Yes" : "No"}; + if (typeof value === "number") return {value.toLocaleString()}; + return {String(value)}; +} diff --git a/packages/compass-viewer/src/history/SourceChanges.tsx b/packages/compass-viewer/src/history/SourceChanges.tsx index ac6dc637..5f37d183 100644 --- a/packages/compass-viewer/src/history/SourceChanges.tsx +++ b/packages/compass-viewer/src/history/SourceChanges.tsx @@ -1,4 +1,4 @@ -import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"; +import { parsePatchFiles } from "@pierre/diffs"; import { FileDiff } from "@pierre/diffs/react"; import { Component, @@ -19,24 +19,6 @@ export type SourceChange = { type DiffStyle = "split" | "unified"; -const DIFF_THEME_CSS = `:host { - --diffs-font-family: var(--compass-font-mono); - --diffs-font-size: 11px; - --diffs-line-height: 19px; - --diffs-gap-inline: 6px; - --diffs-min-number-column-width: 3ch; -} - -[data-gutter], -[data-content] { - grid-template-rows: none; - grid-auto-rows: minmax(var(--diffs-line-height, 19px), auto); - align-content: start; -}`; -const DIFF_LINE_HEIGHT = 19; -const DIFF_HUNK_SEPARATOR_HEIGHT = 32; -const DIFF_VERTICAL_PADDING = 16; - export function SourceChanges({ changes }: { changes: SourceChange[] }) { const [preferredStyle, setPreferredStyle] = useState("split"); const [wrap, setWrap] = useState(false); @@ -204,10 +186,7 @@ function SourcePatch({ fileDiff={parsed.fileDiff} disableWorkerPool className="history-source-diff" - style={{ - ...themeStyle, - minHeight: minimumDiffHeight(parsed.fileDiff, style) - }} + style={themeStyle} options={{ theme: themeType === "light" ? "pierre-light" : "pierre-dark", themeType, @@ -216,29 +195,13 @@ function SourcePatch({ hunkSeparators: "metadata", lineDiffType: "word-alt", overflow: wrap ? "wrap" : "scroll", - disableFileHeader: true, - unsafeCSS: DIFF_THEME_CSS + disableFileHeader: true }} /> ); } -export function minimumDiffHeight( - fileDiff: FileDiffMetadata, - style: DiffStyle -): number { - const lineCount = fileDiff.hunks.reduce( - (total, hunk) => total + ( - style === "split" ? hunk.splitLineCount : hunk.unifiedLineCount - ), - 0 - ); - return lineCount * DIFF_LINE_HEIGHT - + fileDiff.hunks.length * DIFF_HUNK_SEPARATOR_HEIGHT - + DIFF_VERTICAL_PADDING; -} - function PatchFallback({ patch }: { patch: string }) { return (
@@ -339,6 +302,11 @@ function useVscodeDiffTheme(): { ); return { colorScheme: theme, + "--diffs-font-family": "var(--compass-font-mono)", + "--diffs-font-size": "11px", + "--diffs-line-height": "19px", + "--diffs-gap-inline": "6px", + "--diffs-min-number-column-width": "3ch", "--diffs-light-bg": background, "--diffs-dark-bg": background, "--diffs-light": foreground, diff --git a/packages/compass-viewer/src/theme.css b/packages/compass-viewer/src/theme.css index 9d7bd052..97406821 100644 --- a/packages/compass-viewer/src/theme.css +++ b/packages/compass-viewer/src/theme.css @@ -2935,8 +2935,106 @@ body.vscode-high-contrast-light .query-mode button[aria-selected="true"] { font-size: 12px; } -.history-diff-evidence { - padding: 11px 12px 12px; +.history-comparison-workspace { + min-width: 0; + margin-top: 12px; + overflow: hidden; + border: 1px solid var(--border); + background: var(--vscode-editor-background, var(--background)); + color: var(--vscode-editor-foreground, var(--foreground)); +} + +.history-comparison-tabs { + display: flex; + min-width: 0; + overflow-x: auto; + border-bottom: 1px solid var(--border); + background: var( + --vscode-editorGroupHeader-tabsBackground, + var(--vscode-sideBar-background, var(--background)) + ); + scrollbar-width: thin; +} + +.history-comparison-tabs button { + position: relative; + display: inline-flex; + min-width: max-content; + min-height: 41px; + align-items: center; + gap: 7px; + padding: 0 14px; + border: 0; + border-right: 1px solid var(--border); + background: transparent; + color: var(--vscode-tab-inactiveForeground, var(--muted-foreground)); + font: 11px/1 var(--compass-font-sans); +} + +.history-comparison-tabs button:hover { + background: var(--workbench-hover); + color: var(--vscode-tab-activeForeground, var(--foreground)); +} + +.history-comparison-tabs button[aria-selected="true"] { + background: var( + --vscode-tab-activeBackground, + var(--vscode-editor-background, var(--background)) + ); + color: var(--vscode-tab-activeForeground, var(--foreground)); + box-shadow: inset 0 2px 0 var( + --vscode-tab-activeBorder, + var(--vscode-focusBorder, var(--ring)) + ); +} + +.history-comparison-tabs button:focus-visible { + z-index: 1; + outline: 1px solid var(--vscode-focusBorder, var(--ring)); + outline-offset: -2px; +} + +.history-comparison-tabs button > svg { + width: 14px; + height: 14px; + color: var(--vscode-focusBorder, var(--ring)); +} + +.history-comparison-tab-count { + display: inline-flex; + min-width: 19px; + height: 19px; + align-items: center; + justify-content: center; + padding: 0 5px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--vscode-badge-background, var(--muted)); + color: var(--vscode-badge-foreground, var(--foreground)); + font: 9px/1 var(--compass-font-mono); +} + +.history-comparison-tab-panel { + min-width: 0; + background: var(--vscode-editor-background, var(--background)); +} + +.history-evidence-panel { + min-width: 0; + padding: 15px 15px 17px; +} + +.history-evidence-header { + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.history-evidence-header > p, +.history-findings-header > div > p { + margin: 4px 0 0 22px; + color: var(--muted-foreground); + font-size: 11px; + line-height: 1.45; } .history-diff-heading { @@ -2946,25 +3044,41 @@ body.vscode-high-contrast-light .query-mode button[aria-selected="true"] { } .history-diff-heading h2 { - font-size: 12px; + margin: 0; + font-size: 13px; font-weight: 600; } -.history-semantic-heading { - margin-top: 12px; - padding-top: 10px; - border-top: 1px solid var(--border); +.history-findings-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; } -.history-source-changes, -.history-finding-list { +.history-findings-expand { + flex: 0 0 auto; + min-height: 27px; + padding: 4px 9px; + border: 1px solid var(--border); + border-radius: 2px; + background: transparent; + color: var(--foreground); + font-size: 10px; +} + +.history-findings-expand:hover { + border-color: var(--vscode-focusBorder, var(--ring)); + background: var(--workbench-hover); +} + +.history-source-changes { display: grid; gap: 6px; - margin-top: 8px; + margin-top: 12px; } -.history-source-changes details, -.history-finding-list article { +.history-source-changes details { overflow: hidden; border: 1px solid var(--border); border-radius: 2px; @@ -2976,7 +3090,7 @@ body.vscode-high-contrast-light .query-mode button[aria-selected="true"] { display: flex; align-items: center; gap: 6px; - margin-top: 8px; + margin-top: 12px; } .history-diff-layout { @@ -3113,8 +3227,7 @@ body.vscode-high-contrast-light .query-mode button[aria-selected="true"] { color: var(--vscode-gitDecoration-modifiedResourceForeground, #d29922); } -.history-diff-fallback pre, -.history-finding-list pre { +.history-diff-fallback pre { max-height: 260px; margin: 0; padding: 8px; @@ -3126,16 +3239,216 @@ body.vscode-high-contrast-light .query-mode button[aria-selected="true"] { .history-source-changes p, .history-diff-empty { - margin: 8px 0 0; + margin: 12px 0 0; color: var(--muted-foreground); font-size: 11px; } -.history-finding-list article > strong { - display: block; - padding: 6px 8px; +.history-finding-list { + display: grid; + gap: 10px; + margin-top: 14px; +} + +.history-finding-list details { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 4px; + background: color-mix( + in srgb, + var(--vscode-editor-foreground, var(--foreground)) 2%, + var(--vscode-editor-background, var(--background)) + ); + color: var(--vscode-editor-foreground, var(--foreground)); +} + +.history-finding-list details[open] { + border-color: color-mix( + in srgb, + var(--vscode-focusBorder, var(--ring)) 58%, + var(--border) + ); + box-shadow: inset 3px 0 0 var(--vscode-focusBorder, var(--ring)); +} + +.history-finding-list summary { + display: grid; + min-height: 58px; + grid-template-columns: 28px minmax(0, 1fr) 16px; + align-items: center; + gap: 11px; + padding: 11px 13px; + cursor: pointer; + list-style: none; +} + +.history-finding-list summary::-webkit-details-marker { + display: none; +} + +.history-finding-list summary:hover { + background: var(--workbench-hover); +} + +.history-finding-list summary:focus-visible { + outline: 1px solid var(--vscode-focusBorder, var(--ring)); + outline-offset: -2px; +} + +.history-finding-index { + display: inline-flex; + width: 27px; + height: 27px; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--vscode-editor-background, var(--background)); + color: var(--vscode-focusBorder, var(--ring)); + font: 600 10px/1 var(--compass-font-mono); +} + +.history-finding-summary { + display: grid; + min-width: 0; + gap: 4px; +} + +.history-finding-summary strong { + overflow: hidden; + font-size: 12px; + font-weight: 600; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-finding-summary small { + color: var(--muted-foreground); + font-size: 10px; +} + +.history-finding-list summary > svg { + width: 15px; + height: 15px; + color: var(--muted-foreground); + transition: transform 120ms ease; +} + +.history-finding-list details[open] summary > svg { + transform: rotate(90deg); +} + +.history-finding-body { + padding: 0 14px 15px 52px; + border-top: 1px solid var(--border); +} + +.history-finding-body > p { + margin: 13px 0 0; + color: var(--muted-foreground); + font-size: 11px; +} + +.history-finding-body dl { + display: grid; + gap: 0; + margin: 0; +} + +.history-finding-body dl > div { + display: grid; + grid-template-columns: minmax(110px, 170px) minmax(0, 1fr); + gap: 16px; + padding: 12px 0; border-bottom: 1px solid var(--border); +} + +.history-finding-body dl > div:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.history-finding-body dt { + color: var(--muted-foreground); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.history-finding-body dd { + min-width: 0; + margin: 0; font-size: 11px; + line-height: 1.5; +} + +.history-finding-body dd ul { + display: grid; + gap: 5px; + margin: 0; + padding-left: 17px; +} + +.history-finding-body dd pre { + max-height: 280px; + margin: 0; + padding: 9px 10px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--vscode-textCodeBlock-background, var(--muted)); + color: var(--vscode-editor-foreground, var(--foreground)); + font: 10px/1.55 var(--compass-font-mono); + white-space: pre-wrap; +} + +.history-finding-body dd code { + font-family: var(--compass-font-mono); +} + +.history-finding-muted { + color: var(--muted-foreground); + font-style: italic; +} + +.history-findings-empty { + display: flex; + align-items: flex-start; + gap: 10px; + margin-top: 14px; + padding: 15px; + border: 1px dashed var(--border); + border-radius: 4px; + color: var(--muted-foreground); +} + +.history-findings-empty > svg { + width: 17px; + height: 17px; + color: var(--vscode-focusBorder, var(--ring)); +} + +.history-findings-empty strong { + color: var(--foreground); + font-size: 12px; +} + +.history-findings-empty p { + margin: 4px 0 0; + font-size: 11px; +} + +.history-standalone-evidence { + display: grid; + gap: 12px; + margin-top: 12px; +} + +.history-standalone-evidence > section { + border: 1px solid var(--border); + background: var(--vscode-editor-background, var(--background)); } .history-graph-frame { @@ -3148,6 +3461,13 @@ body.vscode-high-contrast-light .query-mode button[aria-selected="true"] { background: var(--vscode-editor-background, var(--background)); } +.history-graph-frame-tabbed { + height: max(460px, calc(100vh - 230px)); + min-height: 420px; + margin-top: 0; + border: 0; +} + .history-graph-ready, .history-graph-canvas { min-width: 0; @@ -3199,6 +3519,10 @@ body.vscode-high-contrast .history-comparison, body.vscode-high-contrast-light .history-comparison, body.vscode-high-contrast .history-diff-evidence, body.vscode-high-contrast-light .history-diff-evidence, +body.vscode-high-contrast .history-comparison-workspace, +body.vscode-high-contrast-light .history-comparison-workspace, +body.vscode-high-contrast .history-finding-list details, +body.vscode-high-contrast-light .history-finding-list details, body.vscode-high-contrast .history-source-changes details, body.vscode-high-contrast-light .history-source-changes details, body.vscode-high-contrast .history-change-counts span, @@ -3238,6 +3562,33 @@ body.vscode-high-contrast-light .compass-change-badge { margin-left: 0; } + .history-comparison-tabs button { + min-height: 39px; + padding-inline: 11px; + } + + .history-evidence-panel { + padding: 12px; + } + + .history-findings-header { + align-items: stretch; + flex-direction: column; + } + + .history-findings-expand { + align-self: flex-start; + } + + .history-finding-body { + padding-left: 14px; + } + + .history-finding-body dl > div { + grid-template-columns: 1fr; + gap: 5px; + } + .query-composer-footer { flex-wrap: wrap; } diff --git a/tests/viewer/fixtures/generate.ts b/tests/viewer/fixtures/generate.ts index bbfa5f0e..fd5189b7 100644 --- a/tests/viewer/fixtures/generate.ts +++ b/tests/viewer/fixtures/generate.ts @@ -414,7 +414,7 @@ function historyHarness( graphs: Record, detail: unknown ): string { - return `Compass history fixture