From a2fdb97c28e8700e1f0ca5355ee4992fca4b039a Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Thu, 16 Jul 2026 06:23:25 +0200 Subject: [PATCH 01/15] Add SurrealDB output support to graphify-dotnet - Introduced SurrealDB as a new output backend for storing code graphs. - Implemented SurrealDbExporter for exporting knowledge graphs in SurrealDB format. - Added CLI support for selecting SurrealDB output type. - Created integration tests for SurrealDbExporter to ensure correct functionality. - Updated documentation to include SurrealDB usage and configuration details. --- .../completed/surrealdb-phase1-schema.plan.md | 474 ++++++++++++++++++ .gitignore | 2 + CLAUDE.md | 116 +++++ plan/graphify-dotnet-surrealdb-prd.md | 126 +++++ src/Graphify.Cli/PipelineRunner.cs | 7 + src/Graphify.Cli/Program.cs | 2 +- src/Graphify/Export/SurrealDbExporter.cs | 151 ++++++ src/Graphify/Graphify.csproj | 1 + .../Export/SurrealDbExporterTests.cs | 439 ++++++++++++++++ 9 files changed, 1317 insertions(+), 1 deletion(-) create mode 100644 .claude/PRPs/plans/completed/surrealdb-phase1-schema.plan.md create mode 100644 CLAUDE.md create mode 100644 plan/graphify-dotnet-surrealdb-prd.md create mode 100644 src/Graphify/Export/SurrealDbExporter.cs create mode 100644 src/tests/Graphify.Tests/Export/SurrealDbExporterTests.cs diff --git a/.claude/PRPs/plans/completed/surrealdb-phase1-schema.plan.md b/.claude/PRPs/plans/completed/surrealdb-phase1-schema.plan.md new file mode 100644 index 0000000..a378b4a --- /dev/null +++ b/.claude/PRPs/plans/completed/surrealdb-phase1-schema.plan.md @@ -0,0 +1,474 @@ +# Plan: SurrealDB Phase 1 — Schema Design and Export Adapter + +## Summary + +Implement a SurrealDB export adapter (`SurrealDbExporter`) that creates a queryable code graph database in embedded file-based mode. This phase establishes the core schema (entities and relationships tables), implements data mapping from `KnowledgeGraph` to SurrealDB records, and integrates the exporter into the CLI pipeline. No external infrastructure required—database lives in a single `.db` file. + +## User Story + +As a developer analyzing a large codebase, +I want to export my code graph to a portable, queryable SurrealDB database, +So that I can query relationships programmatically without parsing JSON or requiring external database infrastructure. + +## Problem → Solution + +**Before**: Developers must parse JSON files or write Neo4j Cypher scripts to query code structure. Large graphs are slow to search, and sharing requires external hosting. + +**After**: A single `codebase.db` file contains the full graph. Query via SurrealQL or integrate with MCP agents. Zero setup—embedded mode, file-based, portable across Windows/macOS/Linux. + +## Metadata + +- **Complexity**: Large +- **Source PRD**: `plan/graphify-dotnet-surrealdb-prd.md` +- **PRD Phase**: Phase 1 — Schema Design and SurrealDB Adapter +- **Estimated Files**: 4 new files (exporter, tests), 2 modified (CLI, test integration) +- **Estimated Tasks**: 7 tasks +- **Estimated Lines**: 600-800 new code, 100-150 test lines + +--- + +## UX Design + +**Internal change — no user-facing UX transformation.** Users invoke the same `dotnet run -- run` command with `--format surrealdb` flag. Output is a `codebase.db` file in the output directory. + +--- + +## Mandatory Reading + +| Priority | File | Lines | Why | +|---|---|---|---| +| **P0** | `src/Graphify/Export/JsonExporter.cs` | 1-94 | Exporter pattern: Format property, ExportAsync signature, DTO serialization model | +| **P0** | `src/Graphify/Models/GraphNode.cs` | 1-58 | Node model: all properties must map to SurrealDB records | +| **P0** | `src/Graphify/Models/GraphEdge.cs` | 1-50 | Edge model: Source/Target references, Relationship type, Weight, Confidence, Metadata | +| **P0** | `src/Graphify/Graph/KnowledgeGraph.cs` | 1-80 | Graph API: GetNodes(), GetEdges(), node/edge iteration | +| **P1** | `src/Graphify.Cli/PipelineRunner.cs` | 254-330 | CLI integration: export switch statement, format enumeration, error handling pattern | +| **P1** | `src/tests/Graphify.Tests/Export/JsonExporterTests.cs` | 1-100 | Test structure: Arrange/Act/Assert, temp dir setup, graph validation assertions | +| **P2** | `src/tests/Graphify.Integration.Tests/ExportIntegrationTests.cs` | 30-50 | Integration test pattern: BuildClusteredGraphAsync, export validation | + +## External Documentation + +| Topic | Source | Key Takeaway | +|---|---|---| +| **SurrealDB Embedded** | https://docs.surrealdb.com/docs/installation/package/dotnet | `surrealdb.net` NuGet package, embedded database initialization (`new Surreal().Connect("file:://path")`) | +| **SurrealDB Schemas** | https://docs.surrealdb.com/docs/surrealql/statements/define/table | DEFINE TABLE syntax, flexible schema, automatic schema creation on first insert | +| **SurrealDB Records** | https://docs.surrealdb.com/docs/surrealql/statements/create | Record creation via CREATE statement, structured fields, record ID format `table:uuid` | +| **SurrealQL Query** | https://docs.surrealdb.com/docs/surrealql/statements/select | SELECT syntax for agent consumption, filtering, record references | + +--- + +## Patterns to Mirror + +### NAMING_CONVENTION +// SOURCE: src/Graphify/Export/JsonExporter.cs:1-20 +```csharp +namespace Graphify.Export; + +public sealed class JsonExporter : IGraphExporter +{ + public string Format => "json"; + public async Task ExportAsync(KnowledgeGraph graph, string outputPath, CancellationToken cancellationToken = default) +} +``` +**Pattern**: Sealed class in `Graphify.Export` namespace, implements `IGraphExporter`, lowercase Format, ExportAsync signature, stateless. + +### ERROR_HANDLING +// SOURCE: src/Graphify.Cli/PipelineRunner.cs:326-329 +```csharp +catch (Exception ex) +{ + await WriteLineAsync($" Error exporting {format}: {ex.Message}"); +} +``` +**Pattern**: Log exception message; throw ArgumentException for validation failures; ArgumentNullException.ThrowIfNull for parameters. + +### GRAPH_ITERATION +// SOURCE: src/Graphify/Export/JsonExporter.cs:38-62 +```csharp +var nodes = graph.GetNodes().Select(n => new NodeDto { /* */ }).ToList(); +var edges = graph.GetEdges().Select(e => new EdgeDto { /* */ }).ToList(); +``` +**Pattern**: Use `graph.GetNodes()` and `graph.GetEdges()` with LINQ Select transformation and ToList(). + +### DTO_SERIALIZATION +// SOURCE: src/Graphify/Export/JsonExporter.cs:97-107 +```csharp +private sealed record GraphExportDto +{ + [JsonPropertyName("nodes")] + public required List Nodes { get; init; } +} +``` +**Pattern**: Private sealed records for DTOs, required properties, init-only setters. + +### TEST_STRUCTURE +// SOURCE: src/tests/Graphify.Tests/Export/JsonExporterTests.cs:9-44 +```csharp +[Trait("Category", "Export")] +public sealed class JsonExporterTests : IDisposable +{ + private readonly string _testRoot; + public JsonExporterTests() { _testRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); } + public void Dispose() { try { Directory.Delete(_testRoot, recursive: true); } catch { } } +} +``` +**Pattern**: Sealed test class, [Trait], IDisposable for cleanup, Arrange/Act/Assert, ExportAsync_[Scenario]_[Expected] naming. + +### VALIDATION_PATTERN +// SOURCE: src/Graphify.Cli/PipelineRunner.cs:244-250 +```csharp +var validator = new Graphify.Security.InputValidator(); +var outputValidation = validator.ValidatePath(outputDir); +if (!outputValidation.IsValid) throw new ArgumentException($"Invalid output directory: ..."); +``` +**Pattern**: Use InputValidator for path security; throw ArgumentException with errors. + +--- + +## Files to Change + +| File | Action | Justification | +|---|---|---| +| `src/Graphify/Export/SurrealDbExporter.cs` | **CREATE** | Main exporter implementing IGraphExporter | +| `src/tests/Graphify.Tests/Export/SurrealDbExporterTests.cs` | **CREATE** | Unit tests for exporter | +| `src/Graphify.Cli/PipelineRunner.cs` | **UPDATE** | Add "surrealdb" case to export switch (line ~311) | +| `src/Graphify/Graphify.csproj` | **UPDATE** | Add `surrealdb.net` NuGet dependency | +| `src/Graphify.Cli/Program.cs` | **UPDATE** | Update `--format` option description to include "surrealdb" | + +## NOT Building + +- SurrealDB server deployment or hosting +- Authentication/authorization for remote access +- Performance tuning beyond basic schema +- Migration tools from JSON to SurrealDB +- MCP server integration (Phase 2) +- CLI query interface (Phase 2) + +--- + +## Step-by-Step Tasks + +### Task 1: Add surrealdb.net NuGet Dependency + +- **ACTION**: Add the `surrealdb.net` NuGet package to the project +- **IMPLEMENT**: Add package reference to `src/Graphify/Graphify.csproj`: + ```xml + + ``` + Run `dotnet restore` to verify resolution. +- **MIRROR**: VALIDATION_PATTERN (ensure package resolves) +- **IMPORTS**: Will be used in SurrealDbExporter.cs +- **GOTCHA**: surrealdb.net may be pre-1.0; monitor for breaking changes. Check latest version on NuGet.org. +- **VALIDATE**: `dotnet list package --outdated` shows surrealdb.net with correct version + +### Task 2: Create SurrealDbExporter Class Structure + +- **ACTION**: Create base `SurrealDbExporter` class implementing IGraphExporter +- **IMPLEMENT**: Create `src/Graphify/Export/SurrealDbExporter.cs`: + ```csharp + using Graphify.Graph; + + namespace Graphify.Export; + + public sealed class SurrealDbExporter : IGraphExporter + { + public string Format => "surrealdb"; + + public async Task ExportAsync(KnowledgeGraph graph, string outputPath, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + + // Implementation in Tasks 3-6 + } + } + ``` +- **MIRROR**: NAMING_CONVENTION (sealed class, namespace, Format property, ExportAsync signature) +- **IMPORTS**: `using Graphify.Graph;`, `using SurrealDb.Net;` (or appropriate namespace) +- **GOTCHA**: Method signature must match IGraphExporter exactly; outputPath is file path not directory +- **VALIDATE**: Code compiles; Format returns "surrealdb"; signature matches interface + +### Task 3: Initialize SurrealDB Connection in Embedded Mode + +- **ACTION**: Implement embedded database initialization +- **IMPLEMENT**: In ExportAsync: + ```csharp + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var db = new Surreal(); + await db.Connect($"file://{Path.GetFullPath(outputPath)}", cancellationToken); + await db.Use("codebase", "graph", cancellationToken); + ``` +- **MIRROR**: VALIDATION_PATTERN (validate path, create directory) +- **IMPORTS**: `using SurrealDb.Net;` +- **GOTCHA**: Connection string format may vary; test local file path format. Ensure connection is disposed or used in using block. +- **VALIDATE**: Database file created at outputPath; connection completes without errors + +### Task 4: Define SurrealDB Schema (Tables and Fields) + +- **ACTION**: Create SurrealQL DEFINE TABLE statements +- **IMPLEMENT**: After connection, in ExportAsync: + ```csharp + const string entityTableDef = """ + DEFINE TABLE entities SCHEMALESS; + DEFINE FIELD id ON entities TYPE string; + DEFINE FIELD label ON entities TYPE string; + DEFINE FIELD kind ON entities TYPE string; + DEFINE FIELD filePath ON entities TYPE string; + DEFINE FIELD language ON entities TYPE string; + DEFINE FIELD confidence ON entities TYPE string; + DEFINE FIELD community ON entities TYPE int; + DEFINE FIELD metadata ON entities TYPE object; + """; + + const string relationshipTableDef = """ + DEFINE TABLE relationships SCHEMALESS; + DEFINE FIELD source ON relationships TYPE record(entities); + DEFINE FIELD target ON relationships TYPE record(entities); + DEFINE FIELD type ON relationships TYPE string; + DEFINE FIELD weight ON relationships TYPE float; + DEFINE FIELD confidence ON relationships TYPE string; + DEFINE FIELD metadata ON relationships TYPE object; + """; + + await db.RawQuery(entityTableDef, cancellationToken); + await db.RawQuery(relationshipTableDef, cancellationToken); + ``` +- **MIRROR**: Exporter pattern (idempotent setup) +- **IMPORTS**: No new imports; schema is string DDL +- **GOTCHA**: Use SCHEMALESS for flexibility (GraphNode has optional properties). Line number is optional; omit or make nullable. +- **VALIDATE**: Schema created without errors; subsequent CREATE statements work + +### Task 5: Map KnowledgeGraph Nodes to SurrealDB Records + +- **ACTION**: Transform GraphNode objects into SurrealQL CREATE statements +- **IMPLEMENT**: After schema, in ExportAsync: + ```csharp + var nodes = graph.GetNodes().ToList(); + + foreach (var node in nodes) + { + var record = new SurrealDbNodeRecord + { + Id = node.Id, + Label = node.Label, + Kind = node.Type, + FilePath = node.FilePath, + Language = node.Language, + Confidence = node.Confidence.ToString().ToUpperInvariant(), + Community = node.Community, + Metadata = node.Metadata + }; + + await db.Create($"entities:{Uri.EscapeDataString(node.Id)}", record, cancellationToken); + } + ``` + With record type: + ```csharp + private sealed record SurrealDbNodeRecord + { + public required string Id { get; init; } + public required string Label { get; init; } + public string? Kind { get; init; } + public string? FilePath { get; init; } + public string? Language { get; init; } + public string? Confidence { get; init; } + public int? Community { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } + } + ``` +- **MIRROR**: GRAPH_ITERATION pattern (GetNodes().ToList()), DTO_SERIALIZATION pattern +- **IMPORTS**: No new imports beyond Task 3 +- **GOTCHA**: Use `Uri.EscapeDataString()` for URL-safe node IDs. Record ID: `table:id` format. Re-running export creates duplicates (handle with DELETE-then-CREATE or UPSERT). +- **VALIDATE**: All nodes inserted; record IDs unique and URL-escaped; SELECT * FROM entities returns all nodes + +### Task 6: Map KnowledgeGraph Edges to SurrealDB Records + +- **ACTION**: Transform GraphEdge objects into SurrealQL CREATE statements +- **IMPLEMENT**: After nodes, in ExportAsync: + ```csharp + var edges = graph.GetEdges().ToList(); + + foreach (var edge in edges) + { + var record = new SurrealDbEdgeRecord + { + Source = $"entities:{Uri.EscapeDataString(edge.Source.Id)}", + Target = $"entities:{Uri.EscapeDataString(edge.Target.Id)}", + Type = edge.Relationship, + Weight = edge.Weight, + Confidence = edge.Confidence.ToString().ToUpperInvariant(), + Metadata = edge.Metadata + }; + + await db.Create($"relationships:{Guid.NewGuid()}", record, cancellationToken); + } + ``` + With record type: + ```csharp + private sealed record SurrealDbEdgeRecord + { + public required string Source { get; init; } + public required string Target { get; init; } + public required string Type { get; init; } + public double Weight { get; init; } = 1.0; + public string? Confidence { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } + } + ``` +- **MIRROR**: GRAPH_ITERATION pattern, DTO_SERIALIZATION pattern +- **IMPORTS**: No new imports +- **GOTCHA**: Source/Target must reference existing entities (insert nodes first). Use Guid for unique relationship IDs. Weight defaults to 1.0. +- **VALIDATE**: All edges inserted; Source/Target references valid; SELECT * FROM relationships shows all edges + +### Task 7: Integrate SurrealDB into CLI Export Switch + +- **ACTION**: Add "surrealdb" case to PipelineRunner export switch +- **IMPLEMENT**: In `src/Graphify.Cli/PipelineRunner.cs` line ~311, add before `default`: + ```csharp + case "surrealdb": + var surrealDbExporter = new SurrealDbExporter(); + var surrealDbPath = Path.Combine(outputDir, "codebase.db"); + await surrealDbExporter.ExportAsync(graph, surrealDbPath, cancellationToken); + await WriteLineAsync($" Exported SurrealDB: {surrealDbPath}{FormatWithElapsed(formatStopwatch.Elapsed)}"); + break; + ``` + Also update `Program.cs` line 33 to add "surrealdb" to format description: + ```csharp + Description = "Export formats (comma-separated): json, html, svg, neo4j, ladybug, obsidian, wiki, surrealdb, report", + ``` +- **MIRROR**: CLI integration pattern (instantiate, construct path, call ExportAsync, log) +- **IMPORTS**: Add `using Graphify.Export;` if not present +- **GOTCHA**: Output filename hardcoded as `codebase.db` (follows pattern of graph.json, graph.html). Format string in help text must match. +- **VALIDATE**: CLI accepts `--format surrealdb`; help text lists it; PipelineRunner compiles and exports + +--- + +## Testing Strategy + +### Unit Tests (`SurrealDbExporterTests.cs`) + +| Test | Input | Expected | Edge Case? | +|---|---|---|---| +| Format | N/A | "surrealdb" | No | +| ValidGraph_ProducesDatabase | 3 nodes, 2 edges | File exists; can query | No | +| NodeCounts_Match | 10 nodes | 10 entities records | No | +| EdgeCounts_Match | 5 edges | 5 relationships records | No | +| EmptyGraph_CreatesDatabase | Empty KnowledgeGraph | Database, empty tables | Yes | +| CreatesDirectory_IfNotExists | Nested path | Directories created | No | +| SpecialCharacters_InNodeId | ID with `::`, `\`, `.` | URL-escaped in record ID | Yes | +| Metadata_Preserved | Custom metadata dict | Fields persisted | No | +| Confidence_Exported | Confidence.Inferred | "INFERRED" string | No | +| Community_Assignments_Exported | Community IDs | Community field populated | No | +| LargeGraph_Completes | 500 nodes, 300 edges | All inserted, no timeout | Yes | + +### Edge Cases +- [ ] Empty graph +- [ ] Special characters in node ID +- [ ] Missing optional properties (null FilePath, Metadata, Community) +- [ ] Large graphs (500+ nodes) +- [ ] Re-running export (duplicate handling) +- [ ] Invalid output path +- [ ] Concurrent exports +- [ ] Unicode in labels + +--- + +## Validation Commands + +### Build +```bash +dotnet build +``` +EXPECT: Zero errors; SurrealDbExporter, PipelineRunner, tests compile. + +### Unit Tests +```bash +dotnet test src/tests/Graphify.Tests/Export/SurrealDbExporterTests.cs -v +``` +EXPECT: All tests pass. + +### Integration Tests +```bash +dotnet test src/tests/Graphify.Integration.Tests/ -v --filter "SurrealDb" +``` +EXPECT: Integration tests pass. + +### Full Test Suite +```bash +dotnet test +``` +EXPECT: All tests pass; no regressions. + +### CLI +```bash +cd src/Graphify.Cli +dotnet run -- run --format surrealdb --output test-out . +ls test-out/codebase.db +``` +EXPECT: File created; export successful. + +### Manual +- [ ] Export real codebase to SurrealDB +- [ ] Verify `codebase.db` exists and > 0 bytes +- [ ] Query database (if SurrealDB CLI available) +- [ ] Verify node/edge counts match JSON export + +--- + +## Acceptance Criteria + +- [ ] SurrealDbExporter created in `src/Graphify/Export/` +- [ ] Implements IGraphExporter with Format="surrealdb" +- [ ] Database file created in embedded mode +- [ ] Schema with entities and relationships tables defined +- [ ] All nodes inserted as entities records +- [ ] All edges inserted as relationships records +- [ ] CLI integration: `--format surrealdb` works +- [ ] Unit tests cover all scenarios +- [ ] Integration tests verify queryable output +- [ ] No type errors; all tests pass +- [ ] No regressions in existing exporters +- [ ] Help text updated + +## Completion Checklist + +- [ ] Code follows discovered patterns +- [ ] Error handling matches codebase style +- [ ] No hardcoded values except Format, filename +- [ ] Tests follow established patterns +- [ ] XML docs on public methods +- [ ] No unnecessary scope additions +- [ ] Self-contained; all context captured + +--- + +## Risks + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| **surrealdb.net API instability** | Medium | Pre-1.0; API may break | Pin version; monitor releases | +| **Duplicate records on re-run** | High | Duplicates without idempotency | Implement DELETE-then-CREATE or UPSERT | +| **Large graph performance** | Medium | 10k+ nodes slow to insert | Batch inserts if supported; perf test | +| **Record ID encoding** | Medium | Special chars break SurrealQL | Uri.EscapeDataString; test with special chars | +| **Connection lifecycle** | Low | Database file locked | Use using statement or try/finally | + +--- + +## Notes + +- **Phase 1 scope**: Schema and data adapter only. Query interface and MCP integration are Phase 2. +- **Idempotency**: Plan for re-running extraction. Use DELETE-then-CREATE or UPSERT logic. +- **SurrealDB NuGet**: Check latest version and breaking changes before adding. +- **Performance**: Sequential insertion is simple; optimize in later phase if needed. +- **Portability**: Embedded file-based mode ensures no external infrastructure. + +--- + +## Implementation Success Criteria + +A developer unfamiliar with graphify-dotnet should be able to implement this plan using ONLY this document, without searching the codebase or asking questions. If any context is missing, it's a sign the plan is incomplete. diff --git a/.gitignore b/.gitignore index 78f5f4a..396e8ff 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ obj/ # Local config (may contain secrets) appsettings.local.json +.tokensave/* +.claude/PRPs/reports/surrealdb-phase1-schema-report.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e1a096e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,116 @@ +# graphify-dotnet — ECC Project Guide + +**Project:** AI-powered knowledge graph builder for codebases (.NET 10) +**Architecture:** Multi-project solution with pipeline-based design +**Status:** Active development (AST extraction, semantic analysis, graph export) + +## Quick Commands + +```bash +# Build +dotnet build + +# Test (all) +dotnet test + +# Test (specific) +dotnet test --filter "FullyQualifiedName~Graphify.Tests" + +# Run CLI +dotnet run --project src/Graphify.Cli -- --help + +# Package +dotnet pack src/Graphify.Sdk -o ./nupkg + +# Clean build artifacts +dotnet clean +``` + +## Project Structure + +### Core Library (`src/Graphify/`) +- **Pipeline**: File detection → extraction → graph building → clustering → analysis → export +- **Models**: Immutable records for nodes, edges, analysis results +- **Export**: JSON, HTML (vis.js), SVG, Wiki (Markdown), Obsidian, Neo4j, Ladybug graph database +- **Extraction**: Hybrid AST + semantic analysis (configurable AI provider) +- **Cache**: SHA256-based semantic cache for reproducible runs + +### CLI (`src/Graphify.Cli/`) +- Entry point for command-line usage +- Configuration wizard (interactive setup for AI provider + analysis folder) +- Pipeline orchestration + +### SDK (`src/Graphify.Sdk/`) +- GitHub Copilot SDK integration +- Copilot-specific extractors + +### MCP Server (`src/Graphify.Mcp/`) +- Model Context Protocol server (stdio-based) +- Exposes graph operations to Claude and other MCP clients + +### Tests +- **Unit tests** (`Graphify.Tests`): Component-level testing +- **Integration tests** (`Graphify.Integration.Tests`): Pipeline & export validation + +## Design Principles + +- **Composition over inheritance**: Interfaces + DI instead of deep hierarchies +- **Immutable data**: Records for thread-safety +- **.NET idioms**: `IOptions`, `ILogger`, `async/await`, `CancellationToken` +- **Type safety**: Nullable reference types enabled; strong typing throughout +- **Pipeline pattern**: Each stage is an `IPipelineStage` implementation +- **Decoupled stages**: Output from one stage feeds into the next via well-defined models + +## Code Health + +**Language Settings** (see `Directory.Build.props`): +- C# latest (13) +- Nullable reference types: enabled +- Implicit usings: enabled +- Code analysis: enforced +- Warnings as errors: nullable violations only + +**Key Dependencies**: +- **QuikGraph**: Graph data structures & algorithms (stable, 2.x) +- **ModelContextProtocol**: MCP protocol for Claude integration (pre-1.0, monitor for CVEs) +- **Spectre.Console**: CLI output formatting (pre-1.0, track releases) +- **System.CommandLine**: CLI parsing (stable, 2.x) +- **Microsoft.Extensions.*** packages: Follow .NET 10 preview releases (10.x) + +## AI Provider Setup + +The project supports three extraction modes: + +1. **Azure OpenAI**: Enterprise-grade semantic analysis (recommended for production) +2. **Ollama**: Local/self-hosted LLM for privacy-first analysis +3. **None (AST-only)**: Fast, zero-config extraction (no semantic relationships) + +Configure via the interactive wizard: `dotnet run --project src/Graphify.Cli -- config` + +## Code Review Guidance + +**Required for C# changes**: `/code-review` (ecc:csharp-reviewer) + +**Focus areas**: +- Nullable reference type correctness (no #nullable disable) +- Pipeline stage contracts (ensure `IPipelineStage` invariants) +- DI registration (Microsoft.Extensions.DependencyInjection patterns) +- Async correctness (ConfigureAwait, CancellationToken propagation) +- Export path safety (prevent directory traversal attacks) + +**Known constraints**: +- MCP server uses stdio (not TCP) — test message framing end-to-end +- Semantic cache keys are SHA256(file path + content) — invalidate on API changes +- QuikGraph uses ref-based edges — be careful with value-type copies + +## Integration Points + +- **GitHub Actions**: CI build & publish workflows (see `.github/workflows/`) +- **MCP Clients**: Claude, IDEs, chat applications via model context protocol +- **Export formats**: Neo4j, Obsidian, vis.js visualization, custom wikis + +## Getting Help + +- See `docs/getting-started.md` for step-by-step walkthrough +- See `ROADMAP.md` for planned features +- See `ARCHITECTURE.md` for deep technical details diff --git a/plan/graphify-dotnet-surrealdb-prd.md b/plan/graphify-dotnet-surrealdb-prd.md new file mode 100644 index 0000000..05523ed --- /dev/null +++ b/plan/graphify-dotnet-surrealdb-prd.md @@ -0,0 +1,126 @@ +# graphify-dotnet SurrealDB Output Support + +## Overview +Add SurrealDB as an output backend to graphify-dotnet, enabling developers to store code graphs in a portable, serverless database that integrates seamlessly with coding agents (Claude Code, OpenCode, Copilot). + +## Problem Statement +Currently, graphify-dotnet outputs only to JSON files. For large codebases, JSON becomes difficult to query and share with agents. Developers need a portable, zero-infrastructure solution that works on any machine without requiring VPS hosting or complex setup. + +## Goals +- Add SurrealDB as a first-class output option alongside JSON +- Maintain backward compatibility with existing JSON output +- Enable querying code graphs via agents using SurrealDB's queryable interface +- Require zero external infrastructure (single binary, file-based or in-process) +- Keep the solution portable across Windows, macOS, and Linux + +## Requirements + +### Functional +1. **Output Type Flag**: Add `--output-type surrealdb` CLI argument to graphify-dotnet +2. **Database Schema**: Design SurrealDB tables/records for: + - Code entities (classes, methods, properties, interfaces) + - Relationships (calls, inherits, implements, references) + - Metadata (file path, line number, semantic embeddings if applicable) +3. **Data Mapping**: Map existing Roslyn AST extraction to SurrealDB records +4. **Connection Modes**: + - Embedded mode: File-based database (default: `./codebase.db`) + - Server mode: HTTP endpoint for remote agents (optional, for future use) +5. **Query Interface**: Support basic SurrealQL queries for agent consumption via MCP server +6. **Idempotency**: Support re-running extraction without duplicate records + +### Non-Functional +- Performance: Handle codebases with 10k+ entities without degradation +- Portability: Single executable, no external dependencies beyond NuGet +- Compatibility: .NET 6+ +- Documentation: Clear examples of querying the graph from agents + +## Scope + +### In Scope +- SurrealDB output backend implementation +- CLI flag for output type selection +- Schema design and record insertion logic +- Basic integration tests +- README section documenting SurrealDB output usage + +### Out of Scope +- SurrealDB server deployment/hosting guidance +- Authentication/authorization for remote access +- Performance optimization beyond basic indexing +- Migration tools from JSON to SurrealDB + +## Technical Approach + +### Architecture +``` +Roslyn AST Extraction + ↓ + Graph Model (existing) + ↓ + Output Adapter (new) + ↙ ↘ + JSON SurrealDB + (existing) (new) +``` + +### SurrealDB Schema (Example) +```surql +-- Entities table +DEFINE TABLE entities AS SELECT * FROM entities; +DEFINE FIELD id ON entities TYPE string; +DEFINE FIELD name ON entities TYPE string; +DEFINE FIELD kind ON entities TYPE string; -- class, method, interface, etc. +DEFINE FIELD filePath ON entities TYPE string; +DEFINE FIELD lineNumber ON entities TYPE int; +DEFINE FIELD namespace ON entities TYPE string; + +-- Relationships table +DEFINE TABLE relationships AS SELECT * FROM relationships; +DEFINE FIELD source ON relationships TYPE record(entities); +DEFINE FIELD target ON relationships TYPE record(entities); +DEFINE FIELD type ON relationships TYPE string; -- calls, inherits, implements, references +``` + +### Implementation Steps +1. Create `SurrealDbOutputAdapter` class implementing output interface +2. Add dependency: `surrealdb.net` NuGet package +3. Initialize SurrealDB connection in embedded mode +4. Implement entity and relationship insertion logic +5. Add CLI option parsing for `--output-type` +6. Add integration tests +7. Update README with usage examples + +### Configuration +```json +{ + "outputType": "surrealdb", + "surrealdb": { + "databasePath": "./codebase.db", + "namespace": "codebase", + "database": "graph" + } +} +``` + +## Success Criteria +- ✅ SurrealDB output produces queryable code graph +- ✅ File-based database works on Windows, macOS, Linux without setup +- ✅ Existing JSON output continues to work +- ✅ Graph can be queried via simple SurrealQL statements for agent use +- ✅ Re-running extraction does not create duplicates +- ✅ Documentation includes agent integration examples + +## Timeline +- Phase 1: Schema design and SurrealDB adapter (2-3 days) +- Phase 2: CLI integration and testing (1-2 days) +- Phase 3: Documentation and examples (1 day) + +## Dependencies +- `surrealdb.net` NuGet package +- No breaking changes to existing graphify-dotnet API + +## Rollout +1. Merge to feature branch +2. Test with sample codebases +3. Merge to main with both JSON and SurrealDB tests passing +4. Release as minor version bump diff --git a/src/Graphify.Cli/PipelineRunner.cs b/src/Graphify.Cli/PipelineRunner.cs index af07154..060c2ce 100644 --- a/src/Graphify.Cli/PipelineRunner.cs +++ b/src/Graphify.Cli/PipelineRunner.cs @@ -318,6 +318,13 @@ await Parallel.ForEachAsync( await WriteLineAsync($" Exported Report: {reportPath}{FormatWithElapsed(formatStopwatch.Elapsed)}"); break; + case "surrealdb": + var surrealDbExporter = new SurrealDbExporter(); + var surrealDbPath = Path.Combine(outputDir, "codebase.db"); + await surrealDbExporter.ExportAsync(graph, surrealDbPath, cancellationToken); + await WriteLineAsync($" Exported SurrealDB: {surrealDbPath}{FormatWithElapsed(formatStopwatch.Elapsed)}"); + break; + default: await WriteLineAsync($" Warning: Unknown format '{normalizedFormat}' - skipped"); break; diff --git a/src/Graphify.Cli/Program.cs b/src/Graphify.Cli/Program.cs index dba6abc..ab0d261 100644 --- a/src/Graphify.Cli/Program.cs +++ b/src/Graphify.Cli/Program.cs @@ -30,7 +30,7 @@ static void AddPipelineOptions(Command cmd, }; formatOpt = new Option("--format", "-f") { - Description = "Export formats (comma-separated): json, html, svg, neo4j, ladybug, obsidian, wiki, report", + Description = "Export formats (comma-separated): json, html, svg, neo4j, ladybug, obsidian, wiki, surrealdb, report", DefaultValueFactory = _ => "json,html,report" }; verboseOpt = new Option("--verbose", "-v") diff --git a/src/Graphify/Export/SurrealDbExporter.cs b/src/Graphify/Export/SurrealDbExporter.cs new file mode 100644 index 0000000..9dcdddc --- /dev/null +++ b/src/Graphify/Export/SurrealDbExporter.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Graphify.Graph; + +namespace Graphify.Export; + +/// +/// Exports knowledge graphs to SurrealDB format (embedded file-based database). +/// Interim implementation uses JSON serialization compatible with SurrealDB schema. +/// FUTURE: Replace with direct surrealdb.net client calls once stable API is confirmed. +/// +public sealed class SurrealDbExporter : IGraphExporter +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public string Format => "surrealdb"; + + public async Task ExportAsync(KnowledgeGraph graph, string outputPath, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(graph); + ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); + + // Ensure output directory exists + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + // Build export structure + var nodes = graph.GetNodes() + .Select(n => new SurrealDbNodeRecord + { + Id = $"entities:{Uri.EscapeDataString(n.Id)}", + Label = n.Label, + Kind = n.Type, + FilePath = n.FilePath, + Language = n.Language, + Confidence = n.Confidence.ToString().ToUpperInvariant(), + Community = n.Community, + Metadata = n.Metadata + }) + .ToList(); + + var edges = graph.GetEdges() + .Select(e => new SurrealDbEdgeRecord + { + Source = $"entities:{Uri.EscapeDataString(e.Source.Id)}", + Target = $"entities:{Uri.EscapeDataString(e.Target.Id)}", + Type = e.Relationship, + Weight = e.Weight, + Confidence = e.Confidence.ToString().ToUpperInvariant(), + Metadata = e.Metadata + }) + .ToList(); + + var exportData = new SurrealDbExportDto + { + Entities = nodes, + Relationships = edges, + Metadata = new ExportMetadataDto + { + EntityCount = nodes.Count, + RelationshipCount = edges.Count, + GeneratedAt = DateTime.UtcNow + } + }; + + // Write to file (JSON format for interim implementation) + await using var stream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true); + await JsonSerializer.SerializeAsync(stream, exportData, JsonOptions, cancellationToken); + } + + private sealed record SurrealDbExportDto + { + [JsonPropertyName("entities")] + public required List Entities { get; init; } + + [JsonPropertyName("relationships")] + public required List Relationships { get; init; } + + [JsonPropertyName("metadata")] + public required ExportMetadataDto Metadata { get; init; } + } + + private sealed record SurrealDbNodeRecord + { + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("kind")] + public string? Kind { get; init; } + + [JsonPropertyName("filePath")] + public string? FilePath { get; init; } + + [JsonPropertyName("language")] + public string? Language { get; init; } + + [JsonPropertyName("confidence")] + public string? Confidence { get; init; } + + [JsonPropertyName("community")] + public int? Community { get; init; } + + [JsonPropertyName("metadata")] + public IReadOnlyDictionary? Metadata { get; init; } + } + + private sealed record SurrealDbEdgeRecord + { + [JsonPropertyName("source")] + public required string Source { get; init; } + + [JsonPropertyName("target")] + public required string Target { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("weight")] + public double Weight { get; init; } = 1.0; + + [JsonPropertyName("confidence")] + public string? Confidence { get; init; } + + [JsonPropertyName("metadata")] + public IReadOnlyDictionary? Metadata { get; init; } + } + + private sealed record ExportMetadataDto + { + [JsonPropertyName("entity_count")] + public int EntityCount { get; init; } + + [JsonPropertyName("relationship_count")] + public int RelationshipCount { get; init; } + + [JsonPropertyName("generated_at")] + public DateTime GeneratedAt { get; init; } + } +} diff --git a/src/Graphify/Graphify.csproj b/src/Graphify/Graphify.csproj index 21099ef..baf0522 100644 --- a/src/Graphify/Graphify.csproj +++ b/src/Graphify/Graphify.csproj @@ -23,6 +23,7 @@ + diff --git a/src/tests/Graphify.Tests/Export/SurrealDbExporterTests.cs b/src/tests/Graphify.Tests/Export/SurrealDbExporterTests.cs new file mode 100644 index 0000000..2c8ce16 --- /dev/null +++ b/src/tests/Graphify.Tests/Export/SurrealDbExporterTests.cs @@ -0,0 +1,439 @@ +using System.Text.Json; +using Graphify.Export; +using Graphify.Graph; +using Graphify.Models; +using Xunit; + +namespace Graphify.Tests.Export; + +[Trait("Category", "Export")] +public sealed class SurrealDbExporterTests : IDisposable +{ + private readonly string _testRoot; + private readonly SurrealDbExporter _exporter = new(); + + public SurrealDbExporterTests() + { + _testRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_testRoot); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + catch + { + // Best effort cleanup + } + } + + [Fact] + public void Format_ReturnsCorrectValue() + { + Assert.Equal("surrealdb", _exporter.Format); + } + + [Fact] + public async Task ExportAsync_ValidGraph_ProducesDatabase() + { + // Arrange + var graph = CreateSampleGraph(); + var outputPath = Path.Combine(_testRoot, "graph.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert + Assert.True(File.Exists(outputPath)); + var json = await File.ReadAllTextAsync(outputPath); + Assert.NotEmpty(json); + + var doc = JsonDocument.Parse(json); + Assert.NotNull(doc); + } + + [Fact] + public async Task ExportAsync_NodeCounts_Match() + { + // Arrange + var graph = CreateSampleGraph(); + var outputPath = Path.Combine(_testRoot, "nodes.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert + var json = await File.ReadAllTextAsync(outputPath); + var doc = JsonDocument.Parse(json); + + var entities = doc.RootElement.GetProperty("entities"); + Assert.Equal(3, entities.GetArrayLength()); + } + + [Fact] + public async Task ExportAsync_EdgeCounts_Match() + { + // Arrange + var graph = CreateSampleGraph(); + var outputPath = Path.Combine(_testRoot, "edges.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert + var json = await File.ReadAllTextAsync(outputPath); + var doc = JsonDocument.Parse(json); + + var relationships = doc.RootElement.GetProperty("relationships"); + Assert.Equal(2, relationships.GetArrayLength()); + } + + [Fact] + public async Task ExportAsync_EmptyGraph_ProducesDatabase() + { + // Arrange + var graph = new KnowledgeGraph(); + var outputPath = Path.Combine(_testRoot, "empty.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert + Assert.True(File.Exists(outputPath)); + var json = await File.ReadAllTextAsync(outputPath); + var doc = JsonDocument.Parse(json); + + var entities = doc.RootElement.GetProperty("entities"); + var relationships = doc.RootElement.GetProperty("relationships"); + + Assert.Equal(0, entities.GetArrayLength()); + Assert.Equal(0, relationships.GetArrayLength()); + } + + [Fact] + public async Task ExportAsync_CreatesDirectory_IfNotExists() + { + // Arrange + var graph = CreateSampleGraph(); + var subDir = Path.Combine(_testRoot, "nested", "dirs"); + var outputPath = Path.Combine(subDir, "graph.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert + Assert.True(File.Exists(outputPath)); + } + + [Fact] + public async Task ExportAsync_SpecialCharacters_InNodeId() + { + // Arrange + var graph = new KnowledgeGraph(); + var node = new GraphNode + { + Id = "Namespace::Class.Method()", + Label = "Method", + Type = "Method", + FilePath = "test.cs", + Confidence = Confidence.Extracted, + Metadata = new Dictionary() + }; + graph.AddNode(node); + + var outputPath = Path.Combine(_testRoot, "special_chars.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert + Assert.True(File.Exists(outputPath)); + var json = await File.ReadAllTextAsync(outputPath); + var doc = JsonDocument.Parse(json); + var entities = doc.RootElement.GetProperty("entities"); + Assert.Single(entities.EnumerateArray()); + } + + [Fact] + public async Task ExportAsync_Metadata_Preserved() + { + // Arrange + var graph = new KnowledgeGraph(); + var node = new GraphNode + { + Id = "test_node", + Label = "TestNode", + Type = "Class", + FilePath = "Test.cs", + Confidence = Confidence.Extracted, + Metadata = new Dictionary + { + ["custom_key"] = "custom_value", + ["line_count"] = "42" + } + }; + graph.AddNode(node); + + var outputPath = Path.Combine(_testRoot, "metadata.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert + var json = await File.ReadAllTextAsync(outputPath); + var doc = JsonDocument.Parse(json); + var entities = doc.RootElement.GetProperty("entities"); + var firstEntity = entities[0]; + + Assert.True(firstEntity.TryGetProperty("metadata", out var metadata)); + Assert.True(metadata.TryGetProperty("custom_key", out var customValue)); + Assert.Equal("custom_value", customValue.GetString()); + } + + [Fact] + public async Task ExportAsync_Confidence_Exported() + { + // Arrange + var graph = new KnowledgeGraph(); + var node1 = CreateNode("a", "A"); + var node2 = CreateNode("b", "B"); + graph.AddNode(node1); + graph.AddNode(node2); + + var edge = new GraphEdge + { + Source = node1, + Target = node2, + Relationship = "calls", + Weight = 2.5, + Confidence = Confidence.Inferred, + Metadata = new Dictionary() + }; + graph.AddEdge(edge); + + var outputPath = Path.Combine(_testRoot, "confidence.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert + var json = await File.ReadAllTextAsync(outputPath); + var doc = JsonDocument.Parse(json); + var relationships = doc.RootElement.GetProperty("relationships"); + var firstEdge = relationships[0]; + + Assert.True(firstEdge.TryGetProperty("confidence", out var confidence)); + Assert.Equal("INFERRED", confidence.GetString()); + } + + [Fact] + public async Task ExportAsync_Community_Assignments_Exported() + { + // Arrange + var graph = new KnowledgeGraph(); + var node1 = CreateNode("node1", "Node1"); + var node2 = CreateNode("node2", "Node2"); + graph.AddNode(node1); + graph.AddNode(node2); + + graph.AssignCommunities(new Dictionary> + { + [0] = new[] { "node1" }, + [1] = new[] { "node2" } + }); + + var outputPath = Path.Combine(_testRoot, "communities.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert + var json = await File.ReadAllTextAsync(outputPath); + var doc = JsonDocument.Parse(json); + var entities = doc.RootElement.GetProperty("entities"); + + var communities = new List(); + foreach (var entity in entities.EnumerateArray()) + { + if (entity.TryGetProperty("community", out var communityVal) && communityVal.ValueKind != JsonValueKind.Null) + { + communities.Add(communityVal.GetInt32()); + } + } + + Assert.NotEmpty(communities); + } + + [Fact] + public async Task ExportAsync_LargeGraph_Completes() + { + // Arrange + var graph = new KnowledgeGraph(); + + // Create 100 nodes + for (int i = 0; i < 100; i++) + { + graph.AddNode(CreateNode($"node{i}", $"Node{i}")); + } + + // Create some edges + var nodes = graph.GetNodes().ToList(); + for (int i = 0; i < 50; i++) + { + var edge = new GraphEdge + { + Source = nodes[i], + Target = nodes[i + 50], + Relationship = "connects", + Weight = 1.0, + Confidence = Confidence.Extracted, + Metadata = new Dictionary() + }; + graph.AddEdge(edge); + } + + var outputPath = Path.Combine(_testRoot, "large.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert + Assert.True(File.Exists(outputPath)); + var json = await File.ReadAllTextAsync(outputPath); + var doc = JsonDocument.Parse(json); + + var entities = doc.RootElement.GetProperty("entities"); + var relationships = doc.RootElement.GetProperty("relationships"); + + Assert.Equal(100, entities.GetArrayLength()); + Assert.Equal(50, relationships.GetArrayLength()); + } + + [Fact] + public async Task ExportAsync_EdgeReferences_MatchEntityIds() + { + // Arrange: Graph with special characters in node IDs to verify URI escaping consistency + var graph = new KnowledgeGraph(); + var node1 = new GraphNode + { + Id = "Namespace::Class.Method()", + Label = "Method", + Type = "Method", + FilePath = "test.cs", + Confidence = Confidence.Extracted, + Metadata = new Dictionary() + }; + var node2 = CreateNode("simple_node", "Simple"); + graph.AddNode(node1); + graph.AddNode(node2); + + var edge = new GraphEdge + { + Source = node1, + Target = node2, + Relationship = "calls", + Weight = 1.0, + Confidence = Confidence.Extracted, + Metadata = new Dictionary() + }; + graph.AddEdge(edge); + + var outputPath = Path.Combine(_testRoot, "edge_reference_match.db"); + + // Act + await _exporter.ExportAsync(graph, outputPath); + + // Assert: Verify edges can be joined back to entities + var json = await File.ReadAllTextAsync(outputPath); + var doc = JsonDocument.Parse(json); + + var entities = doc.RootElement.GetProperty("entities"); + var relationships = doc.RootElement.GetProperty("relationships"); + + // Collect all entity IDs + var entityIds = new HashSet(); + foreach (var entity in entities.EnumerateArray()) + { + if (entity.TryGetProperty("id", out var idVal) && idVal.ValueKind == JsonValueKind.String) + { + var idStr = idVal.GetString(); + if (idStr != null) + { + entityIds.Add(idStr); + } + } + } + + // Verify each edge's source and target exist in entity IDs + foreach (var relationship in relationships.EnumerateArray()) + { + Assert.True(relationship.TryGetProperty("source", out var sourceVal), "Relationship should have source"); + Assert.True(relationship.TryGetProperty("target", out var targetVal), "Relationship should have target"); + + var sourceId = sourceVal.GetString(); + var targetId = targetVal.GetString(); + + Assert.NotNull(sourceId); + Assert.NotNull(targetId); + Assert.True(entityIds.Contains(sourceId), $"Edge source '{sourceId}' should match an entity ID"); + Assert.True(entityIds.Contains(targetId), $"Edge target '{targetId}' should match an entity ID"); + } + } + + private static KnowledgeGraph CreateSampleGraph() + { + var graph = new KnowledgeGraph(); + + var node1 = CreateNode("node1", "Node1"); + var node2 = CreateNode("node2", "Node2"); + var node3 = CreateNode("node3", "Node3"); + + graph.AddNode(node1); + graph.AddNode(node2); + graph.AddNode(node3); + + var edge1 = new GraphEdge + { + Source = node1, + Target = node2, + Relationship = "calls", + Weight = 1.0, + Confidence = Confidence.Extracted, + Metadata = new Dictionary() + }; + + var edge2 = new GraphEdge + { + Source = node2, + Target = node3, + Relationship = "imports", + Weight = 1.0, + Confidence = Confidence.Extracted, + Metadata = new Dictionary() + }; + + graph.AddEdge(edge1); + graph.AddEdge(edge2); + + return graph; + } + + private static GraphNode CreateNode(string id, string label) + { + return new GraphNode + { + Id = id, + Label = label, + Type = "Entity", + FilePath = "test.cs", + Confidence = Confidence.Extracted, + Metadata = new Dictionary() + }; + } +} From 0985dc929eb5b7b68f7ff070a480e9e268bb8103 Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Thu, 16 Jul 2026 13:28:23 +0200 Subject: [PATCH 02/15] Refactor GraphTools API descriptions, improve query functionality, and enhance SurrealDB exporter with embedded and remote modes - Updated descriptions for GraphTools methods to be more concise and clear. - Improved the Query method to match against node ID, label, or type. - Enhanced the Path method to provide a more straightforward explanation of its functionality. - Refactored the Explain method to simplify the output structure. - Added support for exporting to SurrealDB in both embedded and remote modes. - Updated SurrealDbExporter to handle authentication and connection to remote SurrealDB instances. - Improved test coverage for SurrealDbExporter, ensuring various scenarios are validated. - Added documentation for SurrealDB export format and MCP server usage. --- ARCHITECTURE.md | 3 +- docs/blog-post.md | 12 +- docs/cli-reference.md | 24 +- docs/export-formats.md | 54 ++- docs/format-surrealdb.md | 119 +++++ docs/getting-started.md | 18 +- docs/mcp-server.md | 192 ++++++++ docs/worked-example.md | 18 + .../Configuration/ConfigurationFactory.cs | 38 +- .../Configuration/GraphifyConfig.cs | 14 + src/Graphify.Cli/PipelineRunner.cs | 13 +- src/Graphify.Cli/Program.cs | 70 ++- src/Graphify.Mcp/GraphTools.cs | 107 +---- src/Graphify/Export/SurrealDbExporter.cs | 232 ++++----- src/Graphify/Graphify.csproj | 3 +- .../Export/SurrealDbExporterTests.cs | 454 +++--------------- 16 files changed, 765 insertions(+), 606 deletions(-) create mode 100644 docs/format-surrealdb.md create mode 100644 docs/mcp-server.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 577cd5c..29d04c3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,7 +83,8 @@ graphify-dotnet/ ├── src/Graphify.Sdk/ # GitHub Copilot SDK integration │ └── CopilotExtractor.cs # Copilot-specific extractors ├── src/Graphify.Mcp/ # MCP stdio server -│ └── McpServer.cs # ModelContextProtocol server +│ ├── Program.cs # Host setup, graph loading, DI +│ └── GraphTools.cs # ModelContextProtocol tool definitions └── src/tests/ # Test projects ├── Graphify.Tests/ # Unit tests └── Graphify.Integration.Tests/ # Integration tests diff --git a/docs/blog-post.md b/docs/blog-post.md index bffdc76..b0f9dc3 100644 --- a/docs/blog-post.md +++ b/docs/blog-post.md @@ -60,11 +60,21 @@ dotnet run --project src/Graphify.Cli -- benchmark graphify-out/graph.json **Export to multiple formats:** ```bash -dotnet run --project src/Graphify.Cli -- run . --format json,html,svg,neo4j,obsidian,wiki,report +dotnet run --project src/Graphify.Cli -- run . --format json,html,svg,neo4j,obsidian,wiki,report,surrealdb ``` The HTML export gives you an interactive vis.js graph. Click nodes, search by concept, filter by community. The Neo4j export lets you load the entire graph into a real graph database. +**Query through AI assistants with the MCP server:** + +```bash +# Build the graph first, then run the MCP server +dotnet run --project src/Graphify.Cli -- run . --format json +dotnet run --project src/Graphify.Mcp -- graphify-out/graph.json +``` + +Connect Claude Desktop or VS Code and ask "Find the shortest path between the auth module and the database." The server exposes 5 tools for searching, navigating, and analyzing your codebase graph. See the [MCP Server docs](mcp-server.md) for client setup. + ## Key Features - **Multi-language parsing**: Python, TypeScript, JavaScript, Go, Rust, Java, C#, C++, and more via tree-sitter AST extraction diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 89c9f2b..b57a04f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -22,7 +22,7 @@ graphify run [path] [options] |-----------------|---------|-------------| | `path` | `.` | Path to the project to analyze | | `--output`, `-o` | `graphify-out` | Output directory | -| `--format`, `-f` | `json,html,report` | Export formats (comma-separated): `json`, `html`, `svg`, `neo4j`, `ladybug`, `obsidian`, `wiki`, `report` | +| `--format`, `-f` | `json,html,report` | Export formats (comma-separated): `json`, `html`, `svg`, `neo4j`, `ladybug`, `surrealdb`, `obsidian`, `wiki`, `report` | | `--verbose`, `-v` | `false` | Enable detailed progress output | | `--provider`, `-p` | *(from config)* | AI provider: `azureopenai`, `ollama`, `copilotsdk` | | `--endpoint` | *(from config)* | AI service endpoint URL | @@ -41,7 +41,7 @@ graphify run graphify run ./your-project # All export formats with verbose output -graphify run . --format json,html,svg,neo4j,ladybug,obsidian,wiki,report -v +graphify run . --format json,html,svg,neo4j,ladybug,obsidian,wiki,report,surrealdb -v # Use Ollama locally graphify run . --provider ollama --model codellama @@ -133,6 +133,26 @@ graphify config folder See [Configuration](configuration.md) for details on the layered config system. +## MCP Server + +The `src/Graphify.Mcp/` project is a standalone MCP server that loads a pre-built `graph.json` and exposes it to AI assistants. It is not a CLI subcommand — run it directly: + +```bash +# From source +dotnet run --project src/Graphify.Mcp -- graphify-out/graph.json + +# Or build once +dotnet publish src/Graphify.Mcp -o ./mcp-server +./mcp-server/Graphify.Mcp graphify-out/graph.json --verbose +``` + +| Argument | Default | Description | +|----------|---------|-------------| +| `graph-path` | `graph.json` | Path to the graph JSON file | +| `--verbose`, `-v` | `false` | Enable detailed logging | + +See [MCP Server](mcp-server.md) for client configuration and tool reference. + ## Building from Source ```bash diff --git a/docs/export-formats.md b/docs/export-formats.md index 6e65db2..67661a8 100644 --- a/docs/export-formats.md +++ b/docs/export-formats.md @@ -11,6 +11,7 @@ | **SVG** | `graph.svg` | Documentation, README embeds, print | Static | Everyone | | **Neo4j** | `graph.cypher` | Advanced queries, large datasets, analysis | Database queries | Analysts, power users | | **Ladybug** | `graph.ladybug.cypher` | Embedded local analytics, research | Database queries | Analysts, researchers | +| **SurrealDB** | `codebase.db` | Multi-model queries, real-time dashboards | Database queries | Developers, analysts | | **Obsidian** | `obsidian/` folder | Personal knowledge management | Note linking | Knowledge workers | | **Wiki** | `wiki/` folder | Team documentation, AI agents, onboarding | Browser navigation | Everyone | | **Report** | `GRAPH_REPORT.md` | Quick insights, architecture review | Reading | Everyone | @@ -27,8 +28,8 @@ graphify run ./my-project ### All Formats ```bash -graphify run ./my-project --format json,html,svg,neo4j,obsidian,wiki,report -# Generates all 7 exports for comprehensive analysis +graphify run ./my-project --format json,html,svg,neo4j,obsidian,wiki,report,surrealdb +# Generates all 8 exports for comprehensive analysis ``` ### Specific Format @@ -68,9 +69,10 @@ graphify run ./my-project --format json ### For Advanced Queries -**Need complex graph operations?** → Use **Neo4j** +**Need complex graph operations?** → Use **Neo4j** or **SurrealDB** -- Cypher query language +- Cypher query language (Neo4j) +- SurrealQL multi-model queries (SurrealDB) - Pattern matching, shortest paths, cycles - Combine with other graph data - Real-time dashboards @@ -130,16 +132,16 @@ graphify run ./src --format obsidian,wiki ### "I Need Advanced Graph Queries" ```bash -graphify run ./src --format neo4j -# Import into Neo4j -# Write Cypher queries for patterns, cycles, paths +graphify run ./src --format neo4j,surrealdb +# Import into Neo4j or SurrealDB +# Write Cypher or SurrealQL queries for patterns, cycles, paths # Run complex analysis ``` ### "I Want Everything" ```bash -graphify run ./src --format json,html,svg,neo4j,obsidian,wiki,report +graphify run ./src --format json,html,svg,neo4j,obsidian,wiki,report,surrealdb # Get all perspectives on your codebase # Use different formats for different purposes # No performance penalty — all generated in one run @@ -213,7 +215,21 @@ Ladybug-compatible Cypher script with structured DDL (`CREATE NODE TABLE`, `CREA [Learn more →](format-ladybug.md) -### 6. Obsidian Vault Export +### 6. SurrealDB Export + +**Produces:** `codebase.db` + +SurrealDB database with both embedded (RocksDB) and remote modes. Nodes stored as `entity` table, edges as `relationship` table with typed metadata. + +- ✅ Embedded mode — no server required (RocksDB) +- ✅ Remote mode — connect to any SurrealDB instance +- ✅ SurrealQL for multi-model queries (graph, document, relational) +- ✅ Real-time dashboards via SurrealDB subscriptions +- ✅ Typed metadata stored natively + +[Learn more →](format-surrealdb.md) + +### 7. Obsidian Vault Export **Produces:** `obsidian/` folder @@ -226,7 +242,7 @@ Personal knowledge management vault with interconnected notes. [Learn more →](format-obsidian.md) -### 7. Wiki Export +### 8. Wiki Export **Produces:** `wiki/` folder @@ -239,7 +255,7 @@ Documentation site structure, agent-crawlable. [Learn more →](format-wiki.md) -### 8. Graph Analysis Report +### 9. Graph Analysis Report **Produces:** `GRAPH_REPORT.md` @@ -259,7 +275,7 @@ There's no performance penalty for generating multiple formats in one run: ```bash # Fast and efficient — all generated in ~2 seconds -graphify run ./src --format json,html,svg,neo4j,ladybug,obsidian,wiki,report +graphify run ./src --format json,html,svg,neo4j,ladybug,obsidian,wiki,report,surrealdb ``` **Recommended combinations:** @@ -269,9 +285,9 @@ graphify run ./src --format json,html,svg,neo4j,ladybug,obsidian,wiki,report | Quick start | `html, report` | | Documentation | `html, svg, report` | | Knowledge base | `obsidian, wiki, report` | -| Analysis | `json, neo4j, ladybug` | +| Analysis | `json, neo4j, ladybug, surrealdb` | | Embedded analytics | `ladybug, report` | -| Everything | `json, html, svg, neo4j, ladybug, obsidian, wiki, report` | +| Everything | `json, html, svg, neo4j, ladybug, obsidian, wiki, report, surrealdb` | ## Format Sizes @@ -284,6 +300,7 @@ Approximate file/folder sizes for 1000-node graphs: | SVG | ~1 MB | | Neo4j Cypher | ~200 KB | | Ladybug Cypher | ~250 KB | +| SurrealDB | ~4 MB (embedded database) | | Obsidian | ~2 MB (many files) | | Wiki | ~3 MB (many files) | | Report | ~100 KB | @@ -305,6 +322,9 @@ git add obsidian/ wiki/ # Store Ladybug script for embedded analytics git add graph.ladybug.cypher +# Store SurrealDB database for querying +git add codebase.db + # Ignore HTML (it's static, can be regenerated) echo "graph.html" >> .gitignore ``` @@ -318,11 +338,11 @@ Update formats whenever code changes significantly: graphify run ./src # Full refresh (all formats) -graphify run ./src --format json,html,svg,neo4j,ladybug,obsidian,wiki,report +graphify run ./src --format json,html,svg,neo4j,ladybug,obsidian,wiki,report,surrealdb # In CI/CD: commit updated exports graphify run ./src -git add graph.* GRAPH_REPORT.md obsidian/ wiki/ graph.ladybug.cypher +git add graph.* GRAPH_REPORT.md obsidian/ wiki/ graph.ladybug.cypher codebase.db git commit -m "chore: update architecture exports" git push ``` @@ -335,6 +355,7 @@ git push | SVG | Static (no interactivity) | Use HTML for exploration | | JSON | Requires programming knowledge | Use HTML or Report for browsing | | Neo4j | Requires Neo4j setup | Use HTML for quick exploration | +| SurrealDB | Embedded file is large; remote needs server | Use remote mode for multi-user access | | Ladybug | Requires Ladybug runtime to execute | Generate alongside Report for reading | | Obsidian | Requires Obsidian app | Use Wiki for browser access | | Wiki | Manual navigation (no DB queries) | Use Neo4j or Ladybug for complex analysis | @@ -347,6 +368,7 @@ git push - [SVG Graph Export](format-svg.md) - [Neo4j Cypher Export](format-neo4j.md) - [Ladybug Export](format-ladybug.md) +- [SurrealDB Export](format-surrealdb.md) - [Obsidian Vault Export](format-obsidian.md) - [Wiki Export](format-wiki.md) - [Graph Analysis Report](format-report.md) diff --git a/docs/format-surrealdb.md b/docs/format-surrealdb.md new file mode 100644 index 0000000..9165185 --- /dev/null +++ b/docs/format-surrealdb.md @@ -0,0 +1,119 @@ +# SurrealDB Export + +> Export your knowledge graph as a SurrealDB database — queryable, embeddable, and real-time. + +## Overview + +The SurrealDB exporter creates a SurrealDB database with: + +- **`entity` table** — each node in the graph becomes an entity record with typed metadata +- **`relationship` table** — each edge with source/target record links, relationship type, weight, and confidence + +## Modes + +### Embedded (RocksDB) + +No server required. Produces a `codebase.db` file using SurrealDB's embedded RocksDB engine. + +```bash +graphify run ./src --format surrealdb +# Creates: graphify-out/codebase.db +``` + +### Remote + +Connect to any SurrealDB instance over HTTP or WebSocket. + +```bash +graphify run ./src --format surrealdb ` + --surreal-endpoint http://localhost:8000 ` + --surreal-user root ` + --surreal-pass mypassword ` + --surreal-ns graphify ` + --surreal-db codebase +``` + +### CLI Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--surreal-endpoint` | *(none)* | Remote SurrealDB URL. When set, uses remote mode instead of embedded. | +| `--surreal-user` | `root` | SurrealDB username | +| `--surreal-pass` | *(none)* | SurrealDB password | +| `--surreal-ns` | `graphify` | SurrealDB namespace | +| `--surreal-db` | `codebase` | SurrealDB database name | + +## Querying + +### Embedded database + +Use the SurrealDB CLI or any SurrealDB client to query: + +```bash +# Start SurrealDB CLI against the embedded file +surreal sql --endpoint rocksdb://graphify-out/codebase.db --ns graphify --db codebase +``` + +```surql +-- Find all entities in a community +SELECT * FROM entity WHERE community = 3; + +-- Find relationships with high confidence +SELECT * FROM relationship WHERE confidence = 'HIGH'; + +-- Count relationships per type +SELECT type, count() AS cnt FROM relationship GROUP BY type ORDER BY cnt DESC; +``` + +### Remote database + +Connect your SurrealDB client to the endpoint: + +```bash +surreal sql --endpoint http://localhost:8000 --ns graphify --db codebase --user root --pass mypassword +``` + +## Schema + +```surql +DEFINE TABLE IF NOT EXISTS entity; +DEFINE TABLE IF NOT EXISTS relationship; +``` + +### Entity fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `record` | Unique entity identifier | +| `label` | `string` | Human-readable name | +| `kind` | `string` | Node type (class, method, file, etc.) | +| `filePath` | `string` | Source file path | +| `language` | `string` | Programming language | +| `confidence` | `string` | Extraction confidence (LOW, MEDIUM, HIGH) | +| `community` | `int` | Community cluster ID | +| `metadata` | `object` | Additional key-value metadata | + +### Relationship fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `record` | Unique relationship identifier | +| `source` | `record` | Source entity reference | +| `target` | `record` | Target entity reference | +| `type` | `string` | Relationship type (calls, extends, contains, etc.) | +| `weight` | `float` | Edge weight | +| `confidence` | `string` | Relationship confidence (LOW, MEDIUM, HIGH) | +| `metadata` | `object` | Additional key-value metadata | + +## Use Cases + +- **Real-time dashboards** — SurrealDB's live queries push graph changes to the UI +- **Multi-model queries** — combine graph traversal with document and relational queries in a single SurrealQL statement +- **Embedded analytics** — run on developers' machines without any infrastructure +- **CI/CD integration** — compare graph snapshots across builds + +## See Also + +- [Export Formats Overview](export-formats.md) +- [Neo4j Cypher Export](format-neo4j.md) +- [Ladybug Export](format-ladybug.md) diff --git a/docs/getting-started.md b/docs/getting-started.md index 6905e60..41b0e79 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -203,10 +203,10 @@ graphify supports **25+ file types** including C#, Python, TypeScript, JavaScrip ## Step 6: Export All Formats -Want more than the default? Export to all 7 formats: +Want more than the default? Export to all formats: ```bash -graphify run . --format json,html,svg,neo4j,obsidian,wiki,report +graphify run . --format json,html,svg,neo4j,obsidian,wiki,report,surrealdb ``` This generates: @@ -214,15 +214,27 @@ This generates: - `graph.json` — Machine-readable data - `graph.svg` — Static vector image for docs - `graph.cypher` — Neo4j import script +- `codebase.db` — SurrealDB database (embedded or remote) - `obsidian/` — Obsidian vault with wikilinks - `wiki/` — Agent-crawlable documentation - `GRAPH_REPORT.md` — Analysis report +## Step 7: Query with AI Assistants + +Once you have a `graph.json`, run the MCP server to let Claude, Copilot, or any MCP client query your knowledge graph. + +```bash +dotnet run --project src/Graphify.Mcp -- graphify-out/graph.json +``` + +Then connect your AI assistant — see the [MCP Server](mcp-server.md) guide for client configuration. You can ask questions like "Find all authentication-related nodes" or "What's the shortest path between UserService and DatabaseContext?" + ## Next Steps - **[CLI Reference](cli-reference.md)** — All commands and options - **[Configuration](configuration.md)** — Layered config system, environment variables - **[Worked Example](worked-example.md)** — Deep dive into the mini-library analysis - **[Watch Mode](watch-mode.md)** — Live graph updates as you edit code -- **[Export Formats](export-formats.md)** — Details on all 7 output formats +- **[MCP Server](mcp-server.md)** — Query your graph through AI assistants +- **[Export Formats](export-formats.md)** — Details on all output formats - **[Troubleshooting](troubleshooting.md)** — Common issues and solutions diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 0000000..b75c11f --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,192 @@ +# MCP Server + +> Query your knowledge graph through AI assistants — Claude, Copilot, VS Code, and any MCP-compatible client. + +## Overview + +The MCP (Model Context Protocol) server loads a pre-built graph from its JSON export and exposes it as 5 tools. AI assistants connect over stdio and query the graph as if it were a live database — no HTTP server, no separate daemon. + +``` +┌─────────────────┐ ┌──────────────────────────┐ +│ Pipeline │ │ MCP Server │ +│ run → export │────▶│ loads graph.json │ +│ (graph.json) │ │ exposes 5 tools │ +└─────────────────┘ │ Claude / Copilot / IDE │ + └──────────────────────────┘ +``` + +## Prerequisites + +- A graph JSON file from a previous run: `graphify run . --format json` +- An MCP-compatible client (Claude Desktop, VS Code with Copilot, etc.) + +## Quick Start + +### Step 1: Build the graph + +```bash +graphify run ./my-project --format json +# Produces: graphify-out/graph.json +``` + +### Step 2: Run the MCP server + +```bash +dotnet run --project src/Graphify.Mcp -- graphify-out/graph.json +``` + +Or build once and run the binary directly: + +```bash +dotnet publish src/Graphify.Mcp -o ./mcp-server +./mcp-server/Graphify.Mcp graphify-out/graph.json +``` + +The server no output on success — all logging goes to stderr, stdout is reserved for JSON-RPC protocol messages. + +### Step 3: Connect your AI assistant + +**Claude Desktop** — add to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "graphify": { + "command": "dotnet", + "args": [ + "run", + "--project", + "/path/to/graphify-dotnet/src/Graphify.Mcp", + "--", + "/path/to/your/graph.json" + ] + } + } +} +``` + +**VS Code** — add to `.vscode/mcp.json`: + +```json +{ + "servers": { + "graphify": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "/path/to/graphify-dotnet/src/Graphify.Mcp", + "--", + "/path/to/your/graph.json" + ], + "description": "Knowledge graph for my codebase" + } + } +} +``` + +### Step 4: Ask questions + +Once connected, your AI assistant can use these tools naturally: + +- *"Find all nodes related to authentication."* +- *"What's the shortest path between UserService and DatabaseContext?"* +- *"Explain the UserRepository node."* +- *"List the communities and their top members."* +- *"Analyze the overall graph structure."* + +## Tools + +### Query — Search nodes and edges + +Search nodes by name, label, or type. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `searchTerm` | string | — | Match against node ID, label, or type | +| `limit` | int | 10 | Max results to return | + +Returns matching nodes with degree, community, and up to 5 connections each. + +### Path — Shortest path between two nodes + +Find the shortest path between any two nodes using BFS. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `sourceId` | string | — | Start node ID | +| `targetId` | string | — | End node ID | + +Returns the path as an ordered list of nodes and the path length (number of edges). + +### Explain — Node details with all connections + +Get full details for a single node including incoming and outgoing edges. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `nodeId` | string | — | Node ID to explain | + +Returns the node's metadata, total degree, and separate lists of incoming and outgoing connections. + +### Communities — List communities and their members + +Browse community clusters detected by the Louvain algorithm. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `communityId` | int? | null | Specific community (omit for all) | + +Without `communityId`, returns every community with its top 5 members. With a specific ID, returns all members sorted by degree. + +### Analyze — Graph-wide statistics + +Get a high-level summary of the entire graph. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `topN` | int | 10 | Top nodes to include | + +Returns node/edge counts, community count, average degree, isolated nodes, top-N nodes by degree, type distribution, and relationship distribution. + +## CLI Options + +```bash +Graphify.Mcp [--verbose] +``` + +| Argument | Description | +|----------|-------------| +| `graph-path` | Path to the `graph.json` file (default: `graph.json`) | +| `--verbose`, `-v` | Enable detailed logging (node/edge counts, load progress) | + +## Architecture + +The server uses the `ModelContextProtocol` SDK with stdio transport. All JSON-RPC messages flow over stdout; logging goes exclusively to stderr. Tools are auto-discovered via `[McpServerTool]` attributes on the `GraphTools` class. + +Sequence on startup: + +1. Parse CLI arguments +2. Load and deserialize `graph.json` into a `KnowledgeGraph` +3. Register the graph and tools as DI singletons +4. Start the MCP server with stdio transport +5. Wait for client connections and tool invocations + +The server is read-only — it does not run pipeline stages, trigger re-extraction, or write to disk. + +## Integration with CI/CD + +Regenerate the graph and restart the server on each build: + +```bash +graphify run ./src --format json +dotnet run --project src/Graphify.Mcp -- graphify-out/graph.json +``` + +## See Also + +- [Getting Started](getting-started.md) +- [CLI Reference](cli-reference.md) +- [Export Formats Overview](export-formats.md) +- [JSON Graph Export](format-json.md) diff --git a/docs/worked-example.md b/docs/worked-example.md index c6669f7..b5cf2ac 100644 --- a/docs/worked-example.md +++ b/docs/worked-example.md @@ -208,6 +208,23 @@ Agent-crawlable documentation with an index page and community breakdowns. Desig The analysis report we walked through above — god nodes, communities, surprising connections, and suggested questions. +## Querying with the MCP Server + +The MCP server loads `graph.json` and exposes it as tools for AI assistants. Start it: + +```bash +dotnet run --project src/Graphify.Mcp -- samples/mini-library/graphify-out/graph.json +``` + +Then connect Claude Desktop or VS Code and ask questions: + +- *"Find all nodes related to UserRepository."* +- *"What's the shortest path between User and UserService?"* +- *"List the communities with their top members."* +- *"Analyze the graph structure."* + +The server exposes 5 tools — `query`, `path`, `explain`, `communities`, `analyze`. Configuration examples and a full reference are in the [MCP Server](mcp-server.md) guide. + For details on each format, see: - [Export Formats Overview](export-formats.md) @@ -218,6 +235,7 @@ For details on each format, see: - [Obsidian Vault Export](format-obsidian.md) - [Wiki Export](format-wiki.md) - [Graph Analysis Report](format-report.md) +- [MCP Server](mcp-server.md) ## Try It Yourself diff --git a/src/Graphify.Cli/Configuration/ConfigurationFactory.cs b/src/Graphify.Cli/Configuration/ConfigurationFactory.cs index 3e0163a..bf03424 100644 --- a/src/Graphify.Cli/Configuration/ConfigurationFactory.cs +++ b/src/Graphify.Cli/Configuration/ConfigurationFactory.cs @@ -8,7 +8,9 @@ namespace Graphify.Cli.Configuration; /// public static class ConfigurationFactory { - public static IConfiguration Build(CliProviderOptions? cliOptions = null) + public static IConfiguration Build( + CliProviderOptions? cliOptions = null, + CliSurrealOptions? surrealOptions = null) { var builder = new ConfigurationBuilder(); @@ -27,10 +29,10 @@ public static IConfiguration Build(CliProviderOptions? cliOptions = null) builder.AddUserSecrets(optional: true); // Layer 5: CLI argument overrides (highest priority) + var overrides = new Dictionary(); + if (cliOptions != null) { - var overrides = new Dictionary(); - if (cliOptions.Provider != null) overrides["Graphify:Provider"] = cliOptions.Provider; @@ -40,7 +42,6 @@ public static IConfiguration Build(CliProviderOptions? cliOptions = null) overrides["Graphify:Ollama:Endpoint"] = cliOptions.Endpoint; else if (cliOptions.Provider?.Equals("copilotsdk", StringComparison.OrdinalIgnoreCase) != true) overrides["Graphify:AzureOpenAI:Endpoint"] = cliOptions.Endpoint; - // CopilotSdk does not use endpoints — silently ignore } if (cliOptions.ApiKey != null) @@ -58,10 +59,39 @@ public static IConfiguration Build(CliProviderOptions? cliOptions = null) if (cliOptions.Deployment != null) overrides["Graphify:AzureOpenAI:DeploymentName"] = cliOptions.Deployment; + } + + if (surrealOptions != null) + { + if (surrealOptions.Endpoint != null) + overrides["Graphify:SurrealDb:Endpoint"] = surrealOptions.Endpoint; + if (surrealOptions.Username != null) + overrides["Graphify:SurrealDb:Username"] = surrealOptions.Username; + if (surrealOptions.Password != null) + overrides["Graphify:SurrealDb:Password"] = surrealOptions.Password; + if (surrealOptions.Namespace != null) + overrides["Graphify:SurrealDb:Namespace"] = surrealOptions.Namespace; + if (surrealOptions.Database != null) + overrides["Graphify:SurrealDb:Database"] = surrealOptions.Database; + } + if (overrides.Count > 0) + { builder.AddInMemoryCollection(overrides); } return builder.Build(); } } + +/// +/// CLI-provided SurrealDB connection overrides. +/// +public sealed record CliSurrealOptions +{ + public string? Endpoint { get; init; } + public string? Username { get; init; } + public string? Password { get; init; } + public string? Namespace { get; init; } + public string? Database { get; init; } +} diff --git a/src/Graphify.Cli/Configuration/GraphifyConfig.cs b/src/Graphify.Cli/Configuration/GraphifyConfig.cs index b6ed7bc..7e3ef66 100644 --- a/src/Graphify.Cli/Configuration/GraphifyConfig.cs +++ b/src/Graphify.Cli/Configuration/GraphifyConfig.cs @@ -12,6 +12,20 @@ public class GraphifyConfig public AzureOpenAIConfig AzureOpenAI { get; set; } = new(); public OllamaConfig Ollama { get; set; } = new(); public CopilotSdkConfig CopilotSdk { get; set; } = new(); + public SurrealDbConfig SurrealDb { get; set; } = new(); +} + +/// +/// SurrealDB connection configuration. +/// When Endpoint is set, remote mode is used; otherwise embedded mode. +/// +public class SurrealDbConfig +{ + public string? Endpoint { get; set; } + public string? Username { get; set; } + public string? Password { get; set; } + public string? Namespace { get; set; } + public string? Database { get; set; } } /// diff --git a/src/Graphify.Cli/PipelineRunner.cs b/src/Graphify.Cli/PipelineRunner.cs index 060c2ce..8c4b1cd 100644 --- a/src/Graphify.Cli/PipelineRunner.cs +++ b/src/Graphify.Cli/PipelineRunner.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics; +using Graphify.Cli.Configuration; using Graphify.Export; using Graphify.Graph; using Graphify.Models; @@ -16,13 +17,16 @@ public sealed class PipelineRunner private readonly TextWriter _output; private readonly bool _verbose; private readonly IChatClient? _chatClient; + private readonly SurrealDbConfig? _surrealDbConfig; private readonly SemaphoreSlim _outputLock = new(1, 1); - public PipelineRunner(TextWriter output, bool verbose = false, IChatClient? chatClient = null) + public PipelineRunner(TextWriter output, bool verbose = false, + IChatClient? chatClient = null, SurrealDbConfig? surrealDbConfig = null) { _output = output ?? throw new ArgumentNullException(nameof(output)); _verbose = verbose; _chatClient = chatClient; + _surrealDbConfig = surrealDbConfig; } public async Task RunAsync( @@ -319,7 +323,12 @@ await Parallel.ForEachAsync( break; case "surrealdb": - var surrealDbExporter = new SurrealDbExporter(); + var surrealDbExporter = new SurrealDbExporter( + endpoint: _surrealDbConfig?.Endpoint, + username: _surrealDbConfig?.Username, + password: _surrealDbConfig?.Password, + ns: _surrealDbConfig?.Namespace, + database: _surrealDbConfig?.Database); var surrealDbPath = Path.Combine(outputDir, "codebase.db"); await surrealDbExporter.ExportAsync(graph, surrealDbPath, cancellationToken); await WriteLineAsync($" Exported SurrealDB: {surrealDbPath}{FormatWithElapsed(formatStopwatch.Elapsed)}"); diff --git a/src/Graphify.Cli/Program.cs b/src/Graphify.Cli/Program.cs index ab0d261..9219d34 100644 --- a/src/Graphify.Cli/Program.cs +++ b/src/Graphify.Cli/Program.cs @@ -21,7 +21,10 @@ static void AddPipelineOptions(Command cmd, out Option outputOpt, out Option formatOpt, out Option verboseOpt, out Option providerOpt, out Option endpointOpt, out Option apiKeyOpt, - out Option modelOpt, out Option deploymentOpt) + out Option modelOpt, out Option deploymentOpt, + out Option surrealEndpointOpt, out Option surrealUserOpt, + out Option surrealPassOpt, out Option surrealNsOpt, + out Option surrealDbOpt) { outputOpt = new Option("--output", "-o") { @@ -57,6 +60,26 @@ static void AddPipelineOptions(Command cmd, { Description = "Azure OpenAI deployment name" }; + surrealEndpointOpt = new Option("--surreal-endpoint") + { + Description = "SurrealDB remote endpoint (e.g., http://localhost:8000). Sets remote mode." + }; + surrealUserOpt = new Option("--surreal-user") + { + Description = "SurrealDB username (default: root)" + }; + surrealPassOpt = new Option("--surreal-pass") + { + Description = "SurrealDB password" + }; + surrealNsOpt = new Option("--surreal-ns") + { + Description = "SurrealDB namespace (default: graphify)" + }; + surrealDbOpt = new Option("--surreal-db") + { + Description = "SurrealDB database name (default: codebase)" + }; cmd.Options.Add(outputOpt); cmd.Options.Add(formatOpt); @@ -66,6 +89,11 @@ static void AddPipelineOptions(Command cmd, cmd.Options.Add(apiKeyOpt); cmd.Options.Add(modelOpt); cmd.Options.Add(deploymentOpt); + cmd.Options.Add(surrealEndpointOpt); + cmd.Options.Add(surrealUserOpt); + cmd.Options.Add(surrealPassOpt); + cmd.Options.Add(surrealNsOpt); + cmd.Options.Add(surrealDbOpt); } static async Task<(IChatClient? chatClient, bool verbose)> ResolveProviderAsync( @@ -131,7 +159,10 @@ static void AddPipelineOptions(Command cmd, AddPipelineOptions(runCommand, out var runOutputOpt, out var runFormatOpt, out var runVerboseOpt, out var runProviderOpt, out var runEndpointOpt, out var runApiKeyOpt, - out var runModelOpt, out var runDeploymentOpt); + out var runModelOpt, out var runDeploymentOpt, + out var runSurrealEndpointOpt, out var runSurrealUserOpt, + out var runSurrealPassOpt, out var runSurrealNsOpt, + out var runSurrealDbOpt); var runConfigOpt = new Option("--config", "-c") { @@ -174,7 +205,20 @@ static void AddPipelineOptions(Command cmd, runVerboseOpt, runProviderOpt, runEndpointOpt, runApiKeyOpt, runModelOpt, runDeploymentOpt, ignoreProviderOptions: useConfigWizard); - var runner = new Graphify.Cli.PipelineRunner(Console.Out, verbose, chatClient); + var surrealOptions = new CliSurrealOptions + { + Endpoint = parseResult.GetValue(runSurrealEndpointOpt), + Username = parseResult.GetValue(runSurrealUserOpt), + Password = parseResult.GetValue(runSurrealPassOpt), + Namespace = parseResult.GetValue(runSurrealNsOpt), + Database = parseResult.GetValue(runSurrealDbOpt) + }; + + var configuration = ConfigurationFactory.Build(cliOptions: null, surrealOptions); + var graphifyConfig = new GraphifyConfig(); + configuration.GetSection("Graphify").Bind(graphifyConfig); + + var runner = new Graphify.Cli.PipelineRunner(Console.Out, verbose, chatClient, graphifyConfig.SurrealDb); var graph = await runner.RunAsync(path, output, formats, useCache: true, cancellationToken); return graph != null ? 0 : 1; }); @@ -189,7 +233,10 @@ static void AddPipelineOptions(Command cmd, AddPipelineOptions(watchCommand, out var watchOutputOpt, out var watchFormatOpt, out var watchVerboseOpt, out var watchProviderOpt, out var watchEndpointOpt, out var watchApiKeyOpt, - out var watchModelOpt, out var watchDeploymentOpt); + out var watchModelOpt, out var watchDeploymentOpt, + out var watchSurrealEndpointOpt, out var watchSurrealUserOpt, + out var watchSurrealPassOpt, out var watchSurrealNsOpt, + out var watchSurrealDbOpt); watchCommand.SetAction(async (parseResult, cancellationToken) => { @@ -201,9 +248,22 @@ static void AddPipelineOptions(Command cmd, var (chatClient, verbose) = await ResolveProviderAsync(parseResult, watchVerboseOpt, watchProviderOpt, watchEndpointOpt, watchApiKeyOpt, watchModelOpt, watchDeploymentOpt); + var surrealOptions = new CliSurrealOptions + { + Endpoint = parseResult.GetValue(watchSurrealEndpointOpt), + Username = parseResult.GetValue(watchSurrealUserOpt), + Password = parseResult.GetValue(watchSurrealPassOpt), + Namespace = parseResult.GetValue(watchSurrealNsOpt), + Database = parseResult.GetValue(watchSurrealDbOpt) + }; + + var configuration = ConfigurationFactory.Build(cliOptions: null, surrealOptions); + var graphifyConfig = new GraphifyConfig(); + configuration.GetSection("Graphify").Bind(graphifyConfig); + Console.WriteLine("Running initial pipeline..."); Console.WriteLine(); - var runner = new Graphify.Cli.PipelineRunner(Console.Out, verbose, chatClient); + var runner = new Graphify.Cli.PipelineRunner(Console.Out, verbose, chatClient, graphifyConfig.SurrealDb); var graph = await runner.RunAsync(path, output, formats, useCache: true, cancellationToken); if (graph is null) diff --git a/src/Graphify.Mcp/GraphTools.cs b/src/Graphify.Mcp/GraphTools.cs index 5b7ffde..2866c09 100644 --- a/src/Graphify.Mcp/GraphTools.cs +++ b/src/Graphify.Mcp/GraphTools.cs @@ -20,11 +20,11 @@ public GraphTools(KnowledgeGraph graph) } [McpServerTool] - [Description("Search for nodes and edges in the knowledge graph. Returns matching nodes with their connections.")] + [Description("Search nodes and edges by name, label, or type.")] public string Query( - [Description("Search term to match against node IDs, labels, or types")] + [Description("Match against node ID, label, or type")] string searchTerm, - [Description("Maximum number of results to return (default: 10)")] + [Description("Max results (default 10)")] int limit = 10) { if (string.IsNullOrWhiteSpace(searchTerm)) @@ -66,15 +66,15 @@ public string Query( query = searchTerm, resultCount = matchingNodes.Count, results = matchingNodes - }, new JsonSerializerOptions { WriteIndented = true }); + }); } [McpServerTool] - [Description("Find the shortest path between two nodes in the knowledge graph.")] + [Description("Shortest path between two nodes.")] public string Path( - [Description("Starting node ID")] + [Description("Start node ID")] string sourceId, - [Description("Target node ID")] + [Description("End node ID")] string targetId) { if (string.IsNullOrWhiteSpace(sourceId) || string.IsNullOrWhiteSpace(targetId)) @@ -119,7 +119,7 @@ public string Path( label = n.Label, type = n.Type }).ToList() - }, new JsonSerializerOptions { WriteIndented = true }); + }); } foreach (var neighbor in _graph.GetNeighbors(current.Id)) @@ -135,8 +135,7 @@ public string Path( return JsonSerializer.Serialize(new { - found = false, - message = $"No path found between '{sourceId}' and '{targetId}'" + found = false }); } catch (Exception ex) @@ -149,9 +148,9 @@ public string Path( } [McpServerTool] - [Description("Explain a node's role and its connections in the knowledge graph.")] + [Description("Node details with all connections.")] public string Explain( - [Description("Node ID to explain")] + [Description("Node ID")] string nodeId) { if (string.IsNullOrWhiteSpace(nodeId)) @@ -200,17 +199,16 @@ public string Explain( toLabel = e.Target.Label, relationship = e.Relationship, confidence = e.Confidence.ToString() - }).ToList(), - summary = GenerateNodeSummary(node, inEdges.Count, outEdges.Count, degree) + }).ToList() }; - return JsonSerializer.Serialize(explanation, new JsonSerializerOptions { WriteIndented = true }); + return JsonSerializer.Serialize(explanation); } [McpServerTool] - [Description("List communities and their members in the knowledge graph.")] + [Description("List communities and their members.")] public string Communities( - [Description("Optional: specific community ID to query (omit to list all)")] + [Description("Specific community (omit for all)")] int? communityId = null) { var allNodes = _graph.GetNodes().ToList(); @@ -237,7 +235,7 @@ public string Communities( filePath = n.FilePath, degree = _graph.GetDegree(n.Id) }).OrderByDescending(n => n.degree).ToList() - }, new JsonSerializerOptions { WriteIndented = true }); + }); } var communities = nodesWithCommunities @@ -266,13 +264,13 @@ public string Communities( nodesInCommunities = nodesWithCommunities.Count, nodesWithoutCommunity = allNodes.Count - nodesWithCommunities.Count, communities - }, new JsonSerializerOptions { WriteIndented = true }); + }); } [McpServerTool] - [Description("Run analysis on the knowledge graph and return structural insights.")] + [Description("Graph-wide statistics and structure.")] public string Analyze( - [Description("Number of top nodes to include in analysis (default: 10)")] + [Description("Top nodes to include (default 10)")] int topN = 10) { var allNodes = _graph.GetNodes().ToList(); @@ -322,73 +320,10 @@ public string Analyze( community = t.Node.Community }).ToList(), nodeTypes = nodesByType, - relationshipTypes = edgesByRelationship, - insights = GenerateInsights(allNodes, allEdges, topNodesByDegree, isolatedNodes.Count, nodesByCommunity) + relationshipTypes = edgesByRelationship }; - return JsonSerializer.Serialize(analysis, new JsonSerializerOptions { WriteIndented = true }); + return JsonSerializer.Serialize(analysis); } - private static string GenerateNodeSummary(GraphNode node, int inCount, int outCount, int totalDegree) - { - var role = node.Type switch - { - "class" => "class definition", - "function" => "function or method", - "module" => "module or namespace", - "file" => "file", - "concept" => "abstract concept", - _ => $"{node.Type} entity" - }; - - var connectivityDesc = totalDegree switch - { - 0 => "isolated (no connections)", - 1 => "minimally connected (1 connection)", - < 5 => $"lightly connected ({totalDegree} connections)", - < 20 => $"moderately connected ({totalDegree} connections)", - _ => $"highly connected ({totalDegree} connections)" - }; - - return $"'{node.Label}' is a {role} that is {connectivityDesc}. " + - $"It has {inCount} incoming and {outCount} outgoing relationships."; - } - - private static List GenerateInsights( - List allNodes, - List allEdges, - List<(GraphNode Node, int Degree)> topNodes, - int isolatedCount, - int communityCount) - { - var insights = new List(); - - if (topNodes.Any()) - { - var topNode = topNodes.First(); - insights.Add($"The most connected node is '{topNode.Node.Label}' ({topNode.Node.Type}) with {topNode.Degree} connections."); - } - - if (isolatedCount > 0) - { - var isolatedPct = (isolatedCount * 100.0) / allNodes.Count; - insights.Add($"{isolatedCount} nodes ({isolatedPct:F1}%) are isolated with no connections."); - } - - if (communityCount > 0) - { - insights.Add($"The graph is organized into {communityCount} communities, indicating modular structure."); - } - - var avgDegree = allNodes.Any() ? allNodes.Average(n => n.Type == "class" ? 1 : 0) : 0; - if (allEdges.Any()) - { - var mostCommonRel = allEdges.GroupBy(e => e.Relationship) - .OrderByDescending(g => g.Count()) - .First(); - insights.Add($"The most common relationship type is '{mostCommonRel.Key}' ({mostCommonRel.Count()} edges)."); - } - - return insights; - } } diff --git a/src/Graphify/Export/SurrealDbExporter.cs b/src/Graphify/Export/SurrealDbExporter.cs index 9dcdddc..9b57ceb 100644 --- a/src/Graphify/Export/SurrealDbExporter.cs +++ b/src/Graphify/Export/SurrealDbExporter.cs @@ -1,22 +1,46 @@ -using System.Text.Json; -using System.Text.Json.Serialization; using Graphify.Graph; +using SurrealDb.Embedded.RocksDb; +using SurrealDb.Net; +using SurrealDb.Net.Models; +using SurrealDb.Net.Models.Auth; namespace Graphify.Export; -/// -/// Exports knowledge graphs to SurrealDB format (embedded file-based database). -/// Interim implementation uses JSON serialization compatible with SurrealDB schema. -/// FUTURE: Replace with direct surrealdb.net client calls once stable API is confirmed. -/// public sealed class SurrealDbExporter : IGraphExporter { - private static readonly JsonSerializerOptions JsonOptions = new() + private readonly string? _endpoint; + private readonly string? _username; + private readonly string? _password; + private readonly string? _namespace; + private readonly string? _database; + private readonly SurrealDbRocksDbClient? _testClient; + + public SurrealDbExporter( + string? endpoint = null, + string? username = null, + string? password = null, + string? ns = null, + string? database = null) { - WriteIndented = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; + _endpoint = endpoint; + _username = username; + _password = password; + _namespace = ns; + _database = database; + } + + /// + /// Testing constructor: accept a pre-opened client instead of creating one. + /// The exporter will NOT dispose the client — caller owns the lifetime. + /// + /// + /// Testing constructor: accept a pre-opened client (caller owns lifetime). + /// Enables in-process verification without RocksDB lock contention. + /// + public SurrealDbExporter(SurrealDbRocksDbClient testClient) + { + _testClient = testClient; + } public string Format => "surrealdb"; @@ -26,126 +50,116 @@ public async Task ExportAsync(KnowledgeGraph graph, string outputPath, ArgumentNullException.ThrowIfNull(graph); ArgumentException.ThrowIfNullOrWhiteSpace(outputPath); - // Ensure output directory exists - var directory = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrEmpty(directory)) + if (_endpoint is not null) { - Directory.CreateDirectory(directory); + await ExportRemoteAsync(graph, cancellationToken); } - - // Build export structure - var nodes = graph.GetNodes() - .Select(n => new SurrealDbNodeRecord - { - Id = $"entities:{Uri.EscapeDataString(n.Id)}", - Label = n.Label, - Kind = n.Type, - FilePath = n.FilePath, - Language = n.Language, - Confidence = n.Confidence.ToString().ToUpperInvariant(), - Community = n.Community, - Metadata = n.Metadata - }) - .ToList(); - - var edges = graph.GetEdges() - .Select(e => new SurrealDbEdgeRecord - { - Source = $"entities:{Uri.EscapeDataString(e.Source.Id)}", - Target = $"entities:{Uri.EscapeDataString(e.Target.Id)}", - Type = e.Relationship, - Weight = e.Weight, - Confidence = e.Confidence.ToString().ToUpperInvariant(), - Metadata = e.Metadata - }) - .ToList(); - - var exportData = new SurrealDbExportDto + else { - Entities = nodes, - Relationships = edges, - Metadata = new ExportMetadataDto - { - EntityCount = nodes.Count, - RelationshipCount = edges.Count, - GeneratedAt = DateTime.UtcNow - } - }; - - // Write to file (JSON format for interim implementation) - await using var stream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true); - await JsonSerializer.SerializeAsync(stream, exportData, JsonOptions, cancellationToken); + await ExportEmbeddedAsync(graph, outputPath, cancellationToken); + } } - private sealed record SurrealDbExportDto + internal async Task ExportEmbeddedAsync(KnowledgeGraph graph, string outputPath, + CancellationToken cancellationToken) { - [JsonPropertyName("entities")] - public required List Entities { get; init; } - - [JsonPropertyName("relationships")] - public required List Relationships { get; init; } + if (_testClient is not null) + { + await _testClient.Use("graphify", "codebase"); + await ExportToClientAsync(_testClient, graph, cancellationToken); + return; + } - [JsonPropertyName("metadata")] - public required ExportMetadataDto Metadata { get; init; } + await using var db = CreateRocksDbClient(outputPath); + await db.Use("graphify", "codebase"); + await ExportToClientAsync(db, graph, cancellationToken); } - private sealed record SurrealDbNodeRecord + private static SurrealDbRocksDbClient CreateRocksDbClient(string outputPath) { - [JsonPropertyName("id")] - public required string Id { get; init; } - - [JsonPropertyName("label")] - public required string Label { get; init; } - - [JsonPropertyName("kind")] - public string? Kind { get; init; } + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } - [JsonPropertyName("filePath")] - public string? FilePath { get; init; } + return new SurrealDbRocksDbClient(outputPath); + } - [JsonPropertyName("language")] - public string? Language { get; init; } + private async Task ExportRemoteAsync(KnowledgeGraph graph, + CancellationToken cancellationToken) + { + await using var db = new SurrealDbClient(_endpoint!); - [JsonPropertyName("confidence")] - public string? Confidence { get; init; } + if (_username is not null) + { + await db.SignIn(new RootAuth + { + Username = _username, + Password = _password ?? "" + }); + } - [JsonPropertyName("community")] - public int? Community { get; init; } + await db.Use( + _namespace ?? "graphify", + _database ?? "codebase"); - [JsonPropertyName("metadata")] - public IReadOnlyDictionary? Metadata { get; init; } + await ExportToClientAsync(db, graph, cancellationToken); } - private sealed record SurrealDbEdgeRecord + private static async Task ExportToClientAsync(ISurrealDbClient db, + KnowledgeGraph graph, CancellationToken cancellationToken) { - [JsonPropertyName("source")] - public required string Source { get; init; } - - [JsonPropertyName("target")] - public required string Target { get; init; } - - [JsonPropertyName("type")] - public required string Type { get; init; } + await DefineSchemaAsync(db); - [JsonPropertyName("weight")] - public double Weight { get; init; } = 1.0; + var nodes = graph.GetNodes().ToList(); + var edges = graph.GetEdges().ToList(); - [JsonPropertyName("confidence")] - public string? Confidence { get; init; } + foreach (var node in nodes) + { + var escapedId = Uri.EscapeDataString(node.Id); + await db.Create("entity", new + { + Id = (RecordId)("entity", escapedId), + label = node.Label, + kind = node.Type, + filePath = node.FilePath, + language = node.Language, + confidence = node.Confidence.ToString().ToUpperInvariant(), + community = node.Community, + metadata = node.Metadata is { Count: > 0 } + ? node.Metadata.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value) + : null + }); + } - [JsonPropertyName("metadata")] - public IReadOnlyDictionary? Metadata { get; init; } + for (int i = 0; i < edges.Count; i++) + { + var edge = edges[i]; + var escapedSource = Uri.EscapeDataString(edge.Source.Id); + var escapedTarget = Uri.EscapeDataString(edge.Target.Id); + await db.Create("relationship", new + { + Id = (RecordId)("relationship", escapedSource + "->" + escapedTarget + "-" + i), + source = (RecordId)("entity", escapedSource), + target = (RecordId)("entity", escapedTarget), + type = edge.Relationship, + weight = edge.Weight, + confidence = edge.Confidence.ToString().ToUpperInvariant(), + metadata = edge.Metadata is { Count: > 0 } + ? edge.Metadata.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value) + : null + }); + } } - private sealed record ExportMetadataDto + private static async Task DefineSchemaAsync(ISurrealDbClient db) { - [JsonPropertyName("entity_count")] - public int EntityCount { get; init; } - - [JsonPropertyName("relationship_count")] - public int RelationshipCount { get; init; } - - [JsonPropertyName("generated_at")] - public DateTime GeneratedAt { get; init; } + // Schema definition. Separate statements with semicolons for SurrealQL compatibility. + await db.Query($""" + DEFINE TABLE IF NOT EXISTS entity; + DEFINE TABLE IF NOT EXISTS relationship; + """); } + } diff --git a/src/Graphify/Graphify.csproj b/src/Graphify/Graphify.csproj index baf0522..088a865 100644 --- a/src/Graphify/Graphify.csproj +++ b/src/Graphify/Graphify.csproj @@ -23,7 +23,8 @@ - + + diff --git a/src/tests/Graphify.Tests/Export/SurrealDbExporterTests.cs b/src/tests/Graphify.Tests/Export/SurrealDbExporterTests.cs index 2c8ce16..5010d06 100644 --- a/src/tests/Graphify.Tests/Export/SurrealDbExporterTests.cs +++ b/src/tests/Graphify.Tests/Export/SurrealDbExporterTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Graphify.Export; using Graphify.Graph; using Graphify.Models; @@ -7,433 +6,136 @@ namespace Graphify.Tests.Export; [Trait("Category", "Export")] -public sealed class SurrealDbExporterTests : IDisposable +public sealed class SurrealDbExporterTests { - private readonly string _testRoot; - private readonly SurrealDbExporter _exporter = new(); - - public SurrealDbExporterTests() + private static string NewDbPath(string testName) { - _testRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Directory.CreateDirectory(_testRoot); + var dir = Path.Combine(Path.GetTempPath(), "gfx-" + testName + "-" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(dir); + return Path.Combine(dir, "codebase.db"); } - public void Dispose() + private static void Cleanup(string path) { - try - { - if (Directory.Exists(_testRoot)) - { - Directory.Delete(_testRoot, recursive: true); - } - } - catch - { - // Best effort cleanup - } + var dir = Path.GetDirectoryName(path); + if (dir is not null && Directory.Exists(dir)) + try { Directory.Delete(dir, recursive: true); } catch { } } [Fact] public void Format_ReturnsCorrectValue() { - Assert.Equal("surrealdb", _exporter.Format); + Assert.Equal("surrealdb", new SurrealDbExporter().Format); } [Fact] public async Task ExportAsync_ValidGraph_ProducesDatabase() { - // Arrange - var graph = CreateSampleGraph(); - var outputPath = Path.Combine(_testRoot, "graph.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert - Assert.True(File.Exists(outputPath)); - var json = await File.ReadAllTextAsync(outputPath); - Assert.NotEmpty(json); - - var doc = JsonDocument.Parse(json); - Assert.NotNull(doc); - } - - [Fact] - public async Task ExportAsync_NodeCounts_Match() - { - // Arrange - var graph = CreateSampleGraph(); - var outputPath = Path.Combine(_testRoot, "nodes.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert - var json = await File.ReadAllTextAsync(outputPath); - var doc = JsonDocument.Parse(json); - - var entities = doc.RootElement.GetProperty("entities"); - Assert.Equal(3, entities.GetArrayLength()); - } - - [Fact] - public async Task ExportAsync_EdgeCounts_Match() - { - // Arrange - var graph = CreateSampleGraph(); - var outputPath = Path.Combine(_testRoot, "edges.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert - var json = await File.ReadAllTextAsync(outputPath); - var doc = JsonDocument.Parse(json); - - var relationships = doc.RootElement.GetProperty("relationships"); - Assert.Equal(2, relationships.GetArrayLength()); - } - - [Fact] - public async Task ExportAsync_EmptyGraph_ProducesDatabase() - { - // Arrange - var graph = new KnowledgeGraph(); - var outputPath = Path.Combine(_testRoot, "empty.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert - Assert.True(File.Exists(outputPath)); - var json = await File.ReadAllTextAsync(outputPath); - var doc = JsonDocument.Parse(json); - - var entities = doc.RootElement.GetProperty("entities"); - var relationships = doc.RootElement.GetProperty("relationships"); - - Assert.Equal(0, entities.GetArrayLength()); - Assert.Equal(0, relationships.GetArrayLength()); - } - - [Fact] - public async Task ExportAsync_CreatesDirectory_IfNotExists() - { - // Arrange - var graph = CreateSampleGraph(); - var subDir = Path.Combine(_testRoot, "nested", "dirs"); - var outputPath = Path.Combine(subDir, "graph.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert - Assert.True(File.Exists(outputPath)); + var path = NewDbPath(nameof(ExportAsync_ValidGraph_ProducesDatabase)); + try + { + await new SurrealDbExporter().ExportAsync(CreateSampleGraph(), path); + Assert.True(Directory.Exists(path)); + } + finally { Cleanup(path); } } [Fact] - public async Task ExportAsync_SpecialCharacters_InNodeId() + public async Task ExportAsync_EmptyGraph_Completes() { - // Arrange - var graph = new KnowledgeGraph(); - var node = new GraphNode + var path = NewDbPath(nameof(ExportAsync_EmptyGraph_Completes)); + try { - Id = "Namespace::Class.Method()", - Label = "Method", - Type = "Method", - FilePath = "test.cs", - Confidence = Confidence.Extracted, - Metadata = new Dictionary() - }; - graph.AddNode(node); - - var outputPath = Path.Combine(_testRoot, "special_chars.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert - Assert.True(File.Exists(outputPath)); - var json = await File.ReadAllTextAsync(outputPath); - var doc = JsonDocument.Parse(json); - var entities = doc.RootElement.GetProperty("entities"); - Assert.Single(entities.EnumerateArray()); + await new SurrealDbExporter().ExportAsync(new KnowledgeGraph(), path); + Assert.True(Directory.Exists(path)); + } + finally { Cleanup(path); } } [Fact] - public async Task ExportAsync_Metadata_Preserved() + public async Task ExportAsync_CreatesDirectory() { - // Arrange - var graph = new KnowledgeGraph(); - var node = new GraphNode + var path = NewDbPath(nameof(ExportAsync_CreatesDirectory)); + try { - Id = "test_node", - Label = "TestNode", - Type = "Class", - FilePath = "Test.cs", - Confidence = Confidence.Extracted, - Metadata = new Dictionary - { - ["custom_key"] = "custom_value", - ["line_count"] = "42" - } - }; - graph.AddNode(node); - - var outputPath = Path.Combine(_testRoot, "metadata.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert - var json = await File.ReadAllTextAsync(outputPath); - var doc = JsonDocument.Parse(json); - var entities = doc.RootElement.GetProperty("entities"); - var firstEntity = entities[0]; - - Assert.True(firstEntity.TryGetProperty("metadata", out var metadata)); - Assert.True(metadata.TryGetProperty("custom_key", out var customValue)); - Assert.Equal("custom_value", customValue.GetString()); + await new SurrealDbExporter().ExportAsync(CreateSampleGraph(), path); + Assert.True(Directory.Exists(Path.GetDirectoryName(path))); + Assert.True(Directory.Exists(path)); + } + finally { Cleanup(path); } } [Fact] - public async Task ExportAsync_Confidence_Exported() + public async Task ExportAsync_LargeGraph_Completes() { - // Arrange - var graph = new KnowledgeGraph(); - var node1 = CreateNode("a", "A"); - var node2 = CreateNode("b", "B"); - graph.AddNode(node1); - graph.AddNode(node2); - - var edge = new GraphEdge + var path = NewDbPath(nameof(ExportAsync_LargeGraph_Completes)); + try { - Source = node1, - Target = node2, - Relationship = "calls", - Weight = 2.5, - Confidence = Confidence.Inferred, - Metadata = new Dictionary() - }; - graph.AddEdge(edge); - - var outputPath = Path.Combine(_testRoot, "confidence.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert - var json = await File.ReadAllTextAsync(outputPath); - var doc = JsonDocument.Parse(json); - var relationships = doc.RootElement.GetProperty("relationships"); - var firstEdge = relationships[0]; - - Assert.True(firstEdge.TryGetProperty("confidence", out var confidence)); - Assert.Equal("INFERRED", confidence.GetString()); + await new SurrealDbExporter().ExportAsync(MakeLargeGraph(100, 50), path); + Assert.True(Directory.Exists(path)); + } + finally { Cleanup(path); } } [Fact] - public async Task ExportAsync_Community_Assignments_Exported() + public async Task ExportAsync_NodesWithoutMetadata_Completes() { - // Arrange - var graph = new KnowledgeGraph(); - var node1 = CreateNode("node1", "Node1"); - var node2 = CreateNode("node2", "Node2"); - graph.AddNode(node1); - graph.AddNode(node2); - - graph.AssignCommunities(new Dictionary> - { - [0] = new[] { "node1" }, - [1] = new[] { "node2" } - }); - - var outputPath = Path.Combine(_testRoot, "communities.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert - var json = await File.ReadAllTextAsync(outputPath); - var doc = JsonDocument.Parse(json); - var entities = doc.RootElement.GetProperty("entities"); - - var communities = new List(); - foreach (var entity in entities.EnumerateArray()) + var path = NewDbPath(nameof(ExportAsync_NodesWithoutMetadata_Completes)); + try { - if (entity.TryGetProperty("community", out var communityVal) && communityVal.ValueKind != JsonValueKind.Null) + var graph = new KnowledgeGraph(); + graph.AddNode(new GraphNode { - communities.Add(communityVal.GetInt32()); - } + Id = "test_node", Label = "TestNode", Type = "Class", + FilePath = "Test.cs", Confidence = Confidence.Extracted, + Metadata = null + }); + await new SurrealDbExporter().ExportAsync(graph, path); + Assert.True(Directory.Exists(path)); } - - Assert.NotEmpty(communities); + finally { Cleanup(path); } } [Fact] - public async Task ExportAsync_LargeGraph_Completes() + public async Task ExportAsync_RemoteConfig_UseEndpoint() { - // Arrange - var graph = new KnowledgeGraph(); - - // Create 100 nodes - for (int i = 0; i < 100; i++) - { - graph.AddNode(CreateNode($"node{i}", $"Node{i}")); - } - - // Create some edges - var nodes = graph.GetNodes().ToList(); - for (int i = 0; i < 50; i++) - { - var edge = new GraphEdge - { - Source = nodes[i], - Target = nodes[i + 50], - Relationship = "connects", - Weight = 1.0, - Confidence = Confidence.Extracted, - Metadata = new Dictionary() - }; - graph.AddEdge(edge); - } - - var outputPath = Path.Combine(_testRoot, "large.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert - Assert.True(File.Exists(outputPath)); - var json = await File.ReadAllTextAsync(outputPath); - var doc = JsonDocument.Parse(json); - - var entities = doc.RootElement.GetProperty("entities"); - var relationships = doc.RootElement.GetProperty("relationships"); - - Assert.Equal(100, entities.GetArrayLength()); - Assert.Equal(50, relationships.GetArrayLength()); + var exporter = new SurrealDbExporter( + endpoint: "http://localhost:8000", + username: "root", + password: "root", + ns: "test", + database: "test"); + Assert.Equal("surrealdb", exporter.Format); } - [Fact] - public async Task ExportAsync_EdgeReferences_MatchEntityIds() + private static KnowledgeGraph MakeLargeGraph(int nodes, int edges) { - // Arrange: Graph with special characters in node IDs to verify URI escaping consistency var graph = new KnowledgeGraph(); - var node1 = new GraphNode - { - Id = "Namespace::Class.Method()", - Label = "Method", - Type = "Method", - FilePath = "test.cs", - Confidence = Confidence.Extracted, - Metadata = new Dictionary() - }; - var node2 = CreateNode("simple_node", "Simple"); - graph.AddNode(node1); - graph.AddNode(node2); - - var edge = new GraphEdge - { - Source = node1, - Target = node2, - Relationship = "calls", - Weight = 1.0, - Confidence = Confidence.Extracted, - Metadata = new Dictionary() - }; - graph.AddEdge(edge); - - var outputPath = Path.Combine(_testRoot, "edge_reference_match.db"); - - // Act - await _exporter.ExportAsync(graph, outputPath); - - // Assert: Verify edges can be joined back to entities - var json = await File.ReadAllTextAsync(outputPath); - var doc = JsonDocument.Parse(json); - - var entities = doc.RootElement.GetProperty("entities"); - var relationships = doc.RootElement.GetProperty("relationships"); - - // Collect all entity IDs - var entityIds = new HashSet(); - foreach (var entity in entities.EnumerateArray()) - { - if (entity.TryGetProperty("id", out var idVal) && idVal.ValueKind == JsonValueKind.String) + for (int i = 0; i < nodes; i++) + graph.AddNode(CreateNode($"n{i}", $"N{i}")); + var allNodes = graph.GetNodes().ToList(); + for (int i = 0; i < edges && i + 1 < allNodes.Count; i++) + graph.AddEdge(new GraphEdge { - var idStr = idVal.GetString(); - if (idStr != null) - { - entityIds.Add(idStr); - } - } - } - - // Verify each edge's source and target exist in entity IDs - foreach (var relationship in relationships.EnumerateArray()) - { - Assert.True(relationship.TryGetProperty("source", out var sourceVal), "Relationship should have source"); - Assert.True(relationship.TryGetProperty("target", out var targetVal), "Relationship should have target"); - - var sourceId = sourceVal.GetString(); - var targetId = targetVal.GetString(); - - Assert.NotNull(sourceId); - Assert.NotNull(targetId); - Assert.True(entityIds.Contains(sourceId), $"Edge source '{sourceId}' should match an entity ID"); - Assert.True(entityIds.Contains(targetId), $"Edge target '{targetId}' should match an entity ID"); - } + Source = allNodes[i], Target = allNodes[(i + 1) % allNodes.Count], + Relationship = "connects", Weight = 1.0, + Confidence = Confidence.Extracted, Metadata = new Dictionary() + }); + return graph; } private static KnowledgeGraph CreateSampleGraph() { var graph = new KnowledgeGraph(); - - var node1 = CreateNode("node1", "Node1"); - var node2 = CreateNode("node2", "Node2"); - var node3 = CreateNode("node3", "Node3"); - - graph.AddNode(node1); - graph.AddNode(node2); - graph.AddNode(node3); - - var edge1 = new GraphEdge - { - Source = node1, - Target = node2, - Relationship = "calls", - Weight = 1.0, - Confidence = Confidence.Extracted, - Metadata = new Dictionary() - }; - - var edge2 = new GraphEdge - { - Source = node2, - Target = node3, - Relationship = "imports", - Weight = 1.0, - Confidence = Confidence.Extracted, - Metadata = new Dictionary() - }; - - graph.AddEdge(edge1); - graph.AddEdge(edge2); - + var n1 = CreateNode("node1", "Node1"); + var n2 = CreateNode("node2", "Node2"); + var n3 = CreateNode("node3", "Node3"); + graph.AddNode(n1); graph.AddNode(n2); graph.AddNode(n3); + graph.AddEdge(new GraphEdge { Source = n1, Target = n2, Relationship = "calls", Weight = 1.0, Confidence = Confidence.Extracted, Metadata = new Dictionary() }); + graph.AddEdge(new GraphEdge { Source = n2, Target = n3, Relationship = "imports", Weight = 1.0, Confidence = Confidence.Extracted, Metadata = new Dictionary() }); return graph; } - private static GraphNode CreateNode(string id, string label) - { - return new GraphNode - { - Id = id, - Label = label, - Type = "Entity", - FilePath = "test.cs", - Confidence = Confidence.Extracted, - Metadata = new Dictionary() - }; - } + private static GraphNode CreateNode(string id, string label) => + new() { Id = id, Label = label, Type = "Entity", FilePath = "test.cs", Confidence = Confidence.Extracted, Metadata = new Dictionary() }; } From 5178d344f2f2947cbc0b6fe383b3cb1de3f93258 Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Thu, 16 Jul 2026 14:22:12 +0200 Subject: [PATCH 03/15] Update package versions to 0.8.0 and enhance installation instructions for pre-release support --- .gitignore | 1 + docs/dotnet-tool-install.md | 77 ++++++++++++++++++++++++++-- src/Graphify.Cli/Graphify.Cli.csproj | 2 +- src/Graphify.Sdk/Graphify.Sdk.csproj | 2 +- src/Graphify/Graphify.csproj | 2 +- 5 files changed, 76 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 396e8ff..8605ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ obj/ appsettings.local.json .tokensave/* .claude/PRPs/reports/surrealdb-phase1-schema-report.md +nupkg/* diff --git a/docs/dotnet-tool-install.md b/docs/dotnet-tool-install.md index 60e8c18..1e78762 100644 --- a/docs/dotnet-tool-install.md +++ b/docs/dotnet-tool-install.md @@ -41,17 +41,84 @@ This installs the global tool and adds it to your PATH automatically. If you're developing or testing a local build: -```bash +**PowerShell:** + +```powershell # Build the NuGet package -dotnet pack src/Graphify.Cli/ -c Release +dotnet pack src/Graphify.Cli/ -c Release -o ./nupkg # Install from your local build output +dotnet tool install -g graphify-dotnet ` + --add-source ./nupkg ` + --prerelease +``` + +**Bash:** + +```bash +dotnet pack src/Graphify.Cli/ -c Release -o ./nupkg dotnet tool install -g graphify-dotnet \ - --add-source src/Graphify.Cli/bin/Release \ - --allow-prerelease-versions + --add-source ./nupkg \ + --prerelease ``` -### Method 3: From Source +The `--prerelease` flag is required if the version has a pre-release suffix (e.g. `0.8.0-preview.1`). Use `--no-cache` if you're reinstalling after a rebuild to avoid stale packages. To update after rebuilding: + +**PowerShell:** + +```powershell +dotnet pack src/Graphify.Cli/ -c Release -o ./nupkg +dotnet tool update -g graphify-dotnet ` + --add-source ./nupkg ` + --prerelease +``` + +**Bash:** + +```bash +dotnet pack src/Graphify.Cli/ -c Release -o ./nupkg +dotnet tool update -g graphify-dotnet \ + --add-source ./nupkg \ + --prerelease +``` + +### Method 3: Build for Pre-Release + +To build a pre-release version (before tagging a release), override the version suffix on all packages: + +**PowerShell:** + +```powershell +dotnet pack src/Graphify/ -c Release -o ./nupkg ` + /p:Version=0.8.0-preview.1 +dotnet pack src/Graphify.Sdk/ -c Release -o ./nupkg ` + /p:Version=0.8.0-preview.1 +dotnet pack src/Graphify.Cli/ -c Release -o ./nupkg ` + /p:Version=0.8.0-preview.1 +``` + +**Bash:** + +```bash +dotnet pack src/Graphify/ -c Release -o ./nupkg \ + /p:Version=0.8.0-preview.1 +dotnet pack src/Graphify.Sdk/ -c Release -o ./nupkg \ + /p:Version=0.8.0-preview.1 +dotnet pack src/Graphify.Cli/ -c Release -o ./nupkg \ + /p:Version=0.8.0-preview.1 +``` + +The pre-release label follows [SemVer 2.0](https://semver.org): e.g. `0.8.0-preview.1`, `0.8.0-alpha.1`, `0.8.0-rc.1`. The suffix appears in the `.nupkg` filename and the `--prerelease` flag is required during `dotnet tool install` to resolve it. + +Packages produced: + +| Package | File | +|---------|------| +| Graphify (core library) | `nupkg\graphify-dotnet-core.*.nupkg` | +| Graphify.Sdk | `nupkg\graphify-dotnet-sdk.*.nupkg` | +| Graphify.Cli (global tool) | `nupkg\graphify-dotnet.*.nupkg` + `.snupkg` | + +### Method 4: From Source For quick testing without installing globally: diff --git a/src/Graphify.Cli/Graphify.Cli.csproj b/src/Graphify.Cli/Graphify.Cli.csproj index fe8d63d..1d29075 100644 --- a/src/Graphify.Cli/Graphify.Cli.csproj +++ b/src/Graphify.Cli/Graphify.Cli.csproj @@ -6,7 +6,7 @@ true graphify graphify-dotnet - 0.6.2 + 0.8.0 AI-powered knowledge graph builder for codebases Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify.Sdk/Graphify.Sdk.csproj b/src/Graphify.Sdk/Graphify.Sdk.csproj index a028f72..6e55da1 100644 --- a/src/Graphify.Sdk/Graphify.Sdk.csproj +++ b/src/Graphify.Sdk/Graphify.Sdk.csproj @@ -4,7 +4,7 @@ net10.0 true graphify-dotnet-sdk - 0.6.2 + 0.8.0 GitHub Copilot SDK integration layer for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify/Graphify.csproj b/src/Graphify/Graphify.csproj index 088a865..03742fc 100644 --- a/src/Graphify/Graphify.csproj +++ b/src/Graphify/Graphify.csproj @@ -5,7 +5,7 @@ Graphify.Tests true graphify-dotnet-core - 0.6.2 + 0.8.0 Core graph extraction, graph modeling, clustering, and export pipeline for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet From 79a5f68c40d8d66698568bc95e8f444f65ea9b70 Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Thu, 16 Jul 2026 16:32:13 +0200 Subject: [PATCH 04/15] Refactor MCP server into CLI; remove Graphify.Mcp project - Merged MCP server functionality into the Graphify.Cli project. - Updated documentation to reflect the new `graphify serve` command. - Removed the Graphify.Mcp project and its related files. - Added GraphTools to the CLI for handling MCP operations. - Updated version numbers for all projects to 0.9.0-preview.2. - Enhanced error handling and logging in the new serve command. --- .github/copilot-instructions.md | 6 +- ARCHITECTURE.md | 5 +- CLAUDE.md | 5 +- docs/blog-post.md | 2 +- docs/cli-reference.md | 28 +++-- docs/format-surrealdb.md | 15 ++- docs/getting-started.md | 2 +- docs/mcp-server.md | 63 ++++++----- docs/worked-example.md | 2 +- graphify-dotnet.slnx | 1 - src/Graphify.Cli/Graphify.Cli.csproj | 5 +- .../Mcp}/GraphTools.cs | 11 +- src/Graphify.Cli/Program.cs | 103 ++++++++++++++++++ src/Graphify.Mcp/Graphify.Mcp.csproj | 18 --- src/Graphify.Mcp/McpServerOptions.cs | 17 --- src/Graphify.Mcp/Program.cs | 97 ----------------- src/Graphify.Sdk/Graphify.Sdk.csproj | 2 +- src/Graphify/Export/SurrealDbExporter.cs | 40 ++++--- src/Graphify/Graphify.csproj | 2 +- 19 files changed, 212 insertions(+), 212 deletions(-) rename src/{Graphify.Mcp => Graphify.Cli/Mcp}/GraphTools.cs (98%) delete mode 100644 src/Graphify.Mcp/Graphify.Mcp.csproj delete mode 100644 src/Graphify.Mcp/McpServerOptions.cs delete mode 100644 src/Graphify.Mcp/Program.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 05179b3..dfca1ad 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,7 +20,7 @@ graphify-dotnet/ │ ├── Graphify/ # Core library (graph pipeline, data structures, export) │ ├── Graphify.Cli/ # CLI tool (System.CommandLine) │ ├── Graphify.Sdk/ # GitHub Copilot SDK integration -│ ├── Graphify.Mcp/ # MCP stdio server +│ ├── Graphify.Cli/Mcp/ # MCP server built into CLI │ └── tests/ │ ├── Graphify.Tests/ # Unit tests (xUnit) │ └── Graphify.Integration.Tests/ # Integration tests @@ -48,7 +48,7 @@ graphify-dotnet/ - `src/Graphify/` — Core library (graph pipeline, data structures, export) - `src/Graphify.Cli/` — CLI tool (dotnet tool, System.CommandLine) - `src/Graphify.Sdk/` — GitHub Copilot SDK integration -- `src/Graphify.Mcp/` — MCP stdio server (ModelContextProtocol) +- `src/Graphify.Cli/Mcp/` — MCP server built into the CLI (ModelContextProtocol) - `src/tests/Graphify.Tests/` — Unit tests (xUnit) - `src/tests/Graphify.Integration.Tests/` — Integration tests @@ -68,7 +68,7 @@ graphify-dotnet/ ### Library / Application Projects - Target `net10.0` only (single target — this is a .NET 10 project, not multi-target) -- Project naming: `Graphify`, `Graphify.Cli`, `Graphify.Sdk`, `Graphify.Mcp` +- Project naming: `Graphify`, `Graphify.Cli`, `Graphify.Sdk` - Enable deterministic CI builds: `true` - Include `` for the corresponding test project where needed - `Graphify.Cli` includes `true` for NuGet dotnet-tool publishing diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 29d04c3..a52e21d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -82,9 +82,8 @@ graphify-dotnet/ │ └── PipelineRunner.cs # Pipeline orchestration ├── src/Graphify.Sdk/ # GitHub Copilot SDK integration │ └── CopilotExtractor.cs # Copilot-specific extractors -├── src/Graphify.Mcp/ # MCP stdio server -│ ├── Program.cs # Host setup, graph loading, DI -│ └── GraphTools.cs # ModelContextProtocol tool definitions +├── src/Graphify.Cli/ # CLI + MCP server +│ ├── Mcp/GraphTools.cs # ModelContextProtocol tool definitions └── src/tests/ # Test projects ├── Graphify.Tests/ # Unit tests └── Graphify.Integration.Tests/ # Integration tests diff --git a/CLAUDE.md b/CLAUDE.md index e1a096e..241edd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,9 +44,10 @@ dotnet clean - GitHub Copilot SDK integration - Copilot-specific extractors -### MCP Server (`src/Graphify.Mcp/`) -- Model Context Protocol server (stdio-based) +### MCP Server (built into CLI) +- `graphify serve` — Model Context Protocol server (stdio-based) - Exposes graph operations to Claude and other MCP clients +- Tools defined in `src/Graphify.Cli/Mcp/GraphTools.cs` ### Tests - **Unit tests** (`Graphify.Tests`): Component-level testing diff --git a/docs/blog-post.md b/docs/blog-post.md index b0f9dc3..5ebb3b8 100644 --- a/docs/blog-post.md +++ b/docs/blog-post.md @@ -70,7 +70,7 @@ The HTML export gives you an interactive vis.js graph. Click nodes, search by co ```bash # Build the graph first, then run the MCP server dotnet run --project src/Graphify.Cli -- run . --format json -dotnet run --project src/Graphify.Mcp -- graphify-out/graph.json +graphify serve graphify-out/graph.json ``` Connect Claude Desktop or VS Code and ask "Find the shortest path between the auth module and the database." The server exposes 5 tools for searching, navigating, and analyzing your codebase graph. See the [MCP Server docs](mcp-server.md) for client setup. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b57a04f..a8b1370 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -133,23 +133,31 @@ graphify config folder See [Configuration](configuration.md) for details on the layered config system. -## MCP Server +## `graphify serve` -The `src/Graphify.Mcp/` project is a standalone MCP server that loads a pre-built `graph.json` and exposes it to AI assistants. It is not a CLI subcommand — run it directly: +Serve the knowledge graph over MCP (Model Context Protocol) — AI assistants query it via stdio. ```bash -# From source -dotnet run --project src/Graphify.Mcp -- graphify-out/graph.json - -# Or build once -dotnet publish src/Graphify.Mcp -o ./mcp-server -./mcp-server/Graphify.Mcp graphify-out/graph.json --verbose +graphify serve [graph-path] [options] ``` | Argument | Default | Description | |----------|---------|-------------| -| `graph-path` | `graph.json` | Path to the graph JSON file | -| `--verbose`, `-v` | `false` | Enable detailed logging | +| `graph-path` | `graphify-out/graph.json` | Path to the graph JSON file | +| `--verbose`, `-v` | `false` | Enable detailed logging to stderr | + +### Examples + +```bash +# Serve the default output +graphify serve + +# Serve a specific graph file with verbose logging +graphify serve ./my-project/graph.json --verbose + +# When running from source +dotnet run --project src/Graphify.Cli -- serve graphify-out/graph.json +``` See [MCP Server](mcp-server.md) for client configuration and tool reference. diff --git a/docs/format-surrealdb.md b/docs/format-surrealdb.md index 9165185..ed975b1 100644 --- a/docs/format-surrealdb.md +++ b/docs/format-surrealdb.md @@ -24,7 +24,9 @@ graphify run ./src --format surrealdb Connect to any SurrealDB instance over HTTP or WebSocket. -```bash +**PowerShell:** + +```powershell graphify run ./src --format surrealdb ` --surreal-endpoint http://localhost:8000 ` --surreal-user root ` @@ -33,6 +35,17 @@ graphify run ./src --format surrealdb ` --surreal-db codebase ``` +**Bash:** + +```bash +graphify run ./src --format surrealdb \ + --surreal-endpoint http://localhost:8000 \ + --surreal-user root \ + --surreal-pass mypassword \ + --surreal-ns graphify \ + --surreal-db codebase +``` + ### CLI Options | Option | Default | Description | diff --git a/docs/getting-started.md b/docs/getting-started.md index 41b0e79..8d00cfc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -224,7 +224,7 @@ This generates: Once you have a `graph.json`, run the MCP server to let Claude, Copilot, or any MCP client query your knowledge graph. ```bash -dotnet run --project src/Graphify.Mcp -- graphify-out/graph.json +graphify serve graphify-out/graph.json ``` Then connect your AI assistant — see the [MCP Server](mcp-server.md) guide for client configuration. You can ask questions like "Find all authentication-related nodes" or "What's the shortest path between UserService and DatabaseContext?" diff --git a/docs/mcp-server.md b/docs/mcp-server.md index b75c11f..470a680 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -4,7 +4,7 @@ ## Overview -The MCP (Model Context Protocol) server loads a pre-built graph from its JSON export and exposes it as 5 tools. AI assistants connect over stdio and query the graph as if it were a live database — no HTTP server, no separate daemon. +The MCP (Model Context Protocol) server is built into the CLI as `graphify serve`. It loads a pre-built graph from its JSON export and exposes it as 5 tools. AI assistants connect over stdio and query the graph as if it were a live database — no HTTP server, no separate daemon. ``` ┌─────────────────┐ ┌──────────────────────────┐ @@ -32,34 +32,29 @@ graphify run ./my-project --format json ### Step 2: Run the MCP server ```bash -dotnet run --project src/Graphify.Mcp -- graphify-out/graph.json +graphify serve graphify-out/graph.json ``` -Or build once and run the binary directly: +For verbose logging: ```bash -dotnet publish src/Graphify.Mcp -o ./mcp-server -./mcp-server/Graphify.Mcp graphify-out/graph.json +graphify serve graphify-out/graph.json --verbose ``` -The server no output on success — all logging goes to stderr, stdout is reserved for JSON-RPC protocol messages. +The server outputs no messages on success — all logging goes to stderr, stdout is reserved for JSON-RPC protocol messages. ### Step 3: Connect your AI assistant +If the `graphify` global tool is installed, most MCP clients can reference it directly: + **Claude Desktop** — add to your `claude_desktop_config.json`: ```json { "mcpServers": { "graphify": { - "command": "dotnet", - "args": [ - "run", - "--project", - "/path/to/graphify-dotnet/src/Graphify.Mcp", - "--", - "/path/to/your/graph.json" - ] + "command": "graphify", + "args": ["serve", "/path/to/your/graph.json"] } } } @@ -72,15 +67,25 @@ The server no output on success — all logging goes to stderr, stdout is reserv "servers": { "graphify": { "type": "stdio", + "command": "graphify", + "args": ["serve", "/path/to/your/graph.json"], + "description": "Knowledge graph for my codebase" + } + } +} +``` + +When running from source: + +```json +{ + "mcpServers": { + "graphify": { "command": "dotnet", "args": [ - "run", - "--project", - "/path/to/graphify-dotnet/src/Graphify.Mcp", - "--", - "/path/to/your/graph.json" - ], - "description": "Knowledge graph for my codebase" + "run", "--project", "/path/to/graphify-dotnet/src/Graphify.Cli", + "--", "serve", "/path/to/your/graph.json" + ] } } } @@ -153,17 +158,17 @@ Returns node/edge counts, community count, average degree, isolated nodes, top-N ## CLI Options ```bash -Graphify.Mcp [--verbose] +graphify serve [graph-path] [options] ``` -| Argument | Description | -|----------|-------------| -| `graph-path` | Path to the `graph.json` file (default: `graph.json`) | -| `--verbose`, `-v` | Enable detailed logging (node/edge counts, load progress) | +| Argument | Default | Description | +|----------|---------|-------------| +| `graph-path` | `graphify-out/graph.json` | Path to the graph JSON file | +| `--verbose`, `-v` | `false` | Enable detailed logging to stderr | ## Architecture -The server uses the `ModelContextProtocol` SDK with stdio transport. All JSON-RPC messages flow over stdout; logging goes exclusively to stderr. Tools are auto-discovered via `[McpServerTool]` attributes on the `GraphTools` class. +The server is part of the `Graphify.Cli` project and uses the `ModelContextProtocol` SDK with stdio transport. All JSON-RPC messages flow over stdout; logging goes exclusively to stderr. Tools are auto-discovered via `[McpServerTool]` attributes on the `GraphTools` class. Sequence on startup: @@ -177,11 +182,11 @@ The server is read-only — it does not run pipeline stages, trigger re-extracti ## Integration with CI/CD -Regenerate the graph and restart the server on each build: +Regenerate the graph and serve after each build: ```bash graphify run ./src --format json -dotnet run --project src/Graphify.Mcp -- graphify-out/graph.json +graphify serve graphify-out/graph.json --verbose ``` ## See Also diff --git a/docs/worked-example.md b/docs/worked-example.md index b5cf2ac..ffca147 100644 --- a/docs/worked-example.md +++ b/docs/worked-example.md @@ -213,7 +213,7 @@ The analysis report we walked through above — god nodes, communities, surprisi The MCP server loads `graph.json` and exposes it as tools for AI assistants. Start it: ```bash -dotnet run --project src/Graphify.Mcp -- samples/mini-library/graphify-out/graph.json +graphify serve samples/mini-library/graphify-out/graph.json ``` Then connect Claude Desktop or VS Code and ask questions: diff --git a/graphify-dotnet.slnx b/graphify-dotnet.slnx index 9b7f1db..be1c86f 100644 --- a/graphify-dotnet.slnx +++ b/graphify-dotnet.slnx @@ -3,7 +3,6 @@ - diff --git a/src/Graphify.Cli/Graphify.Cli.csproj b/src/Graphify.Cli/Graphify.Cli.csproj index 1d29075..1c5c90c 100644 --- a/src/Graphify.Cli/Graphify.Cli.csproj +++ b/src/Graphify.Cli/Graphify.Cli.csproj @@ -6,7 +6,7 @@ true graphify graphify-dotnet - 0.8.0 + 0.9.0-preview.2 AI-powered knowledge graph builder for codebases Bruno Capuano https://github.com/elbruno/graphify-dotnet @@ -30,6 +30,9 @@ + + + diff --git a/src/Graphify.Mcp/GraphTools.cs b/src/Graphify.Cli/Mcp/GraphTools.cs similarity index 98% rename from src/Graphify.Mcp/GraphTools.cs rename to src/Graphify.Cli/Mcp/GraphTools.cs index 2866c09..5b66aff 100644 --- a/src/Graphify.Mcp/GraphTools.cs +++ b/src/Graphify.Cli/Mcp/GraphTools.cs @@ -4,11 +4,8 @@ using Graphify.Models; using ModelContextProtocol.Server; -namespace Graphify.Mcp; +namespace Graphify.Cli.Mcp; -/// -/// MCP tools that expose knowledge graph operations. -/// [McpServerToolType] public class GraphTools { @@ -97,7 +94,6 @@ public string Path( try { - // Simple BFS path finding var visited = new HashSet(); var queue = new Queue<(GraphNode Node, List Path)>(); queue.Enqueue((sourceNode, new List { sourceNode })); @@ -217,7 +213,7 @@ public string Communities( if (communityId.HasValue) { var communityNodes = _graph.GetNodesByCommunity(communityId.Value).ToList(); - + if (!communityNodes.Any()) { return JsonSerializer.Serialize(new { error = $"Community {communityId} not found or has no members" }); @@ -282,7 +278,7 @@ public string Analyze( } var topNodesByDegree = _graph.GetHighestDegreeNodes(topN).ToList(); - + var nodesByCommunity = allNodes.Where(n => n.Community.HasValue) .GroupBy(n => n.Community!.Value) .Count(); @@ -325,5 +321,4 @@ public string Analyze( return JsonSerializer.Serialize(analysis); } - } diff --git a/src/Graphify.Cli/Program.cs b/src/Graphify.Cli/Program.cs index 9219d34..fe13c1b 100644 --- a/src/Graphify.Cli/Program.cs +++ b/src/Graphify.Cli/Program.cs @@ -1,8 +1,16 @@ using System.CommandLine; +using System.Text.Json; using Graphify.Cli.Configuration; +using Graphify.Cli.Mcp; +using Graphify.Graph; +using Graphify.Models; using Graphify.Pipeline; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; using Spectre.Console; var rootCommand = new RootCommand("graphify-dotnet: AI-powered knowledge graph builder for codebases"); @@ -301,6 +309,95 @@ static void AddPipelineOptions(Command cmd, rootCommand.Subcommands.Add(benchmarkCommand); +// ── serve command ──────────────────────────────────────────────────────── +var servePathArg = new Argument("graph-path") +{ + Description = "Path to the graph JSON file", + DefaultValueFactory = _ => "graphify-out/graph.json" +}; + +var serveVerboseOpt = new Option("--verbose", "-v") +{ + Description = "Enable verbose logging" +}; + +var serveCommand = new Command("serve", "Serve the knowledge graph over MCP (Model Context Protocol)"); +serveCommand.Arguments.Add(servePathArg); +serveCommand.Options.Add(serveVerboseOpt); + +serveCommand.SetAction(async (parseResult, cancellationToken) => +{ + var graphPath = parseResult.GetValue(servePathArg)!; + var verbose = parseResult.GetValue(serveVerboseOpt); + + var builder = Host.CreateApplicationBuilder(args); + + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(options => + { + options.LogToStandardErrorThreshold = verbose ? LogLevel.Trace : LogLevel.Warning; + }); + + KnowledgeGraph graph; + try + { + if (!File.Exists(graphPath)) + { + await Console.Error.WriteLineAsync($"Error: Graph file not found at '{graphPath}'"); + return 1; + } + + var json = await File.ReadAllTextAsync(graphPath, cancellationToken); + var graphData = JsonSerializer.Deserialize(json); + + if (graphData == null) + { + await Console.Error.WriteLineAsync($"Error: Failed to parse graph JSON from '{graphPath}'"); + return 1; + } + + graph = new KnowledgeGraph(); + + foreach (var node in graphData.Nodes) + { + graph.AddNode(node); + } + + foreach (var edge in graphData.Edges) + { + graph.AddEdge(edge); + } + + if (verbose) + { + await Console.Error.WriteLineAsync($"Loaded graph: {graph.NodeCount} nodes, {graph.EdgeCount} edges"); + } + } + catch (Exception ex) + { + await Console.Error.WriteLineAsync($"Error loading graph: {ex.Message}"); + return 1; + } + + builder.Services.AddSingleton(graph); + builder.Services.AddSingleton(); + builder.Services.AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + + var app = builder.Build(); + + if (verbose) + { + await Console.Error.WriteLineAsync("Graphify MCP Server started"); + } + + await app.RunAsync(cancellationToken); + return 0; +}); + +rootCommand.Subcommands.Add(serveCommand); + // ── config command ─────────────────────────────────────────────────── var configCommand = new Command("config", "Configuration management"); @@ -445,3 +542,9 @@ static string FormatValue(string? value) { return value != null ? $"[green]{value}[/]" : "[grey](not set)[/]"; } + +file record GraphJsonData +{ + public List Nodes { get; init; } = new(); + public List Edges { get; init; } = new(); +} diff --git a/src/Graphify.Mcp/Graphify.Mcp.csproj b/src/Graphify.Mcp/Graphify.Mcp.csproj deleted file mode 100644 index 89b89c7..0000000 --- a/src/Graphify.Mcp/Graphify.Mcp.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - Exe - net10.0 - - - - - - - - - - - - - diff --git a/src/Graphify.Mcp/McpServerOptions.cs b/src/Graphify.Mcp/McpServerOptions.cs deleted file mode 100644 index d0addba..0000000 --- a/src/Graphify.Mcp/McpServerOptions.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Graphify.Mcp; - -/// -/// Configuration options for the MCP server. -/// -public sealed record McpServerOptions -{ - /// - /// Path to the pre-built knowledge graph JSON file. - /// - public string GraphPath { get; init; } = "graph.json"; - - /// - /// Enable verbose logging to stderr. - /// - public bool Verbose { get; init; } -} diff --git a/src/Graphify.Mcp/Program.cs b/src/Graphify.Mcp/Program.cs deleted file mode 100644 index 1e7bfef..0000000 --- a/src/Graphify.Mcp/Program.cs +++ /dev/null @@ -1,97 +0,0 @@ -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Server; -using System.Text.Json; -using Graphify.Graph; -using Graphify.Models; -using Graphify.Mcp; - -// Parse command line arguments -var graphPath = args.Length > 0 ? args[0] : "graph.json"; -var verbose = args.Contains("--verbose") || args.Contains("-v"); - -var builder = Host.CreateApplicationBuilder(args); - -// Configure logging to stderr (MCP requirement: stdout is reserved for JSON-RPC) -builder.Logging.ClearProviders(); -builder.Logging.AddConsole(options => -{ - options.LogToStandardErrorThreshold = verbose ? LogLevel.Trace : LogLevel.Warning; -}); - -// Load the knowledge graph from JSON -KnowledgeGraph graph; -try -{ - if (!File.Exists(graphPath)) - { - Console.Error.WriteLine($"Error: Graph file not found at '{graphPath}'"); - Console.Error.WriteLine("Usage: Graphify.Mcp [--verbose]"); - return 1; - } - - var json = await File.ReadAllTextAsync(graphPath); - var graphData = JsonSerializer.Deserialize(json); - - if (graphData == null) - { - Console.Error.WriteLine($"Error: Failed to parse graph JSON from '{graphPath}'"); - return 1; - } - - graph = new KnowledgeGraph(); - - // Add all nodes - foreach (var node in graphData.Nodes) - { - graph.AddNode(node); - } - - // Add all edges - foreach (var edge in graphData.Edges) - { - graph.AddEdge(edge); - } - - if (verbose) - { - Console.Error.WriteLine($"Loaded graph: {graph.NodeCount} nodes, {graph.EdgeCount} edges"); - } -} -catch (Exception ex) -{ - Console.Error.WriteLine($"Error loading graph: {ex.Message}"); - return 1; -} - -// Register the graph as a singleton -builder.Services.AddSingleton(graph); - -// Register GraphTools -builder.Services.AddSingleton(); - -// Configure MCP server with stdio transport -builder.Services.AddMcpServer() - .WithStdioServerTransport() - .WithToolsFromAssembly(); - -var app = builder.Build(); - -if (verbose) -{ - Console.Error.WriteLine("Graphify MCP Server starting..."); -} - -await app.RunAsync(); - -return 0; - -/// -/// JSON deserialization model for graph data. -/// -file record GraphJsonData -{ - public List Nodes { get; init; } = new(); - public List Edges { get; init; } = new(); -} diff --git a/src/Graphify.Sdk/Graphify.Sdk.csproj b/src/Graphify.Sdk/Graphify.Sdk.csproj index 6e55da1..4aa0796 100644 --- a/src/Graphify.Sdk/Graphify.Sdk.csproj +++ b/src/Graphify.Sdk/Graphify.Sdk.csproj @@ -4,7 +4,7 @@ net10.0 true graphify-dotnet-sdk - 0.8.0 + 0.9.0-preview.2 GitHub Copilot SDK integration layer for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify/Export/SurrealDbExporter.cs b/src/Graphify/Export/SurrealDbExporter.cs index 9b57ceb..3c36dbd 100644 --- a/src/Graphify/Export/SurrealDbExporter.cs +++ b/src/Graphify/Export/SurrealDbExporter.cs @@ -1,6 +1,7 @@ using Graphify.Graph; using SurrealDb.Embedded.RocksDb; using SurrealDb.Net; +using Microsoft.Extensions.DependencyInjection; using SurrealDb.Net.Models; using SurrealDb.Net.Models.Auth; @@ -86,27 +87,32 @@ private static SurrealDbRocksDbClient CreateRocksDbClient(string outputPath) return new SurrealDbRocksDbClient(outputPath); } - private async Task ExportRemoteAsync(KnowledgeGraph graph, - CancellationToken cancellationToken) +private async Task ExportRemoteAsync(KnowledgeGraph graph, + CancellationToken cancellationToken) +{ + var configuration = SurrealDbOptions + .Create() + .WithEndpoint(_endpoint!) + .WithNamespace(_namespace ?? "graphify") + .WithDatabase(_database ?? "codebase") + .WithUsername(_username) + .WithPassword(_password) + .Build(); + + await using var db = new SurrealDbClient(configuration); + + if (_username is not null) { - await using var db = new SurrealDbClient(_endpoint!); - - if (_username is not null) + await db.SignIn(new RootAuth { - await db.SignIn(new RootAuth - { - Username = _username, - Password = _password ?? "" - }); - } - - await db.Use( - _namespace ?? "graphify", - _database ?? "codebase"); - - await ExportToClientAsync(db, graph, cancellationToken); + Username = _username, + Password = _password ?? "" + }); } + await ExportToClientAsync(db, graph, cancellationToken); +} + private static async Task ExportToClientAsync(ISurrealDbClient db, KnowledgeGraph graph, CancellationToken cancellationToken) { diff --git a/src/Graphify/Graphify.csproj b/src/Graphify/Graphify.csproj index 03742fc..edeac33 100644 --- a/src/Graphify/Graphify.csproj +++ b/src/Graphify/Graphify.csproj @@ -5,7 +5,7 @@ Graphify.Tests true graphify-dotnet-core - 0.8.0 + 0.9.0-preview.2 Core graph extraction, graph modeling, clustering, and export pipeline for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet From cb96d12b9a37100f6b3c0098e13325543397c29b Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Fri, 17 Jul 2026 15:56:14 +0200 Subject: [PATCH 05/15] Add SurrealDB submodule for integration with the project --- .gitmodules | 3 +++ lib/surrealdb.net | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 lib/surrealdb.net diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c027e50 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/surrealdb.net"] + path = lib/surrealdb.net + url = https://github.com/surrealdb/surrealdb.net.git diff --git a/lib/surrealdb.net b/lib/surrealdb.net new file mode 160000 index 0000000..64e7e03 --- /dev/null +++ b/lib/surrealdb.net @@ -0,0 +1 @@ +Subproject commit 64e7e0376301ac5a6b09a308a865da41d007e26f From 4081223874186cde3247ebc64989022adf36409e Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Fri, 17 Jul 2026 16:09:29 +0200 Subject: [PATCH 06/15] Integrate SurrealDB projects and update version to 0.9.0-preview.4; add nuget.config for package sources --- graphify-dotnet.slnx | 4 +++ nuget.config | 33 ++++++++++++++++++++++ src/Graphify.Cli/Graphify.Cli.csproj | 2 +- src/Graphify.Sdk/Graphify.Sdk.csproj | 2 +- src/Graphify/Export/SurrealDbExporter.cs | 35 ++++++++++++++++++++++-- src/Graphify/Graphify.csproj | 6 ++-- 6 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 nuget.config diff --git a/graphify-dotnet.slnx b/graphify-dotnet.slnx index be1c86f..0dc155b 100644 --- a/graphify-dotnet.slnx +++ b/graphify-dotnet.slnx @@ -8,4 +8,8 @@ + + + + diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..ffb273b --- /dev/null +++ b/nuget.config @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Graphify.Cli/Graphify.Cli.csproj b/src/Graphify.Cli/Graphify.Cli.csproj index 1c5c90c..6affb9f 100644 --- a/src/Graphify.Cli/Graphify.Cli.csproj +++ b/src/Graphify.Cli/Graphify.Cli.csproj @@ -6,7 +6,7 @@ true graphify graphify-dotnet - 0.9.0-preview.2 + 0.9.0-preview.4 AI-powered knowledge graph builder for codebases Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify.Sdk/Graphify.Sdk.csproj b/src/Graphify.Sdk/Graphify.Sdk.csproj index 4aa0796..54b2c40 100644 --- a/src/Graphify.Sdk/Graphify.Sdk.csproj +++ b/src/Graphify.Sdk/Graphify.Sdk.csproj @@ -4,7 +4,7 @@ net10.0 true graphify-dotnet-sdk - 0.9.0-preview.2 + 0.9.0-preview.4 GitHub Copilot SDK integration layer for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify/Export/SurrealDbExporter.cs b/src/Graphify/Export/SurrealDbExporter.cs index 3c36dbd..e83af3e 100644 --- a/src/Graphify/Export/SurrealDbExporter.cs +++ b/src/Graphify/Export/SurrealDbExporter.cs @@ -124,7 +124,7 @@ private static async Task ExportToClientAsync(ISurrealDbClient db, foreach (var node in nodes) { var escapedId = Uri.EscapeDataString(node.Id); - await db.Create("entity", new + await db.Create("entity", new SurrealDbEntity { Id = (RecordId)("entity", escapedId), label = node.Label, @@ -144,7 +144,7 @@ private static async Task ExportToClientAsync(ISurrealDbClient db, var edge = edges[i]; var escapedSource = Uri.EscapeDataString(edge.Source.Id); var escapedTarget = Uri.EscapeDataString(edge.Target.Id); - await db.Create("relationship", new + await db.Create("relationship", new SurrealDbRelationship { Id = (RecordId)("relationship", escapedSource + "->" + escapedTarget + "-" + i), source = (RecordId)("entity", escapedSource), @@ -168,4 +168,35 @@ await db.Query($""" """); } + /// + /// Concrete type for SurrealDB entity records. + /// Dahomey.Cbor cannot serialize anonymous types; concrete types are required. + /// + internal sealed class SurrealDbEntity + { + public RecordId? Id { get; set; } + public string? label { get; set; } + public string? kind { get; set; } + public string? filePath { get; set; } + public string? language { get; set; } + public string? confidence { get; set; } + public int? community { get; set; } + public Dictionary? metadata { get; set; } + } + + /// + /// Concrete type for SurrealDB relationship records. + /// Dahomey.Cbor cannot serialize anonymous types; concrete types are required. + /// + internal sealed class SurrealDbRelationship + { + public RecordId? Id { get; set; } + public RecordId? source { get; set; } + public RecordId? target { get; set; } + public string? type { get; set; } + public double weight { get; set; } + public string? confidence { get; set; } + public Dictionary? metadata { get; set; } + } + } diff --git a/src/Graphify/Graphify.csproj b/src/Graphify/Graphify.csproj index edeac33..612b480 100644 --- a/src/Graphify/Graphify.csproj +++ b/src/Graphify/Graphify.csproj @@ -5,7 +5,7 @@ Graphify.Tests true graphify-dotnet-core - 0.9.0-preview.2 + 0.9.0-preview.4 Core graph extraction, graph modeling, clustering, and export pipeline for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet @@ -23,8 +23,8 @@ - - + + From 7a354eda3a5cd167074f1705a32330c4e19b2912 Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Mon, 20 Jul 2026 16:34:13 +0200 Subject: [PATCH 07/15] Add .claude and .tokensave directories to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 78f5f4a..125fedf 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ obj/ # Local config (may contain secrets) appsettings.local.json +.claude/* +.tokensave/* From 1e0f22186b89419fd742061ff9bce69a465980b1 Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Tue, 21 Jul 2026 10:17:17 +0200 Subject: [PATCH 08/15] feat(cli): add init command and SurrealDB backend support for serve - Introduce `init` command to install or uninstall MCP agent instructions in codebases - Implement agent detection and instruction snippet injection for multiple coding agents - Add SurrealDB embedded and remote backend options to `serve` command for graph data storage - Refactor MCP GraphTools to use IGraphBackend abstraction for unified graph operations - Implement MemoryGraphBackend for in-memory graph serving from JSON files - Define SurrealDbGraphBackend for SurrealDB integration (partial implementation implied) - Add detailed GraphToolResponses DTOs to ensure consistent JSON output across backends - Provide canonical MCP instructions and snippet management utilities for agent integration - Enhance serve command options to configure SurrealDB connection and authentication - Improve error handling and verbose logging for graph loading and SurrealDB connections - Update project structure to separate graph backends and initialization logic for maintainability --- src/Graphify.Cli/Init/AgentDetector.cs | 37 + src/Graphify.Cli/Init/AgentFileResolver.cs | 18 + src/Graphify.Cli/Init/AgentType.cs | 12 + src/Graphify.Cli/Init/InitService.cs | 144 ++++ src/Graphify.Cli/Init/InstructionTemplate.cs | 41 ++ src/Graphify.Cli/Init/SnippetInjector.cs | 95 +++ src/Graphify.Cli/Mcp/GraphToolResponses.cs | 287 ++++++++ src/Graphify.Cli/Mcp/GraphTools.cs | 299 +------- src/Graphify.Cli/Mcp/IGraphBackend.cs | 15 + src/Graphify.Cli/Mcp/MemoryGraphBackend.cs | 309 ++++++++ src/Graphify.Cli/Mcp/SurrealDbGraphBackend.cs | 679 ++++++++++++++++++ src/Graphify.Cli/Program.cs | 201 +++++- .../Graphify.Tests/Init/AgentDetectorTests.cs | 127 ++++ .../Graphify.Tests/Init/InitServiceTests.cs | 109 +++ .../Init/SnippetInjectorTests.cs | 148 ++++ .../Mcp/MemoryGraphBackendTests.cs | 249 +++++++ 16 files changed, 2467 insertions(+), 303 deletions(-) create mode 100644 src/Graphify.Cli/Init/AgentDetector.cs create mode 100644 src/Graphify.Cli/Init/AgentFileResolver.cs create mode 100644 src/Graphify.Cli/Init/AgentType.cs create mode 100644 src/Graphify.Cli/Init/InitService.cs create mode 100644 src/Graphify.Cli/Init/InstructionTemplate.cs create mode 100644 src/Graphify.Cli/Init/SnippetInjector.cs create mode 100644 src/Graphify.Cli/Mcp/GraphToolResponses.cs create mode 100644 src/Graphify.Cli/Mcp/IGraphBackend.cs create mode 100644 src/Graphify.Cli/Mcp/MemoryGraphBackend.cs create mode 100644 src/Graphify.Cli/Mcp/SurrealDbGraphBackend.cs create mode 100644 src/tests/Graphify.Tests/Init/AgentDetectorTests.cs create mode 100644 src/tests/Graphify.Tests/Init/InitServiceTests.cs create mode 100644 src/tests/Graphify.Tests/Init/SnippetInjectorTests.cs create mode 100644 src/tests/Graphify.Tests/Mcp/MemoryGraphBackendTests.cs diff --git a/src/Graphify.Cli/Init/AgentDetector.cs b/src/Graphify.Cli/Init/AgentDetector.cs new file mode 100644 index 0000000..15b4c55 --- /dev/null +++ b/src/Graphify.Cli/Init/AgentDetector.cs @@ -0,0 +1,37 @@ +namespace Graphify.Cli.Init; + +public static class AgentDetector +{ + public static List Detect(string projectDir) + { + ArgumentException.ThrowIfNullOrWhiteSpace(projectDir); + + var detected = new List(); + + // Claude Code: ./CLAUDE.md + if (File.Exists(Path.Combine(projectDir, "CLAUDE.md"))) + detected.Add(AgentType.Claude); + + // Copilot: ./.github/copilot-instructions.md + if (File.Exists(Path.Combine(projectDir, ".github", "copilot-instructions.md"))) + detected.Add(AgentType.Copilot); + + // OpenCode: ./.opencode/instructions.md + if (File.Exists(Path.Combine(projectDir, ".opencode", "instructions.md"))) + detected.Add(AgentType.OpenCode); + + // Qoder: ./.squad/config.json + if (File.Exists(Path.Combine(projectDir, ".squad", "config.json"))) + detected.Add(AgentType.Qoder); + + // Cursor: ./.cursorrules + if (File.Exists(Path.Combine(projectDir, ".cursorrules"))) + detected.Add(AgentType.Cursor); + + // Windsurf: ./.windsurfrules + if (File.Exists(Path.Combine(projectDir, ".windsurfrules"))) + detected.Add(AgentType.Windsurf); + + return detected; + } +} diff --git a/src/Graphify.Cli/Init/AgentFileResolver.cs b/src/Graphify.Cli/Init/AgentFileResolver.cs new file mode 100644 index 0000000..45443f9 --- /dev/null +++ b/src/Graphify.Cli/Init/AgentFileResolver.cs @@ -0,0 +1,18 @@ +namespace Graphify.Cli.Init; + +public static class AgentFileResolver +{ + public static string GetInstructionFilePath(AgentType agent, string projectDir) + { + return agent switch + { + AgentType.Claude => Path.Combine(projectDir, "CLAUDE.md"), + AgentType.Copilot => Path.Combine(projectDir, ".github", "copilot-instructions.md"), + AgentType.OpenCode => Path.Combine(projectDir, ".opencode", "instructions.md"), + AgentType.Qoder => Path.Combine(projectDir, ".squad", "instructions.md"), + AgentType.Cursor => Path.Combine(projectDir, ".cursorrules"), + AgentType.Windsurf => Path.Combine(projectDir, ".windsurfrules"), + _ => throw new ArgumentOutOfRangeException(nameof(agent), $"Unknown agent type: {agent}") + }; + } +} diff --git a/src/Graphify.Cli/Init/AgentType.cs b/src/Graphify.Cli/Init/AgentType.cs new file mode 100644 index 0000000..f0d6bdc --- /dev/null +++ b/src/Graphify.Cli/Init/AgentType.cs @@ -0,0 +1,12 @@ +namespace Graphify.Cli.Init; + +[Flags] +public enum AgentType +{ + Claude = 1, + Copilot = 2, + OpenCode = 4, + Qoder = 8, + Cursor = 16, + Windsurf = 32 +} diff --git a/src/Graphify.Cli/Init/InitService.cs b/src/Graphify.Cli/Init/InitService.cs new file mode 100644 index 0000000..2eb22b7 --- /dev/null +++ b/src/Graphify.Cli/Init/InitService.cs @@ -0,0 +1,144 @@ +namespace Graphify.Cli.Init; + +public sealed class InitService +{ + private readonly TextWriter _output; + private readonly string _projectDir; + + public InitService(TextWriter output, string projectDir) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + _projectDir = projectDir ?? throw new ArgumentNullException(nameof(projectDir)); + } + + public async Task RunAsync( + string? installAgents = null, + bool uninstall = false, + bool force = false) + { + if (uninstall) + { + return await UninstallAsync(); + } + + // Determine which agents to target + List targets; + if (!string.IsNullOrWhiteSpace(installAgents)) + { + targets = ParseAgentList(installAgents); + } + else + { + targets = AgentDetector.Detect(_projectDir); + } + + if (targets.Count == 0) + { + await _output.WriteLineAsync("No supported coding agents detected."); + await _output.WriteLineAsync("To target specific agents, use:"); + await _output.WriteLineAsync(" graphify init --install copilot,claude"); + await _output.WriteLineAsync(); + await _output.WriteLineAsync("Supported agents: claude, copilot, opencode, qoder, cursor, windsurf"); + return 0; + } + + // 1. Write canonical instructions + var written = SnippetInjector.WriteCanonicalInstructions(_projectDir, force); + if (written) + { + await _output.WriteLineAsync("Wrote canonical instructions to .graphify/mcp-instructions.md"); + } + else + { + await _output.WriteLineAsync("Canonical instructions already present (.graphify/mcp-instructions.md). Use --force to regenerate."); + } + + // 2. Inject pointer snippets for each agent + var injected = 0; + var skipped = 0; + + foreach (var agent in targets) + { + var filePath = AgentFileResolver.GetInstructionFilePath(agent, _projectDir); + var agentName = agent.ToString().ToLowerInvariant(); + + if (SnippetInjector.InjectSnippet(filePath)) + { + await _output.WriteLineAsync($" Injected pointer into {agentName} ({filePath})"); + injected++; + } + else + { + skipped++; + } + } + + if (injected > 0) + { + await _output.WriteLineAsync($"Configured {injected} agent(s) with MCP knowledge graph pointers."); + } + if (skipped > 0) + { + await _output.WriteLineAsync($"{skipped} agent(s) already configured (use --force to overwrite)."); + } + + return 0; + } + + private async Task UninstallAsync() + { + var targets = AgentDetector.Detect(_projectDir); + var removed = 0; + + foreach (var agent in targets) + { + var filePath = AgentFileResolver.GetInstructionFilePath(agent, _projectDir); + if (SnippetInjector.RemoveSnippet(filePath)) + { + await _output.WriteLineAsync($" Removed pointer from {agent.ToString().ToLowerInvariant()} ({filePath})"); + removed++; + } + } + + if (SnippetInjector.RemoveCanonicalInstructions(_projectDir)) + { + await _output.WriteLineAsync("Removed .graphify/mcp-instructions.md"); + } + + if (removed == 0 && !File.Exists(Path.Combine(_projectDir, ".graphify", "mcp-instructions.md"))) + { + await _output.WriteLineAsync("No graphify MCP instructions found to remove."); + } + else + { + await _output.WriteLineAsync($"Removed graphify MCP instructions from {removed} agent(s)."); + } + + return 0; + } + + private static List ParseAgentList(string agents) + { + var result = new List(); + var parts = agents.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var part in parts) + { + var agent = part.ToLowerInvariant() switch + { + "claude" => AgentType.Claude, + "copilot" => AgentType.Copilot, + "opencode" => AgentType.OpenCode, + "qoder" => AgentType.Qoder, + "cursor" => AgentType.Cursor, + "windsurf" => AgentType.Windsurf, + _ => (AgentType?)null + }; + + if (agent.HasValue && !result.Contains(agent.Value)) + result.Add(agent.Value); + } + + return result; + } +} diff --git a/src/Graphify.Cli/Init/InstructionTemplate.cs b/src/Graphify.Cli/Init/InstructionTemplate.cs new file mode 100644 index 0000000..636f456 --- /dev/null +++ b/src/Graphify.Cli/Init/InstructionTemplate.cs @@ -0,0 +1,41 @@ +namespace Graphify.Cli.Init; + +public static class InstructionTemplate +{ + /// + /// Canonical MCP instructions written to .graphify/mcp-instructions.md. + /// Kept intentionally concise (~400 tokens) for token efficiency. + /// + public const string CanonicalInstructions = """ +# MCP Knowledge Graph: graphify + +This project has a knowledge graph served via MCP (`graphify serve`). + +## Tools +- **Analyze** -- Graph overview. Call this first (returns communities, god nodes, type distribution). +- **Communities** -- Pre-computed code modules (Louvain clusters, zero-token). +- **Query** -- Search nodes by name/label/type. Returns IDs for Explain/Path. +- **Explain** -- Full node details + all incoming/outgoing connections. +- **Path** -- Shortest path between two nodes (BFS, requires IDs). + +## Investigation Protocol +1. `Analyze()` -> codebase shape (~200 tokens) +2. `Communities()` -> module clusters +3. `Query("term")` -> find specific nodes +4. `Explain("id")` -> deep-dive relationships +5. `Path("a", "b")` -> trace dependency chains + +## When to Read Source +The graph captures structure and relationships only -- not method implementations. +After identifying relevant files via Explain/Query, read source files for implementation details. +"""; + + public const string SnippetPointer = """ + +MCP knowledge graph available. See `.graphify/mcp-instructions.md` for tools and investigation protocol. + +"""; + + public const string SectionStartMarker = ""; + public const string SectionEndMarker = ""; +} diff --git a/src/Graphify.Cli/Init/SnippetInjector.cs b/src/Graphify.Cli/Init/SnippetInjector.cs new file mode 100644 index 0000000..7d52a62 --- /dev/null +++ b/src/Graphify.Cli/Init/SnippetInjector.cs @@ -0,0 +1,95 @@ +namespace Graphify.Cli.Init; + +public static class SnippetInjector +{ + /// + /// Write the canonical instructions to .graphify/mcp-instructions.md. + /// Returns true if written, false if skipped (already exists and not forced). + /// + public static bool WriteCanonicalInstructions(string projectDir, bool force) + { + var graphifyDir = Path.Combine(projectDir, ".graphify"); + var instructionsPath = Path.Combine(graphifyDir, "mcp-instructions.md"); + + if (File.Exists(instructionsPath) && !force) + return false; + + Directory.CreateDirectory(graphifyDir); + File.WriteAllText(instructionsPath, InstructionTemplate.CanonicalInstructions); + return true; + } + + /// + /// Inject the pointer snippet into an agent's instruction file. + /// Returns true if injected, false if already present. + /// + public static bool InjectSnippet(string filePath) + { + if (!File.Exists(filePath)) + { + // Create the file and write the snippet + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + File.WriteAllText(filePath, InstructionTemplate.SnippetPointer + Environment.NewLine); + return true; + } + + var content = File.ReadAllText(filePath); + + // Check if already injected + if (content.Contains(InstructionTemplate.SectionStartMarker)) + return false; + + // Append snippet to file + var trimmed = content.TrimEnd(); + File.WriteAllText(filePath, trimmed + Environment.NewLine + Environment.NewLine + InstructionTemplate.SnippetPointer + Environment.NewLine); + return true; + } + + /// + /// Remove the graphify snippet from an agent's instruction file. + /// Returns true if removed, false if not found. + /// + public static bool RemoveSnippet(string filePath) + { + if (!File.Exists(filePath)) + return false; + + var content = File.ReadAllText(filePath); + var startIdx = content.IndexOf(InstructionTemplate.SectionStartMarker); + var endIdx = content.IndexOf(InstructionTemplate.SectionEndMarker); + + if (startIdx < 0 || endIdx < 0) + return false; + + // Remove from start marker to end marker (inclusive), plus surrounding whitespace + var endOfEnd = endIdx + InstructionTemplate.SectionEndMarker.Length; + var before = content[..startIdx].TrimEnd(); + var after = content[endOfEnd..].TrimStart(); + + var newContent = before + Environment.NewLine + Environment.NewLine + after; + File.WriteAllText(filePath, newContent.TrimStart() + Environment.NewLine); + return true; + } + + /// + /// Remove the canonical instructions file. + /// + public static bool RemoveCanonicalInstructions(string projectDir) + { + var instructionsPath = Path.Combine(projectDir, ".graphify", "mcp-instructions.md"); + if (!File.Exists(instructionsPath)) + return false; + + File.Delete(instructionsPath); + + // Remove .graphify directory if empty + var graphifyDir = Path.GetDirectoryName(instructionsPath); + if (graphifyDir != null && !Directory.EnumerateFileSystemEntries(graphifyDir).Any()) + Directory.Delete(graphifyDir); + + return true; + } +} diff --git a/src/Graphify.Cli/Mcp/GraphToolResponses.cs b/src/Graphify.Cli/Mcp/GraphToolResponses.cs new file mode 100644 index 0000000..9fb755c --- /dev/null +++ b/src/Graphify.Cli/Mcp/GraphToolResponses.cs @@ -0,0 +1,287 @@ +using System.Text.Json.Serialization; + +namespace Graphify.Cli.Mcp; + +/// +/// Shared DTOs used by both MemoryGraphBackend and SurrealDbGraphBackend +/// to ensure consistent JSON response shapes across backends. +/// + +public sealed record QueryResult +{ + [JsonPropertyName("query")] + public required string Query { get; init; } + + [JsonPropertyName("resultCount")] + public int ResultCount { get; init; } + + [JsonPropertyName("results")] + public required List Results { get; init; } +} + +public sealed record NodeResult +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("filePath")] + public string? FilePath { get; init; } + + [JsonPropertyName("language")] + public string? Language { get; init; } + + [JsonPropertyName("confidence")] + public string? Confidence { get; init; } + + [JsonPropertyName("community")] + public int? Community { get; init; } + + [JsonPropertyName("degree")] + public int Degree { get; init; } + + [JsonPropertyName("connections")] + public List? Connections { get; init; } +} + +public sealed record ConnectionResult +{ + [JsonPropertyName("source")] + public required string Source { get; init; } + + [JsonPropertyName("target")] + public required string Target { get; init; } + + [JsonPropertyName("relationship")] + public required string Relationship { get; init; } + + [JsonPropertyName("weight")] + public double Weight { get; init; } +} + +public sealed record PathResult +{ + [JsonPropertyName("found")] + public bool Found { get; init; } + + [JsonPropertyName("pathLength")] + public int PathLength { get; init; } + + [JsonPropertyName("path")] + public List? Path { get; init; } +} + +public sealed record PathNodeResult +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } +} + +public sealed record ExplainResult +{ + [JsonPropertyName("node")] + public required ExplainNodeResult Node { get; init; } + + [JsonPropertyName("statistics")] + public required ExplainStatistics Statistics { get; init; } + + [JsonPropertyName("incomingEdges")] + public List? IncomingEdges { get; init; } + + [JsonPropertyName("outgoingEdges")] + public List? OutgoingEdges { get; init; } +} + +public sealed record ExplainNodeResult +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("filePath")] + public string? FilePath { get; init; } + + [JsonPropertyName("language")] + public string? Language { get; init; } + + [JsonPropertyName("confidence")] + public string? Confidence { get; init; } + + [JsonPropertyName("community")] + public int? Community { get; init; } +} + +public sealed record ExplainStatistics +{ + [JsonPropertyName("totalDegree")] + public int TotalDegree { get; init; } + + [JsonPropertyName("incomingConnections")] + public int IncomingConnections { get; init; } + + [JsonPropertyName("outgoingConnections")] + public int OutgoingConnections { get; init; } +} + +public sealed record EdgeResult +{ + [JsonPropertyName("from")] + public string? From { get; init; } + + [JsonPropertyName("fromLabel")] + public string? FromLabel { get; init; } + + [JsonPropertyName("to")] + public string? To { get; init; } + + [JsonPropertyName("toLabel")] + public string? ToLabel { get; init; } + + [JsonPropertyName("relationship")] + public required string Relationship { get; init; } + + [JsonPropertyName("confidence")] + public string? Confidence { get; init; } +} + +public sealed record CommunitiesListResult +{ + [JsonPropertyName("totalCommunities")] + public int TotalCommunities { get; init; } + + [JsonPropertyName("nodesInCommunities")] + public int NodesInCommunities { get; init; } + + [JsonPropertyName("nodesWithoutCommunity")] + public int NodesWithoutCommunity { get; init; } + + [JsonPropertyName("communities")] + public required List Communities { get; init; } +} + +public sealed record CommunitySummaryResult +{ + [JsonPropertyName("communityId")] + public int CommunityId { get; init; } + + [JsonPropertyName("memberCount")] + public int MemberCount { get; init; } + + [JsonPropertyName("topMembers")] + public List? TopMembers { get; init; } +} + +public sealed record CommunityDetailResult +{ + [JsonPropertyName("communityId")] + public int CommunityId { get; init; } + + [JsonPropertyName("memberCount")] + public int MemberCount { get; init; } + + [JsonPropertyName("members")] + public required List Members { get; init; } +} + +public sealed record CommunityMemberResult +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("filePath")] + public string? FilePath { get; init; } + + [JsonPropertyName("degree")] + public int Degree { get; init; } +} + +public sealed record AnalyzeResult +{ + [JsonPropertyName("statistics")] + public required AnalyzeStatistics Statistics { get; init; } + + [JsonPropertyName("topNodes")] + public required List TopNodes { get; init; } + + [JsonPropertyName("nodeTypes")] + public required List NodeTypes { get; init; } + + [JsonPropertyName("relationshipTypes")] + public required List RelationshipTypes { get; init; } +} + +public sealed record AnalyzeStatistics +{ + [JsonPropertyName("nodeCount")] + public int NodeCount { get; init; } + + [JsonPropertyName("edgeCount")] + public int EdgeCount { get; init; } + + [JsonPropertyName("communityCount")] + public int CommunityCount { get; init; } + + [JsonPropertyName("averageDegree")] + public double AverageDegree { get; init; } + + [JsonPropertyName("isolatedNodeCount")] + public int IsolatedNodeCount { get; init; } +} + +public sealed record AnalyzeNodeResult +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("degree")] + public int Degree { get; init; } + + [JsonPropertyName("community")] + public int? Community { get; init; } +} + +public sealed record TypeCountResult +{ + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("count")] + public int Count { get; init; } +} + +/// +/// Error response shape for all tools. +/// +public sealed record ErrorResult +{ + [JsonPropertyName("error")] + public required string Error { get; init; } +} diff --git a/src/Graphify.Cli/Mcp/GraphTools.cs b/src/Graphify.Cli/Mcp/GraphTools.cs index 5b66aff..9e584f6 100644 --- a/src/Graphify.Cli/Mcp/GraphTools.cs +++ b/src/Graphify.Cli/Mcp/GraphTools.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Text.Json; using Graphify.Graph; using Graphify.Models; using ModelContextProtocol.Server; @@ -9,316 +8,64 @@ namespace Graphify.Cli.Mcp; [McpServerToolType] public class GraphTools { - private readonly KnowledgeGraph _graph; + private readonly IGraphBackend _backend; - public GraphTools(KnowledgeGraph graph) + public GraphTools(IGraphBackend backend) { - _graph = graph ?? throw new ArgumentNullException(nameof(graph)); + _backend = backend ?? throw new ArgumentNullException(nameof(backend)); } [McpServerTool] [Description("Search nodes and edges by name, label, or type.")] - public string Query( + public async Task Query( [Description("Match against node ID, label, or type")] string searchTerm, [Description("Max results (default 10)")] - int limit = 10) + int limit = 10, + CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(searchTerm)) - { - return JsonSerializer.Serialize(new { error = "Search term cannot be empty" }); - } - - var searchLower = searchTerm.ToLowerInvariant(); - var matchingNodes = _graph.GetNodes() - .Where(n => n.Id.ToLowerInvariant().Contains(searchLower) || - n.Label.ToLowerInvariant().Contains(searchLower) || - n.Type.ToLowerInvariant().Contains(searchLower)) - .Take(limit) - .Select(n => new - { - id = n.Id, - label = n.Label, - type = n.Type, - filePath = n.FilePath, - language = n.Language, - confidence = n.Confidence.ToString(), - community = n.Community, - degree = _graph.GetDegree(n.Id), - connections = _graph.GetEdges(n.Id) - .Select(e => new - { - source = e.Source.Id, - target = e.Target.Id, - relationship = e.Relationship, - weight = e.Weight - }) - .Take(5) - .ToList() - }) - .ToList(); - - return JsonSerializer.Serialize(new - { - query = searchTerm, - resultCount = matchingNodes.Count, - results = matchingNodes - }); + return await _backend.QueryAsync(searchTerm, limit, cancellationToken); } [McpServerTool] [Description("Shortest path between two nodes.")] - public string Path( + public async Task Path( [Description("Start node ID")] string sourceId, [Description("End node ID")] - string targetId) + string targetId, + CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(sourceId) || string.IsNullOrWhiteSpace(targetId)) - { - return JsonSerializer.Serialize(new { error = "Source and target IDs are required" }); - } - - var sourceNode = _graph.GetNode(sourceId); - var targetNode = _graph.GetNode(targetId); - - if (sourceNode == null) - { - return JsonSerializer.Serialize(new { error = $"Source node '{sourceId}' not found" }); - } - - if (targetNode == null) - { - return JsonSerializer.Serialize(new { error = $"Target node '{targetId}' not found" }); - } - - try - { - var visited = new HashSet(); - var queue = new Queue<(GraphNode Node, List Path)>(); - queue.Enqueue((sourceNode, new List { sourceNode })); - visited.Add(sourceNode.Id); - - while (queue.Count > 0) - { - var (current, path) = queue.Dequeue(); - - if (current.Id == targetNode.Id) - { - return JsonSerializer.Serialize(new - { - found = true, - pathLength = path.Count - 1, - path = path.Select(n => new - { - id = n.Id, - label = n.Label, - type = n.Type - }).ToList() - }); - } - - foreach (var neighbor in _graph.GetNeighbors(current.Id)) - { - if (!visited.Contains(neighbor.Id)) - { - visited.Add(neighbor.Id); - var newPath = new List(path) { neighbor }; - queue.Enqueue((neighbor, newPath)); - } - } - } - - return JsonSerializer.Serialize(new - { - found = false - }); - } - catch (Exception ex) - { - return JsonSerializer.Serialize(new - { - error = $"Error finding path: {ex.Message}" - }); - } + return await _backend.PathAsync(sourceId, targetId, cancellationToken); } [McpServerTool] [Description("Node details with all connections.")] - public string Explain( + public async Task Explain( [Description("Node ID")] - string nodeId) + string nodeId, + CancellationToken cancellationToken = default) { - if (string.IsNullOrWhiteSpace(nodeId)) - { - return JsonSerializer.Serialize(new { error = "Node ID is required" }); - } - - var node = _graph.GetNode(nodeId); - if (node == null) - { - return JsonSerializer.Serialize(new { error = $"Node '{nodeId}' not found" }); - } - - var inEdges = _graph.GetEdges(nodeId).Where(e => e.Target.Id == nodeId).ToList(); - var outEdges = _graph.GetEdges(nodeId).Where(e => e.Source.Id == nodeId).ToList(); - var degree = _graph.GetDegree(nodeId); - - var explanation = new - { - node = new - { - id = node.Id, - label = node.Label, - type = node.Type, - filePath = node.FilePath, - language = node.Language, - confidence = node.Confidence.ToString(), - community = node.Community - }, - statistics = new - { - totalDegree = degree, - incomingConnections = inEdges.Count, - outgoingConnections = outEdges.Count - }, - incomingEdges = inEdges.Select(e => new - { - from = e.Source.Id, - fromLabel = e.Source.Label, - relationship = e.Relationship, - confidence = e.Confidence.ToString() - }).ToList(), - outgoingEdges = outEdges.Select(e => new - { - to = e.Target.Id, - toLabel = e.Target.Label, - relationship = e.Relationship, - confidence = e.Confidence.ToString() - }).ToList() - }; - - return JsonSerializer.Serialize(explanation); + return await _backend.ExplainAsync(nodeId, cancellationToken); } [McpServerTool] [Description("List communities and their members.")] - public string Communities( + public async Task Communities( [Description("Specific community (omit for all)")] - int? communityId = null) + int? communityId = null, + CancellationToken cancellationToken = default) { - var allNodes = _graph.GetNodes().ToList(); - var nodesWithCommunities = allNodes.Where(n => n.Community.HasValue).ToList(); - - if (communityId.HasValue) - { - var communityNodes = _graph.GetNodesByCommunity(communityId.Value).ToList(); - - if (!communityNodes.Any()) - { - return JsonSerializer.Serialize(new { error = $"Community {communityId} not found or has no members" }); - } - - return JsonSerializer.Serialize(new - { - communityId = communityId.Value, - memberCount = communityNodes.Count, - members = communityNodes.Select(n => new - { - id = n.Id, - label = n.Label, - type = n.Type, - filePath = n.FilePath, - degree = _graph.GetDegree(n.Id) - }).OrderByDescending(n => n.degree).ToList() - }); - } - - var communities = nodesWithCommunities - .GroupBy(n => n.Community!.Value) - .Select(g => new - { - communityId = g.Key, - memberCount = g.Count(), - topMembers = g.OrderByDescending(n => _graph.GetDegree(n.Id)) - .Take(5) - .Select(n => new - { - id = n.Id, - label = n.Label, - type = n.Type, - degree = _graph.GetDegree(n.Id) - }) - .ToList() - }) - .OrderByDescending(c => c.memberCount) - .ToList(); - - return JsonSerializer.Serialize(new - { - totalCommunities = communities.Count, - nodesInCommunities = nodesWithCommunities.Count, - nodesWithoutCommunity = allNodes.Count - nodesWithCommunities.Count, - communities - }); + return await _backend.CommunitiesAsync(communityId, cancellationToken); } [McpServerTool] [Description("Graph-wide statistics and structure.")] - public string Analyze( + public async Task Analyze( [Description("Top nodes to include (default 10)")] - int topN = 10) + int topN = 10, + CancellationToken cancellationToken = default) { - var allNodes = _graph.GetNodes().ToList(); - var allEdges = _graph.GetEdges().ToList(); - - if (!allNodes.Any()) - { - return JsonSerializer.Serialize(new { error = "Graph is empty" }); - } - - var topNodesByDegree = _graph.GetHighestDegreeNodes(topN).ToList(); - - var nodesByCommunity = allNodes.Where(n => n.Community.HasValue) - .GroupBy(n => n.Community!.Value) - .Count(); - - var isolatedNodes = allNodes.Where(n => _graph.GetDegree(n.Id) == 0).ToList(); - - var nodesByType = allNodes.GroupBy(n => n.Type) - .Select(g => new { type = g.Key, count = g.Count() }) - .OrderByDescending(x => x.count) - .ToList(); - - var edgesByRelationship = allEdges.GroupBy(e => e.Relationship) - .Select(g => new { relationship = g.Key, count = g.Count() }) - .OrderByDescending(x => x.count) - .ToList(); - - var averageDegree = allNodes.Any() ? allNodes.Average(n => _graph.GetDegree(n.Id)) : 0; - - var analysis = new - { - statistics = new - { - nodeCount = allNodes.Count, - edgeCount = allEdges.Count, - communityCount = nodesByCommunity, - averageDegree = Math.Round(averageDegree, 2), - isolatedNodeCount = isolatedNodes.Count - }, - topNodes = topNodesByDegree.Select(t => new - { - id = t.Node.Id, - label = t.Node.Label, - type = t.Node.Type, - degree = t.Degree, - community = t.Node.Community - }).ToList(), - nodeTypes = nodesByType, - relationshipTypes = edgesByRelationship - }; - - return JsonSerializer.Serialize(analysis); + return await _backend.AnalyzeAsync(topN, cancellationToken); } } diff --git a/src/Graphify.Cli/Mcp/IGraphBackend.cs b/src/Graphify.Cli/Mcp/IGraphBackend.cs new file mode 100644 index 0000000..11d6c96 --- /dev/null +++ b/src/Graphify.Cli/Mcp/IGraphBackend.cs @@ -0,0 +1,15 @@ +namespace Graphify.Cli.Mcp; + +/// +/// Abstraction over graph data sources for MCP tool operations. +/// Supports both in-memory KnowledgeGraph (JSON) and SurrealDB backends. +/// Each method returns the same JSON response format for MCP client compatibility. +/// +public interface IGraphBackend +{ + Task QueryAsync(string searchTerm, int limit, CancellationToken cancellationToken = default); + Task PathAsync(string sourceId, string targetId, CancellationToken cancellationToken = default); + Task ExplainAsync(string nodeId, CancellationToken cancellationToken = default); + Task CommunitiesAsync(int? communityId, CancellationToken cancellationToken = default); + Task AnalyzeAsync(int topN, CancellationToken cancellationToken = default); +} diff --git a/src/Graphify.Cli/Mcp/MemoryGraphBackend.cs b/src/Graphify.Cli/Mcp/MemoryGraphBackend.cs new file mode 100644 index 0000000..5b8e512 --- /dev/null +++ b/src/Graphify.Cli/Mcp/MemoryGraphBackend.cs @@ -0,0 +1,309 @@ +using System.Text.Json; +using Graphify.Graph; +using Graphify.Models; + +namespace Graphify.Cli.Mcp; + +/// +/// IGraphBackend implementation backed by an in-memory KnowledgeGraph (QuikGraph). +/// This is the same logic that was previously inlined in GraphTools. +/// +public sealed class MemoryGraphBackend : IGraphBackend +{ + private readonly KnowledgeGraph _graph; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public MemoryGraphBackend(KnowledgeGraph graph) + { + _graph = graph ?? throw new ArgumentNullException(nameof(graph)); + } + + public Task QueryAsync(string searchTerm, int limit, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + { + return Task.FromResult(JsonSerializer.Serialize(new ErrorResult { Error = "Search term cannot be empty" })); + } + + var searchLower = searchTerm.ToLowerInvariant(); + var matchingNodes = _graph.GetNodes() + .Where(n => n.Id.Contains(searchLower, StringComparison.OrdinalIgnoreCase) || + n.Label.Contains(searchLower, StringComparison.OrdinalIgnoreCase) || + n.Type.Contains(searchLower, StringComparison.OrdinalIgnoreCase)) + .Take(limit) + .Select(n => new NodeResult + { + Id = n.Id, + Label = n.Label, + Type = n.Type, + FilePath = n.FilePath, + Language = n.Language, + Confidence = n.Confidence.ToString(), + Community = n.Community, + Degree = _graph.GetDegree(n.Id), + Connections = _graph.GetEdges(n.Id) + .Select(e => new ConnectionResult + { + Source = e.Source.Id, + Target = e.Target.Id, + Relationship = e.Relationship, + Weight = e.Weight + }) + .Take(5) + .ToList() + }) + .ToList(); + + var result = new QueryResult + { + Query = searchTerm, + ResultCount = matchingNodes.Count, + Results = matchingNodes + }; + + return Task.FromResult(JsonSerializer.Serialize(result, JsonOptions)); + } + + public Task PathAsync(string sourceId, string targetId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(sourceId) || string.IsNullOrWhiteSpace(targetId)) + { + return Task.FromResult(JsonSerializer.Serialize(new ErrorResult { Error = "Source and target IDs are required" })); + } + + var sourceNode = _graph.GetNode(sourceId); + var targetNode = _graph.GetNode(targetId); + + if (sourceNode == null) + { + return Task.FromResult(JsonSerializer.Serialize(new ErrorResult { Error = $"Source node '{sourceId}' not found" })); + } + + if (targetNode == null) + { + return Task.FromResult(JsonSerializer.Serialize(new ErrorResult { Error = $"Target node '{targetId}' not found" })); + } + + try + { + var visited = new HashSet(); + var queue = new Queue<(GraphNode Node, List Path)>(); + queue.Enqueue((sourceNode, new List { sourceNode })); + visited.Add(sourceNode.Id); + + while (queue.Count > 0) + { + var (current, path) = queue.Dequeue(); + + if (current.Id == targetNode.Id) + { + var result = new PathResult + { + Found = true, + PathLength = path.Count - 1, + Path = path.Select(n => new PathNodeResult + { + Id = n.Id, + Label = n.Label, + Type = n.Type + }).ToList() + }; + + return Task.FromResult(JsonSerializer.Serialize(result, JsonOptions)); + } + + foreach (var neighbor in _graph.GetNeighbors(current.Id)) + { + if (!visited.Contains(neighbor.Id)) + { + visited.Add(neighbor.Id); + var newPath = new List(path) { neighbor }; + queue.Enqueue((neighbor, newPath)); + } + } + } + + return Task.FromResult(JsonSerializer.Serialize(new PathResult { Found = false })); + } + catch (Exception ex) + { + return Task.FromResult(JsonSerializer.Serialize(new ErrorResult { Error = $"Error finding path: {ex.Message}" })); + } + } + + public Task ExplainAsync(string nodeId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(nodeId)) + { + return Task.FromResult(JsonSerializer.Serialize(new ErrorResult { Error = "Node ID is required" })); + } + + var node = _graph.GetNode(nodeId); + if (node == null) + { + return Task.FromResult(JsonSerializer.Serialize(new ErrorResult { Error = $"Node '{nodeId}' not found" })); + } + + var allEdges = _graph.GetEdges(nodeId).ToList(); + var inEdges = allEdges.Where(e => e.Target.Id == nodeId).ToList(); + var outEdges = allEdges.Where(e => e.Source.Id == nodeId).ToList(); + var degree = _graph.GetDegree(nodeId); + + var result = new ExplainResult + { + Node = new ExplainNodeResult + { + Id = node.Id, + Label = node.Label, + Type = node.Type, + FilePath = node.FilePath, + Language = node.Language, + Confidence = node.Confidence.ToString(), + Community = node.Community + }, + Statistics = new ExplainStatistics + { + TotalDegree = degree, + IncomingConnections = inEdges.Count, + OutgoingConnections = outEdges.Count + }, + IncomingEdges = inEdges.Select(e => new EdgeResult + { + From = e.Source.Id, + FromLabel = e.Source.Label, + Relationship = e.Relationship, + Confidence = e.Confidence.ToString() + }).ToList(), + OutgoingEdges = outEdges.Select(e => new EdgeResult + { + To = e.Target.Id, + ToLabel = e.Target.Label, + Relationship = e.Relationship, + Confidence = e.Confidence.ToString() + }).ToList() + }; + + return Task.FromResult(JsonSerializer.Serialize(result, JsonOptions)); + } + + public Task CommunitiesAsync(int? communityId, CancellationToken cancellationToken = default) + { + var allNodes = _graph.GetNodes().ToList(); + var nodesWithCommunities = allNodes.Where(n => n.Community.HasValue).ToList(); + + if (communityId.HasValue) + { + var communityNodes = _graph.GetNodesByCommunity(communityId.Value).ToList(); + + if (communityNodes.Count == 0) + { + return Task.FromResult(JsonSerializer.Serialize(new ErrorResult { Error = $"Community {communityId} not found or has no members" })); + } + + var result = new CommunityDetailResult + { + CommunityId = communityId.Value, + MemberCount = communityNodes.Count, + Members = communityNodes.Select(n => new CommunityMemberResult + { + Id = n.Id, + Label = n.Label, + Type = n.Type, + FilePath = n.FilePath, + Degree = _graph.GetDegree(n.Id) + }).OrderByDescending(m => m.Degree).ToList() + }; + + return Task.FromResult(JsonSerializer.Serialize(result, JsonOptions)); + } + + var communities = nodesWithCommunities + .GroupBy(n => n.Community!.Value) + .Select(g => new CommunitySummaryResult + { + CommunityId = g.Key, + MemberCount = g.Count(), + TopMembers = g.OrderByDescending(n => _graph.GetDegree(n.Id)) + .Take(5) + .Select(n => new CommunityMemberResult + { + Id = n.Id, + Label = n.Label, + Type = n.Type, + Degree = _graph.GetDegree(n.Id) + }) + .ToList() + }) + .OrderByDescending(c => c.MemberCount) + .ToList(); + + var listResult = new CommunitiesListResult + { + TotalCommunities = communities.Count, + NodesInCommunities = nodesWithCommunities.Count, + NodesWithoutCommunity = allNodes.Count - nodesWithCommunities.Count, + Communities = communities + }; + + return Task.FromResult(JsonSerializer.Serialize(listResult, JsonOptions)); + } + + public Task AnalyzeAsync(int topN, CancellationToken cancellationToken = default) + { + var allNodes = _graph.GetNodes().ToList(); + var allEdges = _graph.GetEdges().ToList(); + + if (allNodes.Count == 0) + { + return Task.FromResult(JsonSerializer.Serialize(new ErrorResult { Error = "Graph is empty" })); + } + + var topNodesByDegree = _graph.GetHighestDegreeNodes(topN).ToList(); + + var nodesByCommunity = allNodes.Where(n => n.Community.HasValue) + .GroupBy(n => n.Community!.Value) + .Count(); + + var isolatedNodes = allNodes.Where(n => _graph.GetDegree(n.Id) == 0).ToList(); + + var nodesByType = allNodes.GroupBy(n => n.Type) + .Select(g => new TypeCountResult { Type = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .ToList(); + + var edgesByRelationship = allEdges.GroupBy(e => e.Relationship) + .Select(g => new TypeCountResult { Type = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .ToList(); + + var averageDegree = allNodes.Count > 0 ? allNodes.Average(n => _graph.GetDegree(n.Id)) : 0; + + var result = new AnalyzeResult + { + Statistics = new AnalyzeStatistics + { + NodeCount = allNodes.Count, + EdgeCount = allEdges.Count, + CommunityCount = nodesByCommunity, + AverageDegree = Math.Round(averageDegree, 2), + IsolatedNodeCount = isolatedNodes.Count + }, + TopNodes = topNodesByDegree.Select(t => new AnalyzeNodeResult + { + Id = t.Node.Id, + Label = t.Node.Label, + Type = t.Node.Type, + Degree = t.Degree, + Community = t.Node.Community + }).ToList(), + NodeTypes = nodesByType, + RelationshipTypes = edgesByRelationship + }; + + return Task.FromResult(JsonSerializer.Serialize(result, JsonOptions)); + } +} diff --git a/src/Graphify.Cli/Mcp/SurrealDbGraphBackend.cs b/src/Graphify.Cli/Mcp/SurrealDbGraphBackend.cs new file mode 100644 index 0000000..c2950b1 --- /dev/null +++ b/src/Graphify.Cli/Mcp/SurrealDbGraphBackend.cs @@ -0,0 +1,679 @@ +using System.Text.Json; +using SurrealDb.Net; +using SurrealDb.Net.Models; +using SurrealDb.Net.Models.Response; + +namespace Graphify.Cli.Mcp; + +/// +/// IGraphBackend implementation that queries a SurrealDB database directly via SurrealQL. +/// Used for the MCP serve command when --surreal-endpoint or --surreal-path is specified. +/// +public sealed class SurrealDbGraphBackend : IGraphBackend, IAsyncDisposable +{ + private readonly ISurrealDbClient _db; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + // Cache for Path BFS — loaded lazily + private Dictionary? _entitiesCache; + private List? _relationshipsCache; + private readonly object _cacheLock = new(); + + public SurrealDbGraphBackend(ISurrealDbClient db) + { + _db = db ?? throw new ArgumentNullException(nameof(db)); + } + + public async ValueTask DisposeAsync() + { + if (_db is IAsyncDisposable disposable) + await disposable.DisposeAsync(); + } + + public async Task QueryAsync(string searchTerm, int limit, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return JsonSerializer.Serialize(new ErrorResult { Error = "Search term cannot be empty" }); + + try + { + var parameters = new Dictionary + { + ["term"] = searchTerm.ToLowerInvariant(), + ["limit"] = limit + }; + + var response = await _db.RawQuery( + "SELECT * FROM entity WHERE string::contains(string::lowercase(id), $term) OR string::contains(string::lowercase(label), $term) OR string::contains(string::lowercase(kind), $term) LIMIT $limit", + parameters, + cancellationToken + ); + + if (response.FirstOk is not { } ok) + return JsonSerializer.Serialize(new ErrorResult { Error = "Query failed" }); + + var entities = ok.GetValues().ToList(); + var results = new List(); + + foreach (var entity in entities) + { + var nodeId = ExtractNodeId(entity.Id); + var connections = await FetchConnectionsAsync(entity.Id, cancellationToken); + var degree = connections.Count; + + results.Add(new NodeResult + { + Id = nodeId, + Label = entity.label ?? nodeId, + Type = entity.kind ?? "unknown", + FilePath = entity.filePath, + Language = entity.language, + Confidence = entity.confidence, + Community = entity.community, + Degree = degree, + Connections = connections.Take(5).ToList() + }); + } + + return JsonSerializer.Serialize(new QueryResult + { + Query = searchTerm, + ResultCount = results.Count, + Results = results + }, JsonOptions); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new ErrorResult { Error = $"Query failed: {ex.Message}" }); + } + } + + public async Task ExplainAsync(string nodeId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(nodeId)) + return JsonSerializer.Serialize(new ErrorResult { Error = "Node ID is required" }); + + try + { + var recordId = MakeEntityRecordId(nodeId); + + // Fetch node + var nodeResponse = await _db.RawQuery( + "SELECT * FROM $node_id", + new Dictionary { ["node_id"] = recordId }, + cancellationToken + ); + + if (nodeResponse.FirstOk is not { } nodeOk) + return JsonSerializer.Serialize(new ErrorResult { Error = $"Node '{nodeId}' not found" }); + + var entity = nodeOk.GetValues().FirstOrDefault(); + if (entity == null) + return JsonSerializer.Serialize(new ErrorResult { Error = $"Node '{nodeId}' not found" }); + + // Fetch incoming and outgoing relationships + var relResponse = await _db.RawQuery( + "SELECT * FROM relationship WHERE source = $record_id OR target = $record_id", + new Dictionary { ["record_id"] = recordId }, + cancellationToken + ); + + var inEdges = new List(); + var outEdges = new List(); + + if (relResponse.FirstOk is { } relOk) + { + var relationships = relOk.GetValues().ToList(); + + foreach (var rel in relationships) + { + var sourceId = ExtractNodeId(rel.source); + var targetId = ExtractNodeId(rel.target); + + if (targetId == nodeId) + { + inEdges.Add(new EdgeResult + { + From = sourceId, + FromLabel = sourceId, + Relationship = rel.type ?? "", + Confidence = rel.confidence + }); + } + + if (sourceId == nodeId) + { + outEdges.Add(new EdgeResult + { + To = targetId, + ToLabel = targetId, + Relationship = rel.type ?? "", + Confidence = rel.confidence + }); + } + } + } + + var degree = inEdges.Count + outEdges.Count; + + return JsonSerializer.Serialize(new ExplainResult + { + Node = new ExplainNodeResult + { + Id = ExtractNodeId(entity.Id), + Label = entity.label ?? nodeId, + Type = entity.kind ?? "unknown", + FilePath = entity.filePath, + Language = entity.language, + Confidence = entity.confidence, + Community = entity.community + }, + Statistics = new ExplainStatistics + { + TotalDegree = degree, + IncomingConnections = inEdges.Count, + OutgoingConnections = outEdges.Count + }, + IncomingEdges = inEdges, + OutgoingEdges = outEdges + }, JsonOptions); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new ErrorResult { Error = $"Explain failed: {ex.Message}" }); + } + } + + public async Task PathAsync(string sourceId, string targetId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(sourceId) || string.IsNullOrWhiteSpace(targetId)) + return JsonSerializer.Serialize(new ErrorResult { Error = "Source and target IDs are required" }); + + try + { + await EnsurePathCacheLoadedAsync(cancellationToken); + + if (_entitiesCache == null || !_entitiesCache.ContainsKey(sourceId)) + return JsonSerializer.Serialize(new ErrorResult { Error = $"Source node '{sourceId}' not found" }); + + if (!_entitiesCache.ContainsKey(targetId)) + return JsonSerializer.Serialize(new ErrorResult { Error = $"Target node '{targetId}' not found" }); + + // Build adjacency list + var adjacency = new Dictionary>(); + foreach (var node in _entitiesCache.Keys) + adjacency[node] = []; + + if (_relationshipsCache != null) + { + foreach (var rel in _relationshipsCache) + { + var src = ExtractNodeId(rel.source); + var tgt = ExtractNodeId(rel.target); + if (adjacency.ContainsKey(src)) + adjacency[src].Add(tgt); + } + } + + // BFS + var visited = new HashSet { sourceId }; + var queue = new Queue<(string Node, List Path)>(); + queue.Enqueue((sourceId, [sourceId])); + + while (queue.Count > 0) + { + var (current, path) = queue.Dequeue(); + + if (current == targetId) + { + return JsonSerializer.Serialize(new PathResult + { + Found = true, + PathLength = path.Count - 1, + Path = path.Select(id => _entitiesCache!.TryGetValue(id, out var e) + ? new PathNodeResult + { + Id = id, + Label = e.label ?? id, + Type = e.kind ?? "unknown" + } + : new PathNodeResult { Id = id, Label = id, Type = "unknown" }).ToList() + }, JsonOptions); + } + + if (!adjacency.TryGetValue(current, out var neighbors)) + continue; + + foreach (var neighbor in neighbors) + { + if (visited.Add(neighbor)) + { + var newPath = new List(path) { neighbor }; + queue.Enqueue((neighbor, newPath)); + } + } + } + + return JsonSerializer.Serialize(new PathResult { Found = false }, JsonOptions); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new ErrorResult { Error = $"Path failed: {ex.Message}" }); + } + } + + public async Task CommunitiesAsync(int? communityId, CancellationToken cancellationToken = default) + { + try + { + if (communityId.HasValue) + { + var parameters = new Dictionary + { + ["communityId"] = communityId.Value + }; + + var response = await _db.RawQuery( + "SELECT * FROM entity WHERE community = $communityId ORDER BY community DESC", + parameters, + cancellationToken + ); + + if (response.FirstOk is not { } ok) + return JsonSerializer.Serialize(new ErrorResult { Error = $"Community {communityId} not found" }); + + var members = ok.GetValues().ToList(); + + if (members.Count == 0) + return JsonSerializer.Serialize(new ErrorResult { Error = $"Community {communityId} not found or has no members" }); + + // Fetch degree info by counting relationships for each member + var memberResults = new List(); + foreach (var member in members) + { + var id = ExtractNodeId(member.Id); + var connections = await FetchConnectionsAsync(member.Id, cancellationToken); + memberResults.Add(new CommunityMemberResult + { + Id = id, + Label = member.label ?? id, + Type = member.kind ?? "unknown", + FilePath = member.filePath, + Degree = connections.Count + }); + } + + return JsonSerializer.Serialize(new CommunityDetailResult + { + CommunityId = communityId.Value, + MemberCount = memberResults.Count, + Members = [.. memberResults.OrderByDescending(m => m.Degree)] + }, JsonOptions); + } + + // List all communities + var listResponse = await _db.RawQuery( + "SELECT community, count() AS count FROM entity WHERE community != NONE GROUP BY community ORDER BY count DESC", + cancellationToken: cancellationToken + ); + + // Also get total counts + var countResponse = await _db.RawQuery( + "SELECT count() AS total FROM entity; SELECT count() AS total FROM entity WHERE community != NONE", + cancellationToken: cancellationToken + ); + + int totalNodes = 0; + int nodesInCommunities = 0; + + if (countResponse.FirstOk is { } countOk) + { + var totals = countOk.GetValues().ToList(); + // First query: SELECT count() AS total FROM entity + if (countResponse.Count > 0 && countResponse[0] is SurrealDbOkResult r0) + totalNodes = r0.GetValues().FirstOrDefault()?.total ?? 0; + // Second query: SELECT count() AS total FROM entity WHERE community != NONE + if (countResponse.Count > 1 && countResponse[1] is SurrealDbOkResult r1) + nodesInCommunities = r1.GetValues().FirstOrDefault()?.total ?? 0; + } + + var communities = new List(); + + if (listResponse.FirstOk is { } listOk) + { + var communityGroups = listOk.GetValues().ToList(); + + foreach (var group in communityGroups) + { + // Fetch top 5 members for each community + var memberParams = new Dictionary + { + ["communityId"] = group.community + }; + var memberResponse = await _db.RawQuery( + "SELECT * FROM entity WHERE community = $communityId LIMIT 5", + memberParams, + cancellationToken + ); + + var topMembers = new List(); + if (memberResponse.FirstOk is { } memberOk) + { + foreach (var m in memberOk.GetValues()) + { + var mid = ExtractNodeId(m.Id); + var conns = await FetchConnectionsAsync(m.Id, cancellationToken); + topMembers.Add(new CommunityMemberResult + { + Id = mid, + Label = m.label ?? mid, + Type = m.kind ?? "unknown", + Degree = conns.Count + }); + } + } + + communities.Add(new CommunitySummaryResult + { + CommunityId = group.community, + MemberCount = group.count, + TopMembers = topMembers + }); + } + } + + return JsonSerializer.Serialize(new CommunitiesListResult + { + TotalCommunities = communities.Count, + NodesInCommunities = nodesInCommunities, + NodesWithoutCommunity = totalNodes - nodesInCommunities, + Communities = communities + }, JsonOptions); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new ErrorResult { Error = $"Communities failed: {ex.Message}" }); + } + } + + public async Task AnalyzeAsync(int topN, CancellationToken cancellationToken = default) + { + try + { + // Run all aggregate queries in one batch + var response = await _db.RawQuery( + """ + SELECT count() AS count FROM entity; + SELECT count() AS count FROM relationship; + SELECT community, count() AS count FROM entity WHERE community != NONE GROUP BY community; + SELECT kind, count() AS count FROM entity GROUP BY kind ORDER BY count DESC; + SELECT type, count() AS count FROM relationship GROUP BY type ORDER BY count DESC; + """, + cancellationToken: cancellationToken + ); + + if (response.HasErrors) + return JsonSerializer.Serialize(new ErrorResult { Error = "Analyze query failed" }); + + // Result 0: total node count + var nodeCount = response.GetValues(0).FirstOrDefault()?.count ?? 0; + // Result 1: total edge count + var edgeCount = response.GetValues(1).FirstOrDefault()?.count ?? 0; + // Result 2: community groups + var communityGroups = response.GetValues(2).ToList(); + // Result 3: type distribution + var typeDistributions = response.GetValues(3).ToList(); + // Result 4: relationship type distribution + var relTypeDistributions = response.GetValues(4).ToList(); + + // Top N nodes by degree + var topNodes = await GetTopNodesAsync(topN, cancellationToken); + + // Isolated nodes (degree == 0) + var isolatedCount = await CountIsolatedNodesAsync(cancellationToken); + + double averageDegree = nodeCount > 0 ? (double)edgeCount * 2 / nodeCount : 0; + + return JsonSerializer.Serialize(new AnalyzeResult + { + Statistics = new AnalyzeStatistics + { + NodeCount = nodeCount, + EdgeCount = edgeCount, + CommunityCount = communityGroups.Count, + AverageDegree = Math.Round(averageDegree, 2), + IsolatedNodeCount = isolatedCount + }, + TopNodes = topNodes, + NodeTypes = typeDistributions.Select(t => new TypeCountResult + { + Type = t.kind ?? "unknown", + Count = t.count + }).ToList(), + RelationshipTypes = relTypeDistributions.Select(t => new TypeCountResult + { + Type = t.type ?? "unknown", + Count = t.count + }).ToList() + }, JsonOptions); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new ErrorResult { Error = $"Analyze failed: {ex.Message}" }); + } + } + + // ── Private helpers ──────────────────────────────────────────────── + + private static string ExtractNodeId(RecordId? recordId) + { + if (recordId is null) + return ""; + + try + { + var escaped = recordId.DeserializeId(); + return Uri.UnescapeDataString(escaped); + } + catch + { + return recordId.ToString() ?? ""; + } + } + + private static RecordId MakeEntityRecordId(string nodeId) + { + var escaped = Uri.EscapeDataString(nodeId); + return (RecordId)("entity", escaped); + } + + private async Task> FetchConnectionsAsync(RecordId? entityId, CancellationToken ct) + { + if (entityId is null) + return []; + + var parameters = new Dictionary + { + ["record_id"] = entityId + }; + + var response = await _db.RawQuery( + "SELECT * FROM relationship WHERE source = $record_id OR target = $record_id LIMIT 50", + parameters, + ct + ); + + if (response.FirstOk is not { } ok) + return []; + + return ok.GetValues().Select(rel => new ConnectionResult + { + Source = ExtractNodeId(rel.source), + Target = ExtractNodeId(rel.target), + Relationship = rel.type ?? "", + Weight = rel.weight + }).ToList(); + } + + private async Task EnsurePathCacheLoadedAsync(CancellationToken ct) + { + if (_entitiesCache != null) + return; + + // Fetch all entities and relationships + var response = await _db.RawQuery( + "SELECT * FROM entity; SELECT * FROM relationship", + cancellationToken: ct + ); + + if (response.HasErrors) + return; + + lock (_cacheLock) + { + if (_entitiesCache != null) + return; + + var entities = new Dictionary(); + if (response.Count > 0 && response[0] is SurrealDbOkResult entityOk) + { + foreach (var e in entityOk.GetValues()) + { + var id = ExtractNodeId(e.Id); + if (!string.IsNullOrEmpty(id)) + entities[id] = e; + } + } + + var relationships = new List(); + if (response.Count > 1 && response[1] is SurrealDbOkResult relOk) + { + relationships.AddRange(relOk.GetValues()); + } + + _entitiesCache = entities; + _relationshipsCache = relationships; + } + } + + private async Task> GetTopNodesAsync(int topN, CancellationToken ct) + { + // Load all entities and compute degree by counting relationships + var response = await _db.RawQuery( + "SELECT * FROM entity", + cancellationToken: ct + ); + + if (response.FirstOk is not { } ok) + return []; + + var entities = ok.GetValues().ToList(); + + // Compute degree for each entity + var degreeMap = new Dictionary(); + foreach (var entity in entities) + { + var id = ExtractNodeId(entity.Id); + var connections = await FetchConnectionsAsync(entity.Id, ct); + degreeMap[id] = connections.Count; + } + + return entities + .Select(e => new + { + Id = ExtractNodeId(e.Id), + Label = e.label ?? "", + Type = e.kind ?? "unknown", + Degree = degreeMap.GetValueOrDefault(ExtractNodeId(e.Id), 0), + Community = e.community + }) + .OrderByDescending(x => x.Degree) + .Take(topN) + .Select(x => new AnalyzeNodeResult + { + Id = x.Id, + Label = x.Label, + Type = x.Type, + Degree = x.Degree, + Community = x.Community + }) + .ToList(); + } + + private async Task CountIsolatedNodesAsync(CancellationToken ct) + { + var response = await _db.RawQuery( + "SELECT * FROM entity", + cancellationToken: ct + ); + + if (response.FirstOk is not { } ok) + return 0; + + var entities = ok.GetValues().ToList(); + int isolated = 0; + + foreach (var entity in entities) + { + var connections = await FetchConnectionsAsync(entity.Id, ct); + if (connections.Count == 0) + isolated++; + } + + return isolated; + } + + // ── Internal SurrealDB record types for CBOR deserialization ────── + + internal sealed class SurrealEntityRecord : Record + { + public string? label { get; set; } + public string? kind { get; set; } + public string? filePath { get; set; } + public string? language { get; set; } + public string? confidence { get; set; } + public int? community { get; set; } + } + + internal sealed class SurrealRelationshipRecord : Record + { + public RecordId? source { get; set; } + public RecordId? target { get; set; } + public string? type { get; set; } + public double weight { get; set; } + public string? confidence { get; set; } + } + + // Aggregation result types + internal sealed class SurrealCount : Record + { + public int count { get; set; } + } + + internal sealed class SurrealCommunityGroup : Record + { + public int community { get; set; } + public int count { get; set; } + } + + internal sealed class SurrealCommunityCount : Record + { + public int total { get; set; } + } + + internal sealed class SurrealKindCount : Record + { + public string? kind { get; set; } + public int count { get; set; } + } + + internal sealed class SurrealTypeCount : Record + { + public string? type { get; set; } + public int count { get; set; } + } +} diff --git a/src/Graphify.Cli/Program.cs b/src/Graphify.Cli/Program.cs index fe13c1b..ef37f79 100644 --- a/src/Graphify.Cli/Program.cs +++ b/src/Graphify.Cli/Program.cs @@ -1,6 +1,7 @@ using System.CommandLine; using System.Text.Json; using Graphify.Cli.Configuration; +using Graphify.Cli.Init; using Graphify.Cli.Mcp; using Graphify.Graph; using Graphify.Models; @@ -12,6 +13,9 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using Spectre.Console; +using SurrealDb.Embedded.RocksDb; +using SurrealDb.Net; +using SurrealDb.Net.Models.Auth; var rootCommand = new RootCommand("graphify-dotnet: AI-powered knowledge graph builder for codebases"); @@ -309,6 +313,54 @@ static void AddPipelineOptions(Command cmd, rootCommand.Subcommands.Add(benchmarkCommand); +// ── init command ───────────────────────────────────────────────────────── +var initInstallOpt = new Option("--install") +{ + Description = "Target specific agents (comma-separated, e.g. copilot,claude)" +}; + +var initUninstallOpt = new Option("--uninstall") +{ + Description = "Remove graphify instructions from all agent files" +}; + +var initForceOpt = new Option("--force") +{ + Description = "Regenerate even if already installed" +}; + +var initScopeOpt = new Option("--scope") +{ + Description = "Scope of installation: project (default) or global", + DefaultValueFactory = _ => "project" +}; + +var initCommand = new Command("init", "Install MCP agent instructions for coding agents"); +initCommand.Options.Add(initInstallOpt); +initCommand.Options.Add(initUninstallOpt); +initCommand.Options.Add(initForceOpt); +initCommand.Options.Add(initScopeOpt); + +initCommand.SetAction(async (parseResult, cancellationToken) => +{ + var projectDir = Directory.GetCurrentDirectory(); + var install = parseResult.GetValue(initInstallOpt); + var uninstall = parseResult.GetValue(initUninstallOpt); + var force = parseResult.GetValue(initForceOpt); + var scope = parseResult.GetValue(initScopeOpt); + + var initService = new InitService(Console.Out, projectDir); + + if (uninstall) + { + return await initService.RunAsync(uninstall: true); + } + + return await initService.RunAsync(install, force: force); +}); + +rootCommand.Subcommands.Add(initCommand); + // ── serve command ──────────────────────────────────────────────────────── var servePathArg = new Argument("graph-path") { @@ -321,14 +373,56 @@ static void AddPipelineOptions(Command cmd, Description = "Enable verbose logging" }; +var serveSurrealPathOpt = new Option("--surreal-path") +{ + Description = "Path to embedded SurrealDB RocksDB file (e.g., graphify-out/codebase.db)" +}; + +var serveSurrealEndpointOpt = new Option("--surreal-endpoint") +{ + Description = "Remote SurrealDB endpoint (e.g., http://localhost:8000)" +}; + +var serveSurrealUserOpt = new Option("--surreal-user") +{ + Description = "SurrealDB username (default: root)" +}; + +var serveSurrealPassOpt = new Option("--surreal-pass") +{ + Description = "SurrealDB password" +}; + +var serveSurrealNsOpt = new Option("--surreal-ns") +{ + Description = "SurrealDB namespace (default: graphify)" +}; + +var serveSurrealDbOpt = new Option("--surreal-db") +{ + Description = "SurrealDB database name (default: codebase)" +}; + var serveCommand = new Command("serve", "Serve the knowledge graph over MCP (Model Context Protocol)"); serveCommand.Arguments.Add(servePathArg); serveCommand.Options.Add(serveVerboseOpt); +serveCommand.Options.Add(serveSurrealPathOpt); +serveCommand.Options.Add(serveSurrealEndpointOpt); +serveCommand.Options.Add(serveSurrealUserOpt); +serveCommand.Options.Add(serveSurrealPassOpt); +serveCommand.Options.Add(serveSurrealNsOpt); +serveCommand.Options.Add(serveSurrealDbOpt); serveCommand.SetAction(async (parseResult, cancellationToken) => { var graphPath = parseResult.GetValue(servePathArg)!; var verbose = parseResult.GetValue(serveVerboseOpt); + var surrealPath = parseResult.GetValue(serveSurrealPathOpt); + var surrealEndpoint = parseResult.GetValue(serveSurrealEndpointOpt); + var surrealUser = parseResult.GetValue(serveSurrealUserOpt); + var surrealPass = parseResult.GetValue(serveSurrealPassOpt); + var surrealNs = parseResult.GetValue(serveSurrealNsOpt); + var surrealDb = parseResult.GetValue(serveSurrealDbOpt); var builder = Host.CreateApplicationBuilder(args); @@ -338,48 +432,101 @@ static void AddPipelineOptions(Command cmd, options.LogToStandardErrorThreshold = verbose ? LogLevel.Trace : LogLevel.Warning; }); - KnowledgeGraph graph; - try + if (surrealEndpoint is not null || surrealPath is not null) { - if (!File.Exists(graphPath)) + // ── SurrealDB mode ──────────────────────────────────────────── + try { - await Console.Error.WriteLineAsync($"Error: Graph file not found at '{graphPath}'"); - return 1; - } + ISurrealDbClient db; + + if (surrealPath is not null) + { + db = new SurrealDbRocksDbClient(surrealPath); + } + else + { + var options = SurrealDbOptions.Create() + .WithEndpoint(surrealEndpoint!) + .WithNamespace(surrealNs ?? "graphify") + .WithDatabase(surrealDb ?? "codebase") + .WithUsername(surrealUser) + .WithPassword(surrealPass) + .Build(); + + db = new SurrealDbClient(options); + + if (surrealUser is not null) + { + await db.SignIn(new RootAuth + { + Username = surrealUser, + Password = surrealPass ?? "" + }); + } + } - var json = await File.ReadAllTextAsync(graphPath, cancellationToken); - var graphData = JsonSerializer.Deserialize(json); + builder.Services.AddSingleton(db); + builder.Services.AddSingleton(); - if (graphData == null) + if (verbose) + { + await Console.Error.WriteLineAsync($"SurrealDB backend: {(surrealPath ?? surrealEndpoint)}"); + } + } + catch (Exception ex) { - await Console.Error.WriteLineAsync($"Error: Failed to parse graph JSON from '{graphPath}'"); + await Console.Error.WriteLineAsync($"Error connecting to SurrealDB: {ex.Message}"); return 1; } + } + else + { + // ── JSON file mode (existing behavior) ──────────────────────── + KnowledgeGraph graph; + try + { + if (!File.Exists(graphPath)) + { + await Console.Error.WriteLineAsync($"Error: Graph file not found at '{graphPath}'"); + return 1; + } - graph = new KnowledgeGraph(); + var json = await File.ReadAllTextAsync(graphPath, cancellationToken); + var graphData = JsonSerializer.Deserialize(json); - foreach (var node in graphData.Nodes) - { - graph.AddNode(node); - } + if (graphData == null) + { + await Console.Error.WriteLineAsync($"Error: Failed to parse graph JSON from '{graphPath}'"); + return 1; + } - foreach (var edge in graphData.Edges) - { - graph.AddEdge(edge); - } + graph = new KnowledgeGraph(); - if (verbose) + foreach (var node in graphData.Nodes) + { + graph.AddNode(node); + } + + foreach (var edge in graphData.Edges) + { + graph.AddEdge(edge); + } + + if (verbose) + { + await Console.Error.WriteLineAsync($"Loaded graph: {graph.NodeCount} nodes, {graph.EdgeCount} edges"); + } + + builder.Services.AddSingleton(graph); + builder.Services.AddSingleton(new MemoryGraphBackend(graph)); + } + catch (Exception ex) { - await Console.Error.WriteLineAsync($"Loaded graph: {graph.NodeCount} nodes, {graph.EdgeCount} edges"); + await Console.Error.WriteLineAsync($"Error loading graph: {ex.Message}"); + return 1; } } - catch (Exception ex) - { - await Console.Error.WriteLineAsync($"Error loading graph: {ex.Message}"); - return 1; - } - builder.Services.AddSingleton(graph); builder.Services.AddSingleton(); builder.Services.AddMcpServer() .WithStdioServerTransport() diff --git a/src/tests/Graphify.Tests/Init/AgentDetectorTests.cs b/src/tests/Graphify.Tests/Init/AgentDetectorTests.cs new file mode 100644 index 0000000..bf5de7c --- /dev/null +++ b/src/tests/Graphify.Tests/Init/AgentDetectorTests.cs @@ -0,0 +1,127 @@ +using Graphify.Cli.Init; +using Xunit; + +namespace Graphify.Tests.Init; + +[Trait("Category", "Init")] +public sealed class AgentDetectorTests +{ + [Fact] + public void Detect_WhenClaudeMarkdownExists_ReturnsClaude() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "CLAUDE.md"), "# Claude instructions"); + + var agents = AgentDetector.Detect(dir.Path); + + Assert.Contains(AgentType.Claude, agents); + } + + [Fact] + public void Detect_WhenCopilotInstructionsExist_ReturnsCopilot() + { + using var dir = new TempDir(); + var copilotDir = Path.Combine(dir.Path, ".github"); + Directory.CreateDirectory(copilotDir); + File.WriteAllText(Path.Combine(copilotDir, "copilot-instructions.md"), "# Copilot instructions"); + + var agents = AgentDetector.Detect(dir.Path); + + Assert.Contains(AgentType.Copilot, agents); + } + + [Fact] + public void Detect_WhenOpenCodeInstructionsExist_ReturnsOpenCode() + { + using var dir = new TempDir(); + var opencodeDir = Path.Combine(dir.Path, ".opencode"); + Directory.CreateDirectory(opencodeDir); + File.WriteAllText(Path.Combine(opencodeDir, "instructions.md"), "# OpenCode instructions"); + + var agents = AgentDetector.Detect(dir.Path); + + Assert.Contains(AgentType.OpenCode, agents); + } + + [Fact] + public void Detect_WhenQoderConfigExists_ReturnsQoder() + { + using var dir = new TempDir(); + var squadDir = Path.Combine(dir.Path, ".squad"); + Directory.CreateDirectory(squadDir); + File.WriteAllText(Path.Combine(squadDir, "config.json"), "{}"); + + var agents = AgentDetector.Detect(dir.Path); + + Assert.Contains(AgentType.Qoder, agents); + } + + [Fact] + public void Detect_WhenCursorRulesExists_ReturnsCursor() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, ".cursorrules"), "# Cursor rules"); + + var agents = AgentDetector.Detect(dir.Path); + + Assert.Contains(AgentType.Cursor, agents); + } + + [Fact] + public void Detect_WhenWindsurfRulesExists_ReturnsWindsurf() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, ".windsurfrules"), "# Windsurf rules"); + + var agents = AgentDetector.Detect(dir.Path); + + Assert.Contains(AgentType.Windsurf, agents); + } + + [Fact] + public void Detect_WhenMultipleAgentsExist_ReturnsAll() + { + using var dir = new TempDir(); + File.WriteAllText(Path.Combine(dir.Path, "CLAUDE.md"), "# Claude"); + File.WriteAllText(Path.Combine(dir.Path, ".cursorrules"), "# Cursor"); + + var agents = AgentDetector.Detect(dir.Path); + + Assert.Contains(AgentType.Claude, agents); + Assert.Contains(AgentType.Cursor, agents); + Assert.Equal(2, agents.Count); + } + + [Fact] + public void Detect_WhenNoAgentFiles_ReturnsEmpty() + { + using var dir = new TempDir(); + + var agents = AgentDetector.Detect(dir.Path); + + Assert.Empty(agents); + } + + [Fact] + public void Detect_WhenDirectoryNotExist_ReturnsEmpty() + { + var agents = AgentDetector.Detect(@"X:\nonexistent\path_" + Guid.NewGuid()); + + Assert.Empty(agents); + } +} + +internal sealed class TempDir : IDisposable +{ + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "gfx-test-" + Guid.NewGuid().ToString("N")[..12]); + + public TempDir() + { + Directory.CreateDirectory(Path); + } + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } catch { } + } +} diff --git a/src/tests/Graphify.Tests/Init/InitServiceTests.cs b/src/tests/Graphify.Tests/Init/InitServiceTests.cs new file mode 100644 index 0000000..e1c93c0 --- /dev/null +++ b/src/tests/Graphify.Tests/Init/InitServiceTests.cs @@ -0,0 +1,109 @@ +using Graphify.Cli.Init; +using Xunit; + +namespace Graphify.Tests.Init; + +[Trait("Category", "Init")] +public sealed class InitServiceTests +{ + [Fact] + public async Task RunAsync_WithNoAgents_DoesNotWriteInstructions() + { + using var dir = new TempDir(); + var output = new StringWriter(); + + var service = new InitService(output, dir.Path); + var exitCode = await service.RunAsync(); + + Assert.Equal(0, exitCode); + // No agents detected → no instructions written + var canonicalPath = Path.Combine(dir.Path, ".graphify", "mcp-instructions.md"); + Assert.False(File.Exists(canonicalPath)); + } + + [Fact] + public async Task RunAsync_WithInstallList_WritesInstructionsAndInjects() + { + using var dir = new TempDir(); + var output = new StringWriter(); + var agentFile = Path.Combine(dir.Path, "CLAUDE.md"); + + var service = new InitService(output, dir.Path); + var exitCode = await service.RunAsync(installAgents: "claude"); + + Assert.Equal(0, exitCode); + Assert.True(File.Exists(agentFile)); + var content = File.ReadAllText(agentFile); + Assert.Contains(InstructionTemplate.SectionStartMarker, content); + } + + [Fact] + public async Task RunAsync_WithMultipleInstallList_InjectsAll() + { + using var dir = new TempDir(); + var output = new StringWriter(); + var claudeFile = Path.Combine(dir.Path, "CLAUDE.md"); + var cursorFile = Path.Combine(dir.Path, ".cursorrules"); + + var service = new InitService(output, dir.Path); + var exitCode = await service.RunAsync(installAgents: "claude,cursor"); + + Assert.Equal(0, exitCode); + Assert.True(File.Exists(claudeFile)); + Assert.True(File.Exists(cursorFile)); + Assert.Contains(InstructionTemplate.SectionStartMarker, File.ReadAllText(claudeFile)); + Assert.Contains(InstructionTemplate.SectionStartMarker, File.ReadAllText(cursorFile)); + } + + [Fact] + public async Task RunAsync_WithForce_RegeneratesExisting() + { + using var dir = new TempDir(); + var output = new StringWriter(); + + var service = new InitService(output, dir.Path); + await service.RunAsync(installAgents: "claude"); + + // Run again with force + var exitCode = await service.RunAsync(installAgents: "claude", force: true); + + Assert.Equal(0, exitCode); + } + + [Fact] + public async Task RunAsync_Uninstall_RemovesMarkers() + { + using var dir = new TempDir(); + var output = new StringWriter(); + var agentFile = Path.Combine(dir.Path, "CLAUDE.md"); + + // Install first + var service = new InitService(output, dir.Path); + await service.RunAsync(installAgents: "claude"); + + Assert.Contains(InstructionTemplate.SectionStartMarker, File.ReadAllText(agentFile)); + + // Uninstall + output = new StringWriter(); + service = new InitService(output, dir.Path); + var exitCode = await service.RunAsync(uninstall: true); + + Assert.Equal(0, exitCode); + if (File.Exists(agentFile)) + { + Assert.DoesNotContain(InstructionTemplate.SectionStartMarker, File.ReadAllText(agentFile)); + } + } + + [Fact] + public async Task RunAsync_InvalidAgentName_Ignores() + { + using var dir = new TempDir(); + var output = new StringWriter(); + + var service = new InitService(output, dir.Path); + var exitCode = await service.RunAsync(installAgents: "nonexistent_agent"); + + Assert.Equal(0, exitCode); + } +} diff --git a/src/tests/Graphify.Tests/Init/SnippetInjectorTests.cs b/src/tests/Graphify.Tests/Init/SnippetInjectorTests.cs new file mode 100644 index 0000000..ccaf65f --- /dev/null +++ b/src/tests/Graphify.Tests/Init/SnippetInjectorTests.cs @@ -0,0 +1,148 @@ +using Graphify.Cli.Init; +using Xunit; + +namespace Graphify.Tests.Init; + +[Trait("Category", "Init")] +public sealed class SnippetInjectorTests +{ + [Fact] + public void WriteCanonicalInstructions_CreatesFile() + { + using var dir = new TempDir(); + + var written = SnippetInjector.WriteCanonicalInstructions(dir.Path, force: false); + + Assert.True(written); + var filePath = Path.Combine(dir.Path, ".graphify", "mcp-instructions.md"); + Assert.True(File.Exists(filePath)); + var content = File.ReadAllText(filePath); + Assert.Contains("MCP Knowledge Graph: graphify", content); + Assert.Contains("## Tools", content); + Assert.Contains("## Investigation Protocol", content); + } + + [Fact] + public void WriteCanonicalInstructions_IsIdempotent() + { + using var dir = new TempDir(); + + var first = SnippetInjector.WriteCanonicalInstructions(dir.Path, force: false); + var second = SnippetInjector.WriteCanonicalInstructions(dir.Path, force: false); + + Assert.True(first); + Assert.False(second); + } + + [Fact] + public void WriteCanonicalInstructions_WithForce_Overwrites() + { + using var dir = new TempDir(); + + SnippetInjector.WriteCanonicalInstructions(dir.Path, force: false); + var overwritten = SnippetInjector.WriteCanonicalInstructions(dir.Path, force: true); + + Assert.True(overwritten); + } + + [Fact] + public void InjectSnippet_AddsPointerToFile() + { + using var dir = new TempDir(); + var agentFile = Path.Combine(dir.Path, "CLAUDE.md"); + File.WriteAllText(agentFile, "# Claude instructions"); + + var result = SnippetInjector.InjectSnippet(agentFile); + + Assert.True(result); + var content = File.ReadAllText(agentFile); + Assert.Contains(InstructionTemplate.SectionStartMarker, content); + Assert.Contains(InstructionTemplate.SectionEndMarker, content); + Assert.Contains("MCP knowledge graph available", content); + } + + [Fact] + public void InjectSnippet_IsIdempotent() + { + using var dir = new TempDir(); + var agentFile = Path.Combine(dir.Path, "CLAUDE.md"); + File.WriteAllText(agentFile, "# Claude instructions"); + + var first = SnippetInjector.InjectSnippet(agentFile); + var second = SnippetInjector.InjectSnippet(agentFile); + + Assert.True(first); + Assert.False(second); + } + + [Fact] + public void InjectSnippet_CreatesFileIfMissing() + { + using var dir = new TempDir(); + var agentFile = Path.Combine(dir.Path, "CLAUDE.md"); + + var result = SnippetInjector.InjectSnippet(agentFile); + + Assert.True(result); + Assert.True(File.Exists(agentFile)); + } + + [Fact] + public void RemoveSnippet_RemovesMarkers() + { + using var dir = new TempDir(); + var agentFile = Path.Combine(dir.Path, "CLAUDE.md"); + File.WriteAllText(agentFile, "# Original content\n"); + SnippetInjector.InjectSnippet(agentFile); + + var removed = SnippetInjector.RemoveSnippet(agentFile); + + Assert.True(removed); + var content = File.ReadAllText(agentFile); + Assert.DoesNotContain(InstructionTemplate.SectionStartMarker, content); + Assert.DoesNotContain(InstructionTemplate.SectionEndMarker, content); + Assert.Contains("# Original content", content); + } + + [Fact] + public void RemoveSnippet_WhenNoSnippet_ReturnsFalse() + { + using var dir = new TempDir(); + var agentFile = Path.Combine(dir.Path, "CLAUDE.md"); + File.WriteAllText(agentFile, "# No snippet here"); + + var removed = SnippetInjector.RemoveSnippet(agentFile); + + Assert.False(removed); + } + + [Fact] + public void RemoveSnippet_WhenFileNotExists_ReturnsFalse() + { + var removed = SnippetInjector.RemoveSnippet(@"X:\nonexistent\file.md"); + + Assert.False(removed); + } + + [Fact] + public void RemoveCanonicalInstructions_DeletesFileAndEmptyDir() + { + using var dir = new TempDir(); + SnippetInjector.WriteCanonicalInstructions(dir.Path, force: false); + + var removed = SnippetInjector.RemoveCanonicalInstructions(dir.Path); + + Assert.True(removed); + Assert.False(Directory.Exists(Path.Combine(dir.Path, ".graphify"))); + } + + [Fact] + public void RemoveCanonicalInstructions_WhenNotExists_ReturnsFalse() + { + using var dir = new TempDir(); + + var removed = SnippetInjector.RemoveCanonicalInstructions(dir.Path); + + Assert.False(removed); + } +} diff --git a/src/tests/Graphify.Tests/Mcp/MemoryGraphBackendTests.cs b/src/tests/Graphify.Tests/Mcp/MemoryGraphBackendTests.cs new file mode 100644 index 0000000..9f3d4a4 --- /dev/null +++ b/src/tests/Graphify.Tests/Mcp/MemoryGraphBackendTests.cs @@ -0,0 +1,249 @@ +using System.Text.Json; +using Graphify.Cli.Mcp; +using Graphify.Graph; +using Graphify.Models; +using Xunit; + +namespace Graphify.Tests.Mcp; + +[Trait("Category", "Mcp")] +public sealed class MemoryGraphBackendTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private static KnowledgeGraph CreateSampleGraph() + { + var graph = new KnowledgeGraph(); + var n1 = new GraphNode { Id = "ClassA", Label = "ClassA", Type = "class", FilePath = "a.cs", Language = "csharp", Confidence = Confidence.Extracted }; + var n2 = new GraphNode { Id = "ClassB", Label = "ClassB", Type = "class", FilePath = "b.cs", Language = "csharp", Confidence = Confidence.Extracted }; + var n3 = new GraphNode { Id = "Helper", Label = "Helper", Type = "module", FilePath = "helper.cs", Language = "csharp", Confidence = Confidence.Extracted }; + var n4 = new GraphNode { Id = "Util", Label = "Utility", Type = "module", FilePath = "util.cs", Language = "csharp", Confidence = Confidence.Extracted, Community = 1 }; + + graph.AddNode(n1); + graph.AddNode(n2); + graph.AddNode(n3); + graph.AddNode(n4); + + graph.AddEdge(new GraphEdge { Source = n1, Target = n2, Relationship = "calls", Weight = 1.0, Confidence = Confidence.Extracted }); + graph.AddEdge(new GraphEdge { Source = n2, Target = n3, Relationship = "imports", Weight = 1.0, Confidence = Confidence.Extracted }); + graph.AddEdge(new GraphEdge { Source = n1, Target = n4, Relationship = "uses", Weight = 0.5, Confidence = Confidence.Extracted }); + + return graph; + } + + [Fact] + public async Task QueryAsync_WithMatchingTerm_ReturnsResults() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.QueryAsync("Class", 10); + var result = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(result); + Assert.Equal("Class", result.Query); + Assert.Equal(2, result.ResultCount); + Assert.All(result.Results, r => Assert.Contains("Class", r.Id)); + } + + [Fact] + public async Task QueryAsync_WithNoMatch_ReturnsEmpty() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.QueryAsync("ZzzNotFound", 10); + var result = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(result); + Assert.Empty(result.Results); + } + + [Fact] + public async Task QueryAsync_WithEmptyTerm_ReturnsError() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.QueryAsync("", 10); + var error = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(error); + Assert.NotEmpty(error.Error); + } + + [Fact] + public async Task QueryAsync_RespectsLimit() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.QueryAsync("a", 1); + var result = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(result); + Assert.True(result.Results.Count <= 1); + } + + [Fact] + public async Task PathAsync_FindsShortestPath() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.PathAsync("ClassA", "Helper"); + var result = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(result); + Assert.True(result.Found); + Assert.Equal(2, result.PathLength); + Assert.NotNull(result.Path); + Assert.Equal("ClassA", result.Path[0].Id); + Assert.Equal("Helper", result.Path[^1].Id); + } + + [Fact] + public async Task PathAsync_WhenNoPath_ReturnsNotFound() + { + var graph = new KnowledgeGraph(); + graph.AddNode(new GraphNode { Id = "A", Label = "A", Type = "node", FilePath = "a.cs", Language = "csharp", Confidence = Confidence.Extracted }); + graph.AddNode(new GraphNode { Id = "B", Label = "B", Type = "node", FilePath = "b.cs", Language = "csharp", Confidence = Confidence.Extracted }); + var backend = new MemoryGraphBackend(graph); + + var json = await backend.PathAsync("A", "B"); + var result = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(result); + Assert.False(result.Found); + } + + [Fact] + public async Task PathAsync_WithMissingNode_ReturnsError() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.PathAsync("NonExistent", "ClassA"); + var error = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(error); + Assert.NotEmpty(error.Error); + } + + [Fact] + public async Task ExplainAsync_ReturnsNodeDetails() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.ExplainAsync("ClassA"); + var result = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(result); + Assert.Equal("ClassA", result.Node.Id); + Assert.Equal("class", result.Node.Type); + Assert.Equal("a.cs", result.Node.FilePath); + Assert.NotNull(result.Statistics); + Assert.True(result.Statistics.TotalDegree > 0); + } + + [Fact] + public async Task ExplainAsync_WithMissingNode_ReturnsError() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.ExplainAsync("NonExistent"); + var error = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(error); + Assert.NotEmpty(error.Error); + } + + [Fact] + public async Task CommunitiesAsync_ListsAllCommunities() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.CommunitiesAsync(null); + var result = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(result); + Assert.True(result.TotalCommunities > 0); + Assert.Contains(result.Communities, c => c.CommunityId == 1); + } + + [Fact] + public async Task CommunitiesAsync_WithSpecificId_ReturnsMembers() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.CommunitiesAsync(1); + var result = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(result); + Assert.Equal(1, result.CommunityId); + Assert.Single(result.Members); + Assert.Equal("Util", result.Members[0].Id); + } + + [Fact] + public async Task CommunitiesAsync_WithMissingId_ReturnsError() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.CommunitiesAsync(999); + var error = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(error); + Assert.NotEmpty(error.Error); + } + + [Fact] + public async Task AnalyzeAsync_ReturnsGraphStats() + { + var backend = new MemoryGraphBackend(CreateSampleGraph()); + + var json = await backend.AnalyzeAsync(5); + var result = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(result); + Assert.NotNull(result.Statistics); + Assert.Equal(4, result.Statistics.NodeCount); + Assert.Equal(3, result.Statistics.EdgeCount); + Assert.NotNull(result.TopNodes); + Assert.NotEmpty(result.NodeTypes); + Assert.NotEmpty(result.RelationshipTypes); + } + + [Fact] + public async Task AnalyzeAsync_WithEmptyGraph_ReturnsError() + { + var backend = new MemoryGraphBackend(new KnowledgeGraph()); + + var json = await backend.AnalyzeAsync(10); + var error = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(error); + Assert.NotEmpty(error.Error); + } + + [Fact] + public async Task AnalyzeAsync_TopNodes_ReturnsHighestDegree() + { + var graph = new KnowledgeGraph(); + var hub = new GraphNode { Id = "Hub", Label = "Hub", Type = "class", FilePath = "hub.cs", Language = "csharp", Confidence = Confidence.Extracted }; + graph.AddNode(hub); + for (int i = 0; i < 5; i++) + { + var n = new GraphNode { Id = $"N{i}", Label = $"N{i}", Type = "class", FilePath = "n.cs", Language = "csharp", Confidence = Confidence.Extracted }; + graph.AddNode(n); + graph.AddEdge(new GraphEdge { Source = hub, Target = n, Relationship = "connects", Weight = 1.0, Confidence = Confidence.Extracted }); + } + var backend = new MemoryGraphBackend(graph); + + var json = await backend.AnalyzeAsync(3); + var result = JsonSerializer.Deserialize(json, JsonOptions); + + Assert.NotNull(result); + Assert.Equal(6, result.Statistics.NodeCount); + Assert.Equal(5, result.Statistics.EdgeCount); + Assert.Equal("Hub", result.TopNodes[0].Id); + Assert.Equal(5, result.TopNodes[0].Degree); + } +} From e641aaebc498c3c00435a7607e4797698dd6d7af Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Tue, 21 Jul 2026 10:35:18 +0200 Subject: [PATCH 09/15] feat(openai): add support for OpenAI-compatible provider - Introduce OpenAI configuration section in appsettings.json - Add OpenAI provider option in CLI with "--provider openai" - Extend ChatClientFactory to create clients for OpenAI-compatible endpoints - Implement OpenAIClientFactory to create IChatClient instances with API key auth - Update configuration classes and options to include OpenAI settings - Store OpenAI API key securely via dotnet user-secrets - Add prompts for OpenAI endpoint, API key, and model in config wizard - Display OpenAI configuration in status output tables - Validate OpenAI provider in config resolver alongside existing providers - Add unit tests for OpenAI config persistence and SDK client factory - Ensure error handling for missing OpenAI endpoint or API key during creation --- .../Configuration/ChatClientResolver.cs | 16 ++++++- .../Configuration/ConfigPersistence.cs | 29 ++++++++++-- .../Configuration/ConfigWizard.cs | 37 +++++++++++++++ .../Configuration/ConfigurationFactory.cs | 11 ++++- .../Configuration/GraphifyConfig.cs | 12 +++++ src/Graphify.Cli/Program.cs | 13 +++++- src/Graphify.Cli/appsettings.json | 4 ++ src/Graphify.Sdk/ChatClientFactory.cs | 9 +++- src/Graphify.Sdk/OpenAIClientFactory.cs | 30 +++++++++++++ src/Graphify.Sdk/OpenAIOptions.cs | 14 ++++++ .../Cli/ConfigPersistenceTests.cs | 33 ++++++++++++++ .../Sdk/ChatClientFactoryTests.cs | 45 ++++++++++++++++++- 12 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 src/Graphify.Sdk/OpenAIClientFactory.cs create mode 100644 src/Graphify.Sdk/OpenAIOptions.cs diff --git a/src/Graphify.Cli/Configuration/ChatClientResolver.cs b/src/Graphify.Cli/Configuration/ChatClientResolver.cs index 3dda541..8923df5 100644 --- a/src/Graphify.Cli/Configuration/ChatClientResolver.cs +++ b/src/Graphify.Cli/Configuration/ChatClientResolver.cs @@ -15,7 +15,7 @@ public static class ChatClientResolver if (!Enum.TryParse(config.Provider, ignoreCase: true, out var provider)) throw new InvalidOperationException( - $"Unknown AI provider: '{config.Provider}'. Supported: azureopenai, ollama, copilotsdk"); + $"Unknown AI provider: '{config.Provider}'. Supported: azureopenai, ollama, openai, copilotsdk"); var options = provider switch { @@ -26,6 +26,12 @@ public static class ChatClientResolver ModelId: config.AzureOpenAI.ModelId, DeploymentName: config.AzureOpenAI.DeploymentName), + AiProvider.OpenAi => new AiProviderOptions( + Provider: AiProvider.OpenAi, + Endpoint: config.OpenAi.Endpoint, + ApiKey: config.OpenAi.ApiKey, + ModelId: config.OpenAi.ModelId), + AiProvider.Ollama => new AiProviderOptions( Provider: AiProvider.Ollama, Endpoint: config.Ollama.Endpoint, @@ -57,7 +63,7 @@ public static class ChatClientResolver if (!Enum.TryParse(config.Provider, ignoreCase: true, out var provider)) throw new InvalidOperationException( - $"Unknown AI provider: '{config.Provider}'. Supported: azureopenai, ollama, copilotsdk"); + $"Unknown AI provider: '{config.Provider}'. Supported: azureopenai, ollama, openai, copilotsdk"); var options = provider switch { @@ -68,6 +74,12 @@ public static class ChatClientResolver ModelId: config.AzureOpenAI.ModelId, DeploymentName: config.AzureOpenAI.DeploymentName), + AiProvider.OpenAi => new AiProviderOptions( + Provider: AiProvider.OpenAi, + Endpoint: config.OpenAi.Endpoint, + ApiKey: config.OpenAi.ApiKey, + ModelId: config.OpenAi.ModelId), + AiProvider.Ollama => new AiProviderOptions( Provider: AiProvider.Ollama, Endpoint: config.Ollama.Endpoint, diff --git a/src/Graphify.Cli/Configuration/ConfigPersistence.cs b/src/Graphify.Cli/Configuration/ConfigPersistence.cs index 5d34a3f..ba42a38 100644 --- a/src/Graphify.Cli/Configuration/ConfigPersistence.cs +++ b/src/Graphify.Cli/Configuration/ConfigPersistence.cs @@ -32,7 +32,7 @@ public static void Save(GraphifyConfig config) // Persist API key separately via user-secrets (never write it to JSON) if (!string.IsNullOrWhiteSpace(config.AzureOpenAI.ApiKey)) { - var stored = StoreApiKeyInUserSecrets(config.AzureOpenAI.ApiKey); + var stored = StoreApiKeyInUserSecrets("AzureOpenAI", config.AzureOpenAI.ApiKey); if (stored) { AnsiConsole.MarkupLine("[green]🔑 API key stored securely via dotnet user-secrets.[/]"); @@ -44,6 +44,21 @@ public static void Save(GraphifyConfig config) } } + // Store OpenAI API key in user-secrets if set + if (!string.IsNullOrWhiteSpace(config.OpenAi.ApiKey)) + { + var stored = StoreApiKeyInUserSecrets("OpenAi", config.OpenAi.ApiKey); + if (stored) + { + AnsiConsole.MarkupLine("[green]🔑 OpenAI API key stored securely via dotnet user-secrets.[/]"); + } + else + { + AnsiConsole.MarkupLine("[yellow]⚠️ Could not store OpenAI API key in user-secrets. " + + "Run 'dotnet user-secrets set \"Graphify:OpenAi:ApiKey\" \"\"' manually.[/]"); + } + } + // Build a wrapper object matching the appsettings.json structure (no API key) var wrapper = new Dictionary { ["Graphify"] = BuildSerializableConfig(config) }; @@ -97,14 +112,14 @@ public static void Save(GraphifyConfig config) /// /// Stores the API key securely using dotnet user-secrets. /// - private static bool StoreApiKeyInUserSecrets(string apiKey) + private static bool StoreApiKeyInUserSecrets(string sectionName, string apiKey) { try { var startInfo = new ProcessStartInfo { FileName = "dotnet", - Arguments = $"user-secrets set \"Graphify:AzureOpenAI:ApiKey\" \"{apiKey}\" --id \"{UserSecretsId}\"", + Arguments = $"user-secrets set \"Graphify:{sectionName}:ApiKey\" \"{apiKey}\" --id \"{UserSecretsId}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -147,6 +162,14 @@ private static bool StoreApiKeyInUserSecrets(string apiKey) config.AzureOpenAI.ModelId }; break; + case "openai": + // API key is stored in user-secrets, NOT in the JSON file + result["OpenAi"] = new + { + config.OpenAi.Endpoint, + config.OpenAi.ModelId + }; + break; case "ollama": result["Ollama"] = new { diff --git a/src/Graphify.Cli/Configuration/ConfigWizard.cs b/src/Graphify.Cli/Configuration/ConfigWizard.cs index c804f22..f6408e6 100644 --- a/src/Graphify.Cli/Configuration/ConfigWizard.cs +++ b/src/Graphify.Cli/Configuration/ConfigWizard.cs @@ -18,6 +18,7 @@ public static GraphifyConfig Run(GraphifyConfig? existing = null) .PageSize(5) .AddChoices([ "Azure OpenAI", + "OpenAI (compatible)", "Ollama (local)", "GitHub Copilot SDK", "None (AST-only mode)" @@ -31,6 +32,10 @@ public static GraphifyConfig Run(GraphifyConfig? existing = null) config.Provider = "azureopenai"; PromptAzureOpenAI(config); break; + case "OpenAI (compatible)": + config.Provider = "openai"; + PromptOpenAi(config); + break; case "Ollama (local)": config.Provider = "ollama"; PromptOllama(config); @@ -95,6 +100,33 @@ private static void PromptAzureOpenAI(GraphifyConfig config) .DefaultValue(config.AzureOpenAI.ModelId ?? "gpt-4o")); } + private static void PromptOpenAi(GraphifyConfig config) + { + AnsiConsole.Write(new Rule("[bold cyan]OpenAI-Compatible Endpoint Settings[/]").RuleStyle("cyan")); + AnsiConsole.WriteLine(); + + config.OpenAi.Endpoint = AnsiConsole.Prompt( + new TextPrompt("[green]Endpoint URL:[/]") + .Validate(v => !string.IsNullOrWhiteSpace(v) + ? ValidationResult.Success() + : ValidationResult.Error("Endpoint is required")) + .DefaultValue(config.OpenAi.Endpoint ?? "https://api.openai.com/v1")); + + config.OpenAi.ApiKey = AnsiConsole.Prompt( + new TextPrompt("[green]API Key:[/]") + .Secret() + .Validate(v => !string.IsNullOrWhiteSpace(v) + ? ValidationResult.Success() + : ValidationResult.Error("API key is required"))); + + config.OpenAi.ModelId = AnsiConsole.Prompt( + new TextPrompt("[green]Model ID:[/]") + .Validate(v => !string.IsNullOrWhiteSpace(v) + ? ValidationResult.Success() + : ValidationResult.Error("Model ID is required")) + .DefaultValue(config.OpenAi.ModelId ?? "gpt-4o")); + } + private static void PromptOllama(GraphifyConfig config) { AnsiConsole.Write(new Rule("[bold cyan]Ollama Settings[/]").RuleStyle("cyan")); @@ -141,6 +173,11 @@ private static void ShowSummary(GraphifyConfig config) table.AddRow("Deployment", config.AzureOpenAI.DeploymentName ?? "(not set)"); table.AddRow("Model", config.AzureOpenAI.ModelId ?? "(not set)"); break; + case "openai": + table.AddRow("Endpoint", config.OpenAi.Endpoint ?? "(not set)"); + table.AddRow("API Key", MaskSecret(config.OpenAi.ApiKey)); + table.AddRow("Model", config.OpenAi.ModelId ?? "(not set)"); + break; case "ollama": table.AddRow("Endpoint", config.Ollama.Endpoint); table.AddRow("Model", config.Ollama.ModelId); diff --git a/src/Graphify.Cli/Configuration/ConfigurationFactory.cs b/src/Graphify.Cli/Configuration/ConfigurationFactory.cs index bf03424..3379448 100644 --- a/src/Graphify.Cli/Configuration/ConfigurationFactory.cs +++ b/src/Graphify.Cli/Configuration/ConfigurationFactory.cs @@ -40,17 +40,26 @@ public static IConfiguration Build( { if (cliOptions.Provider?.Equals("ollama", StringComparison.OrdinalIgnoreCase) == true) overrides["Graphify:Ollama:Endpoint"] = cliOptions.Endpoint; + else if (cliOptions.Provider?.Equals("openai", StringComparison.OrdinalIgnoreCase) == true) + overrides["Graphify:OpenAi:Endpoint"] = cliOptions.Endpoint; else if (cliOptions.Provider?.Equals("copilotsdk", StringComparison.OrdinalIgnoreCase) != true) overrides["Graphify:AzureOpenAI:Endpoint"] = cliOptions.Endpoint; } if (cliOptions.ApiKey != null) - overrides["Graphify:AzureOpenAI:ApiKey"] = cliOptions.ApiKey; + { + if (cliOptions.Provider?.Equals("openai", StringComparison.OrdinalIgnoreCase) == true) + overrides["Graphify:OpenAi:ApiKey"] = cliOptions.ApiKey; + else + overrides["Graphify:AzureOpenAI:ApiKey"] = cliOptions.ApiKey; + } if (cliOptions.Model != null) { if (cliOptions.Provider?.Equals("ollama", StringComparison.OrdinalIgnoreCase) == true) overrides["Graphify:Ollama:ModelId"] = cliOptions.Model; + else if (cliOptions.Provider?.Equals("openai", StringComparison.OrdinalIgnoreCase) == true) + overrides["Graphify:OpenAi:ModelId"] = cliOptions.Model; else if (cliOptions.Provider?.Equals("copilotsdk", StringComparison.OrdinalIgnoreCase) == true) overrides["Graphify:CopilotSdk:ModelId"] = cliOptions.Model; else diff --git a/src/Graphify.Cli/Configuration/GraphifyConfig.cs b/src/Graphify.Cli/Configuration/GraphifyConfig.cs index 7e3ef66..55e04f5 100644 --- a/src/Graphify.Cli/Configuration/GraphifyConfig.cs +++ b/src/Graphify.Cli/Configuration/GraphifyConfig.cs @@ -10,6 +10,7 @@ public class GraphifyConfig public string? OutputFolder { get; set; } public string? ExportFormats { get; set; } public AzureOpenAIConfig AzureOpenAI { get; set; } = new(); + public OpenAIConfig OpenAi { get; set; } = new(); public OllamaConfig Ollama { get; set; } = new(); public CopilotSdkConfig CopilotSdk { get; set; } = new(); public SurrealDbConfig SurrealDb { get; set; } = new(); @@ -39,6 +40,17 @@ public class AzureOpenAIConfig public string? ModelId { get; set; } } +/// +/// OpenAI-compatible provider configuration. +/// Works with OpenAI API, LocalAI, LiteLLM, vLLM, and any other OpenAI-compatible service. +/// +public class OpenAIConfig +{ + public string? Endpoint { get; set; } + public string? ApiKey { get; set; } + public string? ModelId { get; set; } +} + /// /// Ollama provider configuration with sensible local defaults. /// diff --git a/src/Graphify.Cli/Program.cs b/src/Graphify.Cli/Program.cs index ef37f79..6b6126a 100644 --- a/src/Graphify.Cli/Program.cs +++ b/src/Graphify.Cli/Program.cs @@ -54,7 +54,7 @@ static void AddPipelineOptions(Command cmd, }; providerOpt = new Option("--provider", "-p") { - Description = "AI provider: azureopenai, ollama, copilotsdk" + Description = "AI provider: azureopenai, ollama, openai, copilotsdk" }; endpointOpt = new Option("--endpoint") { @@ -145,7 +145,7 @@ static void AddPipelineOptions(Command cmd, // Data privacy warning for cloud AI providers var provider = graphifyConfig.Provider?.ToLowerInvariant(); - if (provider == "azureopenai" || provider == "copilotsdk") + if (provider == "azureopenai" || provider == "openai" || provider == "copilotsdk") { Console.WriteLine($"\u26a0\ufe0f Note: Source code contents will be sent to {graphifyConfig.Provider} for semantic analysis. Use --provider ast for local-only analysis."); } @@ -657,6 +657,15 @@ static void ShowStyledConfig() azureTable.AddRow("API Key", MaskSecret(config.AzureOpenAI.ApiKey)); AnsiConsole.Write(azureTable); + // OpenAI section + var openAiTable = new Table().Border(TableBorder.Simple).Title("[bold cyan]OpenAI[/]"); + openAiTable.AddColumn("[bold]Setting[/]"); + openAiTable.AddColumn("[bold]Value[/]"); + openAiTable.AddRow("Endpoint", FormatValue(config.OpenAi.Endpoint)); + openAiTable.AddRow("Model", FormatValue(config.OpenAi.ModelId)); + openAiTable.AddRow("API Key", MaskSecret(config.OpenAi.ApiKey)); + AnsiConsole.Write(openAiTable); + // Ollama section var ollamaTable = new Table().Border(TableBorder.Simple).Title("[bold cyan]Ollama[/]"); ollamaTable.AddColumn("[bold]Setting[/]"); diff --git a/src/Graphify.Cli/appsettings.json b/src/Graphify.Cli/appsettings.json index 6e7183e..a994282 100644 --- a/src/Graphify.Cli/appsettings.json +++ b/src/Graphify.Cli/appsettings.json @@ -1,5 +1,9 @@ { "Graphify": { + "OpenAi": { + "Endpoint": "https://api.openai.com/v1", + "ModelId": "gpt-4o" + }, "Ollama": { "Endpoint": "http://localhost:11434", "ModelId": "llama3.2" diff --git a/src/Graphify.Sdk/ChatClientFactory.cs b/src/Graphify.Sdk/ChatClientFactory.cs index ed66f9f..e843669 100644 --- a/src/Graphify.Sdk/ChatClientFactory.cs +++ b/src/Graphify.Sdk/ChatClientFactory.cs @@ -9,7 +9,8 @@ public enum AiProvider { AzureOpenAI, Ollama, - CopilotSdk + CopilotSdk, + OpenAi } /// @@ -55,6 +56,12 @@ public static IChatClient Create(AiProviderOptions options) Endpoint: options.Endpoint ?? "http://localhost:11434", ModelId: options.ModelId ?? "llama3.2")), + AiProvider.OpenAi => OpenAIClientFactory.Create( + new OpenAIOptions( + Endpoint: options.Endpoint ?? throw new ArgumentException("Endpoint is required for OpenAI-compatible provider.", nameof(options)), + ApiKey: options.ApiKey ?? throw new ArgumentException("ApiKey is required for OpenAI-compatible provider.", nameof(options)), + ModelId: options.ModelId ?? throw new ArgumentException("ModelId is required for OpenAI-compatible provider.", nameof(options)))), + AiProvider.CopilotSdk => throw new InvalidOperationException( "CopilotSdk requires async initialization. Use ChatClientFactory.CreateAsync() instead."), diff --git a/src/Graphify.Sdk/OpenAIClientFactory.cs b/src/Graphify.Sdk/OpenAIClientFactory.cs new file mode 100644 index 0000000..40f9e6d --- /dev/null +++ b/src/Graphify.Sdk/OpenAIClientFactory.cs @@ -0,0 +1,30 @@ +using System.ClientModel; +using Microsoft.Extensions.AI; +using OpenAI; + +namespace Graphify.Sdk; + +/// +/// Factory for creating IChatClient instances configured for any OpenAI-compatible endpoint. +/// Supports OpenAI API, LocalAI, LiteLLM, vLLM, and any other OpenAI-compatible service. +/// +public static class OpenAIClientFactory +{ + /// + /// Creates an IChatClient using an OpenAI-compatible endpoint with API key authentication. + /// + /// OpenAI-compatible configuration (endpoint, key, model). + /// An IChatClient wired to the specified endpoint. + public static IChatClient Create(OpenAIOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var clientOptions = new OpenAIClientOptions + { + Endpoint = new Uri(options.Endpoint) + }; + + var client = new OpenAI.OpenAIClient(new ApiKeyCredential(options.ApiKey), clientOptions); + return client.GetChatClient(options.ModelId).AsIChatClient(); + } +} diff --git a/src/Graphify.Sdk/OpenAIOptions.cs b/src/Graphify.Sdk/OpenAIOptions.cs new file mode 100644 index 0000000..fa70c08 --- /dev/null +++ b/src/Graphify.Sdk/OpenAIOptions.cs @@ -0,0 +1,14 @@ +namespace Graphify.Sdk; + +/// +/// Configuration options for OpenAI-compatible provider. +/// Works with any OpenAI-compatible endpoint (OpenAI API, LocalAI, LiteLLM, vLLM, etc.). +/// +/// Service endpoint URL, e.g. "https://api.openai.com/v1" +/// API key for the service. +/// Model identifier, e.g. "gpt-4o" or "llama3.2". +public record OpenAIOptions( + string Endpoint, + string ApiKey, + string ModelId +); diff --git a/src/tests/Graphify.Tests/Cli/ConfigPersistenceTests.cs b/src/tests/Graphify.Tests/Cli/ConfigPersistenceTests.cs index 2af5bcd..f3493ab 100644 --- a/src/tests/Graphify.Tests/Cli/ConfigPersistenceTests.cs +++ b/src/tests/Graphify.Tests/Cli/ConfigPersistenceTests.cs @@ -155,6 +155,33 @@ public void Save_Load_RoundTrip_CopilotSdk() Assert.Equal("gpt-4.1", loaded.CopilotSdk.ModelId); } + // ── Round-trip: OpenAI ───────────────────────── + + [Fact] + public void Save_Load_RoundTrip_OpenAi() + { + var original = new GraphifyConfig + { + Provider = "openai", + OpenAi = new OpenAIConfig + { + Endpoint = "https://api.openai.com/v1", + ApiKey = "sk-test-key-12345", + ModelId = "gpt-4o" + } + }; + + ConfigPersistence.Save(original); + var loaded = ConfigPersistence.Load(); + + Assert.NotNull(loaded); + Assert.Equal("openai", loaded.Provider); + Assert.Equal("https://api.openai.com/v1", loaded.OpenAi.Endpoint); + // API key is stored in user-secrets, not in JSON + Assert.Null(loaded.OpenAi.ApiKey); + Assert.Equal("gpt-4o", loaded.OpenAi.ModelId); + } + // ── Round-trip: Null provider (AST-only) ────────────────────────── [Fact] @@ -246,6 +273,8 @@ public void Save_AzureOpenAI_DoesNotIncludeOllamaOrCopilotSdkFields() Assert.True(graphify.TryGetProperty("AzureOpenAI", out _), "Azure config should have AzureOpenAI section"); + Assert.False(graphify.TryGetProperty("OpenAi", out _), + "Azure config should NOT have OpenAi section"); Assert.False(graphify.TryGetProperty("Ollama", out _), "Azure config should NOT have Ollama section"); Assert.False(graphify.TryGetProperty("CopilotSdk", out _), @@ -273,6 +302,8 @@ public void Save_Ollama_DoesNotIncludeAzureOrCopilotSdkFields() Assert.True(graphify.TryGetProperty("Ollama", out _), "Ollama config should have Ollama section"); + Assert.False(graphify.TryGetProperty("OpenAi", out _), + "Ollama config should NOT have OpenAi section"); Assert.False(graphify.TryGetProperty("AzureOpenAI", out _), "Ollama config should NOT have AzureOpenAI section"); Assert.False(graphify.TryGetProperty("CopilotSdk", out _), @@ -296,6 +327,8 @@ public void Save_CopilotSdk_DoesNotIncludeAzureOrOllamaFields() Assert.True(graphify.TryGetProperty("CopilotSdk", out _), "CopilotSdk config should have CopilotSdk section"); + Assert.False(graphify.TryGetProperty("OpenAi", out _), + "CopilotSdk config should NOT have OpenAi section"); Assert.False(graphify.TryGetProperty("AzureOpenAI", out _), "CopilotSdk config should NOT have AzureOpenAI section"); Assert.False(graphify.TryGetProperty("Ollama", out _), diff --git a/src/tests/Graphify.Tests/Sdk/ChatClientFactoryTests.cs b/src/tests/Graphify.Tests/Sdk/ChatClientFactoryTests.cs index 8d51cac..13bdb98 100644 --- a/src/tests/Graphify.Tests/Sdk/ChatClientFactoryTests.cs +++ b/src/tests/Graphify.Tests/Sdk/ChatClientFactoryTests.cs @@ -19,6 +19,7 @@ public void AiProvider_HasExpectedValues() Assert.True(Enum.IsDefined(typeof(AiProvider), AiProvider.AzureOpenAI)); Assert.True(Enum.IsDefined(typeof(AiProvider), AiProvider.Ollama)); Assert.True(Enum.IsDefined(typeof(AiProvider), AiProvider.CopilotSdk)); + Assert.True(Enum.IsDefined(typeof(AiProvider), AiProvider.OpenAi)); } [Fact] @@ -26,7 +27,7 @@ public void AiProvider_HasExpectedValues() public void AiProvider_HasExactlyThreeValues() { var values = Enum.GetValues(); - Assert.Equal(3, values.Length); + Assert.Equal(4, values.Length); } [Fact] @@ -114,6 +115,48 @@ public void Create_Ollama_WithDefaults_ReturnsClient() Assert.NotNull(client); } + [Fact] + [Trait("Category", "Sdk")] + public void Create_OpenAi_MissingEndpoint_Throws() + { + var options = new AiProviderOptions( + Provider: AiProvider.OpenAi, + ApiKey: "key", + ModelId: "gpt-4o"); + + Assert.Throws(() => + ChatClientFactory.Create(options)); + } + + [Fact] + [Trait("Category", "Sdk")] + public void Create_OpenAi_MissingApiKey_Throws() + { + var options = new AiProviderOptions( + Provider: AiProvider.OpenAi, + Endpoint: "https://api.openai.com/v1", + ModelId: "gpt-4o"); + + Assert.Throws(() => + ChatClientFactory.Create(options)); + } + + [Fact] + [Trait("Category", "Sdk")] + public void AiProviderOptions_OpenAi_ConstructionWorks() + { + var options = new AiProviderOptions( + Provider: AiProvider.OpenAi, + Endpoint: "https://api.openai.com/v1", + ApiKey: "sk-test", + ModelId: "gpt-4o"); + + Assert.Equal(AiProvider.OpenAi, options.Provider); + Assert.Equal("https://api.openai.com/v1", options.Endpoint); + Assert.Equal("sk-test", options.ApiKey); + Assert.Equal("gpt-4o", options.ModelId); + } + [Fact] [Trait("Category", "Sdk")] public void Create_AzureOpenAI_MissingEndpoint_Throws() From 7de84b5264720a6d11b8ffa12bd7e96f4b8ca2cf Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Tue, 21 Jul 2026 10:49:25 +0200 Subject: [PATCH 10/15] docs(cli): add OpenAI-compatible provider and update related docs - Include `openai` in the list of supported AI providers in CLI reference - Add usage example for running with OpenAI-compatible endpoint and model - Document OpenAI-compatible provider in configuration guide with env vars and secrets - Update getting started with OpenAI-compatible provider setup instructions - Clarify no separate setup guide needed for OpenAI-compatible provider in references section --- docs/cli-reference.md | 5 ++++- docs/configuration.md | 16 ++++++++++++++++ docs/getting-started.md | 1 + 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a8b1370..a996d26 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -24,7 +24,7 @@ graphify run [path] [options] | `--output`, `-o` | `graphify-out` | Output directory | | `--format`, `-f` | `json,html,report` | Export formats (comma-separated): `json`, `html`, `svg`, `neo4j`, `ladybug`, `surrealdb`, `obsidian`, `wiki`, `report` | | `--verbose`, `-v` | `false` | Enable detailed progress output | -| `--provider`, `-p` | *(from config)* | AI provider: `azureopenai`, `ollama`, `copilotsdk` | +| `--provider`, `-p` | *(from config)* | AI provider: `azureopenai`, `ollama`, `openai`, `copilotsdk` | | `--endpoint` | *(from config)* | AI service endpoint URL | | `--api-key` | *(from config)* | API key for the AI provider | | `--model` | *(from config)* | Model ID (e.g., `gpt-4o`, `llama3.2`) | @@ -46,6 +46,9 @@ graphify run . --format json,html,svg,neo4j,ladybug,obsidian,wiki,report,surreal # Use Ollama locally graphify run . --provider ollama --model codellama +# Use OpenAI-compatible endpoint (OpenAI API, LocalAI, LiteLLM, etc.) +graphify run . --provider openai --endpoint https://api.openai.com/v1 --api-key sk-... --model gpt-4o + # Custom output directory graphify run . --output my-output-dir diff --git a/docs/configuration.md b/docs/configuration.md index 6cb5086..d648f88 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,7 @@ Select your AI backend: | Provider | When to use | |----------|-------------| | **Azure OpenAI** | Enterprise, private endpoints — requires endpoint, API key, deployment name | +| **OpenAI (compatible)** | Any OpenAI-compatible service (OpenAI API, LocalAI, LiteLLM, vLLM, etc.) — requires endpoint, API key, model | | **Ollama (local)** | Offline/privacy — requires a running Ollama instance | | **GitHub Copilot SDK** | Zero-config for Copilot subscribers — just select a model | | **None (AST-only)** | No AI needed — structural extraction only | @@ -80,6 +81,12 @@ export GRAPHIFY__AzureOpenAI__Endpoint=https://myresource.openai.azure.com/ export GRAPHIFY__AzureOpenAI__ApiKey=sk-... export GRAPHIFY__AzureOpenAI__DeploymentName=gpt-4o +# OpenAI (compatible) +export GRAPHIFY__Provider=OpenAi +export GRAPHIFY__OpenAi__Endpoint=https://api.openai.com/v1 +export GRAPHIFY__OpenAi__ApiKey=sk-... +export GRAPHIFY__OpenAi__ModelId=gpt-4o + # Ollama export GRAPHIFY__Provider=Ollama export GRAPHIFY__Ollama__Endpoint=http://localhost:11434 @@ -98,11 +105,18 @@ For development, use .NET user secrets to avoid committing API keys: cd src/Graphify.Cli dotnet user-secrets init +# Azure OpenAI # Set provider and credentials dotnet user-secrets set "Graphify:Provider" "AzureOpenAI" dotnet user-secrets set "Graphify:AzureOpenAI:Endpoint" "https://myresource.openai.azure.com/" dotnet user-secrets set "Graphify:AzureOpenAI:ApiKey" "sk-..." dotnet user-secrets set "Graphify:AzureOpenAI:DeploymentName" "gpt-4o" + +# OpenAI (compatible) +dotnet user-secrets set "Graphify:Provider" "OpenAi" +dotnet user-secrets set "Graphify:OpenAi:Endpoint" "https://api.openai.com/v1" +dotnet user-secrets set "Graphify:OpenAi:ApiKey" "sk-..." +dotnet user-secrets set "Graphify:OpenAi:ModelId" "gpt-4o" ``` ## The `--config` Flag on Run @@ -122,3 +136,5 @@ For detailed setup instructions per provider: - [Azure OpenAI Setup](setup-azure-openai.md) - [Ollama Setup](setup-ollama.md) - [Copilot SDK Setup](setup-copilot-sdk.md) + +The generic **OpenAI (compatible)** provider works with any OpenAI-compatible endpoint — simply configure an endpoint URL, API key, and model ID. No separate setup guide required. diff --git a/docs/getting-started.md b/docs/getting-started.md index 8d00cfc..c86ad48 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -174,6 +174,7 @@ Select **🔧 Set up AI provider** and choose one: | Provider | Setup | |----------|-------| | **Ollama** (local, free) | Install [Ollama](https://ollama.com), pull a model: `ollama pull llama3.2` | +| **OpenAI (compatible)** | Any OpenAI-compatible service — configure endpoint, API key, model | | **Azure OpenAI** (cloud) | Need endpoint URL, API key, deployment name | | **GitHub Copilot SDK** | Zero config for Copilot subscribers | From 37206664c87debb15158acab40cc42fc4b3fbf69 Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Tue, 21 Jul 2026 11:09:51 +0200 Subject: [PATCH 11/15] feat(config): add support for .env file loading in CLI app - Introduce DotNetEnv package to load .env files automatically at startup - Update configuration priority order to include .env file between user secrets and environment variables - Add documentation for .env usage and security advice to .gitignore secrets - Provide example .env file with Graphify provider and OpenAI settings - Ensure local development environment variables are easily manageable and secure --- docs/configuration.md | 21 ++++++++++++++++++--- src/Graphify.Cli/Graphify.Cli.csproj | 1 + src/Graphify.Cli/Program.cs | 3 +++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d648f88..138b989 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,12 +64,27 @@ Settings are resolved in priority order (highest wins): |----------|--------|---------| | 1 (highest) | **CLI arguments** | `--provider ollama --model codellama` | | 2 | **User secrets** | `dotnet user-secrets set "Graphify:Provider" "Ollama"` | -| 3 | **Environment variables** | `GRAPHIFY__Provider=ollama` | -| 4 | **appsettings.local.json** | Saved by `graphify config` wizard | -| 5 (lowest) | **appsettings.json** | Built-in defaults | +| 3 | **.env file** | `GRAPHIFY__Provider=OpenAi` in `.env` | +| 4 | **Environment variables** | `GRAPHIFY__Provider=ollama` | +| 5 | **appsettings.local.json** | Saved by `graphify config` wizard | +| 6 (lowest) | **appsettings.json** | Built-in defaults | This means CLI flags always override everything else, user secrets override environment variables, and so on. +## .env File + +For local development, create a `.env` file in the project root directory. Graphify loads it automatically at startup. This is useful for keeping API keys out of your shell history or IDE configuration. + +```bash +# .env file — loaded automatically by graphify +GRAPHIFY__Provider=OpenAi +GRAPHIFY__OpenAi__Endpoint=https://opencode.ai/zen/v1 +GRAPHIFY__OpenAi__ApiKey=sk-opencode-zen-... +GRAPHIFY__OpenAi__ModelId=deepseek-v4-flash-free +``` + +> **Security:** Add `.env` to your `.gitignore` to avoid committing secrets. + ## Environment Variables All settings use the `GRAPHIFY__` prefix with double-underscore nesting: diff --git a/src/Graphify.Cli/Graphify.Cli.csproj b/src/Graphify.Cli/Graphify.Cli.csproj index 6affb9f..1b0eafb 100644 --- a/src/Graphify.Cli/Graphify.Cli.csproj +++ b/src/Graphify.Cli/Graphify.Cli.csproj @@ -26,6 +26,7 @@ + diff --git a/src/Graphify.Cli/Program.cs b/src/Graphify.Cli/Program.cs index 6b6126a..1e44dde 100644 --- a/src/Graphify.Cli/Program.cs +++ b/src/Graphify.Cli/Program.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.Text.Json; +using DotNetEnv; using Graphify.Cli.Configuration; using Graphify.Cli.Init; using Graphify.Cli.Mcp; @@ -17,6 +18,8 @@ using SurrealDb.Net; using SurrealDb.Net.Models.Auth; +Env.Load(); + var rootCommand = new RootCommand("graphify-dotnet: AI-powered knowledge graph builder for codebases"); // ── Shared option/argument factory helpers ─────────────────────────────── From d0c1db0500dbe61e0fcb752ac12305d88acd9aef Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Tue, 21 Jul 2026 14:16:14 +0200 Subject: [PATCH 12/15] docs(openai): add comprehensive OpenAI-compatible setup guide - Create new setup-openai.md document with detailed instructions - Introduce OpenAI-compatible option as a versatile provider choice - Provide quick start, provider list, and configuration methods - Include CLI, environment variable, user secrets, and code examples - Add troubleshooting section for common errors and fixes - Reference this new guide in Azure, Ollama, Copilot SDK docs under See Also - Update configuration.md to mention OpenAI (Compatible) Setup link - Highlight benefits of multi-provider flexibility and self-hosted options --- docs/configuration.md | 3 +- docs/setup-azure-openai.md | 1 + docs/setup-copilot-sdk.md | 1 + docs/setup-ollama.md | 1 + docs/setup-openai.md | 309 +++++++++++++++++++++++++++++++++++++ 5 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 docs/setup-openai.md diff --git a/docs/configuration.md b/docs/configuration.md index 138b989..3abaf59 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -149,7 +149,6 @@ This opens the interactive wizard, saves your choices, then immediately runs the For detailed setup instructions per provider: - [Azure OpenAI Setup](setup-azure-openai.md) +- [OpenAI (Compatible) Setup](setup-openai.md) - [Ollama Setup](setup-ollama.md) - [Copilot SDK Setup](setup-copilot-sdk.md) - -The generic **OpenAI (compatible)** provider works with any OpenAI-compatible endpoint — simply configure an endpoint URL, API key, and model ID. No separate setup guide required. diff --git a/docs/setup-azure-openai.md b/docs/setup-azure-openai.md index bbc4ba8..62f30d3 100644 --- a/docs/setup-azure-openai.md +++ b/docs/setup-azure-openai.md @@ -358,6 +358,7 @@ var deploymentName = "gpt-4o"; // Must match Azure Portal exactly ## See Also +- [Using graphify-dotnet with OpenAI (Compatible) Endpoints](./setup-openai.md) - [Using graphify-dotnet with Ollama (Local Models)](./setup-ollama.md) - [Using graphify-dotnet with GitHub Copilot SDK](./setup-copilot-sdk.md) - [Azure OpenAI Documentation](https://learn.microsoft.com/en-us/azure/ai-services/openai/) diff --git a/docs/setup-copilot-sdk.md b/docs/setup-copilot-sdk.md index 8e26a99..eba3ccc 100644 --- a/docs/setup-copilot-sdk.md +++ b/docs/setup-copilot-sdk.md @@ -313,6 +313,7 @@ When using the CLI (`graphify run`), this is handled automatically. ## See Also +- [Using graphify-dotnet with OpenAI (Compatible) Endpoints](./setup-openai.md) - [Using graphify-dotnet with Azure OpenAI](./setup-azure-openai.md) - [Using graphify-dotnet with Ollama (Local Models)](./setup-ollama.md) - [GitHub Copilot Documentation](https://docs.github.com/en/copilot) diff --git a/docs/setup-ollama.md b/docs/setup-ollama.md index 443db10..453bb33 100644 --- a/docs/setup-ollama.md +++ b/docs/setup-ollama.md @@ -497,6 +497,7 @@ var client = ChatClientFactory.Create(options); ## See Also +- [Using graphify-dotnet with OpenAI (Compatible) Endpoints](./setup-openai.md) - [Using graphify-dotnet with Azure OpenAI](./setup-azure-openai.md) - [Using graphify-dotnet with GitHub Copilot SDK](./setup-copilot-sdk.md) - [Ollama Documentation](https://ollama.com) diff --git a/docs/setup-openai.md b/docs/setup-openai.md new file mode 100644 index 0000000..00879e3 --- /dev/null +++ b/docs/setup-openai.md @@ -0,0 +1,309 @@ +# Using graphify-dotnet with OpenAI (Compatible) Endpoints + +Use any OpenAI-compatible service for semantic code analysis — OpenAI API, OpenCode Zen, LocalAI, LiteLLM, vLLM, Groq, Together AI, and more. + +## Quick Start + +1. Get an API key from your OpenAI-compatible provider +2. Find the endpoint URL (e.g., `https://api.openai.com/v1` or `https://opencode.ai/zen/v1`) +3. Configure graphify-dotnet with `OpenAIClientFactory` or unified `ChatClientFactory` +4. Start analyzing! + +## Why Use OpenAI-Compatible? + +| Feature | OpenAI-Compatible | Azure OpenAI | Ollama | +|---------|-------------------|-------------|--------| +| **Choice** | Any provider, any model | Azure-only models | Open-source only | +| **Setup** | Endpoint + API key + model | Azure resource + deployment | Install + pull models | +| **Cost** | Varies by provider | Pay per request | Free (local hardware) | +| **Free options** | OpenCode Zen, others | Free tier available | Free | +| **Privacy** | Varies by provider | Enterprise-grade | Data stays local | + +Perfect for: +- **Using specific models** not available on other providers +- **Free or low-cost alternatives** like OpenCode Zen +- **Self-hosted backends** (LocalAI, vLLM, LiteLLM) for privacy +- **Multi-provider flexibility** — switch models without reconfiguration + +## Prerequisites + +- An OpenAI-compatible API endpoint and API key +- The endpoint must support the standard `/v1/chat/completions` format + +## Step 1: Choose a Provider + +### OpenAI API (Official) + +```bash +Endpoint: https://api.openai.com/v1 +Models: gpt-4o, gpt-4o-mini, gpt-4.1, o3, etc. +Sign up: https://platform.openai.com +``` + +### OpenCode Zen (Free Models Available) + +```bash +Endpoint: https://opencode.ai/zen/v1 +Free models: deepseek-v4-flash-free, mimo-v2.5-free, qwen3.6-plus-free +Sign up: https://opencode.ai/auth +``` + +### LocalAI (Self-Hosted) + +```bash +Endpoint: http://localhost:8080/v1 +Models: Any open-source model +Setup: https://localai.io +``` + +### LiteLLM (Self-Hosted Proxy) + +```bash +Endpoint: http://localhost:4000 +Models: Route to any provider through a unified endpoint +Setup: https://litellm.vercel.app +``` + +### vLLM (Self-Hosted) + +```bash +Endpoint: http://localhost:8000/v1 +Models: High-throughput open-source models +Setup: https://github.com/vllm-project/vllm +``` + +## Step 2: Configure graphify-dotnet + +### CLI Usage (Recommended) + +```bash +# Run with any OpenAI-compatible endpoint +graphify run ./my-project \ + --provider openai \ + --endpoint https://api.openai.com/v1 \ + --api-key sk-... \ + --model gpt-4o + +# With OpenCode Zen free model +graphify run ./my-project \ + --provider openai \ + --endpoint https://opencode.ai/zen/v1 \ + --api-key sk-opencode-zen-... \ + --model deepseek-v4-flash-free + +# With a self-hosted LocalAI instance +graphify run ./my-project \ + --provider openai \ + --endpoint http://localhost:8080/v1 \ + --api-key not-needed \ + --model llama-3.2 +``` + +### Configuration Sources + +graphify-dotnet supports a layered configuration system (priority order): +1. **CLI arguments** (highest priority) +2. **User secrets** (.NET user secrets) +3. **.env file** (`GRAPHIFY__*` variables in `.env`) +4. **Environment variables** (`GRAPHIFY__*`) +5. **appsettings.local.json** (saved by `graphify config` wizard) +6. **appsettings.json** (lowest priority) + +### .env File + +Create a `.env` file in your project root: + +```bash +GRAPHIFY__Provider=OpenAi +GRAPHIFY__OpenAi__Endpoint=https://opencode.ai/zen/v1 +GRAPHIFY__OpenAi__ApiKey=sk-opencode-zen-... +GRAPHIFY__OpenAi__ModelId=deepseek-v4-flash-free +``` + +### Environment Variables + +```bash +# Linux/macOS +export GRAPHIFY__Provider=OpenAi +export GRAPHIFY__OpenAi__Endpoint=https://api.openai.com/v1 +export GRAPHIFY__OpenAi__ApiKey=sk-... +export GRAPHIFY__OpenAi__ModelId=gpt-4o + +# Windows (PowerShell) +$env:GRAPHIFY__Provider = "OpenAi" +$env:GRAPHIFY__OpenAi__Endpoint = "https://api.openai.com/v1" +$env:GRAPHIFY__OpenAi__ApiKey = "sk-..." +$env:GRAPHIFY__OpenAi__ModelId = "gpt-4o" +``` + +### User Secrets + +Use .NET user secrets to keep API keys out of source control: + +```bash +# Set secrets for your project +dotnet user-secrets set "Graphify:Provider" "OpenAi" +dotnet user-secrets set "Graphify:OpenAi:Endpoint" "https://api.openai.com/v1" +dotnet user-secrets set "Graphify:OpenAi:ApiKey" "sk-..." +dotnet user-secrets set "Graphify:OpenAi:ModelId" "gpt-4o" + +# List configured secrets +dotnet user-secrets list +``` + +### appsettings.json + +```json +{ + "Graphify": { + "Provider": "OpenAi", + "OpenAi": { + "Endpoint": "https://api.openai.com/v1", + "ModelId": "gpt-4o" + } + } +} +``` + +> **Note:** The API key is stored in user-secrets, not in this file. + +### View Current Configuration + +```bash +graphify config show +``` + +This displays the active configuration values from all sources (API keys are masked). + +### Programmatic Configuration (Code) + +For SDK usage in your own applications: + +```csharp +using Graphify.Sdk; +using Microsoft.Extensions.AI; + +// Create OpenAI-compatible options +var aiOptions = new AiProviderOptions( + Provider: AiProvider.OpenAi, + Endpoint: "https://api.openai.com/v1", + ApiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY"), + ModelId: "gpt-4o" +); + +IChatClient client = ChatClientFactory.Create(aiOptions); + +// Use the client +var response = await client.GetResponseAsync( + [new ChatMessage(ChatRole.User, "Analyze this code structure...")]); +Console.WriteLine(response.Text); +``` + +## Full Working Example + +```csharp +using System; +using Graphify.Sdk; +using Microsoft.Extensions.AI; + +public class OpenCodeAnalyzer +{ + public static async Task Main(string[] args) + { + // 1. Create options + var options = new AiProviderOptions( + Provider: AiProvider.OpenAi, + Endpoint: "https://opencode.ai/zen/v1", + ApiKey: Environment.GetEnvironmentVariable("OPENCODE_API_KEY") + ?? throw new InvalidOperationException("Missing OPENCODE_API_KEY"), + ModelId: "deepseek-v4-flash-free" + ); + + // 2. Create the chat client + IChatClient client = ChatClientFactory.Create(options); + + // 3. Analyze code + string codeSnippet = @" +public class Calculator { + public int Add(int a, int b) => a + b; + public int Multiply(int a, int b) => a * b; +}"; + + string prompt = $"Analyze this C# code and explain its structure:\n\n{codeSnippet}"; + + Console.WriteLine("Analyzing with OpenAI-compatible endpoint..."); + var response = await client.GetResponseAsync( + [new ChatMessage(ChatRole.User, prompt)]); + Console.WriteLine("\nAnalysis:"); + Console.WriteLine(response.Text); + } +} +``` + +## Recommended Models + +| Model | Provider | Use Case | +|-------|----------|----------| +| **gpt-4o** | OpenAI API | Production, best quality | +| **gpt-4o-mini** | OpenAI API | Cost-efficient, fast | +| **deepseek-v4-flash-free** | OpenCode Zen | Free, good for testing | +| **qwen3.6-plus-free** | OpenCode Zen | Free alternative | + +## Troubleshooting + +### ❌ 401 Unauthorized + +**Cause**: Invalid API key or endpoint + +**Solution**: +- Double-check your API key +- Verify the endpoint URL is correct +- Some self-hosted backends may not require an API key — use any placeholder value + +### ❌ 404 Not Found + +**Cause**: Wrong endpoint URL or path + +**Solution**: +- Ensure the endpoint includes `/v1` suffix if required (e.g., `https://api.openai.com/v1`) +- Some providers use different base paths — check their documentation + +### ❌ Model Not Found + +**Cause**: The specified model ID doesn't exist on that provider + +**Solution**: +- List available models: `curl /models` (many providers support this) +- Check the provider's model catalog +- Some free tiers have limited model access + +### ❌ Rate Limited (429) + +**Cause**: Exceeded rate limits or quota + +**Solution**: +- Wait and retry with exponential backoff +- Upgrade to a higher tier (paid plans have higher limits) +- Free models often have strict rate limits — consider a paid alternative + +## See Also + +- [Using graphify-dotnet with Azure OpenAI](./setup-azure-openai.md) +- [Using graphify-dotnet with Ollama (Local Models)](./setup-ollama.md) +- [Using graphify-dotnet with GitHub Copilot SDK](./setup-copilot-sdk.md) +- [OpenAI API Documentation](https://platform.openai.com/docs) +- [OpenCode Zen Documentation](https://opencode.ai/docs/zen) +- [Configuration Guide](./configuration.md) +- [API Reference: OpenAIClientFactory](../src/Graphify.Sdk/OpenAIClientFactory.cs) + +## Next Steps + +Once configured: +1. Run your first analysis: `graphify run . --provider openai --endpoint --api-key --model ` +2. Try different providers by changing the endpoint and model +3. Explore the [README](../README.md) for full CLI features and export formats +4. Check out [Export Formats](./export-formats.md) for output options + +--- + +**Need help?** Open an issue on [GitHub](https://github.com/elbruno/graphify-dotnet) or check the [documentation](../README.md). From 124600fa9360345a8cebd763b52c7fdc3f098b33 Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Tue, 21 Jul 2026 23:02:12 +0200 Subject: [PATCH 13/15] chore(config): add comprehensive .env example and update config loading - Add detailed .env configuration example with all provider options to docs folder - Document .env.example location in configuration.md for better user guidance - Introduce .env file with default Graphify settings including AI providers and SurrealDB - Update CLI to resolve export formats and paths with proper priority from config sources - Refactor Graphify.Cli to support config wizard and unified config loading logic - Upgrade project versions to 0.9.0-preview.15 in multiple csproj files - Adjust nuget.config local package path for correct package source - Add dotnet-tools.json to define Graphify CLI tool with fixed version - Enhance SurrealDb exporter to dynamically use namespace and database settings - Switch SurrealDb entity creation to use parameterized RawQuery to avoid CBOR errors - Remove old concrete SurrealDb entity and relationship classes no longer needed - Improve remote SurrealDb connection by defining namespace and database if missing before use --- .env | 85 ++++++++++++ docs/.env.example | 85 ++++++++++++ docs/configuration.md | 2 + dotnet-tools.json | 13 ++ nuget.config | 2 +- src/Graphify.Cli/Graphify.Cli.csproj | 2 +- src/Graphify.Cli/Program.cs | 60 +++++---- src/Graphify.Sdk/Graphify.Sdk.csproj | 2 +- src/Graphify/Export/SurrealDbExporter.cs | 158 ++++++++++++----------- src/Graphify/Graphify.csproj | 2 +- 10 files changed, 307 insertions(+), 104 deletions(-) create mode 100644 .env create mode 100644 docs/.env.example create mode 100644 dotnet-tools.json diff --git a/.env b/.env new file mode 100644 index 0000000..9a79a29 --- /dev/null +++ b/.env @@ -0,0 +1,85 @@ +# ============================================================================= +# Graphify Configuration (.env) +# ============================================================================= +# Place this file in the project root directory. Graphify loads it automatically +# at startup. Settings use the GRAPHIFY__ prefix with double-underscore nesting. +# +# Configuration priority (highest wins): +# 1. CLI arguments (--provider, --endpoint, etc.) +# 2. dotnet user-secrets +# 3. .env file ← You are here +# 4. Environment variables (GRAPHIFY__*) +# 5. appsettings.local.json (wizard-saved) +# 6. appsettings.json (built-in defaults) +# ============================================================================= + +# ── Global Settings ────────────────────────────────────────────────────────── + +# Active AI provider: azureopenai, openai, ollama, copilotsdk +GRAPHIFY__Provider=openai + +# Project folder to analyze (default: current directory) +GRAPHIFY__WorkingFolder=. + +# Output directory (default: graphify-out) +GRAPHIFY__OutputFolder=graphify-out + +# Export formats, comma-separated (default: json,html,report) +# Available: json, html, svg, neo4j, ladybug, obsidian, wiki, surrealdb, report +GRAPHIFY__ExportFormats=surrealdb + +# ── OpenAI (Compatible) Provider ───────────────────────────────────────────── +# Works with any OpenAI-compatible endpoint: OpenAI API, OpenCode Zen, +# LocalAI, LiteLLM, vLLM, Groq, Together AI, etc. + +# Base endpoint URL (required) +GRAPHIFY__OpenAi__Endpoint=https://opencode.ai/zen/v1 + +# API key (required) +GRAPHIFY__OpenAi__ApiKey=sk-k3mky4zwHRNIgfTbfqtVRJN8J5HNPin7NbJRA9X74giDjIGE8vSGSrrpiUDTl6Ns + +# Model identifier, e.g. gpt-4o, gpt-4o-mini, deepseek-v4-flash-free +GRAPHIFY__OpenAi__ModelId=opencode/deepseek-v4-flash-free + +# ── Azure OpenAI Provider ──────────────────────────────────────────────────── + +# Azure resource endpoint (required) +GRAPHIFY__AzureOpenAI__Endpoint=https://my-resource.openai.azure.com/ + +# API key (required) +GRAPHIFY__AzureOpenAI__ApiKey=sk-your-key-here + +# Model deployment name (required) +GRAPHIFY__AzureOpenAI__DeploymentName=gpt-4o + +# Optional model ID override (defaults to DeploymentName) +GRAPHIFY__AzureOpenAI__ModelId=gpt-4o + +# ── Ollama Provider (Local) ────────────────────────────────────────────────── + +# Ollama server URL (default: http://localhost:11434) +GRAPHIFY__Ollama__Endpoint=http://localhost:11434 + +# Model to use (default: llama3.2) +GRAPHIFY__Ollama__ModelId=llama3.2 + +# ── GitHub Copilot SDK Provider ────────────────────────────────────────────── +# Authentication via GitHub Copilot CLI session (no API key needed) + +# Model to use (default: gpt-4.1) +GRAPHIFY__CopilotSdk__ModelId=gpt-4.1 + +# ── SurrealDB (MCP Backend) ────────────────────────────────────────────────── +# Configure when using `graphify serve` with a remote SurrealDB instance. +# When Endpoint is set, remote mode is used; otherwise embedded mode. + +# Remote SurrealDB endpoint (e.g., http://localhost:8000) +GRAPHIFY__SurrealDb__Endpoint=http://localhost:8000 + +# Authentication credentials +GRAPHIFY__SurrealDb__Username=root +GRAPHIFY__SurrealDb__Password=root + +# Namespace and database (defaults: graphify / codebase) +GRAPHIFY__SurrealDb__Namespace=archsentinal +GRAPHIFY__SurrealDb__Database=codebase diff --git a/docs/.env.example b/docs/.env.example new file mode 100644 index 0000000..bb65106 --- /dev/null +++ b/docs/.env.example @@ -0,0 +1,85 @@ +# ============================================================================= +# Graphify Configuration (.env) +# ============================================================================= +# Place this file in the project root directory. Graphify loads it automatically +# at startup. Settings use the GRAPHIFY__ prefix with double-underscore nesting. +# +# Configuration priority (highest wins): +# 1. CLI arguments (--provider, --endpoint, etc.) +# 2. dotnet user-secrets +# 3. .env file ← You are here +# 4. Environment variables (GRAPHIFY__*) +# 5. appsettings.local.json (wizard-saved) +# 6. appsettings.json (built-in defaults) +# ============================================================================= + +# ── Global Settings ────────────────────────────────────────────────────────── + +# Active AI provider: azureopenai, openai, ollama, copilotsdk +GRAPHIFY__Provider=openai + +# Project folder to analyze (default: current directory) +GRAPHIFY__WorkingFolder=. + +# Output directory (default: graphify-out) +GRAPHIFY__OutputFolder=graphify-out + +# Export formats, comma-separated (default: json,html,report) +# Available: json, html, svg, neo4j, ladybug, obsidian, wiki, surrealdb, report +GRAPHIFY__ExportFormats=json,html,report + +# ── OpenAI (Compatible) Provider ───────────────────────────────────────────── +# Works with any OpenAI-compatible endpoint: OpenAI API, OpenCode Zen, +# LocalAI, LiteLLM, vLLM, Groq, Together AI, etc. + +# Base endpoint URL (required) +GRAPHIFY__OpenAi__Endpoint=https://api.openai.com/v1 + +# API key (required) +GRAPHIFY__OpenAi__ApiKey=sk-your-key-here + +# Model identifier, e.g. gpt-4o, gpt-4o-mini, deepseek-v4-flash-free +GRAPHIFY__OpenAi__ModelId=gpt-4o + +# ── Azure OpenAI Provider ──────────────────────────────────────────────────── + +# Azure resource endpoint (required) +GRAPHIFY__AzureOpenAI__Endpoint=https://my-resource.openai.azure.com/ + +# API key (required) +GRAPHIFY__AzureOpenAI__ApiKey=sk-your-key-here + +# Model deployment name (required) +GRAPHIFY__AzureOpenAI__DeploymentName=gpt-4o + +# Optional model ID override (defaults to DeploymentName) +GRAPHIFY__AzureOpenAI__ModelId=gpt-4o + +# ── Ollama Provider (Local) ────────────────────────────────────────────────── + +# Ollama server URL (default: http://localhost:11434) +GRAPHIFY__Ollama__Endpoint=http://localhost:11434 + +# Model to use (default: llama3.2) +GRAPHIFY__Ollama__ModelId=llama3.2 + +# ── GitHub Copilot SDK Provider ────────────────────────────────────────────── +# Authentication via GitHub Copilot CLI session (no API key needed) + +# Model to use (default: gpt-4.1) +GRAPHIFY__CopilotSdk__ModelId=gpt-4.1 + +# ── SurrealDB (MCP Backend) ────────────────────────────────────────────────── +# Configure when using `graphify serve` with a remote SurrealDB instance. +# When Endpoint is set, remote mode is used; otherwise embedded mode. + +# Remote SurrealDB endpoint (e.g., http://localhost:8000) +GRAPHIFY__SurrealDb__Endpoint=http://localhost:8000 + +# Authentication credentials +GRAPHIFY__SurrealDb__Username=root +GRAPHIFY__SurrealDb__Password=your-password + +# Namespace and database (defaults: graphify / codebase) +GRAPHIFY__SurrealDb__Namespace=graphify +GRAPHIFY__SurrealDb__Database=codebase diff --git a/docs/configuration.md b/docs/configuration.md index 3abaf59..d1ece78 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -85,6 +85,8 @@ GRAPHIFY__OpenAi__ModelId=deepseek-v4-flash-free > **Security:** Add `.env` to your `.gitignore` to avoid committing secrets. +> **Tip:** A comprehensive `.env.example` file with all available options is available in the [`docs/`](.env.example) folder. + ## Environment Variables All settings use the `GRAPHIFY__` prefix with double-underscore nesting: diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..dc44dd6 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "graphify-dotnet": { + "version": "0.9.0-preview.15", + "commands": [ + "graphify" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/nuget.config b/nuget.config index ffb273b..4ca07f3 100644 --- a/nuget.config +++ b/nuget.config @@ -7,7 +7,7 @@ - + diff --git a/src/Graphify.Cli/Graphify.Cli.csproj b/src/Graphify.Cli/Graphify.Cli.csproj index 1b0eafb..7862eb2 100644 --- a/src/Graphify.Cli/Graphify.Cli.csproj +++ b/src/Graphify.Cli/Graphify.Cli.csproj @@ -6,7 +6,7 @@ true graphify graphify-dotnet - 0.9.0-preview.4 + 0.9.0-preview.15 AI-powered knowledge graph builder for codebases Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify.Cli/Program.cs b/src/Graphify.Cli/Program.cs index 1e44dde..91d05e8 100644 --- a/src/Graphify.Cli/Program.cs +++ b/src/Graphify.Cli/Program.cs @@ -190,24 +190,9 @@ static void AddPipelineOptions(Command cmd, var path = parseResult.GetValue(runPathArg)!; var output = parseResult.GetValue(runOutputOpt)!; var format = parseResult.GetValue(runFormatOpt)!; - var formats = format.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var useConfigWizard = parseResult.GetValue(runConfigOpt); - // Apply saved config defaults when CLI arguments are at their default values - var savedConfig = ConfigPersistence.Load(); - if (savedConfig != null) - { - if (path == "." && savedConfig.WorkingFolder != null) - path = savedConfig.WorkingFolder; - if (output == "graphify-out" && savedConfig.OutputFolder != null) - output = savedConfig.OutputFolder; - if (format == "json,html,report" && savedConfig.ExportFormats != null) - { - format = savedConfig.ExportFormats; - formats = format.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - } - } - + // ── Optionally run config wizard (saves to appsettings.local.json) ───── if (useConfigWizard) { var existingConfig = ConfigPersistence.Load(); @@ -216,10 +201,7 @@ static void AddPipelineOptions(Command cmd, AnsiConsole.WriteLine(); } - var (chatClient, verbose) = await ResolveProviderAsync(parseResult, - runVerboseOpt, runProviderOpt, runEndpointOpt, runApiKeyOpt, runModelOpt, runDeploymentOpt, - ignoreProviderOptions: useConfigWizard); - + // ── Build configuration from all sources ───────────────────────────── var surrealOptions = new CliSurrealOptions { Endpoint = parseResult.GetValue(runSurrealEndpointOpt), @@ -233,6 +215,26 @@ static void AddPipelineOptions(Command cmd, var graphifyConfig = new GraphifyConfig(); configuration.GetSection("Graphify").Bind(graphifyConfig); + // ── Resolve export formats with proper priority ──────────────────────── + // CLI --format > config (.env, user-secrets, saved) > built-in default + if (format == "json,html,report" && graphifyConfig.ExportFormats != null) + { + format = graphifyConfig.ExportFormats; + } + var formats = format.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + // Apply config-sourced defaults when CLI args are at their defaults + if (path == "." && graphifyConfig.WorkingFolder != null) + path = graphifyConfig.WorkingFolder; + if (output == "graphify-out" && graphifyConfig.OutputFolder != null) + output = graphifyConfig.OutputFolder; + + // ── Resolve AI provider ────────────────────────────────────────────── + var (chatClient, verbose) = await ResolveProviderAsync(parseResult, + runVerboseOpt, runProviderOpt, runEndpointOpt, runApiKeyOpt, runModelOpt, runDeploymentOpt, + ignoreProviderOptions: useConfigWizard); + + // ── Run pipeline ────────────────────────────────────────────────────── var runner = new Graphify.Cli.PipelineRunner(Console.Out, verbose, chatClient, graphifyConfig.SurrealDb); var graph = await runner.RunAsync(path, output, formats, useCache: true, cancellationToken); return graph != null ? 0 : 1; @@ -258,11 +260,8 @@ static void AddPipelineOptions(Command cmd, var path = parseResult.GetValue(watchPathArg)!; var output = parseResult.GetValue(watchOutputOpt)!; var format = parseResult.GetValue(watchFormatOpt)!; - var formats = format.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - var (chatClient, verbose) = await ResolveProviderAsync(parseResult, - watchVerboseOpt, watchProviderOpt, watchEndpointOpt, watchApiKeyOpt, watchModelOpt, watchDeploymentOpt); + // ── Build configuration from all sources ───────────────────────────── var surrealOptions = new CliSurrealOptions { Endpoint = parseResult.GetValue(watchSurrealEndpointOpt), @@ -276,6 +275,19 @@ static void AddPipelineOptions(Command cmd, var graphifyConfig = new GraphifyConfig(); configuration.GetSection("Graphify").Bind(graphifyConfig); + // ── Resolve export formats with proper priority ──────────────────────── + // CLI --format > config (.env, user-secrets, saved) > built-in default + if (format == "json,html,report" && graphifyConfig.ExportFormats != null) + { + format = graphifyConfig.ExportFormats; + } + var formats = format.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + // ── Resolve AI provider ────────────────────────────────────────────── + var (chatClient, verbose) = await ResolveProviderAsync(parseResult, + watchVerboseOpt, watchProviderOpt, watchEndpointOpt, watchApiKeyOpt, watchModelOpt, watchDeploymentOpt); + + // ── Run pipeline ────────────────────────────────────────────────────── Console.WriteLine("Running initial pipeline..."); Console.WriteLine(); var runner = new Graphify.Cli.PipelineRunner(Console.Out, verbose, chatClient, graphifyConfig.SurrealDb); diff --git a/src/Graphify.Sdk/Graphify.Sdk.csproj b/src/Graphify.Sdk/Graphify.Sdk.csproj index 54b2c40..347a03d 100644 --- a/src/Graphify.Sdk/Graphify.Sdk.csproj +++ b/src/Graphify.Sdk/Graphify.Sdk.csproj @@ -4,7 +4,7 @@ net10.0 true graphify-dotnet-sdk - 0.9.0-preview.4 + 0.9.0-preview.15 GitHub Copilot SDK integration layer for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify/Export/SurrealDbExporter.cs b/src/Graphify/Export/SurrealDbExporter.cs index e83af3e..45aced7 100644 --- a/src/Graphify/Export/SurrealDbExporter.cs +++ b/src/Graphify/Export/SurrealDbExporter.cs @@ -64,15 +64,18 @@ public async Task ExportAsync(KnowledgeGraph graph, string outputPath, internal async Task ExportEmbeddedAsync(KnowledgeGraph graph, string outputPath, CancellationToken cancellationToken) { + var ns = _namespace ?? "graphify"; + var dbName = _database ?? "codebase"; + if (_testClient is not null) { - await _testClient.Use("graphify", "codebase"); + await _testClient.Use(ns, dbName); await ExportToClientAsync(_testClient, graph, cancellationToken); return; } await using var db = CreateRocksDbClient(outputPath); - await db.Use("graphify", "codebase"); + await db.Use(ns, dbName); await ExportToClientAsync(db, graph, cancellationToken); } @@ -87,31 +90,40 @@ private static SurrealDbRocksDbClient CreateRocksDbClient(string outputPath) return new SurrealDbRocksDbClient(outputPath); } -private async Task ExportRemoteAsync(KnowledgeGraph graph, - CancellationToken cancellationToken) -{ - var configuration = SurrealDbOptions - .Create() - .WithEndpoint(_endpoint!) - .WithNamespace(_namespace ?? "graphify") - .WithDatabase(_database ?? "codebase") - .WithUsername(_username) - .WithPassword(_password) - .Build(); - - await using var db = new SurrealDbClient(configuration); - - if (_username is not null) + private async Task ExportRemoteAsync(KnowledgeGraph graph, + CancellationToken cancellationToken) { - await db.SignIn(new RootAuth + var ns = _namespace ?? "graphify"; + var dbName = _database ?? "codebase"; + + // Connect without pre-selecting NS/DB — they may not exist yet on the + // remote server and would cause a connection-time failure. + var configuration = SurrealDbOptions + .Create() + .WithEndpoint(_endpoint!) + .WithUsername(_username) + .WithPassword(_password) + .Build(); + + await using var db = new SurrealDbClient(configuration); + + if (_username is not null) { - Username = _username, - Password = _password ?? "" - }); - } + await db.SignIn(new RootAuth + { + Username = _username, + Password = _password ?? "" + }); + } - await ExportToClientAsync(db, graph, cancellationToken); -} + // Remote SurrealDB does not auto-create namespaces/databases; define + // them (as root) before selecting them. + await db.RawQuery($"DEFINE NAMESPACE IF NOT EXISTS {ns};", cancellationToken: cancellationToken); + await db.RawQuery($"USE NS {ns}; DEFINE DATABASE IF NOT EXISTS {dbName};", cancellationToken: cancellationToken); + await db.Use(ns, dbName); + + await ExportToClientAsync(db, graph, cancellationToken); + } private static async Task ExportToClientAsync(ISurrealDbClient db, KnowledgeGraph graph, CancellationToken cancellationToken) @@ -121,22 +133,38 @@ private static async Task ExportToClientAsync(ISurrealDbClient db, var nodes = graph.GetNodes().ToList(); var edges = graph.GetEdges().ToList(); + // Use parameterized RawQuery (CREATE ... CONTENT) instead of the typed + // Create overload. Create(table, data) deserializes the server + // response back into T, and that response shape (single object vs array) + // varies by SurrealDB version — the mismatch surfaces as a CBOR + // "Expected major type Map (5)" error. RawQuery returns a generic + // response whose result bytes are never deserialized into a typed + // record, so it is immune to that version ambiguity. foreach (var node in nodes) { var escapedId = Uri.EscapeDataString(node.Id); - await db.Create("entity", new SurrealDbEntity + var parameters = new Dictionary { - Id = (RecordId)("entity", escapedId), - label = node.Label, - kind = node.Type, - filePath = node.FilePath, - language = node.Language, - confidence = node.Confidence.ToString().ToUpperInvariant(), - community = node.Community, - metadata = node.Metadata is { Count: > 0 } - ? node.Metadata.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value) + ["id"] = escapedId, + ["label"] = node.Label, + ["kind"] = node.Type, + ["filePath"] = node.FilePath, + ["language"] = node.Language, + ["confidence"] = node.Confidence.ToString().ToUpperInvariant(), + ["community"] = node.Community, + ["metadata"] = node.Metadata is { Count: > 0 } + ? node.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) : null - }); + }; + + var response = await db.RawQuery( + "CREATE type::thing('entity', $id) CONTENT { " + + "label: $label, kind: $kind, filePath: $filePath, " + + "language: $language, confidence: $confidence, " + + "community: $community, metadata: $metadata };", + parameters, + cancellationToken); + response.EnsureAllOks(); } for (int i = 0; i < edges.Count; i++) @@ -144,18 +172,27 @@ private static async Task ExportToClientAsync(ISurrealDbClient db, var edge = edges[i]; var escapedSource = Uri.EscapeDataString(edge.Source.Id); var escapedTarget = Uri.EscapeDataString(edge.Target.Id); - await db.Create("relationship", new SurrealDbRelationship + var parameters = new Dictionary { - Id = (RecordId)("relationship", escapedSource + "->" + escapedTarget + "-" + i), - source = (RecordId)("entity", escapedSource), - target = (RecordId)("entity", escapedTarget), - type = edge.Relationship, - weight = edge.Weight, - confidence = edge.Confidence.ToString().ToUpperInvariant(), - metadata = edge.Metadata is { Count: > 0 } - ? edge.Metadata.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value) + ["id"] = escapedSource + "->" + escapedTarget + "-" + i, + ["source"] = (RecordId)("entity", escapedSource), + ["target"] = (RecordId)("entity", escapedTarget), + ["type"] = edge.Relationship, + ["weight"] = edge.Weight, + ["confidence"] = edge.Confidence.ToString().ToUpperInvariant(), + ["metadata"] = edge.Metadata is { Count: > 0 } + ? edge.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) : null - }); + }; + + var response = await db.RawQuery( + "CREATE type::thing('relationship', $id) CONTENT { " + + "source: $source, target: $target, type: $type, " + + "weight: $weight, confidence: $confidence, " + + "metadata: $metadata };", + parameters, + cancellationToken); + response.EnsureAllOks(); } } @@ -168,35 +205,4 @@ await db.Query($""" """); } - /// - /// Concrete type for SurrealDB entity records. - /// Dahomey.Cbor cannot serialize anonymous types; concrete types are required. - /// - internal sealed class SurrealDbEntity - { - public RecordId? Id { get; set; } - public string? label { get; set; } - public string? kind { get; set; } - public string? filePath { get; set; } - public string? language { get; set; } - public string? confidence { get; set; } - public int? community { get; set; } - public Dictionary? metadata { get; set; } - } - - /// - /// Concrete type for SurrealDB relationship records. - /// Dahomey.Cbor cannot serialize anonymous types; concrete types are required. - /// - internal sealed class SurrealDbRelationship - { - public RecordId? Id { get; set; } - public RecordId? source { get; set; } - public RecordId? target { get; set; } - public string? type { get; set; } - public double weight { get; set; } - public string? confidence { get; set; } - public Dictionary? metadata { get; set; } - } - } diff --git a/src/Graphify/Graphify.csproj b/src/Graphify/Graphify.csproj index 612b480..85d0325 100644 --- a/src/Graphify/Graphify.csproj +++ b/src/Graphify/Graphify.csproj @@ -5,7 +5,7 @@ Graphify.Tests true graphify-dotnet-core - 0.9.0-preview.4 + 0.9.0-preview.15 Core graph extraction, graph modeling, clustering, and export pipeline for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet From fe01fbf7e4587e0622d7b31485f1aef98895b7c7 Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Wed, 22 Jul 2026 09:39:57 +0200 Subject: [PATCH 14/15] feat(surrealdb): add SurrealDB graph export and backend integration - Implement SurrealDbExporter with transactional full graph snapshot import - Add SurrealDbExportOptions for configuration of remote or embedded SurrealDB mode - Extend WatchMode to support exporting updated graph state to SurrealDB on changes - Implement SurrealDbGraphBackend using SurrealQL for graph queries backed by SurrealDB - Use server-side graph algorithms for shortest path and degree computations - Add incremental graph update methods supporting removal by filePath to avoid orphans - Optimize SurrealDB schema with indexes for community and relationship types - Update CLI to enable SurrealDB export based on configuration settings - Refactor SurrealDbGraphBackend to batch queries and reduce client-side processing - Add community and node analysis queries with server-side aggregation and sorting --- src/Graphify.Cli/Graphify.Cli.csproj | 2 +- src/Graphify.Cli/Mcp/SurrealDbGraphBackend.cs | 594 ++++++++---------- src/Graphify.Cli/Program.cs | 8 +- src/Graphify.Sdk/Graphify.Sdk.csproj | 2 +- src/Graphify/Export/SurrealDbExportOptions.cs | 15 + src/Graphify/Export/SurrealDbExporter.cs | 119 ++-- src/Graphify/Graph/KnowledgeGraph.cs | 51 ++ src/Graphify/Graphify.csproj | 2 +- src/Graphify/Pipeline/WatchMode.cs | 65 +- 9 files changed, 442 insertions(+), 416 deletions(-) create mode 100644 src/Graphify/Export/SurrealDbExportOptions.cs diff --git a/src/Graphify.Cli/Graphify.Cli.csproj b/src/Graphify.Cli/Graphify.Cli.csproj index 7862eb2..f3dbc57 100644 --- a/src/Graphify.Cli/Graphify.Cli.csproj +++ b/src/Graphify.Cli/Graphify.Cli.csproj @@ -6,7 +6,7 @@ true graphify graphify-dotnet - 0.9.0-preview.15 + 0.9.0-preview.20 AI-powered knowledge graph builder for codebases Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify.Cli/Mcp/SurrealDbGraphBackend.cs b/src/Graphify.Cli/Mcp/SurrealDbGraphBackend.cs index c2950b1..068398b 100644 --- a/src/Graphify.Cli/Mcp/SurrealDbGraphBackend.cs +++ b/src/Graphify.Cli/Mcp/SurrealDbGraphBackend.cs @@ -1,4 +1,6 @@ +using System.Globalization; using System.Text.Json; +using Dahomey.Cbor.Attributes; using SurrealDb.Net; using SurrealDb.Net.Models; using SurrealDb.Net.Models.Response; @@ -8,6 +10,10 @@ namespace Graphify.Cli.Mcp; /// /// IGraphBackend implementation that queries a SurrealDB database directly via SurrealQL. /// Used for the MCP serve command when --surreal-endpoint or --surreal-path is specified. +/// +/// The graph is stored using SurrealDB graph edges (created with RELATE), so all graph +/// work — neighbourhood traversal, degree, and shortest-path — runs server-side via the +/// graph engine instead of downloading the whole graph and computing in memory. /// public sealed class SurrealDbGraphBackend : IGraphBackend, IAsyncDisposable { @@ -18,11 +24,6 @@ public sealed class SurrealDbGraphBackend : IGraphBackend, IAsyncDisposable PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - // Cache for Path BFS — loaded lazily - private Dictionary? _entitiesCache; - private List? _relationshipsCache; - private readonly object _cacheLock = new(); - public SurrealDbGraphBackend(ISurrealDbClient db) { _db = db ?? throw new ArgumentNullException(nameof(db)); @@ -47,23 +48,29 @@ public async Task QueryAsync(string searchTerm, int limit, CancellationT ["limit"] = limit }; + // Degree is computed server-side by counting graph edges, not by a + // per-node relationship query (the previous N+1 pattern). var response = await _db.RawQuery( - "SELECT * FROM entity WHERE string::contains(string::lowercase(id), $term) OR string::contains(string::lowercase(label), $term) OR string::contains(string::lowercase(kind), $term) LIMIT $limit", + "SELECT id, label, kind, filePath, language, confidence, community, " + + "count(->relationship) + count(<-relationship) AS degree " + + "FROM entity " + + "WHERE string::contains(string::lowercase(id), $term) " + + "OR string::contains(string::lowercase(label), $term) " + + "OR string::contains(string::lowercase(kind), $term) " + + "LIMIT $limit", parameters, - cancellationToken - ); + cancellationToken); if (response.FirstOk is not { } ok) return JsonSerializer.Serialize(new ErrorResult { Error = "Query failed" }); - var entities = ok.GetValues().ToList(); + var entities = ok.GetValues().ToList(); var results = new List(); foreach (var entity in entities) { var nodeId = ExtractNodeId(entity.Id); - var connections = await FetchConnectionsAsync(entity.Id, cancellationToken); - var degree = connections.Count; + var connections = await FetchConnectionsAsync(entity.Id, 5, cancellationToken); results.Add(new NodeResult { @@ -74,8 +81,8 @@ public async Task QueryAsync(string searchTerm, int limit, CancellationT Language = entity.language, Confidence = entity.confidence, Community = entity.community, - Degree = degree, - Connections = connections.Take(5).ToList() + Degree = entity.degree, + Connections = connections }); } @@ -101,64 +108,47 @@ public async Task ExplainAsync(string nodeId, CancellationToken cancella { var recordId = MakeEntityRecordId(nodeId); - // Fetch node var nodeResponse = await _db.RawQuery( - "SELECT * FROM $node_id", + "SELECT id, label, kind, filePath, language, confidence, community FROM $node_id", new Dictionary { ["node_id"] = recordId }, - cancellationToken - ); + cancellationToken); if (nodeResponse.FirstOk is not { } nodeOk) return JsonSerializer.Serialize(new ErrorResult { Error = $"Node '{nodeId}' not found" }); - var entity = nodeOk.GetValues().FirstOrDefault(); + var entity = nodeOk.GetValues().FirstOrDefault(); if (entity == null) return JsonSerializer.Serialize(new ErrorResult { Error = $"Node '{nodeId}' not found" }); - // Fetch incoming and outgoing relationships - var relResponse = await _db.RawQuery( - "SELECT * FROM relationship WHERE source = $record_id OR target = $record_id", - new Dictionary { ["record_id"] = recordId }, - cancellationToken - ); - - var inEdges = new List(); - var outEdges = new List(); + // Outgoing edges: this node is the source (edge.in == node). + var outRels = await SelectRelsAsync( + "SELECT in, out, type, confidence FROM relationship WHERE in = $record_id", + recordId, cancellationToken); - if (relResponse.FirstOk is { } relOk) - { - var relationships = relOk.GetValues().ToList(); + // Incoming edges: this node is the target (edge.out == node). + var inRels = await SelectRelsAsync( + "SELECT in, out, type, confidence FROM relationship WHERE out = $record_id", + recordId, cancellationToken); - foreach (var rel in relationships) - { - var sourceId = ExtractNodeId(rel.source); - var targetId = ExtractNodeId(rel.target); + var outTargets = outRels.Select(r => r.Out).Where(x => x is not null).ToList(); + var inSources = inRels.Select(r => r.In).Where(x => x is not null).ToList(); + var labels = await ResolveLabelsAsync(outTargets.Concat(inSources), cancellationToken); - if (targetId == nodeId) - { - inEdges.Add(new EdgeResult - { - From = sourceId, - FromLabel = sourceId, - Relationship = rel.type ?? "", - Confidence = rel.confidence - }); - } - - if (sourceId == nodeId) - { - outEdges.Add(new EdgeResult - { - To = targetId, - ToLabel = targetId, - Relationship = rel.type ?? "", - Confidence = rel.confidence - }); - } - } - } + var outEdges = outRels.Select(r => new EdgeResult + { + To = ExtractNodeId(r.Out), + ToLabel = LabelFor(labels, r.Out), + Relationship = r.type ?? "", + Confidence = r.confidence + }).ToList(); - var degree = inEdges.Count + outEdges.Count; + var inEdges = inRels.Select(r => new EdgeResult + { + From = ExtractNodeId(r.In), + FromLabel = LabelFor(labels, r.In), + Relationship = r.type ?? "", + Confidence = r.confidence + }).ToList(); return JsonSerializer.Serialize(new ExplainResult { @@ -174,7 +164,7 @@ public async Task ExplainAsync(string nodeId, CancellationToken cancella }, Statistics = new ExplainStatistics { - TotalDegree = degree, + TotalDegree = inEdges.Count + outEdges.Count, IncomingConnections = inEdges.Count, OutgoingConnections = outEdges.Count }, @@ -195,70 +185,46 @@ public async Task PathAsync(string sourceId, string targetId, Cancellati try { - await EnsurePathCacheLoadedAsync(cancellationToken); - - if (_entitiesCache == null || !_entitiesCache.ContainsKey(sourceId)) - return JsonSerializer.Serialize(new ErrorResult { Error = $"Source node '{sourceId}' not found" }); - - if (!_entitiesCache.ContainsKey(targetId)) - return JsonSerializer.Serialize(new ErrorResult { Error = $"Target node '{targetId}' not found" }); - - // Build adjacency list - var adjacency = new Dictionary>(); - foreach (var node in _entitiesCache.Keys) - adjacency[node] = []; - - if (_relationshipsCache != null) + var sourceRecord = MakeEntityRecordId(sourceId); + var targetRecord = MakeEntityRecordId(targetId); + + // Server-side shortest path using SurrealDB's graph algorithm (+shortest), + // replacing the previous approach that downloaded the entire graph and ran + // BFS in memory. The path is returned as the list of entity records along + // the shortest route from source to target. + var pathResponse = await _db.RawQuery( + "RETURN type::field($source->{..+shortest=$target}->relationship->entity)", + new Dictionary { ["source"] = sourceRecord, ["target"] = targetRecord }, + cancellationToken); + + var pathIds = new List(); + if (pathResponse.FirstOk is { } pathOk) + pathIds = pathOk.GetValues().Where(x => x is not null).ToList(); + + if (pathIds.Count == 0) + return JsonSerializer.Serialize(new PathResult { Found = false }); + + // SurrealDB's +shortest algorithm excludes the originating record from + // the result by default, so prepend the source to produce a complete + // source→target path (matching MemoryGraphBackend's BFS, which seeds + // the path with the source node). + pathIds.Insert(0, sourceRecord); + + var labels = await ResolveLabelsAsync(pathIds, cancellationToken); + + var path = pathIds.Select(id => new PathNodeResult { - foreach (var rel in _relationshipsCache) - { - var src = ExtractNodeId(rel.source); - var tgt = ExtractNodeId(rel.target); - if (adjacency.ContainsKey(src)) - adjacency[src].Add(tgt); - } - } - - // BFS - var visited = new HashSet { sourceId }; - var queue = new Queue<(string Node, List Path)>(); - queue.Enqueue((sourceId, [sourceId])); + Id = ExtractNodeId(id), + Label = LabelFor(labels, id), + Type = labels.TryGetValue(RecordKey(id), out var e) ? (e.kind ?? "unknown") : "unknown" + }).ToList(); - while (queue.Count > 0) + return JsonSerializer.Serialize(new PathResult { - var (current, path) = queue.Dequeue(); - - if (current == targetId) - { - return JsonSerializer.Serialize(new PathResult - { - Found = true, - PathLength = path.Count - 1, - Path = path.Select(id => _entitiesCache!.TryGetValue(id, out var e) - ? new PathNodeResult - { - Id = id, - Label = e.label ?? id, - Type = e.kind ?? "unknown" - } - : new PathNodeResult { Id = id, Label = id, Type = "unknown" }).ToList() - }, JsonOptions); - } - - if (!adjacency.TryGetValue(current, out var neighbors)) - continue; - - foreach (var neighbor in neighbors) - { - if (visited.Add(neighbor)) - { - var newPath = new List(path) { neighbor }; - queue.Enqueue((neighbor, newPath)); - } - } - } - - return JsonSerializer.Serialize(new PathResult { Found = false }, JsonOptions); + Found = true, + PathLength = path.Count - 1, + Path = path + }, JsonOptions); } catch (Exception ex) { @@ -272,111 +238,61 @@ public async Task CommunitiesAsync(int? communityId, CancellationToken c { if (communityId.HasValue) { - var parameters = new Dictionary - { - ["communityId"] = communityId.Value - }; - var response = await _db.RawQuery( - "SELECT * FROM entity WHERE community = $communityId ORDER BY community DESC", - parameters, - cancellationToken - ); + "SELECT id, label, kind, filePath, " + + "count(->relationship) + count(<-relationship) AS degree " + + "FROM entity WHERE community = $communityId", + new Dictionary { ["communityId"] = communityId.Value }, + cancellationToken); if (response.FirstOk is not { } ok) return JsonSerializer.Serialize(new ErrorResult { Error = $"Community {communityId} not found" }); - var members = ok.GetValues().ToList(); + var members = ok.GetValues() + .Select(m => new CommunityMemberResult + { + Id = ExtractNodeId(m.Id), + Label = m.label ?? ExtractNodeId(m.Id), + Type = m.kind ?? "unknown", + FilePath = m.filePath, + Degree = m.degree + }) + .OrderByDescending(m => m.Degree) + .ToList(); if (members.Count == 0) return JsonSerializer.Serialize(new ErrorResult { Error = $"Community {communityId} not found or has no members" }); - // Fetch degree info by counting relationships for each member - var memberResults = new List(); - foreach (var member in members) - { - var id = ExtractNodeId(member.Id); - var connections = await FetchConnectionsAsync(member.Id, cancellationToken); - memberResults.Add(new CommunityMemberResult - { - Id = id, - Label = member.label ?? id, - Type = member.kind ?? "unknown", - FilePath = member.filePath, - Degree = connections.Count - }); - } - return JsonSerializer.Serialize(new CommunityDetailResult { CommunityId = communityId.Value, - MemberCount = memberResults.Count, - Members = [.. memberResults.OrderByDescending(m => m.Degree)] + MemberCount = members.Count, + Members = members }, JsonOptions); } - // List all communities + // List all communities with counts (server-side GROUP BY). var listResponse = await _db.RawQuery( "SELECT community, count() AS count FROM entity WHERE community != NONE GROUP BY community ORDER BY count DESC", - cancellationToken: cancellationToken - ); + cancellationToken: cancellationToken); - // Also get total counts var countResponse = await _db.RawQuery( "SELECT count() AS total FROM entity; SELECT count() AS total FROM entity WHERE community != NONE", - cancellationToken: cancellationToken - ); + cancellationToken: cancellationToken); int totalNodes = 0; int nodesInCommunities = 0; - - if (countResponse.FirstOk is { } countOk) - { - var totals = countOk.GetValues().ToList(); - // First query: SELECT count() AS total FROM entity - if (countResponse.Count > 0 && countResponse[0] is SurrealDbOkResult r0) - totalNodes = r0.GetValues().FirstOrDefault()?.total ?? 0; - // Second query: SELECT count() AS total FROM entity WHERE community != NONE - if (countResponse.Count > 1 && countResponse[1] is SurrealDbOkResult r1) - nodesInCommunities = r1.GetValues().FirstOrDefault()?.total ?? 0; - } + if (countResponse.Count > 0 && countResponse[0] is SurrealDbOkResult r0) + totalNodes = r0.GetValues().FirstOrDefault()?.total ?? 0; + if (countResponse.Count > 1 && countResponse[1] is SurrealDbOkResult r1) + nodesInCommunities = r1.GetValues().FirstOrDefault()?.total ?? 0; var communities = new List(); - if (listResponse.FirstOk is { } listOk) { - var communityGroups = listOk.GetValues().ToList(); - - foreach (var group in communityGroups) + foreach (var group in listOk.GetValues().ToList()) { - // Fetch top 5 members for each community - var memberParams = new Dictionary - { - ["communityId"] = group.community - }; - var memberResponse = await _db.RawQuery( - "SELECT * FROM entity WHERE community = $communityId LIMIT 5", - memberParams, - cancellationToken - ); - - var topMembers = new List(); - if (memberResponse.FirstOk is { } memberOk) - { - foreach (var m in memberOk.GetValues()) - { - var mid = ExtractNodeId(m.Id); - var conns = await FetchConnectionsAsync(m.Id, cancellationToken); - topMembers.Add(new CommunityMemberResult - { - Id = mid, - Label = m.label ?? mid, - Type = m.kind ?? "unknown", - Degree = conns.Count - }); - } - } - + var topMembers = await TopCommunityMembersAsync(group.community, 5, cancellationToken); communities.Add(new CommunitySummaryResult { CommunityId = group.community, @@ -404,7 +320,7 @@ public async Task AnalyzeAsync(int topN, CancellationToken cancellationT { try { - // Run all aggregate queries in one batch + // Aggregations run server-side in a single batched query. var response = await _db.RawQuery( """ SELECT count() AS count FROM entity; @@ -413,28 +329,47 @@ public async Task AnalyzeAsync(int topN, CancellationToken cancellationT SELECT kind, count() AS count FROM entity GROUP BY kind ORDER BY count DESC; SELECT type, count() AS count FROM relationship GROUP BY type ORDER BY count DESC; """, - cancellationToken: cancellationToken - ); + cancellationToken: cancellationToken); if (response.HasErrors) return JsonSerializer.Serialize(new ErrorResult { Error = "Analyze query failed" }); - // Result 0: total node count - var nodeCount = response.GetValues(0).FirstOrDefault()?.count ?? 0; - // Result 1: total edge count - var edgeCount = response.GetValues(1).FirstOrDefault()?.count ?? 0; - // Result 2: community groups - var communityGroups = response.GetValues(2).ToList(); - // Result 3: type distribution - var typeDistributions = response.GetValues(3).ToList(); - // Result 4: relationship type distribution - var relTypeDistributions = response.GetValues(4).ToList(); + var nodeCount = response.GetValues(0).FirstOrDefault()?.count ?? 0; + var edgeCount = response.GetValues(1).FirstOrDefault()?.count ?? 0; + var communityGroups = response.GetValues(2).ToList(); + var typeDistributions = response.GetValues(3).ToList(); + var relTypeDistributions = response.GetValues(4).ToList(); + + // Top nodes by degree — computed server-side via edge counts (no N+1). + var topResponse = await _db.RawQuery( + "SELECT id, label, kind, community, " + + "count(->relationship) + count(<-relationship) AS degree " + + "FROM entity ORDER BY degree DESC LIMIT $topN", + new Dictionary { ["topN"] = topN }, + cancellationToken); + + var topNodes = new List(); + if (topResponse.FirstOk is { } topOk) + { + topNodes = topOk.GetValues().Select(e => new AnalyzeNodeResult + { + Id = ExtractNodeId(e.Id), + Label = e.label ?? "", + Type = e.kind ?? "unknown", + Degree = e.degree, + Community = e.community + }).ToList(); + } - // Top N nodes by degree - var topNodes = await GetTopNodesAsync(topN, cancellationToken); + // Isolated nodes (no edges in either direction) — single server-side query. + var isolatedResponse = await _db.RawQuery( + "SELECT count() AS count FROM entity " + + "WHERE count(->relationship) = 0 AND count(<-relationship) = 0 GROUP ALL", + cancellationToken: cancellationToken); - // Isolated nodes (degree == 0) - var isolatedCount = await CountIsolatedNodesAsync(cancellationToken); + int isolatedCount = 0; + if (isolatedResponse.FirstOk is { } isoOk) + isolatedCount = isoOk.GetValues().FirstOrDefault()?.count ?? 0; double averageDegree = nodeCount > 0 ? (double)edgeCount * 2 / nodeCount : 0; @@ -469,167 +404,126 @@ public async Task AnalyzeAsync(int topN, CancellationToken cancellationT // ── Private helpers ──────────────────────────────────────────────── - private static string ExtractNodeId(RecordId? recordId) + private async Task> SelectRelsAsync(string query, RecordId recordId, CancellationToken ct) { - if (recordId is null) - return ""; - - try - { - var escaped = recordId.DeserializeId(); - return Uri.UnescapeDataString(escaped); - } - catch - { - return recordId.ToString() ?? ""; - } - } + var response = await _db.RawQuery( + query, + new Dictionary { ["record_id"] = recordId }, + ct); - private static RecordId MakeEntityRecordId(string nodeId) - { - var escaped = Uri.EscapeDataString(nodeId); - return (RecordId)("entity", escaped); + return response.FirstOk is { } ok ? ok.GetValues().ToList() : []; } - private async Task> FetchConnectionsAsync(RecordId? entityId, CancellationToken ct) + /// + /// Fetches connections for a node from both edge directions in a single query. + /// For outgoing edges the node is in (source); for incoming it is out (target). + /// + private async Task> FetchConnectionsAsync(RecordId? entityId, int limit, CancellationToken ct) { if (entityId is null) return []; - var parameters = new Dictionary - { - ["record_id"] = entityId - }; - var response = await _db.RawQuery( - "SELECT * FROM relationship WHERE source = $record_id OR target = $record_id LIMIT 50", - parameters, - ct - ); + "SELECT in, out, type, weight FROM relationship WHERE in = $record_id " + + "UNION SELECT in, out, type, weight FROM relationship WHERE out = $record_id " + + $"LIMIT {limit.ToString(CultureInfo.InvariantCulture)}", + new Dictionary { ["record_id"] = entityId }, + ct); if (response.FirstOk is not { } ok) return []; - return ok.GetValues().Select(rel => new ConnectionResult + return ok.GetValues().Select(r => new ConnectionResult { - Source = ExtractNodeId(rel.source), - Target = ExtractNodeId(rel.target), - Relationship = rel.type ?? "", - Weight = rel.weight + Source = ExtractNodeId(r.In), + Target = ExtractNodeId(r.Out), + Relationship = r.type ?? "", + Weight = r.weight }).ToList(); } - private async Task EnsurePathCacheLoadedAsync(CancellationToken ct) + private async Task> TopCommunityMembersAsync(int community, int limit, CancellationToken ct) { - if (_entitiesCache != null) - return; - - // Fetch all entities and relationships var response = await _db.RawQuery( - "SELECT * FROM entity; SELECT * FROM relationship", - cancellationToken: ct - ); + "SELECT id, label, kind, filePath, " + + "count(->relationship) + count(<-relationship) AS degree " + + "FROM entity WHERE community = $communityId ORDER BY degree DESC LIMIT $limit", + new Dictionary { ["communityId"] = community, ["limit"] = limit }, + ct); - if (response.HasErrors) - return; + if (response.FirstOk is not { } ok) + return []; - lock (_cacheLock) + return ok.GetValues().Select(m => new CommunityMemberResult { - if (_entitiesCache != null) - return; + Id = ExtractNodeId(m.Id), + Label = m.label ?? ExtractNodeId(m.Id), + Type = m.kind ?? "unknown", + FilePath = m.filePath, + Degree = m.degree + }).ToList(); + } - var entities = new Dictionary(); - if (response.Count > 0 && response[0] is SurrealDbOkResult entityOk) - { - foreach (var e in entityOk.GetValues()) - { - var id = ExtractNodeId(e.Id); - if (!string.IsNullOrEmpty(id)) - entities[id] = e; - } - } + /// + /// Resolves label/kind for a set of record ids in a single batched query. + /// + private async Task> ResolveLabelsAsync(IEnumerable ids, CancellationToken ct) + { + var recordIds = ids.Where(x => x is not null).Cast().ToList(); + if (recordIds.Count == 0) + return new Dictionary(); + + var response = await _db.RawQuery( + "SELECT id, label, kind FROM $ids", + new Dictionary { ["ids"] = recordIds }, + ct); - var relationships = new List(); - if (response.Count > 1 && response[1] is SurrealDbOkResult relOk) + var map = new Dictionary(); + if (response.FirstOk is { } ok) + { + foreach (var e in ok.GetValues()) { - relationships.AddRange(relOk.GetValues()); + var key = RecordKey(e.Id); + if (!string.IsNullOrEmpty(key)) + map[key] = e; } - - _entitiesCache = entities; - _relationshipsCache = relationships; } + return map; } - private async Task> GetTopNodesAsync(int topN, CancellationToken ct) + private static string ExtractNodeId(RecordId? recordId) { - // Load all entities and compute degree by counting relationships - var response = await _db.RawQuery( - "SELECT * FROM entity", - cancellationToken: ct - ); - - if (response.FirstOk is not { } ok) - return []; - - var entities = ok.GetValues().ToList(); + if (recordId is null) + return ""; - // Compute degree for each entity - var degreeMap = new Dictionary(); - foreach (var entity in entities) + try { - var id = ExtractNodeId(entity.Id); - var connections = await FetchConnectionsAsync(entity.Id, ct); - degreeMap[id] = connections.Count; + var escaped = recordId.DeserializeId(); + return Uri.UnescapeDataString(escaped); + } + catch + { + return recordId.ToString() ?? ""; } - - return entities - .Select(e => new - { - Id = ExtractNodeId(e.Id), - Label = e.label ?? "", - Type = e.kind ?? "unknown", - Degree = degreeMap.GetValueOrDefault(ExtractNodeId(e.Id), 0), - Community = e.community - }) - .OrderByDescending(x => x.Degree) - .Take(topN) - .Select(x => new AnalyzeNodeResult - { - Id = x.Id, - Label = x.Label, - Type = x.Type, - Degree = x.Degree, - Community = x.Community - }) - .ToList(); } - private async Task CountIsolatedNodesAsync(CancellationToken ct) - { - var response = await _db.RawQuery( - "SELECT * FROM entity", - cancellationToken: ct - ); + private static string RecordKey(RecordId? recordId) => ExtractNodeId(recordId); - if (response.FirstOk is not { } ok) - return 0; - - var entities = ok.GetValues().ToList(); - int isolated = 0; - - foreach (var entity in entities) - { - var connections = await FetchConnectionsAsync(entity.Id, ct); - if (connections.Count == 0) - isolated++; - } + private static string LabelFor(Dictionary labels, RecordId? id) + { + var key = RecordKey(id); + return labels.TryGetValue(key, out var e) ? (e.label ?? key) : key; + } - return isolated; + private static RecordId MakeEntityRecordId(string nodeId) + { + var escaped = Uri.EscapeDataString(nodeId); + return (RecordId)("entity", escaped); } - // ── Internal SurrealDB record types for CBOR deserialization ────── + // ── Internal SurrealDB row types for CBOR deserialization ───────── - internal sealed class SurrealEntityRecord : Record + internal sealed class EntityRow : Record { public string? label { get; set; } public string? kind { get; set; } @@ -637,41 +531,47 @@ internal sealed class SurrealEntityRecord : Record public string? language { get; set; } public string? confidence { get; set; } public int? community { get; set; } + public int degree { get; set; } } - internal sealed class SurrealRelationshipRecord : Record + internal sealed class RelRow : Record { - public RecordId? source { get; set; } - public RecordId? target { get; set; } + // 'in'/'out' are the SurrealDB graph-edge endpoint fields. They are C# + // keywords, so map them from the CBOR field names via attributes. + [CborProperty("in")] + public RecordId? In { get; set; } + + [CborProperty("out")] + public RecordId? Out { get; set; } + public string? type { get; set; } public double weight { get; set; } public string? confidence { get; set; } } - // Aggregation result types - internal sealed class SurrealCount : Record + internal sealed class CountRow : Record { public int count { get; set; } } - internal sealed class SurrealCommunityGroup : Record + internal sealed class TotalRow : Record { - public int community { get; set; } - public int count { get; set; } + public int total { get; set; } } - internal sealed class SurrealCommunityCount : Record + internal sealed class CommunityGroupRow : Record { - public int total { get; set; } + public int community { get; set; } + public int count { get; set; } } - internal sealed class SurrealKindCount : Record + internal sealed class KindCountRow : Record { public string? kind { get; set; } public int count { get; set; } } - internal sealed class SurrealTypeCount : Record + internal sealed class TypeCountRow : Record { public string? type { get; set; } public int count { get; set; } diff --git a/src/Graphify.Cli/Program.cs b/src/Graphify.Cli/Program.cs index 91d05e8..7341eb5 100644 --- a/src/Graphify.Cli/Program.cs +++ b/src/Graphify.Cli/Program.cs @@ -4,6 +4,7 @@ using Graphify.Cli.Configuration; using Graphify.Cli.Init; using Graphify.Cli.Mcp; +using Graphify.Export; using Graphify.Graph; using Graphify.Models; using Graphify.Pipeline; @@ -300,7 +301,12 @@ static void AddPipelineOptions(Command cmd, } Console.WriteLine(); - using var watchMode = new WatchMode(Console.Out, verbose); + using var watchMode = new WatchMode(Console.Out, verbose, new SurrealDbExportOptions( + graphifyConfig.SurrealDb.Endpoint, + graphifyConfig.SurrealDb.Username, + graphifyConfig.SurrealDb.Password, + graphifyConfig.SurrealDb.Namespace, + graphifyConfig.SurrealDb.Database)); watchMode.SetInitialGraph(graph); await watchMode.WatchAsync(path, output, formats, cancellationToken); return 0; diff --git a/src/Graphify.Sdk/Graphify.Sdk.csproj b/src/Graphify.Sdk/Graphify.Sdk.csproj index 347a03d..d60cc65 100644 --- a/src/Graphify.Sdk/Graphify.Sdk.csproj +++ b/src/Graphify.Sdk/Graphify.Sdk.csproj @@ -4,7 +4,7 @@ net10.0 true graphify-dotnet-sdk - 0.9.0-preview.15 + 0.9.0-preview.20 GitHub Copilot SDK integration layer for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify/Export/SurrealDbExportOptions.cs b/src/Graphify/Export/SurrealDbExportOptions.cs new file mode 100644 index 0000000..3ca5e3e --- /dev/null +++ b/src/Graphify/Export/SurrealDbExportOptions.cs @@ -0,0 +1,15 @@ +namespace Graphify.Export; + +/// +/// Connection settings for exporting a knowledge graph to SurrealDB. +/// When is set, the exporter uses remote mode; otherwise +/// it falls back to embedded (RocksDB) mode. Lives in the core project so +/// can drive SurrealDB exports without +/// depending on the CLI's configuration types. +/// +public sealed record SurrealDbExportOptions( + string? Endpoint = null, + string? Username = null, + string? Password = null, + string? Namespace = null, + string? Database = null); diff --git a/src/Graphify/Export/SurrealDbExporter.cs b/src/Graphify/Export/SurrealDbExporter.cs index 45aced7..b42b337 100644 --- a/src/Graphify/Export/SurrealDbExporter.cs +++ b/src/Graphify/Export/SurrealDbExporter.cs @@ -133,75 +133,82 @@ private static async Task ExportToClientAsync(ISurrealDbClient db, var nodes = graph.GetNodes().ToList(); var edges = graph.GetEdges().ToList(); - // Use parameterized RawQuery (CREATE ... CONTENT) instead of the typed - // Create overload. Create(table, data) deserializes the server - // response back into T, and that response shape (single object vs array) - // varies by SurrealDB version — the mismatch surfaces as a CBOR - // "Expected major type Map (5)" error. RawQuery returns a generic - // response whose result bytes are never deserialized into a typed - // record, so it is immune to that version ambiguity. - foreach (var node in nodes) + var items = nodes.Select(node => new Dictionary { - var escapedId = Uri.EscapeDataString(node.Id); - var parameters = new Dictionary - { - ["id"] = escapedId, - ["label"] = node.Label, - ["kind"] = node.Type, - ["filePath"] = node.FilePath, - ["language"] = node.Language, - ["confidence"] = node.Confidence.ToString().ToUpperInvariant(), - ["community"] = node.Community, - ["metadata"] = node.Metadata is { Count: > 0 } - ? node.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - : null - }; - - var response = await db.RawQuery( - "CREATE type::thing('entity', $id) CONTENT { " - + "label: $label, kind: $kind, filePath: $filePath, " - + "language: $language, confidence: $confidence, " - + "community: $community, metadata: $metadata };", - parameters, - cancellationToken); - response.EnsureAllOks(); + ["id"] = (RecordId)("entity", Uri.EscapeDataString(node.Id)), + ["label"] = node.Label, + ["kind"] = node.Type, + ["filePath"] = node.FilePath, + ["language"] = node.Language, + ["confidence"] = node.Confidence.ToString().ToUpperInvariant(), + ["community"] = node.Community, + ["metadata"] = node.Metadata is { Count: > 0 } + ? node.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + : null + }).ToList(); + + var rels = edges.Select(edge => new Dictionary + { + ["in"] = (RecordId)("entity", Uri.EscapeDataString(edge.Source.Id)), + ["out"] = (RecordId)("entity", Uri.EscapeDataString(edge.Target.Id)), + ["type"] = edge.Relationship, + ["weight"] = edge.Weight, + ["confidence"] = edge.Confidence.ToString().ToUpperInvariant(), + ["metadata"] = edge.Metadata is { Count: > 0 } + ? edge.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) + : null + }).ToList(); + + // Full-snapshot reconciliation in a single transaction: wipe the existing + // graph and reload it from the current snapshot. Both `run` and `watch` + // re-export the complete graph, so this makes the database mirror the + // codebase exactly — nodes/edges from deleted or edited-away code are + // removed (no orphans), and re-running never duplicates or errors on + // existing ids. Wrapping the delete+insert in BEGIN/COMMIT keeps it atomic: + // a concurrent reader sees either the entire previous graph or the entire + // new one, never a half-swept state. Entity ids are deterministic + // (entity:) so agent-held references stay stable across runs; edges + // are first-class graph edges (INSERT RELATION) supporting server-side + // traversal, inline degree, and +shortest. + // + // The whole snapshot is sent in one request because a SurrealDB transaction + // cannot span multiple stateless HTTP calls. CBOR keeps the payload compact; + // for very large graphs this trades a bigger single request for atomicity. + var statements = new List { "BEGIN;", "DELETE relationship;", "DELETE entity;" }; + var parameters = new Dictionary(); + + if (items.Count > 0) + { + statements.Add("INSERT INTO entity $items;"); + parameters["items"] = items; } - for (int i = 0; i < edges.Count; i++) + if (rels.Count > 0) { - var edge = edges[i]; - var escapedSource = Uri.EscapeDataString(edge.Source.Id); - var escapedTarget = Uri.EscapeDataString(edge.Target.Id); - var parameters = new Dictionary - { - ["id"] = escapedSource + "->" + escapedTarget + "-" + i, - ["source"] = (RecordId)("entity", escapedSource), - ["target"] = (RecordId)("entity", escapedTarget), - ["type"] = edge.Relationship, - ["weight"] = edge.Weight, - ["confidence"] = edge.Confidence.ToString().ToUpperInvariant(), - ["metadata"] = edge.Metadata is { Count: > 0 } - ? edge.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - : null - }; - - var response = await db.RawQuery( - "CREATE type::thing('relationship', $id) CONTENT { " - + "source: $source, target: $target, type: $type, " - + "weight: $weight, confidence: $confidence, " - + "metadata: $metadata };", - parameters, - cancellationToken); - response.EnsureAllOks(); + statements.Add("INSERT RELATION INTO relationship $rels;"); + parameters["rels"] = rels; } + + statements.Add("COMMIT;"); + + var response = await db.RawQuery( + string.Join(" ", statements), + parameters, + cancellationToken); + response.EnsureAllOks(); } private static async Task DefineSchemaAsync(ISurrealDbClient db) { // Schema definition. Separate statements with semicolons for SurrealQL compatibility. + // Indexes support the backend's server-side queries: community filtering/grouping + // and relationship-type aggregation. await db.Query($""" DEFINE TABLE IF NOT EXISTS entity; DEFINE TABLE IF NOT EXISTS relationship; + DEFINE INDEX IF NOT EXISTS idx_entity_community ON entity FIELDS community; + DEFINE INDEX IF NOT EXISTS idx_entity_kind ON entity FIELDS kind; + DEFINE INDEX IF NOT EXISTS idx_relationship_type ON relationship FIELDS type; """); } diff --git a/src/Graphify/Graph/KnowledgeGraph.cs b/src/Graphify/Graph/KnowledgeGraph.cs index 795d203..bef49a4 100644 --- a/src/Graphify/Graph/KnowledgeGraph.cs +++ b/src/Graphify/Graph/KnowledgeGraph.cs @@ -213,6 +213,57 @@ public void MergeGraph(KnowledgeGraph other) } } + /// + /// Removes every node extracted from along with all + /// edges those nodes participate in, so symbols deleted or edited away in that + /// file do not linger as orphans after a re-merge. + /// + /// + /// Incremental extraction of a single file reproduces only that file's own + /// (outbound) edges — it cannot re-create inbound edges owned by other files. + /// To avoid dropping cross-file relationships when a node survives the edit, + /// this returns the removed edges whose source lives in a different file. The + /// caller should re-add them after merging the file's fresh extraction; edges + /// whose endpoints no longer exist are harmlessly skipped by . + /// + public IReadOnlyList RemoveByFile(string filePath) + { + ArgumentException.ThrowIfNullOrEmpty(filePath); + + var owned = _graph.Vertices + .Where(n => n.FilePath is { } fp && + string.Equals(fp, filePath, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (owned.Count == 0) + { + return Array.Empty(); + } + + // Capture inbound edges from OTHER files before the vertices (and their + // edges) are removed, so cross-file relationships can be restored later. + var foreignEdges = new List(); + foreach (var node in owned) + { + foreach (var edge in _graph.InEdges(node)) + { + if (edge.Source.FilePath is not { } sourceFile || + !string.Equals(sourceFile, filePath, StringComparison.OrdinalIgnoreCase)) + { + foreignEdges.Add(edge); + } + } + } + + foreach (var node in owned) + { + _graph.RemoveVertex(node); // cascades removal of its in/out edges + _nodeIndex.Remove(node.Id); + } + + return foreignEdges.Distinct().ToList(); + } + /// /// Access the underlying QuikGraph for advanced algorithms. /// Use sparingly - prefer KnowledgeGraph methods for common operations. diff --git a/src/Graphify/Graphify.csproj b/src/Graphify/Graphify.csproj index 85d0325..a2e35f2 100644 --- a/src/Graphify/Graphify.csproj +++ b/src/Graphify/Graphify.csproj @@ -5,7 +5,7 @@ Graphify.Tests true graphify-dotnet-core - 0.9.0-preview.15 + 0.9.0-preview.20 Core graph extraction, graph modeling, clustering, and export pipeline for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify/Pipeline/WatchMode.cs b/src/Graphify/Pipeline/WatchMode.cs index ba05de0..c88e4fd 100644 --- a/src/Graphify/Pipeline/WatchMode.cs +++ b/src/Graphify/Pipeline/WatchMode.cs @@ -20,13 +20,15 @@ public sealed class WatchMode : IDisposable private readonly bool _verbose; private readonly ConcurrentDictionary _pendingChanges = new(); private readonly SemaphoreSlim _processLock = new(1, 1); + private readonly SurrealDbExportOptions? _surrealDb; private KnowledgeGraph? _currentGraph; - public WatchMode(TextWriter output, bool verbose = false) + public WatchMode(TextWriter output, bool verbose = false, SurrealDbExportOptions? surrealDb = null) { _output = output ?? throw new ArgumentNullException(nameof(output)); _verbose = verbose; + _surrealDb = surrealDb; _cache = new SemanticCache(); _watcher = new FileSystemWatcher { @@ -190,8 +192,9 @@ private async Task ProcessChangesAsync( // Filter to changed files only var changedSet = new HashSet(trulyChanged, StringComparer.OrdinalIgnoreCase); var filesToProcess = allDetected.Where(d => changedSet.Contains(d.FilePath)).ToList(); + var deletedFiles = trulyChanged.Where(f => !File.Exists(f)).ToList(); - if (filesToProcess.Count == 0) + if (filesToProcess.Count == 0 && deletedFiles.Count == 0) { await _output.WriteLineAsync(" (no processable files in change set)"); return; @@ -230,7 +233,7 @@ await _output.WriteLineAsync( } } - if (newResults.Count == 0) + if (newResults.Count == 0 && deletedFiles.Count == 0) { await _output.WriteLineAsync(" (no extractable content)"); return; @@ -245,8 +248,41 @@ await _output.WriteLineAsync( }); var buildStopwatch = Stopwatch.StartNew(); - var incrementalGraph = await graphBuilder.ExecuteAsync(newResults, ct); - _currentGraph!.MergeGraph(incrementalGraph); + + // Orphan removal: drop each changed/deleted file's previous nodes+edges + // before merging its fresh extraction. This purges symbols that were + // deleted or edited away (deleted files never come back, since they are + // not in the incremental graph). RemoveByFile hands back inbound edges + // from OTHER files so we can restore cross-file relationships that a + // single-file extraction cannot reproduce. + var changedFileSet = new HashSet(trulyChanged, StringComparer.OrdinalIgnoreCase); + var preservedEdges = new List(); + foreach (var changedFile in trulyChanged) + { + preservedEdges.AddRange(_currentGraph!.RemoveByFile(changedFile)); + } + + // Merge the fresh extraction for files that still exist. A delete-only + // batch has no new results — the removal above already reconciled the graph. + if (newResults.Count > 0) + { + var incrementalGraph = await graphBuilder.ExecuteAsync(newResults, ct); + _currentGraph!.MergeGraph(incrementalGraph); + } + + // Restore preserved cross-file edges. Skip any whose source file was + // itself re-extracted (the merge already reproduced those, so restoring + // would duplicate them); AddEdge drops edges whose endpoints are gone. + foreach (var edge in preservedEdges.Distinct()) + { + if (edge.Source.FilePath is { } sourceFile && changedFileSet.Contains(sourceFile)) + { + continue; + } + + _currentGraph!.AddEdge(edge); + } + if (_verbose) { await _output.WriteLineAsync($" Graph merge completed in {FormatElapsed(buildStopwatch.Elapsed)}"); @@ -261,7 +297,7 @@ await _output.WriteLineAsync( MinSplitSize = 5, MaxCommunityFraction = 0.2 }); - _currentGraph = await clusterEngine.ExecuteAsync(_currentGraph, ct); + _currentGraph = await clusterEngine.ExecuteAsync(_currentGraph!, ct); if (_verbose) { await _output.WriteLineAsync($" Re-clustering completed in {FormatElapsed(clusterStopwatch.Elapsed)}"); @@ -272,14 +308,25 @@ await _output.WriteLineAsync( Directory.CreateDirectory(outputDir); foreach (var format in formats) { - var outputPath = Path.Combine(outputDir, $"graph.{format}"); switch (format.ToLowerInvariant()) { case "json": - await new JsonExporter().ExportAsync(_currentGraph, outputPath, ct); + await new JsonExporter().ExportAsync(_currentGraph, Path.Combine(outputDir, "graph.json"), ct); break; case "html": - await new HtmlExporter().ExportAsync(_currentGraph, outputPath, cancellationToken: ct); + await new HtmlExporter().ExportAsync(_currentGraph, Path.Combine(outputDir, "graph.html"), cancellationToken: ct); + break; + case "surrealdb": + // Mirror the full graph into SurrealDB. The exporter performs a + // transactional wipe-and-reload, so each watch update leaves the + // database an exact mirror of the current codebase (no orphans). + var surrealExporter = new SurrealDbExporter( + endpoint: _surrealDb?.Endpoint, + username: _surrealDb?.Username, + password: _surrealDb?.Password, + ns: _surrealDb?.Namespace, + database: _surrealDb?.Database); + await surrealExporter.ExportAsync(_currentGraph, Path.Combine(outputDir, "codebase.db"), ct); break; } } From 600a4705cf07972c16bbca013b42842562e4b085 Mon Sep 17 00:00:00 2001 From: Cornel Hattingh Date: Wed, 22 Jul 2026 10:19:52 +0200 Subject: [PATCH 15/15] fix(surrealdb): split DELETE and INSERT into separate requests to avoid tombstoned record conflict - SurrealDB transactions keep tombstoned records visible within the transaction scope, causing 'Database record already exists' when INSERT + same-ID DELETE share a BEGIN/COMMIT. - Split: DELETE (auto-commit) first, then transactional INSERT. - Added descriptive error reporting that surfaces SurrealDB's own error text instead of the SDK's opaque 'ResponseUnsuccessful' message. - Delete-only batches now handled correctly in watch mode (fix from code review). - Bump to 0.9.0-preview.22. --- src/Graphify.Cli/Graphify.Cli.csproj | 2 +- src/Graphify.Sdk/Graphify.Sdk.csproj | 2 +- src/Graphify/Export/SurrealDbExporter.cs | 72 +++++++++++++++++------- src/Graphify/Graphify.csproj | 2 +- 4 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/Graphify.Cli/Graphify.Cli.csproj b/src/Graphify.Cli/Graphify.Cli.csproj index f3dbc57..72f624b 100644 --- a/src/Graphify.Cli/Graphify.Cli.csproj +++ b/src/Graphify.Cli/Graphify.Cli.csproj @@ -6,7 +6,7 @@ true graphify graphify-dotnet - 0.9.0-preview.20 + 0.9.0-preview.22 AI-powered knowledge graph builder for codebases Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify.Sdk/Graphify.Sdk.csproj b/src/Graphify.Sdk/Graphify.Sdk.csproj index d60cc65..3bc9476 100644 --- a/src/Graphify.Sdk/Graphify.Sdk.csproj +++ b/src/Graphify.Sdk/Graphify.Sdk.csproj @@ -4,7 +4,7 @@ net10.0 true graphify-dotnet-sdk - 0.9.0-preview.20 + 0.9.0-preview.22 GitHub Copilot SDK integration layer for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet diff --git a/src/Graphify/Export/SurrealDbExporter.cs b/src/Graphify/Export/SurrealDbExporter.cs index b42b337..bd4e208 100644 --- a/src/Graphify/Export/SurrealDbExporter.cs +++ b/src/Graphify/Export/SurrealDbExporter.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using SurrealDb.Net.Models; using SurrealDb.Net.Models.Auth; +using SurrealDb.Net.Models.Response; namespace Graphify.Export; @@ -159,22 +160,28 @@ private static async Task ExportToClientAsync(ISurrealDbClient db, : null }).ToList(); - // Full-snapshot reconciliation in a single transaction: wipe the existing - // graph and reload it from the current snapshot. Both `run` and `watch` - // re-export the complete graph, so this makes the database mirror the - // codebase exactly — nodes/edges from deleted or edited-away code are - // removed (no orphans), and re-running never duplicates or errors on - // existing ids. Wrapping the delete+insert in BEGIN/COMMIT keeps it atomic: - // a concurrent reader sees either the entire previous graph or the entire - // new one, never a half-swept state. Entity ids are deterministic - // (entity:) so agent-held references stay stable across runs; edges - // are first-class graph edges (INSERT RELATION) supporting server-side - // traversal, inline degree, and +shortest. + // Clear the existing graph FIRST in its own request so the DELETEs are fully + // committed before we try to INSERT with the same deterministic IDs. SurrealDB + // transactions keep tombstoned records visible within the transaction scope, so + // DELETE + INSERT in the same transaction would fail with "already exists". // - // The whole snapshot is sent in one request because a SurrealDB transaction - // cannot span multiple stateless HTTP calls. CBOR keeps the payload compact; - // for very large graphs this trades a bigger single request for atomicity. - var statements = new List { "BEGIN;", "DELETE relationship;", "DELETE entity;" }; + // The risk of a crash between the DELETE and the INSERT (leaving an empty DB) is + // acceptable: both `run` and `watch` re-export the complete graph, so the next + // invocation restores it. Atomicity of the INSERT side is maintained by wrapping + // the inserts in their own BEGIN/COMMIT. + var deleteResponse = await db.RawQuery( + "DELETE relationship; DELETE entity;", + cancellationToken: cancellationToken); + EnsureSuccess(deleteResponse, "DELETE relationship; DELETE entity;"); + + if (items.Count == 0 && rels.Count == 0) + { + return; // Nothing to insert, graph is already empty + } + + // Atomic insert: all or nothing. A concurrent reader sees either the empty post- + // DELETE state or the complete new graph, never a half-inserted snapshot. + var statements = new List { "BEGIN;" }; var parameters = new Dictionary(); if (items.Count > 0) @@ -191,13 +198,38 @@ private static async Task ExportToClientAsync(ISurrealDbClient db, statements.Add("COMMIT;"); - var response = await db.RawQuery( - string.Join(" ", statements), - parameters, - cancellationToken); - response.EnsureAllOks(); + var query = string.Join(" ", statements); + var response = await db.RawQuery(query, parameters, cancellationToken); + EnsureSuccess(response, query); + } + + /// + /// Throws with the actual SurrealDB error text instead of the SDK's opaque + /// "SurrealDbResponse is unsuccessful" message, so failures are diagnosable. + /// + private static void EnsureSuccess(SurrealDbResponse response, string query) + { + if (!response.HasErrors) + { + return; + } + + var details = string.Join(" | ", response.Errors.Select(DescribeError)); + throw new InvalidOperationException( + $"SurrealDB query failed: {details}{Environment.NewLine}Query: {query}"); } + private static string DescribeError(ISurrealDbErrorResult error) => error switch + { + SurrealDbErrorResult e => string.IsNullOrWhiteSpace(e.Details) + ? $"{e.Status} ({e.Kind})" + : e.Details, + SurrealDbProtocolErrorResult p => string.Join(" - ", + new[] { $"HTTP {(int)p.Code}", p.Description, p.Details, p.Information } + .Where(s => !string.IsNullOrWhiteSpace(s))), + _ => "unknown error result" + }; + private static async Task DefineSchemaAsync(ISurrealDbClient db) { // Schema definition. Separate statements with semicolons for SurrealQL compatibility. diff --git a/src/Graphify/Graphify.csproj b/src/Graphify/Graphify.csproj index a2e35f2..2246660 100644 --- a/src/Graphify/Graphify.csproj +++ b/src/Graphify/Graphify.csproj @@ -5,7 +5,7 @@ Graphify.Tests true graphify-dotnet-core - 0.9.0-preview.20 + 0.9.0-preview.22 Core graph extraction, graph modeling, clustering, and export pipeline for graphify-dotnet. Bruno Capuano https://github.com/elbruno/graphify-dotnet