diff --git a/crates/compass-cli/tests/viewer_export_cli.rs b/crates/compass-cli/tests/viewer_export_cli.rs index 15358b97..73333cda 100644 --- a/crates/compass-cli/tests/viewer_export_cli.rs +++ b/crates/compass-cli/tests/viewer_export_cli.rs @@ -149,6 +149,10 @@ fn callflow_json_exposes_the_shared_architecture_model() -> Result<(), Box, pub overview_links: Vec, + pub cross_section_calls: Vec, + pub coverage: CallflowCoverage, pub report_highlights: Vec, pub statistics: CallflowStatistics, pub provenance: CallflowProvenance, @@ -26,6 +28,8 @@ pub struct CallflowViewSection { pub id: String, pub name: String, pub communities: Vec, + pub node_count: usize, + pub internal_call_count: usize, pub nodes: Vec, pub edges: Vec, } @@ -37,6 +41,17 @@ pub struct CallflowViewNode { pub label: String, pub kind: String, pub source_file: Option, + pub scope: CallflowSourceScope, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CallflowSourceScope { + Production, + Test, + Generated, + Vendor, + Unknown, } #[derive(Clone, Debug, Serialize)] @@ -47,6 +62,25 @@ pub struct CallflowViewEdge { pub confidence: String, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CallflowCrossSectionCall { + pub source: String, + pub target: String, + pub source_section: String, + pub target_section: String, + pub relation: String, + pub confidence: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CallflowCoverage { + pub internal: usize, + pub cross_section: usize, + pub unassigned: usize, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CallflowViewLink { @@ -152,27 +186,49 @@ pub fn callflow_view_model( confidence: confidence(edge.string("confidence")), }) .collect::>(); + let node_count = nodes.len(); + let internal_call_count = edges.len(); view_sections.push(CallflowViewSection { id: section.id, name: section.name, communities: section.communities, + node_count, + internal_call_count, nodes, edges, }); } let mut link_counts = BTreeMap::<(String, String), usize>::new(); + let mut cross_section_calls = Vec::new(); + let mut coverage = CallflowCoverage { + internal: 0, + cross_section: 0, + unassigned: 0, + }; for edge in &document.links { let (Some(source), Some(target)) = ( node_section.get(edge.source.as_str()), node_section.get(edge.target.as_str()), ) else { + coverage.unassigned += 1; continue; }; - if source != target { - *link_counts - .entry((source.clone(), target.clone())) - .or_default() += 1; + if source == target { + coverage.internal += 1; + continue; } + coverage.cross_section += 1; + *link_counts + .entry((source.clone(), target.clone())) + .or_default() += 1; + cross_section_calls.push(CallflowCrossSectionCall { + source: edge.source.clone(), + target: edge.target.clone(), + source_section: source.clone(), + target_section: target.clone(), + relation: edge.string("relation"), + confidence: confidence(edge.string("confidence")), + }); } let overview_links = link_counts .into_iter() @@ -205,6 +261,8 @@ pub fn callflow_view_model( title: format!("{} — Architecture Flow", options.project_name), sections: view_sections, overview_links, + cross_section_calls, + coverage, report_highlights, statistics: CallflowStatistics { nodes: document.nodes.len(), @@ -247,10 +305,55 @@ fn view_node(node: &NodeRecord) -> CallflowViewNode { kind } }, + scope: source_scope(&source_file), source_file: (!source_file.is_empty()).then_some(source_file), } } +fn source_scope(source_file: &str) -> CallflowSourceScope { + if source_file.trim().is_empty() { + return CallflowSourceScope::Unknown; + } + let normalized = source_file.replace('\\', "/").to_ascii_lowercase(); + let segments = normalized.split('/').collect::>(); + let filename = segments.last().copied().unwrap_or_default(); + if segments + .iter() + .any(|segment| matches!(*segment, "test" | "tests" | "testing" | "__tests__")) + || filename.starts_with("test_") + || filename.contains("_test.") + || filename.contains(".test.") + || filename.contains(".spec.") + { + return CallflowSourceScope::Test; + } + if segments.iter().any(|segment| { + matches!( + *segment, + "generated" | "gen" | "dist" | "build" | "target" | ".next" + ) + }) || filename.contains(".generated.") + || filename.contains("_generated.") + { + return CallflowSourceScope::Generated; + } + if segments.iter().any(|segment| { + matches!( + *segment, + "vendor" + | "third_party" + | "third-party" + | "node_modules" + | ".venv" + | "venv" + | "site-packages" + ) + }) { + return CallflowSourceScope::Vendor; + } + CallflowSourceScope::Production +} + fn confidence(value: String) -> String { match value.to_ascii_lowercase().as_str() { "inferred" => "inferred", diff --git a/crates/compass-output/src/lib.rs b/crates/compass-output/src/lib.rs index d1ffdf95..5f05a643 100644 --- a/crates/compass-output/src/lib.rs +++ b/crates/compass-output/src/lib.rs @@ -24,9 +24,9 @@ pub use callflow::{ derive_callflow_sections, write_callflow_html, }; pub use callflow_model::{ - CALLFLOW_VIEWER_SCHEMA, CallflowProvenance, CallflowStatistics, CallflowViewEdge, - CallflowViewLink, CallflowViewModel, CallflowViewNode, CallflowViewSection, - callflow_view_model, + CALLFLOW_VIEWER_SCHEMA, CallflowCoverage, CallflowCrossSectionCall, CallflowProvenance, + CallflowSourceScope, CallflowStatistics, CallflowViewEdge, CallflowViewLink, CallflowViewModel, + CallflowViewNode, CallflowViewSection, callflow_view_model, }; pub use canvas::{CanvasOptions, canvas_document, write_canvas}; pub use cql::{render_cql_json, render_cql_jsonl, render_cql_table}; diff --git a/crates/compass-output/tests/callflow_model.rs b/crates/compass-output/tests/callflow_model.rs new file mode 100644 index 00000000..d186cdb2 --- /dev/null +++ b/crates/compass-output/tests/callflow_model.rs @@ -0,0 +1,140 @@ +use std::collections::BTreeMap; +use std::error::Error; + +use compass_model::GraphDocument; +use compass_output::{ + CALLFLOW_VIEWER_SCHEMA, CallflowOptions, CallflowSection, callflow_view_model, +}; +use serde_json::json; + +#[test] +fn complete_model_preserves_cross_section_calls_and_edge_coverage() -> Result<(), Box> { + let document: GraphDocument = serde_json::from_value(json!({ + "graph": {}, + "nodes": [ + {"id":"api","label":"api","community":0,"source_file":"src/api.rs"}, + {"id":"helper","label":"helper","community":0,"source_file":"src/helper.rs"}, + {"id":"store","label":"store","community":1,"source_file":"src/store.rs"}, + {"id":"orphan","label":"orphan","source_file":"src/orphan.rs"} + ], + "links": [ + {"source":"api","target":"helper","relation":"calls","confidence":"EXTRACTED"}, + {"source":"api","target":"store","relation":"calls","confidence":"INFERRED"}, + {"source":"orphan","target":"api","relation":"references","confidence":"AMBIGUOUS"} + ] + }))?; + let communities = BTreeMap::from([ + (0, vec!["api".to_owned(), "helper".to_owned()]), + (1, vec!["store".to_owned()]), + ]); + let sections = vec![ + CallflowSection { + id: "overview".to_owned(), + name: "Overview".to_owned(), + communities: vec![], + }, + CallflowSection { + id: "api".to_owned(), + name: "API".to_owned(), + communities: vec!["0".to_owned()], + }, + CallflowSection { + id: "storage".to_owned(), + name: "Storage".to_owned(), + communities: vec!["1".to_owned()], + }, + ]; + + let model = callflow_view_model( + &document, + &communities, + &CallflowOptions { + sections: Some(§ions), + project_name: "Fixture", + ..CallflowOptions::default() + }, + )?; + + assert_eq!(model.schema, CALLFLOW_VIEWER_SCHEMA); + assert_eq!(model.coverage.internal, 1); + assert_eq!(model.coverage.cross_section, 1); + assert_eq!(model.coverage.unassigned, 1); + assert_eq!( + model.coverage.internal + model.coverage.cross_section + model.coverage.unassigned, + document.links.len() + ); + assert_eq!(model.cross_section_calls.len(), 1); + let call = &model.cross_section_calls[0]; + assert_eq!(call.source, "api"); + assert_eq!(call.target, "store"); + assert_eq!(call.source_section, "api"); + assert_eq!(call.target_section, "storage"); + assert_eq!(call.relation, "calls"); + assert_eq!(call.confidence, "inferred"); + assert_eq!(model.sections[1].node_count, 2); + assert_eq!(model.sections[1].internal_call_count, 1); + Ok(()) +} + +#[test] +fn source_scopes_are_classified_without_discarding_nodes() -> Result<(), Box> { + let document: GraphDocument = serde_json::from_value(json!({ + "graph": {}, + "nodes": [ + {"id":"prod","community":0,"source_file":"src/lib.rs"}, + {"id":"test","community":0,"source_file":"tests/test_api.py"}, + {"id":"generated","community":0,"source_file":"generated/schema.rs"}, + {"id":"vendor","community":0,"source_file":"vendor/pkg/lib.rs"}, + {"id":"unknown","community":0} + ], + "links": [] + }))?; + let communities = BTreeMap::from([( + 0, + vec![ + "prod".to_owned(), + "test".to_owned(), + "generated".to_owned(), + "vendor".to_owned(), + "unknown".to_owned(), + ], + )]); + let sections = vec![CallflowSection { + id: "core".to_owned(), + name: "Core".to_owned(), + communities: vec!["0".to_owned()], + }]; + + let model = callflow_view_model( + &document, + &communities, + &CallflowOptions { + sections: Some(§ions), + ..CallflowOptions::default() + }, + )?; + let scopes = model.sections[1] + .nodes + .iter() + .map(|node| { + ( + node.id.as_str(), + serde_json::to_value(&node.scope) + .expect("scope serializes") + .as_str() + .expect("scope is text") + .to_owned(), + ) + }) + .collect::>(); + + assert_eq!(scopes.get("prod").map(String::as_str), Some("production")); + assert_eq!(scopes.get("test").map(String::as_str), Some("test")); + assert_eq!( + scopes.get("generated").map(String::as_str), + Some("generated") + ); + assert_eq!(scopes.get("vendor").map(String::as_str), Some("vendor")); + assert_eq!(scopes.get("unknown").map(String::as_str), Some("unknown")); + Ok(()) +} diff --git a/docs/superpowers/plans/2026-07-27-call-graph-resolution-pipeline-hardening.md b/docs/superpowers/plans/2026-07-27-call-graph-resolution-pipeline-hardening.md new file mode 100644 index 00000000..19e027a7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-call-graph-resolution-pipeline-hardening.md @@ -0,0 +1,875 @@ +# Call-Graph Resolution Pipeline Hardening — Implementation-First Plan + +**Goal:** Expand PR #74 with six production-focused commits that make Compass +call graphs responsive, progressive, deterministic, and resilient on large +repositories. + +**Implementation style:** Implement each production slice completely, then add +and run focused validation before committing it. Tests are commit-boundary +qualification, not the driver or structure of the implementation. + +**Target branch:** `codex/harden-call-graph-resolution` + +**Performance fixture:** 26,567 nodes and 63,204 edges + +## Product model + +Compass has two first-class call-graph surfaces: + +- `compass call-graph` provides a fast, language-neutral structural result and + may enrich that result with Program IR. +- `compass program call-graph` provides the dedicated Program IR call graph and + remains an active surface for future semantic enhancements. + +They share traversal and index infrastructure, but their response contracts +remain independent. This PR does not deprecate, replace, or freeze the Program +IR call graph. + +## Current implementation context + +### Rust analysis + +`crates/compass-analysis/src/universal_call_graph.rs` currently: + +- scans all graph nodes and structural `calls` edges for every CLI process; +- resolves the cursor against callable graph nodes; +- builds incoming/outgoing adjacency for that request; +- traverses within node and edge bounds; +- caps the continuation set, making some excluded branches unreachable; and +- optionally enriches from a fully loaded `AnalysisBundle`. + +`crates/compass-analysis/src/call_graph.rs` currently: + +- constructs the Program IR node and edge collections for every request; +- scans `all_edges` for each visited node; and +- owns a separate breadth-first traversal implementation. + +The structural traversal hardening already in PR #74 avoids the worst +per-visited-node scan for `compass call-graph`. This plan completes the work +without discarding that implementation. + +### CLI + +`crates/compass-cli/src/call_graph_commands.rs` currently: + +- loads and validates `graph.json` on each request; +- loads the full canonical Program IR when `--program` is supplied; +- supports only `--format json`; and +- returns one buffered response after all resolution is complete. + +`crates/compass-cli/src/bin/compass.rs` already has direct streaming routes for +commands such as watch. `crates/compass-cli/src/ide_contract.rs` and +`editors/vscode/src/cli/processManager.ts` already establish bounded JSONL +parsing patterns, but their generic progress event does not carry a call-graph +result. + +### VS Code and viewer + +`editors/vscode/src/views/callGraphPanel.ts` currently: + +- starts a fresh structural CLI process for every root, direction, or + expansion request; +- has one request controller; +- does not request Program IR enrichment; and +- reports only terminal errors. + +`editors/vscode/src/webviews/callGraph.tsx` renders a static three-step loading +screen. It does not receive real phases. The shared viewer shows only the +continuations present in the response and has no pagination or enrichment +status. + +### Artifact and build pipeline + +The canonical artifacts remain: + +```text +compass-out/graph.json +compass-out/program.json +``` + +The new disposable projections will be: + +```text +compass-out/cache/.graph.json.compass-call-index-v1 +compass-out/cache/.program.json.compass-call-index-v1 +``` + +`crates/compass-core/src/pipeline.rs` publishes the canonical artifacts and +`crates/compass-core/src/build_state.rs` records their verified SHA-256 seals. +Index publication must be best-effort and must never invalidate a successfully +published canonical artifact. + +## Non-negotiable contracts + +- Indexed cold-process structural resolution: at most 500 ms. +- VS Code repository-session cache hit: at most 100 ms. +- Cached Program IR enrichment: at most 1,000 ms. +- First progress event: at most 100 ms. +- Cancellation acknowledgement: at most 100 ms. +- Default continuation page: at most 100 branches and never larger than the + request node bound. +- No excluded frontier branch may become unreachable. +- Structural output renders before optional Program IR enrichment. +- `compass call-graph --format json` remains one compatible JSON document. +- `compass.call_graph/1` receives additive fields only. +- `compass.program.call_graph/1` remains compatible in this PR. +- Root lookup, traversal, evidence merging, bounds, and cursor validation stay + in Rust. +- Cache corruption, cache mismatch, and cache-write failure are recoverable. +- A missing or invalid Program IR artifact cannot remove a structural result. +- Superseded or disposed requests cannot render late progress or results. +- Keep the unrelated `editors/vscode/package.json` version change out of every + commit. + +## Stable implementation interfaces + +Use these names throughout the six commits. + +### Pagination and timing + +```rust +pub const DEFAULT_CONTINUATION_PAGE_SIZE: usize = 100; + +#[derive(Clone, Debug, Default)] +pub struct ContinuationPageRequest { + pub cursor: Option, + pub page_size: usize, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContinuationPage { + pub returned: usize, + pub omitted: usize, + pub next_cursor: Option, +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CallGraphTimings { + pub index_load: u64, + pub root_resolution: u64, + pub traversal: u64, + pub enrichment: u64, + pub resolver_total: u64, +} +``` + +`UniversalCallGraphResponse` gains: + +```rust +pub artifact_fingerprint: String, +pub continuation_page: ContinuationPage, +pub timings_ms: CallGraphTimings, +``` + +### Shared traversal + +```rust +pub(crate) struct TraversalEdge { + pub source: String, + pub target: String, + pub continuable: bool, + pub order_key: String, +} + +pub(crate) struct TraversalInput<'a> { + pub root: &'a str, + pub direction: CallGraphDirection, + pub depth: u32, + pub max_nodes: usize, + pub max_edges: usize, + pub incoming: &'a BTreeMap>, + pub outgoing: &'a BTreeMap>, + pub edges: &'a [TraversalEdge], + pub cancellation: &'a AtomicBool, +} + +pub(crate) struct TraversalSelection { + pub node_ids: BTreeSet, + pub edge_indexes: Vec, + pub frontier: Vec, + pub truncated: bool, +} + +pub(crate) fn traverse_calls( + input: TraversalInput<'_>, +) -> Result; +``` + +### Index loading + +```rust +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ArtifactSignature { + pub bytes: u64, + pub modified_ns: u128, + pub sha256: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CacheDisposition { + Hit, + Rebuilt, + MemoryOnly, +} + +pub struct LoadedCallIndex { + pub index: T, + pub disposition: CacheDisposition, + pub persistence_warning: Option, +} +``` + +### VS Code request identity + +```ts +export type CallGraphRequestIdentity = { + repositoryId: string; + artifactFingerprint: string; + root: readonly string[]; + direction: CallDirection; + depth: number; + maxNodes: number; + maxEdges: number; + continuationCursor: string | null; + evidenceLayer: "structural_graph" | "combined"; +}; +``` + +## Commit 1 — Paginate truncated continuations + +**Commit message:** `feat(call-graph): paginate truncated continuations` + +### Production changes + +Create `crates/compass-analysis/src/continuation_cursor.rs`. + +Define a private, canonical cursor payload: + +```rust +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CursorPayload { + version: u8, + artifact_fingerprint: String, + root_key: String, + direction: CallGraphDirection, + depth: u32, + max_nodes: usize, + max_edges: usize, + evidence_layer: String, + after_order_key: String, +} +``` + +Serialize with `serde_json::to_vec` and encode with base64url without padding. +Decode and validate every field before traversal. Return distinct +`InvalidCursor`, `StaleCursor`, and `CursorRequestMismatch` errors. + +Update `UniversalCallGraphRequest`: + +```rust +pub artifact_fingerprint: String, +pub continuation_page: ContinuationPageRequest, +``` + +Change structural traversal response shaping so it: + +1. retains the complete ordered frontier metadata; +2. resumes strictly after the decoded `after_order_key`; +3. returns at most `min(page_size, max_nodes, 100)` continuations; +4. reports the exact remaining count; +5. emits a next cursor only when another page exists; and +6. keeps `truncated` true whenever node, edge, or depth bounds excluded work. + +The initial CLI implementation may hash `graph.json` once to produce +`sha256:`. Commit 6 replaces that request-time artifact read with the +fingerprint stored in the compact index. + +Extend `crates/compass-cli/src/call_graph_commands.rs` with: + +```text +--continuation-cursor +``` + +Keep the current root, direction, depth, and limits in page requests so Rust +can compare them with the cursor identity. + +Extend the additive Zod response contract in +`packages/compass-viewer/src/contracts/callGraph.ts`. Older responses without +the new fields remain valid. + +Add: + +```ts +export function mergeContinuationPage( + current: CallGraphResponse, + page: CallGraphResponse +): CallGraphResponse; +``` + +The viewer retains its existing twenty-button preview and “show all on this +page” action. It additionally renders the exact omitted count and a “Load more +branches” action when `nextCursor` exists. Symbol-specific branch expansion +remains unchanged. + +Wire `loadContinuationPage` through: + +- `packages/compass-viewer/src/calls/CallGraph.tsx` +- `editors/vscode/src/webviews/callGraph.tsx` +- `editors/vscode/src/views/callGraphArguments.ts` +- `editors/vscode/src/views/callGraphPanel.ts` + +A stale cursor causes a page-one refresh, not a permanent panel error. + +### Validation after implementation + +Add coverage proving: + +- all continuation pages are disjoint and collectively complete; +- deterministic replay produces identical pages; +- tampered, request-mismatched, and stale-artifact cursors fail explicitly; +- JSON without additive fields remains accepted; +- the viewer appends pages without duplicating branch buttons; and +- a continuation request retains the root identity and traversal limits. + +Run: + +```bash +cargo test -p compass-analysis --test universal_call_graph +cargo test -p compass-cli --test call_graph_cli +npm test -w @compass/viewer -- --run src/calls/state.test.ts src/calls/CallGraph.test.tsx +npm test -w editors/vscode -- --run src/views/callGraphArguments.test.ts +npm run typecheck:js +git diff --check +``` + +## Commit 2 — Share bounded traversal with Program IR + +**Commit message:** `perf(call-graph): share bounded Program IR traversal` + +### Production changes + +Create `crates/compass-analysis/src/call_traversal.rs`. + +The module accepts already indexed edges and adjacency. It does not own +response types, source anchors, evidence, or root lookup. Its algorithm: + +1. starts with the root admitted; +2. reads only the relevant incoming/outgoing adjacency lists; +3. sorts and deduplicates edge indexes before applying bounds; +4. stops node admission at `max_nodes`; +5. stops edge admission at `max_edges`; +6. records excluded continuable neighbors in the frontier; +7. handles cycles and self-edges without duplicate queue work; and +8. checks cancellation every 256 records and whenever 25 ms has elapsed. + +Expose `TraversalError::Cancelled`. + +Adapt `universal_call_graph.rs` to project its structural edges into +`TraversalEdge`, call `traverse_calls`, and shape the selected indexes into the +existing universal response. + +Adapt `call_graph.rs` to use the same engine. Preserve +`compass.program.call_graph/1` and its current response model while replacing +the `all_edges.iter().filter(...)` traversal loop. + +Add: + +```rust +pub fn build_call_graph_with_cancellation( + analysis: &AnalysisBundle, + graph: Option<&GraphDocument>, + request: &CallGraphRequest, + cancellation: &AtomicBool, +) -> Result; +``` + +Keep `build_call_graph` as the compatible convenience wrapper. This shared +engine improves the current Program IR call graph without limiting its future +semantic model. + +### Validation after implementation + +Cover callers, callees, both, depth limits, node limits, edge limits, cycles, +self-edges, input permutations, high fanout, and pre-cancelled traversal. +Compare structural and Program IR adapter selection for the same topology. + +Remove the strict debug-build wall-clock assertion from the 16,000-edge unit +fixture. Keep deterministic bounds and response-digest checks there; release +latency becomes Commit 3’s responsibility. + +Run: + +```bash +cargo test -p compass-analysis +cargo test -p compass-cli --test call_graph_cli +cargo clippy -p compass-analysis --all-targets -- -D warnings +rg "all_edges\\.iter\\(\\)\\.filter" crates/compass-analysis/src +git diff --check +``` + +The `rg` command must find no per-visited-node traversal loop. + +## Commit 3 — Establish the release performance gate + +**Commit message:** `test(call-graph): add performance qualification` + +### Production changes + +Create `scripts/benchmark_call_graph.sh` as the release measurement driver and +`scripts/check_call_graph_benchmark.py` as the deterministic result validator. + +The driver accepts: + +```text +--compass PATH +--repo PATH +--out PATH +--samples N +--baseline PATH +--no-enforce +``` + +It records: + +- Compass version and commit; +- OS, architecture, CPU, memory, and Rust toolchain; +- graph and Program IR sizes and fingerprints; +- cache-miss migration; +- indexed cold-process structural latency; +- repeated session-cache latency once Commit 6 supplies that benchmark; +- cached Program IR enrichment; +- continuation-page latency and reachability; +- first-progress-event latency; +- cancellation acknowledgement; +- peak RSS; +- output bytes; and +- deterministic response digests. + +Write raw CSV samples plus: + +```text +target/call-graph-benchmark/results.json +``` + +The Python validator enforces: + +```text +indexedColdMedian <= 500 ms +sessionWarmMedian <= 100 ms +enrichmentMedian <= 1000 ms +firstProgressMedian <= 100 ms +cancellationMedian <= 100 ms +median regression <= 10 percent +``` + +It also fails on digest drift, response-bound violations, inaccessible +continuations, or invalid terminal-event cardinality. During this commit, +metrics depending on later commits are recorded as `not_available`, and CI +uses `--no-enforce`. + +Update: + +- `.github/workflows/compass-ci.yml` to syntax-check the shell driver and run + the validator’s standard-library self-test; +- `.github/workflows/compass-hardening.yml` to run the release measurement on + schedule/manual dispatch and upload raw artifacts even after failure; and +- `PERFORMANCE.md` with exact reproduction and baseline-approval commands. + +### Validation after implementation + +Run: + +```bash +bash -n scripts/benchmark_call_graph.sh +python3 scripts/check_call_graph_benchmark.py --self-test +cargo build --release -p compass-cli +scripts/benchmark_call_graph.sh \ + --compass target/release/compass \ + --repo . \ + --out target/call-graph-benchmark \ + --samples 1 \ + --no-enforce +git diff --check +``` + +Inspect `results.json` and confirm every available metric includes its raw +samples and deterministic digest. + +## Commit 4 — Stream real resolution progress + +**Commit message:** `feat(call-graph): stream resolver progress` + +### Production changes + +Create `crates/compass-cli/src/call_graph_events.rs` with schema: + +```rust +pub const CALL_GRAPH_EVENT_SCHEMA: &str = "compass.call_graph.events/1"; +``` + +Support: + +```text +compass call-graph ... --format jsonl +``` + +Keep `--format json` unchanged. + +The JSONL stream contains: + +```json +{"type":"progress","phase":"loading_structural_index","elapsedMs":4,"terminal":false} +{"type":"progress","phase":"locating_symbol","elapsedMs":7,"terminal":false} +{"type":"progress","phase":"tracing_calls","elapsedMs":11,"terminal":false} +{"type":"progress","phase":"serializing_result","elapsedMs":12,"terminal":false} +{"type":"result","graph":{},"timingsMs":{},"terminal":true} +``` + +Structural phases: + +```text +loading_structural_index +locating_symbol +tracing_calls +serializing_result +``` + +Enrichment phases: + +```text +loading_program_index +joining_evidence +serializing_result +``` + +Add a writer that flushes every record and guarantees exactly one terminal +result or error on normal completion. Host-requested cancellation may terminate +without a terminal record. + +Add `run_call_graph_jsonl` to `crates/compass-cli/src/lib.rs` and route it +directly from `src/bin/compass.rs` before the buffered `Outcome` path. Install +the existing `process_cancellation()` atomic and pass it into index/traversal +work. + +Measure actual boundaries with `Instant`. Put index load, root resolution, +traversal, enrichment, and resolver-total values in the response. Put +serialization and end-to-end values in the terminal stream envelope because +they are not known until encoding completes. + +Advertise: + +```json +"contracts": { + "call_graph_events": "compass.call_graph.events/1" +}, +"features": { + "call_graph_events": true +} +``` + +Create `editors/vscode/src/cli/callGraphEvents.ts` and add a generic bounded +runner to `CompassProcessManager`: + +```ts +async runJsonl( + cwd: string, + args: readonly string[], + schema: ZodType, + terminalResult: (event: E) => R | undefined, + onEvent: (event: E) => void, + signal?: AbortSignal +): Promise; +``` + +Retain `startJsonl` for existing IDE progress users. + +Map resolver phases to the existing loading steps and post progress only when +the request generation is current. Log requests above 500 ms with fingerprint, +artifact bytes, direction, depth, result counts, cache disposition, and phase +timings. + +### Validation after implementation + +Cover split JSONL chunks, malformed events, duplicate terminals, failure +terminals, output above 8 MiB, cancellation, real phase order, and late event +suppression. Confirm the first event is flushed rather than buffered. + +Run: + +```bash +cargo test -p compass-cli --test call_graph_events_cli +cargo test -p compass-cli --test capabilities_cli +npm test -w editors/vscode -- --run src/cli/processManager.test.ts src/views/callGraphArguments.test.ts +npm run typecheck:js +git diff --check +``` + +## Commit 5 — Add cached progressive Program IR enrichment + +**Commit message:** `perf(call-graph): cache progressive Program IR enrichment` + +### Production changes + +Create `crates/compass-analysis/src/call_index_cache.rs` for the focused cache +envelope and atomic MessagePack persistence. Do not create a general cache +framework. + +The header contains: + +```text +cache magic +cache schema +source artifact byte length +source modification timestamp +source artifact SHA-256 +``` + +Cache reads validate the complete header before accepting the payload. A newer +schema, malformed payload, stale signature, or digest mismatch is a cache miss. +Writes use a uniquely named sibling temporary file, file sync, atomic rename, +and best-effort directory sync. + +Create `crates/compass-analysis/src/program_call_index.rs` with: + +```rust +pub const PROGRAM_CALL_INDEX_SCHEMA: &str = "compass.program_call_index/1"; +``` + +The projection stores only: + +- functions keyed by Program symbol and `graph_node_id`; +- source byte anchors; +- resolved, inferred, ambiguous, and unresolved calls; +- exact call-site anchors and evidence IDs; and +- sorted incoming/outgoing adjacency. + +On the first cache miss, call the existing Program loader so size, schema, +semantic validation, and canonical-byte verification still occur before +projection. Subsequent matching requests deserialize the compact index instead +of the full canonical Program IR. + +Update universal enrichment to accept `ProgramCallIndex`. It preserves +structural IDs and relationships, adds exact anchors/evidence, adds +Program-only unresolved or ambiguous calls, deduplicates call sites, and sets +the evidence layer to `combined`. + +In VS Code: + +1. render the structural terminal result immediately; +2. check whether `session.programPath` exists; +3. start a separate cancellable `--program` JSONL request; +4. show a compact “Adding semantic evidence…” status over the visible graph; +5. merge only when repository, generation, root, direction, depth, + fingerprint, and response schema still match; and +6. keep the structural graph visible after enrichment failure. + +Use separate structural and enrichment controllers. Root/direction changes and +panel disposal abort both. Retry targets enrichment only. + +Add `mergeEnrichment` in `packages/compass-viewer/src/calls/state.ts`. It +deduplicates call sites, retains structural data, adds semantic data, and +recalculates coverage. + +### Validation after implementation + +Validate Program index deterministic bytes, valid round trip, corruption, +schema mismatch, stale signatures, interrupted writes, read-only persistence, +and concurrent readers. + +Validate UI ordering and safety: + +- structural hydrate precedes enrichment status and merge; +- missing Program IR completes structurally; +- invalid Program IR is nonfatal; +- enrichment retry does not reload the structural graph; +- stale generations cannot merge; and +- panel disposal aborts both processes. + +Run: + +```bash +cargo test -p compass-analysis --test call_index_cache +cargo test -p compass-cli --test call_graph_cli +npm test -w @compass/viewer -- --run src/calls/state.test.ts src/calls/CallGraph.test.tsx +npm test -w editors/vscode -- --run src/views/callGraphPanel.test.ts src/views/callGraphArguments.test.ts +npm run typecheck:js +git diff --check +``` + +## Commit 6 — Persist compact structural indexes and add the session cache + +**Commit message:** `perf(call-graph): persist compact call indexes` + +### Production changes + +Create `crates/compass-analysis/src/structural_call_index.rs` with: + +```rust +pub const STRUCTURAL_CALL_INDEX_SCHEMA: &str = "compass.call_index/1"; + +pub struct StructuralCallIndex { + pub schema: String, + pub artifact_fingerprint: String, + pub nodes: BTreeMap, + pub file_nodes: BTreeMap>, + pub edges: Vec, + pub incoming: BTreeMap>, + pub outgoing: BTreeMap>, +} +``` + +The projection contains only callable nodes/ranges, normalized file lookup, +structural call edges, evidence metadata, deterministic ordering keys, and +incoming/outgoing adjacency. + +Normalize and sort source lookup during index construction: + +```text +smallest containing range +callable-kind rank +node ID +``` + +Sort structural edges before building adjacency. Check cancellation every 256 +records and at least every 25 ms. + +Expose: + +```rust +pub fn load_or_build_structural_call_index( + graph_path: &Path, + verified_sha256: Option<&str>, + cancellation: &AtomicBool, +) -> Result, CallIndexError>; +``` + +On a cache hit, do not deserialize `graph.json`. On a miss, validate the graph, +build the projection, compute its SHA-256, attempt atomic persistence, and +continue from the in-memory result if the write fails. + +Update `crates/compass-core/src/build_state.rs` with read-only graph/program +seal accessors. Update `crates/compass-core/src/pipeline.rs` to publish both +indexes after valid canonical artifacts exist. Use the verified seals when +available. Index publication failure emits a diagnostic but cannot fail the +update. An unchanged warm update also creates a missing index. + +Create `editors/vscode/src/views/callGraphCache.ts`: + +```ts +export class CallGraphResponseCache { + constructor(options?: { + maxEntriesPerRepository: number; + maxBytes: number; + }); + + get(identity: CallGraphRequestIdentity): CallGraphResponse | undefined; + set(identity: CallGraphRequestIdentity, response: CallGraphResponse): void; + invalidateRepositoryFingerprint( + repositoryId: string, + currentFingerprint: string + ): void; +} +``` + +Defaults: + +```text +8 entries per repository +16 MiB estimated serialized total +``` + +Estimate weight with +`Buffer.byteLength(JSON.stringify(response), "utf8")`. Store only +Zod-validated response objects. Evict in least-recently-used order. A new graph +fingerprint invalidates older entries for that repository. Structural and +combined evidence, cursor pages, directions, depths, roots, and limits have +distinct keys. + +The panel checks the cache before spawning a CLI process. A structural cache +hit renders immediately and may still start Program enrichment. A matching +combined cache hit finishes without spawning either request. + +Finally, enable every benchmark assertion, add the session-cache measurement, +remove `--no-enforce` from the hardening workflow, and retain the raw result +artifacts. + +### Validation after implementation + +Validate: + +- indexed and direct graph resolution are identical; +- index bytes are deterministic; +- corrupt, newer, stale, and interrupted caches rebuild safely; +- a read-only cache still returns a graph; +- `compass update` publishes both indexes; +- index publication failure leaves canonical artifacts valid; +- LRU hit, recency, entry-count eviction, byte eviction, repository isolation, + cursor separation, evidence separation, and fingerprint invalidation; and +- all five latency budgets on the retained release fixture. + +Run the complete branch gate: + +```bash +cargo fmt --all -- --check +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo build --release -p compass-cli +npm run test:js +npm run typecheck:js +npm run build +scripts/benchmark_call_graph.sh \ + --compass target/release/compass \ + --repo . \ + --out target/call-graph-benchmark \ + --samples 7 +git diff --check +``` + +After code changes, refresh the outer knowledge graph: + +```bash +cd /Users/haipingfu/graphify +graphify update . +``` + +Inspect the final commit list and ensure the unrelated extension version change +is not staged: + +```bash +cd /Users/haipingfu/graphify/compass +git status --short +git log --oneline origin/main..HEAD +git diff --check +``` + +Push the branch and update draft PR #74 with: + +- the six production changes; +- compatibility and failure behavior; +- release fixture and measured metrics; +- verification commands; +- benchmark artifact location; and +- confirmation that Program IR call graphs remain a first-class enhancement + surface. + +Do not mark the PR ready until required GitHub checks and the hardening job are +green. + +## Final implementation acceptance + +- Every omitted continuation is reachable exactly once through pagination. +- Structural and Program IR call graphs both use `traverse_calls`. +- Neither traversal scans the complete edge set for each visited node. +- Structural output appears before Program IR enrichment. +- Program IR enrichment adds semantic evidence without becoming a structural + prerequisite. +- Real resolver phases drive the loading UI. +- Cancellation and generation checks prevent stale rendering. +- Structural and Program index failures degrade to valid in-memory behavior. +- Indexed structural resolution, session hits, enrichment, progress, and + cancellation meet their approved budgets. +- Both first-class call-graph contracts remain compatible within this PR. +- PR #74 contains the approved six production commits in the specified order. diff --git a/docs/superpowers/plans/2026-07-28-vscode-architecture-flow.md b/docs/superpowers/plans/2026-07-28-vscode-architecture-flow.md new file mode 100644 index 00000000..4e50e5ae --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-vscode-architecture-flow.md @@ -0,0 +1,492 @@ +# VS Code Architecture Flow Implementation Plan + +**Execution style:** Implementation-first with context. Implement each cohesive +slice, add focused regression coverage immediately afterward, verify it, and +commit it before starting the next slice. Do not use a red-green TDD sequence. + +**Goal:** Load Django-sized architecture exports safely and present a complete, +directional, production-first architecture and call-flow workspace in VS Code. + +**Architecture:** Compass emits the complete, strict +`compass.viewer.callflow/1` model. +The extension host captures up to 128 MiB for this command, retains and indexes +the full model, and sends bounded projections to the webview. The shared viewer +renders those projections as an SVG subsystem map with paged evidence. + +**Tech stack:** Rust, serde, TypeScript, Zod, React 19, Vitest, Testing Library, +SVG, VS Code webviews, existing Compass viewer CSS tokens. + +**Approved design:** `docs/superpowers/specs/2026-07-28-vscode-architecture-flow-design.md` + +## Global constraints + +- Keep the default process stdout and stderr limit at 8 MiB. +- Allow 128 MiB stdout only for architecture export; keep its stderr at 8 MiB. +- Measure limits in UTF-8 bytes. +- Never post the complete call-flow model to the webview. +- Production is the initial scope; `All code` restores tests, generated, vendor, + and unknown scopes without loss. +- Every edge is counted as internal, cross-section, or unassigned. +- Use only local SVG and bundled code; add no runtime network or diagram + dependency. +- Preserve the user's existing `editors/vscode/package.json` version change. +- Honor VS Code themes, high contrast, keyboard operation, narrow layouts, and + reduced motion. + +## File map + +| File | Responsibility | +|---|---| +| `editors/vscode/src/cli/processManager.ts` | Per-command UTF-8 output ceilings | +| `crates/compass-output/src/callflow_model.rs` | Complete `/2` call-flow model and scope classification | +| `crates/compass-output/tests/callflow_model.rs` | Model completeness and classification regression coverage | +| `packages/compass-viewer/src/contracts/callflow.ts` | Full CLI `/2` runtime schema | +| `packages/compass-viewer/src/contracts/architecture.ts` | Bounded host/webview projection types | +| `editors/vscode/src/views/architectureIndex.ts` | Host-side indexes, search, filters, paging, and coverage | +| `editors/vscode/src/transport/architectureMessages.ts` | Validated architecture message protocol | +| `editors/vscode/src/views/architecturePanel.ts` | Panel lifecycle, export, retained model, request routing | +| `editors/vscode/src/webviews/architecture.tsx` | Message bridge and controlled viewer state | +| `packages/compass-viewer/src/architecture/layout.ts` | Deterministic layered subsystem SVG layout | +| `packages/compass-viewer/src/architecture/ArchitectureMap.tsx` | Accessible directional system map | +| `packages/compass-viewer/src/architecture/ArchitectureFlow.tsx` | Three-pane workspace and inspector | +| `packages/compass-viewer/src/theme.css` | Architecture-specific responsive visual system | + +--- + +### Task 1: Add architecture-specific process capacity + +**Context:** The current `bounded()` helper uses JavaScript character length and +one global 8 MiB limit. Django emits 35,110,985 bytes, so architecture needs a +larger stdout ceiling without changing any other workflow. + +**Files:** + +- Modify: `editors/vscode/src/cli/processManager.ts` +- Modify: `editors/vscode/src/cli/processManager.test.ts` + +**Interfaces:** + +```ts +export type OutputLimits = { + stdoutBytes?: number; + stderrBytes?: number; +}; + +runJson( + cwd: string, + args: readonly string[], + schema: ZodType, + signal?: AbortSignal, + limits?: OutputLimits +): Promise; + +run( + cwd: string, + args: readonly string[], + signal?: AbortSignal, + limits?: OutputLimits +): Promise; + +startCommand( + cwd: string, + args: readonly string[], + limits?: OutputLimits +): RunningCommand; +``` + +- [ ] Replace string-length bounding with a small accumulator that tracks + `Buffer.byteLength(chunk, "utf8")`, labels stdout versus stderr, kills the + child on overflow, and defaults each stream to `8 * 1024 * 1024`. +- [ ] Thread optional limits through `run`, `startCommand`, `collect`, and + `runJson` without changing existing call behavior. +- [ ] Add regression tests showing ordinary output rejects above 8 MiB, + multibyte UTF-8 is measured by bytes, architecture-style output between + 8 and 128 MiB succeeds, and stderr remains capped at 8 MiB. +- [ ] Run: + +```bash +npm test -w editors/vscode -- --run src/cli/processManager.test.ts +npm run typecheck -w editors/vscode +``` + +- [ ] Commit only Task 1 files: + +```bash +git add editors/vscode/src/cli/processManager.ts \ + editors/vscode/src/cli/processManager.test.ts +git commit -m "fix(vscode): support bounded large architecture exports" +``` + +### Task 2: Emit a complete call-flow v2 model + +**Context:** Version 1 drops detailed cross-section calls. Version 2 must make +the completeness invariant explicit and provide source scopes for the +production-first presentation. + +**Files:** + +- Modify: `crates/compass-output/src/callflow_model.rs` +- Modify: `crates/compass-output/src/lib.rs` +- Modify: `crates/compass-cli/src/capability_commands.rs` +- Modify: `editors/vscode/src/cli/compatibility.ts` +- Modify: `editors/vscode/src/cli/compatibility.test.ts` +- Modify: `packages/compass-viewer/src/contracts/callflow.ts` +- Create: `crates/compass-output/tests/callflow_model.rs` +- Modify: `crates/compass-cli/tests/viewer_export_cli.rs` + +**Interfaces:** + +```rust +pub const CALLFLOW_VIEWER_SCHEMA: &str = "compass.viewer.callflow/1"; + +pub enum CallflowSourceScope { + Production, + Test, + Generated, + Vendor, + Unknown, +} + +pub struct CallflowCrossSectionCall { + pub source: String, + pub target: String, + pub source_section: String, + pub target_section: String, + pub relation: String, + pub confidence: String, +} + +pub struct CallflowCoverage { + pub internal: usize, + pub cross_section: usize, + pub unassigned: usize, +} +``` + +`CallflowViewNode` adds `scope`. `CallflowViewSection` adds `node_count` and +`internal_call_count`. `CallflowViewModel` adds `cross_section_calls` and +`coverage`. + +- [ ] Implement deterministic source-scope classification using normalized path + segments. Recognize test directories/names, generated/build outputs, + vendor/third-party directories, production paths, and missing paths in + that precedence order. +- [ ] While assigning nodes to sections, build the endpoint-to-section map once. + Classify every graph edge as internal, cross-section, or unassigned; + retain complete cross-section records and assert totals through exported + coverage. +- [ ] Update public exports, CLI capability negotiation, the extension + requirement, Zod schemas, and CLI integration expectations from `/1` to + `/2`. +- [ ] Add Rust regression coverage for all source scopes, detailed + cross-section evidence, section counts, and: + +```rust +assert_eq!( + model.coverage.internal + + model.coverage.cross_section + + model.coverage.unassigned, + document.links.len() +); +``` + +- [ ] Run: + +```bash +cargo fmt --all +cargo test -p compass-output --test callflow_model +cargo test -p compass-cli --test viewer_export_cli callflow_json +npm run typecheck -w @compass/viewer +npm test -w editors/vscode -- --run src/cli/compatibility.test.ts +``` + +- [ ] Commit Task 2 files: + +```bash +git add crates/compass-output crates/compass-cli/src/capability_commands.rs \ + crates/compass-cli/tests/viewer_export_cli.rs \ + editors/vscode/src/cli/compatibility.ts \ + editors/vscode/src/cli/compatibility.test.ts \ + packages/compass-viewer/src/contracts/callflow.ts +git commit -m "feat(callflow): preserve complete architecture evidence" +``` + +### Task 3: Build bounded host-side architecture projections + +**Context:** The extension may retain the complete `/2` model, but the webview +must receive only the overview and requested pages. Projection code stays pure +and independent of VS Code so its filtering and completeness can be reviewed +directly. + +**Files:** + +- Create: `packages/compass-viewer/src/contracts/architecture.ts` +- Modify: `packages/compass-viewer/src/index.ts` +- Create: `editors/vscode/src/views/architectureIndex.ts` +- Create: `editors/vscode/src/views/architectureIndex.test.ts` +- Create: `editors/vscode/src/transport/architectureMessages.ts` +- Create: `editors/vscode/src/transport/architectureMessages.test.ts` + +**Interfaces:** + +```ts +export type ArchitectureScope = "production" | "all"; +export type EvidenceFilter = "all" | "extracted" | "inferred" | "ambiguous"; + +export class ArchitectureIndex { + constructor(model: CallflowViewModel); + overview(scope: ArchitectureScope, evidence: EvidenceFilter): ArchitectureOverview; + sectionPage(request: SectionPageRequest): ArchitectureSectionPage; + routePage(request: RoutePageRequest): ArchitectureRoutePage; + search(request: ArchitectureSearchRequest): ArchitectureSearchPage; +} +``` + +All page requests include `repositoryId`, `generation`, `requestId`, scope, +evidence, page, and page size. All responses echo those identities and include +`total`, `start`, and `end`. + +- [ ] Define Zod schemas for overview summaries, connections, coverage, section + pages, route pages, search pages, loading phases, errors, and webview + requests. +- [ ] Implement normalized indexes for node labels, source paths, relations, + section names, scopes, and evidence. Derive overview counts and routes + from filtered internal and cross-section records. +- [ ] Enforce page sizes from 1 through 100 and cap search results at 100. Never + include `CallflowViewModel` in a host-to-webview schema. +- [ ] Add regression tests for production defaults, all-code restoration, + evidence filters, cross-route paging, complete-model search outside the + current page, identity echoing, and invalid page-size rejection. +- [ ] Run: + +```bash +npm test -w editors/vscode -- --run \ + src/views/architectureIndex.test.ts \ + src/transport/architectureMessages.test.ts +npm run typecheck -w editors/vscode +npm run typecheck -w @compass/viewer +``` + +- [ ] Commit Task 3 files: + +```bash +git add packages/compass-viewer/src/contracts/architecture.ts \ + packages/compass-viewer/src/index.ts \ + editors/vscode/src/views/architectureIndex.ts \ + editors/vscode/src/views/architectureIndex.test.ts \ + editors/vscode/src/transport/architectureMessages.ts \ + editors/vscode/src/transport/architectureMessages.test.ts +git commit -m "feat(vscode): index architecture data in the extension host" +``` + +### Task 4: Convert the architecture panel to a paged controller + +**Context:** `architecturePanel.ts` currently exports one function, accepts +unvalidated messages, and posts the full model. Convert it into a lifecycle +controller that owns the retained index and rejects stale work. + +**Files:** + +- Modify: `editors/vscode/src/views/architecturePanel.ts` +- Create: `editors/vscode/src/views/architecturePanel.test.ts` +- Modify: `editors/vscode/src/webviews/architecture.tsx` + +**Interfaces:** + +```ts +const ARCHITECTURE_STDOUT_LIMIT = 128 * 1024 * 1024; + +class ArchitecturePanelController { + hydrate(): Promise; + handleMessage(message: ArchitectureToHostMessage): Promise; + dispose(): void; +} +``` + +- [ ] Hydrate with `run(..., signal, { stdoutBytes: + ARCHITECTURE_STDOUT_LIMIT })`, record `Buffer.byteLength(result.stdout, + "utf8")`, parse with `CallflowViewModelSchema`, retain an + `ArchitectureIndex`, and post only `architectureOverview`. +- [ ] Route validated section, route, search, scope, source, output, retry, and + ready messages. Reject wrong repository identities and ignore stale + generation/request responses. +- [ ] Update the webview adapter to maintain overview, detail-page, search, + selection, and loading state. It sends typed requests and never receives + the full `/2` model. +- [ ] Show explicit `exporting`, `validating`, `indexing`, and `mapping` loading + copy. Report an actionable 128 MiB error without implying graph + corruption. +- [ ] Add panel tests with fake process/webview boundaries proving 35 MiB + hydration uses the 128 MiB option, only bounded overview data is posted, + route requests page correctly, retry invalidates old work, and disposal + cancels work and releases the index. +- [ ] Run: + +```bash +npm test -w editors/vscode -- --run src/views/architecturePanel.test.ts +npm run typecheck -w editors/vscode +npm run build -w editors/vscode +``` + +- [ ] Commit Task 4 files: + +```bash +git add editors/vscode/src/views/architecturePanel.ts \ + editors/vscode/src/views/architecturePanel.test.ts \ + editors/vscode/src/webviews/architecture.tsx +git commit -m "feat(vscode): page architecture data through the host" +``` + +### Task 5: Build the interactive system map and inspector + +**Context:** Replace the flat relationship-card overview with a directional +three-pane workspace. Keep tables as exhaustive alternatives and make selection +state understandable without relying on color. + +**Files:** + +- Create: `packages/compass-viewer/src/architecture/layout.ts` +- Create: `packages/compass-viewer/src/architecture/layout.test.ts` +- Create: `packages/compass-viewer/src/architecture/ArchitectureMap.tsx` +- Create: `packages/compass-viewer/src/architecture/ArchitectureMap.test.tsx` +- Modify: `packages/compass-viewer/src/architecture/ArchitectureFlow.tsx` +- Modify: `packages/compass-viewer/src/architecture/state.ts` +- Modify: `packages/compass-viewer/src/architecture/state.test.ts` +- Create: `packages/compass-viewer/src/architecture/ArchitectureFlow.test.tsx` +- Modify: `packages/compass-viewer/src/theme.css` + +**Interfaces:** + +```ts +export function layoutArchitecture( + sections: readonly ArchitectureSectionSummary[], + routes: readonly ArchitectureRouteSummary[], + viewport: { width: number; height: number } +): ArchitectureLayout; +``` + +`ArchitectureFlow` receives the current `ArchitectureOverview`, optional +section/route/search pages, loading state, and callbacks for scope, evidence, +selection, paging, search, and source opening. + +- [ ] Implement a deterministic layered layout with stable section ordering, + cycle-safe columns, logarithmically capped node area and route width, and + coordinates suitable for fit/reset/pan. +- [ ] Render an SVG with titled nodes, directional curves, arrow markers, + visible extracted/inferred line styles, keyboard-selectable nodes/routes, + screen-reader labels, and a table alternative. +- [ ] Rebuild `ArchitectureFlow` as: + searchable grouped subsystem rail; top scope/evidence/coverage toolbar; + central map; right selection inspector; paged symbols/internal calls or + cross-route evidence. +- [ ] Make `Production · X of Y symbols` visible on initial load. Keep test, + generated, vendor, and unknown totals visible and expose them through + `All code`. +- [ ] Replace the old card-grid CSS with restrained blueprint-like route + styling derived from VS Code variables. Add high-contrast, reduced-motion, + focus, and sub-760px inspector-below-map rules. +- [ ] Add regression tests for deterministic layout, directed accessible + routes, incoming/outgoing highlighting, production disclosure, all-code + scope, route evidence, table fallback, pagination, empty states, and + source callbacks. +- [ ] Run: + +```bash +npm test -w @compass/viewer -- --run src/architecture +npm run typecheck -w @compass/viewer +npm run build -w @compass/viewer +npm run build -w editors/vscode +``` + +- [ ] Commit Task 5 files: + +```bash +git add packages/compass-viewer/src/architecture \ + packages/compass-viewer/src/theme.css +git commit -m "feat(viewer): visualize complete architecture flow" +``` + +### Task 6: Qualify the Django flow and package the extension + +**Context:** Unit tests protect contracts, but completion requires the original +35,110,985-byte reproduction plus the release-shaped extension bundle. + +**Files:** + +- Modify: `editors/vscode/CHANGELOG.md` +- Refresh: parent repository `graphify-out/` + +- [ ] Add a changelog entry describing the production-first system map, + complete cross-subsystem evidence, and 128 MiB architecture-only export + capacity. +- [ ] Run the complete focused suites: + +```bash +cargo test -p compass-output +cargo test -p compass-cli --test viewer_export_cli +npm test -w @compass/viewer -- --run src/architecture +npm test -w editors/vscode +npm run typecheck:js +npm run build:vscode +``` + +- [ ] Measure the real Django export and inspect the `/2` completeness invariant: + +```bash +set -o pipefail +cargo run -p compass-cli -- export callflow-json \ + --graph /Users/haipingfu/Github/django/compass-out/graph.json | + jq '{ + schema, + bytes: (tostring | utf8bytelength), + nodes: .statistics.nodes, + edges: .statistics.edges, + classifiedEdges: + (.coverage.internal + .coverage.crossSection + .coverage.unassigned) + }' +``` + +Expected: schema `/2`, bytes below 134,217,728, 50,944 nodes, 190,401 edges, +and `classifiedEdges == edges`. + +- [ ] Launch the Extension Development Host against Django and verify: + production scope is disclosed; map selection highlights both directions; + `All code` exposes tests/generated code; a cross-subsystem route pages to + its final call; source navigation opens a real file; retry and output + actions work. +- [ ] Build and inspect the release artifact: + +```bash +npm run package -w editors/vscode +npm run smoke:vsix -w editors/vscode +``` + +- [ ] Refresh the parent graph after all code changes: + +```bash +cd /Users/haipingfu/graphify +graphify update . +``` + +- [ ] Review `git diff --check`, `git status --short`, the parent/submodule + boundary, and preserve the pre-existing package-version edit. Commit the + changelog entry: + +```bash +git add editors/vscode/CHANGELOG.md +git commit -m "docs(vscode): document scalable architecture flow" +``` + +## Completion checklist + +- [ ] Django loads without the 8 MiB architecture failure. +- [ ] Ordinary commands still reject stdout or stderr above 8 MiB. +- [ ] The webview never receives the complete retained model. +- [ ] Production scope and full totals are both visible. +- [ ] All-code search reaches tests, generated, vendor, and unknown sources. +- [ ] Internal, cross-section, and unassigned coverage equals total edges. +- [ ] Cross-subsystem routes expose complete paged call evidence. +- [ ] SVG, table fallback, keyboard use, high contrast, reduced motion, and + narrow layouts preserve the same information. +- [ ] Rust, TypeScript, viewer, extension, build, package, VSIX smoke, Django, + and graph-refresh checks have fresh passing evidence. diff --git a/docs/superpowers/reviews/2026-07-30-best-effort-heavy-framework-qualification.md b/docs/superpowers/reviews/2026-07-30-best-effort-heavy-framework-qualification.md index 0fd34b20..7fe432a5 100644 --- a/docs/superpowers/reviews/2026-07-30-best-effort-heavy-framework-qualification.md +++ b/docs/superpowers/reviews/2026-07-30-best-effort-heavy-framework-qualification.md @@ -201,4 +201,3 @@ artifact-integrity failure: 4. Add this pinned corpus, or an equivalently sized internal mirror, to scheduled release qualification with wall-time, memory, validity, and omission-rate regression thresholds. - diff --git a/docs/superpowers/specs/2026-07-27-call-graph-resolution-pipeline-hardening-design.md b/docs/superpowers/specs/2026-07-27-call-graph-resolution-pipeline-hardening-design.md new file mode 100644 index 00000000..645e155c --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-call-graph-resolution-pipeline-hardening-design.md @@ -0,0 +1,422 @@ +# Call-Graph Resolution Pipeline Hardening Design + +**Date:** 2026-07-27 + +**Status:** Approved + +**Implementation root:** `/Users/haipingfu/graphify/compass` + +## Purpose + +Compass call graphs must remain responsive on large repositories without hiding +partial coverage or moving traversal semantics into the VS Code extension. The +first hardening pass made structural traversal adjacency-indexed and stopped +blocking every editor request on Program IR. This design completes that work: + +1. paginate every excluded continuation without making responses unbounded; +2. share the bounded traversal implementation with Program IR; +3. establish a release performance gate; +4. report real resolver progress; +5. restore Program IR as nonblocking progressive enrichment; and +6. serve requests from compact, persistent indexes with a small editor-session + cache. + +All six changes will land in the existing draft pull request as separate, +reviewable commits in the order above. + +## Goals + +- A fresh Compass process resolves a structural call graph in at most 500 ms + when `compass update` has already published the compact index. +- A repeated request in the same VS Code repository session resolves in at + most 100 ms from the session cache. +- Cached Program IR enrichment completes in at most one second. +- The first progress event is visible within 100 ms. +- Cancellation is acknowledged within 100 ms and no superseded result is + rendered. +- Every truncated frontier branch remains reachable through deterministic + pagination. +- Structural results render before optional semantic enrichment. +- Existing `compass call-graph --format json`, + `compass.program.call_graph/1`, and supported JSON consumers remain + compatible. +- Cache corruption, cache-write failures, stale cursors, and enrichment + failures have explicit, recoverable behavior. + +The release benchmark uses the retained Compass corpus with 26,567 nodes and +63,204 edges. Cache-miss migration and first index construction are measured +separately from the 500 ms indexed cold-process budget. + +## Non-goals + +- Add a long-lived resolver daemon. +- Move root resolution, traversal, evidence merging, or limits into VS Code. +- Change extraction or call-edge inference semantics. +- Make Program IR a prerequisite for opening a call graph. +- Change the current `compass program call-graph` response schema in this + performance-hardening PR. Program IR call graphs remain an active surface + intended for future semantic enhancements. +- Promise that all supported languages have identical call precision. +- Add a general-purpose cache framework for unrelated Compass commands. + +## Chosen architecture + +Compass uses versioned, signature-validated on-disk projections plus a small +VS Code session LRU. It continues to spawn a fresh CLI process for each cache +miss. A resident daemon was rejected because its lifecycle, upgrade, crash +recovery, workspace-trust, and memory ownership costs are disproportionate to +this feature. Extension-side traversal was rejected because Rust is the +canonical owner of deterministic traversal and evidence semantics. + +```text +graph.json ───────> compact structural call index ──┐ + ├─> compass call-graph +program.json ─────> compact Program IR call index ──┘ │ + ├─ structural result +VS Code request ──> repository-session LRU ── miss ────────────┤ + ├─ progress events + └─ enrichment result +``` + +The structural result is terminal for the structural request and immediately +renderable. Enrichment is a separate request and never returns the panel to the +full-screen loading state. + +## Component boundaries + +### Shared traversal engine + +`compass-analysis` will expose one internal direction-aware breadth-first +traversal implementation used by structural and Program IR call graphs. It +consumes: + +- a root identity; +- incoming and outgoing adjacency; +- direction; +- depth; +- maximum nodes; +- maximum edges; and +- a continuation page request. + +It produces selected node IDs, selected edge indexes, the complete ordered +frontier metadata needed for the requested page, and truncation counts. +Evidence conversion and response shaping remain in their respective builders. + +The engine must: + +- never scan the complete edge set once per visited node; +- stop queue admission at the node bound; +- stop edge admission at the edge bound; +- handle self-edges and cycles without duplicate work; +- sort before applying bounds; +- produce the same order for identical inputs; and +- expose cancellation checkpoints no more than 100 ms apart during index and + traversal work. + +### Compact structural index + +`compass-analysis` will define `compass.call_index/1`, stored next to the +existing graph query caches at +`cache/.graph.json.compass-call-index-v1`. It contains only data required by +call-graph resolution: + +- graph artifact fingerprint; +- callable nodes with stable ID, label, kind, file, inclusive lines, and + available evidence metadata; +- callable IDs grouped by normalized source file and ordered by source range; +- structural call edges; +- incoming and outgoing adjacency; and +- deterministic edge and frontier ordering keys. + +`compass update` attempts to publish this index atomically after publishing a +valid graph. Index publication failure does not invalidate the graph; a +call-graph request may build the index lazily from the valid graph, use the +in-memory result, and retry the atomic cache write. + +The cache header includes a cache magic, schema version, source artifact +length, source modification timestamp, and the SHA-256 captured when the index +was built. Fast cache validation uses the same length and modification-time +signature as the existing graph query cache. When a verified Compass build +seal is available, its graph digest must equal the stored SHA-256. Lazy builds +compute the digest while reading the source graph. A mismatch is a cache miss, +never a partial read. + +### Compact Program IR index + +`compass-analysis` will define `compass.program_call_index/1`, stored at +`cache/.program.json.compass-call-index-v1`. It contains: + +- the Program IR artifact signature; +- functions keyed by Program IR symbol and structural `graph_node_id`; +- source byte anchors; +- resolved, inferred, ambiguous, and unresolved call edges; +- exact call-site anchors and evidence IDs; and +- incoming and outgoing adjacency. + +The first valid Program IR load still performs the existing size, schema, +validation, and canonical-byte checks. It then writes the compact index +atomically. Subsequent matching requests load the projection rather than +deserializing, validating, and canonically reserializing the entire +`program.json`. + +Program cache failure cannot invalidate a valid structural result. + +### VS Code repository-session cache + +The extension will reuse the existing bounded LRU pattern. It holds at most +eight entries per repository and evicts until the estimated serialized total +is at most 16 MiB. The call-graph LRU key is: + +```text +repository ID ++ graph artifact fingerprint ++ normalized root ++ direction ++ depth ++ node/edge limits ++ continuation cursor ++ evidence layer +``` + +The cache stores validated response objects, not raw stdout. A graph +fingerprint change invalidates every entry for that repository. + +## Continuation pagination contract + +`compass.call_graph/1` gains additive fields: + +```json +{ + "artifactFingerprint": "sha256:...", + "continuationPage": { + "returned": 100, + "omitted": 2400, + "nextCursor": "opaque-or-null" + } +} +``` + +The existing `continuations` array remains the current page. The default page +size is 100 and cannot exceed the request's node bound. + +`compass call-graph` accepts: + +```text +--continuation-cursor +``` + +The token encodes: + +- cursor contract version; +- artifact fingerprint; +- normalized root identity; +- direction; +- depth; +- node and edge limits; +- evidence layer; and +- the last deterministic frontier ordering key. + +The token is base64url-encoded canonical data. It is not trusted: decode, +schema, fingerprint, and request-identity checks happen before traversal. A +tampered or stale token returns a typed cursor error and no graph result. + +The viewer presents: + +- up to twenty branch buttons initially; +- the existing “show all on this page” behavior; +- an exact omitted count; and +- “Load more branches” when `nextCursor` is present. + +Symbol-specific expansion remains available. Pagination adds reachability for +frontier symbols omitted by response bounds; it does not replace branch +expansion. + +## Progress and timing protocol + +`compass call-graph --format json` retains its current single-response +behavior. `--format jsonl` emits `compass.call_graph.events/1` records: + +```json +{"type":"progress","phase":"loading_structural_index","elapsedMs":4,"terminal":false} +{"type":"progress","phase":"locating_symbol","elapsedMs":7,"terminal":false} +{"type":"progress","phase":"tracing_calls","elapsedMs":11,"terminal":false} +{"type":"result","graph":{},"elapsedMs":13,"terminal":true} +``` + +Allowed structural phases are: + +- `loading_structural_index` +- `locating_symbol` +- `tracing_calls` +- `serializing_result` + +Allowed enrichment phases are: + +- `loading_program_index` +- `joining_evidence` +- `serializing_result` + +Every successful stream has exactly one terminal result. Every failed stream +has exactly one terminal error. Cancellation may end the process without a +terminal record, and the host treats that as cancellation only when it +requested the abort. + +The JSON response gains additive resolver timing metadata: + +```json +{ + "timingsMs": { + "indexLoad": 4, + "rootResolution": 3, + "traversal": 4, + "enrichment": 0, + "resolverTotal": 11 + } +} +``` + +The JSONL terminal envelope additionally reports serialization and end-to-end +elapsed time because those values are only known after the response has been +encoded. VS Code maps progress phases to the three user-facing steps and +records slow requests in the Compass output channel with artifact fingerprint, +artifact size, direction, depth, result counts, cache hit/miss, and phase +timings. + +## Progressive enrichment + +After a structural result is visible, VS Code automatically starts a +cancellable enrichment request when Program IR is available. + +The webview shows a compact “Adding semantic evidence…” status without hiding +the structural graph. An enrichment result may merge only when all of these +still match: + +- repository ID; +- request generation; +- root identity; +- direction; +- depth; +- graph artifact fingerprint; and +- structural response schema. + +Merge behavior preserves structural nodes and relationships, adds exact byte +anchors and evidence IDs, adds Program IR-only unresolved or ambiguous calls, +deduplicates call sites, and updates coverage counts and evidence-layer labels. + +An enrichment error leaves the structural graph visible, changes the compact +status to a nonfatal limitation, and writes diagnostic detail to the output +channel. Retry applies only to enrichment and does not discard the structural +graph. + +## Error handling and cancellation + +- Missing structural index: build from a valid graph and continue. +- Corrupt or incompatible structural index: ignore it, rebuild, and replace it + atomically when possible. +- Structural cache write failure: return the computed structural graph and log + that persistence failed. +- Missing Program IR: finish with structural coverage. +- Invalid Program IR: keep the structural graph visible and report enrichment + unavailable. +- Corrupt or incompatible Program IR index: validate the source Program IR and + rebuild the projection. +- Stale cursor: return a typed error that lets the viewer restart at page one. +- Superseded request: abort its process and ignore every late event. +- Panel disposal: abort structural and enrichment processes and remove + listeners. +- Limit breach: return a bounded, explicitly partial response with pagination + metadata; never label it complete. + +## Compatibility + +- Existing JSON call-graph requests remain valid. +- New JSON fields are additive within `compass.call_graph/1`. +- The viewer accepts responses without new fields during the extension/CLI + compatibility window. +- `compass.program.call_graph/1` remains unchanged. +- Cache formats have independent versions and are safe to delete. +- A newer unsupported cache is a cache miss, not a CLI compatibility failure. +- JSONL progress uses a new schema and is capability-advertised before VS Code + selects it. + +## Performance qualification + +Add a release-mode call-graph qualification script and CI gate. It records: + +- Compass commit and version; +- operating system, architecture, CPU, memory, and Rust toolchain; +- graph and Program IR fingerprints and sizes; +- cache-miss migration time; +- indexed cold-process structural time; +- repeated warm structural time; +- cached enrichment time; +- continuation-page time; +- cancellation acknowledgement time; +- peak resident memory; +- output byte size; and +- deterministic result digest. + +The controlled fixture budgets are: + +- indexed cold-process structural: at most 500 ms; +- VS Code session-cache response: at most 100 ms; +- cached Program IR enrichment: at most one second; +- first progress event: at most 100 ms; and +- cancellation acknowledgement: at most 100 ms. + +The benchmark fails on output-digest drift, missing terminal events, response +bounds violations, unreachable continuation pages, or a median latency +regression above ten percent from the approved baseline. + +The normal unit suite keeps deterministic bounds and ordering assertions. It +does not use a strict wall-clock assertion for a large synthetic graph. + +## Test strategy + +### Rust + +- Shared traversal contract tests run against structural and Program IR + adapters for callers, callees, both, depth, node/edge bounds, pagination, + cycles, self-edges, and deterministic ordering. +- Pagination tests collect all pages and prove every frontier continuation + appears exactly once. +- Cursor tests cover tampering, schema mismatch, request mismatch, stale graph + fingerprints, and deterministic replay. +- Structural and Program IR index tests cover round-trip, deterministic bytes, + corruption, version mismatch, stale signatures, interrupted writes, and + concurrent readers. +- CLI tests cover JSON compatibility, JSONL phase order, exactly one terminal + event, lazy migration, and nonfatal cache-write failure. + +### TypeScript and viewer + +- Process-manager tests cover progress parsing, cancellation, malformed + streams, duplicate terminal events, and output bounds. +- Panel tests cover session-cache hits, fingerprint invalidation, stale + generations, pagination requests, structural-first rendering, and nonfatal + enrichment errors. +- Viewer tests cover real active-step progression, omitted counts, loading the + next continuation page, enrichment status, and evidence merging without + duplicates. + +### Release qualification + +- Benchmark the retained Compass fixture in release mode. +- Record cold, warm, migration, enrichment, pagination, cancellation, memory, + bytes, and digests. +- Retain machine-readable results as CI artifacts. + +## Commit sequence + +The expanded pull request uses six commits: + +1. `feat(call-graph): paginate truncated continuations` +2. `perf(call-graph): share bounded Program IR traversal` +3. `test(call-graph): add performance qualification` +4. `feat(call-graph): stream resolver progress` +5. `perf(call-graph): cache progressive Program IR enrichment` +6. `perf(call-graph): persist compact call indexes` + +Each commit must independently pass its focused tests. The final branch must +pass the complete Rust workspace checks, JavaScript tests, typecheck, extension +build, viewer asset verification, release benchmark, and `git diff --check`. diff --git a/docs/superpowers/specs/2026-07-28-vscode-architecture-flow-design.md b/docs/superpowers/specs/2026-07-28-vscode-architecture-flow-design.md new file mode 100644 index 00000000..733d2df4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-vscode-architecture-flow-design.md @@ -0,0 +1,346 @@ +# VS Code Architecture Flow Design + +**Date:** 2026-07-28 + +**Status:** Approved + +**Implementation root:** `/Users/haipingfu/graphify/compass` + +## Context + +The VS Code architecture panel currently invokes: + +```text +compass export callflow-json --graph +``` + +The CLI returns one JSON document containing every section node and every +intra-section edge. `CompassProcessManager` buffers command output and rejects a +stream after 8 MiB. The Django fixture produces a 35,110,985-byte architecture +document from 50,944 symbols and 190,401 edges, so the extension terminates the +otherwise successful export with: + +```text +Compass output exceeded the 8 MiB safety limit +``` + +Raising the limit alone would permit parsing, but the extension would still post +the complete document to the webview and React would search, sort, and retain all +rows. That would move the bottleneck from process capture to webview hydration. + +The current call-flow model also retains only intra-section edge details. +Cross-section calls become aggregate `sourceSection`, `targetSection`, and +`calls` records. The UI can report that two subsystems communicate, but it cannot +show which symbols make those calls. Its card grid communicates a relationship +inventory rather than a system architecture. + +## Goals + +- Load the 35.1 MB Django architecture export without weakening the ordinary + 8 MiB command safety boundary. +- Keep every symbol and call reachable through search, filtering, paging, and + source navigation. +- Preserve detailed caller, callee, relation, confidence, and source evidence + for cross-subsystem calls. +- Make production architecture legible first while keeping tests, generated + code, vendor code, and unknown scopes explicitly available. +- Replace the overview card grid with a directional, interactive architecture + map that reflects subsystem topology and call volume. +- Keep the extension responsive by sending bounded view slices rather than the + complete model to the webview. +- Preserve local-only operation, VS Code theme integration, keyboard access, + high-contrast support, reduced motion, and cancellation. + +## Non-goals + +- Rendering tens of thousands of symbols simultaneously. +- Replacing the symbol-centered Call Graph workspace. +- Adding a database or persistent architecture index. +- Reimplementing Compass graph extraction or semantic inference in TypeScript. +- Automatically excluding tests or generated code without visible disclosure. +- Raising the output limit for unrelated Compass commands. +- Adding runtime network requests, telemetry, or external diagram services. + +## Considered approaches + +### 1. Architecture-specific 128 MiB capture with host-side projection — selected + +The extension allows up to 128 MiB of stdout for the architecture export only. +It validates and retains the complete model in the extension host, then sends +small overview, search, subsystem, and route pages to the webview. The shared +viewer consumes a provider interface; VS Code uses a message-backed provider and +offline documents can use an in-memory provider. + +This fits the measured Django payload with substantial headroom, keeps ordinary +commands at 8 MiB, avoids repeated CLI derivation, and makes webview work +proportional to what is visible. + +### 2. Paged CLI commands + +Compass could expose separate overview, section, route, and search commands. +This lowers extension-host memory but either reloads and re-derives the graph for +each page or requires a new indexed artifact. It creates a larger public CLI +surface and more process latency than the current payload requires. + +### 3. Disk-backed streaming artifact + +The CLI could write an indexed architecture artifact and the extension could +read records lazily. This provides the highest ceiling, but introduces artifact +lifecycle, atomicity, schema migration, and cleanup work disproportionate to the +35.1 MB reproduction. + +## Selected architecture + +The system keeps three authority layers: + +```text +Compass output model + complete symbols, internal calls, cross-subsystem calls, evidence, counts + | + | validated compass.viewer.callflow/1 JSON (up to 128 MiB) + v +VS Code architecture controller + owns full model, scope projection, search, paging, selection generation + | + | bounded typed messages + v +Shared React architecture workspace + owns visible interaction state, SVG map, tables, inspector, accessibility +``` + +The extension host is a presentation index, not a semantic engine. It groups, +filters, sorts, and pages fields already supplied by Compass. It does not infer +new calls or read private Compass storage. + +### Process capture + +`CompassProcessManager` gains per-command output limits. Existing callers retain +an 8 MiB stdout and stderr ceiling. The architecture controller requests a +128 MiB stdout ceiling while stderr remains bounded at 8 MiB. + +Limits are measured in UTF-8 bytes rather than JavaScript string length. A limit +error identifies the stream and configured ceiling. Cancellation and child +termination behavior remain unchanged. + +The 128 MiB value is a safety ceiling, not a webview payload target. + +### Call-flow contract + +The CLI and extension use only `compass.viewer.callflow/1`. Its complete +contract requires: + +- section summary counts independent of loaded row arrays; +- a source scope for each node: `production`, `test`, `generated`, `vendor`, or + `unknown`; +- complete cross-section call records with source section, target section, + caller, callee, relation, and confidence; +- coverage counts for internal, cross-section, and unassigned calls; and +- enough endpoint source metadata for the inspector and source navigation. + +Every graph edge must be classified as internal, cross-section, or unassigned. +The model exposes all three totals. Section derivation places otherwise +unassigned nodes in `Other`; if an edge still cannot be represented, the UI +discloses its count instead of presenting the view as complete. + +The capability report, export, parser, and VS Code compatibility requirement +all use `/1`. Payloads missing any current v1 field are rejected rather than +normalized, and no alternate schema version is accepted. + +### Host projection and messaging + +The architecture controller replaces its one-shot `hydrate` message with a +request/response protocol carrying repository identity, request identity, and +generation: + +- `architectureOverview` supplies section summaries, overview connections, + statistics, coverage, provenance, and active scope counts; +- `requestSection` / `sectionPage` supplies a bounded symbol or internal-call + page for one section; +- `requestRoute` / `routePage` supplies the detailed calls behind one + cross-section connection; +- `searchArchitecture` / `architectureSearchResults` searches the complete + host model and returns ranked, bounded results; +- `setArchitectureScope` rebuilds summaries for production or all-code scope; +- `openSource`, `retry`, and `showOutput` retain their current responsibilities. + +Responses that do not match the active repository, generation, and request are +ignored. The controller holds the model only for the panel lifetime and releases +it on disposal. Pages are deterministic and disclose total rows, current range, +and active filters. + +Initial and interactive messages target less than 1 MiB. Row pages default to +100 calls or 100 symbols and remain user-pageable to the end of the collection. + +## Information architecture and interaction + +The workspace uses three coordinated regions: + +```text +┌─ Subsystems ──────┬─ System map ──────────────────────┬─ Inspector ─────────┐ +│ Core │ │ Selected subsystem │ +│ Integrations │ API ━━━━━━━▶ Models │ Incoming routes │ +│ Infrastructure │ ┃ ┃ │ Outgoing routes │ +│ Tests │ ▼ ▼ │ Symbols and calls │ +│ Generated │ Auth ──────▶ Storage │ Evidence and source │ +└───────────────────┴───────────────────────────────────┴─────────────────────┘ +``` + +The left rail groups sections by source scope and displays symbol and call +counts. It remains searchable and collapses to a selector at narrow widths. + +The central SVG map is the signature interaction. It uses deterministic layered +layout, directed curves, arrowheads, and restrained motion: + +- node area encodes subsystem symbol count; +- connection width encodes call volume using a capped logarithmic scale; +- solid lines indicate extracted-majority evidence; +- dashed lines indicate inferred-majority evidence; +- selecting a subsystem highlights its incoming and outgoing neighborhood; +- selecting a connection opens every underlying cross-subsystem call; +- unrelated topology dims but remains visible; and +- zoom, fit, reset, pan, keyboard traversal, and a table alternative are + available without external diagram services. + +The right inspector explains the current selection. For a subsystem it shows +scope, counts, top incoming and outgoing routes, source-file groups, symbols, +and internal calls. For a connection it shows source and target, total calls, +evidence distribution, and the paged underlying call records. Caller, callee, +and source paths open code through the extension host. + +A compact top toolbar owns: + +- global architecture search; +- `Production` and `All code` scope; +- extracted, inferred, and ambiguous evidence filters; +- fit/reset controls; and +- a visible coverage summary. + +Production is the initial scope. The toolbar states, for example, “Production · +17,674 of 50,944 symbols.” Tests and generated code are never silently removed: +their counts remain visible in the rail and the `All code` action makes them +fully searchable and inspectable. + +The existing symbols and calls tables remain as accessible, exhaustive +alternatives to the map. Card grids are removed from the primary architecture +path because they obscure direction and topology at scale. + +## Visual language + +The architecture workspace should feel like a live engineering blueprint inside +VS Code, not a dashboard embedded in VS Code. + +- Editor, sidebar, input, focus, selection, warning, and link colors continue to + derive from VS Code variables. +- Subsystem colors are stable data colors with light, dark, and high-contrast + variants; color is never the only evidence cue. +- The utility and data face remains the VS Code monospace font. Labels use the + editor UI font for legibility. +- Borders, labels, route thickness, and negative space carry hierarchy. Shadows, + gradients, and decorative cards are avoided. +- One orchestrated transition connects selection to highlighted routes and the + inspector. Reduced-motion mode changes state immediately. + +## Loading, empty, and error states + +Loading progress distinguishes export, validation, indexing, and map +preparation. Large repositories receive factual copy such as “Indexing 190,401 +calls locally” rather than an indefinite spinner. + +Errors remain in the panel and provide `Retry` and `Show output` actions: + +- exports between 8 MiB and 128 MiB load normally; +- output above 128 MiB reports the architecture-specific ceiling and does not + suggest that the graph is corrupt; +- malformed `/2` data reports an incompatible export; +- disposal or retry cancels the active process and invalidates late responses; +- no matching filters produce an actionable empty state; and +- unassigned calls appear as a coverage warning with their exact count. + +The controller logs repository path, phase, elapsed time, and payload byte count +without logging source-derived graph content. + +## Performance and resilience + +- The CLI runs once per panel hydration. +- The host retains one validated full model and small derived indexes. +- Search uses pre-normalized labels, paths, relations, and section names. +- The webview receives bounded pages and summary topology only. +- SVG renders subsystem-level topology, never all symbols. +- Tables paginate rather than mounting off-screen rows. +- Scope and evidence changes reuse host indexes. +- Panel disposal releases the model, indexes, listeners, and active process. +- Narrow layouts move the inspector below the map; they do not discard + functionality. + +## Testing strategy + +Implementation is organized as context-rich production slices. Each slice is +implemented first, followed immediately by focused regression tests and fresh +verification before the next slice. + +### Rust model tests + +- `/2` serializes section summaries and source scopes. +- A cross-section edge retains its caller, callee, sections, relation, and + confidence. +- `internal + cross-section + unassigned` equals total graph edges. +- Production, test, generated, vendor, and unknown fixtures classify + deterministically. +- The Django-shaped distribution does not lose test or cross-section data. + +### Extension-host tests + +- Ordinary stdout still fails above 8 MiB. +- Architecture stdout succeeds above 8 MiB and through 128 MiB. +- UTF-8 byte counts, stdout and stderr limits, cancellation, and kill behavior + remain correct. +- Overview hydration never posts the full retained model. +- Section, route, search, scope, and paging requests return bounded, + identity-matched results. +- Retry and disposal reject stale responses and release retained state. + +### Viewer tests + +- Production is the initial, visibly disclosed scope. +- `All code` exposes test and generated sections. +- The map renders directed accessible connections and coverage counts. +- Selecting a subsystem highlights both incoming and outgoing routes. +- Selecting a route exposes complete paginated call evidence. +- Search reaches results outside the current page. +- Keyboard navigation, table alternative, high contrast, narrow layout, and + reduced motion preserve the same information. + +### Integration and qualification + +- A synthetic call-flow export larger than 8 MiB and smaller than 128 MiB + hydrates successfully. +- The 35,110,985-byte Django export loads, reports 50,944 symbols and 190,401 + edges, and exposes detailed cross-subsystem calls. +- Type checks, viewer tests, extension tests, Rust tests, production builds, VSIX + smoke checks, and `graphify update .` pass before completion. + +## Acceptance criteria + +1. Opening Django architecture no longer emits the 8 MiB failure. +2. Ordinary Compass commands retain the 8 MiB safety limit. +3. No webview hydration message contains the complete 35.1 MB model. +4. The initial view visibly identifies its production-only scope and full totals. +5. Every section and symbol is reachable in `All code`, including tests and + generated code. +6. Every represented graph edge is reachable as an internal or cross-section + call; any unassigned count is explicitly disclosed. +7. Selecting a subsystem connection reveals its complete caller/callee evidence + with source navigation and pagination. +8. The overview is a directional subsystem map with volume and evidence encoded + accessibly. +9. Search covers the complete retained model rather than the currently rendered + page. +10. Loading, cancellation, retry, errors, high contrast, reduced motion, + keyboard use, and narrow layouts remain functional. + +## Rollout + +The `/2` CLI contract and matching extension requirement ship together. The +architecture panel does not attempt to interpret `/1` as `/2`; it shows the +standard upgrade path. No graph artifact migration is required because the +model is derived from the existing public graph on demand. diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index 74fc7c38..af8a3049 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.1.9 + +- Add first-run Compass CLI installation in a visible VS Code terminal on + macOS, Linux, and Windows. +- Verify and activate installed or manually selected CLIs without reloading the + editor. +- Replace the architecture relationship grid with a production-first, + directional subsystem map and complete paged cross-subsystem call evidence. +- Arrange subsystem maps into low-noise call-flow lanes with bundled routes, + persistent drag positioning, focus highlighting, and collapsible details. +- Use the complete `compass.viewer.callflow/1` contract exclusively for + negotiation, export validation, production scoping, and complete call evidence. +- Load architecture exports up to 128 MiB while keeping ordinary Compass + commands at the 8 MiB safety ceiling. + ## 0.1.8 - Add first-run Compass CLI installation in a visible VS Code terminal on diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 4079de0d..df600dd4 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -3,7 +3,7 @@ "displayName": "Compass Codegraph", "description": "Explore, query, and understand how your codebase evolves with Compass.", "publisher": "crabbuild", - "version": "0.1.8", + "version": "0.1.9", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/editors/vscode/src/cli/compatibility.test.ts b/editors/vscode/src/cli/compatibility.test.ts index c6e495da..98757e06 100644 --- a/editors/vscode/src/cli/compatibility.test.ts +++ b/editors/vscode/src/cli/compatibility.test.ts @@ -55,4 +55,25 @@ describe("compatibilityIssue", () => { expect(compatibilityIssue(compatible, undefined, COMPASS_REQUIREMENTS.calls)) .toContain("'call_graph' feature"); }); + + it("requires the complete architecture flow contract", () => { + const report: CapabilityReport = { + ...compatible, + contracts: { + ...compatible.contracts, + callflow_viewer: "compass.viewer.callflow/1" + } + }; + + expect(compatibilityIssue(report, undefined, COMPASS_REQUIREMENTS.architecture)) + .toBeUndefined(); + expect(compatibilityIssue({ + ...report, + contracts: { + ...report.contracts, + callflow_viewer: "compass.viewer.callflow/2" + } + }, undefined, COMPASS_REQUIREMENTS.architecture)) + .toContain("requires 'compass.viewer.callflow/1'"); + }); }); diff --git a/editors/vscode/src/cli/processManager.test.ts b/editors/vscode/src/cli/processManager.test.ts index 6986f666..ed2be3eb 100644 --- a/editors/vscode/src/cli/processManager.test.ts +++ b/editors/vscode/src/cli/processManager.test.ts @@ -64,6 +64,85 @@ describe("CompassProcessManager", () => { }); }); + it("keeps the ordinary stdout ceiling at 8 MiB", async () => { + const { child, stdout } = childProcess(); + const processes = new CompassProcessManager( + "compass", + vi.fn(() => child) as never + ); + const command = processes.startCommand("/repo", ["export", "json"]); + + stdout.write("x".repeat(8 * 1024 * 1024 + 1)); + + await expect(command.completed).rejects.toThrow( + "Compass stdout exceeded the 8 MiB safety limit" + ); + expect(child.kill).toHaveBeenCalledOnce(); + }); + + it("allows architecture stdout between 8 MiB and 128 MiB", async () => { + const { child, stdout } = childProcess(); + const processes = new CompassProcessManager( + "compass", + vi.fn(() => child) as never + ); + const command = processes.startCommand( + "/repo", + ["export", "callflow-json"], + { stdoutBytes: 128 * 1024 * 1024 } + ); + const payload = "x".repeat(9 * 1024 * 1024); + + stdout.write(payload); + child.emit("close", 0); + + await expect(command.completed).resolves.toEqual({ + code: 0, + stdout: payload, + stderr: "" + }); + }); + + it("measures multibyte output as UTF-8 bytes", async () => { + const { child, stdout } = childProcess(); + const processes = new CompassProcessManager( + "compass", + vi.fn(() => child) as never + ); + const command = processes.startCommand( + "/repo", + ["capabilities"], + { stdoutBytes: 3 } + ); + + stdout.write("éé"); + + await expect(command.completed).rejects.toThrow( + "Compass stdout exceeded the 0.00 MiB safety limit" + ); + expect(child.kill).toHaveBeenCalledOnce(); + }); + + it("keeps stderr at 8 MiB when stdout has the architecture ceiling", async () => { + const { child, stderr } = childProcess(); + const processes = new CompassProcessManager( + "compass", + vi.fn(() => child) as never + ); + const command = processes.startCommand( + "/repo", + ["export", "callflow-json"], + { stdoutBytes: 128 * 1024 * 1024 } + ); + + stderr.write("x".repeat(8 * 1024 * 1024 + 1)); + + await expect(command.completed).rejects.toThrow( + "Compass stderr exceeded the 8 MiB safety limit" + ); + expect(child.kill).toHaveBeenCalledOnce(); + }); + it("switches future launches to an activated executable", () => { const first = childProcess(); const second = childProcess(); diff --git a/editors/vscode/src/cli/processManager.ts b/editors/vscode/src/cli/processManager.ts index 2df4db8b..0a637b00 100644 --- a/editors/vscode/src/cli/processManager.ts +++ b/editors/vscode/src/cli/processManager.ts @@ -3,10 +3,14 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:ch import type { ZodType } from "zod"; import { ProgressEventSchema, type ProgressEvent } from "./contracts"; -const OUTPUT_LIMIT = 8 * 1024 * 1024; +const DEFAULT_OUTPUT_LIMIT = 8 * 1024 * 1024; type Spawn = typeof nodeSpawn; export type CommandResult = { code: number; stdout: string; stderr: string }; +export type OutputLimits = { + stdoutBytes?: number; + stderrBytes?: number; +}; export type RunningCommand = { operationId: string; completed: Promise; @@ -33,18 +37,27 @@ export class CompassProcessManager { this.executable = next; } - run(cwd: string, args: readonly string[], signal?: AbortSignal): Promise { - const command = this.startCommand(cwd, args); + run( + cwd: string, + args: readonly string[], + signal?: AbortSignal, + limits?: OutputLimits + ): Promise { + const command = this.startCommand(cwd, args, limits); const cancel = () => command.cancel(); signal?.addEventListener("abort", cancel, { once: true }); return command.completed.finally(() => signal?.removeEventListener("abort", cancel)); } - startCommand(cwd: string, args: readonly string[]): RunningCommand { + startCommand( + cwd: string, + args: readonly string[], + limits?: OutputLimits + ): RunningCommand { const child = this.start(cwd, args); return { operationId: randomUUID(), - completed: collect(child), + completed: collect(child, true, limits), cancel: () => child.kill() }; } @@ -53,9 +66,10 @@ export class CompassProcessManager { cwd: string, args: readonly string[], schema: ZodType, - signal?: AbortSignal + signal?: AbortSignal, + limits?: OutputLimits ): Promise { - const result = await this.run(cwd, args, signal); + const result = await this.run(cwd, args, signal, limits); if (result.code !== 0) throw new Error(result.stderr || `Compass exited with ${result.code}`); return schema.parse(JSON.parse(result.stdout)); } @@ -74,7 +88,7 @@ export class CompassProcessManager { child.stdout.on("data", (chunk: string) => { if (progressError) return; try { - buffered = bounded(buffered, chunk); + buffered = boundedPartial(buffered, chunk, DEFAULT_OUTPUT_LIMIT, "stdout"); const lines = buffered.split(/\r?\n/); buffered = lines.pop() ?? ""; for (const line of lines.filter(Boolean)) { @@ -109,46 +123,88 @@ export class CompassProcessManager { function collect( child: ChildProcessWithoutNullStreams, - captureStdout = true + captureStdout = true, + limits: OutputLimits = {} ): Promise { return new Promise((resolve, reject) => { - let stdout = ""; - let stderr = ""; + const stdout = new BoundedTextBuffer( + "stdout", + limits.stdoutBytes ?? DEFAULT_OUTPUT_LIMIT + ); + const stderr = new BoundedTextBuffer( + "stderr", + limits.stderrBytes ?? DEFAULT_OUTPUT_LIMIT + ); let streamError: unknown; - const append = (current: string, chunk: string): string => { - if (streamError) return current; + const append = (buffer: BoundedTextBuffer, chunk: string): void => { + if (streamError) return; try { - return bounded(current, chunk); + buffer.append(chunk); } catch (error) { streamError = error; child.kill(); - return current; } }; if (captureStdout) { child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { - stdout = append(stdout, chunk); + append(stdout, chunk); }); } child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { - stderr = append(stderr, chunk); + append(stderr, chunk); }); child.once("error", reject); child.once("close", (code) => { if (streamError) { reject(streamError); } else { - resolve({ code: code ?? 1, stdout, stderr }); + resolve({ code: code ?? 1, stdout: stdout.value, stderr: stderr.value }); } }); }); } -function bounded(current: string, chunk: string): string { - if (current.length + chunk.length > OUTPUT_LIMIT) { - throw new Error("Compass output exceeded the 8 MiB safety limit"); +class BoundedTextBuffer { + private text = ""; + private bytes = 0; + + constructor( + private readonly stream: "stdout" | "stderr", + private readonly limit: number + ) {} + + get value(): string { + return this.text; + } + + append(chunk: string): void { + const nextBytes = Buffer.byteLength(chunk, "utf8"); + if (this.bytes + nextBytes > this.limit) { + throw outputLimitError(this.stream, this.limit); + } + this.text += chunk; + this.bytes += nextBytes; + } +} + +function boundedPartial( + current: string, + chunk: string, + limit: number, + stream: "stdout" | "stderr" +): string { + if ( + Buffer.byteLength(current, "utf8") + Buffer.byteLength(chunk, "utf8") > limit + ) { + throw outputLimitError(stream, limit); } return current + chunk; } + +function outputLimitError(stream: "stdout" | "stderr", limit: number): Error { + const mib = limit / (1024 * 1024); + const display = Number.isInteger(mib) ? mib.toFixed(0) : mib.toFixed(2); + return new Error(`Compass ${stream} exceeded the ${display} MiB safety limit`); +} diff --git a/editors/vscode/src/transport/architectureMessages.test.ts b/editors/vscode/src/transport/architectureMessages.test.ts new file mode 100644 index 00000000..0d1134cb --- /dev/null +++ b/editors/vscode/src/transport/architectureMessages.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + ArchitectureToHostMessageSchema, + HostToArchitectureMessageSchema +} from "./architectureMessages"; + +describe("architecture messages", () => { + it("accepts an identity-bound section request", () => { + expect(ArchitectureToHostMessageSchema.parse({ + type: "requestSection", + requestId: "request-1", + repositoryId: "/repo", + generation: 2, + scope: "production", + evidence: "all", + page: 1, + pageSize: 100, + sectionId: "api", + kind: "calls" + })).toMatchObject({ sectionId: "api", generation: 2 }); + }); + + it("rejects unbounded page sizes", () => { + expect(ArchitectureToHostMessageSchema.safeParse({ + type: "requestRoute", + requestId: "request-1", + repositoryId: "/repo", + generation: 2, + scope: "all", + evidence: "all", + page: 1, + pageSize: 101, + routeId: "api→storage" + }).success).toBe(false); + }); + + it("validates bounded overview responses without a full-model payload", () => { + const parsed = HostToArchitectureMessageSchema.parse({ + type: "architectureOverview", + requestId: "request-1", + repositoryId: "/repo", + generation: 2, + model: { + title: "Fixture", + scope: "production", + evidence: "all", + sections: [], + routes: [], + statistics: { + visibleNodes: 2, + totalNodes: 4, + visibleCalls: 1, + totalCalls: 3, + communities: 2, + extracted: 2, + inferred: 1, + ambiguous: 0 + }, + coverage: { internal: 1, crossSection: 2, unassigned: 0 }, + provenance: { projectName: "Fixture", builtAtCommit: null, generatedAt: null } + } + }); + + expect(parsed.type).toBe("architectureOverview"); + if (parsed.type !== "architectureOverview") throw new Error("Expected overview"); + expect("sections" in parsed.model).toBe(true); + expect("crossSectionCalls" in parsed.model).toBe(false); + }); +}); diff --git a/editors/vscode/src/transport/architectureMessages.ts b/editors/vscode/src/transport/architectureMessages.ts new file mode 100644 index 00000000..2adf5c8b --- /dev/null +++ b/editors/vscode/src/transport/architectureMessages.ts @@ -0,0 +1,90 @@ +import { + ArchitectureEvidenceSchema, + ArchitectureOverviewSchema, + ArchitectureRoutePageSchema, + ArchitectureScopeSchema, + ArchitectureSearchPageSchema, + ArchitectureSectionPageSchema +} from "@compass/viewer/contracts/architecture"; +import { z } from "zod"; + +const RequestIdentitySchema = z.object({ + requestId: z.string().min(1), + repositoryId: z.string().min(1) +}); + +const DataRequestSchema = RequestIdentitySchema.extend({ + generation: z.number().int().nonnegative(), + scope: ArchitectureScopeSchema, + evidence: ArchitectureEvidenceSchema +}); + +const PageRequestSchema = DataRequestSchema.extend({ + page: z.number().int().positive(), + pageSize: z.number().int().min(1).max(100), + query: z.string().optional() +}); + +export const ArchitectureToHostMessageSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("ready") }), + z.object({ type: z.literal("retry") }), + z.object({ type: z.literal("showOutput") }), + RequestIdentitySchema.extend({ + type: z.literal("setArchitectureFilters"), + scope: ArchitectureScopeSchema, + evidence: ArchitectureEvidenceSchema + }), + PageRequestSchema.extend({ + type: z.literal("requestSection"), + sectionId: z.string().min(1), + kind: z.enum(["symbols", "calls"]) + }), + PageRequestSchema.extend({ + type: z.literal("requestRoute"), + routeId: z.string().min(1) + }), + PageRequestSchema.extend({ + type: z.literal("searchArchitecture"), + query: z.string() + }), + RequestIdentitySchema.extend({ + type: z.literal("openSource"), + file: z.string().min(1) + }) +]); + +const ResponseIdentitySchema = RequestIdentitySchema.extend({ + generation: z.number().int().nonnegative() +}); + +export const HostToArchitectureMessageSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("architectureLoading"), + phase: z.enum(["exporting", "validating", "indexing", "mapping"]), + message: z.string() + }), + ResponseIdentitySchema.extend({ + type: z.literal("architectureOverview"), + model: ArchitectureOverviewSchema + }), + ResponseIdentitySchema.extend({ + type: z.literal("architectureSectionPage"), + model: ArchitectureSectionPageSchema + }), + ResponseIdentitySchema.extend({ + type: z.literal("architectureRoutePage"), + model: ArchitectureRoutePageSchema + }), + ResponseIdentitySchema.extend({ + type: z.literal("architectureSearchResults"), + model: ArchitectureSearchPageSchema + }), + z.object({ + type: z.literal("error"), + message: z.string(), + recoverable: z.boolean().default(true) + }) +]); + +export type ArchitectureToHostMessage = z.infer; +export type HostToArchitectureMessage = z.infer; diff --git a/editors/vscode/src/views/architectureIndex.test.ts b/editors/vscode/src/views/architectureIndex.test.ts new file mode 100644 index 00000000..49b2a28d --- /dev/null +++ b/editors/vscode/src/views/architectureIndex.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import type { CallflowViewModel } from "@compass/viewer/contracts/callflow"; +import { ArchitectureIndex, routeId } from "./architectureIndex"; + +const model: CallflowViewModel = { + schema: "compass.viewer.callflow/1", + title: "Fixture — Architecture Flow", + sections: [ + { + id: "overview", + name: "Architecture Overview", + communities: [], + nodeCount: 0, + internalCallCount: 0, + nodes: [], + edges: [] + }, + { + id: "api", + name: "API", + communities: ["0"], + nodeCount: 3, + internalCallCount: 1, + nodes: [ + { + id: "handler", label: "request_handler", kind: "function", + sourceFile: "src/api.ts", scope: "production" + }, + { + id: "helper", label: "helper", kind: "function", + sourceFile: "src/helper.ts", scope: "production" + }, + { + id: "api_test", label: "request_handler_test", kind: "function", + sourceFile: "tests/api.test.ts", scope: "test" + } + ], + edges: [ + { + source: "handler", target: "helper", + relation: "calls", confidence: "extracted" + } + ] + }, + { + id: "storage", + name: "Storage", + communities: ["1"], + nodeCount: 2, + internalCallCount: 0, + nodes: [ + { + id: "store", label: "save_record", kind: "function", + sourceFile: "src/store.ts", scope: "production" + }, + { + id: "generated", label: "GeneratedModel", kind: "class", + sourceFile: "generated/model.ts", scope: "generated" + } + ], + edges: [] + } + ], + overviewLinks: [{ sourceSection: "api", targetSection: "storage", calls: 3 }], + crossSectionCalls: [ + { + source: "handler", target: "store", sourceSection: "api", + targetSection: "storage", relation: "calls", confidence: "extracted" + }, + { + source: "api_test", target: "store", sourceSection: "api", + targetSection: "storage", relation: "calls", confidence: "inferred" + }, + { + source: "handler", target: "generated", sourceSection: "api", + targetSection: "storage", relation: "references", confidence: "ambiguous" + } + ], + coverage: { internal: 1, crossSection: 3, unassigned: 0 }, + reportHighlights: [], + statistics: { + nodes: 5, + edges: 4, + communities: 2, + hyperedges: 0, + extracted: 2, + inferred: 1, + ambiguous: 1 + }, + provenance: { projectName: "Fixture", builtAtCommit: "abc123", generatedAt: null } +}; + +describe("ArchitectureIndex", () => { + it("defaults projections to production while disclosing complete totals", () => { + const overview = new ArchitectureIndex(model).overview("production", "all"); + + expect(overview.statistics).toMatchObject({ + visibleNodes: 3, + totalNodes: 5, + visibleCalls: 2, + totalCalls: 4 + }); + expect(overview.routes).toEqual([ + expect.objectContaining({ + sourceSection: "api", + targetSection: "storage", + calls: 1, + extracted: 1 + }) + ]); + expect(overview.sections.find((section) => section.id === "api")?.scopes.test).toBe(1); + }); + + it("restores test and generated calls in all-code scope", () => { + const overview = new ArchitectureIndex(model).overview("all", "all"); + expect(overview.statistics.visibleNodes).toBe(5); + expect(overview.statistics.visibleCalls).toBe(4); + expect(overview.routes[0]).toMatchObject({ + calls: 3, + extracted: 1, + inferred: 1, + ambiguous: 1 + }); + }); + + it("filters evidence and pages every call behind a route", () => { + const index = new ArchitectureIndex(model); + const page = index.routePage({ + routeId: routeId("api", "storage"), + scope: "all", + evidence: "all", + page: 2, + pageSize: 2 + }); + expect(page).toMatchObject({ total: 3, start: 3, end: 3 }); + expect(page.items).toHaveLength(1); + + const inferred = index.overview("all", "inferred"); + expect(inferred.routes[0]).toMatchObject({ calls: 1, inferred: 1 }); + }); + + it("searches the complete retained model rather than one visible page", () => { + const results = new ArchitectureIndex(model).search({ + query: "GeneratedModel", + scope: "all", + evidence: "all", + page: 1, + pageSize: 10 + }); + expect(results.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: "symbol", + label: "GeneratedModel", + sectionId: "storage" + }) + ])); + }); + + it("returns bounded section pages with complete range metadata", () => { + const page = new ArchitectureIndex(model).sectionPage({ + sectionId: "api", + kind: "symbols", + scope: "all", + evidence: "all", + page: 1, + pageSize: 2 + }); + expect(page).toMatchObject({ + kind: "symbols", + total: 3, + start: 1, + end: 2, + pageCount: 2 + }); + expect(page.items).toHaveLength(2); + }); + +}); diff --git a/editors/vscode/src/views/architectureIndex.ts b/editors/vscode/src/views/architectureIndex.ts new file mode 100644 index 00000000..59cfc2f2 --- /dev/null +++ b/editors/vscode/src/views/architectureIndex.ts @@ -0,0 +1,356 @@ +import type { + ArchitectureCall, + ArchitectureEvidence, + ArchitectureOverview, + ArchitectureRoutePage, + ArchitectureSearchPage, + ArchitectureSearchResult, + ArchitectureScope, + ArchitectureSectionPage, + ArchitectureSourceScope, + ArchitectureSymbol +} from "@compass/viewer/contracts/architecture"; +import type { CallflowViewModel } from "@compass/viewer/contracts/callflow"; + +type SourceScope = ArchitectureSourceScope; + +export type PageRequest = { + page: number; + pageSize: number; + query?: string | undefined; + scope: ArchitectureScope; + evidence: ArchitectureEvidence; +}; + +export type SectionPageRequest = PageRequest & { + sectionId: string; + kind: "symbols" | "calls"; +}; + +export type RoutePageRequest = PageRequest & { + routeId: string; +}; + +export type SearchRequest = PageRequest & { + query: string; +}; + +type IndexedNode = ArchitectureSymbol & { normalized: string }; +type IndexedCall = ArchitectureCall & { normalized: string }; + +const EMPTY_SCOPES: Record = { + production: 0, + test: 0, + generated: 0, + vendor: 0, + unknown: 0 +}; + +export class ArchitectureIndex { + private readonly sections; + private readonly nodes = new Map(); + private readonly internalCalls = new Map(); + private readonly crossCalls: IndexedCall[]; + private readonly routes = new Map(); + + constructor(private readonly model: CallflowViewModel) { + this.sections = model.sections.filter((section) => section.id !== "overview"); + for (const section of this.sections) { + for (const node of section.nodes) { + this.nodes.set(node.id, { + ...node, + sectionId: section.id, + normalized: normalize([node.label, node.kind, node.sourceFile ?? "", section.name]) + }); + } + } + for (const section of this.sections) { + this.internalCalls.set( + section.id, + section.edges.map((edge, index) => + this.indexedCall(edge, section.id, section.id, `internal:${section.id}:${index}`) + ) + ); + } + this.crossCalls = model.crossSectionCalls.map((call, index) => + this.indexedCall( + call, + call.sourceSection, + call.targetSection, + `cross:${call.sourceSection}:${call.targetSection}:${index}` + ) + ); + for (const call of this.crossCalls) { + const id = routeId(call.sourceSection, call.targetSection); + const calls = this.routes.get(id) ?? []; + calls.push(call); + this.routes.set(id, calls); + } + } + + overview( + scope: ArchitectureScope, + evidence: ArchitectureEvidence + ): ArchitectureOverview { + const sectionCounts = new Map(); + const countRoute = (sourceSection: string, targetSection: string, calls: number) => { + sectionCounts.set(sourceSection, { + incoming: sectionCounts.get(sourceSection)?.incoming ?? 0, + outgoing: (sectionCounts.get(sourceSection)?.outgoing ?? 0) + calls + }); + sectionCounts.set(targetSection, { + incoming: (sectionCounts.get(targetSection)?.incoming ?? 0) + calls, + outgoing: sectionCounts.get(targetSection)?.outgoing ?? 0 + }); + }; + const routes: ArchitectureOverview["routes"] = [...this.routes.entries()] + .map(([id, calls]) => { + const visible = calls.filter((call) => this.includeCall(call, scope, evidence)); + if (visible.length === 0) return undefined; + countRoute(visible[0]!.sourceSection, visible[0]!.targetSection, visible.length); + return { + id, + sourceSection: visible[0]!.sourceSection, + targetSection: visible[0]!.targetSection, + calls: visible.length, + extracted: visible.filter((call) => call.confidence === "extracted").length, + inferred: visible.filter((call) => call.confidence === "inferred").length, + ambiguous: visible.filter((call) => call.confidence === "ambiguous").length + }; + }) + .filter((route) => route !== undefined); + routes.sort((left, right) => right.calls - left.calls || left.id.localeCompare(right.id)); + + const sections = this.sections.map((section) => { + const allNodes = section.nodes.map((node) => this.nodes.get(node.id)!); + const visibleNodes = allNodes.filter((node) => this.includeNode(node, scope)); + const calls = (this.internalCalls.get(section.id) ?? []) + .filter((call) => this.includeCall(call, scope, evidence)); + const scopes = { ...EMPTY_SCOPES }; + for (const node of allNodes) scopes[node.scope] += 1; + return { + id: section.id, + name: section.name, + nodeCount: visibleNodes.length, + totalNodeCount: allNodes.length, + internalCallCount: calls.length, + incomingCalls: sectionCounts.get(section.id)?.incoming ?? 0, + outgoingCalls: sectionCounts.get(section.id)?.outgoing ?? 0, + scopes + }; + }); + const visibleNodes = [...this.nodes.values()] + .filter((node) => this.includeNode(node, scope)).length; + const visibleInternal = [...this.internalCalls.values()].flat() + .filter((call) => this.includeCall(call, scope, evidence)).length; + const visibleCross = routes.reduce((total, route) => total + route.calls, 0); + return { + title: this.model.title, + scope, + evidence, + sections, + routes, + statistics: { + visibleNodes, + totalNodes: this.model.statistics.nodes, + visibleCalls: visibleInternal + visibleCross, + totalCalls: this.model.statistics.edges, + communities: this.model.statistics.communities, + extracted: this.model.statistics.extracted, + inferred: this.model.statistics.inferred, + ambiguous: this.model.statistics.ambiguous + }, + coverage: this.model.coverage, + provenance: this.model.provenance + }; + } + + sectionPage(request: SectionPageRequest): ArchitectureSectionPage { + const section = this.sections.find((candidate) => candidate.id === request.sectionId); + if (!section) throw new Error(`Unknown architecture section '${request.sectionId}'`); + const query = normalized(request.query); + if (request.kind === "symbols") { + const items = section.nodes + .map((node) => this.nodes.get(node.id)!) + .filter((node) => this.includeNode(node, request.scope)) + .filter((node) => !query || node.normalized.includes(query)) + .sort((left, right) => left.label.localeCompare(right.label)) + .map(stripNormalized); + return { kind: "symbols", sectionId: section.id, ...page(items, request) }; + } + const items = (this.internalCalls.get(section.id) ?? []) + .filter((call) => this.includeCall(call, request.scope, request.evidence)) + .filter((call) => !query || call.normalized.includes(query)) + .sort(compareCalls) + .map(stripNormalized); + return { kind: "calls", sectionId: section.id, ...page(items, request) }; + } + + routePage(request: RoutePageRequest): ArchitectureRoutePage { + const calls = this.routes.get(request.routeId); + if (!calls) throw new Error(`Unknown architecture route '${request.routeId}'`); + const query = normalized(request.query); + const items = calls + .filter((call) => this.includeCall(call, request.scope, request.evidence)) + .filter((call) => !query || call.normalized.includes(query)) + .sort(compareCalls) + .map(stripNormalized); + return { + routeId: request.routeId, + sourceSection: calls[0]!.sourceSection, + targetSection: calls[0]!.targetSection, + ...page(items, request) + }; + } + + search(request: SearchRequest): ArchitectureSearchPage { + const query = normalized(request.query); + if (!query) return { query: request.query, ...page([], request) }; + const results: ArchitectureSearchResult[] = this.sections + .filter((section) => normalized(section.name).includes(query)) + .map((section) => ({ + id: `section:${section.id}`, + kind: "section" as const, + label: section.name, + detail: "Subsystem", + sectionId: section.id, + routeId: null, + sourceFile: null + })); + for (const node of this.nodes.values()) { + if (!this.includeNode(node, request.scope) || !node.normalized.includes(query)) continue; + results.push({ + id: `symbol:${node.id}`, + kind: "symbol", + label: node.label, + detail: [node.kind || "symbol", node.sourceFile].filter(Boolean).join(" · "), + sectionId: node.sectionId, + routeId: null, + sourceFile: node.sourceFile + }); + } + for (const call of [ + ...this.crossCalls, + ...[...this.internalCalls.values()].flat() + ]) { + if ( + !this.includeCall(call, request.scope, request.evidence) + || !call.normalized.includes(query) + ) continue; + results.push({ + id: `call:${call.id}`, + kind: "call", + label: `${call.sourceLabel} → ${call.targetLabel}`, + detail: `${call.relation} · ${call.confidence}`, + sectionId: call.sourceSection, + routeId: call.sourceSection === call.targetSection + ? null + : routeId(call.sourceSection, call.targetSection), + sourceFile: call.sourceFile + }); + } + results.sort((left, right) => { + const rank = { section: 0, symbol: 1, call: 2 }; + return rank[left.kind] - rank[right.kind] || left.label.localeCompare(right.label); + }); + return { query: request.query, ...page(results.slice(0, 100), request) }; + } + + private indexedCall( + edge: { + source: string; + target: string; + relation: string; + confidence: "extracted" | "inferred" | "ambiguous"; + }, + sourceSection: string, + targetSection: string, + id: string + ): IndexedCall { + const source = this.nodes.get(edge.source); + const target = this.nodes.get(edge.target); + const call: ArchitectureCall = { + id, + source: edge.source, + target: edge.target, + sourceLabel: source?.label ?? edge.source, + targetLabel: target?.label ?? edge.target, + sourceFile: source?.sourceFile ?? null, + targetFile: target?.sourceFile ?? null, + sourceSection, + targetSection, + relation: edge.relation, + confidence: edge.confidence + }; + return { + ...call, + normalized: normalize([ + call.sourceLabel, + call.targetLabel, + call.sourceFile ?? "", + call.targetFile ?? "", + call.relation, + call.confidence + ]) + }; + } + + private includeNode(node: IndexedNode | undefined, scope: ArchitectureScope): boolean { + return Boolean(node && (scope === "all" || node.scope === "production")); + } + + private includeCall( + call: IndexedCall, + scope: ArchitectureScope, + evidence: ArchitectureEvidence + ): boolean { + return this.includeNode(this.nodes.get(call.source), scope) + && this.includeNode(this.nodes.get(call.target), scope) + && (evidence === "all" || call.confidence === evidence); + } +} + +export function routeId(sourceSection: string, targetSection: string): string { + return `${encodeURIComponent(sourceSection)}→${encodeURIComponent(targetSection)}`; +} + +function normalize(values: readonly string[]): string { + return values.join("\u0000").toLocaleLowerCase(); +} + +function normalized(value: string | undefined): string { + return value?.trim().toLocaleLowerCase() ?? ""; +} + +function stripNormalized({ + normalized: _normalized, + ...value +}: T): Omit { + return value; +} + +function compareCalls(left: IndexedCall, right: IndexedCall): number { + return left.sourceLabel.localeCompare(right.sourceLabel) + || left.targetLabel.localeCompare(right.targetLabel) + || left.relation.localeCompare(right.relation); +} + +function page( + items: readonly T[], + request: Pick +) { + const pageSize = Math.min(100, Math.max(1, Math.trunc(request.pageSize))); + const pageCount = Math.max(1, Math.ceil(items.length / pageSize)); + const current = Math.min(pageCount, Math.max(1, Math.trunc(request.page))); + const offset = (current - 1) * pageSize; + const visible = items.slice(offset, offset + pageSize); + return { + items: visible, + page: current, + pageSize, + pageCount, + total: items.length, + start: visible.length === 0 ? 0 : offset + 1, + end: offset + visible.length + }; +} diff --git a/editors/vscode/src/views/architecturePanel.test.ts b/editors/vscode/src/views/architecturePanel.test.ts new file mode 100644 index 00000000..361e9826 --- /dev/null +++ b/editors/vscode/src/views/architecturePanel.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("vscode", () => ({ + ViewColumn: { Active: 1 }, + Uri: { joinPath: vi.fn(() => ({ toString: () => "asset" })) }, + window: { createWebviewPanel: vi.fn() } +})); +vi.mock("./sourceNavigation", () => ({ openGraphSource: vi.fn() })); + +import { ArchitecturePanelController, ARCHITECTURE_STDOUT_LIMIT } from "./architecturePanel"; + +function callflowPayload(): string { + return JSON.stringify({ + schema: "compass.viewer.callflow/1", + title: "Fixture — Architecture Flow", + sections: [ + { + id: "overview", name: "Architecture Overview", communities: [], + nodeCount: 0, internalCallCount: 0, nodes: [], edges: [] + }, + { + id: "api", name: "API", communities: ["0"], + nodeCount: 1, internalCallCount: 0, + nodes: [{ + id: "handler", label: "handler", kind: "function", + sourceFile: "src/api.ts", scope: "production" + }], + edges: [] + }, + { + id: "storage", name: "Storage", communities: ["1"], + nodeCount: 1, internalCallCount: 0, + nodes: [{ + id: "store", label: "store", kind: "function", + sourceFile: "src/store.ts", scope: "production" + }], + edges: [] + } + ], + overviewLinks: [{ sourceSection: "api", targetSection: "storage", calls: 1 }], + crossSectionCalls: [{ + source: "handler", target: "store", sourceSection: "api", + targetSection: "storage", relation: "calls", confidence: "extracted" + }], + coverage: { internal: 0, crossSection: 1, unassigned: 0 }, + reportHighlights: ["x".repeat(9 * 1024 * 1024)], + statistics: { + nodes: 2, edges: 1, communities: 2, hyperedges: 0, + extracted: 1, inferred: 0, ambiguous: 0 + }, + provenance: { projectName: "Fixture", builtAtCommit: null, generatedAt: null } + }); +} + +describe("ArchitecturePanelController", () => { + it("captures large exports in the host and posts only a bounded overview", async () => { + const run = vi.fn(async () => ({ code: 0, stdout: callflowPayload(), stderr: "" })); + const postMessage = vi.fn(async (_message: unknown) => true); + const panel = { + webview: { postMessage }, + onDidDispose: vi.fn() + }; + const output = { appendLine: vi.fn(), show: vi.fn() }; + const session = { + id: "/repo", + root: "/repo", + graphPath: "/repo/compass-out/graph.json", + processes: { run } + }; + const controller = new ArchitecturePanelController( + {} as never, + session as never, + panel as never, + output as never + ); + + await controller.handleMessage({ type: "ready" }); + + expect(run).toHaveBeenCalledWith( + "/repo", + ["export", "callflow-json", "--graph", "/repo/compass-out/graph.json"], + expect.any(AbortSignal), + { stdoutBytes: ARCHITECTURE_STDOUT_LIMIT } + ); + const overview = postMessage.mock.calls + .map(([message]) => message as { type?: string; model?: Record }) + .find((message) => message.type === "architectureOverview"); + expect(overview).toBeDefined(); + if (!overview?.model) throw new Error("Expected architecture overview"); + expect(overview.model.statistics).toMatchObject({ totalNodes: 2, totalCalls: 1 }); + expect(overview.model).not.toHaveProperty("crossSectionCalls"); + expect(JSON.stringify(overview).length).toBeLessThan(100_000); + }); +}); diff --git a/editors/vscode/src/views/architecturePanel.ts b/editors/vscode/src/views/architecturePanel.ts index a60420e3..f82a772f 100644 --- a/editors/vscode/src/views/architecturePanel.ts +++ b/editors/vscode/src/views/architecturePanel.ts @@ -1,9 +1,15 @@ import { randomUUID } from "node:crypto"; +import { Buffer } from "node:buffer"; import * as vscode from "vscode"; import { CallflowViewModelSchema } from "@compass/viewer/contracts/callflow"; +import type { ArchitectureEvidence, ArchitectureScope } from "@compass/viewer/contracts/architecture"; import type { RepositorySession } from "../workspace/repositorySession"; +import { ArchitectureToHostMessageSchema } from "../transport/architectureMessages"; +import { ArchitectureIndex } from "./architectureIndex"; import { openGraphSource } from "./sourceNavigation"; +export const ARCHITECTURE_STDOUT_LIMIT = 128 * 1024 * 1024; + export async function openArchitecturePanel( context: vscode.ExtensionContext, session: RepositorySession, @@ -19,41 +25,197 @@ export async function openArchitecturePanel( localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, "dist")] } ); - const controller = new AbortController(); - let generation = 0; - panel.onDidDispose(() => controller.abort()); - panel.webview.html = html(context, panel.webview); - panel.webview.onDidReceiveMessage(async (message) => { - if (message?.type === "ready" || message?.type === "retry") { - const requestGeneration = ++generation; - try { - const model = await session.processes.runJson( - session.root, - ["export", "callflow-json", "--graph", session.graphPath], - CallflowViewModelSchema, - controller.signal - ); - if (requestGeneration !== generation || controller.signal.aborted) return; - await panel.webview.postMessage({ - type: "hydrate", - repositoryId: session.id, + const controller = new ArchitecturePanelController(context, session, panel, output); + controller.initialize(); +} + +export class ArchitecturePanelController { + private generation = 0; + private disposed = false; + private activeController: AbortController | undefined; + private index: ArchitectureIndex | undefined; + private scope: ArchitectureScope = "production"; + private evidence: ArchitectureEvidence = "all"; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly session: RepositorySession, + private readonly panel: vscode.WebviewPanel, + private readonly output: vscode.OutputChannel + ) {} + + initialize(): void { + this.panel.webview.html = html(this.context, this.panel.webview); + this.panel.onDidDispose(() => this.dispose()); + this.panel.webview.onDidReceiveMessage((message) => { + void this.handleMessage(message); + }); + } + + async handleMessage(untrusted: unknown): Promise { + const parsed = ArchitectureToHostMessageSchema.safeParse(untrusted); + if (!parsed.success || this.disposed) return; + const message = parsed.data; + if (message.type === "ready" || message.type === "retry") { + await this.hydrate(); + return; + } + if (message.type === "showOutput") { + this.output.show(true); + return; + } + if (message.repositoryId !== this.session.id) return; + if (message.type === "openSource") { + await openGraphSource(this.session, message.repositoryId, { file: message.file }); + return; + } + if (message.type === "setArchitectureFilters") { + this.scope = message.scope; + this.evidence = message.evidence; + await this.postOverview(message.requestId); + return; + } + if (!this.index || message.generation !== this.generation) return; + try { + if (message.type === "requestSection") { + const model = this.index.sectionPage({ + sectionId: message.sectionId, + kind: message.kind, + page: message.page, + pageSize: message.pageSize, + query: message.query, + scope: message.scope, + evidence: message.evidence + }); + await this.panel.webview.postMessage({ + type: "architectureSectionPage", + requestId: message.requestId, + repositoryId: this.session.id, + generation: this.generation, model }); - } catch (error) { - if (requestGeneration !== generation || controller.signal.aborted) return; - const detail = error instanceof Error ? error.message : String(error); - output.appendLine(`[error] Architecture export failed for ${session.root}: ${detail}`); - await panel.webview.postMessage({ - type: "error", - message: detail + } else if (message.type === "requestRoute") { + const model = this.index.routePage({ + routeId: message.routeId, + page: message.page, + pageSize: message.pageSize, + query: message.query, + scope: message.scope, + evidence: message.evidence + }); + await this.panel.webview.postMessage({ + type: "architectureRoutePage", + requestId: message.requestId, + repositoryId: this.session.id, + generation: this.generation, + model + }); + } else if (message.type === "searchArchitecture") { + const model = this.index.search({ + query: message.query, + page: message.page, + pageSize: message.pageSize, + scope: message.scope, + evidence: message.evidence + }); + await this.panel.webview.postMessage({ + type: "architectureSearchResults", + requestId: message.requestId, + repositoryId: this.session.id, + generation: this.generation, + model }); } - } else if (message?.type === "showOutput") { - output.show(true); - } else if (message?.type === "openSource" && typeof message.file === "string") { - await openGraphSource(session, message.repositoryId, { file: message.file }); + } catch (error) { + await this.postError(error); + } + } + + dispose(): void { + this.disposed = true; + this.generation += 1; + this.activeController?.abort(); + this.activeController = undefined; + this.index = undefined; + } + + private async hydrate(): Promise { + this.activeController?.abort(); + const controller = new AbortController(); + this.activeController = controller; + const generation = ++this.generation; + this.index = undefined; + this.scope = "production"; + this.evidence = "all"; + const started = Date.now(); + try { + await this.postLoading("exporting", "Deriving complete architecture evidence"); + const result = await this.session.processes.run( + this.session.root, + ["export", "callflow-json", "--graph", this.session.graphPath], + controller.signal, + { stdoutBytes: ARCHITECTURE_STDOUT_LIMIT } + ); + if (!this.isCurrent(generation, controller)) return; + if (result.code !== 0) { + throw new Error(result.stderr || `Compass exited with ${result.code}`); + } + const payloadBytes = Buffer.byteLength(result.stdout, "utf8"); + await this.postLoading("validating", `Validating ${formatBytes(payloadBytes)} export`); + const model = CallflowViewModelSchema.parse(JSON.parse(result.stdout)); + if (!this.isCurrent(generation, controller)) return; + await this.postLoading( + "indexing", + `Indexing ${model.statistics.edges.toLocaleString()} calls locally` + ); + this.index = new ArchitectureIndex(model); + await this.postLoading("mapping", "Laying out subsystem routes"); + await this.postOverview(randomUUID()); + this.output.appendLine( + `[architecture] Loaded ${sessionLabel(this.session.root)}: ` + + `${model.statistics.nodes} symbols, ${model.statistics.edges} calls, ` + + `${formatBytes(payloadBytes)} in ${Date.now() - started} ms` + ); + } catch (error) { + if (!this.isCurrent(generation, controller)) return; + this.output.appendLine( + `[error] Architecture export failed for ${this.session.root}: ${errorText(error)}` + ); + await this.postError(error); } - }); + } + + private async postOverview(requestId: string): Promise { + if (!this.index || this.disposed) return; + await this.panel.webview.postMessage({ + type: "architectureOverview", + requestId, + repositoryId: this.session.id, + generation: this.generation, + model: this.index.overview(this.scope, this.evidence) + }); + } + + private async postLoading( + phase: "exporting" | "validating" | "indexing" | "mapping", + message: string + ): Promise { + await this.panel.webview.postMessage({ type: "architectureLoading", phase, message }); + } + + private async postError(error: unknown): Promise { + const detail = errorText(error); + const message = detail.includes("128 MiB") + ? `${detail}. The graph is valid, but this architecture document is larger than the local safety ceiling.` + : detail; + await this.panel.webview.postMessage({ type: "error", message, recoverable: true }); + } + + private isCurrent(generation: number, controller: AbortController): boolean { + return !this.disposed + && !controller.signal.aborted + && generation === this.generation; + } } function html(context: vscode.ExtensionContext, webview: vscode.Webview): string { @@ -71,3 +233,16 @@ function html(context: vscode.ExtensionContext, webview: vscode.Webview): string
`; } + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function formatBytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(bytes >= 10 * 1024 * 1024 ? 1 : 2)} MiB`; +} + +function sessionLabel(root: string): string { + const normalized = root.replaceAll("\\", "/"); + return normalized.split("/").filter(Boolean).pop() ?? root; +} diff --git a/editors/vscode/src/webviews/architecture.tsx b/editors/vscode/src/webviews/architecture.tsx index 3685e9aa..07a4c627 100644 --- a/editors/vscode/src/webviews/architecture.tsx +++ b/editors/vscode/src/webviews/architecture.tsx @@ -1,5 +1,16 @@ +import { useEffect, useMemo, useState } from "react"; import { createRoot } from "react-dom/client"; -import { ArchitectureFlow, CallflowViewModelSchema } from "@compass/viewer"; +import { + ArchitectureFlow, + type ArchitectureEvidence, + type ArchitectureHost, + type ArchitectureOverview, + type ArchitectureRoutePage, + type ArchitectureScope, + type ArchitectureSearchPage, + type ArchitectureSectionPage +} from "@compass/viewer"; +import { HostToArchitectureMessageSchema } from "../transport/architectureMessages"; import { GraphLoadingState, type GraphLoadingCopy } from "./GraphLoadingState"; declare function acquireVsCodeApi(): { postMessage(message: unknown): void }; @@ -11,59 +22,156 @@ const root = createRoot(element); const ARCHITECTURE_LOADING_COPY: GraphLoadingCopy = { eyebrow: "Compass architecture", title: "Deriving architecture flow", - steps: ["Reading graph", "Deriving subsystem flows", "Preparing symbol index"] + steps: ["Exporting evidence", "Indexing complete call flow", "Laying out subsystem routes"] }; -function renderLoading(): void { - root.render( - vscode.postMessage({ type: "retry" })} - onShowOutput={() => vscode.postMessage({ type: "showOutput" })} - /> - ); -} +type ArchitectureState = { + overview?: ArchitectureOverview | undefined; + sectionPage?: ArchitectureSectionPage | undefined; + routePage?: ArchitectureRoutePage | undefined; + searchPage?: ArchitectureSearchPage | undefined; + repositoryId: string; + generation: number; + loadingMessage?: string | undefined; + error?: string | undefined; +}; -function renderError(message: string): void { - root.render( - { - renderLoading(); - vscode.postMessage({ type: "retry" }); - }} - onShowOutput={() => vscode.postMessage({ type: "showOutput" })} - /> - ); -} +function ArchitectureApp() { + const [state, setState] = useState({ + repositoryId: "", + generation: 0, + loadingMessage: "Reading graph" + }); -window.addEventListener("message", (event) => { - if (event.data?.type === "error") { - renderError( - typeof event.data.message === "string" - ? event.data.message - : "Compass could not derive the architecture flow." + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const parsed = HostToArchitectureMessageSchema.safeParse(event.data); + if (!parsed.success) return; + const message = parsed.data; + if (message.type === "architectureLoading") { + setState((current) => ({ ...current, loadingMessage: message.message, error: undefined })); + } else if (message.type === "error") { + setState((current) => ({ ...current, error: message.message, loadingMessage: undefined })); + } else { + setState((current) => { + if ( + current.repositoryId + && ( + current.repositoryId !== message.repositoryId + || message.generation < current.generation + ) + ) return current; + const identity = { + repositoryId: message.repositoryId, + generation: message.generation, + error: undefined, + loadingMessage: undefined + }; + if (message.type === "architectureOverview") { + return { + ...current, + ...identity, + overview: message.model, + sectionPage: undefined, + routePage: undefined + }; + } + if (message.type === "architectureSectionPage") { + return { ...current, ...identity, sectionPage: message.model }; + } + if (message.type === "architectureRoutePage") { + return { ...current, ...identity, routePage: message.model }; + } + return { ...current, ...identity, searchPage: message.model }; + }); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + const host = useMemo(() => { + const dataRequest = ( + type: "requestSection" | "requestRoute" | "searchArchitecture", + payload: Record + ) => { + vscode.postMessage({ + type, + requestId: crypto.randomUUID(), + repositoryId: state.repositoryId, + generation: state.generation, + scope: state.overview?.scope ?? "production", + evidence: state.overview?.evidence ?? "all", + pageSize: 100, + ...payload + }); + }; + return { + setFilters(scope: ArchitectureScope, evidence: ArchitectureEvidence) { + vscode.postMessage({ + type: "setArchitectureFilters", + requestId: crypto.randomUUID(), + repositoryId: state.repositoryId, + scope, + evidence + }); + }, + requestSection(sectionId, kind, page, query) { + dataRequest("requestSection", { sectionId, kind, page, query }); + }, + requestRoute(routeId, page, query) { + dataRequest("requestRoute", { routeId, page, query }); + }, + search(query, page) { + dataRequest("searchArchitecture", { query, page }); + }, + openSource(file) { + vscode.postMessage({ + type: "openSource", + requestId: crypto.randomUUID(), + repositoryId: state.repositoryId, + file + }); + } + }; + }, [state.generation, state.overview?.evidence, state.overview?.scope, state.repositoryId]); + + if (state.error) { + return ( + vscode.postMessage({ type: "retry" })} + onShowOutput={() => vscode.postMessage({ type: "showOutput" })} + /> + ); + } + if (!state.overview) { + return ( + vscode.postMessage({ type: "retry" })} + onShowOutput={() => vscode.postMessage({ type: "showOutput" })} + /> ); - return; } - if (event.data?.type !== "hydrate") return; - const parsed = CallflowViewModelSchema.safeParse(event.data.model); - if (!parsed.success || typeof event.data.repositoryId !== "string") return; - const repositoryId = event.data.repositoryId; - root.render( + return ( ); -}); -renderLoading(); +} + +root.render(); vscode.postMessage({ type: "ready" }); diff --git a/package-lock.json b/package-lock.json index 6ef52fe8..9c67c4bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ }, "editors/vscode": { "name": "crabbuild-compass-vscode", - "version": "0.1.8", + "version": "0.1.9", "license": "Apache-2.0", "dependencies": { "@compass/viewer": "0.1.0", diff --git a/packages/compass-viewer/package.json b/packages/compass-viewer/package.json index 95b163eb..4286dbfa 100644 --- a/packages/compass-viewer/package.json +++ b/packages/compass-viewer/package.json @@ -7,6 +7,7 @@ ".": "./src/index.ts", "./contracts/callGraph": "./src/contracts/callGraph.ts", "./contracts/callflow": "./src/contracts/callflow.ts", + "./contracts/architecture": "./src/contracts/architecture.ts", "./contracts/codeQuery": "./src/contracts/codeQuery.ts", "./contracts/graph": "./src/contracts/graph.ts", "./contracts/history": "./src/contracts/history.ts", diff --git a/packages/compass-viewer/src/architecture/ArchitectureFlow.test.tsx b/packages/compass-viewer/src/architecture/ArchitectureFlow.test.tsx new file mode 100644 index 00000000..495393e7 --- /dev/null +++ b/packages/compass-viewer/src/architecture/ArchitectureFlow.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { ArchitectureHost } from "./ArchitectureFlow"; +import { ArchitectureFlow } from "./ArchitectureFlow"; +import type { ArchitectureOverview, ArchitectureSectionPage } from "../contracts/architecture"; + +const overview: ArchitectureOverview = { + title: "Fixture", + scope: "production", + evidence: "all", + sections: [{ + id: "api", name: "API", nodeCount: 2, totalNodeCount: 3, + internalCallCount: 1, incomingCalls: 0, outgoingCalls: 1, + scopes: { production: 2, test: 1, generated: 0, vendor: 0, unknown: 0 } + }], + routes: [], + statistics: { + visibleNodes: 2, totalNodes: 3, visibleCalls: 1, totalCalls: 2, + communities: 1, extracted: 2, inferred: 0, ambiguous: 0 + }, + coverage: { internal: 1, crossSection: 0, unassigned: 1 }, + provenance: { projectName: "Fixture", builtAtCommit: null, generatedAt: null } +}; + +const page: ArchitectureSectionPage = { + kind: "symbols", + sectionId: "api", + page: 1, + pageSize: 100, + pageCount: 1, + total: 2, + start: 1, + end: 2, + items: [ + { + id: "a", label: "handler", kind: "function", + sourceFile: "src/api.ts", scope: "production", sectionId: "api" + }, + { + id: "b", label: "helper", kind: "function", + sourceFile: "src/helper.ts", scope: "production", sectionId: "api" + } + ] +}; + +function host(): ArchitectureHost { + return { + setFilters: vi.fn(), + requestSection: vi.fn(), + requestRoute: vi.fn(), + search: vi.fn(), + openSource: vi.fn() + }; +} + +describe("ArchitectureFlow", () => { + it("discloses production scope, full totals, and unassigned coverage", async () => { + const adapter = host(); + render( + + ); + + expect(screen.getByText(/Production · 2 of 3 symbols/i)).toBeInTheDocument(); + expect(screen.getByText(/1 unassigned calls disclosed/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All code" })).toBeInTheDocument(); + expect(screen.getByText("handler")).toBeInTheDocument(); + await waitFor(() => { + expect(adapter.requestSection).toHaveBeenCalledWith("api", "symbols", 1, ""); + }); + }); + + it("lets the map reclaim the detail panel space", () => { + render( + + ); + + expect(screen.getByLabelText("Architecture selection details")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Hide architecture details" })); + expect(screen.queryByLabelText("Architecture selection details")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Show architecture details" })) + .toBeInTheDocument(); + }); + +}); diff --git a/packages/compass-viewer/src/architecture/ArchitectureFlow.tsx b/packages/compass-viewer/src/architecture/ArchitectureFlow.tsx index ce37d4a2..ecf42226 100644 --- a/packages/compass-viewer/src/architecture/ArchitectureFlow.tsx +++ b/packages/compass-viewer/src/architecture/ArchitectureFlow.tsx @@ -1,380 +1,480 @@ -import { useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { - ArrowRightIcon, + AlertTriangleIcon, + ArrowDownLeftIcon, + ArrowUpRightIcon, BoxIcon, FileCodeIcon, - NetworkIcon, - SearchIcon + PanelRightCloseIcon, + PanelRightOpenIcon, + SearchIcon, + SlidersHorizontalIcon } from "lucide-react"; -import { Badge } from "../components/ui/badge"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; -import { CollectionToolbar } from "../components/workbench/CollectionToolbar"; +import type { + ArchitectureEvidence, + ArchitectureOverview, + ArchitectureRoutePage, + ArchitectureScope, + ArchitectureSearchPage, + ArchitectureSectionPage +} from "../contracts/architecture"; import { Pagination } from "../components/workbench/Pagination"; -import { WorkspaceState } from "../components/workbench/WorkspaceState"; -import type { CallflowViewModel } from "../contracts/callflow"; -import { paginate } from "../lib/collectionView"; -import { - filterSectionCalls, - filterSectionSymbols, - nodeNameMap, - searchArchitecture, - sortCalls, - type ArchitectureResult, - type CallSort -} from "./state"; - -const SYMBOL_PAGE_SIZE = 24; -const CALL_PAGE_SIZE = 25; +import { ArchitectureMap, type ArchitectureSelection } from "./ArchitectureMap"; export type ArchitectureHost = { + setFilters(scope: ArchitectureScope, evidence: ArchitectureEvidence): void; + requestSection( + sectionId: string, + kind: "symbols" | "calls", + page: number, + query: string + ): void; + requestRoute(routeId: string, page: number, query: string): void; + search(query: string, page: number): void; openSource(file: string): void; }; export function ArchitectureFlow({ - model, + overview, + sectionPage, + routePage, + searchPage, + loadingMessage, host }: { - model: CallflowViewModel; + overview: ArchitectureOverview; + sectionPage: ArchitectureSectionPage | undefined; + routePage: ArchitectureRoutePage | undefined; + searchPage: ArchitectureSearchPage | undefined; + loadingMessage: string | undefined; host: ArchitectureHost; }) { - const sections = useMemo( - () => model.sections.filter((section) => section.id !== "overview"), - [model.sections] - ); - const first = sections[0]?.id ?? "overview"; - const [sectionId, setSectionId] = useState(first); - const [activeTab, setActiveTab] = useState<"symbols" | "calls">("symbols"); - const [globalQuery, setGlobalQuery] = useState(""); - const [symbolQuery, setSymbolQuery] = useState(""); - const [callQuery, setCallQuery] = useState(""); - const [symbolPage, setSymbolPage] = useState(1); - const [callPage, setCallPage] = useState(1); - const [callSort, setCallSort] = useState({ - column: "caller", - direction: "ascending" - }); - const [showAllFlows, setShowAllFlows] = useState(false); - const section = sections.find((candidate) => candidate.id === sectionId); - const names = useMemo(() => nodeNameMap(model), [model]); - const globalResults = useMemo( - () => searchArchitecture(model, globalQuery), - [globalQuery, model] - ); - const globalResultCount = globalResults.reduce( - (total, group) => total + group.results.length, - 0 - ); - const visibleOverviewLinks = showAllFlows - ? model.overviewLinks - : model.overviewLinks.slice(0, 24); - const symbols = useMemo( - () => section ? filterSectionSymbols(section, symbolQuery) : [], - [section, symbolQuery] + const firstSection = overview.sections.find((section) => section.nodeCount > 0); + const [selection, setSelection] = useState( + firstSection ? { kind: "section", id: firstSection.id } : undefined ); - const calls = useMemo( - () => section - ? sortCalls(filterSectionCalls(section, names, callQuery), names, callSort) - : [], - [callQuery, callSort, names, section] - ); - const symbolResults = paginate(symbols, symbolPage, SYMBOL_PAGE_SIZE); - const callResults = paginate(calls, callPage, CALL_PAGE_SIZE); + const [sectionTab, setSectionTab] = useState<"symbols" | "calls">("symbols"); + const [detailQuery, setDetailQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [inspectorOpen, setInspectorOpen] = useState(true); - const selectSection = (id: string) => { - setSectionId(id); - setSymbolPage(1); - setCallPage(1); - }; - const selectGlobalResult = (result: ArchitectureResult) => { - selectSection(result.sectionId); - setActiveTab(result.tab); - if (result.tab === "symbols") { - setSymbolQuery(result.query); - } else { - setCallQuery(result.query); + useEffect(() => { + if (!selection && firstSection) { + setSelection({ kind: "section", id: firstSection.id }); + return; + } + if (selection?.kind === "section") { + host.requestSection(selection.id, sectionTab, 1, detailQuery); + } else if (selection?.kind === "route") { + host.requestRoute(selection.id, 1, detailQuery); + } + }, [detailQuery, firstSection, host, sectionTab, selection]); + + const selectedSection = selection?.kind === "section" + ? overview.sections.find((section) => section.id === selection.id) + : undefined; + const selectedRoute = selection?.kind === "route" + ? overview.routes.find((route) => route.id === selection.id) + : undefined; + const displayedSectionPage = sectionPage && sectionPage.sectionId === selectedSection?.id + && sectionPage.kind === sectionTab ? sectionPage : undefined; + const displayedRoutePage = routePage?.routeId === selectedRoute?.id ? routePage : undefined; + + const select = (next: Exclude) => { + setSelection(next); + setDetailQuery(""); + if (next.kind === "section") { + setSectionTab("symbols"); } - setGlobalQuery(""); }; - const toggleSort = (column: CallSort["column"]) => { - setCallSort((current) => ({ - column, - direction: current.column === column && current.direction === "ascending" - ? "descending" - : "ascending" - })); - setCallPage(1); + + const updateSearch = (value: string) => { + setSearchQuery(value); + host.search(value, 1); }; return ( -
-