From 596b4ca3b648d2426d6b1a60b15ef7489418c0b4 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 16 Feb 2026 15:42:52 +0100 Subject: [PATCH 1/5] feat: add options to include properties and functions in class diagram analysis --- .github/agents/copilot-instructions.md | 7 +- .../checklists/requirements.md | 35 +++++ .../contracts/mcp-tool.json | 46 +++++++ specs/004-config-class-members/data-model.md | 28 ++++ specs/004-config-class-members/plan.md | 68 +++++++++ specs/004-config-class-members/quickstart.md | 37 +++++ specs/004-config-class-members/research.md | 39 ++++++ specs/004-config-class-members/spec.md | 94 +++++++++++++ specs/004-config-class-members/tasks.md | 52 +++++++ .../Commands/ClassDiagramCommand.cs | 18 +++ .../Application/AnalysisOptions.cs | 10 ++ .../Application/ClassAnalysisService.cs | 10 +- .../Application/IClassAnalysisService.cs | 4 + .../UseCases/AnalyzeFileUseCase.cs | 10 +- .../Infrastructure/TypeAnalyzer.cs | 28 ++-- .../Infrastructure/TypeProcessor.cs | 2 +- src/ProjGraph.Mcp/.mcp/server.json | 1 + src/ProjGraph.Mcp/Program.cs | 12 +- .../McpClassDiagramTests.cs | 10 ++ .../ClassDiagramCommandTests.cs | 55 ++++++++ .../McpClassDiagramTests.cs | 129 +++++++++++++++++- .../AnalyzeFileUseCaseTests.cs | 8 +- .../TypeAnalyzerTests.cs | 73 ++++++++++ 23 files changed, 756 insertions(+), 20 deletions(-) create mode 100644 specs/004-config-class-members/checklists/requirements.md create mode 100644 specs/004-config-class-members/contracts/mcp-tool.json create mode 100644 specs/004-config-class-members/data-model.md create mode 100644 specs/004-config-class-members/plan.md create mode 100644 specs/004-config-class-members/quickstart.md create mode 100644 specs/004-config-class-members/research.md create mode 100644 specs/004-config-class-members/spec.md create mode 100644 specs/004-config-class-members/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 03b01e5..8573963 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -1,9 +1,11 @@ -# ProjGraph Development Guidelines +# ProjGraph Development Guidelines Auto-generated from all feature plans. Last updated: 2026-01-13 ## Active Technologies +- .NET 10.0 (C# 14+) + Microsoft.CodeAnalysis.CSharp (Roslyn), ProjGraph.Lib.Core (004-config-class-members) + - .NET 10.0 (C# 14+) + `Microsoft.CodeAnalysis.CSharp`, `Spectre.Console` (003-mermaid-class-diagram) - .NET 10.0 (C# 14+) + `Microsoft.CodeAnalysis.CSharp`, `Microsoft.CodeAnalysis.Workspaces.MSBuild`, @@ -28,12 +30,13 @@ tests/ ## Recent Changes +- 004-config-class-members: Added .NET 10.0 (C# 14+) + Microsoft.CodeAnalysis.CSharp (Roslyn), ProjGraph.Lib.Core + - 003-mermaid-class-diagram: Added .NET 10.0 (C# 14+) + `Microsoft.CodeAnalysis.CSharp`, `Spectre.Console` - 002-dbcontext-erd: Added .NET 10.0 (C# 14+) + `Microsoft.CodeAnalysis.CSharp`, `Microsoft.CodeAnalysis.Workspaces.MSBuild`, `Microsoft.EntityFrameworkCore` (for symbol analysis) -- 001-cli-graph-rendering: Added .NET 10.0 (C# 14+) + Buildalyzer (Parsing), Spectre.Console (CLI), ModelContextProtocol (MCP) diff --git a/specs/004-config-class-members/checklists/requirements.md b/specs/004-config-class-members/checklists/requirements.md new file mode 100644 index 0000000..2d26465 --- /dev/null +++ b/specs/004-config-class-members/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Configure Class Member Visibility + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-16 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Feature numbered correctly as 004. +- Clarifications on member grouping and relationship visibility incorporated. diff --git a/specs/004-config-class-members/contracts/mcp-tool.json b/specs/004-config-class-members/contracts/mcp-tool.json new file mode 100644 index 0000000..4ceeed4 --- /dev/null +++ b/specs/004-config-class-members/contracts/mcp-tool.json @@ -0,0 +1,46 @@ +{ + "name": "GetClassDiagram", + "description": "Generates a Mermaid class diagram for the types defined in a specific C# file, with options to discover inheritance and related types in the workspace.", + "parameters": { + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Absolute path to the .cs file to analyze." + }, + "includeInheritance": { + "type": "boolean", + "description": "Whether to search the workspace for base classes and interfaces.", + "default": false + }, + "includeDependencies": { + "type": "boolean", + "description": "Whether to search for and include other classes used as properties or fields.", + "default": false + }, + "includeProperties": { + "type": "boolean", + "description": "Whether to display properties and fields in the class diagram.", + "default": true + }, + "includeFunctions": { + "type": "boolean", + "description": "Whether to display functions/methods in the class diagram.", + "default": true + }, + "depth": { + "type": "integer", + "description": "How many levels of relationships to follow.", + "default": 1 + }, + "showTitle": { + "type": "boolean", + "description": "Whether to include the title in the diagram.", + "default": true + } + }, + "required": [ + "filePath" + ] + } +} diff --git a/specs/004-config-class-members/data-model.md b/specs/004-config-class-members/data-model.md new file mode 100644 index 0000000..e72b2f0 --- /dev/null +++ b/specs/004-config-class-members/data-model.md @@ -0,0 +1,28 @@ +# Data Model: Class Member Visibility + +## Entity: AnalysisOptions (Extension) + +The `AnalysisOptions` class in `ProjGraph.Lib.ClassDiagram` will be extended with the following fields: + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| IncludeProperties | bool | true | When true, includes properties and fields in the type definition. | +| IncludeFunctions | bool | true | When true, includes methods in the type definition. Constructors are not currently extracted by the analyzer and are out of scope. | + +## Entity: MemberDefinition (Reference) + +Existing entity in `ProjGraph.Core.Models`. + +| Field | Type | Category mapping | +|-------|------|------------------| +| Kind | MemberKind | Property, Field -> "Properties" | +| Kind | MemberKind | Method -> "Functions" | + +Note: The `MemberKind` enum contains `Field = 0`, `Property = 1`, `Method = 2`. There is no `Constructor` value — constructors are not extracted by `TypeAnalyzer` and are out of scope. + +## Validation Rules + +1. Mapping for `MemberKind`: + - `MemberKind.Property` and `MemberKind.Field` MUST be filtered by `IncludeProperties`. + - `MemberKind.Method` MUST be filtered by `IncludeFunctions`. +2. Discovery Rule: Hiding a property MUST NOT prevent the identification of an Association relationship if the property's type is a local or workspace type. diff --git a/specs/004-config-class-members/plan.md b/specs/004-config-class-members/plan.md new file mode 100644 index 0000000..861090e --- /dev/null +++ b/specs/004-config-class-members/plan.md @@ -0,0 +1,68 @@ +# Implementation Plan: Configure Class Member Visibility + +**Branch**: `004-config-class-members` | **Date**: 2026-02-16 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/004-config-class-members/spec.md` + +## Summary + +This feature improves the `GetClassDiagram` tool by allowing users to toggle the visibility of class properties and functions. The goal is to provide cleaner, more focused diagrams for large codebases. The technical approach involves extending `AnalysisOptions` in `ProjGraph.Lib.ClassDiagram` and updating `TypeAnalyzer` to filter members before building the model, while maintaining relationship discovery in `TypeProcessor`. + +## Technical Context + +**Language/Version**: .NET 10.0 (C# 14+) +**Primary Dependencies**: Microsoft.CodeAnalysis.CSharp (Roslyn), ProjGraph.Lib.Core +**Storage**: N/A +**Testing**: xUnit, FluentAssertions +**Target Platform**: .NET Core (Windows, Linux, macOS) +**Project Type**: Library/MCP Server +**Performance Goals**: < 5s for medium-sized workspaces; SC-004: > 50% character reduction in diagrams when members are hidden. +**Constraints**: MCP 1.0 Compliance, Zero Warnings, Strict SemVer +**Scale/Scope**: Workspace-wide class analysis and rendering. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [x] **I. Modern .NET 10 Baseline**: Targeting .NET 10.0+? +- [x] **II. MCP Native Interoperability**: Tool functionality exposed via MCP? +- [x] **III. Library-First Core**: Logic in libraries, not just CLI/MCP? +- [x] **IV. Absolute Testing Requirement**: Tests planned for unit, integration, and MCP contract? + +## Project Structure + +### Documentation (this feature) + +```text +specs/004-config-class-members/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command - MUST include MCP schema) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + +```text +src/ +├── ProjGraph.Core/ # Shared Domain Models (MemberDefinition, MemberKind) +├── ProjGraph.Lib.ClassDiagram/ # Core Analysis and Rendering Logic +└── ProjGraph.Mcp/ # MCP Server interface (Tool parameters) +``` + +tests/ +├── ProjGraph.Tests.Contract/ # MCP Contract Tests +├── ProjGraph.Tests.Integration.Mcp/ # MCP Integration +└── ProjGraph.Tests.Unit.ClassDiagram/ # Analysis Logic Unit Tests + +**Structure Decision**: Logic primarily resides in `ProjGraph.Lib.ClassDiagram.Application.AnalysisOptions` and `Rendering`. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +| ----------- | ------------ | ------------------------------------- | +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/004-config-class-members/quickstart.md b/specs/004-config-class-members/quickstart.md new file mode 100644 index 0000000..f359d0f --- /dev/null +++ b/specs/004-config-class-members/quickstart.md @@ -0,0 +1,37 @@ +# Quickstart: Configuring Class Member Visibility + +## Objective + +Generate a class diagram that focuses only on high-level relationships by hiding detailed properties and methods. + +## Usage with MCP + +To generate a diagram with only classes and their relationships (no members): + +```json +{ + "name": "GetClassDiagram", + "arguments": { + "filePath": "C:/path/to/YourController.cs", + "includeProperties": false, + "includeFunctions": false + } +} +``` + +To see only the behavioral API (methods): + +```json +{ + "name": "GetClassDiagram", + "arguments": { + "filePath": "C:/path/to/YourService.cs", + "includeProperties": false, + "includeFunctions": true + } +} +``` + +## Default Behavior + +If omitted, `includeProperties` and `includeFunctions` default to `true`, providing the same detailed output as before. diff --git a/specs/004-config-class-members/research.md b/specs/004-config-class-members/research.md new file mode 100644 index 0000000..d51d2b2 --- /dev/null +++ b/specs/004-config-class-members/research.md @@ -0,0 +1,39 @@ +# Research: Class Member Visibility Configuration + +## Decision 1: Mapping Roslyn symbols to categories + +- **Decision**: `PropertySymbol`, `FieldSymbol` -> **Properties**. `MethodSymbol` (ordinary methods only) -> **Functions**. +- **Rationale**: User clarified that Fields should be grouped with Properties. Constructors are not currently extracted by the analyzer (`TypeAnalyzer` only captures `MethodKind.Ordinary`) and are out of scope for this feature. +- **Alternatives Considered**: Separate toggles for each, but rejected for simplicity as per user request. + +## Decision 2: Implementation Point for Filtering + +- **Decision**: Filter during the **Analysis** phase inside `TypeAnalyzer.AnalyzeType`, before building the `TypeDefinition` model. +- **Rationale**: By filtering members in `TypeAnalyzer`, the `TypeDefinition` model arrives pre-filtered to the renderer, making `MermaidClassDiagramRenderer` unchanged. Dependency discovery (Association relationships) must still scan all member types *before* filtering, so `TypeProcessor.DiscoverRelatedTypes` runs on the full symbol regardless of visibility flags. This satisfies both FR-004 (discovery unaffected) and SC-003 (less data to render). + +## Decision 3: MCP Schema Update + +- **Decision**: Add `includeProperties` and `includeFunctions` as boolean parameters with default `true`. +- **Rationale**: Ensures backward compatibility and satisfies FR-003. + +## Findings from Codebase + +- `ProjGraph.Lib.ClassDiagram.Application.AnalysisOptions` needs 2 new fields. +- `ProjGraph.Mcp.Program.GetClassDiagramAsync` needs to pass these new options. +- `ProjGraph.Lib.ClassDiagram.Rendering.MermaidClassDiagramRenderer` requires no changes (receives pre-filtered `TypeDefinition` models). + +### Current Implementation Exploration + +- `ClassAnalysisService` uses Roslyn to populate `TypeDefinition`. +- `TypeDefinition` contains `IReadOnlyList`. +- `MemberDefinition` has `MemberKind` (Field, Property, Method). +- `MermaidClassDiagramRenderer` iterates over members in `RenderType`. +- `DiagramOptions` (in `ProjGraph.Lib.Core`) is general, while `AnalysisOptions` (in `ProjGraph.Lib.ClassDiagram`) is specific to this tool. + +### Proposed Changes + +1. **Update `AnalysisOptions`**: Add `IncludeProperties` and `IncludeFunctions` (both default `true`). +2. **Update `TypeAnalyzer.AnalyzeType`**: Accept `includeProperties` and `includeFunctions` flags. Skip adding members to `TypeDefinition` based on these flags. + - *Caution*: Dependency discovery in `TypeProcessor.DiscoverRelatedTypes` must still scan all symbol members for relationship detection, even if `IncludeProperties` is false. The filtering only affects what goes into `TypeDefinition.Members`. +3. **Update `ProjGraph.Mcp`**: Expose these parameters in `GetClassDiagramAsync` and pass them into the `AnalysisOptions`. +4. **No changes to `MermaidClassDiagramRenderer`** might be needed if `TypeDefinition` already comes filtered from the service. This is cleaner and more performant. diff --git a/specs/004-config-class-members/spec.md b/specs/004-config-class-members/spec.md new file mode 100644 index 0000000..fc2ac1e --- /dev/null +++ b/specs/004-config-class-members/spec.md @@ -0,0 +1,94 @@ +# Feature Specification: Configure Class Member Visibility + +**Feature Branch**: `004-config-class-members` +**Created**: 2026-02-16 +**Status**: Draft +**Input**: User description: "I want to improve the classdiagram tool by allowing the user to set if the tool will look and display properties and/or functions. By default, theses two options are true but can be override." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - High-Level Architecture View (Priority: P1) + +As a software architect, I want to generate a class diagram that only shows the classes and their relationships, without showing any internal members, so that I can focus on the system structure without being distracted by implementation details. + +**Why this priority**: This is the primary use case for reducing noise in diagrams, which is the core of the user request. + +**Independent Test**: Generate a diagram for a complex workspace with `includeProperties` and `includeFunctions` set to `false`, and verify that the output contains only class names and relationship lines. + +**Acceptance Scenarios**: + +1. **Given** a C# file with multiple properties and methods, **When** the `GetClassDiagram` tool is called with `includeProperties=false` and `includeFunctions=false`, **Then** the resulting Mermaid diagram shows class boxes containing only the class names. +2. **Given** no explicit configuration, **When** the `GetClassDiagram` tool is called, **Then** all properties and functions are visible by default. + +--- + +### User Story 2 - Behavioral Focus (Priority: P2) + +As a developer, I want to see only the functions (methods) of a class without the data properties, so that I can understand the available operations and behavior of the API. + +**Why this priority**: Allows for specialized analysis of class behavior, which is a common requirement during code review or API design. + +**Independent Test**: Generate a diagram with `includeProperties=false` and `includeFunctions=true` and verify that only methods are listed in the class boxes. + +**Acceptance Scenarios**: + +1. **Given** a class with both properties and methods, **When** requested with `includeProperties=false` and `includeFunctions=true`, **Then** the class box in the diagram lists the methods but does not list any properties. + +--- + +### User Story 3 - Data Focus (Priority: P3) + +As a data modeler, I want to see only the properties (data) of a class without the logic, so that I can understand the data structure and state maintained by the type. + +**Why this priority**: Useful for documenting data models or DTOs where business logic is secondary. + +**Independent Test**: Generate a diagram with `includeProperties=true` and `includeFunctions=false` and verify that only properties are listed. + +**Acceptance Scenarios**: + +1. **Given** a class with both properties and methods, **When** requested with `includeProperties=true` and `includeFunctions=false`, **Then** the class box in the diagram lists the properties but does not list any methods. + +--- + +### Edge Cases + +- **Empty Classes**: What happens when a class has no members and both toggles are set to true? (System should show an empty class box). +- **Dependency Discovery**: Even if a property/field is hidden via `includeProperties=false`, the tool MUST still use it to discover and draw relationship lines (associations) to other classes. +- **Member Type Grouping**: "Functions" maps to `MemberKind.Method`. "Properties" maps to `MemberKind.Property` and `MemberKind.Field`. Constructors are not currently extracted by the analyzer and are out of scope. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST provide a way to toggle the visibility of type properties and fields in the generated diagram via an `includeProperties` parameter. +- **FR-002**: System MUST provide a way to toggle the visibility of type methods in the generated diagram via an `includeFunctions` parameter. +- **FR-003**: By default, both properties and functions MUST be included in the diagram. +- **FR-004**: The choice of member visibility MUST NOT affect the discovery of types or the rendering of relationship lines (associations, inheritance) between classes. +- **FR-005**: All currently extracted member types (`MemberKind.Property`, `MemberKind.Field`, `MemberKind.Method`) MUST be assigned to either the "Properties" or "Functions" category for visibility control. + +### Key Entities + +- **Analysis Options**: The configuration object that stores the user's preferences for member visibility. +- **Member Filter**: The logic responsible for determining if a member should be included in the rendering based on the options. + +### MCP Tool Interface + +- **Tool Name**: `GetClassDiagram` +- **Description**: Generates a Mermaid class diagram for the types defined in a specific C# file, with options to control member visibility. +- **Parameters**: + - `filePath`: (string) Absolute path to the .cs file to analyze. + - `includeInheritance`: (boolean) Whether to search the workspace for base classes and interfaces. + - `includeDependencies`: (boolean) Whether to search for and include other classes used as properties or fields. + - `includeProperties`: (boolean) Whether to display properties and fields in the class diagram (default: true). + - `includeFunctions`: (boolean) Whether to display functions/methods in the class diagram (default: true). + - `depth`: (integer) How many levels of relationships to follow. + - `showTitle`: (boolean) Whether to include the title in the diagram (default: true). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can successfully generate a "clean" class diagram (no members) with a single tool call using the new parameters. +- **SC-002**: The default behavior remains unchanged for existing users who do not provide the new parameters. +- **SC-003**: The time taken to generate the diagram is reduced when members are excluded. +- **SC-004**: Diagram size (character count) is reduced by at least 50% for typical classes when members are hidden, improving readability in LLM contexts. diff --git a/specs/004-config-class-members/tasks.md b/specs/004-config-class-members/tasks.md new file mode 100644 index 0000000..89e444b --- /dev/null +++ b/specs/004-config-class-members/tasks.md @@ -0,0 +1,52 @@ +# Tasks: Configure Class Member Visibility + +**Feature**: [spec.md](spec.md) +**Plan**: [plan.md](plan.md) + +## Phase 1: Setup + +- [x] T001 Register `IncludeProperties` and `IncludeFunctions` fields in `src/ProjGraph.Lib.ClassDiagram/Application/AnalysisOptions.cs` +- [x] T002 Update `GetClassDiagramAsync` parameters and description in `src/ProjGraph.Mcp/Program.cs` +- [x] T003 Update `IClassAnalysisService.AnalyzeFileAsync` interface to include new visibility flags in `src/ProjGraph.Lib.ClassDiagram/Application/IClassAnalysisService.cs` +- [x] T004 Update `ClassAnalysisService.AnalyzeFileAsync` implementation in `src/ProjGraph.Lib.ClassDiagram/Application/ClassAnalysisService.cs` + +## Phase 2: Foundational + +- [x] T005 Update `AnalyzeFileUseCase.ExecuteAsync` to accept and set new options in `src/ProjGraph.Lib.ClassDiagram/Application/UseCases/AnalyzeFileUseCase.cs` +- [x] T006 [P] Update `TypeAnalyzer.AnalyzeType` signature to accept `includeProperties` and `includeFunctions` flags in `src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeAnalyzer.cs` +- [x] T007 [P] Update `TypeProcessor.ProcessTypeQueueInternalAsync` to pass flags from options to `AnalyzeType` in `src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeProcessor.cs` + +## Phase 3: User Story 1 - High-Level Architecture View (Priority: P1) + +Goal: Generate diagrams with no internal members. +Independent Test: Run `GetClassDiagram` with `includeProperties=false` and `includeFunctions=false` and verify empty class boxes. + +- [x] T008 [US1] Implement member filtering logic in `TypeAnalyzer.AnalyzeType`: skip Property/Field members when `includeProperties=false`, skip Method members when `includeFunctions=false` in `src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeAnalyzer.cs` +- [x] T009 [P] [US1] Create unit test case for fully hidden members in `tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs` +- [x] T010 [US1] Create unit test verifying rendered Mermaid output still contains relationship arrows (`-->`, `<|--`) when members are hidden via `includeProperties=false` and `includeFunctions=false` +- [x] T011 [US1] Create contract test for MCP tool with member visibility toggles in `tests/ProjGraph.Tests.Contract/McpClassDiagramTests.cs` + +## Phase 4: User Story 2 - Behavioral Focus (Priority: P2) + +Goal: Show only functions/methods in the class diagram. +Independent Test: Run with `includeProperties=false` and verify only methods are visible. + +- [x] T012 [P] [US2] Create unit test case for "Functions Only" visibility in `tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs` +- [x] T013 [US2] Verify that inheritance relationships or other types remain visible in the diagram even if members are hidden in `tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs` + +## Phase 5: User Story 3 - Data Focus (Priority: P3) + +Goal: Show only properties/fields in the class diagram. +Independent Test: Run with `includeFunctions=false` and verify only properties are visible. + +- [x] T014 [P] [US3] Create unit test case for "Properties Only" visibility in `tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs` +- [x] T015 [US3] Verify that dependency relationships (Associations) are still discovered correctly from hidden fields/properties in `tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs` + +## Final Phase: Polish + +- [x] T016 [US1] Create unit test for empty class (no members, both toggles true) renders correctly as empty class box +- [x] T017 Create regression test: call `GetClassDiagram` without new parameters and assert output is identical to current baseline (SC-002) +- [x] T018 Verify all tests pass and check for zero warnings in the solution +- [x] T019 Validate SC-004 by generating a diagram for `samples/classdiagram/complex-hierarchy/` with and without members and comparing character counts (expect >= 50% reduction) +- [x] T020 Update `ClassDiagramCommand` to support and pass member visibility flags in `src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs` +- [x] T021 Add CLI integration tests for member visibility in `tests/ProjGraph.Tests.Integration.Cli/ClassDiagramCommandTests.cs` diff --git a/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs b/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs index 3e833d0..6c41763 100644 --- a/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs +++ b/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs @@ -54,6 +54,22 @@ internal sealed class Settings : CommandSettings [DefaultValue(false)] public bool IncludeDependencies { get; init; } + /// + /// Gets or sets a value indicating whether to include properties and fields in the diagram. + /// + [CommandOption("--properties")] + [Description("Show properties and fields in the diagram (default true)")] + [DefaultValue(true)] + public bool IncludeProperties { get; init; } = true; + + /// + /// Gets or sets a value indicating whether to include functions/methods in the diagram. + /// + [CommandOption("--functions")] + [Description("Show functions and methods in the diagram (default true)")] + [DefaultValue(true)] + public bool IncludeFunctions { get; init; } = true; + /// /// Gets or sets the maximum depth for relationship discovery. /// @@ -118,6 +134,8 @@ public override async Task ExecuteAsync( settings.Path, settings.IncludeInheritance, settings.IncludeDependencies, + settings.IncludeProperties, + settings.IncludeFunctions, settings.Depth); var mermaidOutput = mermaidRenderer.Render(model, new DiagramOptions(settings.ShowTitle)); diff --git a/src/ProjGraph.Lib.ClassDiagram/Application/AnalysisOptions.cs b/src/ProjGraph.Lib.ClassDiagram/Application/AnalysisOptions.cs index 627695c..80c8e6e 100644 --- a/src/ProjGraph.Lib.ClassDiagram/Application/AnalysisOptions.cs +++ b/src/ProjGraph.Lib.ClassDiagram/Application/AnalysisOptions.cs @@ -19,4 +19,14 @@ public sealed class AnalysisOptions /// Indicates whether dependency relationships should be included in the analysis. /// public required bool IncludeDependencies { get; init; } + + /// + /// Indicates whether properties and fields should be included in the analysis. + /// + public required bool IncludeProperties { get; init; } + + /// + /// Indicates whether functions and methods should be included in the analysis. + /// + public required bool IncludeFunctions { get; init; } } diff --git a/src/ProjGraph.Lib.ClassDiagram/Application/ClassAnalysisService.cs b/src/ProjGraph.Lib.ClassDiagram/Application/ClassAnalysisService.cs index 3ca1058..184b2c0 100644 --- a/src/ProjGraph.Lib.ClassDiagram/Application/ClassAnalysisService.cs +++ b/src/ProjGraph.Lib.ClassDiagram/Application/ClassAnalysisService.cs @@ -15,8 +15,16 @@ public async Task AnalyzeFileAsync( string filePath, bool includeInheritance = true, bool includeDependencies = false, + bool includeProperties = true, + bool includeFunctions = true, int maxDepth = 1) { - return await analyzeFileUseCase.ExecuteAsync(filePath, includeInheritance, includeDependencies, maxDepth); + return await analyzeFileUseCase.ExecuteAsync( + filePath, + includeInheritance, + includeDependencies, + includeProperties, + includeFunctions, + maxDepth); } } diff --git a/src/ProjGraph.Lib.ClassDiagram/Application/IClassAnalysisService.cs b/src/ProjGraph.Lib.ClassDiagram/Application/IClassAnalysisService.cs index 17223d0..bf607b4 100644 --- a/src/ProjGraph.Lib.ClassDiagram/Application/IClassAnalysisService.cs +++ b/src/ProjGraph.Lib.ClassDiagram/Application/IClassAnalysisService.cs @@ -13,11 +13,15 @@ public interface IClassAnalysisService /// Target .cs file path. /// Whether to discover base classes/interfaces. /// Whether to discover types used in members. + /// Whether to include properties and fields. + /// Whether to include functions and methods. /// Depth of relationship discovery. /// A ClassModel representing the discovered types and relationships. Task AnalyzeFileAsync( string filePath, bool includeInheritance = true, bool includeDependencies = false, + bool includeProperties = true, + bool includeFunctions = true, int maxDepth = 1); } diff --git a/src/ProjGraph.Lib.ClassDiagram/Application/UseCases/AnalyzeFileUseCase.cs b/src/ProjGraph.Lib.ClassDiagram/Application/UseCases/AnalyzeFileUseCase.cs index b3212fd..93a72f8 100644 --- a/src/ProjGraph.Lib.ClassDiagram/Application/UseCases/AnalyzeFileUseCase.cs +++ b/src/ProjGraph.Lib.ClassDiagram/Application/UseCases/AnalyzeFileUseCase.cs @@ -23,13 +23,17 @@ public class AnalyzeFileUseCase( /// The path to the C# source file to analyze. /// Specifies whether to include inheritance relationships in the analysis. /// Specifies whether to include dependency relationships in the analysis. + /// Specifies whether to include properties and fields in the analysis. + /// Specifies whether to include functions and methods in the analysis. /// The maximum depth for analyzing relationships. /// A task that represents the asynchronous operation. The task result contains the analyzed class model. /// Thrown when the specified source file is not found. public async Task ExecuteAsync( string filePath, - bool includeInheritance = true, + bool includeInheritance = false, bool includeDependencies = false, + bool includeProperties = true, + bool includeFunctions = true, int maxDepth = 1) { if (!fileSystem.FileExists(filePath)) @@ -58,7 +62,9 @@ public async Task ExecuteAsync( { MaxDepth = maxDepth, IncludeInheritance = includeInheritance, - IncludeDependencies = includeDependencies + IncludeDependencies = includeDependencies, + IncludeProperties = includeProperties, + IncludeFunctions = includeFunctions }; var typesToAnalyze = new Queue<(INamedTypeSymbol Symbol, int Depth)>(); diff --git a/src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeAnalyzer.cs b/src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeAnalyzer.cs index 0286e35..5c2ffff 100644 --- a/src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeAnalyzer.cs +++ b/src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeAnalyzer.cs @@ -20,10 +20,15 @@ internal static class TypeAnalyzer /// Analyzes a type symbol and extracts its definition including members (properties, fields, and methods). /// /// The representing the type to analyze. + /// Whether to include properties and fields in the analyzed definition. + /// Whether to include functions and methods in the analyzed definition. /// /// A object containing the analyzed type's metadata and members. /// - public static TypeDefinition AnalyzeType(INamedTypeSymbol symbol) + public static TypeDefinition AnalyzeType( + INamedTypeSymbol symbol, + bool includeProperties = true, + bool includeFunctions = true) { var members = new List(); var isEnum = symbol.TypeKind == TypeKind.Enum; @@ -32,7 +37,7 @@ public static TypeDefinition AnalyzeType(INamedTypeSymbol symbol) { switch (member) { - case IPropertySymbol prop: + case IPropertySymbol prop when includeProperties: members.Add(new MemberDefinition( prop.Name, prop.Type.ToDisplayString(ShortNameFormat), @@ -40,15 +45,18 @@ public static TypeDefinition AnalyzeType(INamedTypeSymbol symbol) MemberKind.Property)); break; case IFieldSymbol { IsImplicitlyDeclared: false } field: - // For enums, only show the field name without the type - var fieldType = isEnum ? string.Empty : field.Type.ToDisplayString(ShortNameFormat); - members.Add(new MemberDefinition( - field.Name, - fieldType, - MapAccessibility(field.DeclaredAccessibility), - MemberKind.Field)); + if (isEnum || includeProperties) + { + // For enums, only show the field name without the type + var fieldType = isEnum ? string.Empty : field.Type.ToDisplayString(ShortNameFormat); + members.Add(new MemberDefinition( + field.Name, + fieldType, + MapAccessibility(field.DeclaredAccessibility), + MemberKind.Field)); + } break; - case IMethodSymbol { MethodKind: MethodKind.Ordinary } method: + case IMethodSymbol { MethodKind: MethodKind.Ordinary } method when includeFunctions: var parameters = method.Parameters .Select(p => new ParameterDefinition(p.Name, p.Type.ToDisplayString(ShortNameFormat))) .ToList(); diff --git a/src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeProcessor.cs b/src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeProcessor.cs index 5847b7b..979b493 100644 --- a/src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeProcessor.cs +++ b/src/ProjGraph.Lib.ClassDiagram/Infrastructure/TypeProcessor.cs @@ -53,7 +53,7 @@ private async Task ProcessTypeQueueInternalAsync( continue; } - var typeDef = TypeAnalyzer.AnalyzeType(symbol); + var typeDef = TypeAnalyzer.AnalyzeType(symbol, options.IncludeProperties, options.IncludeFunctions); context.Types.Add(typeDef); if (depth >= options.MaxDepth) diff --git a/src/ProjGraph.Mcp/.mcp/server.json b/src/ProjGraph.Mcp/.mcp/server.json index ea5f7c4..09248c3 100644 --- a/src/ProjGraph.Mcp/.mcp/server.json +++ b/src/ProjGraph.Mcp/.mcp/server.json @@ -4,6 +4,7 @@ "version": "0.4.0", "description": "MCP server for ProjGraph to interact with LLMs and provide project graph context.", "title": "ProjGraph MCP Server", + "icon": "https://raw.githubusercontent.com/HandyS11/ProjGraph/develop/icon.png", "websiteUrl": "https://github.com/HandyS11/ProjGraph", "packages": [ { diff --git a/src/ProjGraph.Mcp/Program.cs b/src/ProjGraph.Mcp/Program.cs index 4ab563e..fbf1e68 100644 --- a/src/ProjGraph.Mcp/Program.cs +++ b/src/ProjGraph.Mcp/Program.cs @@ -62,6 +62,10 @@ public async Task GetClassDiagramAsync( bool includeInheritance = false, [Description("Whether to search for and include other classes used as properties or fields.")] bool includeDependencies = false, + [Description("Whether to display properties and fields in the class diagram (default: true).")] + bool includeProperties = true, + [Description("Whether to display functions/methods in the class diagram (default: true).")] + bool includeFunctions = true, [Description("How many levels of relationships to follow (default: 1).")] int depth = 1, [Description("Whether to include the title in the diagram (default: true).")] @@ -78,7 +82,13 @@ public async Task GetClassDiagramAsync( FilePathGuard.RequireCsFile(filePath, nameof(filePath)); - var model = await classService.AnalyzeFileAsync(filePath, includeInheritance, includeDependencies, depth); + var model = await classService.AnalyzeFileAsync( + filePath, + includeInheritance, + includeDependencies, + includeProperties, + includeFunctions, + depth); return classRenderer.Render(model, new DiagramOptions(showTitle)); } diff --git a/tests/ProjGraph.Tests.Contract/McpClassDiagramTests.cs b/tests/ProjGraph.Tests.Contract/McpClassDiagramTests.cs index a21823a..c78f7c5 100644 --- a/tests/ProjGraph.Tests.Contract/McpClassDiagramTests.cs +++ b/tests/ProjGraph.Tests.Contract/McpClassDiagramTests.cs @@ -54,6 +54,16 @@ public void GetClassDiagram_ShouldHave_RequiredParameters() dependenciesParam.IsOptional.Should().BeTrue(); dependenciesParam.DefaultValue.Should().Be(false); + var propertiesParam = parameters.Should().ContainSingle(p => p.Name == "includeProperties").Which; + propertiesParam.ParameterType.Should().Be(); + propertiesParam.IsOptional.Should().BeTrue(); + propertiesParam.DefaultValue.Should().Be(true); + + var functionsParam = parameters.Should().ContainSingle(p => p.Name == "includeFunctions").Which; + functionsParam.ParameterType.Should().Be(); + functionsParam.IsOptional.Should().BeTrue(); + functionsParam.DefaultValue.Should().Be(true); + var depthParam = parameters.Should().ContainSingle(p => p.Name == "depth").Which; depthParam.ParameterType.Should().Be(); depthParam.IsOptional.Should().BeTrue(); diff --git a/tests/ProjGraph.Tests.Integration.Cli/ClassDiagramCommandTests.cs b/tests/ProjGraph.Tests.Integration.Cli/ClassDiagramCommandTests.cs index 7bc29c1..bdb5dcd 100644 --- a/tests/ProjGraph.Tests.Integration.Cli/ClassDiagramCommandTests.cs +++ b/tests/ProjGraph.Tests.Integration.Cli/ClassDiagramCommandTests.cs @@ -242,4 +242,59 @@ public class User capturedOutput.Should().MatchRegex(@"(-->|\.\.>)", "Output should contain relationship arrow (association or dependency)"); } + + [Fact] + public void ClassDiagramCommand_HideProperties_ShouldNotShowProperties() + { + // Arrange + var app = CliTestHelpers.CreateApp(); + var userPath = CliTestHelpers.GetSamplePath(@"classdiagram\simple-hierarchy\Models\User.cs"); + + // Act + var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => + app.Run(["classdiagram", userPath, "--properties", "false"])); + + // Assert + capturedOutput.Should().Contain("classDiagram"); + capturedOutput.Should().Contain("[\"User\"]"); + capturedOutput.Should().NotContain("+string Username"); + capturedOutput.Should().NotContain("+string Email"); + } + + [Fact] + public void ClassDiagramCommand_HideFunctions_ShouldNotShowMethods() + { + // Arrange + var app = CliTestHelpers.CreateApp(); + var userPath = CliTestHelpers.GetSamplePath(@"classdiagram\simple-hierarchy\Models\User.cs"); + + // Act + var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => + app.Run(["classdiagram", userPath, "--functions", "false"])); + + // Assert + capturedOutput.Should().Contain("classDiagram"); + capturedOutput.Should().Contain("[\"User\"]"); + // User in simple-hierarchy has only properties, so we check they are still there + capturedOutput.Should().Contain("+string Username"); + } + + [Fact] + public async Task ClassDiagramCommand_HideFunctions_WithMethods_ShouldExcludeMethods() + { + // Arrange + var tempDir = _temp.DirectoryPath; + var filePath = Path.Combine(tempDir, "Svc.cs"); + await File.WriteAllTextAsync(filePath, "namespace Test; public class Svc { public void DoWork() {} }"); + + var app = CliTestHelpers.CreateApp(); + + // Act + var capturedOutput = CliTestHelpers.CaptureConsoleOutput(() => + app.Run(["classdiagram", filePath, "--functions", "false"])); + + // Assert + capturedOutput.Should().Contain("[\"Svc\"]"); + capturedOutput.Should().NotContain("DoWork()"); + } } diff --git a/tests/ProjGraph.Tests.Integration.Mcp/McpClassDiagramTests.cs b/tests/ProjGraph.Tests.Integration.Mcp/McpClassDiagramTests.cs index 924e7a4..868ab01 100644 --- a/tests/ProjGraph.Tests.Integration.Mcp/McpClassDiagramTests.cs +++ b/tests/ProjGraph.Tests.Integration.Mcp/McpClassDiagramTests.cs @@ -322,6 +322,8 @@ public async Task GetClassDiagram_AllOptionsEnabled_ShouldWorkCorrectly() _tempFileWithInheritance, true, true, + true, + true, 3); // Assert @@ -341,6 +343,8 @@ public async Task GetClassDiagram_AllOptionsDisabled_ShouldShowBasicClasses() _tempFileWithInheritance, false, false, + false, + false, 0); // Assert @@ -350,10 +354,133 @@ public async Task GetClassDiagram_AllOptionsDisabled_ShouldShowBasicClasses() result.Should().Contain("class TestNamespace_Admin"); } + [Fact] + public async Task GetClassDiagram_IncludePropertiesFalse_ShouldExcludeProperties() + { + // Arrange + var tools = CreateTools(); + + // Act + var result = await tools.GetClassDiagramAsync(_tempFile, includeProperties: false); + + // Assert + result.Should().NotContain("int Id"); + result.Should().NotContain("string Name"); + result.Should().NotContain("int Age"); + } + + [Fact] + public async Task GetClassDiagram_IncludeFunctionsFalse_ShouldExcludeMethods() + { + // Arrange + var tools = CreateTools(); + const string fileWithMethods = "Svc.cs"; + var path = Path.Combine(_temp.DirectoryPath, fileWithMethods); + + await File.WriteAllTextAsync(path, "namespace Test; public class Svc { public void DoWork() {} }"); + + // Act + var result = await tools.GetClassDiagramAsync(path, includeFunctions: false); + + // Assert + result.Should().NotContain("DoWork()"); + } + + [Fact] + public async Task GetClassDiagram_HiddenMembers_ShouldStillShowRelationships() + { + // Arrange + var tools = CreateTools(); + + // Act + // Hide both properties and functions, but enable inheritance and dependencies + var result = await tools.GetClassDiagramAsync( + _tempFileWithDependencies, + true, + true, + false, + false); + + // Assert + result.Should().Contain("class TestNamespace_Customer"); + result.Should().Contain("class TestNamespace_Address"); + result.Should().Contain("-->", "Relationships should still be present"); + + // Members should be hidden + result.Should().NotContain("int Id"); + result.Should().NotContain("string Name"); + } + + [Fact] + public async Task GetClassDiagram_NoOptionalParams_ShouldShowAllMembers() + { + // Arrange + var tools = CreateTools(); + + // Act + // Call with only the mandatory filePath + var result = await tools.GetClassDiagramAsync(_tempFile); + + // Assert + result.Should().Contain("class TestNamespace_Person"); + result.Should().Contain("int Id"); + result.Should().Contain("string Name"); + } + + [Fact] + public async Task GetClassDiagram_ComplexClass_ShouldSignificantlyReduceCharacterCountWhenMembersHidden() + { + // Arrange + var tools = CreateTools(); + var complexFile = Path.Combine(_temp.DirectoryPath, "Complex.cs"); + const string code = """ + namespace Test; + public class Complex + { + public int P1 { get; set; } + public int P2 { get; set; } + public int P3 { get; set; } + public int P4 { get; set; } + public int P5 { get; set; } + public int P6 { get; set; } + public int P7 { get; set; } + public int P8 { get; set; } + public int P9 { get; set; } + public int P10 { get; set; } + public void M1() {} + public void M2() {} + public void M3() {} + public void M4() {} + public void M5() {} + } + """; +#pragma warning disable CA1849, S6966 + File.WriteAllText(complexFile, code); +#pragma warning restore CA1849, S6966 + + // Act + var resultWithMembers = + await tools.GetClassDiagramAsync(complexFile, includeProperties: true, includeFunctions: true); + var resultWithoutMembers = + await tools.GetClassDiagramAsync(complexFile, includeProperties: false, includeFunctions: false); + + // Assert + var withCount = resultWithMembers.Length; + var withoutCount = resultWithoutMembers.Length; + + // With 15 members, reduction should be > 50% + var reduction = (double)(withCount - withoutCount) / withCount; + reduction.Should().BeGreaterThanOrEqualTo(0.5, + $"Hiding members should reduce diagram size significantly. Reduced by {reduction:P}"); + } + private static string GetProjectPath(string relativePath) { var parts = relativePath.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); - var pathParts = new[] { Directory.GetCurrentDirectory(), "..", "..", "..", "..", ".." } + var pathParts = new[] + { + Directory.GetCurrentDirectory(), "..", "..", "..", "..", ".." + } .Concat(parts) .ToArray(); var path = Path.Combine(pathParts); diff --git a/tests/ProjGraph.Tests.Unit.ClassDiagram/AnalyzeFileUseCaseTests.cs b/tests/ProjGraph.Tests.Unit.ClassDiagram/AnalyzeFileUseCaseTests.cs index d8696a1..7cb3cdb 100644 --- a/tests/ProjGraph.Tests.Unit.ClassDiagram/AnalyzeFileUseCaseTests.cs +++ b/tests/ProjGraph.Tests.Unit.ClassDiagram/AnalyzeFileUseCaseTests.cs @@ -79,13 +79,17 @@ public class Svc { } Arg.Any()) .ReturnsForAnyArgs(Task.CompletedTask); - await _sut.ExecuteAsync(filePath, false, true, 3); + await _sut.ExecuteAsync(filePath, false, true, true, false, 3); await _typeProcessor.Received(1).ProcessTypeQueueAsync( Arg.Any>(), Arg.Any(), Arg.Is(o => - o.MaxDepth == 3 && !o.IncludeInheritance && o.IncludeDependencies)); + o.MaxDepth == 3 && + !o.IncludeInheritance && + o.IncludeDependencies && + o.IncludeProperties && + !o.IncludeFunctions)); } [Fact] diff --git a/tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs b/tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs index 2d3b40d..5b901b3 100644 --- a/tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs +++ b/tests/ProjGraph.Tests.Unit.ClassDiagram/TypeAnalyzerTests.cs @@ -192,4 +192,77 @@ public struct Point result.Kind.Should().Be(ModelTypeKind.Struct); } + + [Fact] + public void AnalyzeType_IncludePropertiesFalse_ShouldExcludePropertiesAndFields() + { + const string code = """ + namespace Test; + public class Foo + { + public string Name { get; set; } + public int Count; + public void DoWork() { } + } + """; + var (compilation, _) = RoslynTestHelper.CreateCompilationWithModel(code); + var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Foo")!; + + var result = TypeAnalyzer.AnalyzeType(symbol, false); + + result.Members.Should().NotContain(m => m.Kind == MemberKind.Property); + result.Members.Should().NotContain(m => m.Kind == MemberKind.Field); + result.Members.Should().Contain(m => m.Name == "DoWork" && m.Kind == MemberKind.Method); + } + + [Fact] + public void AnalyzeType_IncludeFunctionsFalse_ShouldExcludeMethods() + { + const string code = """ + namespace Test; + public class Foo + { + public string Name { get; set; } + public void DoWork() { } + } + """; + var (compilation, _) = RoslynTestHelper.CreateCompilationWithModel(code); + var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Foo")!; + + var result = TypeAnalyzer.AnalyzeType(symbol, includeFunctions: false); + + result.Members.Should().NotContain(m => m.Kind == MemberKind.Method); + result.Members.Should().Contain(m => m.Name == "Name" && m.Kind == MemberKind.Property); + } + + [Fact] + public void AnalyzeType_Enum_IncludePropertiesFalse_ShouldStillIncludeEnumMembers() + { + const string code = """ + namespace Test; + public enum Color { Red, Green, Blue } + """; + var (compilation, _) = RoslynTestHelper.CreateCompilationWithModel(code); + var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Color")!; + + var result = TypeAnalyzer.AnalyzeType(symbol, false); + + result.Kind.Should().Be(ModelTypeKind.Enum); + result.Members.Should().Contain(m => m.Name == "Red" && m.Kind == MemberKind.Field); + } + + [Fact] + public void AnalyzeType_EmptyClass_ShouldReturnNoMembers() + { + const string code = """ + namespace Test; + public class Empty { } + """; + var (compilation, _) = RoslynTestHelper.CreateCompilationWithModel(code); + var symbol = RoslynTestHelper.GetTypeSymbol(compilation, "Empty")!; + + var result = TypeAnalyzer.AnalyzeType(symbol); + + result.Members.Should().BeEmpty(); + } } From b2f0bf31f486fa1aaa3bb5bc6c0c24abeca5dcf1 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 16 Feb 2026 16:00:27 +0100 Subject: [PATCH 2/5] feat: add package icon to ProjGraph CLI and MCP projects --- .github/workflows/publish.yml | 95 +++++++++++++++----------- Directory.Build.props | 4 -- README.md | 7 ++ src/ProjGraph.Cli/ProjGraph.Cli.csproj | 6 +- src/ProjGraph.Mcp/ProjGraph.Mcp.csproj | 2 + 5 files changed, 67 insertions(+), 47 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 582015f..6f41deb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,43 +13,58 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - cache: true - cache-dependency-path: Directory.Packages.props - - - name: Restore dependencies - run: dotnet restore ProjGraph.slnx - - - name: Build - run: | - VERSION=${GITHUB_REF_NAME#v} - dotnet build ProjGraph.slnx --no-restore --configuration Release -p:Version=$VERSION - - - name: Test - run: dotnet test ProjGraph.slnx --no-build --configuration Release --verbosity normal - - - name: Pack - run: | - VERSION=${GITHUB_REF_NAME#v} - dotnet pack ProjGraph.slnx --configuration Release --output ./artifacts -p:Version=$VERSION - - - name: Publish to NuGet.org - run: dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate - - - name: Publish to GitHub Packages - run: | - dotnet nuget push ./artifacts/*.nupkg --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: artifacts/* - generate_release_notes: true - draft: false - prerelease: false + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from tag + id: get_version + run: | + VERSION=${GITHUB_REF_NAME#v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Update Directory.Build.props version + run: | + sed -i "s/.*<\/Version>/${{ steps.get_version.outputs.version }}<\/Version>/" Directory.Build.props + echo "Updated Directory.Build.props:" + grep "" Directory.Build.props + + - name: Update server.json version + run: | + sed -i "s/\"version\": \"[0-9]\+\.[0-9]\+\.[0-9]\+\"/\"version\": \"${{ steps.get_version.outputs.version }}\"/g" src/ProjGraph.Mcp/.mcp/server.json + echo "Updated server.json:" + grep "\"version\"" src/ProjGraph.Mcp/.mcp/server.json + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + cache: true + cache-dependency-path: Directory.Packages.props + + - name: Restore dependencies + run: dotnet restore ProjGraph.slnx + + - name: Build + run: dotnet build ProjGraph.slnx --no-restore --configuration Release + + - name: Test + run: dotnet test ProjGraph.slnx --no-build --configuration Release --verbosity normal + + - name: Pack + run: dotnet pack ProjGraph.slnx --configuration Release --output ./artifacts + + - name: Publish to NuGet.org + run: dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate + + - name: Publish to GitHub Packages + run: | + dotnet nuget push ./artifacts/*.nupkg --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/* + generate_release_notes: true + draft: false + prerelease: false diff --git a/Directory.Build.props b/Directory.Build.props index 032c3ee..eaa6679 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -19,7 +19,6 @@ 0.4.0 HandyS11 HandyS11 - icon.png https://github.com/HandyS11/ProjGraph https://github.com/HandyS11/ProjGraph.git git @@ -32,9 +31,6 @@ snupkg - - - diff --git a/README.md b/README.md index 713e9c2..2341813 100644 --- a/README.md +++ b/README.md @@ -132,3 +132,10 @@ AI: [Generates the database schema] - **CLI Package**: [ProjGraph.Cli on NuGet](https://www.nuget.org/packages/ProjGraph.Cli) - **MCP Package**: [ProjGraph.Mcp on NuGet](https://www.nuget.org/packages/ProjGraph.Mcp) + +## 📚 Documentation + +- [Architecture Overview](./ARCHITECTURE.md) - Solution structure and design decisions +- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to the project +- [Security Policy](./SECURITY.md) - Security reporting guidelines + diff --git a/src/ProjGraph.Cli/ProjGraph.Cli.csproj b/src/ProjGraph.Cli/ProjGraph.Cli.csproj index a8be86b..8a6c0eb 100644 --- a/src/ProjGraph.Cli/ProjGraph.Cli.csproj +++ b/src/ProjGraph.Cli/ProjGraph.Cli.csproj @@ -2,8 +2,6 @@ Exe - enable - enable true @@ -13,6 +11,7 @@ ProjGraph.Cli ProjGraph CLI + icon.png CLI tool to visualize project dependencies and generate graphs. dotnet-tool;graph;dependencies;visualization;erd;classdiagram;mermaid;cli README.md @@ -23,9 +22,10 @@ - + + diff --git a/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj b/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj index 444992c..49be07b 100644 --- a/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj +++ b/src/ProjGraph.Mcp/ProjGraph.Mcp.csproj @@ -18,6 +18,7 @@ ProjGraph.Mcp ProjGraph MCP Server + icon.png MCP server for ProjGraph to interact with LLMs and provide project graph context. AI;MCP;server;stdio;graph;dependencies;visualization;erd;classdiagram;mermaid;dotnet README.md @@ -33,6 +34,7 @@ + From 6026710c1e56464464c8e8ea6d18932cbdc5d575 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 16 Feb 2026 16:23:04 +0100 Subject: [PATCH 3/5] feat: update command options to specify boolean values for properties and functions in class diagram --- .github/workflows/publish.yml | 2 +- .../Commands/ClassDiagramCommand.cs | 4 ++-- src/ProjGraph.Cli/README.md | 5 +++++ src/ProjGraph.Mcp/Program.cs | 18 +++++++++++------- src/ProjGraph.Mcp/README.md | 11 +++++++---- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6f41deb..5c06c97 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,7 +32,7 @@ jobs: - name: Update server.json version run: | - sed -i "s/\"version\": \"[0-9]\+\.[0-9]\+\.[0-9]\+\"/\"version\": \"${{ steps.get_version.outputs.version }}\"/g" src/ProjGraph.Mcp/.mcp/server.json + sed -i "s/\"version\": \"[^\"]\+\"/\"version\": \"${{ steps.get_version.outputs.version }}\"/g" src/ProjGraph.Mcp/.mcp/server.json echo "Updated server.json:" grep "\"version\"" src/ProjGraph.Mcp/.mcp/server.json diff --git a/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs b/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs index 6c41763..0cb5a58 100644 --- a/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs +++ b/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs @@ -57,7 +57,7 @@ internal sealed class Settings : CommandSettings /// /// Gets or sets a value indicating whether to include properties and fields in the diagram. /// - [CommandOption("--properties")] + [CommandOption("--properties ")] [Description("Show properties and fields in the diagram (default true)")] [DefaultValue(true)] public bool IncludeProperties { get; init; } = true; @@ -65,7 +65,7 @@ internal sealed class Settings : CommandSettings /// /// Gets or sets a value indicating whether to include functions/methods in the diagram. /// - [CommandOption("--functions")] + [CommandOption("--functions ")] [Description("Show functions and methods in the diagram (default true)")] [DefaultValue(true)] public bool IncludeFunctions { get; init; } = true; diff --git a/src/ProjGraph.Cli/README.md b/src/ProjGraph.Cli/README.md index 95795f1..6e34616 100644 --- a/src/ProjGraph.Cli/README.md +++ b/src/ProjGraph.Cli/README.md @@ -106,6 +106,9 @@ projgraph classdiagram ./Models/Admin.cs --depth 5 # Generate without title header projgraph classdiagram ./Models/Admin.cs --show-title false + +# Hide properties or functions +projgraph classdiagram ./Models/Admin.cs --properties false --functions false ``` **Settings**: @@ -113,6 +116,8 @@ projgraph classdiagram ./Models/Admin.cs --show-title false - ``: Path to the `.cs` file. - `-i|--inheritance`: Include base classes/interfaces. Default: `false`. - `-d|--dependencies`: Include dependent types. Default: `false`. +- `--properties`: Show properties and fields in the diagram. Default: `true`. +- `--functions`: Show functions and methods in the diagram. Default: `true`. - `--depth `: Max discovery depth. Default: `1`. - `--show-title `: Include diagram title. Default: `true`. diff --git a/src/ProjGraph.Mcp/Program.cs b/src/ProjGraph.Mcp/Program.cs index fbf1e68..a4ee7e1 100644 --- a/src/ProjGraph.Mcp/Program.cs +++ b/src/ProjGraph.Mcp/Program.cs @@ -26,7 +26,11 @@ public static async Task Main(string[] args) var builder = Host.CreateApplicationBuilder(args); builder.Services.AddMcpServer(options => - options.ServerInfo = new Implementation { Name = "ProjGraph", Version = version }) + options.ServerInfo = new Implementation + { + Name = "ProjGraph", + Version = version + }) .WithStdioServerTransport() .WithTools(); @@ -57,7 +61,7 @@ internal sealed class ProjGraphTools( "Generates a Mermaid class diagram for the types defined in a specific C# file, with options to discover inheritance and related types in the workspace.")] public async Task GetClassDiagramAsync( [Description("Absolute path to the .cs file to analyze.")] - string filePath, + string path, [Description("Whether to search the workspace for base classes and interfaces.")] bool includeInheritance = false, [Description("Whether to search for and include other classes used as properties or fields.")] @@ -73,17 +77,17 @@ public async Task GetClassDiagramAsync( CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + ArgumentException.ThrowIfNullOrWhiteSpace(path); - if (!File.Exists(filePath)) + if (!File.Exists(path)) { - throw new FileNotFoundException($"File not found: {filePath}", filePath); + throw new FileNotFoundException($"File not found: {path}", path); } - FilePathGuard.RequireCsFile(filePath, nameof(filePath)); + FilePathGuard.RequireCsFile(path); var model = await classService.AnalyzeFileAsync( - filePath, + path, includeInheritance, includeDependencies, includeProperties, diff --git a/src/ProjGraph.Mcp/README.md b/src/ProjGraph.Mcp/README.md index 50e15d9..52af4bf 100644 --- a/src/ProjGraph.Mcp/README.md +++ b/src/ProjGraph.Mcp/README.md @@ -35,7 +35,7 @@ Generates a Mermaid Entity Relationship Diagram from an EF Core `DbContext` or ` **Parameters:** - `path` (string): Absolute path to a `.cs` file containing a `DbContext` or `ModelSnapshot` -- `contextName` (string, optional): Specific DbContext or ModelSnapshot class name if multiple exist in the file +- `context_name` (string, optional): Specific DbContext or ModelSnapshot class name if multiple exist in the file - `show_title` (boolean, optional): Whether to include the title in the diagram (default: true) **Returns:** Mermaid ERD diagram code @@ -64,11 +64,14 @@ related types in the workspace. **Parameters:** -- `filePath` (string): Absolute path to the C# file to analyze -- `includeInheritance` (boolean, optional): Whether to search the workspace for base classes and interfaces (default: +- `path` (string): Absolute path to the C# file to analyze +- `include_inheritance` (boolean, optional): Whether to search the workspace for base classes and interfaces (default: false) -- `includeDependencies` (boolean, optional): Whether to search for and include other classes used as properties or +- `include_dependencies` (boolean, optional): Whether to search for and include other classes used as properties or fields (default: false) +- `include_properties` (boolean, optional): Whether to display properties and fields in the class diagram (default: + true) +- `include_functions` (boolean, optional): Whether to display functions/methods in the class diagram (default: true) - `depth` (number, optional): How many levels of relationships to follow (default: 1) - `show_title` (boolean, optional): Whether to include the title in the diagram (default: true) From eefc526781fd0a3e13cdc789eb5bc3c63e9aed35 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 16 Feb 2026 16:27:58 +0100 Subject: [PATCH 4/5] feat: rename filePath parameter to path in McpClassDiagramTests --- tests/ProjGraph.Tests.Contract/McpClassDiagramTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ProjGraph.Tests.Contract/McpClassDiagramTests.cs b/tests/ProjGraph.Tests.Contract/McpClassDiagramTests.cs index c78f7c5..2338fb9 100644 --- a/tests/ProjGraph.Tests.Contract/McpClassDiagramTests.cs +++ b/tests/ProjGraph.Tests.Contract/McpClassDiagramTests.cs @@ -39,7 +39,7 @@ public void GetClassDiagram_ShouldHave_RequiredParameters() var parameters = method!.GetParameters(); // Check filePath parameter - var pathParam = parameters.Should().ContainSingle(p => p.Name == "filePath").Which; + var pathParam = parameters.Should().ContainSingle(p => p.Name == "path").Which; pathParam.ParameterType.Should().Be(); pathParam.GetCustomAttribute().Should().NotBeNull(); From e02b777373aea7d221a1c68e2b5e26fd351cd24c Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 16 Feb 2026 16:40:55 +0100 Subject: [PATCH 5/5] feat: update CommandArgument attribute to make path parameter optional --- src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs | 2 +- src/ProjGraph.Cli/Commands/VisualizeCommand.cs | 6 ++++-- src/ProjGraph.Cli/README.md | 8 ++++---- .../Application/ClassAnalysisService.cs | 2 +- .../Application/IClassAnalysisService.cs | 2 +- .../ClassAnalysisDepthTests.cs | 6 +++--- .../ClassAnalysisServiceTests.cs | 6 +++--- 7 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs b/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs index 0cb5a58..4b20777 100644 --- a/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs +++ b/src/ProjGraph.Cli/Commands/ClassDiagramCommand.cs @@ -32,7 +32,7 @@ internal sealed class Settings : CommandSettings /// Gets or sets the path to the .cs file to analyze. /// /// The file path as a string. - [CommandArgument(0, "")] + [CommandArgument(0, "[path]")] [Description("Path to the .cs file to analyze.")] public string Path { get; init; } = string.Empty; diff --git a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs index 6c37edb..d825f17 100644 --- a/src/ProjGraph.Cli/Commands/VisualizeCommand.cs +++ b/src/ProjGraph.Cli/Commands/VisualizeCommand.cs @@ -43,7 +43,7 @@ internal sealed class Settings : CommandSettings /// /// Gets or sets the path to the .sln, .slnx, or .csproj file to be analyzed. /// - [CommandArgument(0, "")] + [CommandArgument(0, "[path]")] [Description("The path to the .sln, .slnx, or .csproj file")] public string Path { get; init; } = string.Empty; @@ -88,7 +88,9 @@ public override ValidationResult Validate() return ValidationResult.Error($"File not found: {Path}"); } - if (NormalizedFormat != FormatFlat && NormalizedFormat != FormatTree && NormalizedFormat != FormatMermaid) + if (NormalizedFormat is not FormatFlat && + NormalizedFormat is not FormatTree && + NormalizedFormat is not FormatMermaid) { return ValidationResult.Error("Format must be 'flat', 'tree' or 'mermaid'"); } diff --git a/src/ProjGraph.Cli/README.md b/src/ProjGraph.Cli/README.md index 6e34616..0da2652 100644 --- a/src/ProjGraph.Cli/README.md +++ b/src/ProjGraph.Cli/README.md @@ -28,7 +28,7 @@ projgraph visualize ./MySolution.slnx --format mermaid --show-title false **Settings**: -- ``: Path to `.sln`, `.slnx`, or `.csproj` file. +- `[path]`: Path to `.sln`, `.slnx`, or `.csproj` file. - `-f|--format`: Output format (`flat`, `tree`, `mermaid`). Default: `mermaid`. - `--show-title `: Include diagram title. Default: `true`. @@ -113,11 +113,11 @@ projgraph classdiagram ./Models/Admin.cs --properties false --functions false **Settings**: -- ``: Path to the `.cs` file. +- `[path]`: Path to the `.cs` file. - `-i|--inheritance`: Include base classes/interfaces. Default: `false`. - `-d|--dependencies`: Include dependent types. Default: `false`. -- `--properties`: Show properties and fields in the diagram. Default: `true`. -- `--functions`: Show functions and methods in the diagram. Default: `true`. +- `--properties `: Show properties and fields in the diagram. Default: `true`. +- `--functions `: Show functions and methods in the diagram. Default: `true`. - `--depth `: Max discovery depth. Default: `1`. - `--show-title `: Include diagram title. Default: `true`. diff --git a/src/ProjGraph.Lib.ClassDiagram/Application/ClassAnalysisService.cs b/src/ProjGraph.Lib.ClassDiagram/Application/ClassAnalysisService.cs index 184b2c0..55e871d 100644 --- a/src/ProjGraph.Lib.ClassDiagram/Application/ClassAnalysisService.cs +++ b/src/ProjGraph.Lib.ClassDiagram/Application/ClassAnalysisService.cs @@ -13,7 +13,7 @@ public class ClassAnalysisService(AnalyzeFileUseCase analyzeFileUseCase) : IClas /// public async Task AnalyzeFileAsync( string filePath, - bool includeInheritance = true, + bool includeInheritance = false, bool includeDependencies = false, bool includeProperties = true, bool includeFunctions = true, diff --git a/src/ProjGraph.Lib.ClassDiagram/Application/IClassAnalysisService.cs b/src/ProjGraph.Lib.ClassDiagram/Application/IClassAnalysisService.cs index bf607b4..16f78d8 100644 --- a/src/ProjGraph.Lib.ClassDiagram/Application/IClassAnalysisService.cs +++ b/src/ProjGraph.Lib.ClassDiagram/Application/IClassAnalysisService.cs @@ -19,7 +19,7 @@ public interface IClassAnalysisService /// A ClassModel representing the discovered types and relationships. Task AnalyzeFileAsync( string filePath, - bool includeInheritance = true, + bool includeInheritance = false, bool includeDependencies = false, bool includeProperties = true, bool includeFunctions = true, diff --git a/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassAnalysisDepthTests.cs b/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassAnalysisDepthTests.cs index 1608919..4c8c0ee 100644 --- a/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassAnalysisDepthTests.cs +++ b/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassAnalysisDepthTests.cs @@ -49,7 +49,7 @@ public async Task AnalyzeFileAsync_WithDepthLimit_DoesNotExceedDepth() await File.WriteAllTextAsync(fileC, "public class C {}"); // Depth 1: Should find A and B, but not C - var result = await _service.AnalyzeFileAsync(fileA); + var result = await _service.AnalyzeFileAsync(fileA, true); result.Types.Should().Contain(t => t.Name == "A"); result.Types.Should().Contain(t => t.Name == "B"); @@ -65,7 +65,7 @@ public async Task AnalyzeFileAsync_DepthZero_ReturnsOnlyRootType() await File.WriteAllTextAsync(fileA, "public class A : B {}"); await File.WriteAllTextAsync(fileB, "public class B {}"); - var result = await _service.AnalyzeFileAsync(fileA, maxDepth: 0); + var result = await _service.AnalyzeFileAsync(fileA, true, maxDepth: 0); result.Types.Should().Contain(t => t.Name == "A"); result.Types.Should().NotContain(t => t.Name == "B"); @@ -82,7 +82,7 @@ public async Task AnalyzeFileAsync_DepthTwo_TraversesFullChain() await File.WriteAllTextAsync(fileB, "public class B : C {}"); await File.WriteAllTextAsync(fileC, "public class C {}"); - var result = await _service.AnalyzeFileAsync(fileA, maxDepth: 2); + var result = await _service.AnalyzeFileAsync(fileA, true, maxDepth: 2); result.Types.Should().Contain(t => t.Name == "A"); result.Types.Should().Contain(t => t.Name == "B"); diff --git a/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassAnalysisServiceTests.cs b/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassAnalysisServiceTests.cs index e2146f2..cb54508 100644 --- a/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassAnalysisServiceTests.cs +++ b/tests/ProjGraph.Tests.Unit.ClassDiagram/ClassAnalysisServiceTests.cs @@ -87,7 +87,7 @@ public class Derived : Base {} """; await File.WriteAllTextAsync(_tempFile, code); - var result = await _service.AnalyzeFileAsync(_tempFile); + var result = await _service.AnalyzeFileAsync(_tempFile, true); result.Types.Should().HaveCount(2); result.Relationships.Should().HaveCount(1); @@ -126,7 +126,7 @@ public class Derived : Base {} """; await File.WriteAllTextAsync(_tempFile, code); - var result = await _service.AnalyzeFileAsync(_tempFile); + var result = await _service.AnalyzeFileAsync(_tempFile, true); result.Types.Should().HaveCount(2); result.Relationships.Should().HaveCount(1); @@ -154,7 +154,7 @@ public class User : BaseEntity { public string Name { get; set; } } """); await File.WriteAllTextAsync(Path.Combine(root, "Test.csproj"), ""); - var result = await _service.AnalyzeFileAsync(userFile); + var result = await _service.AnalyzeFileAsync(userFile, true); result.Types.Count.Should().BeGreaterThanOrEqualTo(2); result.Relationships.Should().HaveCount(1);