diff --git a/CHANGELOG.md b/CHANGELOG.md index 4350fc0..df9aae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable public changes to Code System Graph are documented in this file. Code System Graph follows Semantic Versioning. +## [1.0.2] - 2026-08-05 + +### Performance and reliability + +- Made `csgraph sync` inspect structured CodeGraph status before invoking the provider, report + changed and unchanged indexes separately, and reuse the current graph snapshot when neither + native inputs nor CodeGraph indexes changed. +- Grouped CodeGraph corroboration inputs in one pass, bounded and deduplicated queries + deterministically, and limited focused scans to the selected repository. +- Released the previous graph before snapshot staging and verified incremental behavior with a + 100-repository synthetic release workload and a representative large-repository workload. +- Turned ambiguous HTTP providers into deterministic scan degradations so unrelated links still + resolve and exact manual relationships can disambiguate the intended provider. +- Replaced raw worker parser failures with bounded, secret-safe diagnostics that retain actionable + workspace, repository, configuration, and contract context. + +### Documentation + +- Documented workspace-scoped Cursor MCP configuration, including restart and enablement checks, + without requiring a separate CodeGraph installation. +- Added troubleshooting guidance for Cursor setup and per-value extraction budget failures, + including a concrete `maxStringBytesPerValue` example. + ## [1.0.1] - 2026-08-04 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index d1568a7..800fcc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -398,7 +398,7 @@ dependencies = [ [[package]] name = "code-system-graph" -version = "1.0.1" +version = "1.0.2" dependencies = [ "anyhow", "atomic-write-file", @@ -431,7 +431,7 @@ dependencies = [ [[package]] name = "code-system-graph-core" -version = "1.0.1" +version = "1.0.2" dependencies = [ "async-trait", "atomic-write-file", @@ -478,7 +478,7 @@ dependencies = [ [[package]] name = "code-system-graph-hooks" -version = "1.0.1" +version = "1.0.2" dependencies = [ "atomic-write-file", "blake3", @@ -493,7 +493,7 @@ dependencies = [ [[package]] name = "code-system-graph-model" -version = "1.0.1" +version = "1.0.2" dependencies = [ "blake3", "camino", @@ -505,7 +505,7 @@ dependencies = [ [[package]] name = "code-system-graph-store-sqlite" -version = "1.0.1" +version = "1.0.2" dependencies = [ "blake3", "code-system-graph-model", diff --git a/Cargo.toml b/Cargo.toml index cfcaacb..8575736 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,16 +4,16 @@ default-members = ["crates/*"] resolver = "3" [workspace.package] -version = "1.0.1" +version = "1.0.2" edition = "2024" rust-version = "1.97.1" -description = "Local system-level code intelligence across repositories." +description = "Local cross-repository code intelligence and dependency graph for impact analysis and AI coding agents." license = "Apache-2.0" repository = "https://github.com/dertin/code-system-graph" homepage = "https://github.com/dertin/code-system-graph" readme = "README.md" -keywords = ["code-intelligence", "static-analysis", "impact-analysis", "mcp", "monorepo"] -categories = ["development-tools"] +keywords = ["code-intelligence", "static-analysis", "impact-analysis", "mcp", "dependency-graph"] +categories = ["development-tools", "command-line-utilities"] [workspace.lints.rust] future_incompatible = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index d3bff12..2bd0f07 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,14 @@ # Code System Graph -**System-level code intelligence across repositories** +**Open-source cross-repository code intelligence, dependency mapping, and impact analysis for AI +coding agents** -Understand what connects your repositories and what a change could break before you ship it. +Map APIs, events, schemas, packages, databases, and ownership across repositories before a change +breaks another service. [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -![Source version](https://img.shields.io/badge/source-v1.0.1-orange.svg) +![Source version](https://img.shields.io/badge/source-v1.0.2-orange.svg) [![crates.io](https://img.shields.io/crates/v/code-system-graph.svg)](https://crates.io/crates/code-system-graph) ![Platforms](https://img.shields.io/badge/validated-Linux%20%7C%20macOS%20%7C%20Windows-1793d1.svg) ![Privacy](https://img.shields.io/badge/privacy-local%20%7C%20no%20telemetry-2ea44f.svg) @@ -22,10 +24,11 @@ Claude Code | Codex | Gemini CLI | Antigravity | Cursor -Code System Graph builds a local, evidence-backed map of the boundaries between repositories: -APIs, events, schemas, packages, databases, deployments, tests, owners, and documentation. Your -coding agent can then answer system-level questions that are difficult to solve by searching one -repository at a time. +Code System Graph is a local-first static analysis and system architecture tool that builds an +evidence-backed dependency graph across repositories. It connects APIs, events, schemas, packages, +databases, deployments, tests, owners, and documentation, then exposes that context to AI coding +agents through the Model Context Protocol (MCP). Agents can answer system-level questions that are +difficult to solve by searching one repository at a time. ## What problem does it solve? @@ -279,6 +282,11 @@ configuration format; optional routing hooks are a separate step. Follow [Connect an agent](docs/AGENT_SETUP.md) for exact commands, configuration files, verification, and limitations. +For Cursor, keep the entry in `/.cursor/mcp.json`; an absolute database path does not +make a global MCP entry workspace-scoped. Reload Cursor and enable the server after changing the +file. `csgraph mcp --codegraph` starts CodeGraph itself, so a separate `codegraph install` entry is +unnecessary. + You can now ask the agent: ```text @@ -383,7 +391,7 @@ non-loopback HTTP access. - [Use CodeGraph with every repository in a workspace](docs/CODEGRAPH_INTEGRATION.md) - [Connect Claude Code, Codex, Gemini, Antigravity, or Cursor](docs/AGENT_SETUP.md) - [CLI reference](docs/CLI.md) -- [Troubleshooting with `doctor`](docs/GETTING_STARTED.md#troubleshooting) +- [Troubleshooting](docs/TROUBLESHOOTING.md) ### Analysis guides diff --git a/crates/code-system-graph-cli/Cargo.toml b/crates/code-system-graph-cli/Cargo.toml index aeb3260..a92ae87 100644 --- a/crates/code-system-graph-cli/Cargo.toml +++ b/crates/code-system-graph-cli/Cargo.toml @@ -30,10 +30,10 @@ atomic-write-file = "0.3.0" axum = "0.8.9" clap = { version = "4.6.4", features = ["derive"] } clap_complete = "4.6.8" -code-system-graph-core = { version = "1.0.1", path = "../code-system-graph-core" } -code-system-graph-hooks = { version = "1.0.1", path = "../code-system-graph-hooks" } -code-system-graph-model = { version = "1.0.1", path = "../code-system-graph-model" } -code-system-graph-store-sqlite = { version = "1.0.1", path = "../code-system-graph-store-sqlite" } +code-system-graph-core = { version = "1.0.2", path = "../code-system-graph-core" } +code-system-graph-hooks = { version = "1.0.2", path = "../code-system-graph-hooks" } +code-system-graph-model = { version = "1.0.2", path = "../code-system-graph-model" } +code-system-graph-store-sqlite = { version = "1.0.2", path = "../code-system-graph-store-sqlite" } rmcp = { version = "3.1.0", features = ["transport-io"] } notify = "8.2.0" rusqlite = { version = "0.40.1", features = ["bundled"] } diff --git a/crates/code-system-graph-cli/src/lib.rs b/crates/code-system-graph-cli/src/lib.rs index 1d08adb..23461b8 100644 --- a/crates/code-system-graph-cli/src/lib.rs +++ b/crates/code-system-graph-cli/src/lib.rs @@ -16,7 +16,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use atomic_write_file::AtomicWriteFile; use code_system_graph_core::{ - AffectedTestsRequest, AnalyzerVersions, ArtifactKey, BatchAction, BatchPlanError, BitbucketProvider, ChangeAnalysisError, ChangeAnalysisOptions, ChangeError, ChangeImpactReport, ChangeProvider, ChangeRequest, ChangeScope, ChangeSet, CodeGraphConfig, CodeGraphProvider, CommunityError, ConfigDoctorInput, ConfigError, ConfigExtractionError, ContractReport, ContractRequest, CorroborationReport, DataDocument, DataExtractionError, DeclaredImplementation, DeclaredTestCase, DoctorReport, DoctorRequest, DocumentationDocument, DocumentationExtractionError, EXTRACTION_CONTRACT_VERSION, EffectiveRepositoryConfig, EventDocument, EventExtractionError, EventGraphFacts, ExecutionPolicy, ExitCode, ExportReport, ExportRequest, ExtractionBudgets, ExtractionGraphFacts, ExtractionLimitExceeded, ExtractionTracker, ExtractorBatch, ExtractorBatchPlan, FederatedGraph, FreshnessDoctorInput, GeneratedClientError, GeneratedClientMetadata, GitCliChangeProvider, GitHubProvider, GraphqlDocument, GraphqlExtractionError, GraphqlGraphFacts, HttpBoundary, HttpExtractionError, ImpactContext, ImpactError, ImpactReport, ImpactRequest, ImpactTarget, IncrementalPlan, InfrastructureDocument, InfrastructureExtractionError, IntegrityDoctorInput, InterfaceError, LinkError, LocalCodeIntelligenceProvider, LocalContextRequest, LocalContextResult, LocalEnrichmentInput, LocalEnrichmentStatus, LocalImpactItem, LocalImpactRequest, ManifestEdit, ManifestEditError, ManifestError, ManualLinkConfig, ManualLinkError, PackageGraphFacts, PackageManifest, PackageManifestError, PrAuthToken, ProtobufDocument, ProtobufExtractionError, ProtobufGraphFacts, ProviderBudget, ProviderCapability, ProviderDoctorInput, ProviderDoctorStatus, ProviderError, ProviderRequest, ProviderStatus, PullRequestCoordinates, PullRequestError, PullRequestInspectRequest, PullRequestInspection, PullRequestListPage, PullRequestListRequest, PullRequestListState, PullRequestProvider, PullRequestProviderConfig, PullRequestProviderKind, QueryError, RecommendedCommand, RegisteredWorkspace, RegistryError, ReqwestPrHttpTransport, SafeConfigDocument, SchemaDoctorInput, SearchFilters, SearchReport, SearchRequest, SourceEpistemicStatus, SourceGraphFacts, SourceLanguage, SourceObservation, SourceRole, SourceSyntaxError, SourceSyntaxLanguage, SourceWarning, SymbolAnchor, SymbolCorroboration, TraceError, TraversalReport, TraversalRequest, WorkspaceManifest, affected_link_keys, analyze_changes, analyze_communities_with_progress, analyze_impact, apply_openapi_override, classify_interface_error, commit_manifest_edit, compare_community_snapshots, corroborate_repository, declared_implementation, declared_test_case, doctor, documents_to_graph, encode_native_path, event_documents_to_graph, export_graph, extract_asyncapi, extract_codeowners, extract_data_artifact, extract_docker_compose, extract_generated_client_metadata, extract_graphql_document_with_tracker, extract_graphql_persisted_operations_with_tracker, extract_helm, extract_kubernetes, extract_markdown, extract_openapi_with_tracker, extract_package_manifest_with_tracker, extract_protobuf_with_tracker, extract_safe_config, extract_service_catalog, extract_terraform, graphql_documents_to_graph, inspect_contracts, inspect_source_syntax, link_declared_implementations, link_declared_tests, link_http_boundaries, link_registered_package_owners, load_extractor_batch_with_budgets, merge_affected_link_neighborhoods, package_manifest_to_graph, parse_event_source, parse_go_source_with_tracker, parse_graphql_source_with_tracker, parse_java_source_with_tracker, parse_javascript_source_at_path_with_tracker, parse_literal_sql_source_at_root, parse_manifest, parse_protobuf_generated_source, parse_python_source_with_tracker, parse_rust_source_with_tracker, parse_typescript_source_at_path_with_tracker, plan_extractor_batches, plan_incremental_scan, precheck_focused_source_values, preview_add_manual_link, preview_add_repository, preview_remove_repository, protobuf_documents_to_graph, register_workspace, resolve_manual_links, resolve_repository_config, search, source_observations_to_graph, store_extractor_batch, traverse + AffectedTestsRequest, AnalyzerVersions, ArtifactKey, BatchAction, BatchPlanError, BitbucketProvider, ChangeAnalysisError, ChangeAnalysisOptions, ChangeError, ChangeImpactReport, ChangeProvider, ChangeRequest, ChangeScope, ChangeSet, CodeGraphConfig, CodeGraphProvider, CommunityError, ConfigDoctorInput, ConfigError, ConfigExtractionError, ContractReport, ContractRequest, CorroborationReport, DataDocument, DataExtractionError, DeclaredImplementation, DeclaredTestCase, DoctorReport, DoctorRequest, DocumentationDocument, DocumentationExtractionError, EXTRACTION_CONTRACT_VERSION, EffectiveRepositoryConfig, EventDocument, EventExtractionError, EventGraphFacts, ExecutionPolicy, ExitCode, ExportReport, ExportRequest, ExtractionBudgets, ExtractionGraphFacts, ExtractionLimitExceeded, ExtractionTracker, ExtractorBatch, ExtractorBatchPlan, FederatedGraph, FreshnessDoctorInput, GeneratedClientError, GeneratedClientMetadata, GitCliChangeProvider, GitHubProvider, GraphqlDocument, GraphqlExtractionError, GraphqlGraphFacts, HttpBoundary, HttpExtractionError, ImpactContext, ImpactError, ImpactReport, ImpactRequest, ImpactTarget, IncrementalPlan, InfrastructureDocument, InfrastructureExtractionError, IntegrityDoctorInput, InterfaceError, LinkError, LocalCodeIntelligenceProvider, LocalContextRequest, LocalContextResult, LocalEnrichmentInput, LocalEnrichmentStatus, LocalImpactItem, LocalImpactRequest, ManifestEdit, ManifestEditError, ManifestError, ManualLinkConfig, ManualLinkError, PackageGraphFacts, PackageManifest, PackageManifestError, PrAuthToken, ProtobufDocument, ProtobufExtractionError, ProtobufGraphFacts, ProviderBudget, ProviderCapability, ProviderDoctorInput, ProviderDoctorStatus, ProviderError, ProviderRequest, ProviderStatus, PullRequestCoordinates, PullRequestError, PullRequestInspectRequest, PullRequestInspection, PullRequestListPage, PullRequestListRequest, PullRequestListState, PullRequestProvider, PullRequestProviderConfig, PullRequestProviderKind, QueryError, RecommendedCommand, RegisteredWorkspace, RegistryError, ReqwestPrHttpTransport, SafeConfigDocument, SchemaDoctorInput, SearchFilters, SearchReport, SearchRequest, SourceEpistemicStatus, SourceGraphFacts, SourceLanguage, SourceObservation, SourceRole, SourceSyntaxError, SourceSyntaxLanguage, SourceWarning, SymbolAnchor, SymbolCorroboration, TraceError, TraversalReport, TraversalRequest, WorkspaceManifest, affected_link_keys, analyze_changes, analyze_communities_with_progress, analyze_impact, apply_openapi_override, classify_interface_error, commit_manifest_edit, compare_community_snapshots, corroborate_repository, declared_implementation, declared_test_case, doctor, documents_to_graph, encode_native_path, event_documents_to_graph, export_graph, extract_asyncapi, extract_codeowners, extract_data_artifact, extract_docker_compose, extract_generated_client_metadata, extract_graphql_document_with_tracker, extract_graphql_persisted_operations_with_tracker, extract_helm, extract_kubernetes, extract_markdown, extract_openapi_with_tracker, extract_package_manifest_with_tracker, extract_protobuf_with_tracker, extract_safe_config, extract_service_catalog, extract_terraform, graphql_documents_to_graph, inspect_contracts, inspect_source_syntax, link_declared_implementations_with_ambiguities, link_declared_tests_with_ambiguities, link_http_boundaries_with_ambiguities, link_registered_package_owners, load_extractor_batch_with_budgets, merge_affected_link_neighborhoods, package_manifest_to_graph, parse_event_source, parse_go_source_with_tracker, parse_graphql_source_with_tracker, parse_java_source_with_tracker, parse_javascript_source_at_path_with_tracker, parse_literal_sql_source_at_root, parse_manifest, parse_protobuf_generated_source, parse_python_source_with_tracker, parse_rust_source_with_tracker, parse_typescript_source_at_path_with_tracker, plan_extractor_batches, plan_incremental_scan, precheck_focused_source_values, preview_add_manual_link, preview_add_repository, preview_remove_repository, protobuf_documents_to_graph, register_workspace, resolve_manual_links, resolve_repository_config, search, source_observations_to_graph, store_extractor_batch, traverse }; pub use code_system_graph_core::{ ConfigSource, DEFAULT_EXCLUDES, IgnorePolicy, PROTECTED_EXCLUDES @@ -857,6 +857,7 @@ struct GraphAssembly { evidence: Vec, link_decisions: Vec, link_node_keys: BTreeMap, + degradations: Vec, } struct FocusedBatchState { @@ -1133,8 +1134,8 @@ fn generated_state_is_ignored(content: &[u8]) -> bool { /// /// # Errors /// -/// Returns [`ApplicationError`] for unreadable inputs, invalid contracts, ambiguous links, or -/// storage failure. +/// Returns [`ApplicationError`] for unreadable inputs, invalid contracts, invalid manual links, or +/// storage failure. Ambiguous automatic HTTP providers are reported as degradations. pub fn scan_workspace( config_path: &Path, database_path: &Path, @@ -1147,7 +1148,8 @@ pub fn scan_workspace( /// # Errors /// /// Returns [`ApplicationError`] for unknown aliases, invalid overrides, unreadable inputs, -/// invalid contracts, ambiguous links, or storage failure. +/// invalid contracts, invalid manual links, or storage failure. Ambiguous automatic HTTP providers +/// are reported as degradations. pub fn scan_workspace_with_overrides( config_path: &Path, database_path: &Path, @@ -1175,14 +1177,37 @@ pub fn scan_workspace_with_worker_executable( } #[doc(hidden)] +pub(crate) fn scan_workspace_direct( + config_path: &Path, + database_path: &Path, + overrides: &ScanOverrides, +) -> Result { + scan_workspace_direct_with_mode(config_path, database_path, overrides, false) +} + +pub(crate) fn scan_workspace_direct_for_sync( + config_path: &Path, + database_path: &Path, + overrides: &ScanOverrides, + reuse_unchanged_with_codegraph: bool, +) -> Result { + scan_workspace_direct_with_mode( + config_path, + database_path, + overrides, + reuse_unchanged_with_codegraph, + ) +} + #[expect( clippy::too_many_lines, reason = "Atomic scan orchestration keeps lock, resume, publication, and summary sequencing visible" )] -pub(crate) fn scan_workspace_direct( +fn scan_workspace_direct_with_mode( config_path: &Path, database_path: &Path, overrides: &ScanOverrides, + reuse_unchanged_with_codegraph: bool, ) -> Result { let context = load_workspace_context(config_path, overrides)?; worker::report_progress(code_system_graph_core::JobPhase::Configuration, 1); @@ -1390,7 +1415,7 @@ pub(crate) fn scan_workspace_direct( &context.extraction_budgets, ) && previous_communities.is_some() - && !overrides.codegraph + && (!overrides.codegraph || reuse_unchanged_with_codegraph) { work_state .complete_candidate(&context.manifest.name) @@ -1453,6 +1478,7 @@ pub(crate) fn scan_workspace_direct( &context, &focused_batches, &plan, + overrides.repository.as_deref(), overrides.codegraph_binary.clone(), ) } else { @@ -1475,6 +1501,7 @@ pub(crate) fn scan_workspace_direct( edges, evidence, link_decisions, + degradations: graph_degradations, .. } = graph; let community_config = default_community_config(); @@ -1508,6 +1535,7 @@ pub(crate) fn scan_workspace_direct( }, Ok, )?; + drop(previous_graph); let community_delta_count = previous_communities.as_ref().map_or(0, |previous| { compare_community_snapshots(previous, &community_snapshot) .changes @@ -1520,6 +1548,7 @@ pub(crate) fn scan_workspace_direct( let extractor_runs = extractor_runs(&snapshot_id, &fingerprints, &plan); let manual_link_records = persisted_manual_link_records(&snapshot_id, &link_decisions)?; let mut staged_degradations = focused_batches.degradations.clone(); + staged_degradations.extend(graph_degradations); staged_degradations.extend(corroboration.degradations.clone()); staged_degradations.extend(stored_batch_degradations( &focused_batches.stored_batches, @@ -4681,12 +4710,37 @@ fn assemble_graph( &repository_aliases, ); - let mut edges = link_http_boundaries(&boundaries)?; - edges.extend(link_declared_tests(&tests, &boundaries)?); - edges.extend(link_declared_implementations( - &implementations, - &boundaries, - )?); + let mut http_links = link_http_boundaries_with_ambiguities(&boundaries); + let test_links = link_declared_tests_with_ambiguities(&tests, &boundaries); + let implementation_links = + link_declared_implementations_with_ambiguities(&implementations, &boundaries); + http_links.ambiguities.extend(test_links.ambiguities); + http_links + .ambiguities + .extend(implementation_links.ambiguities); + http_links.ambiguities.sort_by(|left, right| { + (&left.method, &left.path, &left.candidates).cmp(&( + &right.method, + &right.path, + &right.candidates, + )) + }); + http_links.ambiguities.dedup(); + let degradations = http_links + .ambiguities + .iter() + .map(|ambiguity| { + format!( + "ambiguous HTTP provider for {} {}; stable candidates: {}", + ambiguity.method, + ambiguity.path, + ambiguity.candidates.join(", ") + ) + }) + .collect::>(); + let mut edges = http_links.edges; + edges.extend(test_links.edges); + edges.extend(implementation_links.edges); edges.extend( source_facts .iter() @@ -4841,6 +4895,7 @@ fn assemble_graph( evidence: evidence.into_values().collect(), link_decisions: Vec::new(), link_node_keys, + degradations, }) } @@ -4963,84 +5018,15 @@ fn relink_affected_graph( Ok(()) } -#[expect( - clippy::too_many_lines, - reason = "Optional provider orchestration keeps budgets, isolation, and degradation accounting together" -)] fn run_codegraph_corroboration( context: &WorkspaceContext, focused: &FocusedBatchState, plan: &IncrementalPlan, + selected_repository: Option<&str>, codegraph_binary: Option, ) -> CorroborationSummary { - let mut jobs = Vec::new(); - let mut setup_degradations = Vec::new(); - for repository in &context.registry.record.repositories { - let Some(project_path) = context.registry.checkout_path(&repository.alias) else { - setup_degradations.push(format!( - "CodeGraph skipped `{}` because its checkout is unavailable", - repository.alias - )); - continue; - }; - let mut anchors = focused - .source_batches - .iter() - .filter(|batch| batch.source.repo_id == repository.id) - .flat_map(|batch| { - let source_path = portable_path(&batch.source.path.display); - batch.outputs.iter().filter_map(move |observation| { - if observation.role != SourceRole::Provider { - return None; - } - Some(SymbolAnchor { - symbol: observation.symbol_name.clone()?, - source_path: source_path.clone(), - start_line: usize::try_from(observation.lines.start).ok()?, - }) - }) - }) - .collect::>(); - anchors.sort_by(|left, right| { - (&left.source_path, left.start_line, &left.symbol).cmp(&( - &right.source_path, - right.start_line, - &right.symbol, - )) - }); - anchors.dedup(); - if anchors.len() > 50 { - anchors.truncate(50); - setup_degradations.push(format!( - "CodeGraph symbol corroboration for `{}` was limited to 50 anchors", - repository.alias - )); - } - let mut changed_files = plan - .changes - .iter() - .filter(|change| repository.id == change.repo_id) - .filter(|change| change.kind != code_system_graph_model::ArtifactChangeKind::Unchanged) - .map(|change| portable_path(&change.path.display)) - .collect::>(); - changed_files.sort(); - changed_files.dedup(); - if changed_files.len() > 1_024 { - changed_files.truncate(1_024); - setup_degradations.push(format!( - "CodeGraph affected-test corroboration for `{}` was limited to 1,024 changed files", - repository.alias - )); - } - if !anchors.is_empty() || !changed_files.is_empty() { - jobs.push(( - repository.id.clone(), - project_path.to_path_buf(), - anchors, - changed_files, - )); - } - } + let (jobs, setup_degradations) = + prepare_codegraph_jobs(context, focused, plan, selected_repository); let worker = std::thread::spawn(move || -> Result, String> { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -5109,6 +5095,98 @@ fn run_codegraph_corroboration( summary } +type CodeGraphJob = (RepoId, PathBuf, Vec, Vec); + +fn prepare_codegraph_jobs( + context: &WorkspaceContext, + focused: &FocusedBatchState, + plan: &IncrementalPlan, + selected_repository: Option<&str>, +) -> (Vec, Vec) { + let mut anchors_by_repository = BTreeMap::>::new(); + for batch in &focused.source_batches { + let source_path = portable_path(&batch.source.path.display); + let anchors = anchors_by_repository + .entry(batch.source.repo_id.clone()) + .or_default(); + anchors.extend(batch.outputs.iter().filter_map(|observation| { + if observation.role != SourceRole::Provider { + return None; + } + Some(SymbolAnchor { + symbol: observation.symbol_name.clone()?, + source_path: source_path.clone(), + start_line: usize::try_from(observation.lines.start).ok()?, + }) + })); + } + let mut changed_files_by_repository = BTreeMap::>::new(); + for change in plan + .changes + .iter() + .filter(|change| change.kind != code_system_graph_model::ArtifactChangeKind::Unchanged) + { + changed_files_by_repository + .entry(change.repo_id.clone()) + .or_default() + .push(portable_path(&change.path.display)); + } + + let mut jobs = Vec::new(); + let mut setup_degradations = Vec::new(); + for repository in &context.registry.record.repositories { + if selected_repository.is_some_and(|selected| selected != repository.alias) { + continue; + } + let Some(project_path) = context.registry.checkout_path(&repository.alias) else { + setup_degradations.push(format!( + "CodeGraph skipped `{}` because its checkout is unavailable", + repository.alias + )); + continue; + }; + let mut anchors = anchors_by_repository + .remove(&repository.id) + .unwrap_or_default(); + anchors.sort_by(|left, right| { + (&left.source_path, left.start_line, &left.symbol).cmp(&( + &right.source_path, + right.start_line, + &right.symbol, + )) + }); + anchors.dedup(); + if anchors.len() > 50 { + anchors.truncate(50); + setup_degradations.push(format!( + "CodeGraph symbol corroboration for `{}` was limited to 50 anchors", + repository.alias + )); + } + let mut changed_files = changed_files_by_repository + .remove(&repository.id) + .unwrap_or_default(); + changed_files.sort(); + changed_files.dedup(); + if changed_files.len() > 1_024 { + changed_files.truncate(1_024); + setup_degradations.push(format!( + "CodeGraph affected-test corroboration for `{}` was limited to 1,024 changed files", + repository.alias + )); + } + if !anchors.is_empty() || !changed_files.is_empty() { + jobs.push(( + repository.id.clone(), + project_path.to_path_buf(), + anchors, + changed_files, + )); + } + } + (jobs, setup_degradations) +} + fn apply_codegraph_corroboration(graph: &mut GraphAssembly, reports: &[RepositoryCorroboration]) { for item in reports { for outcome in &item.report.symbols { diff --git a/crates/code-system-graph-cli/src/sync.rs b/crates/code-system-graph-cli/src/sync.rs index 42a9911..6394cc9 100644 --- a/crates/code-system-graph-cli/src/sync.rs +++ b/crates/code-system-graph-cli/src/sync.rs @@ -5,9 +5,13 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -use code_system_graph_core::{ConfigSource, EffectiveRepositoryConfig, IgnorePolicy}; +use code_system_graph_core::{ + CodeGraphConfig, CodeGraphProvider, ConfigSource, EffectiveRepositoryConfig, IgnorePolicy, ProviderBudget, ProviderRequest, ProviderStatus +}; +use code_system_graph_model::RepoId; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; use super::{ ApplicationError, ScanOverrides, ScanSummary, load_workspace_context, work_database_instance_id @@ -107,6 +111,12 @@ pub struct CodeGraphSyncSummary { pub repository_count: usize, /// Number of local indexes synchronized successfully. pub synchronized_count: usize, + /// Number of local indexes whose structured status required synchronization. + #[serde(default)] + pub changed_count: usize, + /// Number of initialized local indexes that were already current. + #[serde(default)] + pub unchanged_count: usize, /// Number of repositories without an initialized local index. pub skipped_count: usize, /// Number of external synchronization failures. @@ -287,16 +297,22 @@ pub(crate) fn sync_workspace_direct( let targets = workspace_sync_targets(config_path, overrides)?; persist_watch_targets(database_path, &workspace, &targets)?; let binary = codegraph_binary(overrides); - let codegraph = synchronize_codegraph_targets(&targets, synchronize_codegraph, |path| { - run_codegraph_sync( - &binary, - path, - Duration::from_millis(policy.max_codegraph_sync_wall_time_ms_per_repo), - ) - }); + let codegraph_timeout = Duration::from_millis(policy.max_codegraph_sync_wall_time_ms_per_repo); + let codegraph = synchronize_codegraph_targets( + &targets, + synchronize_codegraph, + codegraph_timeout, + |target, deadline| run_codegraph_status(&binary, target, deadline), + |path, deadline| run_codegraph_sync(&binary, path, deadline), + ); let mut scan_overrides = overrides.clone(); scan_overrides.codegraph = codegraph.synchronized_count > 0; - let scan = super::scan_workspace_direct(config_path, database_path, &scan_overrides)?; + let scan = super::scan_workspace_direct_for_sync( + config_path, + database_path, + &scan_overrides, + codegraph.changed_count == 0, + )?; Ok(SyncSummary { schema_version: 1, execution: code_system_graph_core::ExecutionSummary::default(), @@ -305,6 +321,48 @@ pub(crate) fn sync_workspace_direct( }) } +fn run_codegraph_status( + binary: &OsStr, + target: &SyncTarget, + deadline: Instant, +) -> Result { + let binary = binary.to_owned(); + let alias = target.alias.clone(); + let project_path = target.path.clone(); + std::thread::spawn(move || { + let timeout = deadline.saturating_duration_since(Instant::now()); + if timeout.is_zero() { + return Err( + "CodeGraph status exceeded the repository synchronization deadline".to_owned(), + ); + } + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("cannot start CodeGraph status runtime: {error}"))?; + let provider = CodeGraphProvider::new(CodeGraphConfig { + binary, + max_concurrent_processes: 1, + ..CodeGraphConfig::default() + }) + .map_err(|error| format!("cannot configure CodeGraph status provider: {error}"))?; + runtime + .block_on(provider.index_status(ProviderRequest { + repo_id: RepoId::new(format!("sync:{alias}")), + project_path, + budget: ProviderBudget { + timeout, + max_output_bytes: 256 * 1024, + max_items: 1, + }, + cancellation: CancellationToken::new(), + })) + .map_err(|error| format!("cannot inspect CodeGraph status: {error}")) + }) + .join() + .map_err(|_| "CodeGraph status worker terminated unexpectedly".to_owned())? +} + fn persist_watch_targets( database_path: &Path, workspace: &str, @@ -362,8 +420,11 @@ fn codegraph_binary(overrides: &ScanOverrides) -> OsString { fn run_codegraph_sync( binary: &OsStr, project_path: &Path, - timeout: Duration, + deadline: Instant, ) -> Result<(), String> { + if Instant::now() >= deadline { + return Err("codegraph sync exceeded the repository synchronization deadline".to_owned()); + } let mut command = Command::new(binary); command .arg("sync") @@ -377,7 +438,6 @@ fn run_codegraph_sync( let mut child = command .spawn() .map_err(|error| format!("failed to start codegraph sync: {error}"))?; - let started = Instant::now(); let status = loop { if let Some(status) = child .try_wait() @@ -385,13 +445,13 @@ fn run_codegraph_sync( { break status; } - if started.elapsed() >= timeout { + if Instant::now() >= deadline { super::worker::terminate_process_tree(child.id()); terminate_codegraph_process_group(&mut child); - return Err(format!( - "codegraph sync exceeded {} ms and was terminated", - timeout.as_millis() - )); + return Err( + "codegraph sync exceeded the repository synchronization deadline and was terminated" + .to_owned(), + ); } std::thread::sleep(Duration::from_millis(50)); }; @@ -443,33 +503,67 @@ fn cleanup_exited_codegraph_process_group(process_id: u32) { super::worker::terminate_process_tree(process_id); } -fn synchronize_codegraph_targets( +#[derive(Debug)] +struct CodeGraphSyncObservation { + state: CodeGraphSyncState, + detail: Option, + index_changed: bool, +} + +fn synchronize_codegraph_targets( targets: &[SyncTarget], enabled: bool, - mut synchronize: F, + timeout: Duration, + mut inspect: I, + mut synchronize: S, ) -> CodeGraphSyncSummary where - F: FnMut(&Path) -> Result<(), String>, + I: FnMut(&SyncTarget, Instant) -> Result, + S: FnMut(&Path, Instant) -> Result<(), String>, { + let mut observations = Vec::with_capacity(targets.len()); let mut repositories = Vec::with_capacity(targets.len()); if enabled { for target in targets { - let (state, detail) = if target.path.join(".codegraph").is_dir() { - match synchronize(&target.path) { - Ok(()) => (CodeGraphSyncState::Synchronized, None), - Err(detail) => (CodeGraphSyncState::Failed, Some(bounded_detail(&detail))), + let deadline = Instant::now() + timeout; + let observation = if target.path.join(".codegraph").is_dir() { + match inspect(target, deadline) { + Ok(ProviderStatus::Available) => CodeGraphSyncObservation { + state: CodeGraphSyncState::Synchronized, + detail: None, + index_changed: false, + }, + Ok(ProviderStatus::Stale) => match synchronize(&target.path, deadline) { + Ok(()) => CodeGraphSyncObservation { + state: CodeGraphSyncState::Synchronized, + detail: None, + index_changed: true, + }, + Err(detail) => failed_sync_observation(&detail), + }, + Ok(ProviderStatus::IndexMissing) => CodeGraphSyncObservation { + state: CodeGraphSyncState::SkippedNotInitialized, + detail: Some("local CodeGraph index is not initialized".to_owned()), + index_changed: false, + }, + Ok(status) => failed_sync_observation(&format!( + "CodeGraph status is not usable for synchronization: {status:?}" + )), + Err(detail) => failed_sync_observation(&detail), } } else { - ( - CodeGraphSyncState::SkippedNotInitialized, - Some("local CodeGraph index is not initialized".to_owned()), - ) + CodeGraphSyncObservation { + state: CodeGraphSyncState::SkippedNotInitialized, + detail: Some("local CodeGraph index is not initialized".to_owned()), + index_changed: false, + } }; repositories.push(CodeGraphRepositorySync { repository: target.alias.clone(), - state, - detail, + state: observation.state, + detail: observation.detail.clone(), }); + observations.push(observation); super::worker::report_progress(code_system_graph_core::JobPhase::CodeGraphSync, 1); } } @@ -478,6 +572,14 @@ where .iter() .filter(|item| item.state == CodeGraphSyncState::Synchronized) .count(); + let changed_count = observations + .iter() + .filter(|item| item.state == CodeGraphSyncState::Synchronized && item.index_changed) + .count(); + let unchanged_count = observations + .iter() + .filter(|item| item.state == CodeGraphSyncState::Synchronized && !item.index_changed) + .count(); let skipped_count = repositories .iter() .filter(|item| item.state == CodeGraphSyncState::SkippedNotInitialized) @@ -490,12 +592,22 @@ where enabled, repository_count: targets.len(), synchronized_count, + changed_count, + unchanged_count, skipped_count, failed_count, repositories, } } +fn failed_sync_observation(detail: &str) -> CodeGraphSyncObservation { + CodeGraphSyncObservation { + state: CodeGraphSyncState::Failed, + detail: Some(bounded_detail(detail)), + index_changed: false, + } +} + fn bounded_detail(detail: &str) -> String { const MAX_CHARS: usize = 512; let normalized = detail.split_whitespace().collect::>().join(" "); @@ -562,10 +674,16 @@ mod tests { let called = RefCell::new(Vec::new()); let targets = vec![target("zeta", initialized.clone()), target("alpha", absent)]; - let report = synchronize_codegraph_targets(&targets, true, |path| { - called.borrow_mut().push(path.to_path_buf()); - Err("failure ".repeat(600)) - }); + let report = synchronize_codegraph_targets( + &targets, + true, + Duration::from_secs(1), + |target, _| { + called.borrow_mut().push(target.path.clone()); + Err("failure ".repeat(600)) + }, + |_, _| panic!("failed status inspection must not synchronize"), + ); assert_eq!(called.into_inner(), vec![initialized]); assert_eq!(report.repository_count, 2); @@ -589,14 +707,82 @@ mod tests { #[test] fn disabled_codegraph_sync_should_not_invoke_runner() { let target = target("repo", PathBuf::from("repo")); - let report = synchronize_codegraph_targets(&[target], false, |_| { - panic!("disabled synchronization must not invoke CodeGraph") - }); + let report = synchronize_codegraph_targets( + &[target], + false, + Duration::from_secs(1), + |_, _| panic!("disabled synchronization must not inspect CodeGraph"), + |_, _| panic!("disabled synchronization must not invoke CodeGraph"), + ); assert!(!report.enabled); assert_eq!(report.repository_count, 1); assert!(report.repositories.is_empty()); } + #[test] + fn synchronization_should_distinguish_changed_and_current_indexes() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let current = temporary.path().join("current"); + let stale = temporary.path().join("stale"); + std::fs::create_dir_all(current.join(".codegraph"))?; + std::fs::create_dir_all(stale.join(".codegraph"))?; + let synchronized = RefCell::new(Vec::new()); + let targets = vec![target("current", current), target("stale", stale.clone())]; + + let report = synchronize_codegraph_targets( + &targets, + true, + Duration::from_secs(1), + |target, _| { + if target.alias == "stale" { + Ok(ProviderStatus::Stale) + } else { + Ok(ProviderStatus::Available) + } + }, + |path, _| { + synchronized.borrow_mut().push(path.to_path_buf()); + Ok(()) + }, + ); + + assert_eq!(synchronized.into_inner(), vec![stale]); + assert_eq!(report.synchronized_count, 2); + assert_eq!(report.changed_count, 1); + assert_eq!(report.unchanged_count, 1); + Ok(()) + } + + #[test] + fn synchronization_should_share_one_deadline_between_status_and_sync() -> anyhow::Result<()> { + let temporary = tempfile::tempdir()?; + let repository = temporary.path().join("stale"); + std::fs::create_dir_all(repository.join(".codegraph"))?; + let inspected_deadline = RefCell::new(None); + let synchronized_deadline = RefCell::new(None); + + let report = synchronize_codegraph_targets( + &[target("stale", repository)], + true, + Duration::from_secs(1), + |_, deadline| { + inspected_deadline.replace(Some(deadline)); + Ok(ProviderStatus::Stale) + }, + |_, deadline| { + synchronized_deadline.replace(Some(deadline)); + Ok(()) + }, + ); + + assert_eq!(report.changed_count, 1); + assert_eq!( + inspected_deadline.into_inner(), + synchronized_deadline.into_inner() + ); + Ok(()) + } + #[cfg(unix)] #[test] fn codegraph_timeout_should_terminate_its_descendant_process() -> anyhow::Result<()> { @@ -614,9 +800,12 @@ mod tests { )?; std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700))?; - let error = - run_codegraph_sync(script.as_os_str(), temporary.path(), Duration::from_secs(2)) - .expect_err("test process must time out"); + let error = run_codegraph_sync( + script.as_os_str(), + temporary.path(), + Instant::now() + Duration::from_secs(2), + ) + .expect_err("test process must time out"); assert!( error.contains("was terminated"), "unexpected error: {error}" @@ -648,8 +837,12 @@ mod tests { )?; std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700))?; - run_codegraph_sync(script.as_os_str(), temporary.path(), Duration::from_secs(2)) - .map_err(anyhow::Error::msg)?; + run_codegraph_sync( + script.as_os_str(), + temporary.path(), + Instant::now() + Duration::from_secs(2), + ) + .map_err(anyhow::Error::msg)?; let pid = std::fs::read_to_string(&descendant_pid)?.parse::()?; for _ in 0..20 { if nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None).is_err() { diff --git a/crates/code-system-graph-cli/src/worker.rs b/crates/code-system-graph-cli/src/worker.rs index 99c2c97..5e9eb56 100644 --- a/crates/code-system-graph-cli/src/worker.rs +++ b/crates/code-system-graph-cli/src/worker.rs @@ -196,6 +196,7 @@ pub fn run_worker_from_stdio() -> Result<(), String> { fn failure_message(error: ApplicationError) -> WorkerMessage { let retryable = retryable_application_error(&error); + let diagnostic = safe_application_diagnostic(&error); let failure = match error { ApplicationError::ExtractionLimit(error) | ApplicationError::HttpExtraction( @@ -217,13 +218,13 @@ fn failure_message(error: ApplicationError) -> WorkerMessage { ApplicationError::ExecutionLimit(error) => WorkerFailure::ExecutionLimit { error }, ApplicationError::PartialScanBudgetChanged => WorkerFailure::PartialScanBudgetChanged, error if retryable => WorkerFailure::Transient { - message: error.to_string(), + message: bounded_worker_diagnostic(&error.to_string()), }, error => { let exit_code = application_exit_code(&error); WorkerFailure::Other { exit_code, - message: format!("supervised worker failed with {exit_code:?}"), + message: diagnostic, } } }; @@ -233,6 +234,160 @@ fn failure_message(error: ApplicationError) -> WorkerMessage { } } +fn safe_application_diagnostic(error: &ApplicationError) -> String { + let detail = match error { + ApplicationError::ReadFile { path, source } => { + format!("failed to read `{}` ({:?})", path.display(), source.kind()) + } + ApplicationError::WriteFile { path, source } => { + format!( + "failed to create `{}` ({:?})", + path.display(), + source.kind() + ) + } + ApplicationError::UnknownOverrideRepository(alias) => { + format!("CLI override references unknown repository alias `{alias}`") + } + ApplicationError::WorkspaceNameMismatch { + requested, + manifest, + } => format!("workspace name `{requested}` does not match manifest name `{manifest}`"), + ApplicationError::ArtifactOutsideCheckout { path, checkout } => format!( + "artifact `{}` resolves outside checkout `{}`", + path.display(), + checkout.display() + ), + ApplicationError::UnsafeArtifactPath => { + "artifact path contains unsafe metadata characters".to_owned() + } + ApplicationError::PartialScanBudgetChanged => { + "extraction budgets changed; run a full scan without `--repo`".to_owned() + } + ApplicationError::Graphql(GraphqlExtractionError::InvalidGraphql { + source_path, .. + }) => format!("invalid GraphQL document in `{source_path}`"), + ApplicationError::Graphql(GraphqlExtractionError::InvalidJson { + source_path, + line, + .. + }) => format!("invalid persisted-operation JSON in `{source_path}` at line {line}"), + ApplicationError::Graphql(GraphqlExtractionError::UnsupportedPersistedManifest { + source_path, + }) => format!("unsupported persisted-operation manifest in `{source_path}`"), + ApplicationError::HttpExtraction(error) => error.to_string(), + ApplicationError::Link(code_system_graph_core::LinkError::AmbiguousProvider { + method, + path, + candidates, + }) => format!( + "ambiguous HTTP provider for {method} {path}; stable candidates: {}", + candidates.join(", ") + ), + ApplicationError::Config(error) => safe_config_diagnostic(error), + ApplicationError::Manifest(_) => "workspace manifest is invalid".to_owned(), + ApplicationError::ManualLink(error) => { + format!("manual link configuration is invalid: {error}") + } + ApplicationError::RegistryAliasMissing(alias) => { + format!("validated registry omitted repository alias `{alias}`") + } + ApplicationError::Registry(_) => "repository registry is invalid".to_owned(), + ApplicationError::PackageManifest(_) => "package manifest extraction failed".to_owned(), + ApplicationError::GeneratedClient(_) => { + "generated-client metadata extraction failed".to_owned() + } + ApplicationError::Event(_) => "event contract extraction failed".to_owned(), + ApplicationError::Protobuf(_) => "Protobuf contract extraction failed".to_owned(), + ApplicationError::Data(_) => "database contract extraction failed".to_owned(), + ApplicationError::Infrastructure(_) => { + "infrastructure contract extraction failed".to_owned() + } + ApplicationError::Documentation(_) => "documentation extraction failed".to_owned(), + ApplicationError::ConfigExtraction(_) => "configuration-key extraction failed".to_owned(), + ApplicationError::SourceSyntax(_) => "source syntax inspection failed".to_owned(), + ApplicationError::InvalidSourceObservation(_) => { + "focused source observation failed structural validation".to_owned() + } + ApplicationError::BatchPlan(_) => "incremental extractor batch state is invalid".to_owned(), + ApplicationError::Store(_) => "workspace storage operation failed".to_owned(), + ApplicationError::Initialization(_) => "workspace initialization failed".to_owned(), + _ => format!( + "application operation failed with {:?}", + application_exit_code(error) + ), + }; + bounded_worker_diagnostic(&detail) +} + +fn safe_config_diagnostic(error: &code_system_graph_core::ConfigError) -> String { + match error { + code_system_graph_core::ConfigError::Read { path, source } => format!( + "failed to read repository config `{}` ({:?})", + path.display(), + source.kind() + ), + code_system_graph_core::ConfigError::Invalid { path, .. } => { + format!("invalid repository config `{}`", path.display()) + } + code_system_graph_core::ConfigError::AmbiguousOpenApi { root, candidates } => format!( + "ambiguous OpenAPI auto-detection in `{}`; candidates: {}", + root.display(), + candidates.join(", ") + ), + other => other.to_string(), + } +} + +fn bounded_worker_diagnostic(detail: &str) -> String { + const MAX_CHARS: usize = 512; + let mut bounded = String::with_capacity(detail.len().min(MAX_CHARS)); + let mut previous_whitespace = false; + let mut truncated = false; + for character in detail.chars() { + if unsafe_diagnostic_character(character) { + continue; + } + if character.is_whitespace() { + if bounded.is_empty() || previous_whitespace { + continue; + } + if bounded.chars().count() >= MAX_CHARS { + truncated = true; + break; + } + bounded.push(' '); + previous_whitespace = true; + continue; + } + if bounded.chars().count() >= MAX_CHARS { + truncated = true; + break; + } + bounded.push(character); + previous_whitespace = false; + } + while bounded.ends_with(' ') { + bounded.pop(); + } + if truncated { + bounded.push_str("..."); + } + if bounded.is_empty() { + "application operation failed".to_owned() + } else { + bounded + } +} + +fn unsafe_diagnostic_character(character: char) -> bool { + character.is_control() + || matches!( + character, + '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{200e}' | '\u{200f}' + ) +} + fn retryable_application_error(error: &ApplicationError) -> bool { match error { ApplicationError::ReadFile { source, .. } @@ -1278,17 +1433,33 @@ mod tests { .any(|bytes| bytes == secret.as_bytes()) ); assert!(matches!( - message, + &message, WorkerMessage::Failure { failure: WorkerFailure::Other { exit_code: code_system_graph_core::ExitCode::InvalidInput, - .. + message, }, .. - } + } if message.contains("schema.graphql") && message.chars().count() <= 515 )); } + #[test] + fn worker_failure_diagnostic_should_be_bounded_and_actionable() { + let alias = "missing".repeat(200); + let message = failure_message(ApplicationError::UnknownOverrideRepository(alias)); + let WorkerMessage::Failure { + failure: WorkerFailure::Other { message, .. }, + .. + } = message + else { + panic!("other worker failure expected"); + }; + + assert!(message.contains("unknown repository alias")); + assert!(message.chars().count() <= 515); + } + #[cfg(unix)] fn supervise_script(script: &str, policy: &ExecutionPolicy) -> ApplicationError { monitor_script(script, policy).expect_err("script must exceed a limit") diff --git a/crates/code-system-graph-cli/tests/scan_controls_e2e.rs b/crates/code-system-graph-cli/tests/scan_controls_e2e.rs index 12dea62..e88b1b7 100644 --- a/crates/code-system-graph-cli/tests/scan_controls_e2e.rs +++ b/crates/code-system-graph-cli/tests/scan_controls_e2e.rs @@ -1,5 +1,7 @@ //! Acceptance tests for targeted and forced scan controls. +use std::fmt::Write as _; + use code_system_graph::{ ApplicationError, ScanOverrides, WatcherState, application_exit_code, finish_watcher_lease, scan_workspace, scan_workspace_with_overrides, scan_workspace_with_worker_executable, start_watcher_lease, status_workspace }; @@ -103,6 +105,149 @@ fn targeted_scan_should_reuse_unselected_repository_batches() -> anyhow::Result< Ok(()) } +#[cfg(unix)] +#[test] +fn targeted_codegraph_scan_should_not_schedule_unselected_repositories() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::tempdir()?; + let mut manifest = String::from("version: 1\nname: focused-codegraph\nrepos:\n"); + for alias in ["a", "b"] { + let repository = temporary.path().join(alias); + std::fs::create_dir_all(repository.join("src"))?; + std::fs::write( + repository.join("openapi.yaml"), + openapi(&format!("/{alias}")), + )?; + std::fs::write( + repository.join("src/lib.rs"), + format!( + "use axum::{{Router, routing::get}};\npub fn router() -> Router {{ Router::new().route(\"/{alias}\", get(handler)) }}\nasync fn handler() {{}}\n" + ), + )?; + write!( + manifest, + " {alias}:\n path: {alias}\n openapi: openapi.yaml\n" + )?; + } + let manifest_path = temporary.path().join("code-system-graph.yaml"); + let database = temporary.path().join("graph.db"); + std::fs::write(&manifest_path, manifest)?; + scan_workspace(&manifest_path, &database)?; + + let invocation_log = temporary.path().join("codegraph.log"); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/codegraph/fake/codegraph.py") + .canonicalize()?; + let binary = temporary.path().join("codegraph-focused"); + std::fs::write( + &binary, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexec python3 '{}' \"$@\"\n", + invocation_log.display(), + fixture.display() + ), + )?; + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700))?; + std::fs::write( + temporary.path().join("a/openapi.yaml"), + openapi("/a/changed"), + )?; + + let summary = scan_workspace_with_overrides( + &manifest_path, + &database, + &ScanOverrides { + codegraph: true, + codegraph_binary: Some(binary), + repository: Some("a".to_owned()), + ..ScanOverrides::default() + }, + )?; + let invocations = std::fs::read_to_string(invocation_log)?; + + assert!(summary.changed_input_count > 0); + assert!(invocations.contains(temporary.path().join("a").to_string_lossy().as_ref())); + assert!(!invocations.contains(temporary.path().join("b").to_string_lossy().as_ref())); + Ok(()) +} + +#[test] +fn duplicate_http_providers_should_degrade_without_aborting_unrelated_links() -> anyhow::Result<()> +{ + let temporary = tempfile::tempdir()?; + for alias in ["api-a", "api-b", "web"] { + std::fs::create_dir(temporary.path().join(alias))?; + } + std::fs::write( + temporary.path().join("api-a/openapi.yaml"), + "openapi: 3.1.0\ninfo: { title: A, version: 1 }\npaths:\n /orders:\n get: {}\n /health:\n get: {}\n", + )?; + std::fs::write( + temporary.path().join("api-b/openapi.yaml"), + openapi("/orders"), + )?; + for source in ["orders.ts", "health.ts"] { + std::fs::write(temporary.path().join("web").join(source), "export {};\n")?; + } + let base_manifest = "version: 1\nname: http-ambiguity\nrepos:\n api-a:\n path: api-a\n openapi: openapi.yaml\n api-b:\n path: api-b\n openapi: openapi.yaml\n web:\n path: web\n httpConsumers:\n - method: GET\n path: /orders\n source: orders.ts\n - method: GET\n path: /health\n source: health.ts\n"; + let manifest = temporary.path().join("code-system-graph.yaml"); + let database = temporary.path().join("graph.db"); + std::fs::write(&manifest, base_manifest)?; + + let summary = scan_workspace(&manifest, &database)?; + let store = SqliteStore::open_read_only(&database)?; + let (nodes, edges) = store.load_current_graph("http-ambiguity")?; + let automatic_calls = edges + .iter() + .filter(|edge| edge.kind == code_system_graph_model::EdgeKind::CallsRemote) + .count(); + assert_eq!(automatic_calls, 1); + assert!( + summary + .degradations + .iter() + .any(|item| item.contains("ambiguous HTTP provider for GET /orders")) + ); + + let consumer = nodes + .iter() + .find(|node| node.stable_key.contains(":consumer:GET:/orders")) + .expect("orders consumer node"); + let registry = store.load_workspace_registry("http-ambiguity")?; + let api_a = registry + .repositories + .iter() + .find(|repository| repository.alias == "api-a") + .expect("api-a registry entry"); + let provider = nodes + .iter() + .find(|node| { + node.repo_id.as_ref() == Some(&api_a.id) + && node.stable_key.contains(":provider:GET:/orders") + }) + .expect("api-a orders provider"); + let resolved_manifest = format!( + "{base_manifest}manualLinks:\n - from: {}\n to: {}\n relation: calls_remote\n contract: GET /orders\n reason: Select the authoritative orders provider\n", + consumer.id.as_str(), + provider.id.as_str() + ); + drop(store); + std::fs::write(&manifest, resolved_manifest)?; + + scan_workspace(&manifest, &database)?; + let (_, resolved_edges) = + SqliteStore::open_read_only(&database)?.load_current_graph("http-ambiguity")?; + assert_eq!( + resolved_edges + .iter() + .filter(|edge| edge.kind == code_system_graph_model::EdgeKind::CallsRemote) + .count(), + 2 + ); + Ok(()) +} + #[test] fn execution_policy_change_should_not_invalidate_batches_or_require_full_scan() -> anyhow::Result<()> { @@ -219,6 +364,9 @@ fn supervised_validation_failures_should_keep_invalid_input_classification() -> ApplicationError::SupervisedApplication { .. } )); } + assert!(errors[0].to_string().contains("missing")); + assert!(errors[1].to_string().contains("wrong")); + assert!(errors[1].to_string().contains("typed-worker-errors")); std::fs::write(repository.join("openapi.yaml"), "openapi: [")?; let malformed = scan_workspace(&manifest, &database).expect_err("malformed OpenAPI"); @@ -227,6 +375,7 @@ fn supervised_validation_failures_should_keep_invalid_input_classification() -> malformed, ApplicationError::SupervisedApplication { .. } )); + assert!(malformed.to_string().contains("invalid OpenAPI document")); Ok(()) } diff --git a/crates/code-system-graph-cli/tests/sync_e2e.rs b/crates/code-system-graph-cli/tests/sync_e2e.rs index 54fe7bb..90c2ce1 100644 --- a/crates/code-system-graph-cli/tests/sync_e2e.rs +++ b/crates/code-system-graph-cli/tests/sync_e2e.rs @@ -26,8 +26,17 @@ fn write_workspace( } fn run_sync(manifest: &std::path::Path, database: &std::path::Path) -> anyhow::Result { + run_sync_with_arguments(manifest, database, &[]) +} + +fn run_sync_with_arguments( + manifest: &std::path::Path, + database: &std::path::Path, + arguments: &[&std::ffi::OsStr], +) -> anyhow::Result { let output = Command::new(env!("CARGO_BIN_EXE_csgraph")) .arg("sync") + .args(arguments) .arg("--config") .arg(manifest) .arg("--database") @@ -41,6 +50,142 @@ fn run_sync(manifest: &std::path::Path, database: &std::path::Path) -> anyhow::R Ok(serde_json::from_slice(&output.stdout)?) } +#[cfg(unix)] +#[test] +fn initialized_current_codegraph_should_enrich_native_changes_without_republishing_noops() +-> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::tempdir()?; + let (manifest, database) = write_workspace(temporary.path())?; + std::fs::create_dir(temporary.path().join("api/src"))?; + std::fs::write( + temporary.path().join("api/src/lib.rs"), + "use axum::{Router, routing::get};\npub fn router() -> Router { Router::new().route(\"/before\", get(handler)) }\nasync fn handler() {}\n", + )?; + std::fs::create_dir(temporary.path().join("api/.codegraph"))?; + let invocation_log = temporary.path().join("codegraph.log"); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/codegraph/fake/codegraph.py") + .canonicalize()?; + let binary = temporary.path().join("codegraph-current"); + std::fs::write( + &binary, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nexec python3 '{}' \"$@\"\n", + invocation_log.display(), + fixture.display() + ), + )?; + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700))?; + let arguments = [ + std::ffi::OsStr::new("--codegraph-binary"), + binary.as_os_str(), + ]; + + let first = run_sync_with_arguments(&manifest, &database, &arguments)?; + let second = run_sync_with_arguments(&manifest, &database, &arguments)?; + let after_second = std::fs::read_to_string(&invocation_log)?; + let affected_after_second = after_second + .lines() + .filter(|line| line.starts_with("affected ")) + .count(); + + std::fs::write( + temporary.path().join("api/openapi.yaml"), + "openapi: 3.1.0\ninfo: { title: API, version: 1 }\npaths:\n /after:\n get: {}\n", + )?; + let third = run_sync_with_arguments(&manifest, &database, &arguments)?; + let after_third = std::fs::read_to_string(&invocation_log)?; + let affected_after_third = after_third + .lines() + .filter(|line| line.starts_with("affected ")) + .count(); + let fourth = run_sync_with_arguments(&manifest, &database, &arguments)?; + let final_invocations = std::fs::read_to_string(&invocation_log)?; + let affected_final = final_invocations + .lines() + .filter(|line| line.starts_with("affected ")) + .count(); + + assert!(!first.scan.reused_snapshot); + assert!(affected_after_second > 0); + assert!(second.scan.reused_snapshot); + assert_eq!(second.scan.changed_input_count, 0); + assert_eq!(second.scan.snapshot_id, first.scan.snapshot_id); + assert_eq!(second.codegraph.synchronized_count, 1); + assert_eq!(second.codegraph.changed_count, 0); + assert_eq!(second.codegraph.unchanged_count, 1); + assert!(third.scan.changed_input_count > 0); + assert!(!third.scan.reused_snapshot); + assert_eq!(third.codegraph.changed_count, 0); + assert!(affected_after_third > affected_after_second); + assert!(fourth.scan.reused_snapshot); + assert_eq!(fourth.scan.snapshot_id, third.scan.snapshot_id); + assert_eq!(affected_final, affected_after_third); + assert!( + !final_invocations + .lines() + .any(|line| line.starts_with("sync ")) + ); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn stale_codegraph_index_should_corroborate_even_without_native_changes() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let temporary = tempfile::tempdir()?; + let (manifest, database) = write_workspace(temporary.path())?; + std::fs::create_dir(temporary.path().join("api/src"))?; + std::fs::write( + temporary.path().join("api/src/lib.rs"), + "use axum::{Router, routing::get};\npub fn router() -> Router { Router::new().route(\"/before\", get(handler)) }\nasync fn handler() {}\n", + )?; + let initial = run_sync_with_arguments( + &manifest, + &database, + &[std::ffi::OsStr::new("--no-codegraph")], + )?; + std::fs::create_dir(temporary.path().join("api/.codegraph"))?; + let invocation_log = temporary.path().join("codegraph.log"); + let synchronized_marker = temporary.path().join("synchronized"); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/codegraph/fake/codegraph.py") + .canonicalize()?; + let binary = temporary.path().join("codegraph-stale"); + std::fs::write( + &binary, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\ncase \"$1\" in\n status)\n if [ -f '{}' ]; then modified=0; else modified=1; fi\n printf '{{\"initialized\":true,\"version\":\"1.5.0\",\"pendingChanges\":{{\"added\":0,\"modified\":%s,\"removed\":0}},\"worktreeMismatch\":null,\"index\":{{\"reindexRecommended\":false,\"state\":\"complete\"}}}}\\n' \"$modified\"\n ;;\n sync) : > '{}' ;;\n *) exec python3 '{}' \"$@\" ;;\nesac\n", + invocation_log.display(), + synchronized_marker.display(), + synchronized_marker.display(), + fixture.display() + ), + )?; + std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o700))?; + let arguments = [ + std::ffi::OsStr::new("--codegraph-binary"), + binary.as_os_str(), + ]; + + let synchronized = run_sync_with_arguments(&manifest, &database, &arguments)?; + let current = run_sync_with_arguments(&manifest, &database, &arguments)?; + let invocations = std::fs::read_to_string(&invocation_log)?; + + assert_eq!(synchronized.scan.changed_input_count, 0); + assert!(!synchronized.scan.reused_snapshot); + assert_eq!(synchronized.codegraph.changed_count, 1); + assert_eq!(synchronized.scan.snapshot_id, initial.scan.snapshot_id); + assert!(invocations.lines().any(|line| line.starts_with("sync "))); + assert!(invocations.lines().any(|line| line.starts_with("query "))); + assert!(current.scan.reused_snapshot); + assert_eq!(current.codegraph.unchanged_count, 1); + Ok(()) +} + #[test] fn one_shot_sync_should_report_optional_codegraph_and_reuse_snapshot() -> anyhow::Result<()> { let temporary = tempfile::tempdir()?; diff --git a/crates/code-system-graph-core/Cargo.toml b/crates/code-system-graph-core/Cargo.toml index d21b7b3..d3475b9 100644 --- a/crates/code-system-graph-core/Cargo.toml +++ b/crates/code-system-graph-core/Cargo.toml @@ -22,7 +22,7 @@ libc = "0.2" nix = { version = "0.31.3", features = ["fs"] } proto-parser = "1.14.3" pulldown-cmark = "0.13.4" -code-system-graph-model = { version = "1.0.1", path = "../code-system-graph-model" } +code-system-graph-model = { version = "1.0.2", path = "../code-system-graph-model" } reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } rmcp = { version = "3.1.0", default-features = false, features = [ "client", diff --git a/crates/code-system-graph-core/src/codegraph/contract.rs b/crates/code-system-graph-core/src/codegraph/contract.rs index 172dc90..586e042 100644 --- a/crates/code-system-graph-core/src/codegraph/contract.rs +++ b/crates/code-system-graph-core/src/codegraph/contract.rs @@ -327,6 +327,16 @@ mod tests { assert_eq!(status.status(), ProviderStatus::Stale); } + #[test] + fn status_contract_should_recognize_current_index() { + let status: StatusContract = serde_json::from_str(include_str!( + "../../../../fixtures/codegraph/1.5.0/status.json" + )) + .expect("fixture should be valid"); + + assert_eq!(status.status(), ProviderStatus::Available); + } + #[test] fn cli_contract_should_reject_unvalidated_versions() { assert!(supports_cli_contract("1.5.0")); diff --git a/crates/code-system-graph-core/src/codegraph/mod.rs b/crates/code-system-graph-core/src/codegraph/mod.rs index 63b4aac..b728f15 100644 --- a/crates/code-system-graph-core/src/codegraph/mod.rs +++ b/crates/code-system-graph-core/src/codegraph/mod.rs @@ -91,6 +91,26 @@ impl CodeGraphProvider { }) } + /// Reads the structured local-index status without starting MCP or modifying the index. + /// + /// # Errors + /// + /// Returns [`ProviderError`] when the request is invalid, the compatible CLI cannot be + /// executed, or its bounded JSON status does not satisfy the validated contract. + pub async fn index_status( + &self, + mut request: ProviderRequest, + ) -> Result { + let deadline = tokio::time::Instant::now() + request.budget.timeout; + let _permit = self.enter(&request, deadline).await?; + self.compatible_cli_version(&mut request, deadline).await?; + update_remaining_timeout(&mut request, deadline)?; + self.cli + .status(&request) + .await + .map(|status| status.status()) + } + async fn enter( &self, request: &ProviderRequest, diff --git a/crates/code-system-graph-core/src/lib.rs b/crates/code-system-graph-core/src/lib.rs index b0d4593..e220ed1 100644 --- a/crates/code-system-graph-core/src/lib.rs +++ b/crates/code-system-graph-core/src/lib.rs @@ -119,7 +119,7 @@ pub use interfaces::{ Ambiguity, ConfigDoctorInput, ContractAction, ContractCompatibility, ContractCompatibilitySummary, ContractDifference, ContractFinding, ContractIssue, ContractIssueSeverity, ContractLink, ContractReport, ContractRequest, ContractView, DELIVERY_METADATA_VERSION, DoctorCategory, DoctorCheck, DoctorReport, DoctorRequest, DoctorStatus, DomainErrorKind, EvidenceMetadata, ExitCode, ExportFormat, ExportReport, ExportRequest, FreshnessDoctorInput, INTERFACE_RESULT_VERSION, INTERFACE_SCHEMA_VERSION, IntegrityDoctorInput, InterfaceError, MAX_EXPORT_EDGES, MAX_EXPORT_NODES, NextAction, Page, Pagination, ProviderDoctorInput, ProviderDoctorStatus, PublicSchema, PublicSchemaCatalog, SchemaDoctorInput, Summary, Warning, classify_exit_code, classify_interface_error, doctor, export_graph, inspect_contracts, paginate, public_schema_catalog }; pub use linker::{ - LinkError, ManualLinkEndpoint, ManualLinkError, ManualLinkResolution, link_http_boundaries, merge_affected_link_neighborhoods, resolve_manual_links + HttpLinkAmbiguity, HttpLinkResolution, LinkError, ManualLinkEndpoint, ManualLinkError, ManualLinkResolution, link_http_boundaries, link_http_boundaries_with_ambiguities, merge_affected_link_neighborhoods, resolve_manual_links }; pub use manifest::{ ContractImplementationConfig, HttpConsumerConfig, HttpContractConfig, IntegrationTestConfig, ManifestError, ManualLinkConfig, RepositoryConfig, WorkspaceManifest, parse_manifest, validate_manual_links @@ -161,6 +161,6 @@ pub use source_syntax::{ SourceSyntaxError, SourceSyntaxInspection, SourceSyntaxLanguage, inspect_source_syntax }; pub use test_links::{ - DeclaredImplementation, DeclaredTestCase, declared_implementation, declared_test_case, link_declared_implementations, link_declared_tests + DeclaredImplementation, DeclaredTestCase, declared_implementation, declared_test_case, link_declared_implementations, link_declared_implementations_with_ambiguities, link_declared_tests, link_declared_tests_with_ambiguities }; pub use trace::{FederatedGraph, TraceError}; diff --git a/crates/code-system-graph-core/src/linker.rs b/crates/code-system-graph-core/src/linker.rs index b38ed2c..ea41f02 100644 --- a/crates/code-system-graph-core/src/linker.rs +++ b/crates/code-system-graph-core/src/linker.rs @@ -26,6 +26,40 @@ pub enum LinkError { }, } +/// One exact HTTP contract that could not be linked because several providers matched. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpLinkAmbiguity { + /// Canonical HTTP method. + pub method: String, + /// Canonical path template. + pub path: String, + /// Stable candidate node identifiers in deterministic order. + pub candidates: Vec, +} + +/// Deterministic automatic HTTP links plus unresolved exact-provider ambiguities. +#[derive(Debug, Clone, PartialEq)] +pub struct HttpLinkResolution { + /// Unambiguous automatic relationships. + pub edges: Vec, + /// Source-free ambiguous contract identities omitted from the edge set. + pub ambiguities: Vec, +} + +impl HttpLinkResolution { + pub(crate) fn into_legacy_result(self) -> Result, LinkError> { + let Self { edges, ambiguities } = self; + let Some(ambiguity) = ambiguities.into_iter().next() else { + return Ok(edges); + }; + Err(LinkError::AmbiguousProvider { + method: ambiguity.method, + path: ambiguity.path, + candidates: ambiguity.candidates, + }) + } +} + /// Endpoint field being resolved for a manual relationship. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ManualLinkEndpoint { @@ -132,10 +166,20 @@ pub struct ManualLinkResolution { /// Consumers without an observed provider remain unlinked. Callers must represent that as a /// coverage gap rather than concluding that no dependency exists. /// +/// Duplicate providers remain fail-closed for compatibility. Use +/// [`link_http_boundaries_with_ambiguities`] to preserve ambiguities as data while continuing with +/// unrelated contracts. +/// /// # Errors /// -/// Returns [`LinkError::AmbiguousProvider`] instead of selecting among duplicate providers. +/// Returns [`LinkError::AmbiguousProvider`] instead of silently omitting an ambiguous relationship. pub fn link_http_boundaries(boundaries: &[HttpBoundary]) -> Result, LinkError> { + link_http_boundaries_with_ambiguities(boundaries).into_legacy_result() +} + +/// Links exact HTTP boundaries while preserving duplicate-provider decisions. +#[must_use] +pub fn link_http_boundaries_with_ambiguities(boundaries: &[HttpBoundary]) -> HttpLinkResolution { let mut providers: BTreeMap<(&str, &str), Vec<&HttpBoundary>> = BTreeMap::new(); for boundary in boundaries { if boundary.role == BoundaryRole::Provider { @@ -156,6 +200,7 @@ pub fn link_http_boundaries(boundaries: &[HttpBoundary]) -> Result, Li } let mut edges = Vec::new(); + let mut ambiguities = Vec::new(); for consumer in boundaries .iter() .filter(|boundary| boundary.role == BoundaryRole::Consumer) @@ -165,16 +210,12 @@ pub fn link_http_boundaries(boundaries: &[HttpBoundary]) -> Result, Li continue; }; if candidates.len() > 1 { - let mut candidate_ids = candidates - .iter() - .map(|candidate| candidate.node.id.as_str().to_owned()) - .collect::>(); - candidate_ids.sort(); - return Err(LinkError::AmbiguousProvider { - method: consumer.method.clone(), - path: consumer.path.clone(), - candidates: candidate_ids, - }); + ambiguities.push(http_link_ambiguity( + &consumer.method, + &consumer.path, + candidates.iter().map(|candidate| &candidate.node.id), + )); + continue; } let provider = candidates[0]; let edge_key = format!( @@ -201,7 +242,37 @@ pub fn link_http_boundaries(boundaries: &[HttpBoundary]) -> Result, Li }); } edges.sort_by(|left, right| left.id.cmp(&right.id)); - Ok(edges) + sort_http_ambiguities(&mut ambiguities); + HttpLinkResolution { edges, ambiguities } +} + +pub(crate) fn http_link_ambiguity<'a>( + method: &str, + path: &str, + candidates: impl IntoIterator, +) -> HttpLinkAmbiguity { + let mut candidates = candidates + .into_iter() + .map(|candidate| candidate.as_str().to_owned()) + .collect::>(); + candidates.sort(); + candidates.dedup(); + HttpLinkAmbiguity { + method: method.to_owned(), + path: path.to_owned(), + candidates, + } +} + +pub(crate) fn sort_http_ambiguities(ambiguities: &mut Vec) { + ambiguities.sort_by(|left, right| { + (&left.method, &left.path, &left.candidates).cmp(&( + &right.method, + &right.path, + &right.candidates, + )) + }); + ambiguities.dedup(); } fn consensus_status(confidence: f32) -> EpistemicStatus { @@ -501,7 +572,7 @@ mod tests { }; use super::{ - LinkError, ManualLinkEndpoint, ManualLinkError, link_http_boundaries, merge_affected_link_neighborhoods, resolve_manual_links + LinkError, ManualLinkEndpoint, ManualLinkError, link_http_boundaries, link_http_boundaries_with_ambiguities, merge_affected_link_neighborhoods, resolve_manual_links }; use crate::{HttpConsumerConfig, ManualLinkConfig, extract_openapi}; @@ -573,7 +644,22 @@ paths: } #[test] - fn link_http_boundaries_should_reject_duplicate_providers() { + fn link_http_boundaries_should_preserve_duplicate_providers_as_ambiguity() { + let result = link_http_boundaries_with_ambiguities(&[ + consumer(), + provider("repo:api-a"), + provider("repo:api-b"), + ]); + + assert!(result.edges.is_empty()); + assert_eq!(result.ambiguities.len(), 1); + assert_eq!(result.ambiguities[0].method, "POST"); + assert_eq!(result.ambiguities[0].path, "/api/orders"); + assert_eq!(result.ambiguities[0].candidates.len(), 2); + } + + #[test] + fn link_http_boundaries_compatibility_wrapper_should_reject_duplicate_providers() { let result = link_http_boundaries(&[consumer(), provider("repo:api-a"), provider("repo:api-b")]); diff --git a/crates/code-system-graph-core/src/test_links.rs b/crates/code-system-graph-core/src/test_links.rs index 486fed4..f6fbae0 100644 --- a/crates/code-system-graph-core/src/test_links.rs +++ b/crates/code-system-graph-core/src/test_links.rs @@ -4,8 +4,9 @@ use code_system_graph_model::{ Edge, EdgeId, EdgeKind, EpistemicStatus, Evidence, EvidenceId, Node, NodeId, NodeKind, Provenance, RepoId, stable_id }; +use crate::linker::{http_link_ambiguity, sort_http_ambiguities}; use crate::{ - BoundaryRole, ContractImplementationConfig, HttpBoundary, IntegrationTestConfig, LinkError, normalize_http_path + BoundaryRole, ContractImplementationConfig, HttpBoundary, HttpLinkResolution, IntegrationTestConfig, LinkError, normalize_http_path }; /// Declared cross-language test case and its validated HTTP target. @@ -133,13 +134,26 @@ pub fn declared_implementation( /// /// Tests with no observed provider remain unlinked instead of inventing a target. /// +/// Duplicate providers remain fail-closed for compatibility. Use +/// [`link_declared_tests_with_ambiguities`] to preserve ambiguities as data while continuing with +/// unrelated contracts. +/// /// # Errors /// -/// Returns [`LinkError::AmbiguousProvider`] when multiple providers expose the same target. +/// Returns [`LinkError::AmbiguousProvider`] instead of silently omitting an ambiguous relationship. pub fn link_declared_tests( tests: &[DeclaredTestCase], boundaries: &[HttpBoundary], ) -> Result, LinkError> { + link_declared_tests_with_ambiguities(tests, boundaries).into_legacy_result() +} + +/// Links declared tests while preserving duplicate-provider decisions. +#[must_use] +pub fn link_declared_tests_with_ambiguities( + tests: &[DeclaredTestCase], + boundaries: &[HttpBoundary], +) -> HttpLinkResolution { let mut providers: BTreeMap<(&str, &str), Vec<&HttpBoundary>> = BTreeMap::new(); for provider in boundaries .iter() @@ -160,21 +174,18 @@ pub fn link_declared_tests( } } let mut edges = Vec::new(); + let mut ambiguities = Vec::new(); for test in tests { let Some(candidates) = providers.get(&(test.method.as_str(), test.path.as_str())) else { continue; }; if candidates.len() > 1 { - let mut candidate_ids = candidates - .iter() - .map(|candidate| candidate.node.id.as_str().to_owned()) - .collect::>(); - candidate_ids.sort(); - return Err(LinkError::AmbiguousProvider { - method: test.method.clone(), - path: test.path.clone(), - candidates: candidate_ids, - }); + ambiguities.push(http_link_ambiguity( + &test.method, + &test.path, + candidates.iter().map(|candidate| &candidate.node.id), + )); + continue; } let provider = candidates[0]; let edge_key = format!( @@ -193,19 +204,34 @@ pub fn link_declared_tests( }); } edges.sort_by(|left, right| left.id.cmp(&right.id)); - Ok(edges) + sort_http_ambiguities(&mut ambiguities); + HttpLinkResolution { edges, ambiguities } } /// Links HTTP provider contracts to declared source implementations. /// +/// Duplicate providers remain fail-closed for compatibility. Use +/// [`link_declared_implementations_with_ambiguities`] to preserve ambiguities as data while +/// continuing with unrelated contracts. +/// /// # Errors /// -/// Returns [`LinkError::AmbiguousProvider`] when a repository exposes duplicate exact providers. +/// Returns [`LinkError::AmbiguousProvider`] instead of silently omitting an ambiguous relationship. pub fn link_declared_implementations( implementations: &[DeclaredImplementation], boundaries: &[HttpBoundary], ) -> Result, LinkError> { + link_declared_implementations_with_ambiguities(implementations, boundaries).into_legacy_result() +} + +/// Links declared implementations while preserving duplicate-provider decisions. +#[must_use] +pub fn link_declared_implementations_with_ambiguities( + implementations: &[DeclaredImplementation], + boundaries: &[HttpBoundary], +) -> HttpLinkResolution { let mut edges = Vec::new(); + let mut ambiguities = Vec::new(); for implementation in implementations { let candidates = boundaries .iter() @@ -232,16 +258,12 @@ pub fn link_declared_implementations( .into_values() .collect::>(); if candidates.len() > 1 { - let mut candidate_ids = candidates - .iter() - .map(|candidate| candidate.node.id.as_str().to_owned()) - .collect::>(); - candidate_ids.sort(); - return Err(LinkError::AmbiguousProvider { - method: implementation.method.clone(), - path: implementation.path.clone(), - candidates: candidate_ids, - }); + ambiguities.push(http_link_ambiguity( + &implementation.method, + &implementation.path, + candidates.iter().map(|candidate| &candidate.node.id), + )); + continue; } let Some(provider) = candidates.first() else { continue; @@ -269,7 +291,8 @@ pub fn link_declared_implementations( }); } edges.sort_by(|left, right| left.id.cmp(&right.id)); - Ok(edges) + sort_http_ambiguities(&mut ambiguities); + HttpLinkResolution { edges, ambiguities } } fn consensus_status(confidence: f32) -> EpistemicStatus { diff --git a/crates/code-system-graph-store-sqlite/Cargo.toml b/crates/code-system-graph-store-sqlite/Cargo.toml index 557bf9b..d543693 100644 --- a/crates/code-system-graph-store-sqlite/Cargo.toml +++ b/crates/code-system-graph-store-sqlite/Cargo.toml @@ -13,7 +13,7 @@ categories = ["database", "development-tools"] [dependencies] blake3 = "1.8.5" -code-system-graph-model = { version = "1.0.1", path = "../code-system-graph-model" } +code-system-graph-model = { version = "1.0.2", path = "../code-system-graph-model" } rusqlite = { version = "0.40.1", features = ["backup", "bundled"] } same-file = "1.0.6" serde_json = "1.0.151" diff --git a/docs/AGENT_SETUP.md b/docs/AGENT_SETUP.md index fe12683..df43459 100644 --- a/docs/AGENT_SETUP.md +++ b/docs/AGENT_SETUP.md @@ -153,13 +153,15 @@ path together. ## Cursor -Create or merge `.cursor/mcp.json` in the workspace: +Create or merge `/.cursor/mcp.json`. Do not put this Code System Graph entry in +`~/.cursor/mcp.json`; the configuration-file location is what keeps the server scoped to this +workspace. An absolute database path identifies the snapshot but does not make the server global. ```json { "mcpServers": { "code-system-graph": { - "command": "csgraph", + "command": "/absolute/path/to/bin/csgraph", "args": [ "mcp", "--codegraph", @@ -173,7 +175,11 @@ Create or merge `.cursor/mcp.json` in the workspace: } ``` -Open Cursor's MCP settings and confirm that the server and its tools are enabled. +`--codegraph` makes this server advertise `explore`; `csgraph mcp` starts the CodeGraph provider, +so do not run `codegraph install` for this setup. Reload the Cursor window or restart Cursor after +changing the file, then open MCP settings and confirm that `code-system-graph` and its tools are +enabled. A separate global CodeGraph MCP entry would expose repository-local tools in every Cursor +project and is not needed for this integration. ## Install optional routing @@ -266,5 +272,9 @@ If the server is absent or disconnected: 5. rerun the agent's MCP list/status command after changing configuration; 6. rerun `csgraph status` to check database freshness. +For Cursor, also confirm that the entry is in `/.cursor/mcp.json`, reload the window, +and enable the server in Cursor's MCP settings. See [Troubleshooting](TROUBLESHOOTING.md) for +extraction-budget and workspace-scope examples. + Agent configuration is not removed when the `csgraph` binary is uninstalled. Remove MCP registration and Code System Graph-owned hooks first. diff --git a/docs/CODEGRAPH_INTEGRATION.md b/docs/CODEGRAPH_INTEGRATION.md index fc3dfa8..e192338 100644 --- a/docs/CODEGRAPH_INTEGRATION.md +++ b/docs/CODEGRAPH_INTEGRATION.md @@ -109,6 +109,18 @@ CodeGraph is disabled for MCP and HTTP unless the trusted process enables it wit selects a custom executable. When disabled, MCP does not advertise `explore` and HTTP returns `403 codegraph_disabled`. +### Cursor workspace scope + +Cursor should keep this server in `/.cursor/mcp.json`, not `~/.cursor/mcp.json`. Use +absolute paths for both the `csgraph` executable and database, but remember that the MCP config +location controls Cursor scope; an absolute database path does not make a server global. After +editing the file, reload the Cursor window or restart Cursor and enable the server in MCP settings. + +Do not run `codegraph install`: `csgraph mcp --codegraph` starts the provider itself and advertises +`explore`. Avoid a separate global CodeGraph MCP entry unless you intentionally want +repository-local CodeGraph tools exposed in every Cursor project. The complete JSON example is in +[Agent setup](AGENT_SETUP.md#cursor). + ## Verify the setup ```bash @@ -136,9 +148,11 @@ Compatible CLI fallback uses direct process arguments and machine-readable JSON Code System Graph never reads `.codegraph/codegraph.db`, never treats a CodeGraph node identifier as a global identity, and never persists returned source. Normal scans, MCP, and HTTP never initialize, -synchronize, install, or upgrade CodeGraph automatically. The explicit `csgraph sync` command is the -only exception for synchronization: it invokes the public `codegraph sync --quiet ` command -for indexes that are already initialized, before using the normal watcher-free MCP adapter. +synchronize, install, or upgrade CodeGraph automatically. The explicit `csgraph sync` command is +the only exception for synchronization: it reads structured status for initialized indexes and +invokes `codegraph sync --quiet ` only when pending files, a worktree mismatch, incomplete +state, or a reindex recommendation makes an index stale. Current indexes are reported as successful +and unchanged before the normal watcher-free scan path. ## Code System Graph delivery surfaces diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9559905..c0ed4f4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -266,6 +266,20 @@ batch reuse. If budgets changed, a scan restricted with `--repo` fails early and scan so one snapshot cannot mix global policies. Any exhausted budget aborts before publication; the previous snapshot remains current and queryable. +If one legitimate metadata value exceeds the default 65,536-byte per-value string limit, raise +only that ceiling conservatively in the workspace manifest: + +```yaml +extractionBudgets: + maxStringBytesPerValue: 131072 +``` + +This is a positive-integer, workspace-wide ceiling applied independently to each extracted string +value. It does not raise `maxAccumulatedStringBytesPerArtifact`, which bounds all strings produced +by one artifact-extractor invocation, or `maxSerializedOutputBytesPerArtifact`, which bounds the +complete encoded batch. Prefer the smallest practical increase and change the other limits only +when the reported resource names them explicitly. See [Troubleshooting](TROUBLESHOOTING.md). + Supervised execution policy is also global and operator-owned. It bounds the lifetime and resident memory of each fresh scan or sync worker, and makes a foreground watcher expire even when changes continue indefinitely. It does not cap repositories, files, graph nodes, graph edges, or the active diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index a3fa653..bb9ed03 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -5,7 +5,7 @@ for maintainers are in [Release engineering](RELEASE.md). ## Current availability -Code System Graph `1.0.1` is published on [crates.io](https://crates.io/crates/code-system-graph) +Code System Graph `1.0.2` is published on [crates.io](https://crates.io/crates/code-system-graph) and [GitHub Releases](https://github.com/dertin/code-system-graph/releases). Native release CI validates Linux x86_64/ARM64, macOS x86_64/ARM64, and Windows x86_64 before their archives are published. @@ -84,8 +84,8 @@ For a Linux x86_64 archive: ```bash sha256sum --ignore-missing --check SHA256SUMS -tar -xzf code-system-graph-x86_64-unknown-linux-gnu-v1.0.1.tgz -PREFIX="$HOME/.local" ./code-system-graph-x86_64-unknown-linux-gnu-v1.0.1/install.sh +tar -xzf code-system-graph-x86_64-unknown-linux-gnu-v1.0.2.tgz +PREFIX="$HOME/.local" ./code-system-graph-x86_64-unknown-linux-gnu-v1.0.2/install.sh ``` Replace the target in the archive name with `x86_64-apple-darwin`, @@ -93,7 +93,7 @@ Replace the target in the archive name with `x86_64-apple-darwin`, same installer. The Windows archive is a ZIP file. Verify `SHA256SUMS`, extract -`code-system-graph-x86_64-pc-windows-msvc-v1.0.1.zip`, and add its `bin` directory containing +`code-system-graph-x86_64-pc-windows-msvc-v1.0.2.zip`, and add its `bin` directory containing `csgraph.exe` and `code-system-graph-hooks.exe` to `PATH`. `PREFIX` defaults to `$HOME/.local`. The installer places binaries under `$PREFIX/bin`, installed @@ -171,7 +171,7 @@ cargo uninstall code-system-graph-hooks Run `uninstall.sh` from the verified extracted package with the same prefix: ```bash -PREFIX="$HOME/.local" ./code-system-graph-x86_64-unknown-linux-gnu-v1.0.1/uninstall.sh +PREFIX="$HOME/.local" ./code-system-graph-x86_64-unknown-linux-gnu-v1.0.2/uninstall.sh ``` Before uninstalling either installation type, remove any optional agent hooks: diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 5473b92..b1e8fb2 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -97,6 +97,6 @@ includes discovery, fingerprinting, linking, community analysis, and SQLite publ community algorithms. The canonical repository is `https://github.com/dertin/code-system-graph`. Linux x86_64 has native -workload evidence; cross-platform workflow jobs are configured but macOS, Windows, and Linux ARM64 -lack native release validation. No performance result is claimed for those targets until native -validation completes. +performance-workload evidence. Release CI validates supported Linux, macOS, and Windows targets, +but those platform checks do not establish performance characteristics. No performance result is +claimed for targets without equivalent measured workload evidence. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index c89bbe4..3d11b6e 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,7 +1,9 @@ -# Code System Graph 1.0.1 Release +# Code System Graph 1.0.2 Release -Code System Graph 1.0.1 is the first cross-platform maintenance release. The source tree and -package version are `1.0.1`. Continuous integration runs on GitHub at +Code System Graph 1.0.2 is a performance and reliability maintenance release. It avoids +republishing unchanged CodeGraph-backed snapshots, bounds focused corroboration work, preserves +ambiguous HTTP evidence as degradations, and improves supervised-worker diagnostics. The source +tree and package version are `1.0.2`. Continuous integration runs on GitHub at `https://github.com/dertin/code-system-graph`. Platform claims below require native build, test, packaging, and archive-smoke evidence from the @@ -81,8 +83,8 @@ GNU tar, and SHA-256 tooling. The workspace MSRV remains 1.97.1 and is validated ```text SOURCE_DATE_EPOCH=0 scripts/package-release.sh x86_64-unknown-linux-gnu -scripts/smoke-install.sh dist/code-system-graph-x86_64-unknown-linux-gnu-v1.0.1 -sha256sum --check dist/code-system-graph-x86_64-unknown-linux-gnu-v1.0.1.sha256 +scripts/smoke-install.sh dist/code-system-graph-x86_64-unknown-linux-gnu-v1.0.2 +sha256sum --check dist/code-system-graph-x86_64-unknown-linux-gnu-v1.0.2.sha256 ``` The package contains `csgraph`, `code-system-graph-hooks`, public documentation, license and notice files, @@ -115,7 +117,7 @@ clean `main` branch aligned with `origin/main`, Cargo credentials for crates.io, selects `prepare`: ```text -.github/workflows/release.sh 1.0.1 prepare +.github/workflows/release.sh 1.0.2 prepare ``` Preparation runs the complete publish-readiness suite and dry-runs all five packages without @@ -123,7 +125,7 @@ creating a tag, publishing a crate, or dispatching a workflow. To perform the ir pass `publish` explicitly: ```text -.github/workflows/release.sh 1.0.1 publish +.github/workflows/release.sh 1.0.2 publish ``` Publish mode verifies that the workspace repository matches `origin`, creates and pushes the diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..8449081 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,51 @@ +# Troubleshooting + +Start with the conservative, source-free diagnostic: + +```bash +csgraph doctor \ + --config code-system-graph.yaml \ + --database .code-system-graph/code-system-graph.db +``` + +## Cursor does not show the server or `explore` + +Keep the MCP entry in `/.cursor/mcp.json`, not `~/.cursor/mcp.json`. Use absolute paths +for the `csgraph` executable and database. The config-file location controls Cursor scope; an +absolute database path alone does not make the server global. + +After changing the file, reload the Cursor window or restart Cursor, then enable +`code-system-graph` in MCP settings. The command must include `--codegraph` for the server to +advertise `explore`. Do not run `codegraph install`: Code System Graph starts the provider itself. +A separate global CodeGraph entry would expose repository-local tools in every project. + +See [Agent setup](AGENT_SETUP.md#cursor) for the complete JSON. + +## One extracted string exceeds its budget + +The safe default for `maxStringBytesPerValue` is 65,536 bytes. If a legitimate metadata value is +larger, set the smallest practical positive integer in the trusted workspace manifest: + +```yaml +extractionBudgets: + maxStringBytesPerValue: 131072 +``` + +This ceiling applies workspace-wide but independently to each string value. It is different from +`maxAccumulatedStringBytesPerArtifact`, which limits all strings accumulated by one +artifact-extractor invocation, and `maxSerializedOutputBytesPerArtifact`, which limits that +invocation's complete encoded output. Raise only the resource named by the diagnostic. A budget +change invalidates extractor-batch reuse and requires a full scan rather than `--repo`. + +For all fields and trust-boundary rules, see [Configuration](CONFIGURATION.md#security-budgets). + +## Common checks + +| Symptom | Check | +| --- | --- | +| `csgraph` is not found | Use an absolute executable path or add its installation directory to `PATH` | +| Database cannot be opened | Create its parent directory and check permissions | +| Agent shows no tools | Verify its workspace-local MCP config, reload it, and check MCP settings | +| Results are stale | Run `csgraph sync`, then `csgraph status` | +| A relationship is missing | Check ambiguity degradations and exact canonical contract identities | +| Local source detail is unavailable | Verify the `codegraph` executable is available and its index is initialized, current, and enabled with `--codegraph` | diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index ff3f399..dba4152 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -12,8 +12,8 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4.13" -code-system-graph-core = { version = "1.0.1", path = "../crates/code-system-graph-core" } -code-system-graph-model = { version = "1.0.1", path = "../crates/code-system-graph-model" } +code-system-graph-core = { version = "1.0.2", path = "../crates/code-system-graph-core" } +code-system-graph-model = { version = "1.0.2", path = "../crates/code-system-graph-model" } serde_json = "1.0.151" [[bin]] diff --git a/scripts/validate-publish-ready.sh b/scripts/validate-publish-ready.sh index 1620564..c83b2b8 100755 --- a/scripts/validate-publish-ready.sh +++ b/scripts/validate-publish-ready.sh @@ -39,7 +39,7 @@ fi "$ROOT/scripts/smoke-install.sh" "$PACKAGE_DIR" if rg -n -i \ - 'phase[ _-]?[0-9]+|fase[ _-]?[0-9]+|PLAN\.md|STATUS\.md|PROMPT-(GPT|RESUME)|schema v[2-9][0-9]*|3700fe3|93c0078|/opt/procesador' \ + 'phase[ _-]?[0-9]+|fase[ _-]?[0-9]+|PLAN\.md|STATUS\.md|PROMPT-(GPT|RESUME)|schema v[2-9][0-9]*|3700fe3|93c0078|/opt/[[:alnum:]_.-]+' \ "$ROOT" \ --glob '!target/**' \ --glob '!dist/**' \